frontend + i18n

This commit is contained in:
Josh Hawkins 2026-04-11 07:52:41 -05:00
parent 855e5cc0f9
commit 3ef0186464
11 changed files with 1354 additions and 210 deletions

View File

@ -50,15 +50,40 @@
"placeholder": "Name the Export" "placeholder": "Name the Export"
}, },
"case": { "case": {
"newCaseOption": "Create new case",
"newCaseNamePlaceholder": "New case name",
"newCaseDescriptionPlaceholder": "Case description",
"label": "Case", "label": "Case",
"placeholder": "Select a case" "placeholder": "Select a case"
}, },
"select": "Select", "select": "Select",
"export": "Export", "export": "Export",
"selectOrExport": "Select or 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": { "toast": {
"success": "Successfully started export. View the file in the exports page.", "success": "Successfully started export. View the file in the exports page.",
"view": "View", "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": { "error": {
"failed": "Failed to start export: {{error}}", "failed": "Failed to start export: {{error}}",
"endTimeMustAfterStartTime": "End time must be after start time", "endTimeMustAfterStartTime": "End time must be after start time",
@ -67,7 +92,8 @@
}, },
"fromTimeline": { "fromTimeline": {
"saveExport": "Save Export", "saveExport": "Save Export",
"previewExport": "Preview Export" "previewExport": "Preview Export",
"useThisRange": "Use This Range"
} }
}, },
"streaming": { "streaming": {

View File

@ -22,12 +22,24 @@
"deleteExport": "Delete export", "deleteExport": "Delete export",
"assignToCase": "Add to case" "assignToCase": "Add to case"
}, },
"toolbar": {
"newCase": "New Case",
"addExport": "Add Export",
"editCase": "Edit Case",
"deleteCase": "Delete Case"
},
"toast": { "toast": {
"error": { "error": {
"renameExportFailed": "Failed to rename export: {{errorMessage}}", "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": { "caseDialog": {
"title": "Add to case", "title": "Add to case",
"description": "Choose an existing case or create a new one.", "description": "Choose an existing case or create a new one.",
@ -35,5 +47,36 @@
"newCaseOption": "Create new case", "newCaseOption": "Create new case",
"nameLabel": "Case name", "nameLabel": "Case name",
"descriptionLabel": "Description" "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."
} }
} }

View File

@ -28,6 +28,10 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../ui/dropdown-menu"; } from "../ui/dropdown-menu";
import { FaFolder } from "react-icons/fa"; 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 = { type CaseCardProps = {
className: string; className: string;
@ -41,10 +45,15 @@ export function CaseCard({
exports, exports,
onSelect, onSelect,
}: CaseCardProps) { }: CaseCardProps) {
const { t } = useTranslation(["views/exports"]);
const firstExport = useMemo( const firstExport = useMemo(
() => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0), () => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0),
[exports], [exports],
); );
const cameraCount = useMemo(
() => new Set(exports.map((exp) => exp.camera)).size,
[exports],
);
return ( return (
<div <div
@ -61,10 +70,28 @@ export function CaseCard({
alt="" alt=""
/> />
)} )}
{!firstExport && (
<div className="absolute inset-0 bg-gradient-to-br from-secondary via-secondary/80 to-muted" />
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-gradient-to-t from-black/60 to-transparent" /> <div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-2 left-2 z-20 flex items-center justify-start gap-2 text-white"> <div className="absolute left-2 top-2 z-20 flex flex-wrap gap-2 text-xs text-white">
<FaFolder /> <div className="rounded-full bg-black/55 px-2 py-1">
<div className="capitalize">{exportCase.name}</div> {t("caseCard.exportCount", { count: exports.length })}
</div>
<div className="rounded-full bg-black/55 px-2 py-1">
{t("caseCard.cameraCount", { count: cameraCount })}
</div>
</div>
<div className="absolute inset-x-2 bottom-2 z-20 text-white">
<div className="flex items-center justify-start gap-2">
<FaFolder />
<div className="truncate smart-capitalize">{exportCase.name}</div>
</div>
<div className="mt-1 line-clamp-2 text-xs text-white/80">
{exports.length === 0
? t("caseCard.emptyCase")
: exportCase.description}
</div>
</div> </div>
</div> </div>
); );
@ -88,6 +115,16 @@ export function ExportCard({
}: ExportCardProps) { }: ExportCardProps) {
const { t } = useTranslation(["views/exports"]); const { t } = useTranslation(["views/exports"]);
const isAdmin = useIsAdmin(); const isAdmin = useIsAdmin();
const cameraName = useCameraFriendlyName(exportedRecording.camera);
const { data: config } = useSWR<FrigateConfig>("config");
const timeFormat = useTimeFormat(config);
const formattedDate = useFormattedTimestamp(
exportedRecording.date,
t(`time.formattedTimestampMonthDayYearHourMinute.${timeFormat}`, {
ns: "common",
}),
config?.ui.timezone,
);
const [loading, setLoading] = useState( const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0, exportedRecording.thumb_path.length > 0,
); );
@ -291,9 +328,15 @@ export function ExportCard({
{loading && ( {loading && (
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" /> <Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
)} )}
<div className="absolute left-3 top-3 z-30 rounded-full bg-black/55 px-2 py-1 text-xs text-white">
{cameraName}
</div>
<ImageShadowOverlay /> <ImageShadowOverlay />
<div className="absolute bottom-2 left-3 flex items-end text-white smart-capitalize"> <div className="absolute bottom-2 left-3 z-30 text-white">
{exportedRecording.name.replaceAll("_", " ")} <div className="flex items-end smart-capitalize">
{exportedRecording.name.replaceAll("_", " ")}
</div>
<div className="mt-1 text-xs text-white/80">{formattedDate}</div>
</div> </div>
</div> </div>
</> </>

View File

@ -1,4 +1,4 @@
import { useCallback, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -18,6 +18,12 @@ import { toast } from "sonner";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import useSWR from "swr"; import useSWR from "swr";
import {
BatchExportBody,
BatchExportResponse,
CameraActivity,
ExportCase,
} from "@/types/export";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -33,8 +39,14 @@ import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { GenericVideoPlayer } from "../player/GenericVideoPlayer"; import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ExportCase } from "@/types/export";
import { CustomTimeSelector } from "./CustomTimeSelector"; 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 = [ const EXPORT_OPTIONS = [
"1", "1",
@ -46,6 +58,7 @@ const EXPORT_OPTIONS = [
"custom", "custom",
] as const; ] as const;
type ExportOption = (typeof EXPORT_OPTIONS)[number]; type ExportOption = (typeof EXPORT_OPTIONS)[number];
export type ExportTab = "export" | "multi";
type ExportDialogProps = { type ExportDialogProps = {
camera: string; camera: string;
@ -58,6 +71,7 @@ type ExportDialogProps = {
setMode: (mode: ExportMode) => void; setMode: (mode: ExportMode) => void;
setShowPreview: (showPreview: boolean) => void; setShowPreview: (showPreview: boolean) => void;
}; };
export default function ExportDialog({ export default function ExportDialog({
camera, camera,
latestTime, latestTime,
@ -71,9 +85,27 @@ export default function ExportDialog({
}: ExportDialogProps) { }: ExportDialogProps) {
const { t } = useTranslation(["components/dialog"]); const { t } = useTranslation(["components/dialog"]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>( const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>();
undefined, const [activeTab, setActiveTab] = useState<ExportTab>("export");
); const previousModeRef = useRef<ExportMode>(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(() => { const onStartExport = useCallback(() => {
if (!range) { if (!range) {
@ -127,13 +159,14 @@ export default function ExportDialog({
{ position: "top-center" }, { position: "top-center" },
); );
}); });
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]); }, [camera, name, range, selectedCaseId, setMode, setRange, t]);
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
setName(""); setName("");
setSelectedCaseId(undefined); setSelectedCaseId(undefined);
setMode("none"); setMode("none");
setRange(undefined); setRange(undefined);
setActiveTab("export");
}, [setMode, setRange]); }, [setMode, setRange]);
const Overlay = isDesktop ? Dialog : Drawer; const Overlay = isDesktop ? Dialog : Drawer;
@ -150,16 +183,30 @@ export default function ExportDialog({
/> />
<SaveExportOverlay <SaveExportOverlay
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2" className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={mode == "timeline"} show={mode == "timeline" || mode == "timeline_multi"}
hidePreview={mode == "timeline_multi"}
saveLabel={
mode == "timeline_multi"
? t("export.fromTimeline.useThisRange")
: undefined
}
onPreview={() => setShowPreview(true)} onPreview={() => setShowPreview(true)}
onSave={() => onStartExport()} onSave={() => {
if (mode == "timeline_multi") {
setActiveTab("multi");
setMode("select");
return;
}
onStartExport();
}}
onCancel={handleCancel} onCancel={handleCancel}
/> />
<Overlay <Overlay
open={mode == "select"} open={mode == "select"}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
setMode("none"); handleCancel();
} }
}} }}
> >
@ -171,22 +218,16 @@ export default function ExportDialog({
size="sm" size="sm"
onClick={() => { onClick={() => {
const now = new Date(latestTime * 1000); const now = new Date(latestTime * 1000);
let start = 0;
now.setHours(now.getHours() - 1); now.setHours(now.getHours() - 1);
start = now.getTime() / 1000; setActiveTab("export");
setRange({ setRange({
before: latestTime, before: latestTime,
after: start, after: now.getTime() / 1000,
}); });
setMode("select"); setMode("select");
}} }}
> >
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" /> <FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
{isDesktop && (
<div className="text-primary">
{t("menu.export", { ns: "common" })}
</div>
)}
</Button> </Button>
</Trigger> </Trigger>
)} )}
@ -203,7 +244,9 @@ export default function ExportDialog({
range={range} range={range}
name={name} name={name}
selectedCaseId={selectedCaseId} selectedCaseId={selectedCaseId}
activeTab={activeTab}
onStartExport={onStartExport} onStartExport={onStartExport}
setActiveTab={setActiveTab}
setName={setName} setName={setName}
setSelectedCaseId={setSelectedCaseId} setSelectedCaseId={setSelectedCaseId}
setRange={setRange} setRange={setRange}
@ -222,20 +265,25 @@ type ExportContentProps = {
range?: TimeRange; range?: TimeRange;
name: string; name: string;
selectedCaseId?: string; selectedCaseId?: string;
activeTab: ExportTab;
onStartExport: () => void; onStartExport: () => void;
setActiveTab: (tab: ExportTab) => void;
setName: (name: string) => void; setName: (name: string) => void;
setSelectedCaseId: (caseId: string | undefined) => void; setSelectedCaseId: (caseId: string | undefined) => void;
setRange: (range: TimeRange | undefined) => void; setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void; setMode: (mode: ExportMode) => void;
onCancel: () => void; onCancel: () => void;
}; };
export function ExportContent({ export function ExportContent({
latestTime, latestTime,
currentTime, currentTime,
range, range,
name, name,
selectedCaseId, selectedCaseId,
activeTab,
onStartExport, onStartExport,
setActiveTab,
setName, setName,
setSelectedCaseId, setSelectedCaseId,
setRange, setRange,
@ -243,8 +291,143 @@ export function ExportContent({
onCancel, onCancel,
}: ExportContentProps) { }: ExportContentProps) {
const { t } = useTranslation(["components/dialog"]); const { t } = useTranslation(["components/dialog"]);
const navigate = useNavigate();
const [selectedOption, setSelectedOption] = useState<ExportOption>("1"); const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
const { data: cases } = useSWR<ExportCase[]>("cases"); const { data: cases } = useSWR<ExportCase[]>("cases");
const { data: config } = useSWR<FrigateConfig>("config");
const [debouncedRange, setDebouncedRange] = useState<TimeRange | undefined>(
range,
);
const [selectedCameraIds, setSelectedCameraIds] = useState<string[]>([]);
const [batchCaseSelection, setBatchCaseSelection] = useState<string>(
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<Event[]>(
activeTab === "multi" && debouncedRange
? [
"events",
{
after: Math.round(debouncedRange.after),
before: Math.round(debouncedRange.before),
limit: 500,
},
]
: null,
);
const cameraActivities = useMemo<CameraActivity[]>(() => {
const allCameraIds = Object.keys(config?.cameras ?? {});
const counts = new Map<string, number>();
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( const onSelectTime = useCallback(
(option: ExportOption) => { (option: ExportOption) => {
@ -252,6 +435,7 @@ export function ExportContent({
const now = new Date(latestTime * 1000); const now = new Date(latestTime * 1000);
let start = 0; let start = 0;
switch (option) { switch (option) {
case "1": case "1":
now.setHours(now.getHours() - 1); now.setHours(now.getHours() - 1);
@ -276,6 +460,8 @@ export function ExportContent({
case "custom": case "custom":
start = latestTime - 3600; start = latestTime - 3600;
break; break;
default:
start = latestTime - 3600;
} }
setRange({ setRange({
@ -286,6 +472,139 @@ export function ExportContent({
[latestTime, setRange], [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<BatchExportResponse>(
"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 ( return (
<div className="w-full"> <div className="w-full">
{isDesktop && ( {isDesktop && (
@ -296,89 +615,253 @@ export function ExportContent({
<SelectSeparator className="my-4 bg-secondary" /> <SelectSeparator className="my-4 bg-secondary" />
</> </>
)} )}
<RadioGroup
className={`flex flex-col gap-4 ${isDesktop ? "" : "mt-4"}`} <Tabs
onValueChange={(value) => onSelectTime(value as ExportOption)} value={activeTab}
onValueChange={(value) => setActiveTab(value as ExportTab)}
className="w-full"
> >
{EXPORT_OPTIONS.map((opt) => { <TabsList className="grid w-full grid-cols-2">
return ( <TabsTrigger value="export">{t("export.tabs.export")}</TabsTrigger>
<div key={opt} className="flex items-center gap-2"> <TabsTrigger value="multi">
<RadioGroupItem {t("export.tabs.multiCamera")}
className={ </TabsTrigger>
opt == selectedOption </TabsList>
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary" <TabsContent value="export" className="mt-4 space-y-4">
} <RadioGroup
id={opt} className={`flex flex-col gap-4 ${isDesktop ? "" : "mt-1"}`}
value={opt} onValueChange={(value) => onSelectTime(value as ExportOption)}
/> value={selectedOption}
<Label className="cursor-pointer smart-capitalize" htmlFor={opt}> >
{isNaN(parseInt(opt)) {EXPORT_OPTIONS.map((opt) => (
? opt == "timeline" <div key={opt} className="flex items-center gap-2">
? t("export.time.fromTimeline") <RadioGroupItem
: t("export.time." + opt) className={
: t("export.time.lastHour", { opt == selectedOption
count: parseInt(opt), ? "bg-selected from-selected/50 to-selected/90 text-selected"
})} : "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
</Label> }
</div> id={opt}
); value={opt}
})} />
</RadioGroup> <Label
{selectedOption == "custom" && ( className="cursor-pointer smart-capitalize"
<CustomTimeSelector htmlFor={opt}
latestTime={latestTime}
range={range}
setRange={setRange}
startLabel={t("export.time.start.title")}
endLabel={t("export.time.end.title")}
/>
)}
<Input
className="text-md my-6"
type="search"
placeholder={t("export.name.placeholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="my-4">
<Label className="text-sm text-secondary-foreground">
{t("export.case.label", { defaultValue: "Case (optional)" })}
</Label>
<Select
value={selectedCaseId || "none"}
onValueChange={(value) =>
setSelectedCaseId(value === "none" ? undefined : value)
}
>
<SelectTrigger className="mt-2">
<SelectValue
placeholder={t("export.case.placeholder", {
defaultValue: "Select a case (optional)",
})}
/>
</SelectTrigger>
<SelectContent>
<SelectItem
value="none"
className="cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
{t("label.none", { ns: "common" })}
</SelectItem>
{cases
?.sort((a, b) => a.name.localeCompare(b.name))
.map((caseItem) => (
<SelectItem
key={caseItem.id}
value={caseItem.id}
className="cursor-pointer hover:bg-accent hover:text-accent-foreground"
> >
{caseItem.name} {isNaN(parseInt(opt))
? opt == "timeline"
? t("export.time.fromTimeline")
: t(`export.time.${opt}`)
: t("export.time.lastHour", {
count: parseInt(opt),
})}
</Label>
</div>
))}
</RadioGroup>
{selectedOption == "custom" && (
<CustomTimeSelector
latestTime={latestTime}
range={range}
setRange={setRange}
startLabel={t("export.time.start.title")}
endLabel={t("export.time.end.title")}
/>
)}
<Input
className="text-md"
type="search"
placeholder={t("export.name.placeholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
{t("export.case.label")}
</Label>
<Select
value={selectedCaseId || "none"}
onValueChange={(value) =>
setSelectedCaseId(value === "none" ? undefined : value)
}
>
<SelectTrigger>
<SelectValue placeholder={t("export.case.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("label.none", { ns: "common" })}
</SelectItem> </SelectItem>
))} {cases
</SelectContent> ?.sort((a, b) => a.name.localeCompare(b.name))
</Select> .map((caseItem) => (
</div> <SelectItem key={caseItem.id} value={caseItem.id}>
{caseItem.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</TabsContent>
<TabsContent value="multi" className="mt-4 space-y-4">
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
{t("export.multiCamera.timeRange")}
</Label>
<CustomTimeSelector
latestTime={latestTime}
range={range}
setRange={setRange}
startLabel={t("export.time.start.title")}
endLabel={t("export.time.end.title")}
/>
<Button
size="sm"
className="mt-2"
onClick={() => {
if (!range) {
setRange({
before: currentTime + 30,
after: currentTime - 30,
});
}
setActiveTab("multi");
setMode("timeline_multi");
}}
>
{t("export.multiCamera.selectFromTimeline")}
</Button>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm text-secondary-foreground">
{t("export.multiCamera.cameraSelection")}
</Label>
<div className="text-xs text-muted-foreground">
{t("export.multiCamera.selectedCount", {
count: selectedCameraCount,
})}
</div>
</div>
<div className="max-h-64 space-y-2 overflow-y-auto rounded-lg border border-border bg-background/50 p-2">
{isEventsLoading && (
<div className="px-2 py-4 text-sm text-muted-foreground">
{t("export.multiCamera.checkingActivity")}
</div>
)}
{!isEventsLoading && cameraActivities.length === 0 && (
<div className="px-2 py-4 text-sm text-muted-foreground">
{t("export.multiCamera.noCameras")}
</div>
)}
{cameraActivities.map((activity) => {
const isSelected = selectedCameraIds.includes(activity.camera);
return (
<button
key={activity.camera}
type="button"
className={cn(
"flex w-full items-center gap-3 rounded-md border border-border px-3 py-2 text-left transition-colors",
activity.hasDetections
? "bg-secondary/40 hover:bg-secondary/70"
: "bg-background text-muted-foreground",
)}
onClick={() => toggleCameraSelection(activity.camera)}
>
<Checkbox checked={isSelected} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-primary">
{resolveCameraName(config, activity.camera)}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{t("export.multiCamera.detectionCount", {
count: activity.count,
})}
</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-secondary">
<div
className={cn(
"h-full rounded-full",
activity.hasDetections ? "bg-selected" : "bg-muted",
)}
style={{
width: `${Math.max(activity.intensity * 100, activity.hasDetections ? 8 : 0)}%`,
}}
/>
</div>
</div>
</button>
);
})}
</div>
</div>
<Input
className="text-md"
type="search"
placeholder={t("export.multiCamera.namePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
{t("export.case.label")}
</Label>
<Select
value={batchCaseSelection}
onValueChange={(value) => setBatchCaseSelection(value)}
>
<SelectTrigger>
<SelectValue placeholder={t("export.case.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("label.none", { ns: "common" })}
</SelectItem>
{cases
?.sort((a, b) => a.name.localeCompare(b.name))
.map((caseItem) => (
<SelectItem key={caseItem.id} value={caseItem.id}>
{caseItem.name}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value="new">
{t("export.case.newCaseOption")}
</SelectItem>
</SelectContent>
</Select>
{batchCaseSelection === "new" && (
<div className="space-y-2 rounded-lg border border-border bg-background/50 p-3">
<Input
placeholder={t("export.case.newCaseNamePlaceholder")}
value={newCaseName}
onChange={(event) => setNewCaseName(event.target.value)}
/>
<Textarea
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={newCaseDescription}
onChange={(event) =>
setNewCaseDescription(event.target.value)
}
/>
</div>
)}
</div>
</TabsContent>
</Tabs>
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />} {isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
<DialogFooter <DialogFooter
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-4"} className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-4"}
@ -389,26 +872,43 @@ export function ExportContent({
> >
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</div> </div>
<Button {activeTab === "export" ? (
className={isDesktop ? "" : "w-full"} <Button
aria-label={t("export.selectOrExport")} className={isDesktop ? "" : "w-full"}
variant="select" aria-label={t("export.selectOrExport")}
size="sm" variant="select"
onClick={() => { size="sm"
if (selectedOption == "timeline") { onClick={() => {
setRange({ before: currentTime + 30, after: currentTime - 30 }); if (selectedOption == "timeline") {
setMode("timeline"); setRange({ before: currentTime + 30, after: currentTime - 30 });
} else { setMode("timeline");
onStartExport(); } else {
setSelectedOption("1"); onStartExport();
setMode("none"); setSelectedOption("1");
} setMode("none");
}} }
> }}
{selectedOption == "timeline" >
? t("export.select") {selectedOption == "timeline"
: t("export.export")} ? t("export.select")
</Button> : t("export.export")}
</Button>
) : (
<Button
className={isDesktop ? "" : "w-full"}
aria-label={t("export.multiCamera.exportButton", {
count: selectedCameraCount,
})}
variant="select"
size="sm"
disabled={!canStartBatchExport}
onClick={() => void startBatchExport()}
>
{t("export.multiCamera.exportButton", {
count: selectedCameraCount,
})}
</Button>
)}
</DialogFooter> </DialogFooter>
</div> </div>
); );

View File

@ -4,7 +4,7 @@ import { Button } from "../ui/button";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa"; import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
import { LuBug } from "react-icons/lu"; import { LuBug } from "react-icons/lu";
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import { ExportContent, ExportPreviewDialog } from "./ExportDialog"; import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog";
import { import {
DebugReplayContent, DebugReplayContent,
SaveDebugReplayOverlay, SaveDebugReplayOverlay,
@ -102,6 +102,7 @@ export default function MobileReviewSettingsDrawer({
]); ]);
const navigate = useNavigate(); const navigate = useNavigate();
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none"); const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
const [exportTab, setExportTab] = useState<ExportTab>("export");
const [selectedReplayOption, setSelectedReplayOption] = useState< const [selectedReplayOption, setSelectedReplayOption] = useState<
"1" | "5" | "custom" | "timeline" "1" | "5" | "custom" | "timeline"
>("1"); >("1");
@ -115,16 +116,26 @@ export default function MobileReviewSettingsDrawer({
); );
const onStartExport = useCallback(() => { const onStartExport = useCallback(() => {
if (!range) { if (!range) {
toast.error(t("toast.error.noValidTimeSelected"), { toast.error(
position: "top-center", t("export.toast.error.noVaildTimeSelected", {
}); ns: "components/dialog",
}),
{
position: "top-center",
},
);
return; return;
} }
if (range.before < range.after) { if (range.before < range.after) {
toast.error(t("toast.error.endTimeMustAfterStartTime"), { toast.error(
position: "top-center", t("export.toast.error.endTimeMustAfterStartTime", {
}); ns: "components/dialog",
}),
{
position: "top-center",
},
);
return; return;
} }
@ -166,7 +177,7 @@ export default function MobileReviewSettingsDrawer({
toast.error( toast.error(
t("export.toast.error.failed", { t("export.toast.error.failed", {
ns: "components/dialog", ns: "components/dialog",
errorMessage, error: errorMessage,
}), }),
{ {
position: "top-center", position: "top-center",
@ -267,6 +278,7 @@ export default function MobileReviewSettingsDrawer({
className="flex w-full items-center justify-center gap-2" className="flex w-full items-center justify-center gap-2"
aria-label={t("export")} aria-label={t("export")}
onClick={() => { onClick={() => {
setExportTab("export");
setDrawerMode("export"); setDrawerMode("export");
setMode("select"); setMode("select");
}} }}
@ -331,14 +343,16 @@ export default function MobileReviewSettingsDrawer({
range={range} range={range}
name={name} name={name}
selectedCaseId={selectedCaseId} selectedCaseId={selectedCaseId}
activeTab={exportTab}
onStartExport={onStartExport} onStartExport={onStartExport}
setActiveTab={setExportTab}
setName={setName} setName={setName}
setSelectedCaseId={setSelectedCaseId} setSelectedCaseId={setSelectedCaseId}
setRange={setRange} setRange={setRange}
setMode={(mode) => { setMode={(mode) => {
setMode(mode); setMode(mode);
if (mode == "timeline") { if (mode == "timeline" || mode == "timeline_multi") {
setDrawerMode("none"); setDrawerMode("none");
} }
}} }}
@ -346,6 +360,7 @@ export default function MobileReviewSettingsDrawer({
setMode("none"); setMode("none");
setRange(undefined); setRange(undefined);
setSelectedCaseId(undefined); setSelectedCaseId(undefined);
setExportTab("export");
setDrawerMode("select"); setDrawerMode("select");
}} }}
/> />
@ -483,9 +498,28 @@ export default function MobileReviewSettingsDrawer({
<> <>
<SaveExportOverlay <SaveExportOverlay
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2" className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={mode == "timeline"} show={mode == "timeline" || mode == "timeline_multi"}
onSave={() => onStartExport()} hidePreview={mode == "timeline_multi"}
onCancel={() => setMode("none")} 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)} onPreview={() => setShowExportPreview(true)}
/> />
<SaveDebugReplayOverlay <SaveDebugReplayOverlay

View File

@ -7,6 +7,8 @@ import { useTranslation } from "react-i18next";
type SaveExportOverlayProps = { type SaveExportOverlayProps = {
className: string; className: string;
show: boolean; show: boolean;
hidePreview?: boolean;
saveLabel?: string;
onPreview: () => void; onPreview: () => void;
onSave: () => void; onSave: () => void;
onCancel: () => void; onCancel: () => void;
@ -14,6 +16,8 @@ type SaveExportOverlayProps = {
export default function SaveExportOverlay({ export default function SaveExportOverlay({
className, className,
show, show,
hidePreview = false,
saveLabel,
onPreview, onPreview,
onSave, onSave,
onCancel, onCancel,
@ -37,24 +41,26 @@ export default function SaveExportOverlay({
<LuX /> <LuX />
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</Button> </Button>
{!hidePreview && (
<Button
className="flex items-center gap-1"
aria-label={t("export.fromTimeline.previewExport")}
size="sm"
onClick={onPreview}
>
<LuVideo />
{t("export.fromTimeline.previewExport")}
</Button>
)}
<Button <Button
className="flex items-center gap-1" className="flex items-center gap-1"
aria-label={t("export.fromTimeline.previewExport")} aria-label={saveLabel || t("export.fromTimeline.saveExport")}
size="sm"
onClick={onPreview}
>
<LuVideo />
{t("export.fromTimeline.previewExport")}
</Button>
<Button
className="flex items-center gap-1"
aria-label={t("export.fromTimeline.saveExport")}
variant="select" variant="select"
size="sm" size="sm"
onClick={onSave} onClick={onSave}
> >
<FaCompactDisc /> <FaCompactDisc />
{t("export.fromTimeline.saveExport")} {saveLabel || t("export.fromTimeline.saveExport")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -13,12 +13,14 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { useSearchEffect } from "@/hooks/use-overlay-state"; import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useHistoryBack } from "@/hooks/use-history-back"; import { useHistoryBack } from "@/hooks/use-history-back";
import { useApiFilterArgs } from "@/hooks/use-api-filter"; import { useApiFilterArgs } from "@/hooks/use-api-filter";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useFormattedTimestamp, useTimeFormat } from "@/hooks/use-date-utils";
import { import {
DeleteClipType, DeleteClipType,
Export, Export,
@ -27,6 +29,7 @@ import {
} from "@/types/export"; } from "@/types/export";
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog"; import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
import axios from "axios"; import axios from "axios";
import { FrigateConfig } from "@/types/frigateConfig";
import { import {
MutableRefObject, MutableRefObject,
@ -39,6 +42,7 @@ import {
import { isMobile, isMobileOnly } from "react-device-detect"; import { isMobile, isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IoMdArrowRoundBack } from "react-icons/io";
import { LuFolderX } from "react-icons/lu"; import { LuFolderX } from "react-icons/lu";
import { toast } from "sonner"; import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
@ -71,7 +75,7 @@ function Exports() {
const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => { const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => {
const grouped: { [caseId: string]: Export[] } = {}; const grouped: { [caseId: string]: Export[] } = {};
(rawExports ?? []).forEach((exp) => { (rawExports ?? []).forEach((exp) => {
const caseId = exp.export_case || "none"; const caseId = exp.export_case ?? exp.export_case_id ?? "none";
if (!grouped[caseId]) { if (!grouped[caseId]) {
grouped[caseId] = []; grouped[caseId] = [];
} }
@ -82,15 +86,8 @@ function Exports() {
}, [rawExports]); }, [rawExports]);
const filteredCases = useMemo<ExportCase[]>(() => { const filteredCases = useMemo<ExportCase[]>(() => {
if (!cases) { return cases || [];
return []; }, [cases]);
}
return cases.filter((caseItem) => {
const caseExports = exportsByCase[caseItem.id];
return caseExports?.length;
});
}, [cases, exportsByCase]);
const exports = useMemo<Export[]>( const exports = useMemo<Export[]>(
() => exportsByCase["none"] || [], () => exportsByCase["none"] || [],
@ -149,6 +146,13 @@ function Exports() {
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>(); const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
const [exportToAssign, setExportToAssign] = useState<Export | undefined>(); const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
const [caseDialog, setCaseDialog] = useState<
{ mode: "create" | "edit"; exportCase?: ExportCase } | undefined
>();
const [caseToDelete, setCaseToDelete] = useState<ExportCase | undefined>();
const [caseForAddExport, setCaseForAddExport] = useState<
ExportCase | undefined
>();
const onHandleDelete = useCallback(() => { const onHandleDelete = useCallback(() => {
if (!deleteClip) { if (!deleteClip) {
@ -194,8 +198,115 @@ function Exports() {
useKeyboardListener([], undefined, contentRef); useKeyboardListener([], undefined, contentRef);
const selectedCase = useMemo( const selectedCase = useMemo(
() => filteredCases?.find((c) => c.id === selectedCaseId), () => cases?.find((c) => c.id === selectedCaseId),
[filteredCases, 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(() => { const resetCaseDialog = useCallback(() => {
@ -206,6 +317,19 @@ function Exports() {
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2"> <div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
<Toaster closeButton={true} /> <Toaster closeButton={true} />
<CaseEditorDialog
caseDialog={caseDialog}
onClose={() => setCaseDialog(undefined)}
onSave={handleSaveCase}
/>
<CaseAddExportDialog
exportCase={caseForAddExport}
availableExports={uncategorizedExports}
onClose={() => setCaseForAddExport(undefined)}
onAssign={handleAssignExportToCase}
/>
<CaseAssignmentDialog <CaseAssignmentDialog
exportToAssign={exportToAssign} exportToAssign={exportToAssign}
cases={cases} cases={cases}
@ -241,6 +365,34 @@ function Exports() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<AlertDialog
open={caseToDelete != undefined}
onOpenChange={() => setCaseToDelete(undefined)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteCase.label")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteCase.desc", {
caseName: caseToDelete?.name,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<Button
className="text-white"
variant="destructive"
onClick={() => void handleDeleteCase()}
>
{t("button.delete", { ns: "common" })}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog <Dialog
open={selected != undefined} open={selected != undefined}
onOpenChange={(open) => { onOpenChange={(open) => {
@ -288,7 +440,22 @@ function Exports() {
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0", isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
)} )}
> >
<div className="w-full"> <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 <Input
className="text-md w-full bg-muted md:w-1/2" className="text-md w-full bg-muted md:w-1/2"
placeholder={t("search")} placeholder={t("search")}
@ -296,12 +463,50 @@ function Exports() {
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</div> </div>
<ExportFilterGroup {!selectedCase && (
className="w-full justify-between md:justify-start lg:justify-end" <div className="flex w-full items-center justify-between gap-2 md:justify-start lg:justify-end">
filter={exportFilter} <ExportFilterGroup
filters={["cameras"]} className="justify-start"
onUpdateFilter={setExportFilter} filter={exportFilter}
/> filters={["cameras"]}
onUpdateFilter={setExportFilter}
/>
<Button
variant="default"
size="sm"
onClick={() => setCaseDialog({ mode: "create" })}
>
{t("toolbar.newCase")}
</Button>
</div>
)}
{selectedCase && (
<div className="flex w-full items-center justify-end gap-2">
<Button
className="flex items-center gap-2.5 rounded-lg"
size="sm"
onClick={() => setCaseForAddExport(selectedCase)}
>
<div className="text-primary">{t("toolbar.addExport")}</div>
</Button>
<Button
className="flex items-center gap-2.5 rounded-lg"
size="sm"
onClick={() =>
setCaseDialog({ mode: "edit", exportCase: selectedCase })
}
>
<div className="text-primary">{t("toolbar.editCase")}</div>
</Button>
<Button
className="flex items-center gap-2.5 rounded-lg"
size="sm"
onClick={() => setCaseToDelete(selectedCase)}
>
<div className="text-primary">{t("toolbar.deleteCase")}</div>
</Button>
</div>
)}
</div> </div>
{selectedCase ? ( {selectedCase ? (
@ -309,11 +514,13 @@ function Exports() {
contentRef={contentRef} contentRef={contentRef}
selectedCase={selectedCase} selectedCase={selectedCase}
exports={exportsByCase[selectedCase.id] || []} exports={exportsByCase[selectedCase.id] || []}
availableExports={uncategorizedExports}
search={search} search={search}
setSelected={setSelected} setSelected={setSelected}
renameClip={onHandleRename} renameClip={onHandleRename}
setDeleteClip={setDeleteClip} setDeleteClip={setDeleteClip}
onAssignToCase={setExportToAssign} onAssignToCase={setExportToAssign}
onAddExport={() => setCaseForAddExport(selectedCase)}
/> />
) : ( ) : (
<AllExportsView <AllExportsView
@ -398,14 +605,10 @@ function AllExportsView({
<div className="space-y-2"> <div className="space-y-2">
<Heading as="h4">{t("headings.cases")}</Heading> <Heading as="h4">{t("headings.cases")}</Heading>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{cases?.map((item) => ( {filteredCases.map((item) => (
<CaseCard <CaseCard
key={item.id} key={item.id}
className={ className=""
search == "" || filteredCases?.includes(item)
? ""
: "hidden"
}
exportCase={item} exportCase={item}
exports={exportsByCase[item.id] || []} exports={exportsByCase[item.id] || []}
onSelect={() => { onSelect={() => {
@ -424,14 +627,10 @@ function AllExportsView({
ref={contentRef} ref={contentRef}
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
> >
{exports.map((item) => ( {filteredExports.map((item) => (
<ExportCard <ExportCard
key={item.name} key={item.name}
className={ className=""
search == "" || filteredExports.includes(item)
? ""
: "hidden"
}
exportedRecording={item} exportedRecording={item}
onSelect={setSelected} onSelect={setSelected}
onRename={renameClip} onRename={renameClip}
@ -459,25 +658,38 @@ type CaseViewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>; contentRef: MutableRefObject<HTMLDivElement | null>;
selectedCase: ExportCase; selectedCase: ExportCase;
exports?: Export[]; exports?: Export[];
availableExports: Export[];
search: string; search: string;
setSelected: (e: Export) => void; setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void; renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void; setDeleteClip: (d: DeleteClipType | undefined) => void;
onAssignToCase: (e: Export) => void; onAssignToCase: (e: Export) => void;
onAddExport: () => void;
}; };
function CaseView({ function CaseView({
contentRef, contentRef,
selectedCase, selectedCase,
exports, exports,
availableExports,
search, search,
setSelected, setSelected,
renameClip, renameClip,
setDeleteClip, setDeleteClip,
onAssignToCase, onAssignToCase,
onAddExport,
}: CaseViewProps) { }: 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 filteredExports = useMemo<Export[]>(() => {
const caseExports = (exports || []).filter( const caseExports = (exports || []).filter(
(e) => e.export_case == selectedCase.id, (e) => (e.export_case ?? e.export_case_id) == selectedCase.id,
); );
if (!search) { if (!search) {
@ -492,38 +704,260 @@ function CaseView({
); );
}, [selectedCase, exports, search]); }, [selectedCase, exports, search]);
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 ( return (
<div className="flex size-full flex-col gap-8 overflow-hidden"> <div className="flex size-full flex-col gap-4 overflow-hidden">
<div className="flex shrink-0 flex-col gap-1"> <div className="flex shrink-0 flex-col gap-2">
<Heading className="capitalize" as="h2"> <Heading className="mb-0" as="h2">
{selectedCase.name} {selectedCase.name}
</Heading> </Heading>
<div className="text-secondary-foreground"> <div className="mb-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
{selectedCase.description} <span>{t("caseView.createdAt", { value: createdAt })}</span>
<span>
{t("caseView.exportCount", { count: filteredExports.length })}
</span>
<span>{t("caseView.cameraCount", { count: cameraCount })}</span>
</div> </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> </div>
<div {filteredExports.length > 0 ? (
ref={contentRef} <div
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" 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"
{exports?.map((item) => ( >
<ExportCard {filteredExports.map((item) => (
key={item.name} <ExportCard
className={filteredExports.includes(item) ? "" : "hidden"} key={item.id}
exportedRecording={item} className=""
onSelect={setSelected} exportedRecording={item}
onRename={renameClip} onSelect={setSelected}
onDelete={({ file, exportName }) => onRename={renameClip}
setDeleteClip({ file, exportName }) onDelete={({ file, exportName }) =>
} setDeleteClip({ file, exportName })
onAssignToCase={onAssignToCase} }
/> onAssignToCase={onAssignToCase}
))} />
</div> ))}
</div>
) : (
<div className="flex min-h-[16rem] flex-col items-center justify-center rounded-2xl border border-dashed border-border bg-background/40 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> </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)}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="default"
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>
</div>
</div>
</DialogContent>
</Dialog>
);
}
type CaseAddExportDialogProps = {
exportCase?: ExportCase;
availableExports: Export[];
onClose: () => void;
onAssign: (exportId: string, caseId: string) => Promise<void>;
};
function CaseAddExportDialog({
exportCase,
availableExports,
onClose,
onAssign,
}: CaseAddExportDialogProps) {
const { t } = useTranslation(["views/exports", "common"]);
const [search, setSearch] = useState("");
useEffect(() => {
setSearch("");
}, [exportCase?.id]);
const filteredExports = useMemo(() => {
if (!search) {
return availableExports;
}
return availableExports.filter((exportItem) =>
exportItem.name.toLowerCase().includes(search.toLowerCase()),
);
}, [availableExports, search]);
return (
<Dialog
open={exportCase != undefined}
onOpenChange={(open) => !open && onClose()}
>
<DialogContent className="max-h-[80dvh] overflow-hidden">
<DialogTitle>
{t("addExportDialog.title", { caseName: exportCase?.name })}
</DialogTitle>
<div className="space-y-3 overflow-hidden">
<Input
placeholder={t("addExportDialog.searchPlaceholder")}
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<div className="scrollbar-container max-h-[50dvh] space-y-2 overflow-y-auto">
{filteredExports.length > 0 ? (
filteredExports.map((exportItem) => (
<div
key={exportItem.id}
className="flex items-center justify-between rounded-lg border border-border p-3"
>
<div className="min-w-0 pr-4">
<div className="truncate font-medium">
{exportItem.name}
</div>
<div className="truncate text-xs text-muted-foreground">
{exportItem.camera.replaceAll("_", " ")}
</div>
</div>
<Button
size="sm"
variant="select"
onClick={() => {
if (!exportCase) {
return;
}
void onAssign(exportItem.id, exportCase.id).then(onClose);
}}
>
{t("button.add", { ns: "common" })}
</Button>
</div>
))
) : (
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{t("addExportDialog.empty")}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
type CaseAssignmentDialogProps = { type CaseAssignmentDialogProps = {
exportToAssign?: Export; exportToAssign?: Export;
cases?: ExportCase[]; cases?: ExportCase[];

View File

@ -6,7 +6,8 @@ export type Export = {
video_path: string; video_path: string;
thumb_path: string; thumb_path: string;
in_progress: boolean; in_progress: boolean;
export_case?: string; export_case?: string | null;
export_case_id?: string | null;
}; };
export type ExportCase = { export type ExportCase = {
@ -17,6 +18,36 @@ export type ExportCase = {
updated_at: number; updated_at: number;
}; };
export type BatchExportBody = {
start_time: number;
end_time: number;
camera_ids: string[];
name?: string;
export_case_id?: string;
new_case_name?: string;
new_case_description?: string;
};
export type BatchExportResult = {
camera: string;
export_id?: string | null;
success: boolean;
error?: string | null;
};
export type BatchExportResponse = {
export_case_id: string;
export_ids: string[];
results: BatchExportResult[];
};
export type CameraActivity = {
camera: string;
count: number;
intensity: number;
hasDetections: boolean;
};
export type DeleteClipType = { export type DeleteClipType = {
file: string; file: string;
exportName: string; exportName: string;

View File

@ -2,7 +2,7 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FilterType = { [searchKey: string]: any }; export type FilterType = { [searchKey: string]: any };
export type ExportMode = "select" | "timeline" | "none"; export type ExportMode = "select" | "timeline" | "timeline_multi" | "none";
export type FilterList = { export type FilterList = {
labels?: string[]; labels?: string[];

View File

@ -270,7 +270,10 @@ export default function MotionSearchView({
); );
useEffect(() => { useEffect(() => {
if (exportMode !== "timeline" || exportRange) { if (
(exportMode !== "timeline" && exportMode !== "timeline_multi") ||
exportRange
) {
return; return;
} }
@ -955,9 +958,25 @@ export default function MotionSearchView({
<SaveExportOverlay <SaveExportOverlay
className="pointer-events-none absolute inset-x-0 top-0 z-30" className="pointer-events-none absolute inset-x-0 top-0 z-30"
show={exportMode === "timeline" && Boolean(exportRange)} show={
(exportMode === "timeline" || exportMode === "timeline_multi") &&
Boolean(exportRange)
}
hidePreview={exportMode === "timeline_multi"}
saveLabel={
exportMode === "timeline_multi"
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
: undefined
}
onPreview={handleExportPreview} onPreview={handleExportPreview}
onSave={handleExportSave} onSave={() => {
if (exportMode === "timeline_multi") {
setExportMode("select");
return;
}
handleExportSave();
}}
onCancel={handleExportCancel} onCancel={handleExportCancel}
/> />
@ -976,7 +995,10 @@ export default function MotionSearchView({
noRecordingRanges={noRecordings ?? []} noRecordingRanges={noRecordings ?? []}
contentRef={contentRef} contentRef={contentRef}
onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)} onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)}
showExportHandles={exportMode === "timeline" && Boolean(exportRange)} showExportHandles={
(exportMode === "timeline" || exportMode === "timeline_multi") &&
Boolean(exportRange)
}
exportStartTime={exportRange?.after} exportStartTime={exportRange?.after}
exportEndTime={exportRange?.before} exportEndTime={exportRange?.before}
setExportStartTime={setExportStartTime} setExportStartTime={setExportStartTime}
@ -1408,7 +1430,11 @@ export default function MotionSearchView({
onControllerReady={(controller) => { onControllerReady={(controller) => {
mainControllerRef.current = controller; mainControllerRef.current = controller;
}} }}
isScrubbing={scrubbing || exportMode == "timeline"} isScrubbing={
scrubbing ||
exportMode == "timeline" ||
exportMode == "timeline_multi"
}
supportsFullscreen={supportsFullScreen} supportsFullscreen={supportsFullScreen}
setFullResolution={setFullResolution} setFullResolution={setFullResolution}
toggleFullscreen={toggleFullscreen} toggleFullscreen={toggleFullscreen}

View File

@ -833,6 +833,7 @@ export function RecordingView({
isScrubbing={ isScrubbing={
scrubbing || scrubbing ||
exportMode == "timeline" || exportMode == "timeline" ||
exportMode == "timeline_multi" ||
debugReplayMode == "timeline" debugReplayMode == "timeline"
} }
supportsFullscreen={supportsFullScreen} supportsFullscreen={supportsFullScreen}
@ -911,7 +912,7 @@ export function RecordingView({
activeReviewItem={activeReviewItem} activeReviewItem={activeReviewItem}
currentTime={currentTime} currentTime={currentTime}
exportRange={ exportRange={
exportMode == "timeline" exportMode == "timeline" || exportMode == "timeline_multi"
? exportRange ? exportRange
: debugReplayMode == "timeline" : debugReplayMode == "timeline"
? debugReplayRange ? debugReplayRange