import { baseUrl } from "@/api/baseUrl"; import { ActiveExportJobCard, CaseCard, ExportCard, } from "@/components/card/ExportCard"; import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogTitle, } from "@/components/ui/dialog"; import Heading from "@/components/ui/heading"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Toaster } from "@/components/ui/sonner"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; 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 { useFormattedTimestamp, useTimeFormat } from "@/hooks/use-date-utils"; import { DeleteClipType, Export, ExportCase, ExportFilter, ExportJob, } from "@/types/export"; import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog"; import axios from "axios"; import { FrigateConfig } from "@/types/frigateConfig"; import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { isMobile, isMobileOnly } from "react-device-detect"; import { useTranslation } from "react-i18next"; import { IoMdArrowRoundBack } from "react-icons/io"; import { LuFolderPlus, LuFolderX, LuPencil, LuPlus, LuTrash2, } from "react-icons/lu"; import { toast } from "sonner"; import useSWR from "swr"; import ExportActionGroup from "@/components/filter/ExportActionGroup"; import ExportFilterGroup from "@/components/filter/ExportFilterGroup"; import { useIsAdmin } from "@/hooks/use-is-admin"; // always parse these as string arrays const EXPORT_FILTER_ARRAY_KEYS = ["cameras"]; function Exports() { const { t } = useTranslation(["views/exports"]); const isAdmin = useIsAdmin(); useEffect(() => { document.title = t("documentTitle"); }, [t]); // Filters const [exportFilter, setExportFilter, exportSearchParams] = useApiFilterArgs(EXPORT_FILTER_ARRAY_KEYS); // Data const { data: cases, mutate: updateCases } = useSWR("cases"); const { data: activeExportJobs } = useSWR("jobs/export", { refreshInterval: (latestJobs) => ((latestJobs ?? []).length > 0 ? 2000 : 0), }); // Keep polling exports while there are queued/running jobs OR while any // existing export is still marked in_progress. Without the second clause, // a stale in_progress=true snapshot can stick if the activeExportJobs poll // clears before the rawExports poll fires — SWR cancels the pending // rawExports refresh and the UI freezes on spinners until a manual reload. const { data: rawExports, mutate: updateExports } = useSWR( exportSearchParams && Object.keys(exportSearchParams).length > 0 ? ["exports", exportSearchParams] : "exports", { refreshInterval: (latestExports) => { if ((activeExportJobs?.length ?? 0) > 0) { return 2000; } if ((latestExports ?? []).some((exp) => exp.in_progress)) { return 2000; } return 0; }, }, ); const visibleActiveJobs = useMemo(() => { const existingExportIds = new Set((rawExports ?? []).map((exp) => exp.id)); const filteredCameras = exportFilter?.cameras; return (activeExportJobs ?? []).filter((job) => { if (existingExportIds.has(job.id)) { return false; } if (filteredCameras && filteredCameras.length > 0) { return filteredCameras.includes(job.camera); } return true; }); }, [activeExportJobs, exportFilter?.cameras, rawExports]); const activeJobsByCase = useMemo<{ [caseId: string]: ExportJob[] }>(() => { const grouped: { [caseId: string]: ExportJob[] } = {}; visibleActiveJobs.forEach((job) => { const caseId = job.export_case_id ?? "none"; if (!grouped[caseId]) { grouped[caseId] = []; } grouped[caseId].push(job); }); return grouped; }, [visibleActiveJobs]); const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => { const grouped: { [caseId: string]: Export[] } = {}; (rawExports ?? []).forEach((exp) => { const caseId = exp.export_case ?? exp.export_case_id ?? "none"; if (!grouped[caseId]) { grouped[caseId] = []; } grouped[caseId].push(exp); }); return grouped; }, [rawExports]); const filteredCases = useMemo(() => { if (!cases) return []; const hasCameraFilter = exportFilter?.cameras && exportFilter.cameras.length > 0; if (!hasCameraFilter) return cases; // When a camera filter is active, hide cases that have zero exports // and zero active jobs matching the filter — they're just noise. return cases.filter( (c) => (exportsByCase[c.id]?.length ?? 0) > 0 || (activeJobsByCase[c.id]?.length ?? 0) > 0, ); }, [activeJobsByCase, cases, exportFilter?.cameras, exportsByCase]); const exports = useMemo( () => exportsByCase["none"] || [], [exportsByCase], ); const mutate = useCallback(() => { updateExports(); updateCases(); }, [updateExports, updateCases]); // Search const [search, setSearch] = useState(""); // Viewing const [selected, setSelected] = useState(); const [selectedCaseId, setSelectedCaseId] = useState( undefined, ); 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) => { if (!rawExports) { return false; } setSelected(rawExports.find((exp) => exp.id == id)); return true; }); useSearchEffect("caseId", (caseId: string) => { if (!filteredCases) { return false; } const exists = filteredCases.some((c) => c.id === caseId); if (!exists) { return false; } setSelectedCaseId(caseId); return true; }); // Bulk selection const [selectedExports, setSelectedExports] = useState([]); const selectionMode = selectedExports.length > 0; // Clear selection when switching views useEffect(() => { setSelectedExports([]); }, [selectedCaseId]); const onSelectExport = useCallback( (exportItem: Export) => { const index = selectedExports.findIndex((e) => e.id === exportItem.id); if (index !== -1) { if (selectedExports.length === 1) { setSelectedExports([]); } else { setSelectedExports([ ...selectedExports.slice(0, index), ...selectedExports.slice(index + 1), ]); } } else { setSelectedExports([...selectedExports, exportItem]); } }, [selectedExports], ); const onSelectAllExports = useCallback(() => { const currentExports = selectedCaseId ? exportsByCase[selectedCaseId] || [] : exports; const visibleExports = currentExports.filter((e) => { if (e.in_progress) return false; if (!search) return true; return e.name .toLowerCase() .replaceAll("_", " ") .includes(search.toLowerCase()); }); if (selectedExports.length < visibleExports.length) { setSelectedExports(visibleExports); } else { setSelectedExports([]); } }, [selectedCaseId, exportsByCase, exports, search, selectedExports]); // Modifying const [deleteClip, setDeleteClip] = useState(); const [exportToAssign, setExportToAssign] = useState(); const [caseDialog, setCaseDialog] = useState< { mode: "create" | "edit"; exportCase?: ExportCase } | undefined >(); const [caseToDelete, setCaseToDelete] = useState(); const [deleteExportsWithCase, setDeleteExportsWithCase] = useState(false); const [caseForAddExport, setCaseForAddExport] = useState< ExportCase | undefined >(); const onHandleDelete = useCallback(() => { if (!deleteClip) { return; } axios .post("exports/delete", { ids: [deleteClip.file] }) .then((response) => { if (response.status == 200) { setDeleteClip(undefined); mutate(); } }); }, [deleteClip, mutate]); const onHandleRename = useCallback( (id: string, update: string) => { axios .patch(`export/${id}/rename`, { name: update, }) .then((response) => { if (response.status === 200) { setDeleteClip(undefined); mutate(); } }) .catch((error) => { const errorMessage = error.response?.data?.message || error.response?.data?.detail || "Unknown error"; toast.error(t("toast.error.renameExportFailed", { errorMessage }), { position: "top-center", }); }); }, [mutate, setDeleteClip, t], ); // Keyboard Listener const contentRef = useRef(null); useKeyboardListener( ["a", "Escape"], (key, modifiers) => { if (!modifiers.down) return true; switch (key) { case "a": if (modifiers.ctrl && !modifiers.repeat) { onSelectAllExports(); return true; } break; case "Escape": setSelectedExports([]); return true; } return false; }, contentRef, ); const selectedCase = useMemo( () => cases?.find((c) => c.id === selectedCaseId), [cases, selectedCaseId], ); const uncategorizedExports = useMemo( () => exportsByCase["none"] || [], [exportsByCase], ); const saveCase = useCallback( async ( payload: { name: string; description: string }, exportCaseId?: string, ) => { try { let savedCaseId = exportCaseId; if (exportCaseId) { await axios.patch(`cases/${exportCaseId}`, payload); } else { const response = await axios.post("cases", payload); savedCaseId = response.data.id; } if (savedCaseId) { setSelectedCaseId(savedCaseId); } mutate(); return true; } catch (error) { 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.caseSaveFailed", { errorMessage }), { position: "top-center", }); return false; } }, [mutate, t], ); const handleSaveCase = useCallback( async (payload: { name: string; description: string }) => { const didSave = await saveCase( payload, caseDialog?.mode === "edit" ? caseDialog.exportCase?.id : undefined, ); if (didSave) { setCaseDialog(undefined); } }, [caseDialog, saveCase], ); const handleDeleteCase = useCallback(async () => { if (!caseToDelete) { return; } try { await axios.delete(`cases/${caseToDelete.id}`, { params: deleteExportsWithCase ? { delete_exports: true } : undefined, }); if (selectedCaseId === caseToDelete.id) { setSelectedCaseId(undefined); } setCaseToDelete(undefined); setDeleteExportsWithCase(false); mutate(); } catch (error) { 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.caseDeleteFailed", { errorMessage }), { position: "top-center", }); } }, [caseToDelete, deleteExportsWithCase, mutate, selectedCaseId, t]); const handleRemoveExportFromCase = useCallback( async (exportedRecording: Export) => { try { await axios.post("exports/reassign", { ids: [exportedRecording.id], export_case_id: null, }); mutate(); } catch (error) { 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", }); } }, [mutate, t], ); const resetCaseDialog = useCallback(() => { setExportToAssign(undefined); }, []); return (
setCaseDialog(undefined)} onSave={handleSaveCase} /> setCaseForAddExport(undefined)} mutate={mutate} /> setDeleteClip(undefined)} > {t("deleteExport.label")} {t("deleteExport.desc", { exportName: deleteClip?.exportName })} {t("button.cancel", { ns: "common" })} { if (!open) { setCaseToDelete(undefined); setDeleteExportsWithCase(false); } }} > {t("deleteCase.label")} {t("deleteCase.desc", { caseName: caseToDelete?.name, })}{" "} {deleteExportsWithCase ? t("deleteCase.descDeleteExports") : t("deleteCase.descKeepExports")}
{t("button.cancel", { ns: "common" })}
{ if (!open) { setSelected(undefined); } }} > {selected?.name?.replaceAll("_", " ")}
{selectionMode ? ( ) : ( <>
{selectedCase && ( )} setSearch(e.target.value)} />
{!selectedCase && (
{isAdmin && ( )}
)} {selectedCase && (
{isAdmin && (
)}
)} )}
{selectedCase ? ( setCaseForAddExport(selectedCase)} /> ) : ( )}
); } type AllExportsViewProps = { contentRef: MutableRefObject; search: string; cases?: ExportCase[]; exports: Export[]; exportsByCase: { [caseId: string]: Export[] }; activeJobs: ExportJob[]; selectedExports: Export[]; selectionMode: boolean; onSelectExport: (e: Export) => void; 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, activeJobs, selectedExports, selectionMode, onSelectExport, 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(() => { if (!search) { return exports; } return exports.filter((exp) => exp.name .toLowerCase() .replaceAll("_", " ") .includes(search.toLowerCase()), ); }, [exports, search]); const filteredActiveJobs = useMemo(() => { if (!search) { return activeJobs; } return activeJobs.filter((job) => (job.name || job.camera) .toLowerCase() .replaceAll("_", " ") .includes(search.toLowerCase()), ); }, [activeJobs, search]); return (
{filteredCases?.length || filteredActiveJobs.length || filteredExports.length ? (
{filteredCases.length > 0 && (
{t("headings.cases")}
{filteredCases.map((item) => ( { setSelectedCaseId(item.id); }} /> ))}
)} {(filteredActiveJobs.length > 0 || filteredExports.length > 0) && (
{t("headings.uncategorizedExports")}
{filteredActiveJobs.map((job) => ( ))} {filteredExports.map((item) => ( e.id === item.id)} selectionMode={selectionMode} onContextSelect={onSelectExport} onSelect={setSelected} onRename={renameClip} onDelete={({ file, exportName }) => setDeleteClip({ file, exportName }) } onAssignToCase={onAssignToCase} /> ))}
)}
) : (
{t("noExports")}
)}
); } type CaseViewProps = { contentRef: MutableRefObject; selectedCase: ExportCase; exports?: Export[]; availableExports: Export[]; activeJobs: ExportJob[]; search: string; selectedExports: Export[]; selectionMode: boolean; onSelectExport: (e: Export) => void; setSelected: (e: Export) => void; renameClip: (id: string, update: string) => void; setDeleteClip: (d: DeleteClipType | undefined) => void; onAssignToCase: (e: Export) => void; onRemoveFromCase: (e: Export) => void; onAddExport: () => void; }; function CaseView({ contentRef, selectedCase, exports, availableExports, activeJobs, search, selectedExports, selectionMode, onSelectExport, setSelected, renameClip, setDeleteClip, onAssignToCase, onRemoveFromCase, onAddExport, }: CaseViewProps) { const { t } = useTranslation(["views/exports", "common"]); const { data: config } = useSWR("config"); const timeFormat = useTimeFormat(config); const createdAt = useFormattedTimestamp( selectedCase.created_at, t(`time.formattedTimestampMonthDayYear.${timeFormat}`, { ns: "common" }), config?.ui.timezone, ); const filteredExports = useMemo(() => { const caseExports = (exports || []).filter( (e) => (e.export_case ?? e.export_case_id) == selectedCase.id, ); if (!search) { return caseExports; } return caseExports.filter((exp) => exp.name .toLowerCase() .replaceAll("_", " ") .includes(search.toLowerCase()), ); }, [selectedCase, exports, search]); const filteredActiveJobs = useMemo(() => { const caseJobs = activeJobs.filter( (job) => job.export_case_id === selectedCase.id, ); if (!search) { return caseJobs; } return caseJobs.filter((job) => (job.name || job.camera) .toLowerCase() .replaceAll("_", " ") .includes(search.toLowerCase()), ); }, [activeJobs, search, selectedCase.id]); const cameraCount = useMemo( () => new Set(filteredExports.map((exp) => exp.camera)).size, [filteredExports], ); const [descriptionExpanded, setDescriptionExpanded] = useState(false); const descriptionRef = useRef(null); const [descriptionIsClamped, setDescriptionIsClamped] = useState(false); useEffect(() => { setDescriptionExpanded(false); }, [selectedCase.id]); useEffect(() => { const element = descriptionRef.current; if (!element) { setDescriptionIsClamped(false); return; } setDescriptionIsClamped(element.scrollHeight > element.clientHeight + 1); }, [selectedCase.description, descriptionExpanded]); return (
{selectedCase.name}
{t("caseView.createdAt", { value: createdAt })} {t("caseView.exportCount", { count: filteredExports.length })} {t("caseView.cameraCount", { count: cameraCount })}
{selectedCase.description && (
{selectedCase.description}
{(descriptionIsClamped || descriptionExpanded) && ( )}
)}
{filteredExports.length > 0 || filteredActiveJobs.length > 0 ? (
{filteredActiveJobs.map((job) => ( ))} {filteredExports.map((item) => ( e.id === item.id)} selectionMode={selectionMode} onContextSelect={onSelectExport} onSelect={setSelected} onRename={renameClip} onDelete={({ file, exportName }) => setDeleteClip({ file, exportName }) } onAssignToCase={onAssignToCase} onRemoveFromCase={onRemoveFromCase} /> ))}
) : (
{t("caseView.emptyTitle")}
{availableExports.length > 0 ? t("caseView.emptyDescription") : t("caseView.emptyDescriptionNoExports")}
{availableExports.length > 0 && ( )}
)}
); } type CaseEditorDialogProps = { caseDialog?: { mode: "create" | "edit"; exportCase?: ExportCase }; onClose: () => void; onSave: (payload: { name: string; description: string }) => Promise; }; function CaseEditorDialog({ caseDialog, onClose, onSave, }: CaseEditorDialogProps) { const { t } = useTranslation(["views/exports", "common"]); const [name, setName] = useState(""); const [description, setDescription] = useState(""); useEffect(() => { setName(caseDialog?.exportCase?.name || ""); setDescription(caseDialog?.exportCase?.description || ""); }, [caseDialog?.exportCase?.description, caseDialog?.exportCase?.name]); return ( !open && onClose()} > {caseDialog?.mode === "edit" ? t("caseEditor.editTitle") : t("caseEditor.createTitle")}
setName(event.target.value)} />