2023-12-13 02:21:42 +03:00
|
|
|
import { baseUrl } from "@/api/baseUrl";
|
2026-04-17 21:18:12 +03:00
|
|
|
import { useJobStatus } from "@/api/ws";
|
2026-04-14 17:19:50 +03:00
|
|
|
import {
|
|
|
|
|
ActiveExportJobCard,
|
|
|
|
|
CaseCard,
|
|
|
|
|
ExportCard,
|
|
|
|
|
} from "@/components/card/ExportCard";
|
2023-12-13 02:21:42 +03:00
|
|
|
import {
|
|
|
|
|
AlertDialog,
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
} from "@/components/ui/alert-dialog";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2026-04-14 17:19:50 +03:00
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from "@/components/ui/dialog";
|
2025-12-15 23:10:50 +03:00
|
|
|
import Heading from "@/components/ui/heading";
|
2024-04-07 23:35:45 +03:00
|
|
|
import { Input } from "@/components/ui/input";
|
2026-04-14 17:19:50 +03:00
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
2024-07-17 16:39:37 +03:00
|
|
|
import { Toaster } from "@/components/ui/sonner";
|
2025-10-02 16:21:37 +03:00
|
|
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
2024-09-12 17:46:29 +03:00
|
|
|
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
2025-12-15 23:10:50 +03:00
|
|
|
import { useHistoryBack } from "@/hooks/use-history-back";
|
|
|
|
|
import { useApiFilterArgs } from "@/hooks/use-api-filter";
|
2024-08-02 16:06:15 +03:00
|
|
|
import { cn } from "@/lib/utils";
|
2026-04-14 17:19:50 +03:00
|
|
|
import { useFormattedTimestamp, useTimeFormat } from "@/hooks/use-date-utils";
|
2025-12-15 23:10:50 +03:00
|
|
|
import {
|
|
|
|
|
DeleteClipType,
|
|
|
|
|
Export,
|
|
|
|
|
ExportCase,
|
|
|
|
|
ExportFilter,
|
2026-04-14 17:19:50 +03:00
|
|
|
ExportJob,
|
2025-12-15 23:10:50 +03:00
|
|
|
} from "@/types/export";
|
|
|
|
|
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
|
2023-12-13 02:21:42 +03:00
|
|
|
import axios from "axios";
|
2026-04-14 17:19:50 +03:00
|
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
2025-03-16 18:36:20 +03:00
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
import {
|
|
|
|
|
MutableRefObject,
|
|
|
|
|
useCallback,
|
|
|
|
|
useEffect,
|
|
|
|
|
useMemo,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
} from "react";
|
|
|
|
|
import { isMobile, isMobileOnly } from "react-device-detect";
|
2025-03-16 18:36:20 +03:00
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
|
2026-04-14 17:19:50 +03:00
|
|
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
|
|
|
|
import {
|
|
|
|
|
LuFolderPlus,
|
|
|
|
|
LuFolderX,
|
|
|
|
|
LuPencil,
|
|
|
|
|
LuPlus,
|
|
|
|
|
LuTrash2,
|
|
|
|
|
} from "react-icons/lu";
|
2024-07-17 16:39:37 +03:00
|
|
|
import { toast } from "sonner";
|
2023-12-13 02:21:42 +03:00
|
|
|
import useSWR from "swr";
|
2026-04-14 17:19:50 +03:00
|
|
|
import ExportActionGroup from "@/components/filter/ExportActionGroup";
|
2025-12-15 23:10:50 +03:00
|
|
|
import ExportFilterGroup from "@/components/filter/ExportFilterGroup";
|
2026-04-14 17:19:50 +03:00
|
|
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
2025-12-15 23:10:50 +03:00
|
|
|
|
|
|
|
|
// always parse these as string arrays
|
|
|
|
|
const EXPORT_FILTER_ARRAY_KEYS = ["cameras"];
|
2023-12-13 02:21:42 +03:00
|
|
|
|
2024-04-20 01:11:41 +03:00
|
|
|
function Exports() {
|
2025-03-16 18:36:20 +03:00
|
|
|
const { t } = useTranslation(["views/exports"]);
|
2026-04-14 17:19:50 +03:00
|
|
|
const isAdmin = useIsAdmin();
|
2023-12-13 02:21:42 +03:00
|
|
|
|
2024-04-12 15:31:30 +03:00
|
|
|
useEffect(() => {
|
2025-03-16 18:36:20 +03:00
|
|
|
document.title = t("documentTitle");
|
|
|
|
|
}, [t]);
|
2024-04-12 15:31:30 +03:00
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
// Filters
|
2026-02-27 07:16:10 +03:00
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
const [exportFilter, setExportFilter, exportSearchParams] =
|
|
|
|
|
useApiFilterArgs<ExportFilter>(EXPORT_FILTER_ARRAY_KEYS);
|
2026-02-27 07:16:10 +03:00
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
// Data
|
|
|
|
|
|
|
|
|
|
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases");
|
2026-04-17 21:18:12 +03:00
|
|
|
|
|
|
|
|
// 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<ExportJob[]>(
|
|
|
|
|
() => 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<ExportJob[]>(() => {
|
|
|
|
|
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.
|
2025-12-15 23:10:50 +03:00
|
|
|
const { data: rawExports, mutate: updateExports } = useSWR<Export[]>(
|
|
|
|
|
exportSearchParams && Object.keys(exportSearchParams).length > 0
|
|
|
|
|
? ["exports", exportSearchParams]
|
|
|
|
|
: "exports",
|
2026-04-14 17:19:50 +03:00
|
|
|
{
|
|
|
|
|
refreshInterval: (latestExports) => {
|
|
|
|
|
if ((latestExports ?? []).some((exp) => exp.in_progress)) {
|
|
|
|
|
return 2000;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
},
|
|
|
|
|
},
|
2025-12-15 23:10:50 +03:00
|
|
|
);
|
|
|
|
|
|
2026-04-17 21:18:12 +03:00
|
|
|
// 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<Set<string>>(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]);
|
|
|
|
|
|
2026-04-14 17:19:50 +03:00
|
|
|
const visibleActiveJobs = useMemo<ExportJob[]>(() => {
|
|
|
|
|
const filteredCameras = exportFilter?.cameras;
|
|
|
|
|
|
|
|
|
|
return (activeExportJobs ?? []).filter((job) => {
|
|
|
|
|
if (filteredCameras && filteredCameras.length > 0) {
|
|
|
|
|
return filteredCameras.includes(job.camera);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
});
|
2026-04-17 21:18:12 +03:00
|
|
|
}, [activeExportJobs, exportFilter?.cameras]);
|
2026-04-14 17:19:50 +03:00
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-04-17 21:18:12 +03:00
|
|
|
// 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<Set<string>>(
|
|
|
|
|
() => new Set(visibleActiveJobs.map((job) => job.id)),
|
|
|
|
|
[visibleActiveJobs],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const visibleExports = useMemo<Export[]>(
|
|
|
|
|
() => (rawExports ?? []).filter((exp) => !activeJobIds.has(exp.id)),
|
|
|
|
|
[activeJobIds, rawExports],
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => {
|
|
|
|
|
const grouped: { [caseId: string]: Export[] } = {};
|
2026-04-17 21:18:12 +03:00
|
|
|
visibleExports.forEach((exp) => {
|
2026-04-14 17:19:50 +03:00
|
|
|
const caseId = exp.export_case ?? exp.export_case_id ?? "none";
|
2025-12-15 23:10:50 +03:00
|
|
|
if (!grouped[caseId]) {
|
|
|
|
|
grouped[caseId] = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
grouped[caseId].push(exp);
|
|
|
|
|
});
|
|
|
|
|
return grouped;
|
2026-04-17 21:18:12 +03:00
|
|
|
}, [visibleExports]);
|
2025-12-15 23:10:50 +03:00
|
|
|
|
|
|
|
|
const filteredCases = useMemo<ExportCase[]>(() => {
|
2026-04-14 17:19:50 +03:00
|
|
|
if (!cases) return [];
|
2024-04-07 23:35:45 +03:00
|
|
|
|
2026-04-14 17:19:50 +03:00
|
|
|
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]);
|
2025-12-15 23:10:50 +03:00
|
|
|
|
|
|
|
|
const exports = useMemo<Export[]>(
|
|
|
|
|
() => exportsByCase["none"] || [],
|
|
|
|
|
[exportsByCase],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const mutate = useCallback(() => {
|
|
|
|
|
updateExports();
|
|
|
|
|
updateCases();
|
|
|
|
|
}, [updateExports, updateCases]);
|
|
|
|
|
|
2026-04-17 21:18:12 +03:00
|
|
|
// 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<void> => {
|
|
|
|
|
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],
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
// Search
|
|
|
|
|
|
|
|
|
|
const [search, setSearch] = useState("");
|
2024-04-07 23:35:45 +03:00
|
|
|
|
2024-09-12 17:46:29 +03:00
|
|
|
// Viewing
|
|
|
|
|
|
|
|
|
|
const [selected, setSelected] = useState<Export>();
|
2025-12-15 23:10:50 +03:00
|
|
|
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
|
|
|
|
|
undefined,
|
|
|
|
|
);
|
2024-09-12 17:46:29 +03:00
|
|
|
const [selectedAspect, setSelectedAspect] = useState(0.0);
|
|
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
// Handle browser back button to deselect case before navigating away
|
|
|
|
|
useHistoryBack({
|
|
|
|
|
enabled: true,
|
|
|
|
|
open: selectedCaseId !== undefined,
|
|
|
|
|
onClose: () => setSelectedCaseId(undefined),
|
|
|
|
|
});
|
|
|
|
|
|
2024-09-12 17:46:29 +03:00
|
|
|
useSearchEffect("id", (id) => {
|
2025-12-15 23:10:50 +03:00
|
|
|
if (!rawExports) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 21:18:12 +03:00
|
|
|
// 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));
|
2025-12-15 23:10:50 +03:00
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
useSearchEffect("caseId", (caseId: string) => {
|
|
|
|
|
if (!filteredCases) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const exists = filteredCases.some((c) => c.id === caseId);
|
|
|
|
|
|
|
|
|
|
if (!exists) {
|
2024-09-12 17:46:29 +03:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
setSelectedCaseId(caseId);
|
2024-09-12 17:46:29 +03:00
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-14 17:19:50 +03:00
|
|
|
// Bulk selection
|
|
|
|
|
|
|
|
|
|
const [selectedExports, setSelectedExports] = useState<Export[]>([]);
|
|
|
|
|
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;
|
2026-04-17 21:18:12 +03:00
|
|
|
const selectable = currentExports.filter((e) => {
|
2026-04-14 17:19:50 +03:00
|
|
|
if (e.in_progress) return false;
|
|
|
|
|
if (!search) return true;
|
|
|
|
|
return e.name
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replaceAll("_", " ")
|
|
|
|
|
.includes(search.toLowerCase());
|
|
|
|
|
});
|
2026-04-17 21:18:12 +03:00
|
|
|
if (selectedExports.length < selectable.length) {
|
|
|
|
|
setSelectedExports(selectable);
|
2026-04-14 17:19:50 +03:00
|
|
|
} else {
|
|
|
|
|
setSelectedExports([]);
|
|
|
|
|
}
|
|
|
|
|
}, [selectedCaseId, exportsByCase, exports, search, selectedExports]);
|
|
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
// Modifying
|
2024-04-07 23:35:45 +03:00
|
|
|
|
2024-04-20 16:44:59 +03:00
|
|
|
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
2025-12-15 23:10:50 +03:00
|
|
|
const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
|
2026-04-14 17:19:50 +03:00
|
|
|
const [caseDialog, setCaseDialog] = useState<
|
|
|
|
|
{ mode: "create" | "edit"; exportCase?: ExportCase } | undefined
|
|
|
|
|
>();
|
|
|
|
|
const [caseToDelete, setCaseToDelete] = useState<ExportCase | undefined>();
|
|
|
|
|
const [deleteExportsWithCase, setDeleteExportsWithCase] = useState(false);
|
|
|
|
|
const [caseForAddExport, setCaseForAddExport] = useState<
|
|
|
|
|
ExportCase | undefined
|
|
|
|
|
>();
|
2024-04-03 17:02:07 +03:00
|
|
|
|
2023-12-13 02:21:42 +03:00
|
|
|
const onHandleDelete = useCallback(() => {
|
|
|
|
|
if (!deleteClip) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 21:18:12 +03:00
|
|
|
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" },
|
|
|
|
|
);
|
2026-04-14 17:19:50 +03:00
|
|
|
});
|
2026-04-17 21:18:12 +03:00
|
|
|
}, [deleteClip, deleteExports, t]);
|
2023-12-13 02:21:42 +03:00
|
|
|
|
2024-04-07 23:35:45 +03:00
|
|
|
const onHandleRename = useCallback(
|
2024-04-20 01:11:41 +03:00
|
|
|
(id: string, update: string) => {
|
2024-07-17 16:39:37 +03:00
|
|
|
axios
|
2025-02-15 05:18:14 +03:00
|
|
|
.patch(`export/${id}/rename`, {
|
|
|
|
|
name: update,
|
|
|
|
|
})
|
2024-07-17 16:39:37 +03:00
|
|
|
.then((response) => {
|
2025-02-15 05:18:14 +03:00
|
|
|
if (response.status === 200) {
|
2024-07-17 16:39:37 +03:00
|
|
|
setDeleteClip(undefined);
|
|
|
|
|
mutate();
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch((error) => {
|
2025-03-08 19:01:08 +03:00
|
|
|
const errorMessage =
|
|
|
|
|
error.response?.data?.message ||
|
|
|
|
|
error.response?.data?.detail ||
|
|
|
|
|
"Unknown error";
|
2025-03-16 18:36:20 +03:00
|
|
|
toast.error(t("toast.error.renameExportFailed", { errorMessage }), {
|
2025-03-08 19:01:08 +03:00
|
|
|
position: "top-center",
|
|
|
|
|
});
|
2024-07-17 16:39:37 +03:00
|
|
|
});
|
2024-04-07 23:35:45 +03:00
|
|
|
},
|
2025-12-15 23:10:50 +03:00
|
|
|
[mutate, setDeleteClip, t],
|
2024-04-07 23:35:45 +03:00
|
|
|
);
|
|
|
|
|
|
2025-10-02 16:21:37 +03:00
|
|
|
// Keyboard Listener
|
|
|
|
|
|
|
|
|
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
2026-04-14 17:19:50 +03:00
|
|
|
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,
|
|
|
|
|
);
|
2025-10-02 16:21:37 +03:00
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
const selectedCase = useMemo(
|
2026-04-14 17:19:50 +03:00
|
|
|
() => 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],
|
2025-12-15 23:10:50 +03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const resetCaseDialog = useCallback(() => {
|
|
|
|
|
setExportToAssign(undefined);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2023-12-08 16:33:22 +03:00
|
|
|
return (
|
2024-05-16 19:51:57 +03:00
|
|
|
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
|
2024-07-17 16:39:37 +03:00
|
|
|
<Toaster closeButton={true} />
|
|
|
|
|
|
2026-04-14 17:19:50 +03:00
|
|
|
<CaseEditorDialog
|
|
|
|
|
caseDialog={caseDialog}
|
|
|
|
|
onClose={() => setCaseDialog(undefined)}
|
|
|
|
|
onSave={handleSaveCase}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<CaseAddExportDialog
|
|
|
|
|
exportCase={caseForAddExport}
|
|
|
|
|
availableExports={uncategorizedExports}
|
|
|
|
|
onClose={() => setCaseForAddExport(undefined)}
|
|
|
|
|
mutate={mutate}
|
|
|
|
|
/>
|
|
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
<CaseAssignmentDialog
|
|
|
|
|
exportToAssign={exportToAssign}
|
|
|
|
|
cases={cases}
|
|
|
|
|
selectedCaseId={selectedCaseId}
|
|
|
|
|
onClose={resetCaseDialog}
|
|
|
|
|
mutate={mutate}
|
|
|
|
|
/>
|
|
|
|
|
|
2023-12-13 02:21:42 +03:00
|
|
|
<AlertDialog
|
|
|
|
|
open={deleteClip != undefined}
|
2024-02-29 01:23:56 +03:00
|
|
|
onOpenChange={() => setDeleteClip(undefined)}
|
2023-12-13 02:21:42 +03:00
|
|
|
>
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
<AlertDialogHeader>
|
2026-03-23 16:48:02 +03:00
|
|
|
<AlertDialogTitle>{t("deleteExport.label")}</AlertDialogTitle>
|
2023-12-13 02:21:42 +03:00
|
|
|
<AlertDialogDescription>
|
2025-03-16 18:36:20 +03:00
|
|
|
{t("deleteExport.desc", { exportName: deleteClip?.exportName })}
|
2023-12-13 02:21:42 +03:00
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter>
|
2025-03-16 18:36:20 +03:00
|
|
|
<AlertDialogCancel>
|
|
|
|
|
{t("button.cancel", { ns: "common" })}
|
|
|
|
|
</AlertDialogCancel>
|
2024-04-20 01:11:41 +03:00
|
|
|
<Button
|
|
|
|
|
className="text-white"
|
2024-10-23 01:07:42 +03:00
|
|
|
aria-label="Delete Export"
|
2024-04-20 01:11:41 +03:00
|
|
|
variant="destructive"
|
|
|
|
|
onClick={() => onHandleDelete()}
|
|
|
|
|
>
|
2025-03-16 18:36:20 +03:00
|
|
|
{t("button.delete", { ns: "common" })}
|
2023-12-13 02:21:42 +03:00
|
|
|
</Button>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
2026-04-14 17:19:50 +03:00
|
|
|
<AlertDialog
|
|
|
|
|
open={caseToDelete != undefined}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!open) {
|
|
|
|
|
setCaseToDelete(undefined);
|
|
|
|
|
setDeleteExportsWithCase(false);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
<AlertDialogTitle>{t("deleteCase.label")}</AlertDialogTitle>
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
{t("deleteCase.desc", {
|
|
|
|
|
caseName: caseToDelete?.name,
|
|
|
|
|
})}{" "}
|
|
|
|
|
{deleteExportsWithCase
|
|
|
|
|
? t("deleteCase.descDeleteExports")
|
|
|
|
|
: t("deleteCase.descKeepExports")}
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<div className="flex items-center justify-start gap-6">
|
|
|
|
|
<Label
|
|
|
|
|
htmlFor="delete-exports-switch"
|
|
|
|
|
className="cursor-pointer text-sm"
|
|
|
|
|
>
|
|
|
|
|
{t("deleteCase.deleteExports")}
|
|
|
|
|
</Label>
|
|
|
|
|
<Switch
|
|
|
|
|
id="delete-exports-switch"
|
|
|
|
|
checked={deleteExportsWithCase}
|
|
|
|
|
onCheckedChange={setDeleteExportsWithCase}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
<AlertDialogCancel>
|
|
|
|
|
{t("button.cancel", { ns: "common" })}
|
|
|
|
|
</AlertDialogCancel>
|
|
|
|
|
<Button
|
|
|
|
|
className="text-white"
|
|
|
|
|
variant="destructive"
|
|
|
|
|
onClick={() => void handleDeleteCase()}
|
|
|
|
|
>
|
|
|
|
|
{t("button.delete", { ns: "common" })}
|
|
|
|
|
</Button>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
2024-04-20 01:11:41 +03:00
|
|
|
<Dialog
|
|
|
|
|
open={selected != undefined}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!open) {
|
|
|
|
|
setSelected(undefined);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
2024-08-02 16:06:15 +03:00
|
|
|
<DialogContent
|
2025-09-04 16:08:07 +03:00
|
|
|
className={cn(
|
|
|
|
|
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
|
|
|
|
|
isMobile && "landscape:max-w-[60%]",
|
|
|
|
|
)}
|
2024-08-02 16:06:15 +03:00
|
|
|
>
|
2025-04-23 01:21:09 +03:00
|
|
|
<DialogTitle className="smart-capitalize">
|
2024-08-02 16:06:15 +03:00
|
|
|
{selected?.name?.replaceAll("_", " ")}
|
|
|
|
|
</DialogTitle>
|
2024-04-20 01:11:41 +03:00
|
|
|
<video
|
2024-08-02 16:06:15 +03:00
|
|
|
className={cn(
|
|
|
|
|
"size-full rounded-lg md:rounded-2xl",
|
|
|
|
|
selectedAspect < 1.5 && "aspect-video h-full",
|
|
|
|
|
)}
|
2024-04-20 01:11:41 +03:00
|
|
|
playsInline
|
|
|
|
|
preload="auto"
|
|
|
|
|
autoPlay
|
|
|
|
|
controls
|
|
|
|
|
muted
|
2024-08-02 16:06:15 +03:00
|
|
|
onLoadedData={(e) =>
|
|
|
|
|
setSelectedAspect(
|
|
|
|
|
e.currentTarget.videoWidth / e.currentTarget.videoHeight,
|
|
|
|
|
)
|
|
|
|
|
}
|
2024-04-20 01:11:41 +03:00
|
|
|
>
|
|
|
|
|
<source
|
|
|
|
|
src={`${baseUrl}${selected?.video_path?.replace("/media/frigate/", "")}`}
|
|
|
|
|
type="video/mp4"
|
|
|
|
|
/>
|
|
|
|
|
</video>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-04-14 17:19:50 +03:00
|
|
|
"flex w-full flex-col items-start space-y-2 md:mb-2 lg:relative lg:h-10 lg:flex-row lg:items-center lg:space-y-0",
|
2025-12-15 23:10:50 +03:00
|
|
|
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-04-14 17:19:50 +03:00
|
|
|
{selectionMode ? (
|
|
|
|
|
<ExportActionGroup
|
|
|
|
|
selectedExports={selectedExports}
|
|
|
|
|
setSelectedExports={setSelectedExports}
|
|
|
|
|
context={selectedCase ? "case" : "uncategorized"}
|
|
|
|
|
cases={cases}
|
|
|
|
|
currentCaseId={selectedCaseId}
|
|
|
|
|
mutate={mutate}
|
2026-04-17 21:18:12 +03:00
|
|
|
deleteExports={deleteExports}
|
2024-07-03 16:44:25 +03:00
|
|
|
/>
|
2026-04-14 17:19:50 +03:00
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex w-full items-center gap-2">
|
|
|
|
|
{selectedCase && (
|
|
|
|
|
<Button
|
|
|
|
|
className="flex items-center gap-2.5 rounded-lg"
|
|
|
|
|
aria-label={t("label.back", { ns: "common" })}
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setSelectedCaseId(undefined)}
|
|
|
|
|
>
|
|
|
|
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
|
|
|
|
{!isMobileOnly && (
|
|
|
|
|
<div className="text-primary">
|
|
|
|
|
{t("button.back", { ns: "common" })}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Input
|
|
|
|
|
className="text-md w-full bg-muted md:w-1/2"
|
|
|
|
|
placeholder={t("search")}
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{!selectedCase && (
|
|
|
|
|
<div className="flex w-full items-center justify-end gap-2">
|
|
|
|
|
<ExportFilterGroup
|
|
|
|
|
className="justify-start"
|
|
|
|
|
filter={exportFilter}
|
|
|
|
|
filters={["cameras"]}
|
|
|
|
|
onUpdateFilter={setExportFilter}
|
|
|
|
|
/>
|
|
|
|
|
{isAdmin && (
|
|
|
|
|
<Button
|
|
|
|
|
className="flex items-center gap-2.5 rounded-lg"
|
|
|
|
|
variant="default"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setCaseDialog({ mode: "create" })}
|
|
|
|
|
>
|
|
|
|
|
<LuFolderPlus className="text-secondary-foreground" />
|
|
|
|
|
<div className="text-primary">{t("toolbar.newCase")}</div>
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{selectedCase && (
|
|
|
|
|
<div className="flex w-full items-center justify-end gap-2">
|
|
|
|
|
<ExportFilterGroup
|
|
|
|
|
className="justify-start"
|
|
|
|
|
filter={exportFilter}
|
|
|
|
|
filters={["cameras"]}
|
|
|
|
|
onUpdateFilter={setExportFilter}
|
|
|
|
|
/>
|
|
|
|
|
{isAdmin && (
|
|
|
|
|
<div className="flex items-center gap-1 md:gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
className="flex items-center gap-2 p-2"
|
|
|
|
|
size="sm"
|
|
|
|
|
aria-label={t("toolbar.addExport")}
|
|
|
|
|
onClick={() => setCaseForAddExport(selectedCase)}
|
|
|
|
|
>
|
|
|
|
|
<LuPlus className="text-secondary-foreground" />
|
|
|
|
|
{!isMobile && (
|
|
|
|
|
<div className="text-primary">
|
|
|
|
|
{t("toolbar.addExport")}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
className="flex items-center gap-2 p-2"
|
|
|
|
|
size="sm"
|
|
|
|
|
aria-label={t("toolbar.editCase")}
|
|
|
|
|
onClick={() =>
|
|
|
|
|
setCaseDialog({
|
|
|
|
|
mode: "edit",
|
|
|
|
|
exportCase: selectedCase,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<LuPencil className="text-secondary-foreground" />
|
|
|
|
|
{!isMobile && (
|
|
|
|
|
<div className="text-primary">
|
|
|
|
|
{t("toolbar.editCase")}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
className="flex items-center gap-2 p-2"
|
|
|
|
|
size="sm"
|
|
|
|
|
aria-label={t("toolbar.deleteCase")}
|
|
|
|
|
onClick={() => setCaseToDelete(selectedCase)}
|
|
|
|
|
>
|
|
|
|
|
<LuTrash2 className="text-secondary-foreground" />
|
|
|
|
|
{!isMobile && (
|
|
|
|
|
<div className="text-primary">
|
|
|
|
|
{t("toolbar.deleteCase")}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2025-12-15 23:10:50 +03:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{selectedCase ? (
|
|
|
|
|
<CaseView
|
|
|
|
|
contentRef={contentRef}
|
|
|
|
|
selectedCase={selectedCase}
|
|
|
|
|
exports={exportsByCase[selectedCase.id] || []}
|
2026-04-14 17:19:50 +03:00
|
|
|
availableExports={uncategorizedExports}
|
|
|
|
|
activeJobs={activeJobsByCase[selectedCase.id] || []}
|
2025-12-15 23:10:50 +03:00
|
|
|
search={search}
|
2026-04-14 17:19:50 +03:00
|
|
|
selectedExports={selectedExports}
|
|
|
|
|
selectionMode={selectionMode}
|
|
|
|
|
onSelectExport={onSelectExport}
|
2025-12-15 23:10:50 +03:00
|
|
|
setSelected={setSelected}
|
|
|
|
|
renameClip={onHandleRename}
|
|
|
|
|
setDeleteClip={setDeleteClip}
|
|
|
|
|
onAssignToCase={setExportToAssign}
|
2026-04-14 17:19:50 +03:00
|
|
|
onRemoveFromCase={handleRemoveExportFromCase}
|
|
|
|
|
onAddExport={() => setCaseForAddExport(selectedCase)}
|
2025-12-15 23:10:50 +03:00
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<AllExportsView
|
|
|
|
|
contentRef={contentRef}
|
|
|
|
|
search={search}
|
|
|
|
|
cases={filteredCases}
|
|
|
|
|
exports={exports}
|
|
|
|
|
exportsByCase={exportsByCase}
|
2026-04-14 17:19:50 +03:00
|
|
|
activeJobs={activeJobsByCase["none"] || []}
|
|
|
|
|
selectedExports={selectedExports}
|
|
|
|
|
selectionMode={selectionMode}
|
|
|
|
|
onSelectExport={onSelectExport}
|
2025-12-15 23:10:50 +03:00
|
|
|
setSelectedCaseId={setSelectedCaseId}
|
|
|
|
|
setSelected={setSelected}
|
|
|
|
|
renameClip={onHandleRename}
|
|
|
|
|
setDeleteClip={setDeleteClip}
|
|
|
|
|
onAssignToCase={setExportToAssign}
|
|
|
|
|
/>
|
2024-07-03 16:44:25 +03:00
|
|
|
)}
|
2025-12-15 23:10:50 +03:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-27 07:16:10 +03:00
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
type AllExportsViewProps = {
|
|
|
|
|
contentRef: MutableRefObject<HTMLDivElement | null>;
|
|
|
|
|
search: string;
|
|
|
|
|
cases?: ExportCase[];
|
|
|
|
|
exports: Export[];
|
|
|
|
|
exportsByCase: { [caseId: string]: Export[] };
|
2026-04-14 17:19:50 +03:00
|
|
|
activeJobs: ExportJob[];
|
|
|
|
|
selectedExports: Export[];
|
|
|
|
|
selectionMode: boolean;
|
|
|
|
|
onSelectExport: (e: Export) => void;
|
2025-12-15 23:10:50 +03:00
|
|
|
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,
|
2026-04-14 17:19:50 +03:00
|
|
|
activeJobs,
|
|
|
|
|
selectedExports,
|
|
|
|
|
selectionMode,
|
|
|
|
|
onSelectExport,
|
2025-12-15 23:10:50 +03:00
|
|
|
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]);
|
|
|
|
|
|
2026-04-14 17:19:50 +03:00
|
|
|
const filteredActiveJobs = useMemo<ExportJob[]>(() => {
|
|
|
|
|
if (!search) {
|
|
|
|
|
return activeJobs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return activeJobs.filter((job) =>
|
|
|
|
|
(job.name || job.camera)
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replaceAll("_", " ")
|
|
|
|
|
.includes(search.toLowerCase()),
|
|
|
|
|
);
|
|
|
|
|
}, [activeJobs, search]);
|
|
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
return (
|
|
|
|
|
<div className="w-full overflow-hidden">
|
2026-04-14 17:19:50 +03:00
|
|
|
{filteredCases?.length ||
|
|
|
|
|
filteredActiveJobs.length ||
|
|
|
|
|
filteredExports.length ? (
|
2025-12-15 23:10:50 +03:00
|
|
|
<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">
|
2026-04-14 17:19:50 +03:00
|
|
|
{filteredCases.map((item) => (
|
2025-12-15 23:10:50 +03:00
|
|
|
<CaseCard
|
|
|
|
|
key={item.id}
|
2026-04-14 17:19:50 +03:00
|
|
|
className=""
|
2025-12-15 23:10:50 +03:00
|
|
|
exportCase={item}
|
|
|
|
|
exports={exportsByCase[item.id] || []}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setSelectedCaseId(item.id);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-14 17:19:50 +03:00
|
|
|
{(filteredActiveJobs.length > 0 || filteredExports.length > 0) && (
|
2025-12-15 23:10:50 +03:00
|
|
|
<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"
|
|
|
|
|
>
|
2026-04-14 17:19:50 +03:00
|
|
|
{filteredActiveJobs.map((job) => (
|
|
|
|
|
<ActiveExportJobCard key={job.id} job={job} />
|
|
|
|
|
))}
|
|
|
|
|
{filteredExports.map((item) => (
|
2025-12-15 23:10:50 +03:00
|
|
|
<ExportCard
|
2026-04-17 21:18:12 +03:00
|
|
|
key={item.id}
|
2026-04-14 17:19:50 +03:00
|
|
|
className=""
|
2025-12-15 23:10:50 +03:00
|
|
|
exportedRecording={item}
|
2026-04-14 17:19:50 +03:00
|
|
|
isSelected={selectedExports.some((e) => e.id === item.id)}
|
|
|
|
|
selectionMode={selectionMode}
|
|
|
|
|
onContextSelect={onSelectExport}
|
2025-12-15 23:10:50 +03:00
|
|
|
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[];
|
2026-04-14 17:19:50 +03:00
|
|
|
availableExports: Export[];
|
|
|
|
|
activeJobs: ExportJob[];
|
2025-12-15 23:10:50 +03:00
|
|
|
search: string;
|
2026-04-14 17:19:50 +03:00
|
|
|
selectedExports: Export[];
|
|
|
|
|
selectionMode: boolean;
|
|
|
|
|
onSelectExport: (e: Export) => void;
|
2025-12-15 23:10:50 +03:00
|
|
|
setSelected: (e: Export) => void;
|
|
|
|
|
renameClip: (id: string, update: string) => void;
|
|
|
|
|
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
|
|
|
|
onAssignToCase: (e: Export) => void;
|
2026-04-14 17:19:50 +03:00
|
|
|
onRemoveFromCase: (e: Export) => void;
|
|
|
|
|
onAddExport: () => void;
|
2025-12-15 23:10:50 +03:00
|
|
|
};
|
|
|
|
|
function CaseView({
|
|
|
|
|
contentRef,
|
|
|
|
|
selectedCase,
|
|
|
|
|
exports,
|
2026-04-14 17:19:50 +03:00
|
|
|
availableExports,
|
|
|
|
|
activeJobs,
|
2025-12-15 23:10:50 +03:00
|
|
|
search,
|
2026-04-14 17:19:50 +03:00
|
|
|
selectedExports,
|
|
|
|
|
selectionMode,
|
|
|
|
|
onSelectExport,
|
2025-12-15 23:10:50 +03:00
|
|
|
setSelected,
|
|
|
|
|
renameClip,
|
|
|
|
|
setDeleteClip,
|
|
|
|
|
onAssignToCase,
|
2026-04-14 17:19:50 +03:00
|
|
|
onRemoveFromCase,
|
|
|
|
|
onAddExport,
|
2025-12-15 23:10:50 +03:00
|
|
|
}: CaseViewProps) {
|
2026-04-14 17:19:50 +03:00
|
|
|
const { t } = useTranslation(["views/exports", "common"]);
|
|
|
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
|
|
|
const timeFormat = useTimeFormat(config);
|
|
|
|
|
const createdAt = useFormattedTimestamp(
|
|
|
|
|
selectedCase.created_at,
|
|
|
|
|
t(`time.formattedTimestampMonthDayYear.${timeFormat}`, { ns: "common" }),
|
|
|
|
|
config?.ui.timezone,
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
const filteredExports = useMemo<Export[]>(() => {
|
|
|
|
|
const caseExports = (exports || []).filter(
|
2026-04-14 17:19:50 +03:00
|
|
|
(e) => (e.export_case ?? e.export_case_id) == selectedCase.id,
|
2025-12-15 23:10:50 +03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!search) {
|
|
|
|
|
return caseExports;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return caseExports.filter((exp) =>
|
|
|
|
|
exp.name
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replaceAll("_", " ")
|
|
|
|
|
.includes(search.toLowerCase()),
|
|
|
|
|
);
|
|
|
|
|
}, [selectedCase, exports, search]);
|
|
|
|
|
|
2026-04-14 17:19:50 +03:00
|
|
|
const filteredActiveJobs = useMemo<ExportJob[]>(() => {
|
|
|
|
|
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<HTMLDivElement | null>(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]);
|
|
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
return (
|
2026-04-14 17:19:50 +03:00
|
|
|
<div className="flex size-full flex-col gap-4 overflow-hidden">
|
|
|
|
|
<div className="flex shrink-0 flex-col gap-2">
|
|
|
|
|
<Heading className="mb-0" as="h2">
|
2025-12-15 23:10:50 +03:00
|
|
|
{selectedCase.name}
|
|
|
|
|
</Heading>
|
2026-04-14 17:19:50 +03:00
|
|
|
<div className="mb-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
|
|
|
<span>{t("caseView.createdAt", { value: createdAt })}</span>
|
|
|
|
|
<span>
|
|
|
|
|
{t("caseView.exportCount", { count: filteredExports.length })}
|
|
|
|
|
</span>
|
|
|
|
|
<span>{t("caseView.cameraCount", { count: cameraCount })}</span>
|
2025-12-15 23:10:50 +03:00
|
|
|
</div>
|
2026-04-14 17:19:50 +03:00
|
|
|
{selectedCase.description && (
|
|
|
|
|
<div className="mb-2 flex max-w-5xl flex-col items-start gap-1">
|
|
|
|
|
<div
|
|
|
|
|
ref={descriptionRef}
|
|
|
|
|
className={cn(
|
|
|
|
|
"whitespace-pre-wrap text-sm text-secondary-foreground",
|
|
|
|
|
!descriptionExpanded && "line-clamp-3",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{selectedCase.description}
|
|
|
|
|
</div>
|
|
|
|
|
{(descriptionIsClamped || descriptionExpanded) && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="text-xs text-primary-variant underline-offset-2 hover:underline"
|
|
|
|
|
onClick={() => setDescriptionExpanded((prev) => !prev)}
|
|
|
|
|
>
|
|
|
|
|
{descriptionExpanded
|
|
|
|
|
? t("caseView.showLess")
|
|
|
|
|
: t("caseView.showMore")}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-12-15 23:10:50 +03:00
|
|
|
</div>
|
2026-04-14 17:19:50 +03:00
|
|
|
{filteredExports.length > 0 || filteredActiveJobs.length > 0 ? (
|
|
|
|
|
<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"
|
|
|
|
|
>
|
|
|
|
|
{filteredActiveJobs.map((job) => (
|
|
|
|
|
<ActiveExportJobCard key={job.id} job={job} />
|
|
|
|
|
))}
|
|
|
|
|
{filteredExports.map((item) => (
|
|
|
|
|
<ExportCard
|
|
|
|
|
key={item.id}
|
|
|
|
|
className=""
|
|
|
|
|
exportedRecording={item}
|
|
|
|
|
isSelected={selectedExports.some((e) => e.id === item.id)}
|
|
|
|
|
selectionMode={selectionMode}
|
|
|
|
|
onContextSelect={onSelectExport}
|
|
|
|
|
onSelect={setSelected}
|
|
|
|
|
onRename={renameClip}
|
|
|
|
|
onDelete={({ file, exportName }) =>
|
|
|
|
|
setDeleteClip({ file, exportName })
|
|
|
|
|
}
|
|
|
|
|
onAssignToCase={onAssignToCase}
|
|
|
|
|
onRemoveFromCase={onRemoveFromCase}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex min-h-[16rem] flex-col items-center justify-center p-6 text-center">
|
|
|
|
|
<LuFolderX className="size-12" />
|
|
|
|
|
<div className="mt-3 text-lg font-medium">
|
|
|
|
|
{t("caseView.emptyTitle")}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 max-w-md text-sm text-muted-foreground">
|
|
|
|
|
{availableExports.length > 0
|
|
|
|
|
? t("caseView.emptyDescription")
|
|
|
|
|
: t("caseView.emptyDescriptionNoExports")}
|
|
|
|
|
</div>
|
|
|
|
|
{availableExports.length > 0 && (
|
|
|
|
|
<Button className="mt-4" variant="default" onClick={onAddExport}>
|
|
|
|
|
{t("toolbar.addExport")}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2024-02-24 03:26:26 +03:00
|
|
|
</div>
|
2023-12-08 16:33:22 +03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 17:19:50 +03:00
|
|
|
type CaseEditorDialogProps = {
|
|
|
|
|
caseDialog?: { mode: "create" | "edit"; exportCase?: ExportCase };
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onSave: (payload: { name: string; description: string }) => Promise<void>;
|
|
|
|
|
};
|
|
|
|
|
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 (
|
|
|
|
|
<Dialog
|
|
|
|
|
open={caseDialog != undefined}
|
|
|
|
|
onOpenChange={(open) => !open && onClose()}
|
|
|
|
|
>
|
|
|
|
|
<DialogContent>
|
|
|
|
|
<DialogTitle>
|
|
|
|
|
{caseDialog?.mode === "edit"
|
|
|
|
|
? t("caseEditor.editTitle")
|
|
|
|
|
: t("caseEditor.createTitle")}
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<Input
|
|
|
|
|
placeholder={t("caseEditor.namePlaceholder")}
|
|
|
|
|
value={name}
|
|
|
|
|
onChange={(event) => setName(event.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
<Textarea
|
|
|
|
|
placeholder={t("caseEditor.descriptionPlaceholder")}
|
|
|
|
|
value={description}
|
|
|
|
|
onChange={(event) => setDescription(event.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex justify-end gap-2">
|
|
|
|
|
<Button variant="outline" onClick={onClose}>
|
|
|
|
|
{t("button.cancel", { ns: "common" })}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="select"
|
|
|
|
|
disabled={name.trim().length === 0}
|
|
|
|
|
onClick={() =>
|
|
|
|
|
void onSave({
|
|
|
|
|
name: name.trim(),
|
|
|
|
|
description: description.trim(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{caseDialog?.mode === "edit"
|
|
|
|
|
? t("button.save", { ns: "common" })
|
|
|
|
|
: t("toolbar.newCase")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CaseAddExportDialogProps = {
|
|
|
|
|
exportCase?: ExportCase;
|
|
|
|
|
availableExports: Export[];
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
mutate: () => void;
|
|
|
|
|
};
|
|
|
|
|
function CaseAddExportDialog({
|
|
|
|
|
exportCase,
|
|
|
|
|
availableExports,
|
|
|
|
|
onClose,
|
|
|
|
|
mutate,
|
|
|
|
|
}: CaseAddExportDialogProps) {
|
|
|
|
|
const { t } = useTranslation(["views/exports", "common"]);
|
|
|
|
|
const [search, setSearch] = useState("");
|
|
|
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
|
|
|
const [isAdding, setIsAdding] = useState(false);
|
|
|
|
|
|
|
|
|
|
// Reset dialog state whenever the target case changes or the dialog reopens.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setSearch("");
|
|
|
|
|
setSelectedIds([]);
|
|
|
|
|
setIsAdding(false);
|
|
|
|
|
}, [exportCase?.id]);
|
|
|
|
|
|
|
|
|
|
const filteredExports = useMemo(() => {
|
|
|
|
|
const completedExports = availableExports.filter(
|
|
|
|
|
(exportItem) => !exportItem.in_progress,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!search) {
|
|
|
|
|
return completedExports;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return completedExports.filter((exportItem) =>
|
|
|
|
|
exportItem.name.toLowerCase().includes(search.toLowerCase()),
|
|
|
|
|
);
|
|
|
|
|
}, [availableExports, search]);
|
|
|
|
|
|
|
|
|
|
const toggleSelection = useCallback((exportId: string) => {
|
|
|
|
|
setSelectedIds((previous) =>
|
|
|
|
|
previous.includes(exportId)
|
|
|
|
|
? previous.filter((id) => id !== exportId)
|
|
|
|
|
: [...previous, exportId],
|
|
|
|
|
);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleAdd = useCallback(async () => {
|
|
|
|
|
if (!exportCase || selectedIds.length === 0 || isAdding) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsAdding(true);
|
|
|
|
|
try {
|
|
|
|
|
await axios.post("exports/reassign", {
|
|
|
|
|
ids: selectedIds,
|
|
|
|
|
export_case_id: exportCase.id,
|
|
|
|
|
});
|
|
|
|
|
mutate();
|
|
|
|
|
onClose();
|
|
|
|
|
} 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",
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
setIsAdding(false);
|
|
|
|
|
}
|
|
|
|
|
}, [exportCase, isAdding, mutate, onClose, selectedIds, t]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog
|
|
|
|
|
open={exportCase != undefined}
|
|
|
|
|
onOpenChange={(open) => !open && onClose()}
|
|
|
|
|
>
|
|
|
|
|
<DialogContent className="flex max-h-[80dvh] flex-col overflow-hidden">
|
|
|
|
|
<DialogTitle>
|
|
|
|
|
{t("addExportDialog.title", { caseName: exportCase?.name })}
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
|
|
|
|
|
<Input
|
|
|
|
|
placeholder={t("addExportDialog.searchPlaceholder")}
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="scrollbar-container min-h-0 flex-1 space-y-2 overflow-y-auto py-1 pr-1">
|
|
|
|
|
{filteredExports.length > 0 ? (
|
|
|
|
|
filteredExports.map((exportItem) => {
|
|
|
|
|
const isSelected = selectedIds.includes(exportItem.id);
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={exportItem.id}
|
|
|
|
|
type="button"
|
|
|
|
|
aria-pressed={isSelected}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors",
|
|
|
|
|
isSelected
|
|
|
|
|
? "border-selected bg-selected/10 ring-1 ring-selected"
|
|
|
|
|
: "border-transparent bg-secondary/40 hover:bg-secondary/70",
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => toggleSelection(exportItem.id)}
|
|
|
|
|
>
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="truncate text-sm font-medium text-primary">
|
|
|
|
|
{exportItem.name}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="truncate text-xs text-muted-foreground">
|
|
|
|
|
{exportItem.camera.replaceAll("_", " ")}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
) : (
|
|
|
|
|
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
|
|
|
|
{t("addExportDialog.empty")}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter className="flex-row justify-end gap-2">
|
|
|
|
|
<Button variant="outline" size="sm" onClick={onClose}>
|
|
|
|
|
{t("button.cancel", { ns: "common" })}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="select"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={selectedIds.length === 0 || isAdding}
|
|
|
|
|
onClick={() => void handleAdd()}
|
|
|
|
|
>
|
|
|
|
|
{isAdding
|
|
|
|
|
? t("addExportDialog.adding")
|
|
|
|
|
: t("addExportDialog.addButton", {
|
|
|
|
|
count: selectedIds.length || 1,
|
|
|
|
|
})}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-15 23:10:50 +03:00
|
|
|
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 {
|
2026-04-14 17:19:50 +03:00
|
|
|
await axios.post("exports/reassign", {
|
|
|
|
|
ids: [exportToAssign.id],
|
2025-12-15 23:10:50 +03:00
|
|
|
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",
|
|
|
|
|
});
|
2026-04-14 17:19:50 +03:00
|
|
|
throw error;
|
2025-12-15 23:10:50 +03:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[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) {
|
2026-04-14 17:19:50 +03:00
|
|
|
await axios.post("exports/reassign", {
|
|
|
|
|
ids: [exportToAssign.id],
|
2025-12-15 23:10:50 +03:00
|
|
|
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",
|
|
|
|
|
});
|
2026-04-14 17:19:50 +03:00
|
|
|
throw error;
|
2025-12-15 23:10:50 +03:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[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}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 19:07:34 +03:00
|
|
|
export default Exports;
|