import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "../ui/dialog"; import { Label } from "../ui/label"; import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; import { Button } from "../ui/button"; import { ExportMode } from "@/types/filter"; import { FaArrowDown } from "react-icons/fa"; import { LuAudioLines } from "react-icons/lu"; import axios from "axios"; import { toast } from "sonner"; import { Input } from "../ui/input"; import { TimeRange } from "@/types/timeline"; import useSWR from "swr"; import { BatchExportBody, BatchExportResponse, CameraActivity, ExportCase, StartExportResponse, } from "@/types/export"; import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue, } from "../ui/select"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { isDesktop, isMobile } from "react-device-detect"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; import SaveExportOverlay from "./SaveExportOverlay"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import { GenericVideoPlayer } from "../player/GenericVideoPlayer"; import { useTranslation } from "react-i18next"; import { CustomTimeSelector } from "./CustomTimeSelector"; import { Event } from "@/types/event"; import { FrigateConfig } from "@/types/frigateConfig"; import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { Textarea } from "../ui/textarea"; import { useNavigate } from "react-router-dom"; import { useIsAdmin } from "@/hooks/use-is-admin"; const EXPORT_OPTIONS = [ "1", "4", "8", "12", "24", "timeline", "custom", ] as const; type ExportOption = (typeof EXPORT_OPTIONS)[number]; export type ExportTab = "export" | "multi"; type ExportDialogProps = { camera: string; latestTime: number; currentTime: number; range?: TimeRange; mode: ExportMode; showPreview: boolean; setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; setShowPreview: (showPreview: boolean) => void; }; export default function ExportDialog({ camera, latestTime, currentTime, range, mode, showPreview, setRange, setMode, setShowPreview, }: ExportDialogProps) { const { t } = useTranslation(["components/dialog"]); const [name, setName] = useState(""); const [selectedCaseId, setSelectedCaseId] = useState(); const [singleNewCaseName, setSingleNewCaseName] = useState(""); const [singleNewCaseDescription, setSingleNewCaseDescription] = useState(""); const [activeTab, setActiveTab] = useState("export"); const [isStartingExport, setIsStartingExport] = useState(false); const previousModeRef = useRef(mode); useEffect(() => { const previousMode = previousModeRef.current; if (mode === "select" && previousMode === "none") { setActiveTab("export"); } if (mode === "select" && previousMode === "timeline_multi") { setActiveTab("multi"); } if (mode === "none") { setActiveTab("export"); } previousModeRef.current = mode; }, [mode]); const onStartExport = useCallback(async () => { if (isStartingExport) { return false; } if (!range) { toast.error(t("export.toast.error.noVaildTimeSelected"), { position: "top-center", }); return false; } if (range.before < range.after) { toast.error(t("export.toast.error.endTimeMustAfterStartTime"), { position: "top-center", }); return false; } setIsStartingExport(true); try { let exportCaseId: string | undefined = selectedCaseId; if (selectedCaseId === "new" && singleNewCaseName.trim().length > 0) { const caseResp = await axios.post("cases", { name: singleNewCaseName.trim(), description: singleNewCaseDescription.trim() || undefined, }); exportCaseId = caseResp.data?.id; } else if (selectedCaseId === "new" || selectedCaseId === "none") { exportCaseId = undefined; } await axios.post( `export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`, { source: "recordings", name, export_case_id: exportCaseId, }, ); toast.success(t("export.toast.queued"), { position: "top-center", action: ( ), }); setName(""); setSelectedCaseId(undefined); setSingleNewCaseName(""); setSingleNewCaseDescription(""); setRange(undefined); setMode("none"); 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("export.toast.error.failed", { error: errorMessage, }), { position: "top-center" }, ); return false; } finally { setIsStartingExport(false); } }, [ camera, isStartingExport, name, range, selectedCaseId, singleNewCaseDescription, singleNewCaseName, setMode, setRange, t, ]); const handleCancel = useCallback(() => { setName(""); setSelectedCaseId(undefined); setSingleNewCaseName(""); setSingleNewCaseDescription(""); setMode("none"); setRange(undefined); setActiveTab("export"); }, [setMode, setRange]); const Overlay = isDesktop ? Dialog : Drawer; const Trigger = isDesktop ? DialogTrigger : DrawerTrigger; const Content = isDesktop ? DialogContent : DrawerContent; return ( <> setShowPreview(true)} onSave={() => { if (mode == "timeline_multi") { setActiveTab("multi"); setMode("select"); return; } void onStartExport(); }} onCancel={handleCancel} /> { if (!open) { handleCancel(); } }} > {!isDesktop && ( )} ); } type ExportContentProps = { latestTime: number; currentTime: number; range?: TimeRange; name: string; selectedCaseId?: string; singleNewCaseName: string; singleNewCaseDescription: string; activeTab: ExportTab; isStartingExport: boolean; onStartExport: () => Promise; setActiveTab: (tab: ExportTab) => void; setName: (name: string) => void; setSelectedCaseId: (caseId: string | undefined) => void; setSingleNewCaseName: (name: string) => void; setSingleNewCaseDescription: (description: string) => void; setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; onCancel: () => void; }; export function ExportContent({ latestTime, currentTime, range, name, selectedCaseId, singleNewCaseName, singleNewCaseDescription, activeTab, isStartingExport, onStartExport, setActiveTab, setName, setSelectedCaseId, setSingleNewCaseName, setSingleNewCaseDescription, setRange, setMode, onCancel, }: ExportContentProps) { const { t } = useTranslation(["components/dialog"]); const navigate = useNavigate(); const isAdmin = useIsAdmin(); const [selectedOption, setSelectedOption] = useState("1"); const { data: cases } = useSWR(isAdmin ? "cases" : null); const { data: config } = useSWR("config"); const [debouncedRange, setDebouncedRange] = useState( range, ); const [selectedCameraIds, setSelectedCameraIds] = useState([]); const [batchCaseSelection, setBatchCaseSelection] = useState( selectedCaseId || "none", ); const [hasManualCameraSelection, setHasManualCameraSelection] = useState(false); const [newCaseName, setNewCaseName] = useState(""); const [newCaseDescription, setNewCaseDescription] = useState(""); const [isStartingBatchExport, setIsStartingBatchExport] = useState(false); const multiRangeKey = useMemo(() => { if (activeTab !== "multi" || !range) { return undefined; } return `${Math.round(range.after)}-${Math.round(range.before)}`; }, [activeTab, range]); useEffect(() => { if (activeTab !== "multi") { setDebouncedRange(undefined); return; } if (!range) { setDebouncedRange(undefined); return; } const timeoutId = window.setTimeout(() => { setDebouncedRange(range); }, 300); return () => window.clearTimeout(timeoutId); }, [activeTab, range]); useEffect(() => { if (activeTab !== "multi") { return; } if (selectedCaseId) { setBatchCaseSelection(selectedCaseId); return; } if ((cases?.length ?? 0) === 0) { setBatchCaseSelection("new"); return; } setBatchCaseSelection("new"); }, [activeTab, cases?.length, selectedCaseId]); useEffect(() => { setHasManualCameraSelection(false); }, [multiRangeKey]); useEffect(() => { if (activeTab !== "multi" || range) { return; } setRange({ before: latestTime, after: latestTime - 3600, }); }, [activeTab, latestTime, range, setRange]); const { data: events, isLoading: isEventsLoading } = useSWR( activeTab === "multi" && debouncedRange ? [ "events", { after: Math.round(debouncedRange.after), before: Math.round(debouncedRange.before), limit: 500, }, ] : null, ); const cameraActivities = useMemo(() => { const allCameraIds = Object.keys(config?.cameras ?? {}); const byCamera = new Map(); events?.forEach((event) => { const bucket = byCamera.get(event.camera); if (bucket) { bucket.push(event); } else { byCamera.set(event.camera, [event]); } }); const rangeStart = debouncedRange?.after ?? 0; const rangeEnd = debouncedRange?.before ?? 0; const rangeDuration = Math.max(1, rangeEnd - rangeStart); return allCameraIds.map((cameraId) => { const cameraEvents = byCamera.get(cameraId) ?? []; const segments = cameraEvents .map((event) => { // Event end_time is null for in-progress events; fall back to start. const eventEnd = event.end_time ?? event.start_time; const start = Math.max( 0, Math.min(1, (event.start_time - rangeStart) / rangeDuration), ); const end = Math.max( 0, Math.min(1, (eventEnd - rangeStart) / rangeDuration), ); return { start, end: Math.max(end, start) }; }) .sort((a, b) => a.start - b.start); return { camera: cameraId, count: cameraEvents.length, hasDetections: cameraEvents.length > 0, segments, }; }); }, [config?.cameras, debouncedRange, events]); useEffect(() => { if ( activeTab !== "multi" || !config || isEventsLoading || hasManualCameraSelection ) { return; } setSelectedCameraIds( cameraActivities .filter((activity) => activity.hasDetections) .map((activity) => activity.camera), ); }, [ activeTab, cameraActivities, config, hasManualCameraSelection, isEventsLoading, ]); const selectedCameraCount = selectedCameraIds.length; const canStartBatchExport = Boolean(range && range.before > range.after) && selectedCameraCount > 0 && !isStartingBatchExport && (batchCaseSelection !== "new" || newCaseName.trim().length > 0) && batchCaseSelection.length > 0; const onSelectTime = useCallback( (option: ExportOption) => { setSelectedOption(option); const now = new Date(latestTime * 1000); let start = 0; switch (option) { case "1": now.setHours(now.getHours() - 1); start = now.getTime() / 1000; break; case "4": now.setHours(now.getHours() - 4); start = now.getTime() / 1000; break; case "8": now.setHours(now.getHours() - 8); start = now.getTime() / 1000; break; case "12": now.setHours(now.getHours() - 12); start = now.getTime() / 1000; break; case "24": now.setHours(now.getHours() - 24); start = now.getTime() / 1000; break; case "custom": start = latestTime - 3600; break; default: start = latestTime - 3600; } setRange({ before: latestTime, after: start, }); }, [latestTime, setRange], ); const toggleCameraSelection = useCallback((cameraId: string) => { setHasManualCameraSelection(true); setSelectedCameraIds((previous) => previous.includes(cameraId) ? previous.filter((selectedId) => selectedId !== cameraId) : [...previous, cameraId], ); }, []); const startBatchExport = useCallback(async () => { if (isStartingBatchExport) { return; } if (!range) { toast.error(t("export.toast.error.noVaildTimeSelected"), { position: "top-center", }); return; } if (range.before <= range.after) { toast.error(t("export.toast.error.endTimeMustAfterStartTime"), { position: "top-center", }); return; } const payload: BatchExportBody = { items: selectedCameraIds.map((cameraId) => ({ camera: cameraId, start_time: Math.round(range.after), end_time: Math.round(range.before), friendly_name: name ? `${name} - ${resolveCameraName(config, cameraId)}` : undefined, })), }; if (isAdmin && batchCaseSelection !== "none") { if (batchCaseSelection === "new") { payload.new_case_name = newCaseName.trim(); payload.new_case_description = newCaseDescription.trim() || undefined; } else { payload.export_case_id = batchCaseSelection; } } setIsStartingBatchExport(true); try { const response = await axios.post( "exports/batch", payload, ); const results = response.data.results; const successfulResults = results.filter((result) => result.success); const failedResults = results.filter((result) => !result.success); const failedSummary = failedResults .map((result) => { const cameraName = resolveCameraName(config, result.camera); return result.error ? `${cameraName}: ${result.error}` : cameraName; }) .join(", "); if (failedResults.length > 0 && successfulResults.length > 0) { toast.success( t("export.toast.batchQueuedPartial", { successful: successfulResults.length, total: results.length, failedCameras: failedResults .map((result) => resolveCameraName(config, result.camera)) .join(", "), }), { position: "top-center", description: failedSummary, }, ); } else if (failedResults.length > 0) { toast.error( t("export.toast.batchQueueFailed", { total: results.length, failedCameras: failedResults .map((result) => resolveCameraName(config, result.camera)) .join(", "), }), { position: "top-center", description: failedSummary, }, ); } else { toast.success( t("export.toast.batchQueuedSuccess", { count: successfulResults.length, }), { position: "top-center" }, ); } if (successfulResults.length > 0) { setName(""); setSelectedCaseId(undefined); setBatchCaseSelection("new"); setNewCaseName(""); setNewCaseDescription(""); setRange(undefined); setMode("none"); setActiveTab("export"); 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", { error: errorMessage, }), { position: "top-center" }, ); } finally { setIsStartingBatchExport(false); } }, [ batchCaseSelection, config, isAdmin, isStartingBatchExport, name, newCaseDescription, newCaseName, range, selectedCameraIds, setActiveTab, setMode, setName, setRange, setSelectedCaseId, t, navigate, ]); return (
{isDesktop && ( {t("menu.export", { ns: "common" })} )} setActiveTab(value as ExportTab)} className={cn("w-full", !isDesktop && "flex min-h-0 flex-1 flex-col")} > {t("export.tabs.export")} {t("export.tabs.multiCamera")} onSelectTime(value as ExportOption)} value={selectedOption} > {EXPORT_OPTIONS.map((opt) => (
))}
{selectedOption == "custom" && ( )} setName(e.target.value)} /> {isAdmin && (
{selectedCaseId === "new" && (
setSingleNewCaseName(e.target.value)} />