diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index 51d59a1c..3e92c280 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -3,7 +3,7 @@ from typing import Annotated, Literal, Optional import reflector.auth as auth from fastapi import APIRouter, Depends, HTTPException -from fastapi_pagination import Page +from fastapi_pagination import Page, Params from fastapi_pagination.ext.databases import paginate from jose import jwt from pydantic import BaseModel, Field, field_serializer @@ -128,6 +128,7 @@ async def transcripts_list( order_by="-created_at", return_query=True, ), + params=Params(size=10), ) diff --git a/www/app/(app)/browse/_components/DeleteTranscriptDialog.tsx b/www/app/(app)/browse/_components/DeleteTranscriptDialog.tsx new file mode 100644 index 00000000..f18d8fef --- /dev/null +++ b/www/app/(app)/browse/_components/DeleteTranscriptDialog.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { + Button, + AlertDialog, + AlertDialogOverlay, + AlertDialogContent, + AlertDialogHeader, + AlertDialogBody, + AlertDialogFooter, +} from "@chakra-ui/react"; + +interface DeleteTranscriptDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + cancelRef: React.RefObject; +} + +export default function DeleteTranscriptDialog({ + isOpen, + onClose, + onConfirm, + cancelRef, +}: DeleteTranscriptDialogProps) { + return ( + + + + + Delete Transcript + + + Are you sure? You can't undo this action afterwards. + + + + + + + + + ); +} diff --git a/www/app/(app)/browse/_components/FilterSidebar.tsx b/www/app/(app)/browse/_components/FilterSidebar.tsx new file mode 100644 index 00000000..ec555770 --- /dev/null +++ b/www/app/(app)/browse/_components/FilterSidebar.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { Box, Stack, Link, Heading, Divider } from "@chakra-ui/react"; +import NextLink from "next/link"; +import { Room, SourceKind } from "../../../api"; + +interface FilterSidebarProps { + rooms: Room[]; + selectedSourceKind: SourceKind | null; + selectedRoomId: string; + onFilterChange: (sourceKind: SourceKind | null, roomId: string) => void; +} + +export default function FilterSidebar({ + rooms, + selectedSourceKind, + selectedRoomId, + onFilterChange, +}: FilterSidebarProps) { + const myRooms = rooms.filter((room) => !room.is_shared); + const sharedRooms = rooms.filter((room) => room.is_shared); + + return ( + + + onFilterChange(null, "")} + color={selectedSourceKind === null ? "blue.500" : "gray.600"} + _hover={{ color: "blue.300" }} + fontWeight={selectedSourceKind === null ? "bold" : "normal"} + > + All Transcripts + + + + + {myRooms.length > 0 && ( + <> + My Rooms + + {myRooms.map((room) => ( + onFilterChange("room", room.id)} + color={ + selectedSourceKind === "room" && selectedRoomId === room.id + ? "blue.500" + : "gray.600" + } + _hover={{ color: "blue.300" }} + fontWeight={ + selectedSourceKind === "room" && selectedRoomId === room.id + ? "bold" + : "normal" + } + ml={4} + > + {room.name} + + ))} + + )} + + {sharedRooms.length > 0 && ( + <> + Shared Rooms + + {sharedRooms.map((room) => ( + onFilterChange("room", room.id)} + color={ + selectedSourceKind === "room" && selectedRoomId === room.id + ? "blue.500" + : "gray.600" + } + _hover={{ color: "blue.300" }} + fontWeight={ + selectedSourceKind === "room" && selectedRoomId === room.id + ? "bold" + : "normal" + } + ml={4} + > + {room.name} + + ))} + + )} + + + onFilterChange("live", "")} + color={selectedSourceKind === "live" ? "blue.500" : "gray.600"} + _hover={{ color: "blue.300" }} + fontWeight={selectedSourceKind === "live" ? "bold" : "normal"} + > + Live Transcripts + + onFilterChange("file", "")} + color={selectedSourceKind === "file" ? "blue.500" : "gray.600"} + _hover={{ color: "blue.300" }} + fontWeight={selectedSourceKind === "file" ? "bold" : "normal"} + > + Uploaded Files + + + + ); +} diff --git a/www/app/(app)/browse/_components/SearchBar.tsx b/www/app/(app)/browse/_components/SearchBar.tsx new file mode 100644 index 00000000..8fd14fac --- /dev/null +++ b/www/app/(app)/browse/_components/SearchBar.tsx @@ -0,0 +1,34 @@ +import React, { useState } from "react"; +import { Flex, Input, Button } from "@chakra-ui/react"; + +interface SearchBarProps { + onSearch: (searchTerm: string) => void; +} + +export default function SearchBar({ onSearch }: SearchBarProps) { + const [searchInputValue, setSearchInputValue] = useState(""); + + const handleSearch = () => { + onSearch(searchInputValue); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + handleSearch(); + } + }; + + return ( + + setSearchInputValue(e.target.value)} + onKeyDown={handleKeyDown} + /> + + + ); +} diff --git a/www/app/(app)/browse/_components/TranscriptActionsMenu.tsx b/www/app/(app)/browse/_components/TranscriptActionsMenu.tsx new file mode 100644 index 00000000..85644bcc --- /dev/null +++ b/www/app/(app)/browse/_components/TranscriptActionsMenu.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { + Menu, + MenuButton, + MenuList, + MenuItem, + IconButton, + Icon, +} from "@chakra-ui/react"; +import { FaEllipsisVertical } from "react-icons/fa6"; + +interface TranscriptActionsMenuProps { + transcriptId: string; + onDelete: (transcriptId: string) => (e: any) => void; + onReprocess: (transcriptId: string) => (e: any) => void; +} + +export default function TranscriptActionsMenu({ + transcriptId, + onDelete, + onReprocess, +}: TranscriptActionsMenuProps) { + return ( + + } + variant="outline" + aria-label="Options" + /> + + onDelete(transcriptId)(e)}>Delete + onReprocess(transcriptId)(e)}> + Reprocess + + + + ); +} diff --git a/www/app/(app)/browse/_components/TranscriptCards.tsx b/www/app/(app)/browse/_components/TranscriptCards.tsx new file mode 100644 index 00000000..f18f79d7 --- /dev/null +++ b/www/app/(app)/browse/_components/TranscriptCards.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { Box, Stack, Text, Flex, Link, Spinner } from "@chakra-ui/react"; +import NextLink from "next/link"; +import { GetTranscriptMinimal } from "../../../api"; +import { formatTimeMs, formatLocalDate } from "../../../lib/time"; +import TranscriptStatusIcon from "./TranscriptStatusIcon"; +import TranscriptActionsMenu from "./TranscriptActionsMenu"; + +interface TranscriptCardsProps { + transcripts: GetTranscriptMinimal[]; + onDelete: (transcriptId: string) => (e: any) => void; + onReprocess: (transcriptId: string) => (e: any) => void; + loading?: boolean; +} + +export default function TranscriptCards({ + transcripts, + onDelete, + onReprocess, + loading, +}: TranscriptCardsProps) { + return ( + + {loading && ( + + + + )} + + + {transcripts.map((item) => ( + + + + + + + + {item.title || "Unnamed Transcript"} + + + Source:{" "} + {item.source_kind === "room" + ? item.room_name + : item.source_kind} + + Date: {formatLocalDate(item.created_at)} + Duration: {formatTimeMs(item.duration)} + + + + + ))} + + + + ); +} diff --git a/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx b/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx new file mode 100644 index 00000000..6b62e0e0 --- /dev/null +++ b/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { Icon, Tooltip } from "@chakra-ui/react"; +import { + FaCheck, + FaTrash, + FaStar, + FaMicrophone, + FaGear, +} from "react-icons/fa6"; + +interface TranscriptStatusIconProps { + status: string; +} + +export default function TranscriptStatusIcon({ + status, +}: TranscriptStatusIconProps) { + switch (status) { + case "ended": + return ( + + + + + + ); + case "error": + return ( + + + + + + ); + case "idle": + return ( + + + + + + ); + case "processing": + return ( + + + + + + ); + case "recording": + return ( + + + + + + ); + default: + return null; + } +} diff --git a/www/app/(app)/browse/_components/TranscriptTable.tsx b/www/app/(app)/browse/_components/TranscriptTable.tsx new file mode 100644 index 00000000..33ded6da --- /dev/null +++ b/www/app/(app)/browse/_components/TranscriptTable.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { + Box, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + Link, + Flex, + Spinner, +} from "@chakra-ui/react"; +import NextLink from "next/link"; +import { GetTranscriptMinimal } from "../../../api"; +import { formatTimeMs, formatLocalDate } from "../../../lib/time"; +import TranscriptStatusIcon from "./TranscriptStatusIcon"; +import TranscriptActionsMenu from "./TranscriptActionsMenu"; + +interface TranscriptTableProps { + transcripts: GetTranscriptMinimal[]; + onDelete: (transcriptId: string) => (e: any) => void; + onReprocess: (transcriptId: string) => (e: any) => void; + loading?: boolean; +} + +export default function TranscriptTable({ + transcripts, + onDelete, + onReprocess, + loading, +}: TranscriptTableProps) { + return ( + + {loading && ( + + + + )} + + + + + + + + + + + + + {transcripts.map((item) => ( + + + + + + + + ))} + +
+ Transcription Title + SourceDateDuration
+ + + + {item.title || "Unnamed Transcript"} + + + + {item.source_kind === "room" + ? item.room_name + : item.source_kind} + {formatLocalDate(item.created_at)}{formatTimeMs(item.duration)} + +
+
+
+ ); +} diff --git a/www/app/(app)/browse/page.tsx b/www/app/(app)/browse/page.tsx index a36a4aba..17f298b8 100644 --- a/www/app/(app)/browse/page.tsx +++ b/www/app/(app)/browse/page.tsx @@ -1,55 +1,18 @@ "use client"; import React, { useState, useEffect } from "react"; -import { - Flex, - Spinner, - Heading, - Box, - Text, - Link, - Stack, - Table, - Thead, - Tbody, - Tr, - Th, - Td, - Button, - Divider, - Input, - Icon, - Tooltip, - Menu, - MenuButton, - MenuList, - MenuItem, - IconButton, - AlertDialog, - AlertDialogOverlay, - AlertDialogContent, - AlertDialogHeader, - AlertDialogBody, - AlertDialogFooter, - Spacer, -} from "@chakra-ui/react"; -import { - FaCheck, - FaTrash, - FaStar, - FaMicrophone, - FaGear, - FaEllipsisVertical, - FaArrowRotateRight, -} from "react-icons/fa6"; +import { Flex, Spinner, Heading, Text, Link } from "@chakra-ui/react"; import useTranscriptList from "../transcripts/useTranscriptList"; import useSessionUser from "../../lib/useSessionUser"; -import NextLink from "next/link"; -import { Room, GetTranscriptMinimal } from "../../api"; +import { Room } from "../../api"; import Pagination from "./pagination"; -import { formatTimeMs, formatLocalDate } from "../../lib/time"; import useApi from "../../lib/useApi"; import { useError } from "../../(errors)/errorContext"; import { SourceKind } from "../../api"; +import FilterSidebar from "./_components/FilterSidebar"; +import SearchBar from "./_components/SearchBar"; +import TranscriptTable from "./_components/TranscriptTable"; +import TranscriptCards from "./_components/TranscriptCards"; +import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog"; export default function TranscriptBrowser() { const [selectedSourceKind, setSelectedSourceKind] = @@ -58,7 +21,6 @@ export default function TranscriptBrowser() { const [rooms, setRooms] = useState([]); const [page, setPage] = useState(1); const [searchTerm, setSearchTerm] = useState(""); - const [searchInputValue, setSearchInputValue] = useState(""); const { loading, response, refetch } = useTranscriptList( page, selectedSourceKind, @@ -74,9 +36,6 @@ export default function TranscriptBrowser() { React.useState(); const [deletedItemIds, setDeletedItemIds] = React.useState(); - const myRooms = rooms.filter((room) => !room.is_shared); - const sharedRooms = rooms.filter((room) => room.is_shared); - useEffect(() => { setDeletedItemIds([]); }, [page, response]); @@ -103,20 +62,14 @@ export default function TranscriptBrowser() { refetch(); }; - const handleSearch = () => { + const handleSearch = (searchTerm: string) => { setPage(1); - setSearchTerm(searchInputValue); + setSearchTerm(searchTerm); setSelectedSourceKind(null); setSelectedRoomId(""); refetch(); }; - const handleKeyDown = (event) => { - if (event.key === "Enter") { - handleSearch(); - } - }; - if (loading && !response) return ( @@ -195,324 +148,42 @@ export default function TranscriptBrowser() { - - - handleFilterTranscripts(null, "")} - color={selectedSourceKind === null ? "blue.500" : "gray.600"} - _hover={{ color: "blue.300" }} - fontWeight={selectedSourceKind === null ? "bold" : "normal"} - > - All Transcripts - - - - - {myRooms.length > 0 && ( - <> - My Rooms - - {myRooms.map((room) => ( - handleFilterTranscripts("room", room.id)} - color={ - selectedSourceKind === "room" && - selectedRoomId === room.id - ? "blue.500" - : "gray.600" - } - _hover={{ color: "blue.300" }} - fontWeight={ - selectedSourceKind === "room" && - selectedRoomId === room.id - ? "bold" - : "normal" - } - ml={4} - > - {room.name} - - ))} - - )} - - {sharedRooms.length > 0 && ( - <> - Shared Rooms - - {sharedRooms.map((room) => ( - handleFilterTranscripts("room", room.id)} - color={ - selectedSourceKind === "room" && - selectedRoomId === room.id - ? "blue.500" - : "gray.600" - } - _hover={{ color: "blue.300" }} - fontWeight={ - selectedSourceKind === "room" && - selectedRoomId === room.id - ? "bold" - : "normal" - } - ml={4} - > - {room.name} - - ))} - - )} - - - handleFilterTranscripts("live", "")} - color={selectedSourceKind === "live" ? "blue.500" : "gray.600"} - _hover={{ color: "blue.300" }} - fontWeight={selectedSourceKind === "live" ? "bold" : "normal"} - > - Live Transcripts - - handleFilterTranscripts("file", "")} - color={selectedSourceKind === "file" ? "blue.500" : "gray.600"} - _hover={{ color: "blue.300" }} - fontWeight={selectedSourceKind === "file" ? "bold" : "normal"} - > - Uploaded Files - - - + - - setSearchInputValue(e.target.value)} - onKeyDown={handleKeyDown} - /> - - + - - - - - - - - - - - - - {response?.items?.map((item: GetTranscriptMinimal) => ( - - - - - - - - ))} - -
- Transcription Title - SourceDateDuration
- - {item.status === "ended" && ( - - - - - - )} - {item.status === "error" && ( - - - - - - )} - {item.status === "idle" && ( - - - - - - )} - {item.status === "processing" && ( - - - - - - )} - {item.status === "recording" && ( - - - - - - )} - - {item.title || "Unnamed Transcript"} - - - - {item.source_kind === "room" - ? item.room_name - : item.source_kind} - {formatLocalDate(item.created_at)}{formatTimeMs(item.duration)} - - } - variant="outline" - aria-label="Options" - /> - - - Delete - - - Reprocess - - - -
-
- - - {response?.items?.map((item: GetTranscriptMinimal) => ( - - - - {item.status === "ended" && ( - - - - - - )} - {item.status === "error" && ( - - - - - - )} - {item.status === "idle" && ( - - - - - - )} - {item.status === "processing" && ( - - - - - - )} - {item.status === "recording" && ( - - - - - - )} - - - - {item.title || "Unnamed Transcript"} - - - Source:{" "} - {item.source_kind === "room" - ? item.room_name - : item.source_kind} - - Date: {formatLocalDate(item.created_at)} - Duration: {formatTimeMs(item.duration)} - - - } - variant="outline" - aria-label="Options" - /> - - - Delete - - - Reprocess - - - - - - ))} - - + +
- - - - - Delete Transcript - - - Are you sure? You can't undo this action afterwards. - - - - - - - - + onConfirm={() => handleDeleteTranscript(transcriptToDeleteId)(null)} + cancelRef={cancelRef} + /> ); }