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"
},
"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": {

View File

@ -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."
}
}

View File

@ -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 (
<div
@ -61,10 +70,28 @@ export function CaseCard({
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="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">
<div className="rounded-full bg-black/55 px-2 py-1">
{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="capitalize">{exportCase.name}</div>
<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>
);
@ -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<FrigateConfig>("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,10 +328,16 @@ export function ExportCard({
{loading && (
<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 />
<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">
<div className="flex items-end smart-capitalize">
{exportedRecording.name.replaceAll("_", " ")}
</div>
<div className="mt-1 text-xs text-white/80">{formattedDate}</div>
</div>
</div>
</>
);

View File

@ -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<string | undefined>(
undefined,
);
const [selectedCaseId, setSelectedCaseId] = useState<string | 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(() => {
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({
/>
<SaveExportOverlay
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)}
onSave={() => onStartExport()}
onSave={() => {
if (mode == "timeline_multi") {
setActiveTab("multi");
setMode("select");
return;
}
onStartExport();
}}
onCancel={handleCancel}
/>
<Overlay
open={mode == "select"}
onOpenChange={(open) => {
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");
}}
>
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
{isDesktop && (
<div className="text-primary">
{t("menu.export", { ns: "common" })}
</div>
)}
</Button>
</Trigger>
)}
@ -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<ExportOption>("1");
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(
(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<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 (
<div className="w-full">
{isDesktop && (
@ -296,12 +615,26 @@ export function ExportContent({
<SelectSeparator className="my-4 bg-secondary" />
</>
)}
<RadioGroup
className={`flex flex-col gap-4 ${isDesktop ? "" : "mt-4"}`}
onValueChange={(value) => onSelectTime(value as ExportOption)}
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as ExportTab)}
className="w-full"
>
{EXPORT_OPTIONS.map((opt) => {
return (
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="export">{t("export.tabs.export")}</TabsTrigger>
<TabsTrigger value="multi">
{t("export.tabs.multiCamera")}
</TabsTrigger>
</TabsList>
<TabsContent value="export" className="mt-4 space-y-4">
<RadioGroup
className={`flex flex-col gap-4 ${isDesktop ? "" : "mt-1"}`}
onValueChange={(value) => onSelectTime(value as ExportOption)}
value={selectedOption}
>
{EXPORT_OPTIONS.map((opt) => (
<div key={opt} className="flex items-center gap-2">
<RadioGroupItem
className={
@ -312,19 +645,22 @@ export function ExportContent({
id={opt}
value={opt}
/>
<Label className="cursor-pointer smart-capitalize" htmlFor={opt}>
<Label
className="cursor-pointer smart-capitalize"
htmlFor={opt}
>
{isNaN(parseInt(opt))
? opt == "timeline"
? t("export.time.fromTimeline")
: t("export.time." + opt)
: t(`export.time.${opt}`)
: t("export.time.lastHour", {
count: parseInt(opt),
})}
</Label>
</div>
);
})}
))}
</RadioGroup>
{selectedOption == "custom" && (
<CustomTimeSelector
latestTime={latestTime}
@ -334,16 +670,18 @@ export function ExportContent({
endLabel={t("export.time.end.title")}
/>
)}
<Input
className="text-md my-6"
className="text-md"
type="search"
placeholder={t("export.name.placeholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="my-4">
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
{t("export.case.label", { defaultValue: "Case (optional)" })}
{t("export.case.label")}
</Label>
<Select
value={selectedCaseId || "none"}
@ -351,34 +689,179 @@ export function ExportContent({
setSelectedCaseId(value === "none" ? undefined : value)
}
>
<SelectTrigger className="mt-2">
<SelectValue
placeholder={t("export.case.placeholder", {
defaultValue: "Select a case (optional)",
})}
/>
<SelectTrigger>
<SelectValue placeholder={t("export.case.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem
value="none"
className="cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
<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}
className="cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
<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" />}
<DialogFooter
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-4"}
@ -389,6 +872,7 @@ export function ExportContent({
>
{t("button.cancel", { ns: "common" })}
</div>
{activeTab === "export" ? (
<Button
className={isDesktop ? "" : "w-full"}
aria-label={t("export.selectOrExport")}
@ -409,6 +893,22 @@ export function ExportContent({
? t("export.select")
: 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>
</div>
);

View File

@ -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<DrawerMode>("none");
const [exportTab, setExportTab] = useState<ExportTab>("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"), {
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"), {
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({
<>
<SaveExportOverlay
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={mode == "timeline"}
onSave={() => 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)}
/>
<SaveDebugReplayOverlay

View File

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

View File

@ -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<ExportCase[]>(() => {
if (!cases) {
return [];
}
return cases.filter((caseItem) => {
const caseExports = exportsByCase[caseItem.id];
return caseExports?.length;
});
}, [cases, exportsByCase]);
return cases || [];
}, [cases]);
const exports = useMemo<Export[]>(
() => exportsByCase["none"] || [],
@ -149,6 +146,13 @@ function Exports() {
const [deleteClip, setDeleteClip] = useState<DeleteClipType | 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(() => {
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() {
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
<Toaster closeButton={true} />
<CaseEditorDialog
caseDialog={caseDialog}
onClose={() => setCaseDialog(undefined)}
onSave={handleSaveCase}
/>
<CaseAddExportDialog
exportCase={caseForAddExport}
availableExports={uncategorizedExports}
onClose={() => setCaseForAddExport(undefined)}
onAssign={handleAssignExportToCase}
/>
<CaseAssignmentDialog
exportToAssign={exportToAssign}
cases={cases}
@ -241,6 +365,34 @@ function Exports() {
</AlertDialogContent>
</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
open={selected != undefined}
onOpenChange={(open) => {
@ -288,7 +440,22 @@ function Exports() {
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
className="text-md w-full bg-muted md:w-1/2"
placeholder={t("search")}
@ -296,12 +463,50 @@ function Exports() {
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{!selectedCase && (
<div className="flex w-full items-center justify-between gap-2 md:justify-start lg:justify-end">
<ExportFilterGroup
className="w-full justify-between md:justify-start lg:justify-end"
className="justify-start"
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>
{selectedCase ? (
@ -309,11 +514,13 @@ function Exports() {
contentRef={contentRef}
selectedCase={selectedCase}
exports={exportsByCase[selectedCase.id] || []}
availableExports={uncategorizedExports}
search={search}
setSelected={setSelected}
renameClip={onHandleRename}
setDeleteClip={setDeleteClip}
onAssignToCase={setExportToAssign}
onAddExport={() => setCaseForAddExport(selectedCase)}
/>
) : (
<AllExportsView
@ -398,14 +605,10 @@ function AllExportsView({
<div className="space-y-2">
<Heading as="h4">{t("headings.cases")}</Heading>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{cases?.map((item) => (
{filteredCases.map((item) => (
<CaseCard
key={item.id}
className={
search == "" || filteredCases?.includes(item)
? ""
: "hidden"
}
className=""
exportCase={item}
exports={exportsByCase[item.id] || []}
onSelect={() => {
@ -424,14 +627,10 @@ function AllExportsView({
ref={contentRef}
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
key={item.name}
className={
search == "" || filteredExports.includes(item)
? ""
: "hidden"
}
className=""
exportedRecording={item}
onSelect={setSelected}
onRename={renameClip}
@ -459,25 +658,38 @@ type CaseViewProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
selectedCase: ExportCase;
exports?: Export[];
availableExports: Export[];
search: string;
setSelected: (e: Export) => void;
renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void;
onAssignToCase: (e: Export) => void;
onAddExport: () => void;
};
function CaseView({
contentRef,
selectedCase,
exports,
availableExports,
search,
setSelected,
renameClip,
setDeleteClip,
onAssignToCase,
onAddExport,
}: 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 caseExports = (exports || []).filter(
(e) => e.export_case == selectedCase.id,
(e) => (e.export_case ?? e.export_case_id) == selectedCase.id,
);
if (!search) {
@ -492,24 +704,76 @@ function CaseView({
);
}, [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 (
<div className="flex size-full flex-col gap-8 overflow-hidden">
<div className="flex shrink-0 flex-col gap-1">
<Heading className="capitalize" as="h2">
<div className="flex size-full flex-col gap-4 overflow-hidden">
<div className="flex shrink-0 flex-col gap-2">
<Heading className="mb-0" as="h2">
{selectedCase.name}
</Heading>
<div className="text-secondary-foreground">
<div className="mb-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{t("caseView.createdAt", { value: createdAt })}</span>
<span>
{t("caseView.exportCount", { count: filteredExports.length })}
</span>
<span>{t("caseView.cameraCount", { count: cameraCount })}</span>
</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>
{filteredExports.length > 0 ? (
<div
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) => (
{filteredExports.map((item) => (
<ExportCard
key={item.name}
className={filteredExports.includes(item) ? "" : "hidden"}
key={item.id}
className=""
exportedRecording={item}
onSelect={setSelected}
onRename={renameClip}
@ -520,7 +784,177 @@ function CaseView({
/>
))}
</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>
);
}
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>
);
}

View File

@ -6,7 +6,8 @@ export type Export = {
video_path: string;
thumb_path: string;
in_progress: boolean;
export_case?: string;
export_case?: string | null;
export_case_id?: string | null;
};
export type ExportCase = {
@ -17,6 +18,36 @@ export type ExportCase = {
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 = {
file: string;
exportName: string;

View File

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

View File

@ -270,7 +270,10 @@ export default function MotionSearchView({
);
useEffect(() => {
if (exportMode !== "timeline" || exportRange) {
if (
(exportMode !== "timeline" && exportMode !== "timeline_multi") ||
exportRange
) {
return;
}
@ -955,9 +958,25 @@ export default function MotionSearchView({
<SaveExportOverlay
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}
onSave={handleExportSave}
onSave={() => {
if (exportMode === "timeline_multi") {
setExportMode("select");
return;
}
handleExportSave();
}}
onCancel={handleExportCancel}
/>
@ -976,7 +995,10 @@ export default function MotionSearchView({
noRecordingRanges={noRecordings ?? []}
contentRef={contentRef}
onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)}
showExportHandles={exportMode === "timeline" && Boolean(exportRange)}
showExportHandles={
(exportMode === "timeline" || exportMode === "timeline_multi") &&
Boolean(exportRange)
}
exportStartTime={exportRange?.after}
exportEndTime={exportRange?.before}
setExportStartTime={setExportStartTime}
@ -1408,7 +1430,11 @@ export default function MotionSearchView({
onControllerReady={(controller) => {
mainControllerRef.current = controller;
}}
isScrubbing={scrubbing || exportMode == "timeline"}
isScrubbing={
scrubbing ||
exportMode == "timeline" ||
exportMode == "timeline_multi"
}
supportsFullscreen={supportsFullScreen}
setFullResolution={setFullResolution}
toggleFullscreen={toggleFullscreen}

View File

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