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 })}
+
+
+
+
+
+ {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)}
- />
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {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)}
+ />
+
+
+
+
setBatchCaseSelection(value)}
+ >
+
+
+
+
+
+ {t("label.none", { ns: "common" })}
+
+ {cases
+ ?.sort((a, b) => a.name.localeCompare(b.name))
+ .map((caseItem) => (
+
+ {caseItem.name}
+
+ ))}
+
+
+ {t("export.case.newCaseOption")}
+
+
+
+ {batchCaseSelection === "new" && (
+
+ setNewCaseName(event.target.value)}
+ />
+
+ )}
+
+
+
+
{isDesktop &&
}
{t("button.cancel", { ns: "common" })}
-
+ {activeTab === "export" ? (
+
+ ) : (
+
+ )}
);
diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx
index 4a54bb142..a09a31ba1 100644
--- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx
+++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx
@@ -4,7 +4,7 @@ import { Button } from "../ui/button";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
import { LuBug } from "react-icons/lu";
import { TimeRange } from "@/types/timeline";
-import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
+import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog";
import {
DebugReplayContent,
SaveDebugReplayOverlay,
@@ -102,6 +102,7 @@ export default function MobileReviewSettingsDrawer({
]);
const navigate = useNavigate();
const [drawerMode, setDrawerMode] = useState("none");
+ const [exportTab, setExportTab] = useState("export");
const [selectedReplayOption, setSelectedReplayOption] = useState<
"1" | "5" | "custom" | "timeline"
>("1");
@@ -115,16 +116,26 @@ export default function MobileReviewSettingsDrawer({
);
const onStartExport = useCallback(() => {
if (!range) {
- toast.error(t("toast.error.noValidTimeSelected"), {
- position: "top-center",
- });
+ toast.error(
+ t("export.toast.error.noVaildTimeSelected", {
+ ns: "components/dialog",
+ }),
+ {
+ position: "top-center",
+ },
+ );
return;
}
if (range.before < range.after) {
- toast.error(t("toast.error.endTimeMustAfterStartTime"), {
- position: "top-center",
- });
+ toast.error(
+ t("export.toast.error.endTimeMustAfterStartTime", {
+ ns: "components/dialog",
+ }),
+ {
+ position: "top-center",
+ },
+ );
return;
}
@@ -166,7 +177,7 @@ export default function MobileReviewSettingsDrawer({
toast.error(
t("export.toast.error.failed", {
ns: "components/dialog",
- errorMessage,
+ error: errorMessage,
}),
{
position: "top-center",
@@ -267,6 +278,7 @@ export default function MobileReviewSettingsDrawer({
className="flex w-full items-center justify-center gap-2"
aria-label={t("export")}
onClick={() => {
+ setExportTab("export");
setDrawerMode("export");
setMode("select");
}}
@@ -331,14 +343,16 @@ export default function MobileReviewSettingsDrawer({
range={range}
name={name}
selectedCaseId={selectedCaseId}
+ activeTab={exportTab}
onStartExport={onStartExport}
+ setActiveTab={setExportTab}
setName={setName}
setSelectedCaseId={setSelectedCaseId}
setRange={setRange}
setMode={(mode) => {
setMode(mode);
- if (mode == "timeline") {
+ if (mode == "timeline" || mode == "timeline_multi") {
setDrawerMode("none");
}
}}
@@ -346,6 +360,7 @@ export default function MobileReviewSettingsDrawer({
setMode("none");
setRange(undefined);
setSelectedCaseId(undefined);
+ setExportTab("export");
setDrawerMode("select");
}}
/>
@@ -483,9 +498,28 @@ export default function MobileReviewSettingsDrawer({
<>
onStartExport()}
- onCancel={() => setMode("none")}
+ show={mode == "timeline" || mode == "timeline_multi"}
+ hidePreview={mode == "timeline_multi"}
+ saveLabel={
+ mode == "timeline_multi"
+ ? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
+ : undefined
+ }
+ onSave={() => {
+ if (mode == "timeline_multi") {
+ setExportTab("multi");
+ setDrawerMode("export");
+ setMode("select");
+ return;
+ }
+
+ onStartExport();
+ }}
+ onCancel={() => {
+ setExportTab("export");
+ setRange(undefined);
+ setMode("none");
+ }}
onPreview={() => setShowExportPreview(true)}
/>
void;
onSave: () => void;
onCancel: () => void;
@@ -14,6 +16,8 @@ type SaveExportOverlayProps = {
export default function SaveExportOverlay({
className,
show,
+ hidePreview = false,
+ saveLabel,
onPreview,
onSave,
onCancel,
@@ -37,24 +41,26 @@ export default function SaveExportOverlay({
{t("button.cancel", { ns: "common" })}
+ {!hidePreview && (
+
+ )}
-
diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx
index 065e0c900..98560593e 100644
--- a/web/src/pages/Exports.tsx
+++ b/web/src/pages/Exports.tsx
@@ -13,12 +13,14 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import Heading from "@/components/ui/heading";
import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
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,
@@ -27,6 +29,7 @@ import {
} from "@/types/export";
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
import axios from "axios";
+import { FrigateConfig } from "@/types/frigateConfig";
import {
MutableRefObject,
@@ -39,6 +42,7 @@ import {
import { isMobile, isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next";
+import { IoMdArrowRoundBack } from "react-icons/io";
import { LuFolderX } from "react-icons/lu";
import { toast } from "sonner";
import useSWR from "swr";
@@ -71,7 +75,7 @@ function Exports() {
const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => {
const grouped: { [caseId: string]: Export[] } = {};
(rawExports ?? []).forEach((exp) => {
- const caseId = exp.export_case || "none";
+ const caseId = exp.export_case ?? exp.export_case_id ?? "none";
if (!grouped[caseId]) {
grouped[caseId] = [];
}
@@ -82,15 +86,8 @@ function Exports() {
}, [rawExports]);
const filteredCases = useMemo(() => {
- if (!cases) {
- return [];
- }
-
- return cases.filter((caseItem) => {
- const caseExports = exportsByCase[caseItem.id];
- return caseExports?.length;
- });
- }, [cases, exportsByCase]);
+ return cases || [];
+ }, [cases]);
const exports = useMemo(
() => exportsByCase["none"] || [],
@@ -149,6 +146,13 @@ function Exports() {
const [deleteClip, setDeleteClip] = useState();
const [exportToAssign, setExportToAssign] = useState();
+ const [caseDialog, setCaseDialog] = useState<
+ { mode: "create" | "edit"; exportCase?: ExportCase } | undefined
+ >();
+ const [caseToDelete, setCaseToDelete] = useState();
+ const [caseForAddExport, setCaseForAddExport] = useState<
+ ExportCase | undefined
+ >();
const onHandleDelete = useCallback(() => {
if (!deleteClip) {
@@ -194,8 +198,115 @@ function Exports() {
useKeyboardListener([], undefined, contentRef);
const selectedCase = useMemo(
- () => filteredCases?.find((c) => c.id === selectedCaseId),
- [filteredCases, selectedCaseId],
+ () => 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}`);
+ if (selectedCaseId === caseToDelete.id) {
+ setSelectedCaseId(undefined);
+ }
+ setCaseToDelete(undefined);
+ 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, mutate, selectedCaseId, t]);
+
+ const handleAssignExportToCase = useCallback(
+ async (exportId: string, caseId: string) => {
+ try {
+ await axios.patch(`export/${exportId}/case`, {
+ export_case_id: caseId,
+ });
+ 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(() => {
@@ -206,6 +317,19 @@ function Exports() {
+
setCaseDialog(undefined)}
+ onSave={handleSaveCase}
+ />
+
+ setCaseForAddExport(undefined)}
+ onAssign={handleAssignExportToCase}
+ />
+
+ setCaseToDelete(undefined)}
+ >
+
+
+ {t("deleteCase.label")}
+
+ {t("deleteCase.desc", {
+ caseName: caseToDelete?.name,
+ })}
+
+
+
+
+ {t("button.cancel", { ns: "common" })}
+
+
+
+
+
+