Case management UI (#21299)

* Refactor export cards to match existing cards in other UI pages

* Show cases separately from exports

* Add proper filtering and display of cases

* Add ability to edit and select cases for exports

* Cleanup typing

* Hide if no unassigned

* Cleanup hiding logic

* fix scrolling

* Improve layout
This commit is contained in:
Nicolas Mowen 2025-12-15 13:10:50 -07:00 committed by GitHub
parent a08c044144
commit 08311a6ee2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 705 additions and 148 deletions

View File

@ -2,6 +2,10 @@
"documentTitle": "Export - Frigate", "documentTitle": "Export - Frigate",
"search": "Search", "search": "Search",
"noExports": "No exports found", "noExports": "No exports found",
"headings": {
"cases": "Cases",
"uncategorizedExports": "Uncategorized Exports"
},
"deleteExport": "Delete Export", "deleteExport": "Delete Export",
"deleteExport.desc": "Are you sure you want to delete {{exportName}}?", "deleteExport.desc": "Are you sure you want to delete {{exportName}}?",
"editExport": { "editExport": {
@ -13,11 +17,21 @@
"shareExport": "Share export", "shareExport": "Share export",
"downloadVideo": "Download video", "downloadVideo": "Download video",
"editName": "Edit name", "editName": "Edit name",
"deleteExport": "Delete export" "deleteExport": "Delete export",
"assignToCase": "Add to case"
}, },
"toast": { "toast": {
"error": { "error": {
"renameExportFailed": "Failed to rename export: {{errorMessage}}" "renameExportFailed": "Failed to rename export: {{errorMessage}}",
"assignCaseFailed": "Failed to update case assignment: {{errorMessage}}"
} }
},
"caseDialog": {
"title": "Add to case",
"description": "Choose an existing case or create a new one.",
"selectLabel": "Case",
"newCaseOption": "Create new case",
"nameLabel": "Case name",
"descriptionLabel": "Description"
} }
} }

View File

@ -1,9 +1,8 @@
import ActivityIndicator from "../indicators/activity-indicator"; import ActivityIndicator from "../indicators/activity-indicator";
import { LuTrash } from "react-icons/lu";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { isDesktop, isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { FaDownload, FaPlay, FaShareAlt } from "react-icons/fa"; import { FiMoreVertical } from "react-icons/fi";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import { import {
Dialog, Dialog,
@ -14,35 +13,62 @@ import {
} from "../ui/dialog"; } from "../ui/dialog";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { DeleteClipType, Export } from "@/types/export"; import { DeleteClipType, Export, ExportCase } from "@/types/export";
import { MdEditSquare } from "react-icons/md";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { shareOrCopy } from "@/utils/browserUtil"; import { shareOrCopy } from "@/utils/browserUtil";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay"; import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
import BlurredIconButton from "../button/BlurredIconButton"; import BlurredIconButton from "../button/BlurredIconButton";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { useIsAdmin } from "@/hooks/use-is-admin"; import { useIsAdmin } from "@/hooks/use-is-admin";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { FaFolder } from "react-icons/fa";
type ExportProps = { type CaseCardProps = {
className: string;
exportCase: ExportCase;
onSelect: () => void;
};
export function CaseCard({ className, exportCase, onSelect }: CaseCardProps) {
return (
<div
className={cn(
"relative flex aspect-video size-full cursor-pointer items-center justify-center rounded-lg bg-secondary md:rounded-2xl",
className,
)}
onClick={() => onSelect()}
>
<div className="absolute bottom-2 left-2 flex items-center justify-start gap-2">
<FaFolder />
<div className="capitalize">{exportCase.name}</div>
</div>
</div>
);
}
type ExportCardProps = {
className: string; className: string;
exportedRecording: Export; exportedRecording: Export;
onSelect: (selected: Export) => void; onSelect: (selected: Export) => void;
onRename: (original: string, update: string) => void; onRename: (original: string, update: string) => void;
onDelete: ({ file, exportName }: DeleteClipType) => void; onDelete: ({ file, exportName }: DeleteClipType) => void;
onAssignToCase?: (selected: Export) => void;
}; };
export function ExportCard({
export default function ExportCard({
className, className,
exportedRecording, exportedRecording,
onSelect, onSelect,
onRename, onRename,
onDelete, onDelete,
}: ExportProps) { onAssignToCase,
}: ExportCardProps) {
const { t } = useTranslation(["views/exports"]); const { t } = useTranslation(["views/exports"]);
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const [hovered, setHovered] = useState(false);
const [loading, setLoading] = useState( const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0, exportedRecording.thumb_path.length > 0,
); );
@ -136,12 +162,14 @@ export default function ExportCard({
<div <div
className={cn( className={cn(
"relative flex aspect-video items-center justify-center rounded-lg bg-black md:rounded-2xl", "relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
className, className,
)} )}
onMouseEnter={isDesktop ? () => setHovered(true) : undefined} onClick={() => {
onMouseLeave={isDesktop ? () => setHovered(false) : undefined} if (!exportedRecording.in_progress) {
onClick={isDesktop ? undefined : () => setHovered(!hovered)} onSelect(exportedRecording);
}
}}
> >
{exportedRecording.in_progress ? ( {exportedRecording.in_progress ? (
<ActivityIndicator /> <ActivityIndicator />
@ -158,95 +186,88 @@ export default function ExportCard({
)} )}
</> </>
)} )}
{hovered && ( {!exportedRecording.in_progress && (
<> <div className="absolute bottom-2 right-3 z-40">
<div className="absolute inset-0 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" /> <DropdownMenu modal={false}>
<div className="absolute right-3 top-2"> <DropdownMenuTrigger>
<div className="flex items-center justify-center gap-4"> <BlurredIconButton
{!exportedRecording.in_progress && ( aria-label={t("tooltip.editName")}
<Tooltip> onClick={(e) => e.stopPropagation()}
<TooltipTrigger asChild> >
<BlurredIconButton <FiMoreVertical className="size-5" />
onClick={() => </BlurredIconButton>
shareOrCopy( </DropdownMenuTrigger>
`${baseUrl}export?id=${exportedRecording.id}`, <DropdownMenuContent align="end">
exportedRecording.name.replaceAll("_", " "), <DropdownMenuItem
) className="cursor-pointer"
} aria-label={t("tooltip.shareExport")}
> onClick={(e) => {
<FaShareAlt className="size-4" /> e.stopPropagation();
</BlurredIconButton> shareOrCopy(
</TooltipTrigger> `${baseUrl}export?id=${exportedRecording.id}`,
<TooltipContent>{t("tooltip.shareExport")}</TooltipContent> exportedRecording.name.replaceAll("_", " "),
</Tooltip> );
)} }}
{!exportedRecording.in_progress && ( >
{t("tooltip.shareExport")}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.downloadVideo")}
>
<a <a
download download
href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`} href={`${baseUrl}${exportedRecording.video_path.replace("/media/frigate/", "")}`}
onClick={(e) => e.stopPropagation()}
> >
<Tooltip> {t("tooltip.downloadVideo")}
<TooltipTrigger asChild>
<BlurredIconButton>
<FaDownload className="size-4" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>
{t("tooltip.downloadVideo")}
</TooltipContent>
</Tooltip>
</a> </a>
)} </DropdownMenuItem>
{isAdmin && !exportedRecording.in_progress && ( {isAdmin && onAssignToCase && (
<Tooltip> <DropdownMenuItem
<TooltipTrigger asChild> className="cursor-pointer"
<BlurredIconButton aria-label={t("tooltip.assignToCase")}
onClick={() => onClick={(e) => {
setEditName({ e.stopPropagation();
original: exportedRecording.name, onAssignToCase(exportedRecording);
update: undefined, }}
}) >
} {t("tooltip.assignToCase")}
> </DropdownMenuItem>
<MdEditSquare className="size-4" />
</BlurredIconButton>
</TooltipTrigger>
<TooltipContent>{t("tooltip.editName")}</TooltipContent>
</Tooltip>
)} )}
{isAdmin && ( {isAdmin && (
<Tooltip> <DropdownMenuItem
<TooltipTrigger asChild> className="cursor-pointer"
<BlurredIconButton aria-label={t("tooltip.editName")}
onClick={() => onClick={(e) => {
onDelete({ e.stopPropagation();
file: exportedRecording.id, setEditName({
exportName: exportedRecording.name, original: exportedRecording.name,
}) update: undefined,
} });
> }}
<LuTrash className="size-4 fill-destructive text-destructive hover:text-white" /> >
</BlurredIconButton> {t("tooltip.editName")}
</TooltipTrigger> </DropdownMenuItem>
<TooltipContent>{t("tooltip.deleteExport")}</TooltipContent>
</Tooltip>
)} )}
</div> {isAdmin && (
</div> <DropdownMenuItem
className="cursor-pointer"
{!exportedRecording.in_progress && ( aria-label={t("tooltip.deleteExport")}
<Button onClick={(e) => {
className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 cursor-pointer text-white hover:bg-transparent hover:text-white" e.stopPropagation();
aria-label={t("button.play", { ns: "common" })} onDelete({
variant="ghost" file: exportedRecording.id,
onClick={() => { exportName: exportedRecording.name,
onSelect(exportedRecording); });
}} }}
> >
<FaPlay /> {t("tooltip.deleteExport")}
</Button> </DropdownMenuItem>
)} )}
</> </DropdownMenuContent>
</DropdownMenu>
</div>
)} )}
{loading && ( {loading && (
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" /> <Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />

View File

@ -0,0 +1,166 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
type Option = {
value: string;
label: string;
};
type OptionAndInputDialogProps = {
open: boolean;
title: string;
description?: string;
options: Option[];
newValueKey: string;
initialValue?: string;
nameLabel: string;
descriptionLabel: string;
setOpen: (open: boolean) => void;
onSave: (value: string) => void;
onCreateNew: (name: string, description: string) => void;
};
export default function OptionAndInputDialog({
open,
title,
description,
options,
newValueKey,
initialValue,
nameLabel,
descriptionLabel,
setOpen,
onSave,
onCreateNew,
}: OptionAndInputDialogProps) {
const { t } = useTranslation("common");
const firstOption = useMemo(() => options[0]?.value, [options]);
const [selectedValue, setSelectedValue] = useState<string | undefined>(
initialValue ?? firstOption,
);
const [name, setName] = useState("");
const [descriptionValue, setDescriptionValue] = useState("");
useEffect(() => {
if (open) {
setSelectedValue(initialValue ?? firstOption);
setName("");
setDescriptionValue("");
}
}, [open, initialValue, firstOption]);
const isNew = selectedValue === newValueKey;
const disableSave = !selectedValue || (isNew && name.trim().length === 0);
const handleSave = () => {
if (!selectedValue) {
return;
}
const trimmedName = name.trim();
const trimmedDescription = descriptionValue.trim();
if (isNew) {
onCreateNew(trimmedName, trimmedDescription);
} else {
onSave(selectedValue);
}
setOpen(false);
};
return (
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
<DialogContent
className={cn("space-y-4", isMobile && "px-4")}
onOpenAutoFocus={(e) => {
if (isMobile) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="space-y-2">
<Select
value={selectedValue}
onValueChange={(val) => setSelectedValue(val)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isNew && (
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium text-secondary-foreground">
{nameLabel}
</label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-secondary-foreground">
{descriptionLabel}
</label>
<Input
value={descriptionValue}
onChange={(e) => setDescriptionValue(e.target.value)}
/>
</div>
</div>
)}
<DialogFooter className={cn("pt-2", isMobile && "gap-2")}>
<Button
type="button"
variant="outline"
onClick={() => {
setOpen(false);
}}
>
{t("button.cancel")}
</Button>
<Button
type="button"
variant="select"
disabled={disableSave}
onClick={handleSave}
>
{t("button.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,5 +1,5 @@
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import ExportCard from "@/components/card/ExportCard"; import { CaseCard, ExportCard } from "@/components/card/ExportCard";
import { import {
AlertDialog, AlertDialog,
AlertDialogCancel, AlertDialogCancel,
@ -11,15 +11,24 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import Heading from "@/components/ui/heading";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DeleteClipType, Export } from "@/types/export"; import { DeleteClipType, Export, ExportCase } from "@/types/export";
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -29,32 +38,37 @@ import useSWR from "swr";
function Exports() { function Exports() {
const { t } = useTranslation(["views/exports"]); const { t } = useTranslation(["views/exports"]);
const { data: exports, mutate } = useSWR<Export[]>("exports");
useEffect(() => { useEffect(() => {
document.title = t("documentTitle"); document.title = t("documentTitle");
}, [t]); }, [t]);
// Data
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases");
const { data: rawExports, mutate: updateExports } =
useSWR<Export[]>("exports");
const exports = useMemo<Export[]>(
() => (rawExports ?? []).filter((e) => !e.export_case),
[rawExports],
);
const mutate = useCallback(() => {
updateExports();
updateCases();
}, [updateExports, updateCases]);
// Search // Search
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const filteredExports = useMemo(() => {
if (!search || !exports) {
return exports;
}
return exports.filter((exp) =>
exp.name
.toLowerCase()
.replaceAll("_", " ")
.includes(search.toLowerCase()),
);
}, [exports, search]);
// Viewing // Viewing
const [selected, setSelected] = useState<Export>(); const [selected, setSelected] = useState<Export>();
const [selectedCaseId, setSelectedCaseId] = useOverlayState<
string | undefined
>("caseId", undefined);
const [selectedAspect, setSelectedAspect] = useState(0.0); const [selectedAspect, setSelectedAspect] = useState(0.0);
useSearchEffect("id", (id) => { useSearchEffect("id", (id) => {
@ -66,9 +80,25 @@ function Exports() {
return true; return true;
}); });
// Deleting useSearchEffect("caseId", (caseId: string) => {
if (!cases) {
return false;
}
const exists = cases.some((c) => c.id === caseId);
if (!exists) {
return false;
}
setSelectedCaseId(caseId);
return true;
});
// Modifying
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>(); const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
const onHandleDelete = useCallback(() => { const onHandleDelete = useCallback(() => {
if (!deleteClip) { if (!deleteClip) {
@ -83,8 +113,6 @@ function Exports() {
}); });
}, [deleteClip, mutate]); }, [deleteClip, mutate]);
// Renaming
const onHandleRename = useCallback( const onHandleRename = useCallback(
(id: string, update: string) => { (id: string, update: string) => {
axios axios
@ -107,7 +135,7 @@ function Exports() {
}); });
}); });
}, },
[mutate, t], [mutate, setDeleteClip, t],
); );
// Keyboard Listener // Keyboard Listener
@ -115,10 +143,27 @@ function Exports() {
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
useKeyboardListener([], undefined, contentRef); useKeyboardListener([], undefined, contentRef);
const selectedCase = useMemo(
() => cases?.find((c) => c.id === selectedCaseId),
[cases, selectedCaseId],
);
const resetCaseDialog = useCallback(() => {
setExportToAssign(undefined);
}, []);
return ( return (
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2"> <div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
<Toaster closeButton={true} /> <Toaster closeButton={true} />
<CaseAssignmentDialog
exportToAssign={exportToAssign}
cases={cases}
selectedCaseId={selectedCaseId}
onClose={resetCaseDialog}
mutate={mutate}
/>
<AlertDialog <AlertDialog
open={deleteClip != undefined} open={deleteClip != undefined}
onOpenChange={() => setDeleteClip(undefined)} onOpenChange={() => setDeleteClip(undefined)}
@ -187,7 +232,7 @@ function Exports() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{exports && ( {(exports?.length || cases?.length) && (
<div className="flex w-full items-center justify-center p-2"> <div className="flex w-full items-center justify-center p-2">
<Input <Input
className="text-md w-full bg-muted md:w-1/3" className="text-md w-full bg-muted md:w-1/3"
@ -198,36 +243,338 @@ function Exports() {
</div> </div>
)} )}
<div className="w-full overflow-hidden"> {selectedCase ? (
{exports && filteredExports && filteredExports.length > 0 ? ( <CaseView
<div contentRef={contentRef}
ref={contentRef} selectedCase={selectedCase}
className="scrollbar-container grid size-full gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" exports={rawExports}
> search={search}
{Object.values(exports).map((item) => ( setSelected={setSelected}
<ExportCard renameClip={onHandleRename}
key={item.name} setDeleteClip={setDeleteClip}
className={ onAssignToCase={setExportToAssign}
search == "" || filteredExports.includes(item) ? "" : "hidden" />
} ) : (
exportedRecording={item} <AllExportsView
onSelect={setSelected} contentRef={contentRef}
onRename={onHandleRename} search={search}
onDelete={({ file, exportName }) => cases={cases}
setDeleteClip({ file, exportName }) exports={exports}
} setSelectedCaseId={setSelectedCaseId}
/> setSelected={setSelected}
))} renameClip={onHandleRename}
</div> setDeleteClip={setDeleteClip}
) : ( onAssignToCase={setExportToAssign}
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center"> />
<LuFolderX className="size-16" /> )}
{t("noExports")} </div>
</div> );
)} }
type AllExportsViewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
search: string;
cases?: ExportCase[];
exports: Export[];
setSelectedCaseId: (id: string) => void;
setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void;
onAssignToCase: (e: Export) => void;
};
function AllExportsView({
contentRef,
search,
cases,
exports,
setSelectedCaseId,
setSelected,
renameClip,
setDeleteClip,
onAssignToCase,
}: AllExportsViewProps) {
const { t } = useTranslation(["views/exports"]);
// Filter
const filteredCases = useMemo(() => {
if (!search || !cases) {
return cases || [];
}
return cases.filter(
(caseItem) =>
caseItem.name.toLowerCase().includes(search.toLowerCase()) ||
(caseItem.description &&
caseItem.description.toLowerCase().includes(search.toLowerCase())),
);
}, [search, cases]);
const filteredExports = useMemo<Export[]>(() => {
if (!search) {
return exports;
}
return exports.filter((exp) =>
exp.name
.toLowerCase()
.replaceAll("_", " ")
.includes(search.toLowerCase()),
);
}, [exports, search]);
return (
<div className="w-full overflow-hidden">
{filteredCases?.length || filteredExports.length ? (
<div
ref={contentRef}
className="scrollbar-container flex size-full flex-col gap-4 overflow-y-auto"
>
{filteredCases.length > 0 && (
<div className="space-y-2">
<Heading as="h4">{t("headings.cases")}</Heading>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{cases?.map((item) => (
<CaseCard
key={item.name}
className={
search == "" || filteredCases?.includes(item)
? ""
: "hidden"
}
exportCase={item}
onSelect={() => {
setSelectedCaseId(item.id);
}}
/>
))}
</div>
</div>
)}
{filteredExports.length > 0 && (
<div className="space-y-4">
<Heading as="h4">{t("headings.uncategorizedExports")}</Heading>
<div
ref={contentRef}
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{exports.map((item) => (
<ExportCard
key={item.name}
className={
search == "" || filteredExports.includes(item)
? ""
: "hidden"
}
exportedRecording={item}
onSelect={setSelected}
onRename={renameClip}
onDelete={({ file, exportName }) =>
setDeleteClip({ file, exportName })
}
onAssignToCase={onAssignToCase}
/>
))}
</div>
</div>
)}
</div>
) : (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderX className="size-16" />
{t("noExports")}
</div>
)}
</div>
);
}
type CaseViewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
selectedCase: ExportCase;
exports?: Export[];
search: string;
setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void;
onAssignToCase: (e: Export) => void;
};
function CaseView({
contentRef,
selectedCase,
exports,
search,
setSelected,
renameClip,
setDeleteClip,
onAssignToCase,
}: CaseViewProps) {
const filteredExports = useMemo<Export[]>(() => {
const caseExports = (exports || []).filter(
(e) => e.export_case == selectedCase.id,
);
if (!search) {
return caseExports;
}
return caseExports.filter((exp) =>
exp.name
.toLowerCase()
.replaceAll("_", " ")
.includes(search.toLowerCase()),
);
}, [selectedCase, exports, search]);
return (
<div className="flex size-full flex-col gap-8">
<div className="flex flex-col gap-1">
<Heading className="capitalize" as="h2">
{selectedCase.name}
</Heading>
<div className="text-secondary-foreground">
{selectedCase.description}
</div>
</div>
<div
ref={contentRef}
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{exports?.map((item) => (
<ExportCard
key={item.name}
className={filteredExports.includes(item) ? "" : "hidden"}
exportedRecording={item}
onSelect={setSelected}
onRename={renameClip}
onDelete={({ file, exportName }) =>
setDeleteClip({ file, exportName })
}
onAssignToCase={onAssignToCase}
/>
))}
</div> </div>
</div> </div>
); );
} }
type CaseAssignmentDialogProps = {
exportToAssign?: Export;
cases?: ExportCase[];
selectedCaseId?: string;
onClose: () => void;
mutate: () => void;
};
function CaseAssignmentDialog({
exportToAssign,
cases,
selectedCaseId,
onClose,
mutate,
}: CaseAssignmentDialogProps) {
const { t } = useTranslation(["views/exports"]);
const caseOptions = useMemo(
() => [
...(cases ?? [])
.map((c) => ({
value: c.id,
label: c.name,
}))
.sort((cA, cB) => cA.label.localeCompare(cB.label)),
{
value: "new",
label: t("caseDialog.newCaseOption"),
},
],
[cases, t],
);
const handleSave = useCallback(
async (caseId: string) => {
if (!exportToAssign) return;
try {
await axios.patch(`export/${exportToAssign.id}/case`, {
export_case_id: caseId,
});
mutate();
onClose();
} catch (error: unknown) {
const apiError = error as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
apiError.response?.data?.message ||
apiError.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
position: "top-center",
});
}
},
[exportToAssign, mutate, onClose, t],
);
const handleCreateNew = useCallback(
async (name: string, description: string) => {
if (!exportToAssign) return;
try {
const createResp = await axios.post("cases", {
name,
description,
});
const newCaseId: string | undefined = createResp.data?.id;
if (newCaseId) {
await axios.patch(`export/${exportToAssign.id}/case`, {
export_case_id: newCaseId,
});
}
mutate();
onClose();
} catch (error: unknown) {
const apiError = error as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
apiError.response?.data?.message ||
apiError.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
position: "top-center",
});
}
},
[exportToAssign, mutate, onClose, t],
);
if (!exportToAssign) {
return null;
}
return (
<OptionAndInputDialog
open={!!exportToAssign}
title={t("caseDialog.title")}
description={t("caseDialog.description")}
setOpen={(open) => {
if (!open) {
onClose();
}
}}
options={caseOptions}
nameLabel={t("caseDialog.nameLabel")}
descriptionLabel={t("caseDialog.descriptionLabel")}
initialValue={selectedCaseId}
newValueKey="new"
onSave={handleSave}
onCreateNew={handleCreateNew}
/>
);
}
export default Exports; export default Exports;

View File

@ -6,6 +6,15 @@ export type Export = {
video_path: string; video_path: string;
thumb_path: string; thumb_path: string;
in_progress: boolean; in_progress: boolean;
export_case?: string;
};
export type ExportCase = {
id: string;
name: string;
description: string;
created_at: number;
updated_at: number;
}; };
export type DeleteClipType = { export type DeleteClipType = {