From 3ef0186464a7f9ac126aba80653a2ad05cede24b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:52:41 -0500 Subject: [PATCH] frontend + i18n --- web/public/locales/en/components/dialog.json | 28 +- web/public/locales/en/views/exports.json | 45 +- web/src/components/card/ExportCard.tsx | 53 +- web/src/components/overlay/ExportDialog.tsx | 734 +++++++++++++++--- .../overlay/MobileReviewSettingsDrawer.tsx | 58 +- .../components/overlay/SaveExportOverlay.tsx | 28 +- web/src/pages/Exports.tsx | 544 +++++++++++-- web/src/types/export.ts | 33 +- web/src/types/filter.ts | 2 +- .../views/motion-search/MotionSearchView.tsx | 36 +- web/src/views/recording/RecordingView.tsx | 3 +- 11 files changed, 1354 insertions(+), 210 deletions(-) diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index 9a6f68daf..3a9196063 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -50,15 +50,40 @@ "placeholder": "Name the Export" }, "case": { + "newCaseOption": "Create new case", + "newCaseNamePlaceholder": "New case name", + "newCaseDescriptionPlaceholder": "Case description", "label": "Case", "placeholder": "Select a case" }, "select": "Select", "export": "Export", "selectOrExport": "Select or Export", + "tabs": { + "export": "Single Camera", + "multiCamera": "Multi-Camera" + }, + "multiCamera": { + "timeRange": "Time range", + "selectFromTimeline": "Select from Timeline", + "cameraSelection": "Cameras", + "selectedCount_one": "1 selected", + "selectedCount_other": "{{count}} selected", + "checkingActivity": "Checking camera activity...", + "noCameras": "No cameras available", + "detectionCount_one": "1 detection", + "detectionCount_other": "{{count}} detections", + "namePlaceholder": "Optional base name for these exports", + "exportButton_one": "Export 1 Camera", + "exportButton_other": "Export {{count}} Cameras" + }, "toast": { "success": "Successfully started export. View the file in the exports page.", "view": "View", + "batchSuccess_one": "Started 1 export. Opening the case now.", + "batchSuccess_other": "Started {{count}} exports. Opening the case now.", + "batchPartial": "Started {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}", + "batchFailed": "Failed to start {{total}} exports. Failed cameras: {{failedCameras}}", "error": { "failed": "Failed to start export: {{error}}", "endTimeMustAfterStartTime": "End time must be after start time", @@ -67,7 +92,8 @@ }, "fromTimeline": { "saveExport": "Save Export", - "previewExport": "Preview Export" + "previewExport": "Preview Export", + "useThisRange": "Use This Range" } }, "streaming": { diff --git a/web/public/locales/en/views/exports.json b/web/public/locales/en/views/exports.json index 46cd06ead..b8b0eb6c5 100644 --- a/web/public/locales/en/views/exports.json +++ b/web/public/locales/en/views/exports.json @@ -22,12 +22,24 @@ "deleteExport": "Delete export", "assignToCase": "Add to case" }, + "toolbar": { + "newCase": "New Case", + "addExport": "Add Export", + "editCase": "Edit Case", + "deleteCase": "Delete Case" + }, "toast": { "error": { "renameExportFailed": "Failed to rename export: {{errorMessage}}", - "assignCaseFailed": "Failed to update case assignment: {{errorMessage}}" + "assignCaseFailed": "Failed to update case assignment: {{errorMessage}}", + "caseSaveFailed": "Failed to save case: {{errorMessage}}", + "caseDeleteFailed": "Failed to delete case: {{errorMessage}}" } }, + "deleteCase": { + "label": "Delete Case", + "desc": "Are you sure you want to delete {{caseName}}? Exports will remain available as uncategorized exports." + }, "caseDialog": { "title": "Add to case", "description": "Choose an existing case or create a new one.", @@ -35,5 +47,36 @@ "newCaseOption": "Create new case", "nameLabel": "Case name", "descriptionLabel": "Description" + }, + "caseCard": { + "exportCount_one": "1 export", + "exportCount_other": "{{count}} exports", + "cameraCount_one": "1 camera", + "cameraCount_other": "{{count}} cameras", + "emptyCase": "No exports yet" + }, + "caseView": { + "noDescription": "No description", + "createdAt": "Created {{value}}", + "exportCount_one": "1 export", + "exportCount_other": "{{count}} exports", + "cameraCount_one": "1 camera", + "cameraCount_other": "{{count}} cameras", + "showMore": "Show more", + "showLess": "Show less", + "emptyTitle": "This case is empty", + "emptyDescription": "Add existing uncategorized exports to keep the case organized.", + "emptyDescriptionNoExports": "There are no uncategorized exports available to add yet." + }, + "caseEditor": { + "createTitle": "Create Case", + "editTitle": "Edit Case", + "namePlaceholder": "Case name", + "descriptionPlaceholder": "Add notes or context for this case" + }, + "addExportDialog": { + "title": "Add Export to {{caseName}}", + "searchPlaceholder": "Search uncategorized exports", + "empty": "No uncategorized exports match this search." } } diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index c8d9c4c65..cc412801a 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -28,6 +28,10 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { FaFolder } from "react-icons/fa"; +import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name"; +import { useFormattedTimestamp, useTimeFormat } from "@/hooks/use-date-utils"; +import useSWR from "swr"; +import { FrigateConfig } from "@/types/frigateConfig"; type CaseCardProps = { className: string; @@ -41,10 +45,15 @@ export function CaseCard({ exports, onSelect, }: CaseCardProps) { + const { t } = useTranslation(["views/exports"]); const firstExport = useMemo( () => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0), [exports], ); + const cameraCount = useMemo( + () => new Set(exports.map((exp) => exp.camera)).size, + [exports], + ); return (
)} + {!firstExport && ( +
+ )}
-
- -
{exportCase.name}
+
+
+ {t("caseCard.exportCount", { count: exports.length })} +
+
+ {t("caseCard.cameraCount", { count: cameraCount })} +
+
+
+
+ +
{exportCase.name}
+
+
+ {exports.length === 0 + ? t("caseCard.emptyCase") + : exportCase.description} +
); @@ -88,6 +115,16 @@ export function ExportCard({ }: ExportCardProps) { const { t } = useTranslation(["views/exports"]); const isAdmin = useIsAdmin(); + const cameraName = useCameraFriendlyName(exportedRecording.camera); + const { data: config } = useSWR("config"); + const timeFormat = useTimeFormat(config); + const formattedDate = useFormattedTimestamp( + exportedRecording.date, + t(`time.formattedTimestampMonthDayYearHourMinute.${timeFormat}`, { + ns: "common", + }), + config?.ui.timezone, + ); const [loading, setLoading] = useState( exportedRecording.thumb_path.length > 0, ); @@ -291,9 +328,15 @@ export function ExportCard({ {loading && ( )} +
+ {cameraName} +
-
- {exportedRecording.name.replaceAll("_", " ")} +
+
+ {exportedRecording.name.replaceAll("_", " ")} +
+
{formattedDate}
diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index 6912ebf46..c42ecbd74 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Dialog, DialogContent, @@ -18,6 +18,12 @@ import { toast } from "sonner"; import { Input } from "../ui/input"; import { TimeRange } from "@/types/timeline"; import useSWR from "swr"; +import { + BatchExportBody, + BatchExportResponse, + CameraActivity, + ExportCase, +} from "@/types/export"; import { Select, SelectContent, @@ -33,8 +39,14 @@ import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import { GenericVideoPlayer } from "../player/GenericVideoPlayer"; import { useTranslation } from "react-i18next"; -import { ExportCase } from "@/types/export"; import { CustomTimeSelector } from "./CustomTimeSelector"; +import { Event } from "@/types/event"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { resolveCameraName } from "@/hooks/use-camera-friendly-name"; +import { Checkbox } from "../ui/checkbox"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; +import { Textarea } from "../ui/textarea"; +import { useNavigate } from "react-router-dom"; const EXPORT_OPTIONS = [ "1", @@ -46,6 +58,7 @@ const EXPORT_OPTIONS = [ "custom", ] as const; type ExportOption = (typeof EXPORT_OPTIONS)[number]; +export type ExportTab = "export" | "multi"; type ExportDialogProps = { camera: string; @@ -58,6 +71,7 @@ type ExportDialogProps = { setMode: (mode: ExportMode) => void; setShowPreview: (showPreview: boolean) => void; }; + export default function ExportDialog({ camera, latestTime, @@ -71,9 +85,27 @@ export default function ExportDialog({ }: ExportDialogProps) { const { t } = useTranslation(["components/dialog"]); const [name, setName] = useState(""); - const [selectedCaseId, setSelectedCaseId] = useState( - undefined, - ); + const [selectedCaseId, setSelectedCaseId] = useState(); + const [activeTab, setActiveTab] = useState("export"); + 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(() => { if (!range) { @@ -127,13 +159,14 @@ export default function ExportDialog({ { position: "top-center" }, ); }); - }, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]); + }, [camera, name, range, selectedCaseId, setMode, setRange, t]); const handleCancel = useCallback(() => { setName(""); setSelectedCaseId(undefined); setMode("none"); setRange(undefined); + setActiveTab("export"); }, [setMode, setRange]); const Overlay = isDesktop ? Dialog : Drawer; @@ -150,16 +183,30 @@ export default function ExportDialog({ /> setShowPreview(true)} - onSave={() => onStartExport()} + onSave={() => { + if (mode == "timeline_multi") { + setActiveTab("multi"); + setMode("select"); + return; + } + + onStartExport(); + }} onCancel={handleCancel} /> { if (!open) { - setMode("none"); + handleCancel(); } }} > @@ -171,22 +218,16 @@ export default function ExportDialog({ size="sm" onClick={() => { const now = new Date(latestTime * 1000); - let start = 0; now.setHours(now.getHours() - 1); - start = now.getTime() / 1000; + setActiveTab("export"); setRange({ before: latestTime, - after: start, + after: now.getTime() / 1000, }); setMode("select"); }} > - {isDesktop && ( -
- {t("menu.export", { ns: "common" })} -
- )} )} @@ -203,7 +244,9 @@ export default function ExportDialog({ range={range} name={name} selectedCaseId={selectedCaseId} + activeTab={activeTab} onStartExport={onStartExport} + setActiveTab={setActiveTab} setName={setName} setSelectedCaseId={setSelectedCaseId} setRange={setRange} @@ -222,20 +265,25 @@ type ExportContentProps = { range?: TimeRange; name: string; selectedCaseId?: string; + activeTab: ExportTab; onStartExport: () => void; + setActiveTab: (tab: ExportTab) => void; setName: (name: string) => void; setSelectedCaseId: (caseId: string | undefined) => void; setRange: (range: TimeRange | undefined) => void; setMode: (mode: ExportMode) => void; onCancel: () => void; }; + export function ExportContent({ latestTime, currentTime, range, name, selectedCaseId, + activeTab, onStartExport, + setActiveTab, setName, setSelectedCaseId, setRange, @@ -243,8 +291,143 @@ export function ExportContent({ onCancel, }: ExportContentProps) { const { t } = useTranslation(["components/dialog"]); + const navigate = useNavigate(); const [selectedOption, setSelectedOption] = useState("1"); const { data: cases } = useSWR("cases"); + 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 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 counts = new Map(); + + events?.forEach((event) => { + counts.set(event.camera, (counts.get(event.camera) ?? 0) + 1); + }); + + const maxCount = Math.max(1, ...Array.from(counts.values()), 1); + + return allCameraIds.map((cameraId) => { + const count = counts.get(cameraId) ?? 0; + return { + camera: cameraId, + count, + intensity: count / maxCount, + hasDetections: count > 0, + }; + }); + }, [config?.cameras, 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 && + ((batchCaseSelection !== "none" && batchCaseSelection !== "new") || + (batchCaseSelection === "new" && newCaseName.trim().length > 0)); const onSelectTime = useCallback( (option: ExportOption) => { @@ -252,6 +435,7 @@ export function ExportContent({ const now = new Date(latestTime * 1000); let start = 0; + switch (option) { case "1": now.setHours(now.getHours() - 1); @@ -276,6 +460,8 @@ export function ExportContent({ case "custom": start = latestTime - 3600; break; + default: + start = latestTime - 3600; } setRange({ @@ -286,6 +472,139 @@ export function ExportContent({ [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 (!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 = { + start_time: Math.round(range.after), + end_time: Math.round(range.before), + camera_ids: selectedCameraIds, + name: name || undefined, + }; + + if (batchCaseSelection === "new") { + payload.new_case_name = newCaseName.trim(); + payload.new_case_description = newCaseDescription.trim() || undefined; + } else if (batchCaseSelection !== "none") { + payload.export_case_id = batchCaseSelection; + } + + 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.batchPartial", { + 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.batchFailed", { + total: results.length, + failedCameras: failedResults + .map((result) => resolveCameraName(config, result.camera)) + .join(", "), + }), + { + position: "top-center", + description: failedSummary, + }, + ); + } else { + toast.success( + t("export.toast.batchSuccess", { + count: successfulResults.length, + }), + { position: "top-center" }, + ); + } + + if (successfulResults.length > 0) { + setName(""); + setSelectedCaseId(undefined); + setBatchCaseSelection("none"); + setNewCaseName(""); + setNewCaseDescription(""); + setRange(undefined); + setMode("none"); + setActiveTab("export"); + 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" }, + ); + } + }, [ + batchCaseSelection, + config, + name, + newCaseDescription, + newCaseName, + range, + selectedCameraIds, + setActiveTab, + setMode, + setName, + setRange, + setSelectedCaseId, + t, + navigate, + ]); + return (
{isDesktop && ( @@ -296,89 +615,253 @@ export function ExportContent({ )} - onSelectTime(value as ExportOption)} + + setActiveTab(value as ExportTab)} + className="w-full" > - {EXPORT_OPTIONS.map((opt) => { - return ( -
- - -
- ); - })} -
- {selectedOption == "custom" && ( - - )} - setName(e.target.value)} - /> -
- - setName(e.target.value)} + /> + +
+ + -
+ {cases + ?.sort((a, b) => a.name.localeCompare(b.name)) + .map((caseItem) => ( + + {caseItem.name} + + ))} + + +
+ + + +
+ + + +
+ +
+
+ +
+ {t("export.multiCamera.selectedCount", { + count: selectedCameraCount, + })} +
+
+
+ {isEventsLoading && ( +
+ {t("export.multiCamera.checkingActivity")} +
+ )} + {!isEventsLoading && cameraActivities.length === 0 && ( +
+ {t("export.multiCamera.noCameras")} +
+ )} + {cameraActivities.map((activity) => { + const isSelected = selectedCameraIds.includes(activity.camera); + + return ( + + ); + })} +
+
+ + setName(e.target.value)} + /> + +
+ + + {batchCaseSelection === "new" && ( +
+ setNewCaseName(event.target.value)} + /> +