import { useCallback, useMemo, useState } from "react"; import { isDesktop } from "react-device-detect"; import axios from "axios"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import useSWR from "swr"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "../ui/dialog"; import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger, } from "../ui/drawer"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Label } from "../ui/label"; import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue, } from "../ui/select"; import { Textarea } from "../ui/textarea"; import { BatchExportBody, BatchExportResponse, BatchExportResult, ExportCase, } from "@/types/export"; import { FrigateConfig } from "@/types/frigateConfig"; import { REVIEW_PADDING, ReviewSegment } from "@/types/review"; import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; import { useDateLocale } from "@/hooks/use-date-locale"; import { useIsAdmin } from "@/hooks/use-is-admin"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; type MultiExportDialogProps = { selectedReviews: ReviewSegment[]; onStarted: () => void; children: React.ReactNode; }; const NONE_CASE_OPTION = "none"; const NEW_CASE_OPTION = "new"; export default function MultiExportDialog({ selectedReviews, onStarted, children, }: MultiExportDialogProps) { const { t } = useTranslation(["components/dialog", "common"]); const locale = useDateLocale(); const navigate = useNavigate(); const isAdmin = useIsAdmin(); const { data: config } = useSWR("config"); // Only admins can attach exports to an existing case (enforced server-side // by POST /exports/batch). Skip fetching the case list entirely for // non-admins — they can only ever use the "Create new case" branch. const { data: cases } = useSWR(isAdmin ? "cases" : null); const [open, setOpen] = useState(false); const [caseSelection, setCaseSelection] = useState(NONE_CASE_OPTION); const [newCaseName, setNewCaseName] = useState(""); const [newCaseDescription, setNewCaseDescription] = useState(""); const [isExporting, setIsExporting] = useState(false); const count = selectedReviews.length; // Resolve a failed batch result back to a human-readable label via the // client-provided review id when available. Falls back to item_index and // finally camera name for defensive compatibility. const formatFailureLabel = useCallback( (result: BatchExportResult): string => { const cameraName = resolveCameraName(config, result.camera); if (result.client_item_id) { const review = selectedReviews.find( (item) => item.id === result.client_item_id, ); if (review) { const time = formatUnixTimestampToDateTime(review.start_time, { date_style: "short", time_style: "short", locale, }); return `${cameraName} • ${time}`; } } if ( typeof result.item_index === "number" && result.item_index >= 0 && result.item_index < selectedReviews.length ) { const review = selectedReviews[result.item_index]; const time = formatUnixTimestampToDateTime(review.start_time, { date_style: "short", time_style: "short", locale, }); return `${cameraName} • ${time}`; } return cameraName; }, [config, locale, selectedReviews], ); const defaultCaseName = useMemo(() => { const formattedDate = formatUnixTimestampToDateTime(Date.now() / 1000, { date_style: "medium", time_style: "short", locale, }); return t("export.multi.caseNamePlaceholder", { ns: "components/dialog", date: formattedDate, }); }, [t, locale]); const resetState = useCallback(() => { setCaseSelection(NONE_CASE_OPTION); setNewCaseName(""); setNewCaseDescription(""); setIsExporting(false); }, []); const handleOpenChange = useCallback( (next: boolean) => { if (!next) { resetState(); } else { // Freshly reset each time so the default name reflects "now" setCaseSelection(NONE_CASE_OPTION); setNewCaseName(defaultCaseName); setNewCaseDescription(""); setIsExporting(false); } setOpen(next); }, [defaultCaseName, resetState], ); const existingCases = useMemo(() => { return (cases ?? []).slice().sort((a, b) => a.name.localeCompare(b.name)); }, [cases]); const isNewCase = caseSelection === NEW_CASE_OPTION; const canSubmit = useMemo(() => { if (isExporting) return false; if (count === 0) return false; if (!isAdmin) return true; if (isNewCase) { return newCaseName.trim().length > 0; } return caseSelection.length > 0; }, [caseSelection, count, isAdmin, isExporting, isNewCase, newCaseName]); const handleSubmit = useCallback(async () => { if (!canSubmit) return; const items = selectedReviews.map((review) => ({ camera: review.camera, start_time: review.start_time - REVIEW_PADDING, end_time: (review.end_time ?? Date.now() / 1000) + REVIEW_PADDING, image_path: review.thumb_path || undefined, friendly_name: review.data.metadata?.title?.trim() || undefined, client_item_id: review.id, })); const payload: BatchExportBody = { items }; if (isAdmin && caseSelection !== NONE_CASE_OPTION) { if (isNewCase) { payload.new_case_name = newCaseName.trim(); payload.new_case_description = newCaseDescription.trim() || undefined; } else { payload.export_case_id = caseSelection; } } setIsExporting(true); try { const response = await axios.post( "exports/batch", payload, ); const results = response.data.results ?? []; const successful = results.filter((r) => r.success); const failed = results.filter((r) => !r.success); if (successful.length > 0 && failed.length === 0) { toast.success( t( isAdmin ? "export.multi.toast.started" : "export.multi.toast.startedNoCase", { ns: "components/dialog", count: successful.length, }, ), { position: "top-center" }, ); } else if (successful.length > 0 && failed.length > 0) { // Resolve each failure to its review via item_index so same-camera // items are disambiguated by time. Falls back to camera-only if the // server didn't populate item_index. const failedLabels = failed.map(formatFailureLabel).join(", "); toast.success( t("export.multi.toast.partial", { ns: "components/dialog", successful: successful.length, total: results.length, failedItems: failedLabels, }), { position: "top-center" }, ); } else { const failedLabels = failed.map(formatFailureLabel).join(", "); toast.error( t("export.multi.toast.failed", { ns: "components/dialog", total: results.length, failedItems: failedLabels, }), { position: "top-center" }, ); } if (successful.length > 0) { onStarted(); setOpen(false); resetState(); if (response.data.export_case_id) { navigate(`/export?caseId=${response.data.export_case_id}`); } } } 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("export.toast.error.failed", { ns: "components/dialog", error: errorMessage, }), { position: "top-center" }, ); } finally { setIsExporting(false); } }, [ canSubmit, caseSelection, formatFailureLabel, isAdmin, isNewCase, navigate, newCaseDescription, newCaseName, onStarted, resetState, selectedReviews, t, ]); // New-case inputs: rendered below the Select when caseSelection === "new", // or rendered standalone for non-admins (who never see the Select since // they cannot attach to an existing case). const newCaseInputs = (
setNewCaseName(event.target.value)} maxLength={100} autoFocus={isDesktop} />