import { baseUrl } from "@/api/baseUrl"; import { useJobStatus } from "@/api/ws"; 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"); // The HTTP fetch hydrates the page on first paint and on focus. Once the // WebSocket is connected, the `job_state` topic delivers progress updates // in real time, so periodic polling here would only add noise. const { data: pollExportJobs, mutate: updateActiveJobs } = useSWR< ExportJob[] >("jobs/export", { refreshInterval: 0 }); const { payload: exportJobState } = useJobStatus<{ jobs: ExportJob[] }>( "export", ); const wsExportJobs = useMemo( () => exportJobState?.results?.jobs ?? [], [exportJobState], ); // Merge: a job present in the WS payload is authoritative (it has the // freshest progress); the SWR snapshot fills in jobs that haven't yet // arrived over the socket (e.g. before the first WS message after a // page load). Once we've seen at least one WS message, we trust the WS // payload as the complete active set. const hasWsState = exportJobState !== null; const activeExportJobs = useMemo(() => { if (hasWsState) { return wsExportJobs; } return pollExportJobs ?? []; }, [hasWsState, wsExportJobs, pollExportJobs]); // Keep polling exports while any existing export is still marked // in_progress so the UI flips from spinner to playable card without a // manual reload. Once active jobs disappear from the WS feed we also // mutate() below to fetch newly-completed exports immediately. const { data: rawExports, mutate: updateExports } = useSWR( exportSearchParams && Object.keys(exportSearchParams).length > 0 ? ["exports", exportSearchParams] : "exports", { refreshInterval: (latestExports) => { if ((latestExports ?? []).some((exp) => exp.in_progress)) { return 2000; } return 0; }, }, ); // When one or more active jobs disappear from the WS feed, refresh the // exports list so newly-finished items appear without waiting for focus- // based SWR revalidation. Clear the HTTP jobs snapshot once the live set is // empty so a stale poll result does not resurrect completed jobs. const previousActiveJobIdsRef = useRef>(new Set()); useEffect(() => { const previousIds = previousActiveJobIdsRef.current; const currentIds = new Set(activeExportJobs.map((job) => job.id)); const removedJob = Array.from(previousIds).some( (id) => !currentIds.has(id), ); if (removedJob) { updateExports(); updateCases(); } if (previousIds.size > 0 && currentIds.size === 0) { updateActiveJobs([], false); } previousActiveJobIdsRef.current = currentIds; }, [activeExportJobs, updateExports, updateCases, updateActiveJobs]); const visibleActiveJobs = useMemo(() => { const filteredCameras = exportFilter?.cameras; return (activeExportJobs ?? []).filter((job) => { if (filteredCameras && filteredCameras.length > 0) { return filteredCameras.includes(job.camera); } return true; }); }, [activeExportJobs, exportFilter?.cameras]); 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]); // The backend inserts the Export row with in_progress=True before the // FFmpeg encode kicks off, so the same id is briefly present in BOTH // rawExports and the active job list. The ActiveExportJobCard renders // step + percent; the ExportCard would render a binary spinner. To // avoid that downgrade, hide the rawExport entry while there's a // matching active job — once the job leaves the active list the // exports SWR refresh kicks in and the regular card takes over. const activeJobIds = useMemo>( () => new Set(visibleActiveJobs.map((job) => job.id)), [visibleActiveJobs], ); const visibleExports = useMemo( () => (rawExports ?? []).filter((exp) => !activeJobIds.has(exp.id)), [activeJobIds, rawExports], ); const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => { const grouped: { [caseId: string]: Export[] } = {}; visibleExports.forEach((exp) => { const caseId = exp.export_case ?? exp.export_case_id ?? "none"; if (!grouped[caseId]) { grouped[caseId] = []; } grouped[caseId].push(exp); }); return grouped; }, [visibleExports]); 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]); // Deletes one or more exports and keeps the UI in sync. SWR's default // mutate() keeps the stale list visible until the revalidation GET // returns, which can be seconds for large batches — long enough for // users to click on a card whose underlying file is already gone. // Strip the deleted ids from the cache up front, then fire the POST, // then revalidate to reconcile with server truth. const deleteExports = useCallback( async (ids: string[]): Promise => { const idSet = new Set(ids); const removeDeleted = (current: Export[] | undefined) => current ? current.filter((exp) => !idSet.has(exp.id)) : current; await updateExports(removeDeleted, { revalidate: false }); try { await axios.post("exports/delete", { ids }); await updateExports(); await updateCases(); } catch (err) { // On failure, pull fresh state from the server so any items that // weren't actually deleted reappear in the UI. await updateExports(); throw err; } }, [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; } // Use visibleExports so deep links to a still-encoding id don't try // to open a player against a half-written video file. setSelected(visibleExports.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 selectable = currentExports.filter((e) => { if (e.in_progress) return false; if (!search) return true; return e.name .toLowerCase() .replaceAll("_", " ") .includes(search.toLowerCase()); }); if (selectedExports.length < selectable.length) { setSelectedExports(selectable); } 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; } deleteExports([deleteClip.file]) .then(() => setDeleteClip(undefined)) .catch((error) => { const errorMessage = error?.response?.data?.message || error?.response?.data?.detail || "Unknown error"; toast.error( t("bulkToast.error.deleteFailed", { errorMessage: errorMessage }), { position: "top-center" }, ); }); }, [deleteClip, deleteExports, t]); 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)} />