mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-04-23 13:45:18 +00:00
feat: show trash for soft deleted transcripts and hard delete option (#942)
* feat: show trash for soft deleted transcripts and hard delete option * fix: test fixtures * docs: aws new permissions
This commit is contained in:
committed by
GitHub
parent
cc9c5cd4a5
commit
ec8b49738e
@@ -34,11 +34,11 @@ export default function DeleteTranscriptDialog({
|
||||
<Dialog.Positioner>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header fontSize="lg" fontWeight="bold">
|
||||
Delete transcript
|
||||
Move to Trash
|
||||
</Dialog.Header>
|
||||
<Dialog.Body>
|
||||
Are you sure you want to delete this transcript? This action cannot
|
||||
be undone.
|
||||
This transcript will be moved to the trash. You can restore it later
|
||||
from the Trash view.
|
||||
{title && (
|
||||
<Text mt={3} fontWeight="600">
|
||||
{title}
|
||||
@@ -71,7 +71,7 @@ export default function DeleteTranscriptDialog({
|
||||
ml={3}
|
||||
disabled={!!isLoading}
|
||||
>
|
||||
Delete
|
||||
Move to Trash
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
|
||||
83
www/app/(app)/browse/_components/DestroyTranscriptDialog.tsx
Normal file
83
www/app/(app)/browse/_components/DestroyTranscriptDialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import { Button, Dialog, Text } from "@chakra-ui/react";
|
||||
|
||||
interface DestroyTranscriptDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
cancelRef: React.RefObject<any>;
|
||||
isLoading?: boolean;
|
||||
title?: string;
|
||||
date?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export default function DestroyTranscriptDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
cancelRef,
|
||||
isLoading,
|
||||
title,
|
||||
date,
|
||||
source,
|
||||
}: DestroyTranscriptDialogProps) {
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(e) => {
|
||||
if (!e.open) onClose();
|
||||
}}
|
||||
initialFocusEl={() => cancelRef.current}
|
||||
>
|
||||
<Dialog.Backdrop />
|
||||
<Dialog.Positioner>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header fontSize="lg" fontWeight="bold">
|
||||
Permanently Destroy Transcript
|
||||
</Dialog.Header>
|
||||
<Dialog.Body>
|
||||
<Text color="red.600" fontWeight="medium">
|
||||
This will permanently delete this transcript and all its
|
||||
associated audio files. This action cannot be undone.
|
||||
</Text>
|
||||
{title && (
|
||||
<Text mt={3} fontWeight="600">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{date && (
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
Date: {date}
|
||||
</Text>
|
||||
)}
|
||||
{source && (
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
Source: {source}
|
||||
</Text>
|
||||
)}
|
||||
</Dialog.Body>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
ref={cancelRef as any}
|
||||
onClick={onClose}
|
||||
disabled={!!isLoading}
|
||||
variant="outline"
|
||||
colorPalette="gray"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
colorPalette="red"
|
||||
onClick={onConfirm}
|
||||
ml={3}
|
||||
disabled={!!isLoading}
|
||||
>
|
||||
Destroy
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Positioner>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Box, Stack, Link, Heading } from "@chakra-ui/react";
|
||||
import { Box, Stack, Link, Heading, Flex } from "@chakra-ui/react";
|
||||
import NextLink from "next/link";
|
||||
import { LuTrash2 } from "react-icons/lu";
|
||||
import type { components } from "../../../reflector-api";
|
||||
|
||||
type Room = components["schemas"]["Room"];
|
||||
@@ -13,6 +14,9 @@ interface FilterSidebarProps {
|
||||
selectedSourceKind: SourceKind | null;
|
||||
selectedRoomId: string;
|
||||
onFilterChange: (sourceKind: SourceKind | null, roomId: string) => void;
|
||||
isTrashView: boolean;
|
||||
onTrashClick: () => void;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
export default function FilterSidebar({
|
||||
@@ -20,6 +24,9 @@ export default function FilterSidebar({
|
||||
selectedSourceKind,
|
||||
selectedRoomId,
|
||||
onFilterChange,
|
||||
isTrashView,
|
||||
onTrashClick,
|
||||
isAuthenticated,
|
||||
}: FilterSidebarProps) {
|
||||
const myRooms = rooms.filter((room) => !room.is_shared);
|
||||
const sharedRooms = rooms.filter((room) => room.is_shared);
|
||||
@@ -32,8 +39,14 @@ export default function FilterSidebar({
|
||||
fontSize="sm"
|
||||
href="#"
|
||||
onClick={() => onFilterChange(null, "")}
|
||||
color={selectedSourceKind === null ? "blue.500" : "gray.600"}
|
||||
fontWeight={selectedSourceKind === null ? "bold" : "normal"}
|
||||
color={
|
||||
!isTrashView && selectedSourceKind === null
|
||||
? "blue.500"
|
||||
: "gray.600"
|
||||
}
|
||||
fontWeight={
|
||||
!isTrashView && selectedSourceKind === null ? "bold" : "normal"
|
||||
}
|
||||
>
|
||||
All Transcripts
|
||||
</Link>
|
||||
@@ -51,12 +64,16 @@ export default function FilterSidebar({
|
||||
href="#"
|
||||
onClick={() => onFilterChange("room", room.id)}
|
||||
color={
|
||||
selectedSourceKind === "room" && selectedRoomId === room.id
|
||||
!isTrashView &&
|
||||
selectedSourceKind === "room" &&
|
||||
selectedRoomId === room.id
|
||||
? "blue.500"
|
||||
: "gray.600"
|
||||
}
|
||||
fontWeight={
|
||||
selectedSourceKind === "room" && selectedRoomId === room.id
|
||||
!isTrashView &&
|
||||
selectedSourceKind === "room" &&
|
||||
selectedRoomId === room.id
|
||||
? "bold"
|
||||
: "normal"
|
||||
}
|
||||
@@ -79,12 +96,16 @@ export default function FilterSidebar({
|
||||
href="#"
|
||||
onClick={() => onFilterChange("room" as SourceKind, room.id)}
|
||||
color={
|
||||
selectedSourceKind === "room" && selectedRoomId === room.id
|
||||
!isTrashView &&
|
||||
selectedSourceKind === "room" &&
|
||||
selectedRoomId === room.id
|
||||
? "blue.500"
|
||||
: "gray.600"
|
||||
}
|
||||
fontWeight={
|
||||
selectedSourceKind === "room" && selectedRoomId === room.id
|
||||
!isTrashView &&
|
||||
selectedSourceKind === "room" &&
|
||||
selectedRoomId === room.id
|
||||
? "bold"
|
||||
: "normal"
|
||||
}
|
||||
@@ -101,9 +122,15 @@ export default function FilterSidebar({
|
||||
as={NextLink}
|
||||
href="#"
|
||||
onClick={() => onFilterChange("live", "")}
|
||||
color={selectedSourceKind === "live" ? "blue.500" : "gray.600"}
|
||||
color={
|
||||
!isTrashView && selectedSourceKind === "live"
|
||||
? "blue.500"
|
||||
: "gray.600"
|
||||
}
|
||||
_hover={{ color: "blue.300" }}
|
||||
fontWeight={selectedSourceKind === "live" ? "bold" : "normal"}
|
||||
fontWeight={
|
||||
!isTrashView && selectedSourceKind === "live" ? "bold" : "normal"
|
||||
}
|
||||
fontSize="sm"
|
||||
>
|
||||
Live Transcripts
|
||||
@@ -112,13 +139,39 @@ export default function FilterSidebar({
|
||||
as={NextLink}
|
||||
href="#"
|
||||
onClick={() => onFilterChange("file", "")}
|
||||
color={selectedSourceKind === "file" ? "blue.500" : "gray.600"}
|
||||
color={
|
||||
!isTrashView && selectedSourceKind === "file"
|
||||
? "blue.500"
|
||||
: "gray.600"
|
||||
}
|
||||
_hover={{ color: "blue.300" }}
|
||||
fontWeight={selectedSourceKind === "file" ? "bold" : "normal"}
|
||||
fontWeight={
|
||||
!isTrashView && selectedSourceKind === "file" ? "bold" : "normal"
|
||||
}
|
||||
fontSize="sm"
|
||||
>
|
||||
Uploaded Files
|
||||
</Link>
|
||||
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Box borderBottomWidth="1px" my={2} />
|
||||
<Link
|
||||
as={NextLink}
|
||||
href="#"
|
||||
onClick={onTrashClick}
|
||||
color={isTrashView ? "red.600" : "red.500"}
|
||||
_hover={{ color: "red.400" }}
|
||||
fontWeight={isTrashView ? "bold" : "normal"}
|
||||
fontSize="sm"
|
||||
>
|
||||
<Flex align="center" gap={1}>
|
||||
<LuTrash2 />
|
||||
Trash
|
||||
</Flex>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import React from "react";
|
||||
import { IconButton, Icon, Menu } from "@chakra-ui/react";
|
||||
import { LuMenu, LuTrash, LuRotateCw } from "react-icons/lu";
|
||||
import { IconButton, Menu } from "@chakra-ui/react";
|
||||
import { LuMenu, LuTrash, LuRotateCw, LuUndo2 } from "react-icons/lu";
|
||||
|
||||
interface TranscriptActionsMenuProps {
|
||||
transcriptId: string;
|
||||
onDelete: (transcriptId: string) => void;
|
||||
onReprocess: (transcriptId: string) => void;
|
||||
onDelete?: (transcriptId: string) => void;
|
||||
onReprocess?: (transcriptId: string) => void;
|
||||
onRestore?: (transcriptId: string) => void;
|
||||
onDestroy?: (transcriptId: string) => void;
|
||||
}
|
||||
|
||||
export default function TranscriptActionsMenu({
|
||||
transcriptId,
|
||||
onDelete,
|
||||
onReprocess,
|
||||
onRestore,
|
||||
onDestroy,
|
||||
}: TranscriptActionsMenuProps) {
|
||||
return (
|
||||
<Menu.Root closeOnSelect={true} lazyMount={true}>
|
||||
@@ -22,21 +26,42 @@ export default function TranscriptActionsMenu({
|
||||
</Menu.Trigger>
|
||||
<Menu.Positioner>
|
||||
<Menu.Content>
|
||||
<Menu.Item
|
||||
value="reprocess"
|
||||
onClick={() => onReprocess(transcriptId)}
|
||||
>
|
||||
<LuRotateCw /> Reprocess
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
value="delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(transcriptId);
|
||||
}}
|
||||
>
|
||||
<LuTrash /> Delete
|
||||
</Menu.Item>
|
||||
{onReprocess && (
|
||||
<Menu.Item
|
||||
value="reprocess"
|
||||
onClick={() => onReprocess(transcriptId)}
|
||||
>
|
||||
<LuRotateCw /> Reprocess
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Menu.Item
|
||||
value="delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(transcriptId);
|
||||
}}
|
||||
>
|
||||
<LuTrash /> Delete
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onRestore && (
|
||||
<Menu.Item value="restore" onClick={() => onRestore(transcriptId)}>
|
||||
<LuUndo2 /> Restore
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onDestroy && (
|
||||
<Menu.Item
|
||||
value="destroy"
|
||||
color="red.500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDestroy(transcriptId);
|
||||
}}
|
||||
>
|
||||
<LuTrash /> Destroy
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Content>
|
||||
</Menu.Positioner>
|
||||
</Menu.Root>
|
||||
|
||||
@@ -29,8 +29,11 @@ interface TranscriptCardsProps {
|
||||
results: SearchResult[];
|
||||
query: string;
|
||||
isLoading?: boolean;
|
||||
onDelete: (transcriptId: string) => void;
|
||||
onReprocess: (transcriptId: string) => void;
|
||||
isTrash?: boolean;
|
||||
onDelete?: (transcriptId: string) => void;
|
||||
onReprocess?: (transcriptId: string) => void;
|
||||
onRestore?: (transcriptId: string) => void;
|
||||
onDestroy?: (transcriptId: string) => void;
|
||||
}
|
||||
|
||||
function highlightText(text: string, query: string): React.ReactNode {
|
||||
@@ -102,13 +105,19 @@ const transcriptHref = (
|
||||
function TranscriptCard({
|
||||
result,
|
||||
query,
|
||||
isTrash,
|
||||
onDelete,
|
||||
onReprocess,
|
||||
onRestore,
|
||||
onDestroy,
|
||||
}: {
|
||||
result: SearchResult;
|
||||
query: string;
|
||||
onDelete: (transcriptId: string) => void;
|
||||
onReprocess: (transcriptId: string) => void;
|
||||
isTrash?: boolean;
|
||||
onDelete?: (transcriptId: string) => void;
|
||||
onReprocess?: (transcriptId: string) => void;
|
||||
onRestore?: (transcriptId: string) => void;
|
||||
onDestroy?: (transcriptId: string) => void;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
@@ -136,22 +145,36 @@ function TranscriptCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box borderWidth={1} p={4} borderRadius="md" fontSize="sm">
|
||||
<Box
|
||||
borderWidth={1}
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
borderLeftWidth={isTrash ? "3px" : 1}
|
||||
borderLeftColor={isTrash ? "red.400" : undefined}
|
||||
bg={isTrash ? "gray.50" : undefined}
|
||||
>
|
||||
<Flex justify="space-between" alignItems="flex-start" gap="2">
|
||||
<Box>
|
||||
<TranscriptStatusIcon status={result.status} />
|
||||
</Box>
|
||||
<Box flex="1">
|
||||
{/* Title with highlighting and text fragment for deep linking */}
|
||||
<Link
|
||||
as={NextLink}
|
||||
href={transcriptHref(result.id, mainSnippet, query)}
|
||||
fontWeight="600"
|
||||
display="block"
|
||||
mb={2}
|
||||
>
|
||||
{highlightText(resultTitle, query)}
|
||||
</Link>
|
||||
{/* Title — plain text in trash (deleted transcripts return 404) */}
|
||||
{isTrash ? (
|
||||
<Text fontWeight="600" mb={2} color="gray.600">
|
||||
{highlightText(resultTitle, query)}
|
||||
</Text>
|
||||
) : (
|
||||
<Link
|
||||
as={NextLink}
|
||||
href={transcriptHref(result.id, mainSnippet, query)}
|
||||
fontWeight="600"
|
||||
display="block"
|
||||
mb={2}
|
||||
>
|
||||
{highlightText(resultTitle, query)}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Metadata - Horizontal on desktop, vertical on mobile */}
|
||||
<Flex
|
||||
@@ -272,8 +295,10 @@ function TranscriptCard({
|
||||
</Box>
|
||||
<TranscriptActionsMenu
|
||||
transcriptId={result.id}
|
||||
onDelete={onDelete}
|
||||
onReprocess={onReprocess}
|
||||
onDelete={isTrash ? undefined : onDelete}
|
||||
onReprocess={isTrash ? undefined : onReprocess}
|
||||
onRestore={isTrash ? onRestore : undefined}
|
||||
onDestroy={isTrash ? onDestroy : undefined}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
@@ -284,8 +309,11 @@ export default function TranscriptCards({
|
||||
results,
|
||||
query,
|
||||
isLoading,
|
||||
isTrash,
|
||||
onDelete,
|
||||
onReprocess,
|
||||
onRestore,
|
||||
onDestroy,
|
||||
}: TranscriptCardsProps) {
|
||||
return (
|
||||
<Box position="relative">
|
||||
@@ -315,8 +343,11 @@ export default function TranscriptCards({
|
||||
key={result.id}
|
||||
result={result}
|
||||
query={query}
|
||||
isTrash={isTrash}
|
||||
onDelete={onDelete}
|
||||
onReprocess={onReprocess}
|
||||
onRestore={onRestore}
|
||||
onDestroy={onDestroy}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
parseAsStringLiteral,
|
||||
} from "nuqs";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { toaster } from "../../components/ui/toaster";
|
||||
import type { components } from "../../reflector-api";
|
||||
|
||||
type Room = components["schemas"]["Room"];
|
||||
@@ -29,6 +30,9 @@ import {
|
||||
useTranscriptsSearch,
|
||||
useTranscriptDelete,
|
||||
useTranscriptProcess,
|
||||
useTranscriptRestore,
|
||||
useTranscriptDestroy,
|
||||
useAuthReady,
|
||||
} from "../../lib/apiHooks";
|
||||
import FilterSidebar from "./_components/FilterSidebar";
|
||||
import Pagination, {
|
||||
@@ -40,6 +44,7 @@ import Pagination, {
|
||||
} from "./_components/Pagination";
|
||||
import TranscriptCards from "./_components/TranscriptCards";
|
||||
import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog";
|
||||
import DestroyTranscriptDialog from "./_components/DestroyTranscriptDialog";
|
||||
import { formatLocalDate } from "../../lib/time";
|
||||
import { RECORD_A_MEETING_URL } from "../../api/urls";
|
||||
import { useUserName } from "../../lib/useUserName";
|
||||
@@ -175,14 +180,17 @@ const UnderSearchFormFilterIndicators: React.FC<{
|
||||
|
||||
const EmptyResult: React.FC<{
|
||||
searchQuery: string;
|
||||
}> = ({ searchQuery }) => {
|
||||
isTrash?: boolean;
|
||||
}> = ({ searchQuery, isTrash }) => {
|
||||
return (
|
||||
<Flex flexDir="column" alignItems="center" justifyContent="center" py={8}>
|
||||
<Text textAlign="center">
|
||||
{searchQuery
|
||||
? `No results found for "${searchQuery}". Try adjusting your search terms.`
|
||||
: "No transcripts found, but you can "}
|
||||
{!searchQuery && (
|
||||
{isTrash
|
||||
? "Trash is empty."
|
||||
: searchQuery
|
||||
? `No results found for "${searchQuery}". Try adjusting your search terms.`
|
||||
: "No transcripts found, but you can "}
|
||||
{!isTrash && !searchQuery && (
|
||||
<>
|
||||
<Link href={RECORD_A_MEETING_URL} color="blue.500">
|
||||
record a meeting
|
||||
@@ -196,6 +204,8 @@ const EmptyResult: React.FC<{
|
||||
};
|
||||
|
||||
export default function TranscriptBrowser() {
|
||||
const { isAuthenticated } = useAuthReady();
|
||||
|
||||
const [urlSearchQuery, setUrlSearchQuery] = useQueryState(
|
||||
"q",
|
||||
parseAsString.withDefault("").withOptions({ shallow: false }),
|
||||
@@ -216,6 +226,12 @@ export default function TranscriptBrowser() {
|
||||
parseAsString.withDefault("").withOptions({ shallow: false }),
|
||||
);
|
||||
|
||||
const [urlTrash, setUrlTrash] = useQueryState(
|
||||
"trash",
|
||||
parseAsStringLiteral(["1"] as const).withOptions({ shallow: false }),
|
||||
);
|
||||
const isTrashView = urlTrash === "1";
|
||||
|
||||
const [urlPage, setPage] = useQueryState(
|
||||
"page",
|
||||
parseAsInteger.withDefault(1).withOptions({ shallow: false }),
|
||||
@@ -231,7 +247,7 @@ export default function TranscriptBrowser() {
|
||||
return;
|
||||
}
|
||||
_setSafePage(maybePage.value);
|
||||
}, [urlPage]);
|
||||
}, [urlPage, setPage]);
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
@@ -240,11 +256,12 @@ export default function TranscriptBrowser() {
|
||||
() => ({
|
||||
q: urlSearchQuery,
|
||||
extras: {
|
||||
room_id: urlRoomId || undefined,
|
||||
source_kind: urlSourceKind || undefined,
|
||||
room_id: isTrashView ? undefined : urlRoomId || undefined,
|
||||
source_kind: isTrashView ? undefined : urlSourceKind || undefined,
|
||||
include_deleted: isTrashView ? true : undefined,
|
||||
},
|
||||
}),
|
||||
[urlSearchQuery, urlRoomId, urlSourceKind],
|
||||
[urlSearchQuery, urlRoomId, urlSourceKind, isTrashView],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -266,35 +283,55 @@ export default function TranscriptBrowser() {
|
||||
|
||||
const totalPages = getTotalPages(totalResults, pageSize);
|
||||
|
||||
// reset pagination when search results change (detected by total change; good enough approximation)
|
||||
// reset pagination when search filters change
|
||||
useEffect(() => {
|
||||
// operation is idempotent
|
||||
setPage(FIRST_PAGE).then(() => {});
|
||||
}, [JSON.stringify(searchFilters)]);
|
||||
}, [searchFilters, setPage]);
|
||||
|
||||
const userName = useUserName();
|
||||
const [deletionLoading, setDeletionLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const cancelRef = React.useRef(null);
|
||||
const destroyCancelRef = React.useRef(null);
|
||||
|
||||
// Delete (soft-delete / move to trash)
|
||||
const [transcriptToDeleteId, setTranscriptToDeleteId] =
|
||||
React.useState<string>();
|
||||
|
||||
// Destroy (hard-delete)
|
||||
const [transcriptToDestroyId, setTranscriptToDestroyId] =
|
||||
React.useState<string>();
|
||||
|
||||
const handleFilterTranscripts = (
|
||||
sourceKind: SourceKind | null,
|
||||
roomId: string,
|
||||
) => {
|
||||
if (isTrashView) {
|
||||
setUrlTrash(null);
|
||||
}
|
||||
setUrlSourceKind(sourceKind);
|
||||
setUrlRoomId(roomId);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleTrashClick = () => {
|
||||
setUrlTrash(isTrashView ? null : "1");
|
||||
setUrlSourceKind(null);
|
||||
setUrlRoomId(null);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const onCloseDeletion = () => setTranscriptToDeleteId(undefined);
|
||||
const onCloseDestroy = () => setTranscriptToDestroyId(undefined);
|
||||
|
||||
const deleteTranscript = useTranscriptDelete();
|
||||
const processTranscript = useTranscriptProcess();
|
||||
const restoreTranscript = useTranscriptRestore();
|
||||
const destroyTranscript = useTranscriptDestroy();
|
||||
|
||||
const confirmDeleteTranscript = (transcriptId: string) => {
|
||||
if (deletionLoading) return;
|
||||
setDeletionLoading(true);
|
||||
if (actionLoading) return;
|
||||
setActionLoading(true);
|
||||
deleteTranscript.mutate(
|
||||
{
|
||||
params: {
|
||||
@@ -303,12 +340,12 @@ export default function TranscriptBrowser() {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDeletionLoading(false);
|
||||
setActionLoading(false);
|
||||
onCloseDeletion();
|
||||
reloadSearch();
|
||||
},
|
||||
onError: () => {
|
||||
setDeletionLoading(false);
|
||||
setActionLoading(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -322,18 +359,83 @@ export default function TranscriptBrowser() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleRestoreTranscript = (transcriptId: string) => {
|
||||
if (actionLoading) return;
|
||||
setActionLoading(true);
|
||||
restoreTranscript.mutate(
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: transcriptId },
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setActionLoading(false);
|
||||
reloadSearch();
|
||||
toaster.create({
|
||||
duration: 3000,
|
||||
render: () => (
|
||||
<Box bg="green.500" color="white" px={4} py={3} borderRadius="md">
|
||||
<Text fontWeight="bold">Transcript restored</Text>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
setActionLoading(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const confirmDestroyTranscript = (transcriptId: string) => {
|
||||
if (actionLoading) return;
|
||||
setActionLoading(true);
|
||||
destroyTranscript.mutate(
|
||||
{
|
||||
params: {
|
||||
path: { transcript_id: transcriptId },
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setActionLoading(false);
|
||||
onCloseDestroy();
|
||||
reloadSearch();
|
||||
},
|
||||
onError: () => {
|
||||
setActionLoading(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Dialog data for delete
|
||||
const transcriptToDelete = results?.find(
|
||||
(i) => i.id === transcriptToDeleteId,
|
||||
);
|
||||
const dialogTitle = transcriptToDelete?.title || "Unnamed Transcript";
|
||||
const dialogDate = transcriptToDelete?.created_at
|
||||
const deleteDialogTitle = transcriptToDelete?.title || "Unnamed Transcript";
|
||||
const deleteDialogDate = transcriptToDelete?.created_at
|
||||
? formatLocalDate(transcriptToDelete.created_at)
|
||||
: undefined;
|
||||
const dialogSource =
|
||||
const deleteDialogSource =
|
||||
transcriptToDelete?.source_kind === "room" && transcriptToDelete?.room_id
|
||||
? transcriptToDelete.room_name || transcriptToDelete.room_id
|
||||
: transcriptToDelete?.source_kind;
|
||||
|
||||
// Dialog data for destroy
|
||||
const transcriptToDestroy = results?.find(
|
||||
(i) => i.id === transcriptToDestroyId,
|
||||
);
|
||||
const destroyDialogTitle = transcriptToDestroy?.title || "Unnamed Transcript";
|
||||
const destroyDialogDate = transcriptToDestroy?.created_at
|
||||
? formatLocalDate(transcriptToDestroy.created_at)
|
||||
: undefined;
|
||||
const destroyDialogSource =
|
||||
transcriptToDestroy?.source_kind === "room" && transcriptToDestroy?.room_id
|
||||
? transcriptToDestroy.room_name || transcriptToDestroy.room_id
|
||||
: transcriptToDestroy?.source_kind;
|
||||
|
||||
if (searchLoading && results.length === 0) {
|
||||
return (
|
||||
<Flex
|
||||
@@ -361,17 +463,24 @@ export default function TranscriptBrowser() {
|
||||
mb={4}
|
||||
>
|
||||
<Heading size="lg">
|
||||
{userName ? `${userName}'s Transcriptions` : "Your Transcriptions"}{" "}
|
||||
{(searchLoading || deletionLoading) && <Spinner size="sm" />}
|
||||
{isTrashView
|
||||
? "Trash"
|
||||
: userName
|
||||
? `${userName}'s Transcriptions`
|
||||
: "Your Transcriptions"}{" "}
|
||||
{(searchLoading || actionLoading) && <Spinner size="sm" />}
|
||||
</Heading>
|
||||
</Flex>
|
||||
|
||||
<Flex flexDir={{ base: "column", md: "row" }}>
|
||||
<FilterSidebar
|
||||
rooms={rooms}
|
||||
selectedSourceKind={urlSourceKind}
|
||||
selectedRoomId={urlRoomId}
|
||||
selectedSourceKind={isTrashView ? null : urlSourceKind}
|
||||
selectedRoomId={isTrashView ? "" : urlRoomId}
|
||||
onFilterChange={handleFilterTranscripts}
|
||||
isTrashView={isTrashView}
|
||||
onTrashClick={handleTrashClick}
|
||||
isAuthenticated={isAuthenticated}
|
||||
/>
|
||||
|
||||
<Flex
|
||||
@@ -384,8 +493,8 @@ export default function TranscriptBrowser() {
|
||||
>
|
||||
<SearchForm
|
||||
setPage={setPage}
|
||||
sourceKind={urlSourceKind}
|
||||
roomId={urlRoomId}
|
||||
sourceKind={isTrashView ? null : urlSourceKind}
|
||||
roomId={isTrashView ? null : urlRoomId}
|
||||
searchQuery={urlSearchQuery}
|
||||
setSearchQuery={setUrlSearchQuery}
|
||||
setSourceKind={setUrlSourceKind}
|
||||
@@ -406,12 +515,15 @@ export default function TranscriptBrowser() {
|
||||
results={results}
|
||||
query={urlSearchQuery}
|
||||
isLoading={searchLoading}
|
||||
onDelete={setTranscriptToDeleteId}
|
||||
onReprocess={handleProcessTranscript}
|
||||
isTrash={isTrashView}
|
||||
onDelete={isTrashView ? undefined : setTranscriptToDeleteId}
|
||||
onReprocess={isTrashView ? undefined : handleProcessTranscript}
|
||||
onRestore={isTrashView ? handleRestoreTranscript : undefined}
|
||||
onDestroy={isTrashView ? setTranscriptToDestroyId : undefined}
|
||||
/>
|
||||
|
||||
{!searchLoading && results.length === 0 && (
|
||||
<EmptyResult searchQuery={urlSearchQuery} />
|
||||
<EmptyResult searchQuery={urlSearchQuery} isTrash={isTrashView} />
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -423,10 +535,24 @@ export default function TranscriptBrowser() {
|
||||
transcriptToDeleteId && confirmDeleteTranscript(transcriptToDeleteId)
|
||||
}
|
||||
cancelRef={cancelRef}
|
||||
isLoading={deletionLoading}
|
||||
title={dialogTitle}
|
||||
date={dialogDate}
|
||||
source={dialogSource}
|
||||
isLoading={actionLoading}
|
||||
title={deleteDialogTitle}
|
||||
date={deleteDialogDate}
|
||||
source={deleteDialogSource}
|
||||
/>
|
||||
|
||||
<DestroyTranscriptDialog
|
||||
isOpen={!!transcriptToDestroyId}
|
||||
onClose={onCloseDestroy}
|
||||
onConfirm={() =>
|
||||
transcriptToDestroyId &&
|
||||
confirmDestroyTranscript(transcriptToDestroyId)
|
||||
}
|
||||
cancelRef={destroyCancelRef}
|
||||
isLoading={actionLoading}
|
||||
title={destroyDialogTitle}
|
||||
date={destroyDialogDate}
|
||||
source={destroyDialogSource}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -136,6 +136,7 @@ export function UserEventsProvider({
|
||||
switch (msg.event) {
|
||||
case "TRANSCRIPT_CREATED":
|
||||
case "TRANSCRIPT_DELETED":
|
||||
case "TRANSCRIPT_RESTORED":
|
||||
case "TRANSCRIPT_STATUS":
|
||||
case "TRANSCRIPT_FINAL_TITLE":
|
||||
case "TRANSCRIPT_DURATION":
|
||||
|
||||
@@ -57,6 +57,7 @@ export function useTranscriptsSearch(
|
||||
offset?: number;
|
||||
room_id?: string;
|
||||
source_kind?: SourceKind;
|
||||
include_deleted?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
return $api.useQuery(
|
||||
@@ -70,6 +71,7 @@ export function useTranscriptsSearch(
|
||||
offset: options.offset,
|
||||
room_id: options.room_id,
|
||||
source_kind: options.source_kind,
|
||||
include_deleted: options.include_deleted,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -105,6 +107,38 @@ export function useTranscriptProcess() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useTranscriptRestore() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation("post", "/v1/transcripts/{transcript_id}/restore", {
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: ["get", TRANSCRIPT_SEARCH_URL],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error restoring the transcript");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTranscriptDestroy() {
|
||||
const { setError } = useError();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return $api.useMutation("delete", "/v1/transcripts/{transcript_id}/destroy", {
|
||||
onSuccess: () => {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: ["get", TRANSCRIPT_SEARCH_URL],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setError(error as Error, "There was an error destroying the transcript");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const ACTIVE_TRANSCRIPT_STATUSES = new Set<TranscriptStatus>([
|
||||
"processing",
|
||||
"uploaded",
|
||||
|
||||
125
www/app/reflector-api.d.ts
vendored
125
www/app/reflector-api.d.ts
vendored
@@ -388,6 +388,46 @@ export interface paths {
|
||||
patch: operations["v1_transcript_update"];
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/transcripts/{transcript_id}/restore": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Transcript Restore
|
||||
* @description Restore a soft-deleted transcript.
|
||||
*/
|
||||
post: operations["v1_transcript_restore"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/transcripts/{transcript_id}/destroy": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Transcript Destroy
|
||||
* @description Permanently delete a transcript and all associated files.
|
||||
*/
|
||||
delete: operations["v1_transcript_destroy"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/transcripts/{transcript_id}/topics": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2391,6 +2431,14 @@ export interface components {
|
||||
*/
|
||||
title: string;
|
||||
};
|
||||
/** UserTranscriptRestoredData */
|
||||
UserTranscriptRestoredData: {
|
||||
/**
|
||||
* Id
|
||||
* @description A non-empty string
|
||||
*/
|
||||
id: string;
|
||||
};
|
||||
/** UserTranscriptStatusData */
|
||||
UserTranscriptStatusData: {
|
||||
/**
|
||||
@@ -2446,6 +2494,15 @@ export interface components {
|
||||
event: "TRANSCRIPT_FINAL_TITLE";
|
||||
data: components["schemas"]["UserTranscriptFinalTitleData"];
|
||||
};
|
||||
/** UserWsTranscriptRestored */
|
||||
UserWsTranscriptRestored: {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
event: "TRANSCRIPT_RESTORED";
|
||||
data: components["schemas"]["UserTranscriptRestoredData"];
|
||||
};
|
||||
/** UserWsTranscriptStatus */
|
||||
UserWsTranscriptStatus: {
|
||||
/**
|
||||
@@ -3293,6 +3350,7 @@ export interface operations {
|
||||
from?: string | null;
|
||||
/** @description Filter transcripts created on or before this datetime (ISO 8601 with timezone) */
|
||||
to?: string | null;
|
||||
include_deleted?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -3427,6 +3485,68 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
v1_transcript_restore: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
transcript_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DeletionStatus"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
v1_transcript_destroy: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
transcript_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DeletionStatus"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
v1_transcript_get_topics: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3995,9 +4115,7 @@ export interface operations {
|
||||
};
|
||||
v1_transcript_get_video_url: {
|
||||
parameters: {
|
||||
query?: {
|
||||
token?: string | null;
|
||||
};
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
transcript_id: string;
|
||||
@@ -4254,6 +4372,7 @@ export interface operations {
|
||||
"application/json":
|
||||
| components["schemas"]["UserWsTranscriptCreated"]
|
||||
| components["schemas"]["UserWsTranscriptDeleted"]
|
||||
| components["schemas"]["UserWsTranscriptRestored"]
|
||||
| components["schemas"]["UserWsTranscriptStatus"]
|
||||
| components["schemas"]["UserWsTranscriptFinalTitle"]
|
||||
| components["schemas"]["UserWsTranscriptDuration"];
|
||||
|
||||
Reference in New Issue
Block a user