Merge pull request #335 from Monadical-SAS/sara/UI-improvements

Sara/UI improvements & fix transcript deletion
This commit is contained in:
2024-05-28 12:28:50 +02:00
committed by GitHub
12 changed files with 431 additions and 102 deletions

View File

@@ -1,88 +1,288 @@
"use client";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { GetTranscript } from "../../api";
import { Title } from "../../lib/textComponents";
import Pagination from "./pagination";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGear } from "@fortawesome/free-solid-svg-icons";
import NextLink from "next/link";
import { FaGear } from "react-icons/fa6";
import { FaCheck, FaTrash, FaStar, FaMicrophone } from "react-icons/fa";
import { MdError } from "react-icons/md";
import useTranscriptList from "../transcripts/useTranscriptList";
import { formatTime } from "../../lib/time";
import useApi from "../../lib/useApi";
import { useError } from "../../(errors)/errorContext";
import { FaEllipsisVertical } from "react-icons/fa6";
import {
Flex,
Spinner,
Heading,
Button,
Card,
Link,
CardBody,
CardFooter,
Stack,
Text,
Icon,
Grid,
Divider,
Popover,
PopoverTrigger,
PopoverContent,
PopoverArrow,
PopoverCloseButton,
PopoverHeader,
PopoverBody,
PopoverFooter,
IconButton,
Spacer,
Menu,
MenuButton,
MenuItem,
MenuList,
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogBody,
AlertDialogFooter,
keyframes,
Tooltip,
} from "@chakra-ui/react";
import { PlusSquareIcon } from "@chakra-ui/icons";
import { ExpandableText } from "../../lib/expandableText";
// import { useFiefUserinfo } from "@fief/fief/nextjs/react";
export default function TranscriptBrowser() {
const [page, setPage] = useState<number>(1);
const { loading, response } = useTranscriptList(page);
const { loading, response, refetch } = useTranscriptList(page);
const [deletionLoading, setDeletionLoading] = useState(false);
const api = useApi();
const { setError } = useError();
const cancelRef = React.useRef(null);
const [transcriptToDeleteId, setTranscriptToDeleteId] =
React.useState<string>();
const [deletedItemIds, setDeletedItemIds] = React.useState<string[]>();
// Todo: fief add name field to userinfo
// const user = useFiefUserinfo();
// console.log(user);
useEffect(() => {
setDeletedItemIds([]);
}, [page, response]);
if (loading && !response)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Spinner size="xl" />
</Flex>
);
if (!loading && !response)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Text>
No transcripts found, but you can&nbsp;
<Link href="/transcripts/new" className="underline">
record a meeting
</Link>
&nbsp;to get started.
</Text>
</Flex>
);
const onCloseDeletion = () => setTranscriptToDeleteId(undefined);
const handleDeleteTranscript = (transcriptToDeleteId) => (e) => {
e.stopPropagation();
if (api && !deletionLoading) {
setDeletionLoading(true);
api
.v1TranscriptDelete(transcriptToDeleteId)
.then(() => {
setDeletionLoading(false);
refetch();
onCloseDeletion();
setDeletedItemIds((deletedItemIds) => [
deletedItemIds,
...transcriptToDeleteId,
]);
})
.catch((err) => {
setDeletionLoading(false);
setError(err, "There was an error deleting the transcript");
});
}
};
return (
<div className="grid grid-rows-layout-topbar gap-2 lg:gap-4 h-full max-h-full">
<div className="flex flex-row gap-2 items-center">
<Title className="mb-5 mt-5 flex-1">Past transcripts</Title>
<Flex
maxW="container.xl"
flexDir="column"
margin="auto"
gap={2}
overflowY="scroll"
maxH="100%"
>
<Flex
flexDir="row"
justify="flex-end"
align="center"
flexWrap={"wrap-reverse"}
>
{/* <Heading>{user?.fields?.name}'s Meetings</Heading> */}
<Heading>Your Meetings</Heading>
{loading || (deletionLoading && <Spinner></Spinner>)}
<Spacer />
<Pagination
page={page}
setPage={setPage}
total={response?.total || 0}
size={response?.size || 0}
/>
</div>
{loading && (
<div className="full-screen flex flex-col items-center justify-center">
<FontAwesomeIcon
icon={faGear}
className="animate-spin-slow h-14 w-14 md:h-20 md:w-20"
/>
</div>
)}
{!loading && !response && (
<div className="text-gray-500">
No transcripts found, but you can&nbsp;
<Link href="/transcripts/new" className="underline">
record a meeting
</Link>
&nbsp;to get started.
</div>
)}
<div /** center and max 900px wide */ className="overflow-y-scroll">
<div className="grid grid-cols-1 gap-2 lg:gap-4 h-full mx-auto max-w-[900px]">
{response?.items.map((item: GetTranscript) => (
<div
key={item.id}
className="flex flex-col bg-blue-400/20 rounded-lg md:rounded-xl p-2 md:px-4"
>
<div className="flex flex-col">
<div className="flex flex-row gap-2 items-start">
<Link
href={`/transcripts/${item.id}`}
className="text-1xl flex-1 pl-0 hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
>
{item.title || item.name}
</Link>
<Button colorScheme="blue" rightIcon={<PlusSquareIcon />}>
New Meeting
</Button>
</Flex>
{item.locked ? (
<div className="inline-block bg-red-500 text-white px-2 py-1 rounded-full text-xs font-semibold">
Locked
</div>
) : (
<></>
<Grid
templateColumns={{
base: "repeat(1, 1fr)",
md: "repeat(2, 1fr)",
lg: "repeat(3, 1fr)",
}}
gap={{
base: 2,
lg: 4,
}}
maxH="100%"
overflowY={"scroll"}
mb="4"
>
{response?.items
.filter((i) => !deletedItemIds?.includes(i.id))
.map((item: GetTranscript) => (
<Card key={item.id} border="gray.light" variant="outline">
<CardBody>
<Flex align={"center"} ml="-6px">
{item.status == "ended" && (
<Tooltip label="Processing done">
<span>
<Icon color="green" as={FaCheck} mr="2" />
</span>
</Tooltip>
)}
{item.source_language ? (
<div className="inline-block bg-blue-500 text-white px-2 py-1 rounded-full text-xs font-semibold">
{item.source_language}
</div>
) : (
<></>
{item.status == "error" && (
<Tooltip label="Processing error">
<span>
<Icon color="red.primary" as={MdError} mr="2" />
</span>
</Tooltip>
)}
</div>
<div className="text-xs text-gray-700">
{new Date(item.created_at).toLocaleDateString("en-US")}
</div>
<div className="text-sm">{item.short_summary}</div>
</div>
</div>
{item.status == "idle" && (
<Tooltip label="New meeting, no recording">
<span>
<Icon color="yellow.500" as={FaStar} mr="2" />
</span>
</Tooltip>
)}
{item.status == "processing" && (
<Tooltip label="Processing in progress">
<span>
<Icon
color="grey.primary"
as={FaGear}
mr="2"
transition={"all 2s ease"}
transform={"rotate(0deg)"}
_hover={{ transform: "rotate(360deg)" }}
/>
</span>
</Tooltip>
)}
{item.status == "recording" && (
<Tooltip label="Recording in progress">
<span>
<Icon color="blue.primary" as={FaMicrophone} mr="2" />
</span>
</Tooltip>
)}
<Heading size="md">
<Link
as={NextLink}
href={`/transcripts/${item.id}`}
noOfLines={2}
>
{item.title || item.name || "Unamed Transcript"}
</Link>
</Heading>
<Spacer />
<Menu closeOnSelect={false}>
<MenuButton
as={IconButton}
icon={<FaEllipsisVertical />}
aria-label="actions"
/>
<MenuList>
<MenuItem
disabled={deletionLoading}
onClick={() => setTranscriptToDeleteId(item.id)}
icon={<FaTrash color={"red.500"} />}
>
Delete
</MenuItem>
<AlertDialog
isOpen={transcriptToDeleteId === item.id}
leastDestructiveRef={cancelRef}
onClose={onCloseDeletion}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete{" "}
{item.title || item.name || "Unamed Transcript"}
</AlertDialogHeader>
<AlertDialogBody>
Are you sure? You can't undo this action
afterwards.
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onCloseDeletion}>
Cancel
</Button>
<Button
colorScheme="red"
onClick={handleDeleteTranscript(item.id)}
ml={3}
>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</MenuList>
</Menu>
</Flex>
<Stack mt="6" spacing="3">
<Text fontSize="small">
{new Date(item.created_at).toLocaleString("en-US")}
{"\u00A0"}-{"\u00A0"}
{formatTime(Math.floor(item.duration / 1000))}
</Text>
<ExpandableText noOfLines={5}>
{item.short_summary}
</ExpandableText>
</Stack>
</CardBody>
</Card>
))}
</div>
</div>
</div>
</Grid>
</Flex>
);
}

View File

@@ -1,3 +1,6 @@
import { Button, Flex, IconButton } from "@chakra-ui/react";
import { FaChevronLeft, FaChevronRight } from "react-icons/fa";
type PaginationProps = {
page: number;
setPage: (page: number) => void;
@@ -36,40 +39,41 @@ export default function Pagination(props: PaginationProps) {
setPage(newPage);
}
};
return (
<div className="flex justify-center space-x-4 my-4">
<button
className={`w-10 h-10 rounded-full p-2 border border-gray-300 disabled:bg-white ${
canGoPrevious ? "text-gray-500" : "text-gray-300"
}`}
<Flex justify="center" align="center" gap="2" mx="2">
<IconButton
isRound={true}
variant="text"
color={!canGoPrevious ? "gray" : "dark"}
mb="1"
icon={<FaChevronLeft />}
onClick={() => handlePageChange(page - 1)}
disabled={!canGoPrevious}
>
<i className="fa fa-chevron-left">&lt;</i>
</button>
aria-label="Previous page"
/>
{pageNumbers.map((pageNumber) => (
<button
<Button
key={pageNumber}
className={`w-10 h-10 rounded-full p-2 border ${
page === pageNumber ? "border-gray-600" : "border-gray-300"
} rounded`}
variant="text"
color={page === pageNumber ? "gray" : "dark"}
onClick={() => handlePageChange(pageNumber)}
disabled={page === pageNumber}
>
{pageNumber}
</button>
</Button>
))}
<button
className={`w-10 h-10 rounded-full p-2 border border-gray-300 disabled:bg-white ${
canGoNext ? "text-gray-500" : "text-gray-300"
}`}
<IconButton
isRound={true}
variant="text"
color={!canGoNext ? "gray" : "dark"}
icon={<FaChevronRight />}
mb="1"
onClick={() => handlePageChange(page + 1)}
disabled={!canGoNext}
>
<i className="fa fa-chevron-right">&gt;</i>
</button>
</div>
aria-label="Next page"
/>
</Flex>
);
}

View File

@@ -6,7 +6,6 @@ import UserInfo from "../(auth)/userInfo";
import { ErrorProvider } from "../(errors)/errorContext";
import ErrorMessage from "../(errors)/errorMessage";
import Image from "next/image";
import Link from "next/link";
import About from "../(aboutAndPrivacy)/about";
import Privacy from "../(aboutAndPrivacy)/privacy";
import { DomainContextProvider } from "./domainContext";
@@ -15,6 +14,8 @@ import { ErrorBoundary } from "@sentry/nextjs";
import { cookies } from "next/dist/client/components/headers";
import { SESSION_COOKIE_NAME } from "../lib/fief";
import { Providers } from "../providers";
import NextLink from "next/link";
import { Container, Flex, Link } from "@chakra-ui/react";
const poppins = Poppins({ subsets: ["latin"], weight: ["200", "400", "600"] });
@@ -91,13 +92,25 @@ export default async function RootLayout({ children, params }: LayoutProps) {
<ErrorProvider>
<ErrorMessage />
<Providers>
<div
id="container"
className="items-center h-[100svh] w-[100svw] p-2 md:p-4 grid grid-rows-layout-topbar gap-2 md:gap-4"
<Container
minW="100vw"
maxH="100vh"
minH="100vh"
maxW="container.xl"
display="grid"
gridTemplateRows="auto minmax(0,1fr)"
>
<header className="flex justify-between items-center w-full">
<Flex
as="header"
justify="space-between"
alignItems="center"
w="100%"
py="2"
px="0"
>
{/* Logo on the left */}
<Link
as={NextLink}
href="/"
className="flex outline-blue-300 md:outline-none focus-visible:underline underline-offset-2 decoration-[.5px] decoration-gray-500"
>
@@ -120,6 +133,7 @@ export default async function RootLayout({ children, params }: LayoutProps) {
<div>
{/* Text link on the right */}
<Link
as={NextLink}
href="/transcripts/new"
className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
>
@@ -130,6 +144,7 @@ export default async function RootLayout({ children, params }: LayoutProps) {
&nbsp;·&nbsp;
<Link
href="/browse"
as={NextLink}
className="hover:underline focus-within:underline underline-offset-2 decoration-[.5px] font-light px-2"
prefetch={false}
>
@@ -158,10 +173,10 @@ export default async function RootLayout({ children, params }: LayoutProps) {
<></>
)}
</div>
</header>
</Flex>
{children}
</div>
</Container>
</Providers>
</ErrorProvider>
</ErrorBoundary>

View File

@@ -1,16 +1,17 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useError } from "../../(errors)/errorContext";
import { GetTranscript, CreateTranscript } from "../../api";
import { CreateTranscript, GetTranscript } from "../../api";
import useApi from "../../lib/useApi";
type UseTranscript = {
type UseCreateTranscript = {
transcript: GetTranscript | null;
loading: boolean;
error: Error | null;
create: (transcriptCreationDetails: CreateTranscript) => void;
};
const useCreateTranscript = (): UseTranscript => {
const useCreateTranscript = (): UseCreateTranscript => {
const [transcript, setTranscript] = useState<GetTranscript | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setErrorState] = useState<Error | null>(null);

View File

@@ -7,6 +7,7 @@ type TranscriptList = {
response: Page_GetTranscript_ | null;
loading: boolean;
error: Error | null;
refetch: () => void;
};
//always protected
@@ -16,6 +17,11 @@ const useTranscriptList = (page: number): TranscriptList => {
const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError();
const api = useApi();
const [refetchCount, setRefetchCount] = useState(0);
const refetch = () => {
setRefetchCount(refetchCount + 1);
};
useEffect(() => {
if (!api) return;
@@ -32,9 +38,9 @@ const useTranscriptList = (page: number): TranscriptList => {
setError(err);
setErrorState(err);
});
}, [!api, page]);
}, [!api, page, refetchCount]);
return { response, loading, error };
return { response, loading, error, refetch };
};
export default useTranscriptList;

View File

@@ -0,0 +1,46 @@
import type { BoxProps } from "@chakra-ui/react";
import { Box, Button, Text } from "@chakra-ui/react";
import React, { forwardRef, useState } from "react";
interface Props extends BoxProps {
children: React.ReactNode;
noOfLines: number;
}
export const ExpandableText = forwardRef<HTMLDivElement, Props>(
({ children, noOfLines, ...rest }, ref) => {
const [expandedCount, setExpandedCount] = useState<number | undefined>(
noOfLines,
);
const [isClicked, setIsClicked] = useState(false);
const handleToggle = () => {
setIsClicked(true);
setExpandedCount(expandedCount ? undefined : noOfLines);
};
const inputRef = React.useRef<HTMLInputElement>(null);
const isTextClamped =
(inputRef.current?.scrollHeight as number) >
(inputRef.current?.clientHeight as number) || isClicked;
return (
<Box ref={ref} {...rest}>
<Box ref={inputRef} noOfLines={expandedCount}>
{children}
</Box>
<Button
display={isTextClamped ? "block" : "none"}
size="sm"
variant="link"
onClick={handleToggle}
mt={2}
>
<Text>{!expandedCount ? "Show less" : "Read more"}</Text>
</Button>
</Box>
);
},
);
ExpandableText.displayName = "ExpandableText";

View File

@@ -1,7 +1,8 @@
"use client";
import { ChakraProvider } from "@chakra-ui/react";
import theme from "./theme";
export function Providers({ children }: { children: React.ReactNode }) {
return <ChakraProvider>{children}</ChakraProvider>;
return <ChakraProvider theme={theme}>{children}</ChakraProvider>;
}

34
www/app/theme.ts Normal file
View File

@@ -0,0 +1,34 @@
// 1. Import `extendTheme`
import { extendTheme } from "@chakra-ui/react";
// 2. Call `extendTheme` and pass your custom values
const theme = extendTheme({
colors: {
blue: {
primary: "#3158E2",
500: "#3158E2",
light: "#B1CBFF",
200: "#B1CBFF",
dark: "#0E1B48",
900: "#0E1B48",
},
red: {
primary: "#DF7070",
500: "#DF7070",
light: "#FBD5D5",
200: "#FBD5D5",
},
gray: {
bg: "#F4F4F4",
100: "#F4F4F4",
light: "#D5D5D5",
200: "#D5D5D5",
primary: "#838383",
500: "#838383",
},
light: "#FFFFFF",
dark: "#0C0D0E",
},
});
export default theme;

View File

@@ -38,6 +38,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropdown": "^1.11.0",
"react-icons": "^5.0.1",
"react-markdown": "^9.0.0",
"react-qr-code": "^2.0.12",
"react-select-search": "^4.1.7",

View File

@@ -4674,6 +4674,11 @@ react-focus-lock@^2.9.4:
use-callback-ref "^1.3.0"
use-sidecar "^1.1.2"
react-icons@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.0.1.tgz#1694e11bfa2a2888cab47dcc30154ce90485feee"
integrity sha512-WqLZJ4bLzlhmsvme6iFdgO8gfZP17rfjYEJ2m9RsZjZ+cc4k1hTzknEz63YS1MeT50kVzoa1Nz36f4BEx+Wigw==
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"