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
parent 85feb4edcb
commit 004bb7d80d
5 changed files with 776 additions and 154 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,64 +11,144 @@ 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 { useSearchEffect } from "@/hooks/use-overlay-state";
import { useHistoryBack } from "@/hooks/use-history-back";
import { useApiFilterArgs } from "@/hooks/use-api-filter";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DeleteClipType, Export } from "@/types/export"; import {
DeleteClipType,
Export,
ExportCase,
ExportFilter,
} 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 {
import { isMobile } from "react-device-detect"; MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isMobile, isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuFolderX } from "react-icons/lu"; import { LuFolderX } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
import ExportFilterGroup from "@/components/filter/ExportFilterGroup";
// always parse these as string arrays
const EXPORT_FILTER_ARRAY_KEYS = ["cameras"];
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]);
// Filters
const [exportFilter, setExportFilter, exportSearchParams] =
useApiFilterArgs<ExportFilter>(EXPORT_FILTER_ARRAY_KEYS);
// Data
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases");
const { data: rawExports, mutate: updateExports } = useSWR<Export[]>(
exportSearchParams && Object.keys(exportSearchParams).length > 0
? ["exports", exportSearchParams]
: "exports",
);
const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => {
const grouped: { [caseId: string]: Export[] } = {};
(rawExports ?? []).forEach((exp) => {
const caseId = exp.export_case || "none";
if (!grouped[caseId]) {
grouped[caseId] = [];
}
grouped[caseId].push(exp);
});
return grouped;
}, [rawExports]);
const filteredCases = useMemo<ExportCase[]>(() => {
if (!cases) {
return [];
}
return cases.filter((caseItem) => {
const caseExports = exportsByCase[caseItem.id];
return caseExports?.length;
});
}, [cases, exportsByCase]);
const exports = useMemo<Export[]>(
() => exportsByCase["none"] || [],
[exportsByCase],
);
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] = useState<string | undefined>(
undefined,
);
const [selectedAspect, setSelectedAspect] = useState(0.0); const [selectedAspect, setSelectedAspect] = useState(0.0);
// Handle browser back button to deselect case before navigating away
useHistoryBack({
enabled: true,
open: selectedCaseId !== undefined,
onClose: () => setSelectedCaseId(undefined),
});
useSearchEffect("id", (id) => { useSearchEffect("id", (id) => {
if (!exports) { if (!rawExports) {
return false; return false;
} }
setSelected(exports.find((exp) => exp.id == id)); setSelected(rawExports.find((exp) => exp.id == id));
return true; return true;
}); });
// Deleting useSearchEffect("caseId", (caseId: string) => {
if (!filteredCases) {
return false;
}
const exists = filteredCases.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 +163,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 +185,7 @@ function Exports() {
}); });
}); });
}, },
[mutate, t], [mutate, setDeleteClip, t],
); );
// Keyboard Listener // Keyboard Listener
@ -115,10 +193,27 @@ function Exports() {
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
useKeyboardListener([], undefined, contentRef); useKeyboardListener([], undefined, contentRef);
const selectedCase = useMemo(
() => filteredCases?.find((c) => c.id === selectedCaseId),
[filteredCases, 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,47 +282,364 @@ function Exports() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{exports && ( <div
<div className="flex w-full items-center justify-center p-2"> className={cn(
"flex w-full flex-col items-start space-y-2 pr-2 md:mb-2 lg:relative lg:h-10 lg:flex-row lg:items-center lg:space-y-0",
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
)}
>
<div className="w-full">
<Input <Input
className="text-md w-full bg-muted md:w-1/3" className="text-md w-full bg-muted md:w-1/2"
placeholder={t("search")} placeholder={t("search")}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</div> </div>
)} <ExportFilterGroup
className="w-full justify-between md:justify-start lg:justify-end"
filter={exportFilter}
filters={["cameras"]}
onUpdateFilter={setExportFilter}
/>
</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={exportsByCase[selectedCase.id] || []}
> search={search}
{Object.values(exports).map((item) => ( setSelected={setSelected}
<ExportCard renameClip={onHandleRename}
key={item.id} 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={filteredCases}
setDeleteClip({ file, exportName }) exports={exports}
} exportsByCase={exportsByCase}
/> setSelectedCaseId={setSelectedCaseId}
))} setSelected={setSelected}
</div> renameClip={onHandleRename}
) : exports !== undefined ? ( setDeleteClip={setDeleteClip}
<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"> onAssignToCase={setExportToAssign}
<LuFolderX className="size-16" /> />
{t("noExports")} )}
</div> </div>
) : null} );
}
type AllExportsViewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
search: string;
cases?: ExportCase[];
exports: Export[];
exportsByCase: { [caseId: string]: 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,
exportsByCase,
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.id}
className={
search == "" || filteredCases?.includes(item)
? ""
: "hidden"
}
exportCase={item}
exports={exportsByCase[item.id] || []}
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 overflow-hidden">
<div className="flex shrink-0 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 min-h-0 flex-1 content-start 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 = {