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,4 +1,6 @@
import json import json
import os
import shutil
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -188,7 +190,8 @@ class Transcript(BaseModel):
return [participant.model_dump(mode=mode) for participant in self.participants] return [participant.model_dump(mode=mode) for participant in self.participants]
def unlink(self): def unlink(self):
self.data_path.unlink(missing_ok=True) if os.path.exists(self.data_path) and os.path.isdir(self.data_path):
shutil.rmtree(self.data_path)
@property @property
def data_path(self): def data_path(self):

View File

@@ -118,3 +118,16 @@ async def test_transcript_audio_download_range_with_seek(
assert response.status_code == 206 assert response.status_code == 206
assert response.headers["content-type"] == content_type assert response.headers["content-type"] == content_type
assert response.headers["content-range"].startswith("bytes 100-") assert response.headers["content-range"].startswith("bytes 100-")
@pytest.mark.asyncio
async def test_transcript_delete_with_audio(fake_transcript):
from reflector.app import app
ac = AsyncClient(app=app, base_url="http://test/v1")
response = await ac.delete(f"/transcripts/{fake_transcript.id}")
assert response.status_code == 200
assert response.json()["status"] == "ok"
response = await ac.get(f"/transcripts/{fake_transcript.id}")
assert response.status_code == 404

View File

@@ -1,88 +1,288 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { GetTranscript } from "../../api"; import { GetTranscript } from "../../api";
import { Title } from "../../lib/textComponents";
import Pagination from "./pagination"; import Pagination from "./pagination";
import Link from "next/link"; import NextLink from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FaGear } from "react-icons/fa6";
import { faGear } from "@fortawesome/free-solid-svg-icons"; import { FaCheck, FaTrash, FaStar, FaMicrophone } from "react-icons/fa";
import { MdError } from "react-icons/md";
import useTranscriptList from "../transcripts/useTranscriptList"; 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() { export default function TranscriptBrowser() {
const [page, setPage] = useState<number>(1); 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 ( return (
<div className="grid grid-rows-layout-topbar gap-2 lg:gap-4 h-full max-h-full"> <Flex
<div className="flex flex-row gap-2 items-center"> maxW="container.xl"
<Title className="mb-5 mt-5 flex-1">Past transcripts</Title> 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 <Pagination
page={page} page={page}
setPage={setPage} setPage={setPage}
total={response?.total || 0} total={response?.total || 0}
size={response?.size || 0} size={response?.size || 0}
/> />
</div>
{loading && ( <Button colorScheme="blue" rightIcon={<PlusSquareIcon />}>
<div className="full-screen flex flex-col items-center justify-center"> New Meeting
<FontAwesomeIcon </Button>
icon={faGear} </Flex>
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>
{item.locked ? ( <Grid
<div className="inline-block bg-red-500 text-white px-2 py-1 rounded-full text-xs font-semibold"> templateColumns={{
Locked base: "repeat(1, 1fr)",
</div> 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.status == "error" && (
{item.source_language ? ( <Tooltip label="Processing error">
<div className="inline-block bg-blue-500 text-white px-2 py-1 rounded-full text-xs font-semibold"> <span>
{item.source_language} <Icon color="red.primary" as={MdError} mr="2" />
</div> </span>
) : ( </Tooltip>
<></>
)} )}
</div> {item.status == "idle" && (
<div className="text-xs text-gray-700"> <Tooltip label="New meeting, no recording">
{new Date(item.created_at).toLocaleDateString("en-US")} <span>
</div> <Icon color="yellow.500" as={FaStar} mr="2" />
<div className="text-sm">{item.short_summary}</div> </span>
</div> </Tooltip>
</div> )}
{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> </Grid>
</div> </Flex>
</div>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ type TranscriptList = {
response: Page_GetTranscript_ | null; response: Page_GetTranscript_ | null;
loading: boolean; loading: boolean;
error: Error | null; error: Error | null;
refetch: () => void;
}; };
//always protected //always protected
@@ -16,6 +17,11 @@ const useTranscriptList = (page: number): TranscriptList => {
const [error, setErrorState] = useState<Error | null>(null); const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError(); const { setError } = useError();
const api = useApi(); const api = useApi();
const [refetchCount, setRefetchCount] = useState(0);
const refetch = () => {
setRefetchCount(refetchCount + 1);
};
useEffect(() => { useEffect(() => {
if (!api) return; if (!api) return;
@@ -32,9 +38,9 @@ const useTranscriptList = (page: number): TranscriptList => {
setError(err); setError(err);
setErrorState(err); setErrorState(err);
}); });
}, [!api, page]); }, [!api, page, refetchCount]);
return { response, loading, error }; return { response, loading, error, refetch };
}; };
export default useTranscriptList; 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"; "use client";
import { ChakraProvider } from "@chakra-ui/react"; import { ChakraProvider } from "@chakra-ui/react";
import theme from "./theme";
export function Providers({ children }: { children: React.ReactNode }) { 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": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropdown": "^1.11.0", "react-dropdown": "^1.11.0",
"react-icons": "^5.0.1",
"react-markdown": "^9.0.0", "react-markdown": "^9.0.0",
"react-qr-code": "^2.0.12", "react-qr-code": "^2.0.12",
"react-select-search": "^4.1.7", "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-callback-ref "^1.3.0"
use-sidecar "^1.1.2" 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: react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"