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:
Juan Diego García
2026-03-31 13:15:52 -05:00
committed by GitHub
parent cc9c5cd4a5
commit ec8b49738e
20 changed files with 1351 additions and 94 deletions

View File

@@ -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>

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>