mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
frontend + i18n
This commit is contained in:
parent
855e5cc0f9
commit
3ef0186464
@ -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": {
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">
|
||||
<FaFolder />
|
||||
<div className="capitalize">{exportCase.name}</div>
|
||||
<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="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,9 +328,15 @@ 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">
|
||||
{exportedRecording.name.replaceAll("_", " ")}
|
||||
<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>
|
||||
</>
|
||||
|
||||
@ -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,89 +615,253 @@ 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 (
|
||||
<div key={opt} className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
className={
|
||||
opt == selectedOption
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
id={opt}
|
||||
value={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.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 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"
|
||||
<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={
|
||||
opt == selectedOption
|
||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||
}
|
||||
id={opt}
|
||||
value={opt}
|
||||
/>
|
||||
<Label
|
||||
className="cursor-pointer smart-capitalize"
|
||||
htmlFor={opt}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{cases
|
||||
?.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((caseItem) => (
|
||||
<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,26 +872,43 @@ export function ExportContent({
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</div>
|
||||
<Button
|
||||
className={isDesktop ? "" : "w-full"}
|
||||
aria-label={t("export.selectOrExport")}
|
||||
variant="select"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (selectedOption == "timeline") {
|
||||
setRange({ before: currentTime + 30, after: currentTime - 30 });
|
||||
setMode("timeline");
|
||||
} else {
|
||||
onStartExport();
|
||||
setSelectedOption("1");
|
||||
setMode("none");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedOption == "timeline"
|
||||
? t("export.select")
|
||||
: t("export.export")}
|
||||
</Button>
|
||||
{activeTab === "export" ? (
|
||||
<Button
|
||||
className={isDesktop ? "" : "w-full"}
|
||||
aria-label={t("export.selectOrExport")}
|
||||
variant="select"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (selectedOption == "timeline") {
|
||||
setRange({ before: currentTime + 30, after: currentTime - 30 });
|
||||
setMode("timeline");
|
||||
} else {
|
||||
onStartExport();
|
||||
setSelectedOption("1");
|
||||
setMode("none");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedOption == "timeline"
|
||||
? 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>
|
||||
);
|
||||
|
||||
@ -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"), {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("export.toast.error.noVaildTimeSelected", {
|
||||
ns: "components/dialog",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (range.before < range.after) {
|
||||
toast.error(t("toast.error.endTimeMustAfterStartTime"), {
|
||||
position: "top-center",
|
||||
});
|
||||
toast.error(
|
||||
t("export.toast.error.endTimeMustAfterStartTime", {
|
||||
ns: "components/dialog",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -166,7 +177,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
toast.error(
|
||||
t("export.toast.error.failed", {
|
||||
ns: "components/dialog",
|
||||
errorMessage,
|
||||
error: errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
@ -267,6 +278,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label={t("export")}
|
||||
onClick={() => {
|
||||
setExportTab("export");
|
||||
setDrawerMode("export");
|
||||
setMode("select");
|
||||
}}
|
||||
@ -331,14 +343,16 @@ export default function MobileReviewSettingsDrawer({
|
||||
range={range}
|
||||
name={name}
|
||||
selectedCaseId={selectedCaseId}
|
||||
activeTab={exportTab}
|
||||
onStartExport={onStartExport}
|
||||
setActiveTab={setExportTab}
|
||||
setName={setName}
|
||||
setSelectedCaseId={setSelectedCaseId}
|
||||
setRange={setRange}
|
||||
setMode={(mode) => {
|
||||
setMode(mode);
|
||||
|
||||
if (mode == "timeline") {
|
||||
if (mode == "timeline" || mode == "timeline_multi") {
|
||||
setDrawerMode("none");
|
||||
}
|
||||
}}
|
||||
@ -346,6 +360,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
setMode("none");
|
||||
setRange(undefined);
|
||||
setSelectedCaseId(undefined);
|
||||
setExportTab("export");
|
||||
setDrawerMode("select");
|
||||
}}
|
||||
/>
|
||||
@ -483,9 +498,28 @@ export default function MobileReviewSettingsDrawer({
|
||||
<>
|
||||
<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
|
||||
|
||||
@ -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,24 +41,26 @@ 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")}
|
||||
size="sm"
|
||||
onClick={onPreview}
|
||||
>
|
||||
<LuVideo />
|
||||
{t("export.fromTimeline.previewExport")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label={t("export.fromTimeline.previewExport")}
|
||||
size="sm"
|
||||
onClick={onPreview}
|
||||
>
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
<ExportFilterGroup
|
||||
className="w-full justify-between md:justify-start lg:justify-end"
|
||||
filter={exportFilter}
|
||||
filters={["cameras"]}
|
||||
onUpdateFilter={setExportFilter}
|
||||
/>
|
||||
{!selectedCase && (
|
||||
<div className="flex w-full items-center justify-between gap-2 md:justify-start lg:justify-end">
|
||||
<ExportFilterGroup
|
||||
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,38 +704,260 @@ 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">
|
||||
{selectedCase.description}
|
||||
<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>
|
||||
<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) => (
|
||||
<ExportCard
|
||||
key={item.name}
|
||||
className={filteredExports.includes(item) ? "" : "hidden"}
|
||||
exportedRecording={item}
|
||||
onSelect={setSelected}
|
||||
onRename={renameClip}
|
||||
onDelete={({ file, exportName }) =>
|
||||
setDeleteClip({ file, exportName })
|
||||
}
|
||||
onAssignToCase={onAssignToCase}
|
||||
/>
|
||||
))}
|
||||
</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"
|
||||
>
|
||||
{filteredExports.map((item) => (
|
||||
<ExportCard
|
||||
key={item.id}
|
||||
className=""
|
||||
exportedRecording={item}
|
||||
onSelect={setSelected}
|
||||
onRename={renameClip}
|
||||
onDelete={({ file, exportName }) =>
|
||||
setDeleteClip({ file, exportName })
|
||||
}
|
||||
onAssignToCase={onAssignToCase}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
type CaseAssignmentDialogProps = {
|
||||
exportToAssign?: Export;
|
||||
cases?: ExportCase[];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user