From fc38345d6548775ea6c3d77522e8c478127ac032 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 17 Jul 2025 19:42:09 -0600 Subject: [PATCH] fix: separate browsing page into different components, limit to 10 by default (#498) * feat: limit the amount of transcripts to 10 by default * feat: separate page into different component, greatly improving the loading and reactivity * fix: current implementation immediately invokes the onDelete and onReprocess From pr-agent-monadical: Suggestion: The current implementation immediately invokes the onDelete and onReprocess functions when the component renders, rather than when the menu items are clicked. This can cause unexpected behavior and potential memory leaks. Use callback functions that only execute when the menu items are actually clicked. [possible issue, importance: 9] --- server/reflector/views/transcripts.py | 3 +- .../_components/DeleteTranscriptDialog.tsx | 51 +++ .../browse/_components/FilterSidebar.tsx | 120 ++++++ .../(app)/browse/_components/SearchBar.tsx | 34 ++ .../_components/TranscriptActionsMenu.tsx | 39 ++ .../browse/_components/TranscriptCards.tsx | 81 ++++ .../_components/TranscriptStatusIcon.tsx | 62 +++ .../browse/_components/TranscriptTable.tsx | 99 +++++ www/app/(app)/browse/page.tsx | 393 ++---------------- 9 files changed, 520 insertions(+), 362 deletions(-) create mode 100644 www/app/(app)/browse/_components/DeleteTranscriptDialog.tsx create mode 100644 www/app/(app)/browse/_components/FilterSidebar.tsx create mode 100644 www/app/(app)/browse/_components/SearchBar.tsx create mode 100644 www/app/(app)/browse/_components/TranscriptActionsMenu.tsx create mode 100644 www/app/(app)/browse/_components/TranscriptCards.tsx create mode 100644 www/app/(app)/browse/_components/TranscriptStatusIcon.tsx create mode 100644 www/app/(app)/browse/_components/TranscriptTable.tsx 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} + /> ); }