mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 06:55:28 +03:00
frontend + i18n
This commit is contained in:
parent
855e5cc0f9
commit
3ef0186464
@ -50,15 +50,40 @@
|
|||||||
"placeholder": "Name the Export"
|
"placeholder": "Name the Export"
|
||||||
},
|
},
|
||||||
"case": {
|
"case": {
|
||||||
|
"newCaseOption": "Create new case",
|
||||||
|
"newCaseNamePlaceholder": "New case name",
|
||||||
|
"newCaseDescriptionPlaceholder": "Case description",
|
||||||
"label": "Case",
|
"label": "Case",
|
||||||
"placeholder": "Select a case"
|
"placeholder": "Select a case"
|
||||||
},
|
},
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"selectOrExport": "Select or Export",
|
"selectOrExport": "Select or Export",
|
||||||
|
"tabs": {
|
||||||
|
"export": "Single Camera",
|
||||||
|
"multiCamera": "Multi-Camera"
|
||||||
|
},
|
||||||
|
"multiCamera": {
|
||||||
|
"timeRange": "Time range",
|
||||||
|
"selectFromTimeline": "Select from Timeline",
|
||||||
|
"cameraSelection": "Cameras",
|
||||||
|
"selectedCount_one": "1 selected",
|
||||||
|
"selectedCount_other": "{{count}} selected",
|
||||||
|
"checkingActivity": "Checking camera activity...",
|
||||||
|
"noCameras": "No cameras available",
|
||||||
|
"detectionCount_one": "1 detection",
|
||||||
|
"detectionCount_other": "{{count}} detections",
|
||||||
|
"namePlaceholder": "Optional base name for these exports",
|
||||||
|
"exportButton_one": "Export 1 Camera",
|
||||||
|
"exportButton_other": "Export {{count}} Cameras"
|
||||||
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"success": "Successfully started export. View the file in the exports page.",
|
"success": "Successfully started export. View the file in the exports page.",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
|
"batchSuccess_one": "Started 1 export. Opening the case now.",
|
||||||
|
"batchSuccess_other": "Started {{count}} exports. Opening the case now.",
|
||||||
|
"batchPartial": "Started {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||||
|
"batchFailed": "Failed to start {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||||
"error": {
|
"error": {
|
||||||
"failed": "Failed to start export: {{error}}",
|
"failed": "Failed to start export: {{error}}",
|
||||||
"endTimeMustAfterStartTime": "End time must be after start time",
|
"endTimeMustAfterStartTime": "End time must be after start time",
|
||||||
@ -67,7 +92,8 @@
|
|||||||
},
|
},
|
||||||
"fromTimeline": {
|
"fromTimeline": {
|
||||||
"saveExport": "Save Export",
|
"saveExport": "Save Export",
|
||||||
"previewExport": "Preview Export"
|
"previewExport": "Preview Export",
|
||||||
|
"useThisRange": "Use This Range"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"streaming": {
|
"streaming": {
|
||||||
|
|||||||
@ -22,12 +22,24 @@
|
|||||||
"deleteExport": "Delete export",
|
"deleteExport": "Delete export",
|
||||||
"assignToCase": "Add to case"
|
"assignToCase": "Add to case"
|
||||||
},
|
},
|
||||||
|
"toolbar": {
|
||||||
|
"newCase": "New Case",
|
||||||
|
"addExport": "Add Export",
|
||||||
|
"editCase": "Edit Case",
|
||||||
|
"deleteCase": "Delete Case"
|
||||||
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"error": {
|
"error": {
|
||||||
"renameExportFailed": "Failed to rename export: {{errorMessage}}",
|
"renameExportFailed": "Failed to rename export: {{errorMessage}}",
|
||||||
"assignCaseFailed": "Failed to update case assignment: {{errorMessage}}"
|
"assignCaseFailed": "Failed to update case assignment: {{errorMessage}}",
|
||||||
|
"caseSaveFailed": "Failed to save case: {{errorMessage}}",
|
||||||
|
"caseDeleteFailed": "Failed to delete case: {{errorMessage}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"deleteCase": {
|
||||||
|
"label": "Delete Case",
|
||||||
|
"desc": "Are you sure you want to delete {{caseName}}? Exports will remain available as uncategorized exports."
|
||||||
|
},
|
||||||
"caseDialog": {
|
"caseDialog": {
|
||||||
"title": "Add to case",
|
"title": "Add to case",
|
||||||
"description": "Choose an existing case or create a new one.",
|
"description": "Choose an existing case or create a new one.",
|
||||||
@ -35,5 +47,36 @@
|
|||||||
"newCaseOption": "Create new case",
|
"newCaseOption": "Create new case",
|
||||||
"nameLabel": "Case name",
|
"nameLabel": "Case name",
|
||||||
"descriptionLabel": "Description"
|
"descriptionLabel": "Description"
|
||||||
|
},
|
||||||
|
"caseCard": {
|
||||||
|
"exportCount_one": "1 export",
|
||||||
|
"exportCount_other": "{{count}} exports",
|
||||||
|
"cameraCount_one": "1 camera",
|
||||||
|
"cameraCount_other": "{{count}} cameras",
|
||||||
|
"emptyCase": "No exports yet"
|
||||||
|
},
|
||||||
|
"caseView": {
|
||||||
|
"noDescription": "No description",
|
||||||
|
"createdAt": "Created {{value}}",
|
||||||
|
"exportCount_one": "1 export",
|
||||||
|
"exportCount_other": "{{count}} exports",
|
||||||
|
"cameraCount_one": "1 camera",
|
||||||
|
"cameraCount_other": "{{count}} cameras",
|
||||||
|
"showMore": "Show more",
|
||||||
|
"showLess": "Show less",
|
||||||
|
"emptyTitle": "This case is empty",
|
||||||
|
"emptyDescription": "Add existing uncategorized exports to keep the case organized.",
|
||||||
|
"emptyDescriptionNoExports": "There are no uncategorized exports available to add yet."
|
||||||
|
},
|
||||||
|
"caseEditor": {
|
||||||
|
"createTitle": "Create Case",
|
||||||
|
"editTitle": "Edit Case",
|
||||||
|
"namePlaceholder": "Case name",
|
||||||
|
"descriptionPlaceholder": "Add notes or context for this case"
|
||||||
|
},
|
||||||
|
"addExportDialog": {
|
||||||
|
"title": "Add Export to {{caseName}}",
|
||||||
|
"searchPlaceholder": "Search uncategorized exports",
|
||||||
|
"empty": "No uncategorized exports match this search."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,10 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu";
|
} from "../ui/dropdown-menu";
|
||||||
import { FaFolder } from "react-icons/fa";
|
import { FaFolder } from "react-icons/fa";
|
||||||
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||||
|
import { useFormattedTimestamp, useTimeFormat } from "@/hooks/use-date-utils";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
|
||||||
type CaseCardProps = {
|
type CaseCardProps = {
|
||||||
className: string;
|
className: string;
|
||||||
@ -41,10 +45,15 @@ export function CaseCard({
|
|||||||
exports,
|
exports,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: CaseCardProps) {
|
}: CaseCardProps) {
|
||||||
|
const { t } = useTranslation(["views/exports"]);
|
||||||
const firstExport = useMemo(
|
const firstExport = useMemo(
|
||||||
() => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0),
|
() => exports.find((exp) => exp.thumb_path && exp.thumb_path.length > 0),
|
||||||
[exports],
|
[exports],
|
||||||
);
|
);
|
||||||
|
const cameraCount = useMemo(
|
||||||
|
() => new Set(exports.map((exp) => exp.camera)).size,
|
||||||
|
[exports],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -61,10 +70,28 @@ export function CaseCard({
|
|||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!firstExport && (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-secondary via-secondary/80 to-muted" />
|
||||||
|
)}
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-gradient-to-t from-black/60 to-transparent" />
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-gradient-to-t from-black/60 to-transparent" />
|
||||||
<div className="absolute bottom-2 left-2 z-20 flex items-center justify-start gap-2 text-white">
|
<div className="absolute left-2 top-2 z-20 flex flex-wrap gap-2 text-xs text-white">
|
||||||
<FaFolder />
|
<div className="rounded-full bg-black/55 px-2 py-1">
|
||||||
<div className="capitalize">{exportCase.name}</div>
|
{t("caseCard.exportCount", { count: exports.length })}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full bg-black/55 px-2 py-1">
|
||||||
|
{t("caseCard.cameraCount", { count: cameraCount })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-x-2 bottom-2 z-20 text-white">
|
||||||
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<FaFolder />
|
||||||
|
<div className="truncate smart-capitalize">{exportCase.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 line-clamp-2 text-xs text-white/80">
|
||||||
|
{exports.length === 0
|
||||||
|
? t("caseCard.emptyCase")
|
||||||
|
: exportCase.description}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -88,6 +115,16 @@ export function ExportCard({
|
|||||||
}: ExportCardProps) {
|
}: ExportCardProps) {
|
||||||
const { t } = useTranslation(["views/exports"]);
|
const { t } = useTranslation(["views/exports"]);
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
|
const cameraName = useCameraFriendlyName(exportedRecording.camera);
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const timeFormat = useTimeFormat(config);
|
||||||
|
const formattedDate = useFormattedTimestamp(
|
||||||
|
exportedRecording.date,
|
||||||
|
t(`time.formattedTimestampMonthDayYearHourMinute.${timeFormat}`, {
|
||||||
|
ns: "common",
|
||||||
|
}),
|
||||||
|
config?.ui.timezone,
|
||||||
|
);
|
||||||
const [loading, setLoading] = useState(
|
const [loading, setLoading] = useState(
|
||||||
exportedRecording.thumb_path.length > 0,
|
exportedRecording.thumb_path.length > 0,
|
||||||
);
|
);
|
||||||
@ -291,9 +328,15 @@ export function ExportCard({
|
|||||||
{loading && (
|
{loading && (
|
||||||
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
||||||
)}
|
)}
|
||||||
|
<div className="absolute left-3 top-3 z-30 rounded-full bg-black/55 px-2 py-1 text-xs text-white">
|
||||||
|
{cameraName}
|
||||||
|
</div>
|
||||||
<ImageShadowOverlay />
|
<ImageShadowOverlay />
|
||||||
<div className="absolute bottom-2 left-3 flex items-end text-white smart-capitalize">
|
<div className="absolute bottom-2 left-3 z-30 text-white">
|
||||||
{exportedRecording.name.replaceAll("_", " ")}
|
<div className="flex items-end smart-capitalize">
|
||||||
|
{exportedRecording.name.replaceAll("_", " ")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-white/80">{formattedDate}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -18,6 +18,12 @@ import { toast } from "sonner";
|
|||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
import { TimeRange } from "@/types/timeline";
|
import { TimeRange } from "@/types/timeline";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import {
|
||||||
|
BatchExportBody,
|
||||||
|
BatchExportResponse,
|
||||||
|
CameraActivity,
|
||||||
|
ExportCase,
|
||||||
|
} from "@/types/export";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -33,8 +39,14 @@ import { baseUrl } from "@/api/baseUrl";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
|
import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ExportCase } from "@/types/export";
|
|
||||||
import { CustomTimeSelector } from "./CustomTimeSelector";
|
import { CustomTimeSelector } from "./CustomTimeSelector";
|
||||||
|
import { Event } from "@/types/event";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||||
|
import { Checkbox } from "../ui/checkbox";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||||
|
import { Textarea } from "../ui/textarea";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
const EXPORT_OPTIONS = [
|
const EXPORT_OPTIONS = [
|
||||||
"1",
|
"1",
|
||||||
@ -46,6 +58,7 @@ const EXPORT_OPTIONS = [
|
|||||||
"custom",
|
"custom",
|
||||||
] as const;
|
] as const;
|
||||||
type ExportOption = (typeof EXPORT_OPTIONS)[number];
|
type ExportOption = (typeof EXPORT_OPTIONS)[number];
|
||||||
|
export type ExportTab = "export" | "multi";
|
||||||
|
|
||||||
type ExportDialogProps = {
|
type ExportDialogProps = {
|
||||||
camera: string;
|
camera: string;
|
||||||
@ -58,6 +71,7 @@ type ExportDialogProps = {
|
|||||||
setMode: (mode: ExportMode) => void;
|
setMode: (mode: ExportMode) => void;
|
||||||
setShowPreview: (showPreview: boolean) => void;
|
setShowPreview: (showPreview: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ExportDialog({
|
export default function ExportDialog({
|
||||||
camera,
|
camera,
|
||||||
latestTime,
|
latestTime,
|
||||||
@ -71,9 +85,27 @@ export default function ExportDialog({
|
|||||||
}: ExportDialogProps) {
|
}: ExportDialogProps) {
|
||||||
const { t } = useTranslation(["components/dialog"]);
|
const { t } = useTranslation(["components/dialog"]);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
|
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>();
|
||||||
undefined,
|
const [activeTab, setActiveTab] = useState<ExportTab>("export");
|
||||||
);
|
const previousModeRef = useRef<ExportMode>(mode);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previousMode = previousModeRef.current;
|
||||||
|
|
||||||
|
if (mode === "select" && previousMode === "none") {
|
||||||
|
setActiveTab("export");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "select" && previousMode === "timeline_multi") {
|
||||||
|
setActiveTab("multi");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "none") {
|
||||||
|
setActiveTab("export");
|
||||||
|
}
|
||||||
|
|
||||||
|
previousModeRef.current = mode;
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
const onStartExport = useCallback(() => {
|
const onStartExport = useCallback(() => {
|
||||||
if (!range) {
|
if (!range) {
|
||||||
@ -127,13 +159,14 @@ export default function ExportDialog({
|
|||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
|
}, [camera, name, range, selectedCaseId, setMode, setRange, t]);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
setName("");
|
setName("");
|
||||||
setSelectedCaseId(undefined);
|
setSelectedCaseId(undefined);
|
||||||
setMode("none");
|
setMode("none");
|
||||||
setRange(undefined);
|
setRange(undefined);
|
||||||
|
setActiveTab("export");
|
||||||
}, [setMode, setRange]);
|
}, [setMode, setRange]);
|
||||||
|
|
||||||
const Overlay = isDesktop ? Dialog : Drawer;
|
const Overlay = isDesktop ? Dialog : Drawer;
|
||||||
@ -150,16 +183,30 @@ export default function ExportDialog({
|
|||||||
/>
|
/>
|
||||||
<SaveExportOverlay
|
<SaveExportOverlay
|
||||||
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
|
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
|
||||||
show={mode == "timeline"}
|
show={mode == "timeline" || mode == "timeline_multi"}
|
||||||
|
hidePreview={mode == "timeline_multi"}
|
||||||
|
saveLabel={
|
||||||
|
mode == "timeline_multi"
|
||||||
|
? t("export.fromTimeline.useThisRange")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onPreview={() => setShowPreview(true)}
|
onPreview={() => setShowPreview(true)}
|
||||||
onSave={() => onStartExport()}
|
onSave={() => {
|
||||||
|
if (mode == "timeline_multi") {
|
||||||
|
setActiveTab("multi");
|
||||||
|
setMode("select");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onStartExport();
|
||||||
|
}}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
/>
|
/>
|
||||||
<Overlay
|
<Overlay
|
||||||
open={mode == "select"}
|
open={mode == "select"}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setMode("none");
|
handleCancel();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -171,22 +218,16 @@ export default function ExportDialog({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const now = new Date(latestTime * 1000);
|
const now = new Date(latestTime * 1000);
|
||||||
let start = 0;
|
|
||||||
now.setHours(now.getHours() - 1);
|
now.setHours(now.getHours() - 1);
|
||||||
start = now.getTime() / 1000;
|
setActiveTab("export");
|
||||||
setRange({
|
setRange({
|
||||||
before: latestTime,
|
before: latestTime,
|
||||||
after: start,
|
after: now.getTime() / 1000,
|
||||||
});
|
});
|
||||||
setMode("select");
|
setMode("select");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
|
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
|
||||||
{isDesktop && (
|
|
||||||
<div className="text-primary">
|
|
||||||
{t("menu.export", { ns: "common" })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Trigger>
|
</Trigger>
|
||||||
)}
|
)}
|
||||||
@ -203,7 +244,9 @@ export default function ExportDialog({
|
|||||||
range={range}
|
range={range}
|
||||||
name={name}
|
name={name}
|
||||||
selectedCaseId={selectedCaseId}
|
selectedCaseId={selectedCaseId}
|
||||||
|
activeTab={activeTab}
|
||||||
onStartExport={onStartExport}
|
onStartExport={onStartExport}
|
||||||
|
setActiveTab={setActiveTab}
|
||||||
setName={setName}
|
setName={setName}
|
||||||
setSelectedCaseId={setSelectedCaseId}
|
setSelectedCaseId={setSelectedCaseId}
|
||||||
setRange={setRange}
|
setRange={setRange}
|
||||||
@ -222,20 +265,25 @@ type ExportContentProps = {
|
|||||||
range?: TimeRange;
|
range?: TimeRange;
|
||||||
name: string;
|
name: string;
|
||||||
selectedCaseId?: string;
|
selectedCaseId?: string;
|
||||||
|
activeTab: ExportTab;
|
||||||
onStartExport: () => void;
|
onStartExport: () => void;
|
||||||
|
setActiveTab: (tab: ExportTab) => void;
|
||||||
setName: (name: string) => void;
|
setName: (name: string) => void;
|
||||||
setSelectedCaseId: (caseId: string | undefined) => void;
|
setSelectedCaseId: (caseId: string | undefined) => void;
|
||||||
setRange: (range: TimeRange | undefined) => void;
|
setRange: (range: TimeRange | undefined) => void;
|
||||||
setMode: (mode: ExportMode) => void;
|
setMode: (mode: ExportMode) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ExportContent({
|
export function ExportContent({
|
||||||
latestTime,
|
latestTime,
|
||||||
currentTime,
|
currentTime,
|
||||||
range,
|
range,
|
||||||
name,
|
name,
|
||||||
selectedCaseId,
|
selectedCaseId,
|
||||||
|
activeTab,
|
||||||
onStartExport,
|
onStartExport,
|
||||||
|
setActiveTab,
|
||||||
setName,
|
setName,
|
||||||
setSelectedCaseId,
|
setSelectedCaseId,
|
||||||
setRange,
|
setRange,
|
||||||
@ -243,8 +291,143 @@ export function ExportContent({
|
|||||||
onCancel,
|
onCancel,
|
||||||
}: ExportContentProps) {
|
}: ExportContentProps) {
|
||||||
const { t } = useTranslation(["components/dialog"]);
|
const { t } = useTranslation(["components/dialog"]);
|
||||||
|
const navigate = useNavigate();
|
||||||
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
|
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
|
||||||
const { data: cases } = useSWR<ExportCase[]>("cases");
|
const { data: cases } = useSWR<ExportCase[]>("cases");
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const [debouncedRange, setDebouncedRange] = useState<TimeRange | undefined>(
|
||||||
|
range,
|
||||||
|
);
|
||||||
|
const [selectedCameraIds, setSelectedCameraIds] = useState<string[]>([]);
|
||||||
|
const [batchCaseSelection, setBatchCaseSelection] = useState<string>(
|
||||||
|
selectedCaseId || "none",
|
||||||
|
);
|
||||||
|
const [hasManualCameraSelection, setHasManualCameraSelection] =
|
||||||
|
useState(false);
|
||||||
|
const [newCaseName, setNewCaseName] = useState("");
|
||||||
|
const [newCaseDescription, setNewCaseDescription] = useState("");
|
||||||
|
const multiRangeKey = useMemo(() => {
|
||||||
|
if (activeTab !== "multi" || !range) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.round(range.after)}-${Math.round(range.before)}`;
|
||||||
|
}, [activeTab, range]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== "multi") {
|
||||||
|
setDebouncedRange(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
setDebouncedRange(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setDebouncedRange(range);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [activeTab, range]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== "multi") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCaseId) {
|
||||||
|
setBatchCaseSelection(selectedCaseId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((cases?.length ?? 0) === 0) {
|
||||||
|
setBatchCaseSelection("new");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBatchCaseSelection("new");
|
||||||
|
}, [activeTab, cases?.length, selectedCaseId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasManualCameraSelection(false);
|
||||||
|
}, [multiRangeKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== "multi" || range) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRange({
|
||||||
|
before: latestTime,
|
||||||
|
after: latestTime - 3600,
|
||||||
|
});
|
||||||
|
}, [activeTab, latestTime, range, setRange]);
|
||||||
|
|
||||||
|
const { data: events, isLoading: isEventsLoading } = useSWR<Event[]>(
|
||||||
|
activeTab === "multi" && debouncedRange
|
||||||
|
? [
|
||||||
|
"events",
|
||||||
|
{
|
||||||
|
after: Math.round(debouncedRange.after),
|
||||||
|
before: Math.round(debouncedRange.before),
|
||||||
|
limit: 500,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cameraActivities = useMemo<CameraActivity[]>(() => {
|
||||||
|
const allCameraIds = Object.keys(config?.cameras ?? {});
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
|
||||||
|
events?.forEach((event) => {
|
||||||
|
counts.set(event.camera, (counts.get(event.camera) ?? 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxCount = Math.max(1, ...Array.from(counts.values()), 1);
|
||||||
|
|
||||||
|
return allCameraIds.map((cameraId) => {
|
||||||
|
const count = counts.get(cameraId) ?? 0;
|
||||||
|
return {
|
||||||
|
camera: cameraId,
|
||||||
|
count,
|
||||||
|
intensity: count / maxCount,
|
||||||
|
hasDetections: count > 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [config?.cameras, events]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
activeTab !== "multi" ||
|
||||||
|
!config ||
|
||||||
|
isEventsLoading ||
|
||||||
|
hasManualCameraSelection
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCameraIds(
|
||||||
|
cameraActivities
|
||||||
|
.filter((activity) => activity.hasDetections)
|
||||||
|
.map((activity) => activity.camera),
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
activeTab,
|
||||||
|
cameraActivities,
|
||||||
|
config,
|
||||||
|
hasManualCameraSelection,
|
||||||
|
isEventsLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectedCameraCount = selectedCameraIds.length;
|
||||||
|
const canStartBatchExport =
|
||||||
|
Boolean(range && range.before > range.after) &&
|
||||||
|
selectedCameraCount > 0 &&
|
||||||
|
((batchCaseSelection !== "none" && batchCaseSelection !== "new") ||
|
||||||
|
(batchCaseSelection === "new" && newCaseName.trim().length > 0));
|
||||||
|
|
||||||
const onSelectTime = useCallback(
|
const onSelectTime = useCallback(
|
||||||
(option: ExportOption) => {
|
(option: ExportOption) => {
|
||||||
@ -252,6 +435,7 @@ export function ExportContent({
|
|||||||
|
|
||||||
const now = new Date(latestTime * 1000);
|
const now = new Date(latestTime * 1000);
|
||||||
let start = 0;
|
let start = 0;
|
||||||
|
|
||||||
switch (option) {
|
switch (option) {
|
||||||
case "1":
|
case "1":
|
||||||
now.setHours(now.getHours() - 1);
|
now.setHours(now.getHours() - 1);
|
||||||
@ -276,6 +460,8 @@ export function ExportContent({
|
|||||||
case "custom":
|
case "custom":
|
||||||
start = latestTime - 3600;
|
start = latestTime - 3600;
|
||||||
break;
|
break;
|
||||||
|
default:
|
||||||
|
start = latestTime - 3600;
|
||||||
}
|
}
|
||||||
|
|
||||||
setRange({
|
setRange({
|
||||||
@ -286,6 +472,139 @@ export function ExportContent({
|
|||||||
[latestTime, setRange],
|
[latestTime, setRange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const toggleCameraSelection = useCallback((cameraId: string) => {
|
||||||
|
setHasManualCameraSelection(true);
|
||||||
|
setSelectedCameraIds((previous) =>
|
||||||
|
previous.includes(cameraId)
|
||||||
|
? previous.filter((selectedId) => selectedId !== cameraId)
|
||||||
|
: [...previous, cameraId],
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startBatchExport = useCallback(async () => {
|
||||||
|
if (!range) {
|
||||||
|
toast.error(t("export.toast.error.noVaildTimeSelected"), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range.before <= range.after) {
|
||||||
|
toast.error(t("export.toast.error.endTimeMustAfterStartTime"), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: BatchExportBody = {
|
||||||
|
start_time: Math.round(range.after),
|
||||||
|
end_time: Math.round(range.before),
|
||||||
|
camera_ids: selectedCameraIds,
|
||||||
|
name: name || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (batchCaseSelection === "new") {
|
||||||
|
payload.new_case_name = newCaseName.trim();
|
||||||
|
payload.new_case_description = newCaseDescription.trim() || undefined;
|
||||||
|
} else if (batchCaseSelection !== "none") {
|
||||||
|
payload.export_case_id = batchCaseSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post<BatchExportResponse>(
|
||||||
|
"exports/batch",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
const results = response.data.results;
|
||||||
|
const successfulResults = results.filter((result) => result.success);
|
||||||
|
const failedResults = results.filter((result) => !result.success);
|
||||||
|
const failedSummary = failedResults
|
||||||
|
.map((result) => {
|
||||||
|
const cameraName = resolveCameraName(config, result.camera);
|
||||||
|
return result.error ? `${cameraName}: ${result.error}` : cameraName;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
if (failedResults.length > 0 && successfulResults.length > 0) {
|
||||||
|
toast.success(
|
||||||
|
t("export.toast.batchPartial", {
|
||||||
|
successful: successfulResults.length,
|
||||||
|
total: results.length,
|
||||||
|
failedCameras: failedResults
|
||||||
|
.map((result) => resolveCameraName(config, result.camera))
|
||||||
|
.join(", "),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
description: failedSummary,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (failedResults.length > 0) {
|
||||||
|
toast.error(
|
||||||
|
t("export.toast.batchFailed", {
|
||||||
|
total: results.length,
|
||||||
|
failedCameras: failedResults
|
||||||
|
.map((result) => resolveCameraName(config, result.camera))
|
||||||
|
.join(", "),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
description: failedSummary,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
t("export.toast.batchSuccess", {
|
||||||
|
count: successfulResults.length,
|
||||||
|
}),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successfulResults.length > 0) {
|
||||||
|
setName("");
|
||||||
|
setSelectedCaseId(undefined);
|
||||||
|
setBatchCaseSelection("none");
|
||||||
|
setNewCaseName("");
|
||||||
|
setNewCaseDescription("");
|
||||||
|
setRange(undefined);
|
||||||
|
setMode("none");
|
||||||
|
setActiveTab("export");
|
||||||
|
navigate(`/export?caseId=${response.data.export_case_id}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
};
|
||||||
|
const errorMessage =
|
||||||
|
apiError.response?.data?.message ||
|
||||||
|
apiError.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
t("export.toast.error.failed", {
|
||||||
|
error: errorMessage,
|
||||||
|
}),
|
||||||
|
{ position: "top-center" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
batchCaseSelection,
|
||||||
|
config,
|
||||||
|
name,
|
||||||
|
newCaseDescription,
|
||||||
|
newCaseName,
|
||||||
|
range,
|
||||||
|
selectedCameraIds,
|
||||||
|
setActiveTab,
|
||||||
|
setMode,
|
||||||
|
setName,
|
||||||
|
setRange,
|
||||||
|
setSelectedCaseId,
|
||||||
|
t,
|
||||||
|
navigate,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
@ -296,89 +615,253 @@ export function ExportContent({
|
|||||||
<SelectSeparator className="my-4 bg-secondary" />
|
<SelectSeparator className="my-4 bg-secondary" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<RadioGroup
|
|
||||||
className={`flex flex-col gap-4 ${isDesktop ? "" : "mt-4"}`}
|
<Tabs
|
||||||
onValueChange={(value) => onSelectTime(value as ExportOption)}
|
value={activeTab}
|
||||||
|
onValueChange={(value) => setActiveTab(value as ExportTab)}
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
{EXPORT_OPTIONS.map((opt) => {
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
return (
|
<TabsTrigger value="export">{t("export.tabs.export")}</TabsTrigger>
|
||||||
<div key={opt} className="flex items-center gap-2">
|
<TabsTrigger value="multi">
|
||||||
<RadioGroupItem
|
{t("export.tabs.multiCamera")}
|
||||||
className={
|
</TabsTrigger>
|
||||||
opt == selectedOption
|
</TabsList>
|
||||||
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
|
||||||
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
<TabsContent value="export" className="mt-4 space-y-4">
|
||||||
}
|
<RadioGroup
|
||||||
id={opt}
|
className={`flex flex-col gap-4 ${isDesktop ? "" : "mt-1"}`}
|
||||||
value={opt}
|
onValueChange={(value) => onSelectTime(value as ExportOption)}
|
||||||
/>
|
value={selectedOption}
|
||||||
<Label className="cursor-pointer smart-capitalize" htmlFor={opt}>
|
>
|
||||||
{isNaN(parseInt(opt))
|
{EXPORT_OPTIONS.map((opt) => (
|
||||||
? opt == "timeline"
|
<div key={opt} className="flex items-center gap-2">
|
||||||
? t("export.time.fromTimeline")
|
<RadioGroupItem
|
||||||
: t("export.time." + opt)
|
className={
|
||||||
: t("export.time.lastHour", {
|
opt == selectedOption
|
||||||
count: parseInt(opt),
|
? "bg-selected from-selected/50 to-selected/90 text-selected"
|
||||||
})}
|
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
|
||||||
</Label>
|
}
|
||||||
</div>
|
id={opt}
|
||||||
);
|
value={opt}
|
||||||
})}
|
/>
|
||||||
</RadioGroup>
|
<Label
|
||||||
{selectedOption == "custom" && (
|
className="cursor-pointer smart-capitalize"
|
||||||
<CustomTimeSelector
|
htmlFor={opt}
|
||||||
latestTime={latestTime}
|
|
||||||
range={range}
|
|
||||||
setRange={setRange}
|
|
||||||
startLabel={t("export.time.start.title")}
|
|
||||||
endLabel={t("export.time.end.title")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
className="text-md my-6"
|
|
||||||
type="search"
|
|
||||||
placeholder={t("export.name.placeholder")}
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="my-4">
|
|
||||||
<Label className="text-sm text-secondary-foreground">
|
|
||||||
{t("export.case.label", { defaultValue: "Case (optional)" })}
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={selectedCaseId || "none"}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setSelectedCaseId(value === "none" ? undefined : value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-2">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t("export.case.placeholder", {
|
|
||||||
defaultValue: "Select a case (optional)",
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem
|
|
||||||
value="none"
|
|
||||||
className="cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
|
||||||
>
|
|
||||||
{t("label.none", { ns: "common" })}
|
|
||||||
</SelectItem>
|
|
||||||
{cases
|
|
||||||
?.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.map((caseItem) => (
|
|
||||||
<SelectItem
|
|
||||||
key={caseItem.id}
|
|
||||||
value={caseItem.id}
|
|
||||||
className="cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
|
||||||
>
|
>
|
||||||
{caseItem.name}
|
{isNaN(parseInt(opt))
|
||||||
|
? opt == "timeline"
|
||||||
|
? t("export.time.fromTimeline")
|
||||||
|
: t(`export.time.${opt}`)
|
||||||
|
: t("export.time.lastHour", {
|
||||||
|
count: parseInt(opt),
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{selectedOption == "custom" && (
|
||||||
|
<CustomTimeSelector
|
||||||
|
latestTime={latestTime}
|
||||||
|
range={range}
|
||||||
|
setRange={setRange}
|
||||||
|
startLabel={t("export.time.start.title")}
|
||||||
|
endLabel={t("export.time.end.title")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
className="text-md"
|
||||||
|
type="search"
|
||||||
|
placeholder={t("export.name.placeholder")}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm text-secondary-foreground">
|
||||||
|
{t("export.case.label")}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedCaseId || "none"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setSelectedCaseId(value === "none" ? undefined : value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t("export.case.placeholder")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">
|
||||||
|
{t("label.none", { ns: "common" })}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
{cases
|
||||||
</SelectContent>
|
?.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
</Select>
|
.map((caseItem) => (
|
||||||
</div>
|
<SelectItem key={caseItem.id} value={caseItem.id}>
|
||||||
|
{caseItem.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="multi" className="mt-4 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm text-secondary-foreground">
|
||||||
|
{t("export.multiCamera.timeRange")}
|
||||||
|
</Label>
|
||||||
|
<CustomTimeSelector
|
||||||
|
latestTime={latestTime}
|
||||||
|
range={range}
|
||||||
|
setRange={setRange}
|
||||||
|
startLabel={t("export.time.start.title")}
|
||||||
|
endLabel={t("export.time.end.title")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={() => {
|
||||||
|
if (!range) {
|
||||||
|
setRange({
|
||||||
|
before: currentTime + 30,
|
||||||
|
after: currentTime - 30,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveTab("multi");
|
||||||
|
setMode("timeline_multi");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("export.multiCamera.selectFromTimeline")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm text-secondary-foreground">
|
||||||
|
{t("export.multiCamera.cameraSelection")}
|
||||||
|
</Label>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("export.multiCamera.selectedCount", {
|
||||||
|
count: selectedCameraCount,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 space-y-2 overflow-y-auto rounded-lg border border-border bg-background/50 p-2">
|
||||||
|
{isEventsLoading && (
|
||||||
|
<div className="px-2 py-4 text-sm text-muted-foreground">
|
||||||
|
{t("export.multiCamera.checkingActivity")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isEventsLoading && cameraActivities.length === 0 && (
|
||||||
|
<div className="px-2 py-4 text-sm text-muted-foreground">
|
||||||
|
{t("export.multiCamera.noCameras")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cameraActivities.map((activity) => {
|
||||||
|
const isSelected = selectedCameraIds.includes(activity.camera);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={activity.camera}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 rounded-md border border-border px-3 py-2 text-left transition-colors",
|
||||||
|
activity.hasDetections
|
||||||
|
? "bg-secondary/40 hover:bg-secondary/70"
|
||||||
|
: "bg-background text-muted-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleCameraSelection(activity.camera)}
|
||||||
|
>
|
||||||
|
<Checkbox checked={isSelected} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-sm font-medium text-primary">
|
||||||
|
{resolveCameraName(config, activity.camera)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{t("export.multiCamera.detectionCount", {
|
||||||
|
count: activity.count,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-secondary">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-full",
|
||||||
|
activity.hasDetections ? "bg-selected" : "bg-muted",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(activity.intensity * 100, activity.hasDetections ? 8 : 0)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
className="text-md"
|
||||||
|
type="search"
|
||||||
|
placeholder={t("export.multiCamera.namePlaceholder")}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm text-secondary-foreground">
|
||||||
|
{t("export.case.label")}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={batchCaseSelection}
|
||||||
|
onValueChange={(value) => setBatchCaseSelection(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t("export.case.placeholder")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">
|
||||||
|
{t("label.none", { ns: "common" })}
|
||||||
|
</SelectItem>
|
||||||
|
{cases
|
||||||
|
?.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((caseItem) => (
|
||||||
|
<SelectItem key={caseItem.id} value={caseItem.id}>
|
||||||
|
{caseItem.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectSeparator />
|
||||||
|
<SelectItem value="new">
|
||||||
|
{t("export.case.newCaseOption")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{batchCaseSelection === "new" && (
|
||||||
|
<div className="space-y-2 rounded-lg border border-border bg-background/50 p-3">
|
||||||
|
<Input
|
||||||
|
placeholder={t("export.case.newCaseNamePlaceholder")}
|
||||||
|
value={newCaseName}
|
||||||
|
onChange={(event) => setNewCaseName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
|
||||||
|
value={newCaseDescription}
|
||||||
|
onChange={(event) =>
|
||||||
|
setNewCaseDescription(event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
|
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
|
||||||
<DialogFooter
|
<DialogFooter
|
||||||
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-4"}
|
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-4"}
|
||||||
@ -389,26 +872,43 @@ export function ExportContent({
|
|||||||
>
|
>
|
||||||
{t("button.cancel", { ns: "common" })}
|
{t("button.cancel", { ns: "common" })}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{activeTab === "export" ? (
|
||||||
className={isDesktop ? "" : "w-full"}
|
<Button
|
||||||
aria-label={t("export.selectOrExport")}
|
className={isDesktop ? "" : "w-full"}
|
||||||
variant="select"
|
aria-label={t("export.selectOrExport")}
|
||||||
size="sm"
|
variant="select"
|
||||||
onClick={() => {
|
size="sm"
|
||||||
if (selectedOption == "timeline") {
|
onClick={() => {
|
||||||
setRange({ before: currentTime + 30, after: currentTime - 30 });
|
if (selectedOption == "timeline") {
|
||||||
setMode("timeline");
|
setRange({ before: currentTime + 30, after: currentTime - 30 });
|
||||||
} else {
|
setMode("timeline");
|
||||||
onStartExport();
|
} else {
|
||||||
setSelectedOption("1");
|
onStartExport();
|
||||||
setMode("none");
|
setSelectedOption("1");
|
||||||
}
|
setMode("none");
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
{selectedOption == "timeline"
|
>
|
||||||
? t("export.select")
|
{selectedOption == "timeline"
|
||||||
: t("export.export")}
|
? t("export.select")
|
||||||
</Button>
|
: t("export.export")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className={isDesktop ? "" : "w-full"}
|
||||||
|
aria-label={t("export.multiCamera.exportButton", {
|
||||||
|
count: selectedCameraCount,
|
||||||
|
})}
|
||||||
|
variant="select"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canStartBatchExport}
|
||||||
|
onClick={() => void startBatchExport()}
|
||||||
|
>
|
||||||
|
{t("export.multiCamera.exportButton", {
|
||||||
|
count: selectedCameraCount,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { Button } from "../ui/button";
|
|||||||
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
|
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
|
||||||
import { LuBug } from "react-icons/lu";
|
import { LuBug } from "react-icons/lu";
|
||||||
import { TimeRange } from "@/types/timeline";
|
import { TimeRange } from "@/types/timeline";
|
||||||
import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
|
import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog";
|
||||||
import {
|
import {
|
||||||
DebugReplayContent,
|
DebugReplayContent,
|
||||||
SaveDebugReplayOverlay,
|
SaveDebugReplayOverlay,
|
||||||
@ -102,6 +102,7 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
]);
|
]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
|
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
|
||||||
|
const [exportTab, setExportTab] = useState<ExportTab>("export");
|
||||||
const [selectedReplayOption, setSelectedReplayOption] = useState<
|
const [selectedReplayOption, setSelectedReplayOption] = useState<
|
||||||
"1" | "5" | "custom" | "timeline"
|
"1" | "5" | "custom" | "timeline"
|
||||||
>("1");
|
>("1");
|
||||||
@ -115,16 +116,26 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
);
|
);
|
||||||
const onStartExport = useCallback(() => {
|
const onStartExport = useCallback(() => {
|
||||||
if (!range) {
|
if (!range) {
|
||||||
toast.error(t("toast.error.noValidTimeSelected"), {
|
toast.error(
|
||||||
position: "top-center",
|
t("export.toast.error.noVaildTimeSelected", {
|
||||||
});
|
ns: "components/dialog",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (range.before < range.after) {
|
if (range.before < range.after) {
|
||||||
toast.error(t("toast.error.endTimeMustAfterStartTime"), {
|
toast.error(
|
||||||
position: "top-center",
|
t("export.toast.error.endTimeMustAfterStartTime", {
|
||||||
});
|
ns: "components/dialog",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
position: "top-center",
|
||||||
|
},
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,7 +177,7 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
toast.error(
|
toast.error(
|
||||||
t("export.toast.error.failed", {
|
t("export.toast.error.failed", {
|
||||||
ns: "components/dialog",
|
ns: "components/dialog",
|
||||||
errorMessage,
|
error: errorMessage,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
@ -267,6 +278,7 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
className="flex w-full items-center justify-center gap-2"
|
className="flex w-full items-center justify-center gap-2"
|
||||||
aria-label={t("export")}
|
aria-label={t("export")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setExportTab("export");
|
||||||
setDrawerMode("export");
|
setDrawerMode("export");
|
||||||
setMode("select");
|
setMode("select");
|
||||||
}}
|
}}
|
||||||
@ -331,14 +343,16 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
range={range}
|
range={range}
|
||||||
name={name}
|
name={name}
|
||||||
selectedCaseId={selectedCaseId}
|
selectedCaseId={selectedCaseId}
|
||||||
|
activeTab={exportTab}
|
||||||
onStartExport={onStartExport}
|
onStartExport={onStartExport}
|
||||||
|
setActiveTab={setExportTab}
|
||||||
setName={setName}
|
setName={setName}
|
||||||
setSelectedCaseId={setSelectedCaseId}
|
setSelectedCaseId={setSelectedCaseId}
|
||||||
setRange={setRange}
|
setRange={setRange}
|
||||||
setMode={(mode) => {
|
setMode={(mode) => {
|
||||||
setMode(mode);
|
setMode(mode);
|
||||||
|
|
||||||
if (mode == "timeline") {
|
if (mode == "timeline" || mode == "timeline_multi") {
|
||||||
setDrawerMode("none");
|
setDrawerMode("none");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -346,6 +360,7 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
setMode("none");
|
setMode("none");
|
||||||
setRange(undefined);
|
setRange(undefined);
|
||||||
setSelectedCaseId(undefined);
|
setSelectedCaseId(undefined);
|
||||||
|
setExportTab("export");
|
||||||
setDrawerMode("select");
|
setDrawerMode("select");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -483,9 +498,28 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
<>
|
<>
|
||||||
<SaveExportOverlay
|
<SaveExportOverlay
|
||||||
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
|
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
|
||||||
show={mode == "timeline"}
|
show={mode == "timeline" || mode == "timeline_multi"}
|
||||||
onSave={() => onStartExport()}
|
hidePreview={mode == "timeline_multi"}
|
||||||
onCancel={() => setMode("none")}
|
saveLabel={
|
||||||
|
mode == "timeline_multi"
|
||||||
|
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onSave={() => {
|
||||||
|
if (mode == "timeline_multi") {
|
||||||
|
setExportTab("multi");
|
||||||
|
setDrawerMode("export");
|
||||||
|
setMode("select");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onStartExport();
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setExportTab("export");
|
||||||
|
setRange(undefined);
|
||||||
|
setMode("none");
|
||||||
|
}}
|
||||||
onPreview={() => setShowExportPreview(true)}
|
onPreview={() => setShowExportPreview(true)}
|
||||||
/>
|
/>
|
||||||
<SaveDebugReplayOverlay
|
<SaveDebugReplayOverlay
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
type SaveExportOverlayProps = {
|
type SaveExportOverlayProps = {
|
||||||
className: string;
|
className: string;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
|
hidePreview?: boolean;
|
||||||
|
saveLabel?: string;
|
||||||
onPreview: () => void;
|
onPreview: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@ -14,6 +16,8 @@ type SaveExportOverlayProps = {
|
|||||||
export default function SaveExportOverlay({
|
export default function SaveExportOverlay({
|
||||||
className,
|
className,
|
||||||
show,
|
show,
|
||||||
|
hidePreview = false,
|
||||||
|
saveLabel,
|
||||||
onPreview,
|
onPreview,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
@ -37,24 +41,26 @@ export default function SaveExportOverlay({
|
|||||||
<LuX />
|
<LuX />
|
||||||
{t("button.cancel", { ns: "common" })}
|
{t("button.cancel", { ns: "common" })}
|
||||||
</Button>
|
</Button>
|
||||||
|
{!hidePreview && (
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
aria-label={t("export.fromTimeline.previewExport")}
|
||||||
|
size="sm"
|
||||||
|
onClick={onPreview}
|
||||||
|
>
|
||||||
|
<LuVideo />
|
||||||
|
{t("export.fromTimeline.previewExport")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
aria-label={t("export.fromTimeline.previewExport")}
|
aria-label={saveLabel || t("export.fromTimeline.saveExport")}
|
||||||
size="sm"
|
|
||||||
onClick={onPreview}
|
|
||||||
>
|
|
||||||
<LuVideo />
|
|
||||||
{t("export.fromTimeline.previewExport")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex items-center gap-1"
|
|
||||||
aria-label={t("export.fromTimeline.saveExport")}
|
|
||||||
variant="select"
|
variant="select"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
>
|
>
|
||||||
<FaCompactDisc />
|
<FaCompactDisc />
|
||||||
{t("export.fromTimeline.saveExport")}
|
{saveLabel || t("export.fromTimeline.saveExport")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,12 +13,14 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||||
import { useHistoryBack } from "@/hooks/use-history-back";
|
import { useHistoryBack } from "@/hooks/use-history-back";
|
||||||
import { useApiFilterArgs } from "@/hooks/use-api-filter";
|
import { useApiFilterArgs } from "@/hooks/use-api-filter";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useFormattedTimestamp, useTimeFormat } from "@/hooks/use-date-utils";
|
||||||
import {
|
import {
|
||||||
DeleteClipType,
|
DeleteClipType,
|
||||||
Export,
|
Export,
|
||||||
@ -27,6 +29,7 @@ import {
|
|||||||
} from "@/types/export";
|
} from "@/types/export";
|
||||||
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
|
import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MutableRefObject,
|
MutableRefObject,
|
||||||
@ -39,6 +42,7 @@ import {
|
|||||||
import { isMobile, isMobileOnly } from "react-device-detect";
|
import { isMobile, isMobileOnly } from "react-device-detect";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||||
import { LuFolderX } from "react-icons/lu";
|
import { LuFolderX } from "react-icons/lu";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -71,7 +75,7 @@ function Exports() {
|
|||||||
const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => {
|
const exportsByCase = useMemo<{ [caseId: string]: Export[] }>(() => {
|
||||||
const grouped: { [caseId: string]: Export[] } = {};
|
const grouped: { [caseId: string]: Export[] } = {};
|
||||||
(rawExports ?? []).forEach((exp) => {
|
(rawExports ?? []).forEach((exp) => {
|
||||||
const caseId = exp.export_case || "none";
|
const caseId = exp.export_case ?? exp.export_case_id ?? "none";
|
||||||
if (!grouped[caseId]) {
|
if (!grouped[caseId]) {
|
||||||
grouped[caseId] = [];
|
grouped[caseId] = [];
|
||||||
}
|
}
|
||||||
@ -82,15 +86,8 @@ function Exports() {
|
|||||||
}, [rawExports]);
|
}, [rawExports]);
|
||||||
|
|
||||||
const filteredCases = useMemo<ExportCase[]>(() => {
|
const filteredCases = useMemo<ExportCase[]>(() => {
|
||||||
if (!cases) {
|
return cases || [];
|
||||||
return [];
|
}, [cases]);
|
||||||
}
|
|
||||||
|
|
||||||
return cases.filter((caseItem) => {
|
|
||||||
const caseExports = exportsByCase[caseItem.id];
|
|
||||||
return caseExports?.length;
|
|
||||||
});
|
|
||||||
}, [cases, exportsByCase]);
|
|
||||||
|
|
||||||
const exports = useMemo<Export[]>(
|
const exports = useMemo<Export[]>(
|
||||||
() => exportsByCase["none"] || [],
|
() => exportsByCase["none"] || [],
|
||||||
@ -149,6 +146,13 @@ function Exports() {
|
|||||||
|
|
||||||
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
||||||
const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
|
const [exportToAssign, setExportToAssign] = useState<Export | undefined>();
|
||||||
|
const [caseDialog, setCaseDialog] = useState<
|
||||||
|
{ mode: "create" | "edit"; exportCase?: ExportCase } | undefined
|
||||||
|
>();
|
||||||
|
const [caseToDelete, setCaseToDelete] = useState<ExportCase | undefined>();
|
||||||
|
const [caseForAddExport, setCaseForAddExport] = useState<
|
||||||
|
ExportCase | undefined
|
||||||
|
>();
|
||||||
|
|
||||||
const onHandleDelete = useCallback(() => {
|
const onHandleDelete = useCallback(() => {
|
||||||
if (!deleteClip) {
|
if (!deleteClip) {
|
||||||
@ -194,8 +198,115 @@ function Exports() {
|
|||||||
useKeyboardListener([], undefined, contentRef);
|
useKeyboardListener([], undefined, contentRef);
|
||||||
|
|
||||||
const selectedCase = useMemo(
|
const selectedCase = useMemo(
|
||||||
() => filteredCases?.find((c) => c.id === selectedCaseId),
|
() => cases?.find((c) => c.id === selectedCaseId),
|
||||||
[filteredCases, selectedCaseId],
|
[cases, selectedCaseId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const uncategorizedExports = useMemo(
|
||||||
|
() => exportsByCase["none"] || [],
|
||||||
|
[exportsByCase],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveCase = useCallback(
|
||||||
|
async (
|
||||||
|
payload: { name: string; description: string },
|
||||||
|
exportCaseId?: string,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
let savedCaseId = exportCaseId;
|
||||||
|
|
||||||
|
if (exportCaseId) {
|
||||||
|
await axios.patch(`cases/${exportCaseId}`, payload);
|
||||||
|
} else {
|
||||||
|
const response = await axios.post("cases", payload);
|
||||||
|
savedCaseId = response.data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedCaseId) {
|
||||||
|
setSelectedCaseId(savedCaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
mutate();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
};
|
||||||
|
const errorMessage =
|
||||||
|
apiError.response?.data?.message ||
|
||||||
|
apiError.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(t("toast.error.caseSaveFailed", { errorMessage }), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutate, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSaveCase = useCallback(
|
||||||
|
async (payload: { name: string; description: string }) => {
|
||||||
|
const didSave = await saveCase(
|
||||||
|
payload,
|
||||||
|
caseDialog?.mode === "edit" ? caseDialog.exportCase?.id : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (didSave) {
|
||||||
|
setCaseDialog(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[caseDialog, saveCase],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteCase = useCallback(async () => {
|
||||||
|
if (!caseToDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(`cases/${caseToDelete.id}`);
|
||||||
|
if (selectedCaseId === caseToDelete.id) {
|
||||||
|
setSelectedCaseId(undefined);
|
||||||
|
}
|
||||||
|
setCaseToDelete(undefined);
|
||||||
|
mutate();
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
};
|
||||||
|
const errorMessage =
|
||||||
|
apiError.response?.data?.message ||
|
||||||
|
apiError.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(t("toast.error.caseDeleteFailed", { errorMessage }), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [caseToDelete, mutate, selectedCaseId, t]);
|
||||||
|
|
||||||
|
const handleAssignExportToCase = useCallback(
|
||||||
|
async (exportId: string, caseId: string) => {
|
||||||
|
try {
|
||||||
|
await axios.patch(`export/${exportId}/case`, {
|
||||||
|
export_case_id: caseId,
|
||||||
|
});
|
||||||
|
mutate();
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = error as {
|
||||||
|
response?: { data?: { message?: string; detail?: string } };
|
||||||
|
};
|
||||||
|
const errorMessage =
|
||||||
|
apiError.response?.data?.message ||
|
||||||
|
apiError.response?.data?.detail ||
|
||||||
|
"Unknown error";
|
||||||
|
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
|
||||||
|
position: "top-center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutate, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetCaseDialog = useCallback(() => {
|
const resetCaseDialog = useCallback(() => {
|
||||||
@ -206,6 +317,19 @@ function Exports() {
|
|||||||
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
|
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
|
||||||
<Toaster closeButton={true} />
|
<Toaster closeButton={true} />
|
||||||
|
|
||||||
|
<CaseEditorDialog
|
||||||
|
caseDialog={caseDialog}
|
||||||
|
onClose={() => setCaseDialog(undefined)}
|
||||||
|
onSave={handleSaveCase}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CaseAddExportDialog
|
||||||
|
exportCase={caseForAddExport}
|
||||||
|
availableExports={uncategorizedExports}
|
||||||
|
onClose={() => setCaseForAddExport(undefined)}
|
||||||
|
onAssign={handleAssignExportToCase}
|
||||||
|
/>
|
||||||
|
|
||||||
<CaseAssignmentDialog
|
<CaseAssignmentDialog
|
||||||
exportToAssign={exportToAssign}
|
exportToAssign={exportToAssign}
|
||||||
cases={cases}
|
cases={cases}
|
||||||
@ -241,6 +365,34 @@ function Exports() {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={caseToDelete != undefined}
|
||||||
|
onOpenChange={() => setCaseToDelete(undefined)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("deleteCase.label")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("deleteCase.desc", {
|
||||||
|
caseName: caseToDelete?.name,
|
||||||
|
})}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("button.cancel", { ns: "common" })}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
className="text-white"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void handleDeleteCase()}
|
||||||
|
>
|
||||||
|
{t("button.delete", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={selected != undefined}
|
open={selected != undefined}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@ -288,7 +440,22 @@ function Exports() {
|
|||||||
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
|
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="w-full">
|
<div className="flex w-full items-center gap-2">
|
||||||
|
{selectedCase && (
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
|
aria-label={t("label.back", { ns: "common" })}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedCaseId(undefined)}
|
||||||
|
>
|
||||||
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
|
{!isMobileOnly && (
|
||||||
|
<div className="text-primary">
|
||||||
|
{t("button.back", { ns: "common" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
className="text-md w-full bg-muted md:w-1/2"
|
className="text-md w-full bg-muted md:w-1/2"
|
||||||
placeholder={t("search")}
|
placeholder={t("search")}
|
||||||
@ -296,12 +463,50 @@ function Exports() {
|
|||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ExportFilterGroup
|
{!selectedCase && (
|
||||||
className="w-full justify-between md:justify-start lg:justify-end"
|
<div className="flex w-full items-center justify-between gap-2 md:justify-start lg:justify-end">
|
||||||
filter={exportFilter}
|
<ExportFilterGroup
|
||||||
filters={["cameras"]}
|
className="justify-start"
|
||||||
onUpdateFilter={setExportFilter}
|
filter={exportFilter}
|
||||||
/>
|
filters={["cameras"]}
|
||||||
|
onUpdateFilter={setExportFilter}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCaseDialog({ mode: "create" })}
|
||||||
|
>
|
||||||
|
{t("toolbar.newCase")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedCase && (
|
||||||
|
<div className="flex w-full items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCaseForAddExport(selectedCase)}
|
||||||
|
>
|
||||||
|
<div className="text-primary">{t("toolbar.addExport")}</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setCaseDialog({ mode: "edit", exportCase: selectedCase })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="text-primary">{t("toolbar.editCase")}</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCaseToDelete(selectedCase)}
|
||||||
|
>
|
||||||
|
<div className="text-primary">{t("toolbar.deleteCase")}</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedCase ? (
|
{selectedCase ? (
|
||||||
@ -309,11 +514,13 @@ function Exports() {
|
|||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
selectedCase={selectedCase}
|
selectedCase={selectedCase}
|
||||||
exports={exportsByCase[selectedCase.id] || []}
|
exports={exportsByCase[selectedCase.id] || []}
|
||||||
|
availableExports={uncategorizedExports}
|
||||||
search={search}
|
search={search}
|
||||||
setSelected={setSelected}
|
setSelected={setSelected}
|
||||||
renameClip={onHandleRename}
|
renameClip={onHandleRename}
|
||||||
setDeleteClip={setDeleteClip}
|
setDeleteClip={setDeleteClip}
|
||||||
onAssignToCase={setExportToAssign}
|
onAssignToCase={setExportToAssign}
|
||||||
|
onAddExport={() => setCaseForAddExport(selectedCase)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AllExportsView
|
<AllExportsView
|
||||||
@ -398,14 +605,10 @@ function AllExportsView({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Heading as="h4">{t("headings.cases")}</Heading>
|
<Heading as="h4">{t("headings.cases")}</Heading>
|
||||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{cases?.map((item) => (
|
{filteredCases.map((item) => (
|
||||||
<CaseCard
|
<CaseCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={
|
className=""
|
||||||
search == "" || filteredCases?.includes(item)
|
|
||||||
? ""
|
|
||||||
: "hidden"
|
|
||||||
}
|
|
||||||
exportCase={item}
|
exportCase={item}
|
||||||
exports={exportsByCase[item.id] || []}
|
exports={exportsByCase[item.id] || []}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@ -424,14 +627,10 @@ function AllExportsView({
|
|||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
className="scrollbar-container grid gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
>
|
>
|
||||||
{exports.map((item) => (
|
{filteredExports.map((item) => (
|
||||||
<ExportCard
|
<ExportCard
|
||||||
key={item.name}
|
key={item.name}
|
||||||
className={
|
className=""
|
||||||
search == "" || filteredExports.includes(item)
|
|
||||||
? ""
|
|
||||||
: "hidden"
|
|
||||||
}
|
|
||||||
exportedRecording={item}
|
exportedRecording={item}
|
||||||
onSelect={setSelected}
|
onSelect={setSelected}
|
||||||
onRename={renameClip}
|
onRename={renameClip}
|
||||||
@ -459,25 +658,38 @@ type CaseViewProps = {
|
|||||||
contentRef: MutableRefObject<HTMLDivElement | null>;
|
contentRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
selectedCase: ExportCase;
|
selectedCase: ExportCase;
|
||||||
exports?: Export[];
|
exports?: Export[];
|
||||||
|
availableExports: Export[];
|
||||||
search: string;
|
search: string;
|
||||||
setSelected: (e: Export) => void;
|
setSelected: (e: Export) => void;
|
||||||
renameClip: (id: string, update: string) => void;
|
renameClip: (id: string, update: string) => void;
|
||||||
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
||||||
onAssignToCase: (e: Export) => void;
|
onAssignToCase: (e: Export) => void;
|
||||||
|
onAddExport: () => void;
|
||||||
};
|
};
|
||||||
function CaseView({
|
function CaseView({
|
||||||
contentRef,
|
contentRef,
|
||||||
selectedCase,
|
selectedCase,
|
||||||
exports,
|
exports,
|
||||||
|
availableExports,
|
||||||
search,
|
search,
|
||||||
setSelected,
|
setSelected,
|
||||||
renameClip,
|
renameClip,
|
||||||
setDeleteClip,
|
setDeleteClip,
|
||||||
onAssignToCase,
|
onAssignToCase,
|
||||||
|
onAddExport,
|
||||||
}: CaseViewProps) {
|
}: CaseViewProps) {
|
||||||
|
const { t } = useTranslation(["views/exports", "common"]);
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const timeFormat = useTimeFormat(config);
|
||||||
|
const createdAt = useFormattedTimestamp(
|
||||||
|
selectedCase.created_at,
|
||||||
|
t(`time.formattedTimestampMonthDayYear.${timeFormat}`, { ns: "common" }),
|
||||||
|
config?.ui.timezone,
|
||||||
|
);
|
||||||
|
|
||||||
const filteredExports = useMemo<Export[]>(() => {
|
const filteredExports = useMemo<Export[]>(() => {
|
||||||
const caseExports = (exports || []).filter(
|
const caseExports = (exports || []).filter(
|
||||||
(e) => e.export_case == selectedCase.id,
|
(e) => (e.export_case ?? e.export_case_id) == selectedCase.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!search) {
|
if (!search) {
|
||||||
@ -492,38 +704,260 @@ function CaseView({
|
|||||||
);
|
);
|
||||||
}, [selectedCase, exports, search]);
|
}, [selectedCase, exports, search]);
|
||||||
|
|
||||||
|
const cameraCount = useMemo(
|
||||||
|
() => new Set(filteredExports.map((exp) => exp.camera)).size,
|
||||||
|
[filteredExports],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||||
|
const descriptionRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [descriptionIsClamped, setDescriptionIsClamped] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDescriptionExpanded(false);
|
||||||
|
}, [selectedCase.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = descriptionRef.current;
|
||||||
|
if (!element) {
|
||||||
|
setDescriptionIsClamped(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDescriptionIsClamped(element.scrollHeight > element.clientHeight + 1);
|
||||||
|
}, [selectedCase.description, descriptionExpanded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col gap-8 overflow-hidden">
|
<div className="flex size-full flex-col gap-4 overflow-hidden">
|
||||||
<div className="flex shrink-0 flex-col gap-1">
|
<div className="flex shrink-0 flex-col gap-2">
|
||||||
<Heading className="capitalize" as="h2">
|
<Heading className="mb-0" as="h2">
|
||||||
{selectedCase.name}
|
{selectedCase.name}
|
||||||
</Heading>
|
</Heading>
|
||||||
<div className="text-secondary-foreground">
|
<div className="mb-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||||
{selectedCase.description}
|
<span>{t("caseView.createdAt", { value: createdAt })}</span>
|
||||||
|
<span>
|
||||||
|
{t("caseView.exportCount", { count: filteredExports.length })}
|
||||||
|
</span>
|
||||||
|
<span>{t("caseView.cameraCount", { count: cameraCount })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedCase.description && (
|
||||||
|
<div className="mb-2 flex max-w-5xl flex-col items-start gap-1">
|
||||||
|
<div
|
||||||
|
ref={descriptionRef}
|
||||||
|
className={cn(
|
||||||
|
"whitespace-pre-wrap text-sm text-secondary-foreground",
|
||||||
|
!descriptionExpanded && "line-clamp-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedCase.description}
|
||||||
|
</div>
|
||||||
|
{(descriptionIsClamped || descriptionExpanded) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-primary-variant underline-offset-2 hover:underline"
|
||||||
|
onClick={() => setDescriptionExpanded((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{descriptionExpanded
|
||||||
|
? t("caseView.showLess")
|
||||||
|
: t("caseView.showMore")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
{filteredExports.length > 0 ? (
|
||||||
ref={contentRef}
|
<div
|
||||||
className="scrollbar-container grid min-h-0 flex-1 content-start gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
ref={contentRef}
|
||||||
>
|
className="scrollbar-container grid min-h-0 flex-1 content-start gap-2 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
{exports?.map((item) => (
|
>
|
||||||
<ExportCard
|
{filteredExports.map((item) => (
|
||||||
key={item.name}
|
<ExportCard
|
||||||
className={filteredExports.includes(item) ? "" : "hidden"}
|
key={item.id}
|
||||||
exportedRecording={item}
|
className=""
|
||||||
onSelect={setSelected}
|
exportedRecording={item}
|
||||||
onRename={renameClip}
|
onSelect={setSelected}
|
||||||
onDelete={({ file, exportName }) =>
|
onRename={renameClip}
|
||||||
setDeleteClip({ file, exportName })
|
onDelete={({ file, exportName }) =>
|
||||||
}
|
setDeleteClip({ file, exportName })
|
||||||
onAssignToCase={onAssignToCase}
|
}
|
||||||
/>
|
onAssignToCase={onAssignToCase}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-[16rem] flex-col items-center justify-center rounded-2xl border border-dashed border-border bg-background/40 p-6 text-center">
|
||||||
|
<LuFolderX className="size-12" />
|
||||||
|
<div className="mt-3 text-lg font-medium">
|
||||||
|
{t("caseView.emptyTitle")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 max-w-md text-sm text-muted-foreground">
|
||||||
|
{availableExports.length > 0
|
||||||
|
? t("caseView.emptyDescription")
|
||||||
|
: t("caseView.emptyDescriptionNoExports")}
|
||||||
|
</div>
|
||||||
|
{availableExports.length > 0 && (
|
||||||
|
<Button className="mt-4" variant="default" onClick={onAddExport}>
|
||||||
|
{t("toolbar.addExport")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CaseEditorDialogProps = {
|
||||||
|
caseDialog?: { mode: "create" | "edit"; exportCase?: ExportCase };
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (payload: { name: string; description: string }) => Promise<void>;
|
||||||
|
};
|
||||||
|
function CaseEditorDialog({
|
||||||
|
caseDialog,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: CaseEditorDialogProps) {
|
||||||
|
const { t } = useTranslation(["views/exports", "common"]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setName(caseDialog?.exportCase?.name || "");
|
||||||
|
setDescription(caseDialog?.exportCase?.description || "");
|
||||||
|
}, [caseDialog?.exportCase?.description, caseDialog?.exportCase?.name]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={caseDialog != undefined}
|
||||||
|
onOpenChange={(open) => !open && onClose()}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle>
|
||||||
|
{caseDialog?.mode === "edit"
|
||||||
|
? t("caseEditor.editTitle")
|
||||||
|
: t("caseEditor.createTitle")}
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
placeholder={t("caseEditor.namePlaceholder")}
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
placeholder={t("caseEditor.descriptionPlaceholder")}
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t("button.cancel", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
disabled={name.trim().length === 0}
|
||||||
|
onClick={() =>
|
||||||
|
void onSave({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{caseDialog?.mode === "edit"
|
||||||
|
? t("button.save", { ns: "common" })
|
||||||
|
: t("toolbar.newCase")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaseAddExportDialogProps = {
|
||||||
|
exportCase?: ExportCase;
|
||||||
|
availableExports: Export[];
|
||||||
|
onClose: () => void;
|
||||||
|
onAssign: (exportId: string, caseId: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
function CaseAddExportDialog({
|
||||||
|
exportCase,
|
||||||
|
availableExports,
|
||||||
|
onClose,
|
||||||
|
onAssign,
|
||||||
|
}: CaseAddExportDialogProps) {
|
||||||
|
const { t } = useTranslation(["views/exports", "common"]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearch("");
|
||||||
|
}, [exportCase?.id]);
|
||||||
|
|
||||||
|
const filteredExports = useMemo(() => {
|
||||||
|
if (!search) {
|
||||||
|
return availableExports;
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableExports.filter((exportItem) =>
|
||||||
|
exportItem.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
}, [availableExports, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={exportCase != undefined}
|
||||||
|
onOpenChange={(open) => !open && onClose()}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-h-[80dvh] overflow-hidden">
|
||||||
|
<DialogTitle>
|
||||||
|
{t("addExportDialog.title", { caseName: exportCase?.name })}
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="space-y-3 overflow-hidden">
|
||||||
|
<Input
|
||||||
|
placeholder={t("addExportDialog.searchPlaceholder")}
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="scrollbar-container max-h-[50dvh] space-y-2 overflow-y-auto">
|
||||||
|
{filteredExports.length > 0 ? (
|
||||||
|
filteredExports.map((exportItem) => (
|
||||||
|
<div
|
||||||
|
key={exportItem.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-border p-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 pr-4">
|
||||||
|
<div className="truncate font-medium">
|
||||||
|
{exportItem.name}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
|
{exportItem.camera.replaceAll("_", " ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="select"
|
||||||
|
onClick={() => {
|
||||||
|
if (!exportCase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void onAssign(exportItem.id, exportCase.id).then(onClose);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("button.add", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||||
|
{t("addExportDialog.empty")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type CaseAssignmentDialogProps = {
|
type CaseAssignmentDialogProps = {
|
||||||
exportToAssign?: Export;
|
exportToAssign?: Export;
|
||||||
cases?: ExportCase[];
|
cases?: ExportCase[];
|
||||||
|
|||||||
@ -6,7 +6,8 @@ export type Export = {
|
|||||||
video_path: string;
|
video_path: string;
|
||||||
thumb_path: string;
|
thumb_path: string;
|
||||||
in_progress: boolean;
|
in_progress: boolean;
|
||||||
export_case?: string;
|
export_case?: string | null;
|
||||||
|
export_case_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExportCase = {
|
export type ExportCase = {
|
||||||
@ -17,6 +18,36 @@ export type ExportCase = {
|
|||||||
updated_at: number;
|
updated_at: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BatchExportBody = {
|
||||||
|
start_time: number;
|
||||||
|
end_time: number;
|
||||||
|
camera_ids: string[];
|
||||||
|
name?: string;
|
||||||
|
export_case_id?: string;
|
||||||
|
new_case_name?: string;
|
||||||
|
new_case_description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BatchExportResult = {
|
||||||
|
camera: string;
|
||||||
|
export_id?: string | null;
|
||||||
|
success: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BatchExportResponse = {
|
||||||
|
export_case_id: string;
|
||||||
|
export_ids: string[];
|
||||||
|
results: BatchExportResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CameraActivity = {
|
||||||
|
camera: string;
|
||||||
|
count: number;
|
||||||
|
intensity: number;
|
||||||
|
hasDetections: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type DeleteClipType = {
|
export type DeleteClipType = {
|
||||||
file: string;
|
file: string;
|
||||||
exportName: string;
|
exportName: string;
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type FilterType = { [searchKey: string]: any };
|
export type FilterType = { [searchKey: string]: any };
|
||||||
|
|
||||||
export type ExportMode = "select" | "timeline" | "none";
|
export type ExportMode = "select" | "timeline" | "timeline_multi" | "none";
|
||||||
|
|
||||||
export type FilterList = {
|
export type FilterList = {
|
||||||
labels?: string[];
|
labels?: string[];
|
||||||
|
|||||||
@ -270,7 +270,10 @@ export default function MotionSearchView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (exportMode !== "timeline" || exportRange) {
|
if (
|
||||||
|
(exportMode !== "timeline" && exportMode !== "timeline_multi") ||
|
||||||
|
exportRange
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -955,9 +958,25 @@ export default function MotionSearchView({
|
|||||||
|
|
||||||
<SaveExportOverlay
|
<SaveExportOverlay
|
||||||
className="pointer-events-none absolute inset-x-0 top-0 z-30"
|
className="pointer-events-none absolute inset-x-0 top-0 z-30"
|
||||||
show={exportMode === "timeline" && Boolean(exportRange)}
|
show={
|
||||||
|
(exportMode === "timeline" || exportMode === "timeline_multi") &&
|
||||||
|
Boolean(exportRange)
|
||||||
|
}
|
||||||
|
hidePreview={exportMode === "timeline_multi"}
|
||||||
|
saveLabel={
|
||||||
|
exportMode === "timeline_multi"
|
||||||
|
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onPreview={handleExportPreview}
|
onPreview={handleExportPreview}
|
||||||
onSave={handleExportSave}
|
onSave={() => {
|
||||||
|
if (exportMode === "timeline_multi") {
|
||||||
|
setExportMode("select");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleExportSave();
|
||||||
|
}}
|
||||||
onCancel={handleExportCancel}
|
onCancel={handleExportCancel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -976,7 +995,10 @@ export default function MotionSearchView({
|
|||||||
noRecordingRanges={noRecordings ?? []}
|
noRecordingRanges={noRecordings ?? []}
|
||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)}
|
onHandlebarDraggingChange={(dragging) => setScrubbing(dragging)}
|
||||||
showExportHandles={exportMode === "timeline" && Boolean(exportRange)}
|
showExportHandles={
|
||||||
|
(exportMode === "timeline" || exportMode === "timeline_multi") &&
|
||||||
|
Boolean(exportRange)
|
||||||
|
}
|
||||||
exportStartTime={exportRange?.after}
|
exportStartTime={exportRange?.after}
|
||||||
exportEndTime={exportRange?.before}
|
exportEndTime={exportRange?.before}
|
||||||
setExportStartTime={setExportStartTime}
|
setExportStartTime={setExportStartTime}
|
||||||
@ -1408,7 +1430,11 @@ export default function MotionSearchView({
|
|||||||
onControllerReady={(controller) => {
|
onControllerReady={(controller) => {
|
||||||
mainControllerRef.current = controller;
|
mainControllerRef.current = controller;
|
||||||
}}
|
}}
|
||||||
isScrubbing={scrubbing || exportMode == "timeline"}
|
isScrubbing={
|
||||||
|
scrubbing ||
|
||||||
|
exportMode == "timeline" ||
|
||||||
|
exportMode == "timeline_multi"
|
||||||
|
}
|
||||||
supportsFullscreen={supportsFullScreen}
|
supportsFullscreen={supportsFullScreen}
|
||||||
setFullResolution={setFullResolution}
|
setFullResolution={setFullResolution}
|
||||||
toggleFullscreen={toggleFullscreen}
|
toggleFullscreen={toggleFullscreen}
|
||||||
|
|||||||
@ -833,6 +833,7 @@ export function RecordingView({
|
|||||||
isScrubbing={
|
isScrubbing={
|
||||||
scrubbing ||
|
scrubbing ||
|
||||||
exportMode == "timeline" ||
|
exportMode == "timeline" ||
|
||||||
|
exportMode == "timeline_multi" ||
|
||||||
debugReplayMode == "timeline"
|
debugReplayMode == "timeline"
|
||||||
}
|
}
|
||||||
supportsFullscreen={supportsFullScreen}
|
supportsFullscreen={supportsFullScreen}
|
||||||
@ -911,7 +912,7 @@ export function RecordingView({
|
|||||||
activeReviewItem={activeReviewItem}
|
activeReviewItem={activeReviewItem}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
exportRange={
|
exportRange={
|
||||||
exportMode == "timeline"
|
exportMode == "timeline" || exportMode == "timeline_multi"
|
||||||
? exportRange
|
? exportRange
|
||||||
: debugReplayMode == "timeline"
|
: debugReplayMode == "timeline"
|
||||||
? debugReplayRange
|
? debugReplayRange
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user