diff --git a/server/reflector/db/transcripts.py b/server/reflector/db/transcripts.py index 78e34a1c..e25de7a1 100644 --- a/server/reflector/db/transcripts.py +++ b/server/reflector/db/transcripts.py @@ -1,4 +1,6 @@ import json +import os +import shutil from contextlib import asynccontextmanager from datetime import datetime from pathlib import Path @@ -188,7 +190,8 @@ class Transcript(BaseModel): return [participant.model_dump(mode=mode) for participant in self.participants] 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 def data_path(self): diff --git a/server/tests/test_transcripts_audio_download.py b/server/tests/test_transcripts_audio_download.py index 28f83fff..efedeb59 100644 --- a/server/tests/test_transcripts_audio_download.py +++ b/server/tests/test_transcripts_audio_download.py @@ -118,3 +118,16 @@ async def test_transcript_audio_download_range_with_seek( assert response.status_code == 206 assert response.headers["content-type"] == content_type 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 diff --git a/www/app/[domain]/browse/page.tsx b/www/app/[domain]/browse/page.tsx index 0a180143..211c927b 100644 --- a/www/app/[domain]/browse/page.tsx +++ b/www/app/[domain]/browse/page.tsx @@ -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(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(); + const [deletedItemIds, setDeletedItemIds] = React.useState(); + + // Todo: fief add name field to userinfo + // const user = useFiefUserinfo(); + // console.log(user); + + useEffect(() => { + setDeletedItemIds([]); + }, [page, response]); + + if (loading && !response) + return ( + + + + ); + + if (!loading && !response) + return ( + + + No transcripts found, but you can  + + record a meeting + +  to get started. + + + ); + 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 ( -
-
- Past transcripts + + + {/* {user?.fields?.name}'s Meetings */} + Your Meetings + {loading || (deletionLoading && )} + + -
- {loading && ( -
- -
- )} - {!loading && !response && ( -
- No transcripts found, but you can  - - record a meeting - -  to get started. -
- )} -
-
- {response?.items.map((item: GetTranscript) => ( -
-
-
- - {item.title || item.name} - + + - {item.locked ? ( -
- Locked -
- ) : ( - <> + + {response?.items + .filter((i) => !deletedItemIds?.includes(i.id)) + .map((item: GetTranscript) => ( + + + + {item.status == "ended" && ( + + + + + )} - - {item.source_language ? ( -
- {item.source_language} -
- ) : ( - <> + {item.status == "error" && ( + + + + + )} -
-
- {new Date(item.created_at).toLocaleDateString("en-US")} -
-
{item.short_summary}
-
-
+ {item.status == "idle" && ( + + + + + + )} + {item.status == "processing" && ( + + + + + + )} + {item.status == "recording" && ( + + + + + + )} + + + {item.title || item.name || "Unamed Transcript"} + + + + + + } + aria-label="actions" + /> + + setTranscriptToDeleteId(item.id)} + icon={} + > + Delete + + + + + + Delete{" "} + {item.title || item.name || "Unamed Transcript"} + + + + Are you sure? You can't undo this action + afterwards. + + + + + + + + + + + + + + + {new Date(item.created_at).toLocaleString("en-US")} + {"\u00A0"}-{"\u00A0"} + {formatTime(Math.floor(item.duration / 1000))} + + + {item.short_summary} + + + + ))} -
-
-
+ + ); } diff --git a/www/app/[domain]/browse/pagination.tsx b/www/app/[domain]/browse/pagination.tsx index e10d5321..7c67a60b 100644 --- a/www/app/[domain]/browse/pagination.tsx +++ b/www/app/[domain]/browse/pagination.tsx @@ -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 ( -
- + aria-label="Previous page" + /> {pageNumbers.map((pageNumber) => ( - + ))} - -
+ aria-label="Next page" + /> + ); } diff --git a/www/app/[domain]/layout.tsx b/www/app/[domain]/layout.tsx index cce65fc3..95d0f544 100644 --- a/www/app/[domain]/layout.tsx +++ b/www/app/[domain]/layout.tsx @@ -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) { -
-
+ {/* Logo on the left */} @@ -120,6 +133,7 @@ export default async function RootLayout({ children, params }: LayoutProps) {
{/* Text link on the right */} @@ -130,6 +144,7 @@ export default async function RootLayout({ children, params }: LayoutProps) {  ยท  @@ -158,10 +173,10 @@ export default async function RootLayout({ children, params }: LayoutProps) { <> )}
-
+ {children} -
+
diff --git a/www/app/[domain]/transcripts/createTranscript.ts b/www/app/[domain]/transcripts/createTranscript.ts index 8435e6c2..cf68498c 100644 --- a/www/app/[domain]/transcripts/createTranscript.ts +++ b/www/app/[domain]/transcripts/createTranscript.ts @@ -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(null); const [loading, setLoading] = useState(false); const [error, setErrorState] = useState(null); diff --git a/www/app/[domain]/transcripts/useTranscriptList.ts b/www/app/[domain]/transcripts/useTranscriptList.ts index 7621a9f8..2cfab374 100644 --- a/www/app/[domain]/transcripts/useTranscriptList.ts +++ b/www/app/[domain]/transcripts/useTranscriptList.ts @@ -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(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; diff --git a/www/app/lib/expandableText.tsx b/www/app/lib/expandableText.tsx new file mode 100644 index 00000000..1925d1f7 --- /dev/null +++ b/www/app/lib/expandableText.tsx @@ -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( + ({ children, noOfLines, ...rest }, ref) => { + const [expandedCount, setExpandedCount] = useState( + noOfLines, + ); + const [isClicked, setIsClicked] = useState(false); + const handleToggle = () => { + setIsClicked(true); + setExpandedCount(expandedCount ? undefined : noOfLines); + }; + + const inputRef = React.useRef(null); + + const isTextClamped = + (inputRef.current?.scrollHeight as number) > + (inputRef.current?.clientHeight as number) || isClicked; + + return ( + + + {children} + + + + ); + }, +); + +ExpandableText.displayName = "ExpandableText"; diff --git a/www/app/providers.tsx b/www/app/providers.tsx index 5197ff15..e1c4f283 100644 --- a/www/app/providers.tsx +++ b/www/app/providers.tsx @@ -1,7 +1,8 @@ "use client"; import { ChakraProvider } from "@chakra-ui/react"; +import theme from "./theme"; export function Providers({ children }: { children: React.ReactNode }) { - return {children}; + return {children}; } diff --git a/www/app/theme.ts b/www/app/theme.ts new file mode 100644 index 00000000..e951eef0 --- /dev/null +++ b/www/app/theme.ts @@ -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; diff --git a/www/package.json b/www/package.json index b0821b2f..8c86456e 100644 --- a/www/package.json +++ b/www/package.json @@ -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", diff --git a/www/yarn.lock b/www/yarn.lock index b2d55ac1..baac24ec 100644 --- a/www/yarn.lock +++ b/www/yarn.lock @@ -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"