mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* remove redundant per-view toasters in settings * add variants to standardize dialog footer button layouts * remove text-md this class name compiles to nothing in tailwind. we used to add it to prevent iOS from zooming when focusing on an input, but that is now solved via the viewport meta in index.html * make wizard footers consistent with dialog footers * consistent destructive button style remove text-white from individual buttons and add it to the variant
1572 lines
48 KiB
TypeScript
1572 lines
48 KiB
TypeScript
import { baseUrl } from "@/api/baseUrl";
|
|
import { useJobStatus } from "@/api/ws";
|
|
import {
|
|
ActiveExportJobCard,
|
|
CaseCard,
|
|
ExportCard,
|
|
} from "@/components/card/ExportCard";
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
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 {
|
|
LuDownload,
|
|
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<ExportFilter>(EXPORT_FILTER_ARRAY_KEYS);
|
|
|
|
// Data
|
|
|
|
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("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<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.
|
|
const { data: rawExports, mutate: updateExports } = useSWR<Export[]>(
|
|
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<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]);
|
|
|
|
const visibleActiveJobs = useMemo<ExportJob[]>(() => {
|
|
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<Set<string>>(
|
|
() => new Set(visibleActiveJobs.map((job) => job.id)),
|
|
[visibleActiveJobs],
|
|
);
|
|
|
|
const visibleExports = useMemo<Export[]>(
|
|
() => (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<ExportCase[]>(() => {
|
|
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<Export[]>(
|
|
() => 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<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],
|
|
);
|
|
|
|
// Search
|
|
|
|
const [search, setSearch] = useState("");
|
|
|
|
// Viewing
|
|
|
|
const [selected, setSelected] = useState<Export>();
|
|
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
|
|
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<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;
|
|
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<DeleteClipType | undefined>();
|
|
const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
|
|
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
|
|
>();
|
|
|
|
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<HTMLDivElement | null>(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 (
|
|
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
|
|
<Toaster closeButton={true} />
|
|
|
|
<CaseEditorDialog
|
|
caseDialog={caseDialog}
|
|
onClose={() => setCaseDialog(undefined)}
|
|
onSave={handleSaveCase}
|
|
/>
|
|
|
|
<CaseAddExportDialog
|
|
exportCase={caseForAddExport}
|
|
availableExports={uncategorizedExports}
|
|
onClose={() => setCaseForAddExport(undefined)}
|
|
mutate={mutate}
|
|
/>
|
|
|
|
<CaseAssignmentDialog
|
|
exportToAssign={exportToAssign}
|
|
cases={cases}
|
|
selectedCaseId={selectedCaseId}
|
|
onClose={resetCaseDialog}
|
|
mutate={mutate}
|
|
/>
|
|
|
|
<AlertDialog
|
|
open={deleteClip != undefined}
|
|
onOpenChange={() => setDeleteClip(undefined)}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("deleteExport.label")}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("deleteExport.desc", { exportName: deleteClip?.exportName })}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>
|
|
{t("button.cancel", { ns: "common" })}
|
|
</AlertDialogCancel>
|
|
<Button
|
|
aria-label="Delete Export"
|
|
variant="destructive"
|
|
onClick={() => onHandleDelete()}
|
|
>
|
|
{t("button.delete", { ns: "common" })}
|
|
</Button>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<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
|
|
variant="destructive"
|
|
onClick={() => void handleDeleteCase()}
|
|
>
|
|
{t("button.delete", { ns: "common" })}
|
|
</Button>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<Dialog
|
|
open={selected != undefined}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setSelected(undefined);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent
|
|
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%]",
|
|
)}
|
|
>
|
|
<DialogTitle className="smart-capitalize">
|
|
{selected?.name?.replaceAll("_", " ")}
|
|
</DialogTitle>
|
|
<video
|
|
className={cn(
|
|
"size-full rounded-lg md:rounded-2xl",
|
|
selectedAspect < 1.5 && "aspect-video h-full",
|
|
)}
|
|
playsInline
|
|
preload="auto"
|
|
autoPlay
|
|
controls
|
|
muted
|
|
onLoadedData={(e) =>
|
|
setSelectedAspect(
|
|
e.currentTarget.videoWidth / e.currentTarget.videoHeight,
|
|
)
|
|
}
|
|
>
|
|
<source
|
|
src={`${baseUrl}${selected?.video_path?.replace("/media/frigate/", "")}`}
|
|
type="video/mp4"
|
|
/>
|
|
</video>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<div
|
|
className={cn(
|
|
"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",
|
|
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
|
|
)}
|
|
>
|
|
{selectionMode ? (
|
|
<ExportActionGroup
|
|
selectedExports={selectedExports}
|
|
setSelectedExports={setSelectedExports}
|
|
context={selectedCase ? "case" : "uncategorized"}
|
|
cases={cases}
|
|
currentCaseId={selectedCaseId}
|
|
mutate={mutate}
|
|
deleteExports={deleteExports}
|
|
/>
|
|
) : (
|
|
<>
|
|
<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="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}
|
|
/>
|
|
<div className="flex items-center gap-1 md:gap-2">
|
|
{(exportsByCase[selectedCase.id]?.length ?? 0) > 0 && (
|
|
<Button
|
|
asChild
|
|
className="flex items-center gap-2 p-2"
|
|
size="sm"
|
|
aria-label={t("button.download", { ns: "common" })}
|
|
>
|
|
<a
|
|
download
|
|
href={`${baseUrl}api/cases/${selectedCase.id}/download`}
|
|
>
|
|
<LuDownload className="text-secondary-foreground" />
|
|
{!isMobile && (
|
|
<div className="text-primary">
|
|
{t("button.download", { ns: "common" })}
|
|
</div>
|
|
)}
|
|
</a>
|
|
</Button>
|
|
)}
|
|
{isAdmin && (
|
|
<>
|
|
<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>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{selectedCase ? (
|
|
<CaseView
|
|
contentRef={contentRef}
|
|
selectedCase={selectedCase}
|
|
exports={exportsByCase[selectedCase.id] || []}
|
|
availableExports={uncategorizedExports}
|
|
activeJobs={activeJobsByCase[selectedCase.id] || []}
|
|
search={search}
|
|
selectedExports={selectedExports}
|
|
selectionMode={selectionMode}
|
|
isLoading={cases === undefined || rawExports === undefined}
|
|
onSelectExport={onSelectExport}
|
|
setSelected={setSelected}
|
|
renameClip={onHandleRename}
|
|
setDeleteClip={setDeleteClip}
|
|
onAssignToCase={setExportToAssign}
|
|
onRemoveFromCase={handleRemoveExportFromCase}
|
|
onAddExport={() => setCaseForAddExport(selectedCase)}
|
|
/>
|
|
) : (
|
|
<AllExportsView
|
|
contentRef={contentRef}
|
|
search={search}
|
|
cases={filteredCases}
|
|
exports={exports}
|
|
exportsByCase={exportsByCase}
|
|
activeJobs={activeJobsByCase["none"] || []}
|
|
selectedExports={selectedExports}
|
|
selectionMode={selectionMode}
|
|
isLoading={cases === undefined || rawExports === undefined}
|
|
onSelectExport={onSelectExport}
|
|
setSelectedCaseId={setSelectedCaseId}
|
|
setSelected={setSelected}
|
|
renameClip={onHandleRename}
|
|
setDeleteClip={setDeleteClip}
|
|
onAssignToCase={setExportToAssign}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type AllExportsViewProps = {
|
|
contentRef: MutableRefObject<HTMLDivElement | null>;
|
|
search: string;
|
|
cases?: ExportCase[];
|
|
exports: Export[];
|
|
exportsByCase: { [caseId: string]: Export[] };
|
|
activeJobs: ExportJob[];
|
|
selectedExports: Export[];
|
|
selectionMode: boolean;
|
|
isLoading: 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,
|
|
isLoading,
|
|
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<Export[]>(() => {
|
|
if (!search) {
|
|
return exports;
|
|
}
|
|
|
|
return exports.filter((exp) =>
|
|
exp.name
|
|
.toLowerCase()
|
|
.replaceAll("_", " ")
|
|
.includes(search.toLowerCase()),
|
|
);
|
|
}, [exports, search]);
|
|
|
|
const filteredActiveJobs = useMemo<ExportJob[]>(() => {
|
|
if (!search) {
|
|
return activeJobs;
|
|
}
|
|
|
|
return activeJobs.filter((job) =>
|
|
(job.name || job.camera)
|
|
.toLowerCase()
|
|
.replaceAll("_", " ")
|
|
.includes(search.toLowerCase()),
|
|
);
|
|
}, [activeJobs, search]);
|
|
|
|
return (
|
|
<div className="w-full overflow-hidden">
|
|
{filteredCases?.length ||
|
|
filteredActiveJobs.length ||
|
|
filteredExports.length ? (
|
|
<div
|
|
ref={contentRef}
|
|
className="scrollbar-container flex size-full flex-col gap-4 overflow-y-auto"
|
|
>
|
|
{filteredCases.length > 0 && (
|
|
<div className="space-y-2">
|
|
<Heading as="h4">{t("headings.cases")}</Heading>
|
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
{filteredCases.map((item) => (
|
|
<CaseCard
|
|
key={item.id}
|
|
className=""
|
|
exportCase={item}
|
|
exports={exportsByCase[item.id] || []}
|
|
onSelect={() => {
|
|
setSelectedCaseId(item.id);
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{(filteredActiveJobs.length > 0 || filteredExports.length > 0) && (
|
|
<div className="space-y-4">
|
|
<Heading as="h4">{t("headings.uncategorizedExports")}</Heading>
|
|
<div
|
|
ref={contentRef}
|
|
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
|
>
|
|
{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}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : isLoading ? (
|
|
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
|
) : (
|
|
<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[];
|
|
availableExports: Export[];
|
|
activeJobs: ExportJob[];
|
|
search: string;
|
|
selectedExports: Export[];
|
|
selectionMode: boolean;
|
|
isLoading: 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,
|
|
isLoading,
|
|
onSelectExport,
|
|
setSelected,
|
|
renameClip,
|
|
setDeleteClip,
|
|
onAssignToCase,
|
|
onRemoveFromCase,
|
|
onAddExport,
|
|
}: CaseViewProps) {
|
|
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,
|
|
);
|
|
|
|
const filteredExports = useMemo<Export[]>(() => {
|
|
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<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]);
|
|
|
|
return (
|
|
<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">
|
|
{selectedCase.name}
|
|
</Heading>
|
|
<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>
|
|
</div>
|
|
{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>
|
|
)}
|
|
</div>
|
|
{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>
|
|
) : isLoading ? (
|
|
<div className="flex min-h-[16rem] flex-1 items-center justify-center">
|
|
<ActivityIndicator />
|
|
</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>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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)}
|
|
/>
|
|
<DialogFooter>
|
|
<Button 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>
|
|
</DialogFooter>
|
|
</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>
|
|
<Button onClick={onClose}>
|
|
{t("button.cancel", { ns: "common" })}
|
|
</Button>
|
|
<Button
|
|
variant="select"
|
|
disabled={selectedIds.length === 0 || isAdding}
|
|
onClick={() => void handleAdd()}
|
|
>
|
|
{isAdding
|
|
? t("addExportDialog.adding")
|
|
: t("addExportDialog.addButton", {
|
|
count: selectedIds.length || 1,
|
|
})}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
type CaseAssignmentDialogProps = {
|
|
exportToAssign?: Export;
|
|
cases?: ExportCase[];
|
|
selectedCaseId?: string;
|
|
onClose: () => void;
|
|
mutate: () => void;
|
|
};
|
|
function CaseAssignmentDialog({
|
|
exportToAssign,
|
|
cases,
|
|
selectedCaseId,
|
|
onClose,
|
|
mutate,
|
|
}: CaseAssignmentDialogProps) {
|
|
const { t } = useTranslation(["views/exports"]);
|
|
const caseOptions = useMemo(
|
|
() => [
|
|
...(cases ?? [])
|
|
.map((c) => ({
|
|
value: c.id,
|
|
label: c.name,
|
|
}))
|
|
.sort((cA, cB) => cA.label.localeCompare(cB.label)),
|
|
{
|
|
value: "new",
|
|
label: t("caseDialog.newCaseOption"),
|
|
},
|
|
],
|
|
[cases, t],
|
|
);
|
|
|
|
const handleSave = useCallback(
|
|
async (caseId: string) => {
|
|
if (!exportToAssign) return;
|
|
|
|
try {
|
|
await axios.post("exports/reassign", {
|
|
ids: [exportToAssign.id],
|
|
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",
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
[exportToAssign, mutate, onClose, t],
|
|
);
|
|
|
|
const handleCreateNew = useCallback(
|
|
async (name: string, description: string) => {
|
|
if (!exportToAssign) return;
|
|
|
|
try {
|
|
const createResp = await axios.post("cases", {
|
|
name,
|
|
description,
|
|
});
|
|
|
|
const newCaseId: string | undefined = createResp.data?.id;
|
|
|
|
if (newCaseId) {
|
|
await axios.post("exports/reassign", {
|
|
ids: [exportToAssign.id],
|
|
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",
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
[exportToAssign, mutate, onClose, t],
|
|
);
|
|
|
|
if (!exportToAssign) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<OptionAndInputDialog
|
|
open={!!exportToAssign}
|
|
title={t("caseDialog.title")}
|
|
description={t("caseDialog.description")}
|
|
setOpen={(open) => {
|
|
if (!open) {
|
|
onClose();
|
|
}
|
|
}}
|
|
options={caseOptions}
|
|
nameLabel={t("caseDialog.nameLabel")}
|
|
descriptionLabel={t("caseDialog.descriptionLabel")}
|
|
initialValue={selectedCaseId}
|
|
newValueKey="new"
|
|
onSave={handleSave}
|
|
onCreateNew={handleCreateNew}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export default Exports;
|