2026-04-14 17:19:50 +03:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
2024-03-27 00:37:45 +03:00
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
2024-10-13 20:46:40 +03:00
|
|
|
DialogDescription,
|
2024-03-27 00:37:45 +03:00
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
DialogTrigger,
|
|
|
|
|
} from "../ui/dialog";
|
|
|
|
|
import { Label } from "../ui/label";
|
|
|
|
|
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
|
|
|
|
|
import { Button } from "../ui/button";
|
|
|
|
|
import { ExportMode } from "@/types/filter";
|
2026-03-04 19:07:34 +03:00
|
|
|
import { FaArrowDown } from "react-icons/fa";
|
2026-04-14 17:19:50 +03:00
|
|
|
import { LuAudioLines } from "react-icons/lu";
|
2024-03-27 00:37:45 +03:00
|
|
|
import axios from "axios";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import { Input } from "../ui/input";
|
|
|
|
|
import { TimeRange } from "@/types/timeline";
|
|
|
|
|
import useSWR from "swr";
|
2026-04-14 17:19:50 +03:00
|
|
|
import {
|
|
|
|
|
BatchExportBody,
|
|
|
|
|
BatchExportResponse,
|
|
|
|
|
CameraActivity,
|
|
|
|
|
ExportCase,
|
|
|
|
|
StartExportResponse,
|
|
|
|
|
} from "@/types/export";
|
2026-01-03 18:03:33 +03:00
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectSeparator,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "../ui/select";
|
2026-04-14 17:19:50 +03:00
|
|
|
import {
|
|
|
|
|
Tooltip,
|
|
|
|
|
TooltipContent,
|
|
|
|
|
TooltipTrigger,
|
|
|
|
|
} from "@/components/ui/tooltip";
|
2026-03-04 19:07:34 +03:00
|
|
|
import { isDesktop, isMobile } from "react-device-detect";
|
2024-03-28 02:03:05 +03:00
|
|
|
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
|
|
|
|
import SaveExportOverlay from "./SaveExportOverlay";
|
2024-10-13 20:46:40 +03:00
|
|
|
import { baseUrl } from "@/api/baseUrl";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
|
2025-03-16 18:36:20 +03:00
|
|
|
import { useTranslation } from "react-i18next";
|
2026-03-04 19:07:34 +03:00
|
|
|
import { CustomTimeSelector } from "./CustomTimeSelector";
|
2026-04-14 17:19:50 +03:00
|
|
|
import { Event } from "@/types/event";
|
|
|
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
|
|
|
|
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
|
|
|
|
import { Textarea } from "../ui/textarea";
|
|
|
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
2024-03-27 00:37:45 +03:00
|
|
|
|
|
|
|
|
const EXPORT_OPTIONS = [
|
|
|
|
|
"1",
|
|
|
|
|
"4",
|
|
|
|
|
"8",
|
|
|
|
|
"12",
|
|
|
|
|
"24",
|
|
|
|
|
"timeline",
|
|
|
|
|
"custom",
|
|
|
|
|
] as const;
|
|
|
|
|
type ExportOption = (typeof EXPORT_OPTIONS)[number];
|
2026-04-14 17:19:50 +03:00
|
|
|
export type ExportTab = "export" | "multi";
|
2024-03-27 00:37:45 +03:00
|
|
|
|
|
|
|
|
type ExportDialogProps = {
|
|
|
|
|
camera: string;
|
|
|
|
|
latestTime: number;
|
|
|
|
|
currentTime: number;
|
|
|
|
|
range?: TimeRange;
|
|
|
|
|
mode: ExportMode;
|
2024-10-13 20:46:40 +03:00
|
|
|
showPreview: boolean;
|
2024-03-27 00:37:45 +03:00
|
|
|
setRange: (range: TimeRange | undefined) => void;
|
|
|
|
|
setMode: (mode: ExportMode) => void;
|
2024-10-13 20:46:40 +03:00
|
|
|
setShowPreview: (showPreview: boolean) => void;
|
2024-03-27 00:37:45 +03:00
|
|
|
};
|
2026-04-14 17:19:50 +03:00
|
|
|
|
2024-03-27 00:37:45 +03:00
|
|
|
export default function ExportDialog({
|
|
|
|
|
camera,
|
|
|
|
|
latestTime,
|
|
|
|
|
currentTime,
|
|
|
|
|
range,
|
|
|
|
|
mode,
|
2024-10-13 20:46:40 +03:00
|
|
|
showPreview,
|
2024-03-27 00:37:45 +03:00
|
|
|
setRange,
|
|
|
|
|
setMode,
|
2024-10-13 20:46:40 +03:00
|
|
|
setShowPreview,
|
2024-03-27 00:37:45 +03:00
|
|
|
}: ExportDialogProps) {
|
2025-03-16 18:36:20 +03:00
|
|
|
const { t } = useTranslation(["components/dialog"]);
|
2024-03-27 00:37:45 +03:00
|
|
|
const [name, setName] = useState("");
|
2026-04-14 17:19:50 +03:00
|
|
|
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>();
|
|
|
|
|
const [singleNewCaseName, setSingleNewCaseName] = useState("");
|
|
|
|
|
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
|
|
|
|
|
const [activeTab, setActiveTab] = useState<ExportTab>("export");
|
|
|
|
|
const [isStartingExport, setIsStartingExport] = useState(false);
|
|
|
|
|
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(async () => {
|
|
|
|
|
if (isStartingExport) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2024-10-13 20:46:40 +03:00
|
|
|
|
2024-03-28 02:03:05 +03:00
|
|
|
if (!range) {
|
2025-03-16 18:36:20 +03:00
|
|
|
toast.error(t("export.toast.error.noVaildTimeSelected"), {
|
|
|
|
|
position: "top-center",
|
|
|
|
|
});
|
2026-04-14 17:19:50 +03:00
|
|
|
return false;
|
2024-03-28 02:03:05 +03:00
|
|
|
}
|
|
|
|
|
|
2024-07-16 18:04:33 +03:00
|
|
|
if (range.before < range.after) {
|
2025-03-16 18:36:20 +03:00
|
|
|
toast.error(t("export.toast.error.endTimeMustAfterStartTime"), {
|
2024-07-16 18:04:33 +03:00
|
|
|
position: "top-center",
|
|
|
|
|
});
|
2026-04-14 17:19:50 +03:00
|
|
|
return false;
|
2024-07-16 18:04:33 +03:00
|
|
|
}
|
|
|
|
|
|
2026-04-14 17:19:50 +03:00
|
|
|
setIsStartingExport(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
let exportCaseId: string | undefined = selectedCaseId;
|
|
|
|
|
|
|
|
|
|
if (selectedCaseId === "new" && singleNewCaseName.trim().length > 0) {
|
|
|
|
|
const caseResp = await axios.post("cases", {
|
|
|
|
|
name: singleNewCaseName.trim(),
|
|
|
|
|
description: singleNewCaseDescription.trim() || undefined,
|
|
|
|
|
});
|
|
|
|
|
exportCaseId = caseResp.data?.id;
|
|
|
|
|
} else if (selectedCaseId === "new" || selectedCaseId === "none") {
|
|
|
|
|
exportCaseId = undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await axios.post<StartExportResponse>(
|
2024-04-07 23:35:45 +03:00
|
|
|
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
|
|
|
|
|
{
|
2026-04-14 17:19:50 +03:00
|
|
|
source: "recordings",
|
2024-04-07 23:35:45 +03:00
|
|
|
name,
|
2026-04-14 17:19:50 +03:00
|
|
|
export_case_id: exportCaseId,
|
2024-04-07 23:35:45 +03:00
|
|
|
},
|
2026-04-14 17:19:50 +03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
toast.success(t("export.toast.queued"), {
|
|
|
|
|
position: "top-center",
|
|
|
|
|
action: (
|
|
|
|
|
<a href="/export" target="_blank" rel="noopener noreferrer">
|
|
|
|
|
<Button>{t("export.toast.view")}</Button>
|
|
|
|
|
</a>
|
|
|
|
|
),
|
2024-03-28 02:03:05 +03:00
|
|
|
});
|
2026-04-14 17:19:50 +03:00
|
|
|
setName("");
|
|
|
|
|
setSelectedCaseId(undefined);
|
|
|
|
|
setSingleNewCaseName("");
|
|
|
|
|
setSingleNewCaseDescription("");
|
|
|
|
|
setRange(undefined);
|
|
|
|
|
setMode("none");
|
|
|
|
|
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("export.toast.error.failed", {
|
|
|
|
|
error: errorMessage,
|
|
|
|
|
}),
|
|
|
|
|
{ position: "top-center" },
|
|
|
|
|
);
|
|
|
|
|
return false;
|
|
|
|
|
} finally {
|
|
|
|
|
setIsStartingExport(false);
|
|
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
camera,
|
|
|
|
|
isStartingExport,
|
|
|
|
|
name,
|
|
|
|
|
range,
|
|
|
|
|
selectedCaseId,
|
|
|
|
|
singleNewCaseDescription,
|
|
|
|
|
singleNewCaseName,
|
|
|
|
|
setMode,
|
|
|
|
|
setRange,
|
|
|
|
|
t,
|
|
|
|
|
]);
|
2024-03-28 02:03:05 +03:00
|
|
|
|
2025-02-18 21:50:32 +03:00
|
|
|
const handleCancel = useCallback(() => {
|
|
|
|
|
setName("");
|
2026-01-03 18:03:33 +03:00
|
|
|
setSelectedCaseId(undefined);
|
2026-04-14 17:19:50 +03:00
|
|
|
setSingleNewCaseName("");
|
|
|
|
|
setSingleNewCaseDescription("");
|
2025-02-18 21:50:32 +03:00
|
|
|
setMode("none");
|
|
|
|
|
setRange(undefined);
|
2026-04-14 17:19:50 +03:00
|
|
|
setActiveTab("export");
|
2025-02-18 21:50:32 +03:00
|
|
|
}, [setMode, setRange]);
|
|
|
|
|
|
2024-03-28 02:03:05 +03:00
|
|
|
const Overlay = isDesktop ? Dialog : Drawer;
|
|
|
|
|
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
|
|
|
|
|
const Content = isDesktop ? DialogContent : DrawerContent;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
2024-10-13 20:46:40 +03:00
|
|
|
<ExportPreviewDialog
|
|
|
|
|
camera={camera}
|
|
|
|
|
range={range}
|
|
|
|
|
showPreview={showPreview}
|
|
|
|
|
setShowPreview={setShowPreview}
|
|
|
|
|
/>
|
2024-03-28 02:03:05 +03:00
|
|
|
<SaveExportOverlay
|
2024-05-14 18:06:44 +03:00
|
|
|
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
|
2026-04-14 17:19:50 +03:00
|
|
|
show={mode == "timeline" || mode == "timeline_multi"}
|
|
|
|
|
hidePreview={mode == "timeline_multi"}
|
|
|
|
|
isSaving={isStartingExport}
|
|
|
|
|
saveLabel={
|
|
|
|
|
mode == "timeline_multi"
|
|
|
|
|
? t("export.fromTimeline.useThisRange")
|
|
|
|
|
: undefined
|
|
|
|
|
}
|
2024-10-13 20:46:40 +03:00
|
|
|
onPreview={() => setShowPreview(true)}
|
2026-04-14 17:19:50 +03:00
|
|
|
onSave={() => {
|
|
|
|
|
if (mode == "timeline_multi") {
|
|
|
|
|
setActiveTab("multi");
|
|
|
|
|
setMode("select");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void onStartExport();
|
|
|
|
|
}}
|
2025-02-18 21:50:32 +03:00
|
|
|
onCancel={handleCancel}
|
2024-03-28 02:03:05 +03:00
|
|
|
/>
|
|
|
|
|
<Overlay
|
|
|
|
|
open={mode == "select"}
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!open) {
|
2026-04-14 17:19:50 +03:00
|
|
|
handleCancel();
|
2024-03-28 02:03:05 +03:00
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-03-04 19:07:34 +03:00
|
|
|
{!isDesktop && (
|
|
|
|
|
<Trigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
className="flex items-center gap-2"
|
|
|
|
|
aria-label={t("menu.export", { ns: "common" })}
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const now = new Date(latestTime * 1000);
|
|
|
|
|
now.setHours(now.getHours() - 1);
|
2026-04-14 17:19:50 +03:00
|
|
|
setActiveTab("export");
|
2026-03-04 19:07:34 +03:00
|
|
|
setRange({
|
|
|
|
|
before: latestTime,
|
2026-04-14 17:19:50 +03:00
|
|
|
after: now.getTime() / 1000,
|
2026-03-04 19:07:34 +03:00
|
|
|
});
|
|
|
|
|
setMode("select");
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
|
|
|
|
|
</Button>
|
|
|
|
|
</Trigger>
|
|
|
|
|
)}
|
2024-03-28 02:03:05 +03:00
|
|
|
<Content
|
|
|
|
|
className={
|
2024-04-22 18:12:45 +03:00
|
|
|
isDesktop
|
|
|
|
|
? "sm:rounded-lg md:rounded-2xl"
|
2024-05-14 18:06:44 +03:00
|
|
|
: "mx-4 rounded-lg px-4 pb-4 md:rounded-2xl"
|
2024-03-28 02:03:05 +03:00
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<ExportContent
|
|
|
|
|
latestTime={latestTime}
|
|
|
|
|
currentTime={currentTime}
|
|
|
|
|
range={range}
|
|
|
|
|
name={name}
|
2026-01-03 18:03:33 +03:00
|
|
|
selectedCaseId={selectedCaseId}
|
2026-04-14 17:19:50 +03:00
|
|
|
singleNewCaseName={singleNewCaseName}
|
|
|
|
|
singleNewCaseDescription={singleNewCaseDescription}
|
|
|
|
|
activeTab={activeTab}
|
|
|
|
|
isStartingExport={isStartingExport}
|
2024-03-28 02:03:05 +03:00
|
|
|
onStartExport={onStartExport}
|
2026-04-14 17:19:50 +03:00
|
|
|
setActiveTab={setActiveTab}
|
2024-03-28 02:03:05 +03:00
|
|
|
setName={setName}
|
2026-01-03 18:03:33 +03:00
|
|
|
setSelectedCaseId={setSelectedCaseId}
|
2026-04-14 17:19:50 +03:00
|
|
|
setSingleNewCaseName={setSingleNewCaseName}
|
|
|
|
|
setSingleNewCaseDescription={setSingleNewCaseDescription}
|
2024-03-28 02:03:05 +03:00
|
|
|
setRange={setRange}
|
|
|
|
|
setMode={setMode}
|
2025-02-18 21:50:32 +03:00
|
|
|
onCancel={handleCancel}
|
2024-03-28 02:03:05 +03:00
|
|
|
/>
|
|
|
|
|
</Content>
|
|
|
|
|
</Overlay>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ExportContentProps = {
|
|
|
|
|
latestTime: number;
|
|
|
|
|
currentTime: number;
|
|
|
|
|
range?: TimeRange;
|
|
|
|
|
name: string;
|
2026-01-03 18:03:33 +03:00
|
|
|
selectedCaseId?: string;
|
2026-04-14 17:19:50 +03:00
|
|
|
singleNewCaseName: string;
|
|
|
|
|
singleNewCaseDescription: string;
|
|
|
|
|
activeTab: ExportTab;
|
|
|
|
|
isStartingExport: boolean;
|
|
|
|
|
onStartExport: () => Promise<boolean>;
|
|
|
|
|
setActiveTab: (tab: ExportTab) => void;
|
2024-03-28 02:03:05 +03:00
|
|
|
setName: (name: string) => void;
|
2026-01-03 18:03:33 +03:00
|
|
|
setSelectedCaseId: (caseId: string | undefined) => void;
|
2026-04-14 17:19:50 +03:00
|
|
|
setSingleNewCaseName: (name: string) => void;
|
|
|
|
|
setSingleNewCaseDescription: (description: string) => void;
|
2024-03-28 02:03:05 +03:00
|
|
|
setRange: (range: TimeRange | undefined) => void;
|
|
|
|
|
setMode: (mode: ExportMode) => void;
|
|
|
|
|
onCancel: () => void;
|
|
|
|
|
};
|
2026-04-14 17:19:50 +03:00
|
|
|
|
2024-03-28 02:03:05 +03:00
|
|
|
export function ExportContent({
|
|
|
|
|
latestTime,
|
|
|
|
|
currentTime,
|
|
|
|
|
range,
|
|
|
|
|
name,
|
2026-01-03 18:03:33 +03:00
|
|
|
selectedCaseId,
|
2026-04-14 17:19:50 +03:00
|
|
|
singleNewCaseName,
|
|
|
|
|
singleNewCaseDescription,
|
|
|
|
|
activeTab,
|
|
|
|
|
isStartingExport,
|
2024-03-28 02:03:05 +03:00
|
|
|
onStartExport,
|
2026-04-14 17:19:50 +03:00
|
|
|
setActiveTab,
|
2024-03-28 02:03:05 +03:00
|
|
|
setName,
|
2026-01-03 18:03:33 +03:00
|
|
|
setSelectedCaseId,
|
2026-04-14 17:19:50 +03:00
|
|
|
setSingleNewCaseName,
|
|
|
|
|
setSingleNewCaseDescription,
|
2024-03-28 02:03:05 +03:00
|
|
|
setRange,
|
|
|
|
|
setMode,
|
|
|
|
|
onCancel,
|
|
|
|
|
}: ExportContentProps) {
|
2025-03-16 18:36:20 +03:00
|
|
|
const { t } = useTranslation(["components/dialog"]);
|
2026-04-14 17:19:50 +03:00
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const isAdmin = useIsAdmin();
|
2024-03-28 02:03:05 +03:00
|
|
|
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
|
2026-04-14 17:19:50 +03:00
|
|
|
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
|
|
|
|
|
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 [isStartingBatchExport, setIsStartingBatchExport] = useState(false);
|
|
|
|
|
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 byCamera = new Map<string, Event[]>();
|
|
|
|
|
|
|
|
|
|
events?.forEach((event) => {
|
|
|
|
|
const bucket = byCamera.get(event.camera);
|
|
|
|
|
if (bucket) {
|
|
|
|
|
bucket.push(event);
|
|
|
|
|
} else {
|
|
|
|
|
byCamera.set(event.camera, [event]);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const rangeStart = debouncedRange?.after ?? 0;
|
|
|
|
|
const rangeEnd = debouncedRange?.before ?? 0;
|
|
|
|
|
const rangeDuration = Math.max(1, rangeEnd - rangeStart);
|
|
|
|
|
|
|
|
|
|
return allCameraIds.map((cameraId) => {
|
|
|
|
|
const cameraEvents = byCamera.get(cameraId) ?? [];
|
|
|
|
|
const segments = cameraEvents
|
|
|
|
|
.map((event) => {
|
|
|
|
|
// Event end_time is null for in-progress events; fall back to start.
|
|
|
|
|
const eventEnd = event.end_time ?? event.start_time;
|
|
|
|
|
const start = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
Math.min(1, (event.start_time - rangeStart) / rangeDuration),
|
|
|
|
|
);
|
|
|
|
|
const end = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
Math.min(1, (eventEnd - rangeStart) / rangeDuration),
|
|
|
|
|
);
|
|
|
|
|
return { start, end: Math.max(end, start) };
|
|
|
|
|
})
|
|
|
|
|
.sort((a, b) => a.start - b.start);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
camera: cameraId,
|
|
|
|
|
count: cameraEvents.length,
|
|
|
|
|
hasDetections: cameraEvents.length > 0,
|
|
|
|
|
segments,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}, [config?.cameras, debouncedRange, 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 &&
|
|
|
|
|
!isStartingBatchExport &&
|
|
|
|
|
(batchCaseSelection !== "new" || newCaseName.trim().length > 0) &&
|
|
|
|
|
batchCaseSelection.length > 0;
|
2024-03-27 00:37:45 +03:00
|
|
|
|
|
|
|
|
const onSelectTime = useCallback(
|
|
|
|
|
(option: ExportOption) => {
|
|
|
|
|
setSelectedOption(option);
|
|
|
|
|
|
|
|
|
|
const now = new Date(latestTime * 1000);
|
|
|
|
|
let start = 0;
|
2026-04-14 17:19:50 +03:00
|
|
|
|
2024-03-27 00:37:45 +03:00
|
|
|
switch (option) {
|
|
|
|
|
case "1":
|
|
|
|
|
now.setHours(now.getHours() - 1);
|
|
|
|
|
start = now.getTime() / 1000;
|
|
|
|
|
break;
|
|
|
|
|
case "4":
|
|
|
|
|
now.setHours(now.getHours() - 4);
|
|
|
|
|
start = now.getTime() / 1000;
|
|
|
|
|
break;
|
|
|
|
|
case "8":
|
|
|
|
|
now.setHours(now.getHours() - 8);
|
|
|
|
|
start = now.getTime() / 1000;
|
|
|
|
|
break;
|
|
|
|
|
case "12":
|
|
|
|
|
now.setHours(now.getHours() - 12);
|
|
|
|
|
start = now.getTime() / 1000;
|
|
|
|
|
break;
|
|
|
|
|
case "24":
|
|
|
|
|
now.setHours(now.getHours() - 24);
|
|
|
|
|
start = now.getTime() / 1000;
|
|
|
|
|
break;
|
2025-02-09 16:36:55 +03:00
|
|
|
case "custom":
|
|
|
|
|
start = latestTime - 3600;
|
|
|
|
|
break;
|
2026-04-14 17:19:50 +03:00
|
|
|
default:
|
|
|
|
|
start = latestTime - 3600;
|
2024-03-27 00:37:45 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setRange({
|
|
|
|
|
before: latestTime,
|
|
|
|
|
after: start,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[latestTime, setRange],
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-14 17:19:50 +03:00
|
|
|
const toggleCameraSelection = useCallback((cameraId: string) => {
|
|
|
|
|
setHasManualCameraSelection(true);
|
|
|
|
|
setSelectedCameraIds((previous) =>
|
|
|
|
|
previous.includes(cameraId)
|
|
|
|
|
? previous.filter((selectedId) => selectedId !== cameraId)
|
|
|
|
|
: [...previous, cameraId],
|
|
|
|
|
);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const startBatchExport = useCallback(async () => {
|
|
|
|
|
if (isStartingBatchExport) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 = {
|
|
|
|
|
items: selectedCameraIds.map((cameraId) => ({
|
|
|
|
|
camera: cameraId,
|
|
|
|
|
start_time: Math.round(range.after),
|
|
|
|
|
end_time: Math.round(range.before),
|
|
|
|
|
friendly_name: name
|
|
|
|
|
? `${name} - ${resolveCameraName(config, cameraId)}`
|
|
|
|
|
: undefined,
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (isAdmin && batchCaseSelection !== "none") {
|
|
|
|
|
if (batchCaseSelection === "new") {
|
|
|
|
|
payload.new_case_name = newCaseName.trim();
|
|
|
|
|
payload.new_case_description = newCaseDescription.trim() || undefined;
|
|
|
|
|
} else {
|
|
|
|
|
payload.export_case_id = batchCaseSelection;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsStartingBatchExport(true);
|
|
|
|
|
|
|
|
|
|
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.batchQueuedPartial", {
|
|
|
|
|
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.batchQueueFailed", {
|
|
|
|
|
total: results.length,
|
|
|
|
|
failedCameras: failedResults
|
|
|
|
|
.map((result) => resolveCameraName(config, result.camera))
|
|
|
|
|
.join(", "),
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
position: "top-center",
|
|
|
|
|
description: failedSummary,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
toast.success(
|
|
|
|
|
t("export.toast.batchQueuedSuccess", {
|
|
|
|
|
count: successfulResults.length,
|
|
|
|
|
}),
|
|
|
|
|
{ position: "top-center" },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (successfulResults.length > 0) {
|
|
|
|
|
setName("");
|
|
|
|
|
setSelectedCaseId(undefined);
|
|
|
|
|
setBatchCaseSelection("new");
|
|
|
|
|
setNewCaseName("");
|
|
|
|
|
setNewCaseDescription("");
|
|
|
|
|
setRange(undefined);
|
|
|
|
|
setMode("none");
|
|
|
|
|
setActiveTab("export");
|
|
|
|
|
if (response.data.export_case_id) {
|
|
|
|
|
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" },
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsStartingBatchExport(false);
|
|
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
batchCaseSelection,
|
|
|
|
|
config,
|
|
|
|
|
isAdmin,
|
|
|
|
|
isStartingBatchExport,
|
|
|
|
|
name,
|
|
|
|
|
newCaseDescription,
|
|
|
|
|
newCaseName,
|
|
|
|
|
range,
|
|
|
|
|
selectedCameraIds,
|
|
|
|
|
setActiveTab,
|
|
|
|
|
setMode,
|
|
|
|
|
setName,
|
|
|
|
|
setRange,
|
|
|
|
|
setSelectedCaseId,
|
|
|
|
|
t,
|
|
|
|
|
navigate,
|
|
|
|
|
]);
|
|
|
|
|
|
2024-03-27 00:37:45 +03:00
|
|
|
return (
|
2026-04-14 17:19:50 +03:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex w-full flex-col",
|
|
|
|
|
!isDesktop && "max-h-[80dvh] min-h-0",
|
|
|
|
|
)}
|
|
|
|
|
>
|
2024-03-28 02:03:05 +03:00
|
|
|
{isDesktop && (
|
2026-04-14 17:19:50 +03:00
|
|
|
<DialogHeader className="mb-4">
|
|
|
|
|
<DialogTitle>{t("menu.export", { ns: "common" })}</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
2024-03-28 02:03:05 +03:00
|
|
|
)}
|
2026-04-14 17:19:50 +03:00
|
|
|
|
|
|
|
|
<Tabs
|
|
|
|
|
value={activeTab}
|
|
|
|
|
onValueChange={(value) => setActiveTab(value as ExportTab)}
|
|
|
|
|
className={cn("w-full", !isDesktop && "flex min-h-0 flex-1 flex-col")}
|
2024-03-28 02:03:05 +03:00
|
|
|
>
|
2026-04-14 17:19:50 +03:00
|
|
|
<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={cn(
|
|
|
|
|
"mt-4 space-y-4",
|
|
|
|
|
!isDesktop && "min-h-0 flex-1 overflow-y-auto pr-1",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<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}
|
|
|
|
|
>
|
|
|
|
|
{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)}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{isAdmin && (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-sm text-secondary-foreground">
|
|
|
|
|
{t("export.case.label")}
|
2024-03-28 02:03:05 +03:00
|
|
|
</Label>
|
2026-04-14 17:19:50 +03:00
|
|
|
<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>
|
|
|
|
|
{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>
|
|
|
|
|
{selectedCaseId === "new" && (
|
|
|
|
|
<div className="space-y-2 pt-1">
|
|
|
|
|
<Input
|
|
|
|
|
className="text-md"
|
|
|
|
|
placeholder={t("export.case.newCaseNamePlaceholder")}
|
|
|
|
|
value={singleNewCaseName}
|
|
|
|
|
onChange={(e) => setSingleNewCaseName(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
<Textarea
|
|
|
|
|
className="text-md"
|
|
|
|
|
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
|
|
|
|
|
value={singleNewCaseDescription}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
setSingleNewCaseDescription(e.target.value)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2024-03-28 02:03:05 +03:00
|
|
|
</div>
|
2026-04-14 17:19:50 +03:00
|
|
|
)}
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
<TabsContent
|
|
|
|
|
value="multi"
|
|
|
|
|
className={cn(
|
|
|
|
|
"mt-4 space-y-4",
|
|
|
|
|
!isDesktop && "min-h-0 flex-1 overflow-y-auto pr-1",
|
|
|
|
|
)}
|
2026-01-03 18:03:33 +03:00
|
|
|
>
|
2026-04-14 17:19:50 +03:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-sm text-secondary-foreground">
|
|
|
|
|
{t("export.multiCamera.timeRange")}
|
|
|
|
|
</Label>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="min-w-0 flex-1 [&>div]:!mx-0 [&>div]:!mt-0">
|
|
|
|
|
<CustomTimeSelector
|
|
|
|
|
latestTime={latestTime}
|
|
|
|
|
range={range}
|
|
|
|
|
setRange={setRange}
|
|
|
|
|
startLabel={t("export.time.start.title")}
|
|
|
|
|
endLabel={t("export.time.end.title")}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="size-9 shrink-0 p-0"
|
|
|
|
|
aria-label={t("export.multiCamera.selectFromTimeline")}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!range) {
|
|
|
|
|
setRange({
|
|
|
|
|
before: currentTime + 30,
|
|
|
|
|
after: currentTime - 30,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setActiveTab("multi");
|
|
|
|
|
setMode("timeline_multi");
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<LuAudioLines className="size-4 -rotate-90" />
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
{t("export.multiCamera.selectFromTimeline")}
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-sm text-secondary-foreground">
|
|
|
|
|
{t("export.multiCamera.cameraSelection")}
|
|
|
|
|
</Label>
|
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
|
|
|
{t("export.multiCamera.cameraSelectionHelp")}
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"scrollbar-container space-y-2",
|
|
|
|
|
isDesktop && "max-h-64 overflow-y-auto pr-1",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{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"
|
|
|
|
|
aria-pressed={isSelected}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors",
|
|
|
|
|
isSelected
|
|
|
|
|
? "border-selected bg-selected/10 ring-1 ring-selected"
|
|
|
|
|
: "border-transparent bg-secondary/40 hover:bg-secondary/70",
|
|
|
|
|
!activity.hasDetections &&
|
|
|
|
|
!isSelected &&
|
|
|
|
|
"text-muted-foreground",
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => toggleCameraSelection(activity.camera)}
|
|
|
|
|
>
|
|
|
|
|
<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="relative mt-2 h-1.5 w-full overflow-hidden rounded-full bg-secondary">
|
|
|
|
|
{activity.segments.map((segment, index) => {
|
|
|
|
|
const leftPct = segment.start * 100;
|
|
|
|
|
const widthPct = Math.max(
|
|
|
|
|
(segment.end - segment.start) * 100,
|
|
|
|
|
1,
|
|
|
|
|
);
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={index}
|
|
|
|
|
className="absolute top-0 h-full rounded-full bg-selected"
|
|
|
|
|
style={{
|
|
|
|
|
left: `${leftPct}%`,
|
|
|
|
|
width: `${widthPct}%`,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
2026-01-03 18:03:33 +03:00
|
|
|
})}
|
2026-04-14 17:19:50 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-sm text-secondary-foreground">
|
|
|
|
|
{t("export.multiCamera.nameLabel")}
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
className="text-md"
|
|
|
|
|
type="search"
|
|
|
|
|
placeholder={t("export.multiCamera.namePlaceholder")}
|
|
|
|
|
value={name}
|
|
|
|
|
onChange={(e) => setName(e.target.value)}
|
2026-01-03 18:03:33 +03:00
|
|
|
/>
|
2026-04-14 17:19:50 +03:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{isAdmin && (
|
|
|
|
|
<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 pt-1">
|
|
|
|
|
<Input
|
|
|
|
|
className="text-md"
|
|
|
|
|
placeholder={t("export.case.newCaseNamePlaceholder")}
|
|
|
|
|
value={newCaseName}
|
|
|
|
|
onChange={(event) => setNewCaseName(event.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
<Textarea
|
|
|
|
|
className="text-md"
|
|
|
|
|
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
|
|
|
|
|
value={newCaseDescription}
|
|
|
|
|
onChange={(event) =>
|
|
|
|
|
setNewCaseDescription(event.target.value)
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</TabsContent>
|
|
|
|
|
</Tabs>
|
|
|
|
|
|
2024-04-04 18:43:54 +03:00
|
|
|
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
|
2024-03-28 02:03:05 +03:00
|
|
|
<DialogFooter
|
|
|
|
|
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-4"}
|
|
|
|
|
>
|
|
|
|
|
<div
|
2024-05-14 18:06:44 +03:00
|
|
|
className={`cursor-pointer p-2 text-center ${isDesktop ? "" : "w-full"}`}
|
2024-03-28 02:03:05 +03:00
|
|
|
onClick={onCancel}
|
|
|
|
|
>
|
2025-03-16 18:36:20 +03:00
|
|
|
{t("button.cancel", { ns: "common" })}
|
2024-03-28 02:03:05 +03:00
|
|
|
</div>
|
2026-04-14 17:19:50 +03:00
|
|
|
{activeTab === "export" ? (
|
|
|
|
|
<Button
|
|
|
|
|
className={isDesktop ? "" : "w-full"}
|
|
|
|
|
aria-label={t("export.selectOrExport")}
|
|
|
|
|
variant="select"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={isStartingExport}
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
if (selectedOption == "timeline") {
|
|
|
|
|
setRange({ before: currentTime + 30, after: currentTime - 30 });
|
|
|
|
|
setMode("timeline");
|
|
|
|
|
} else {
|
|
|
|
|
const didQueue = await onStartExport();
|
|
|
|
|
if (didQueue) {
|
|
|
|
|
setSelectedOption("1");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{isStartingExport
|
|
|
|
|
? t("export.queueing")
|
|
|
|
|
: 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()}
|
|
|
|
|
>
|
|
|
|
|
{isStartingBatchExport
|
|
|
|
|
? t("export.multiCamera.queueingButton")
|
|
|
|
|
: t("export.multiCamera.exportButton", {
|
|
|
|
|
count: selectedCameraCount,
|
|
|
|
|
})}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2024-03-28 02:03:05 +03:00
|
|
|
</DialogFooter>
|
|
|
|
|
</div>
|
2024-03-27 00:37:45 +03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-13 20:46:40 +03:00
|
|
|
type ExportPreviewDialogProps = {
|
|
|
|
|
camera: string;
|
|
|
|
|
range?: TimeRange;
|
|
|
|
|
showPreview: boolean;
|
|
|
|
|
setShowPreview: (showPreview: boolean) => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function ExportPreviewDialog({
|
|
|
|
|
camera,
|
|
|
|
|
range,
|
|
|
|
|
showPreview,
|
|
|
|
|
setShowPreview,
|
|
|
|
|
}: ExportPreviewDialogProps) {
|
2025-03-16 18:36:20 +03:00
|
|
|
const { t } = useTranslation(["components/dialog"]);
|
2024-10-13 20:46:40 +03:00
|
|
|
if (!range) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const source = `${baseUrl}vod/${camera}/start/${range.after}/end/${range.before}/index.m3u8`;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
|
|
|
|
<DialogContent
|
|
|
|
|
className={cn(
|
|
|
|
|
"scrollbar-container overflow-y-auto",
|
|
|
|
|
isDesktop &&
|
|
|
|
|
"max-h-[95dvh] sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl",
|
|
|
|
|
isMobile && "px-4",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<DialogHeader>
|
2025-03-16 18:36:20 +03:00
|
|
|
<DialogTitle>{t("export.fromTimeline.previewExport")}</DialogTitle>
|
2024-10-13 20:46:40 +03:00
|
|
|
<DialogDescription className="sr-only">
|
2025-03-16 18:36:20 +03:00
|
|
|
{t("export.fromTimeline.previewExport")}
|
2024-10-13 20:46:40 +03:00
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<GenericVideoPlayer source={source} />
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|