mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-12 17:52:48 +03:00
Multi-export UI fixes (#23959)
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
CI / AMD64 Build (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* multi export fixes * i18n * new tests
This commit is contained in:
+133
-11
@@ -1,4 +1,4 @@
|
|||||||
import { test, expect } from "../fixtures/frigate-test";
|
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||||
import {
|
import {
|
||||||
expectBodyInteractive,
|
expectBodyInteractive,
|
||||||
waitForBodyInteractive,
|
waitForBodyInteractive,
|
||||||
@@ -575,7 +575,7 @@ test.describe("Multi-Review Export @high", () => {
|
|||||||
await expect(dialog.getByText(/None/)).toBeVisible();
|
await expect(dialog.getByText(/None/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("starting an export posts the expected payload and navigates to the case", async ({
|
test("starting an export posts the expected payload and stays on the review page", async ({
|
||||||
frigateApp,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
|
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
|
||||||
@@ -673,9 +673,15 @@ test.describe("Multi-Review Export @high", () => {
|
|||||||
"mex-review-002",
|
"mex-review-002",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(frigateApp.page).toHaveURL(/caseId=new-case-xyz/, {
|
// Creating a case must not pull the user off the review they were
|
||||||
timeout: 5_000,
|
// working through — the case is offered as a link on the toast instead.
|
||||||
});
|
const viewCase = frigateApp.page.getByRole("link", { name: /view/i });
|
||||||
|
await expect(viewCase).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(viewCase).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
/export\?caseId=new-case-xyz$/,
|
||||||
|
);
|
||||||
|
await expect(frigateApp.page).toHaveURL(/\/review(\?|$)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mobile opens a drawer (not a dialog) for the multi-review export flow", async ({
|
test("mobile opens a drawer (not a dialog) for the multi-review export flow", async ({
|
||||||
@@ -834,12 +840,128 @@ test.describe("Multi-Review Export @high", () => {
|
|||||||
expect(payload.new_case_description).toBeUndefined();
|
expect(payload.new_case_description).toBeUndefined();
|
||||||
expect(payload.items).toHaveLength(2);
|
expect(payload.items).toHaveLength(2);
|
||||||
|
|
||||||
// Navigate should hit /export. useSearchEffect consumes the caseId
|
// Attaching to a case leaves the user on the review page; the case is
|
||||||
// query param and strips it once the case is found in the cases list,
|
// reachable from the toast action.
|
||||||
// so we assert on the path, not the query string.
|
const viewCase = frigateApp.page.getByRole("link", { name: /view/i });
|
||||||
await expect(frigateApp.page).toHaveURL(/\/export(\?|$)/, {
|
await expect(viewCase).toBeVisible({ timeout: 5_000 });
|
||||||
timeout: 5_000,
|
await expect(viewCase).toHaveAttribute(
|
||||||
});
|
"href",
|
||||||
|
/export\?caseId=existing-case-abc$/,
|
||||||
|
);
|
||||||
|
await expect(frigateApp.page).toHaveURL(/\/review(\?|$)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Multi-Camera Export from History @high", () => {
|
||||||
|
// The recording view seeds the multi-camera range around the playback
|
||||||
|
// position, so the deep link has to land close to the live edge for the
|
||||||
|
// seeded end to run past the end of the timeline.
|
||||||
|
const playbackTime = Math.floor(Date.now() / 1000) - 300;
|
||||||
|
|
||||||
|
async function openRecordingView(frigateApp: FrigateApp) {
|
||||||
|
// The recording view pulls these while the timeline renders; the preview
|
||||||
|
// server 500s on them, which the error collector would flag.
|
||||||
|
await frigateApp.page.route("**/api/*/recordings**", (route) =>
|
||||||
|
route.fulfill({ json: [] }),
|
||||||
|
);
|
||||||
|
await frigateApp.page.route("**/api/recordings/unavailable**", (route) =>
|
||||||
|
route.fulfill({ json: [] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await frigateApp.goto(`/review?timestamp=front_door_${playbackTime}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop opens the export form in a dialog from the Actions menu; mobile
|
||||||
|
// opens the same form inside the settings drawer.
|
||||||
|
async function openMultiCameraTab(frigateApp: FrigateApp) {
|
||||||
|
await openRecordingView(frigateApp);
|
||||||
|
|
||||||
|
if (frigateApp.isMobile) {
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("button", { name: /filters/i })
|
||||||
|
.first()
|
||||||
|
.click({ timeout: 15_000 });
|
||||||
|
await frigateApp.page.getByRole("button", { name: /^export$/i }).click();
|
||||||
|
} else {
|
||||||
|
await frigateApp.page
|
||||||
|
.getByRole("button", { name: /actions/i })
|
||||||
|
.click({ timeout: 15_000 });
|
||||||
|
await frigateApp.page.getByRole("menuitem", { name: /export/i }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = frigateApp.page.getByRole("dialog");
|
||||||
|
await expect(form).toBeVisible({ timeout: 5_000 });
|
||||||
|
await form.getByRole("tab", { name: /multi-camera/i }).click();
|
||||||
|
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("timeline selection renders both export handles on the timeline", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults();
|
||||||
|
const form = await openMultiCameraTab(frigateApp);
|
||||||
|
|
||||||
|
await form
|
||||||
|
.getByRole("button", { name: "Select from Timeline" })
|
||||||
|
.click({ timeout: 5_000 });
|
||||||
|
await expect(form).toBeHidden({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// A range seeded past the end of the timeline has no segment to anchor
|
||||||
|
// to, which leaves the handle unpositioned at the top of the timeline
|
||||||
|
// with an empty label until it is dragged.
|
||||||
|
for (const handle of [".export-start", ".export-end"]) {
|
||||||
|
const locator = frigateApp.page.locator(handle);
|
||||||
|
await expect(locator).toHaveText(/\d{1,2}:\d{2}/, { timeout: 5_000 });
|
||||||
|
await expect(locator).not.toHaveAttribute("style", /top:\s*0px/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the time range picker opens without a configured timezone", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults();
|
||||||
|
const form = await openMultiCameraTab(frigateApp);
|
||||||
|
|
||||||
|
// ui.timezone is null until the user sets one, which used to take the
|
||||||
|
// whole page down when the calendar worked out its disabled days
|
||||||
|
await form
|
||||||
|
.getByRole("button", { name: /^start time$/i })
|
||||||
|
.click({ timeout: 5_000 });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByRole("button", { name: /previous month/i }),
|
||||||
|
).toBeVisible({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canceling timeline selection reopens the form with the case intact", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults();
|
||||||
|
const form = await openMultiCameraTab(frigateApp);
|
||||||
|
|
||||||
|
await form
|
||||||
|
.getByPlaceholder(/new case name/i)
|
||||||
|
.fill("Incident 7", { timeout: 5_000 });
|
||||||
|
await form
|
||||||
|
.getByPlaceholder(/case description/i)
|
||||||
|
.fill("Front gate follow-up");
|
||||||
|
|
||||||
|
await form.getByRole("button", { name: "Select from Timeline" }).click();
|
||||||
|
await expect(form).toBeHidden({ timeout: 5_000 });
|
||||||
|
|
||||||
|
await frigateApp.page.getByRole("button", { name: /cancel/i }).click();
|
||||||
|
|
||||||
|
await expect(form).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(
|
||||||
|
form.getByRole("tab", { name: /multi-camera/i }),
|
||||||
|
).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(form.getByPlaceholder(/new case name/i)).toHaveValue(
|
||||||
|
"Incident 7",
|
||||||
|
);
|
||||||
|
await expect(form.getByPlaceholder(/case description/i)).toHaveValue(
|
||||||
|
"Front gate follow-up",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -100,10 +100,8 @@
|
|||||||
"exportButton_other": "Export {{count}} reviews",
|
"exportButton_other": "Export {{count}} reviews",
|
||||||
"exportingButton": "Exporting...",
|
"exportingButton": "Exporting...",
|
||||||
"toast": {
|
"toast": {
|
||||||
"started_one": "Started 1 export. Opening the case now.",
|
"started_one": "Started 1 export.",
|
||||||
"started_other": "Started {{count}} exports. Opening the case now.",
|
"started_other": "Started {{count}} exports.",
|
||||||
"startedNoCase_one": "Started 1 export.",
|
|
||||||
"startedNoCase_other": "Started {{count}} exports.",
|
|
||||||
"partial": "Started {{successful}} of {{total}} exports. Failed: {{failedItems}}",
|
"partial": "Started {{successful}} of {{total}} exports. Failed: {{failedItems}}",
|
||||||
"failed": "Failed to start {{total}} exports. Failed: {{failedItems}}"
|
"failed": "Failed to start {{total}} exports. Failed: {{failedItems}}"
|
||||||
}
|
}
|
||||||
@@ -116,8 +114,8 @@
|
|||||||
"batchSuccess_other": "Started {{count}} exports. Opening the case now.",
|
"batchSuccess_other": "Started {{count}} exports. Opening the case now.",
|
||||||
"batchPartial": "Started {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
|
"batchPartial": "Started {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||||
"batchFailed": "Failed to start {{total}} exports. Failed cameras: {{failedCameras}}",
|
"batchFailed": "Failed to start {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||||
"batchQueuedSuccess_one": "Queued 1 export. Opening the case now.",
|
"batchQueuedSuccess_one": "Queued 1 export.",
|
||||||
"batchQueuedSuccess_other": "Queued {{count}} exports. Opening the case now.",
|
"batchQueuedSuccess_other": "Queued {{count}} exports.",
|
||||||
"batchQueuedPartial": "Queued {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
|
"batchQueuedPartial": "Queued {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||||
"batchQueueFailed": "Failed to queue {{total}} exports. Failed cameras: {{failedCameras}}",
|
"batchQueueFailed": "Failed to queue {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||||
"error": {
|
"error": {
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export default function ReviewFilterGroup({
|
|||||||
// not applicable as exports are not used
|
// not applicable as exports are not used
|
||||||
camera=""
|
camera=""
|
||||||
latestTime={0}
|
latestTime={0}
|
||||||
|
earliestTime={0}
|
||||||
currentTime={0}
|
currentTime={0}
|
||||||
mode="none"
|
mode="none"
|
||||||
setMode={() => {}}
|
setMode={() => {}}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
@@ -62,7 +63,6 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
|||||||
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||||
import { Textarea } from "../ui/textarea";
|
import { Textarea } from "../ui/textarea";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||||
import { isValidIconName } from "@/utils/iconUtil";
|
import { isValidIconName } from "@/utils/iconUtil";
|
||||||
@@ -79,9 +79,14 @@ const EXPORT_OPTIONS = [
|
|||||||
type ExportOption = (typeof EXPORT_OPTIONS)[number];
|
type ExportOption = (typeof EXPORT_OPTIONS)[number];
|
||||||
export type ExportTab = "export" | "multi";
|
export type ExportTab = "export" | "multi";
|
||||||
|
|
||||||
|
// length of a range seeded around the current playback time
|
||||||
|
const MULTI_CAMERA_RANGE_SECONDS = 3600;
|
||||||
|
const TIMELINE_SELECTION_SECONDS = 60;
|
||||||
|
|
||||||
type ExportDialogProps = {
|
type ExportDialogProps = {
|
||||||
camera: string;
|
camera: string;
|
||||||
latestTime: number;
|
latestTime: number;
|
||||||
|
earliestTime: number;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
range?: TimeRange;
|
range?: TimeRange;
|
||||||
mode: ExportMode;
|
mode: ExportMode;
|
||||||
@@ -94,6 +99,7 @@ type ExportDialogProps = {
|
|||||||
export default function ExportDialog({
|
export default function ExportDialog({
|
||||||
camera,
|
camera,
|
||||||
latestTime,
|
latestTime,
|
||||||
|
earliestTime,
|
||||||
currentTime,
|
currentTime,
|
||||||
range,
|
range,
|
||||||
mode,
|
mode,
|
||||||
@@ -107,9 +113,13 @@ export default function ExportDialog({
|
|||||||
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>();
|
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>();
|
||||||
const [singleNewCaseName, setSingleNewCaseName] = useState("");
|
const [singleNewCaseName, setSingleNewCaseName] = useState("");
|
||||||
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
|
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
|
||||||
|
const [batchCaseSelection, setBatchCaseSelection] = useState("new");
|
||||||
|
const [newCaseName, setNewCaseName] = useState("");
|
||||||
|
const [newCaseDescription, setNewCaseDescription] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState<ExportTab>("export");
|
const [activeTab, setActiveTab] = useState<ExportTab>("export");
|
||||||
const [isStartingExport, setIsStartingExport] = useState(false);
|
const [isStartingExport, setIsStartingExport] = useState(false);
|
||||||
const previousModeRef = useRef<ExportMode>(mode);
|
const previousModeRef = useRef<ExportMode>(mode);
|
||||||
|
const preTimelineRangeRef = useRef<TimeRange | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previousMode = previousModeRef.current;
|
const previousMode = previousModeRef.current;
|
||||||
@@ -188,6 +198,9 @@ export default function ExportDialog({
|
|||||||
setSelectedCaseId(undefined);
|
setSelectedCaseId(undefined);
|
||||||
setSingleNewCaseName("");
|
setSingleNewCaseName("");
|
||||||
setSingleNewCaseDescription("");
|
setSingleNewCaseDescription("");
|
||||||
|
setBatchCaseSelection("new");
|
||||||
|
setNewCaseName("");
|
||||||
|
setNewCaseDescription("");
|
||||||
setRange(undefined);
|
setRange(undefined);
|
||||||
setMode("none");
|
setMode("none");
|
||||||
return true;
|
return true;
|
||||||
@@ -223,14 +236,32 @@ export default function ExportDialog({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
|
if (mode == "timeline_multi") {
|
||||||
|
setRange(preTimelineRangeRef.current);
|
||||||
|
setMode("select");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setName("");
|
setName("");
|
||||||
setSelectedCaseId(undefined);
|
setSelectedCaseId(undefined);
|
||||||
setSingleNewCaseName("");
|
setSingleNewCaseName("");
|
||||||
setSingleNewCaseDescription("");
|
setSingleNewCaseDescription("");
|
||||||
|
setBatchCaseSelection("new");
|
||||||
|
setNewCaseName("");
|
||||||
|
setNewCaseDescription("");
|
||||||
setMode("none");
|
setMode("none");
|
||||||
setRange(undefined);
|
setRange(undefined);
|
||||||
setActiveTab("export");
|
setActiveTab("export");
|
||||||
}, [setMode, setRange]);
|
}, [mode, setMode, setRange]);
|
||||||
|
|
||||||
|
const onSelectFromTimeline = useCallback(
|
||||||
|
(initialRange: TimeRange) => {
|
||||||
|
preTimelineRangeRef.current = range;
|
||||||
|
setRange(initialRange);
|
||||||
|
setMode("timeline_multi");
|
||||||
|
},
|
||||||
|
[range, setMode, setRange],
|
||||||
|
);
|
||||||
|
|
||||||
const Overlay = isDesktop ? Dialog : Drawer;
|
const Overlay = isDesktop ? Dialog : Drawer;
|
||||||
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
|
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
|
||||||
@@ -304,12 +335,16 @@ export default function ExportDialog({
|
|||||||
>
|
>
|
||||||
<ExportContent
|
<ExportContent
|
||||||
latestTime={latestTime}
|
latestTime={latestTime}
|
||||||
|
earliestTime={earliestTime}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
range={range}
|
range={range}
|
||||||
name={name}
|
name={name}
|
||||||
selectedCaseId={selectedCaseId}
|
selectedCaseId={selectedCaseId}
|
||||||
singleNewCaseName={singleNewCaseName}
|
singleNewCaseName={singleNewCaseName}
|
||||||
singleNewCaseDescription={singleNewCaseDescription}
|
singleNewCaseDescription={singleNewCaseDescription}
|
||||||
|
batchCaseSelection={batchCaseSelection}
|
||||||
|
newCaseName={newCaseName}
|
||||||
|
newCaseDescription={newCaseDescription}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
isStartingExport={isStartingExport}
|
isStartingExport={isStartingExport}
|
||||||
onStartExport={onStartExport}
|
onStartExport={onStartExport}
|
||||||
@@ -318,8 +353,12 @@ export default function ExportDialog({
|
|||||||
setSelectedCaseId={setSelectedCaseId}
|
setSelectedCaseId={setSelectedCaseId}
|
||||||
setSingleNewCaseName={setSingleNewCaseName}
|
setSingleNewCaseName={setSingleNewCaseName}
|
||||||
setSingleNewCaseDescription={setSingleNewCaseDescription}
|
setSingleNewCaseDescription={setSingleNewCaseDescription}
|
||||||
|
setBatchCaseSelection={setBatchCaseSelection}
|
||||||
|
setNewCaseName={setNewCaseName}
|
||||||
|
setNewCaseDescription={setNewCaseDescription}
|
||||||
setRange={setRange}
|
setRange={setRange}
|
||||||
setMode={setMode}
|
setMode={setMode}
|
||||||
|
onSelectFromTimeline={onSelectFromTimeline}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
/>
|
/>
|
||||||
</Content>
|
</Content>
|
||||||
@@ -330,12 +369,16 @@ export default function ExportDialog({
|
|||||||
|
|
||||||
type ExportContentProps = {
|
type ExportContentProps = {
|
||||||
latestTime: number;
|
latestTime: number;
|
||||||
|
earliestTime: number;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
range?: TimeRange;
|
range?: TimeRange;
|
||||||
name: string;
|
name: string;
|
||||||
selectedCaseId?: string;
|
selectedCaseId?: string;
|
||||||
singleNewCaseName: string;
|
singleNewCaseName: string;
|
||||||
singleNewCaseDescription: string;
|
singleNewCaseDescription: string;
|
||||||
|
batchCaseSelection: string;
|
||||||
|
newCaseName: string;
|
||||||
|
newCaseDescription: string;
|
||||||
activeTab: ExportTab;
|
activeTab: ExportTab;
|
||||||
isStartingExport: boolean;
|
isStartingExport: boolean;
|
||||||
onStartExport: () => Promise<boolean>;
|
onStartExport: () => Promise<boolean>;
|
||||||
@@ -344,19 +387,27 @@ type ExportContentProps = {
|
|||||||
setSelectedCaseId: (caseId: string | undefined) => void;
|
setSelectedCaseId: (caseId: string | undefined) => void;
|
||||||
setSingleNewCaseName: (name: string) => void;
|
setSingleNewCaseName: (name: string) => void;
|
||||||
setSingleNewCaseDescription: (description: string) => void;
|
setSingleNewCaseDescription: (description: string) => void;
|
||||||
|
setBatchCaseSelection: (caseId: string) => void;
|
||||||
|
setNewCaseName: (name: string) => void;
|
||||||
|
setNewCaseDescription: (description: string) => void;
|
||||||
setRange: (range: TimeRange | undefined) => void;
|
setRange: (range: TimeRange | undefined) => void;
|
||||||
setMode: (mode: ExportMode) => void;
|
setMode: (mode: ExportMode) => void;
|
||||||
|
onSelectFromTimeline: (range: TimeRange) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ExportContent({
|
export function ExportContent({
|
||||||
latestTime,
|
latestTime,
|
||||||
|
earliestTime,
|
||||||
currentTime,
|
currentTime,
|
||||||
range,
|
range,
|
||||||
name,
|
name,
|
||||||
selectedCaseId,
|
selectedCaseId,
|
||||||
singleNewCaseName,
|
singleNewCaseName,
|
||||||
singleNewCaseDescription,
|
singleNewCaseDescription,
|
||||||
|
batchCaseSelection,
|
||||||
|
newCaseName,
|
||||||
|
newCaseDescription,
|
||||||
activeTab,
|
activeTab,
|
||||||
isStartingExport,
|
isStartingExport,
|
||||||
onStartExport,
|
onStartExport,
|
||||||
@@ -365,12 +416,15 @@ export function ExportContent({
|
|||||||
setSelectedCaseId,
|
setSelectedCaseId,
|
||||||
setSingleNewCaseName,
|
setSingleNewCaseName,
|
||||||
setSingleNewCaseDescription,
|
setSingleNewCaseDescription,
|
||||||
|
setBatchCaseSelection,
|
||||||
|
setNewCaseName,
|
||||||
|
setNewCaseDescription,
|
||||||
setRange,
|
setRange,
|
||||||
setMode,
|
setMode,
|
||||||
|
onSelectFromTimeline,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: ExportContentProps) {
|
}: ExportContentProps) {
|
||||||
const { t } = useTranslation(["components/dialog"]);
|
const { t } = useTranslation(["components/dialog"]);
|
||||||
const navigate = useNavigate();
|
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
|
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
|
||||||
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
|
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
|
||||||
@@ -379,13 +433,8 @@ export function ExportContent({
|
|||||||
range,
|
range,
|
||||||
);
|
);
|
||||||
const [selectedCameraIds, setSelectedCameraIds] = useState<string[]>([]);
|
const [selectedCameraIds, setSelectedCameraIds] = useState<string[]>([]);
|
||||||
const [batchCaseSelection, setBatchCaseSelection] = useState<string>(
|
|
||||||
selectedCaseId || "none",
|
|
||||||
);
|
|
||||||
const [hasManualCameraSelection, setHasManualCameraSelection] =
|
const [hasManualCameraSelection, setHasManualCameraSelection] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [newCaseName, setNewCaseName] = useState("");
|
|
||||||
const [newCaseDescription, setNewCaseDescription] = useState("");
|
|
||||||
const [isStartingBatchExport, setIsStartingBatchExport] = useState(false);
|
const [isStartingBatchExport, setIsStartingBatchExport] = useState(false);
|
||||||
const [cameraSearch, setCameraSearch] = useState("");
|
const [cameraSearch, setCameraSearch] = useState("");
|
||||||
const [cameraMenuOpen, setCameraMenuOpen] = useState(false);
|
const [cameraMenuOpen, setCameraMenuOpen] = useState(false);
|
||||||
@@ -416,38 +465,47 @@ export function ExportContent({
|
|||||||
return () => window.clearTimeout(timeoutId);
|
return () => window.clearTimeout(timeoutId);
|
||||||
}, [activeTab, range]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setHasManualCameraSelection(false);
|
setHasManualCameraSelection(false);
|
||||||
}, [multiRangeKey]);
|
}, [multiRangeKey]);
|
||||||
|
|
||||||
|
const buildRangeAroundCurrentTime = useCallback(
|
||||||
|
(durationSeconds: number): TimeRange => ({
|
||||||
|
after: Math.max(earliestTime, currentTime - durationSeconds / 2),
|
||||||
|
before: Math.min(latestTime, currentTime + durationSeconds / 2),
|
||||||
|
}),
|
||||||
|
[currentTime, earliestTime, latestTime],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clampRangeToTimeline = useCallback(
|
||||||
|
(candidate?: TimeRange): TimeRange => {
|
||||||
|
const fallback = buildRangeAroundCurrentTime(TIMELINE_SELECTION_SECONDS);
|
||||||
|
|
||||||
|
if (!candidate) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = Math.min(
|
||||||
|
latestTime,
|
||||||
|
Math.max(earliestTime, candidate.after),
|
||||||
|
);
|
||||||
|
const before = Math.min(
|
||||||
|
latestTime,
|
||||||
|
Math.max(earliestTime, candidate.before),
|
||||||
|
);
|
||||||
|
|
||||||
|
return before > after ? { after, before } : fallback;
|
||||||
|
},
|
||||||
|
[buildRangeAroundCurrentTime, earliestTime, latestTime],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab !== "multi" || range) {
|
if (activeTab !== "multi" || range) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setRange({
|
setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS));
|
||||||
before: currentTime + 1800,
|
}, [activeTab, buildRangeAroundCurrentTime, range, setRange]);
|
||||||
after: currentTime - 1800,
|
|
||||||
});
|
|
||||||
}, [activeTab, currentTime, range, setRange]);
|
|
||||||
|
|
||||||
const { data: events, isLoading: isEventsLoading } = useSWR<Event[]>(
|
const { data: events, isLoading: isEventsLoading } = useSWR<Event[]>(
|
||||||
activeTab === "multi" && debouncedRange
|
activeTab === "multi" && debouncedRange
|
||||||
@@ -715,6 +773,16 @@ export function ExportContent({
|
|||||||
return result.error ? `${cameraName}: ${result.error}` : cameraName;
|
return result.error ? `${cameraName}: ${result.error}` : cameraName;
|
||||||
})
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
const exportCaseId = response.data.export_case_id;
|
||||||
|
const viewCaseAction = exportCaseId ? (
|
||||||
|
<a
|
||||||
|
href={`${baseUrl}export?caseId=${exportCaseId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button>{t("export.toast.view")}</Button>
|
||||||
|
</a>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
if (failedResults.length > 0 && successfulResults.length > 0) {
|
if (failedResults.length > 0 && successfulResults.length > 0) {
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -728,6 +796,7 @@ export function ExportContent({
|
|||||||
{
|
{
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
description: failedSummary,
|
description: failedSummary,
|
||||||
|
action: viewCaseAction,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else if (failedResults.length > 0) {
|
} else if (failedResults.length > 0) {
|
||||||
@@ -748,7 +817,7 @@ export function ExportContent({
|
|||||||
t("export.toast.batchQueuedSuccess", {
|
t("export.toast.batchQueuedSuccess", {
|
||||||
count: successfulResults.length,
|
count: successfulResults.length,
|
||||||
}),
|
}),
|
||||||
{ position: "top-center" },
|
{ position: "top-center", action: viewCaseAction },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,9 +830,6 @@ export function ExportContent({
|
|||||||
setRange(undefined);
|
setRange(undefined);
|
||||||
setMode("none");
|
setMode("none");
|
||||||
setActiveTab("export");
|
setActiveTab("export");
|
||||||
if (response.data.export_case_id) {
|
|
||||||
navigate(`/export?caseId=${response.data.export_case_id}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const apiError = error as {
|
const apiError = error as {
|
||||||
@@ -794,12 +860,14 @@ export function ExportContent({
|
|||||||
range,
|
range,
|
||||||
selectedCameraIds,
|
selectedCameraIds,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
|
setBatchCaseSelection,
|
||||||
setMode,
|
setMode,
|
||||||
setName,
|
setName,
|
||||||
|
setNewCaseDescription,
|
||||||
|
setNewCaseName,
|
||||||
setRange,
|
setRange,
|
||||||
setSelectedCaseId,
|
setSelectedCaseId,
|
||||||
t,
|
t,
|
||||||
navigate,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -820,10 +888,8 @@ export function ExportContent({
|
|||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const tab = value as ExportTab;
|
const tab = value as ExportTab;
|
||||||
if (tab === "multi") {
|
if (tab === "multi") {
|
||||||
setRange({
|
setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS));
|
||||||
before: currentTime + 1800,
|
setBatchCaseSelection(selectedCaseId ?? "new");
|
||||||
after: currentTime - 1800,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
onSelectTime(selectedOption);
|
onSelectTime(selectedOption);
|
||||||
}
|
}
|
||||||
@@ -975,23 +1041,18 @@ export function ExportContent({
|
|||||||
className="size-9 shrink-0 p-0"
|
className="size-9 shrink-0 p-0"
|
||||||
aria-label={t("export.multiCamera.selectFromTimeline")}
|
aria-label={t("export.multiCamera.selectFromTimeline")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!range) {
|
|
||||||
setRange({
|
|
||||||
before: currentTime + 30,
|
|
||||||
after: currentTime - 30,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveTab("multi");
|
setActiveTab("multi");
|
||||||
setMode("timeline_multi");
|
onSelectFromTimeline(clampRangeToTimeline(range));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LuAudioLines className="size-4 -rotate-90" />
|
<LuAudioLines className="size-4 -rotate-90" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipPortal>
|
||||||
{t("export.multiCamera.selectFromTimeline")}
|
<TooltipContent>
|
||||||
</TooltipContent>
|
{t("export.multiCamera.selectFromTimeline")}
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1256,7 +1317,9 @@ export function ExportContent({
|
|||||||
disabled={isStartingExport}
|
disabled={isStartingExport}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (selectedOption == "timeline") {
|
if (selectedOption == "timeline") {
|
||||||
setRange({ before: currentTime + 30, after: currentTime - 30 });
|
setRange(
|
||||||
|
buildRangeAroundCurrentTime(TIMELINE_SELECTION_SECONDS),
|
||||||
|
);
|
||||||
setMode("timeline");
|
setMode("timeline");
|
||||||
} else {
|
} else {
|
||||||
const didQueue = await onStartExport();
|
const didQueue = await onStartExport();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
@@ -65,6 +65,7 @@ type MobileReviewSettingsDrawerProps = {
|
|||||||
filter?: ReviewFilter;
|
filter?: ReviewFilter;
|
||||||
currentSeverity?: ReviewSeverity;
|
currentSeverity?: ReviewSeverity;
|
||||||
latestTime: number;
|
latestTime: number;
|
||||||
|
earliestTime: number;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
range?: TimeRange;
|
range?: TimeRange;
|
||||||
mode: ExportMode;
|
mode: ExportMode;
|
||||||
@@ -90,6 +91,7 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
filter,
|
filter,
|
||||||
currentSeverity,
|
currentSeverity,
|
||||||
latestTime,
|
latestTime,
|
||||||
|
earliestTime,
|
||||||
currentTime,
|
currentTime,
|
||||||
range,
|
range,
|
||||||
mode,
|
mode,
|
||||||
@@ -142,7 +144,22 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
);
|
);
|
||||||
const [singleNewCaseName, setSingleNewCaseName] = useState("");
|
const [singleNewCaseName, setSingleNewCaseName] = useState("");
|
||||||
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
|
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
|
||||||
|
const [batchCaseSelection, setBatchCaseSelection] = useState("new");
|
||||||
|
const [newCaseName, setNewCaseName] = useState("");
|
||||||
|
const [newCaseDescription, setNewCaseDescription] = useState("");
|
||||||
const [isStartingExport, setIsStartingExport] = useState(false);
|
const [isStartingExport, setIsStartingExport] = useState(false);
|
||||||
|
const preTimelineRangeRef = useRef<TimeRange | undefined>(undefined);
|
||||||
|
|
||||||
|
const onSelectFromTimeline = useCallback(
|
||||||
|
(initialRange: TimeRange) => {
|
||||||
|
preTimelineRangeRef.current = range;
|
||||||
|
setRange(initialRange);
|
||||||
|
setMode("timeline_multi");
|
||||||
|
setDrawerMode("none");
|
||||||
|
},
|
||||||
|
[range, setMode, setRange],
|
||||||
|
);
|
||||||
|
|
||||||
const onStartExport = useCallback(async () => {
|
const onStartExport = useCallback(async () => {
|
||||||
if (isStartingExport) {
|
if (isStartingExport) {
|
||||||
return false;
|
return false;
|
||||||
@@ -214,6 +231,9 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
setSelectedCaseId(undefined);
|
setSelectedCaseId(undefined);
|
||||||
setSingleNewCaseName("");
|
setSingleNewCaseName("");
|
||||||
setSingleNewCaseDescription("");
|
setSingleNewCaseDescription("");
|
||||||
|
setBatchCaseSelection("new");
|
||||||
|
setNewCaseName("");
|
||||||
|
setNewCaseDescription("");
|
||||||
setRange(undefined);
|
setRange(undefined);
|
||||||
setMode("none");
|
setMode("none");
|
||||||
return true;
|
return true;
|
||||||
@@ -433,12 +453,16 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
content = (
|
content = (
|
||||||
<ExportContent
|
<ExportContent
|
||||||
latestTime={latestTime}
|
latestTime={latestTime}
|
||||||
|
earliestTime={earliestTime}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
range={range}
|
range={range}
|
||||||
name={name}
|
name={name}
|
||||||
selectedCaseId={selectedCaseId}
|
selectedCaseId={selectedCaseId}
|
||||||
singleNewCaseName={singleNewCaseName}
|
singleNewCaseName={singleNewCaseName}
|
||||||
singleNewCaseDescription={singleNewCaseDescription}
|
singleNewCaseDescription={singleNewCaseDescription}
|
||||||
|
batchCaseSelection={batchCaseSelection}
|
||||||
|
newCaseName={newCaseName}
|
||||||
|
newCaseDescription={newCaseDescription}
|
||||||
activeTab={exportTab}
|
activeTab={exportTab}
|
||||||
isStartingExport={isStartingExport}
|
isStartingExport={isStartingExport}
|
||||||
onStartExport={onStartExport}
|
onStartExport={onStartExport}
|
||||||
@@ -447,6 +471,9 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
setSelectedCaseId={setSelectedCaseId}
|
setSelectedCaseId={setSelectedCaseId}
|
||||||
setSingleNewCaseName={setSingleNewCaseName}
|
setSingleNewCaseName={setSingleNewCaseName}
|
||||||
setSingleNewCaseDescription={setSingleNewCaseDescription}
|
setSingleNewCaseDescription={setSingleNewCaseDescription}
|
||||||
|
setBatchCaseSelection={setBatchCaseSelection}
|
||||||
|
setNewCaseName={setNewCaseName}
|
||||||
|
setNewCaseDescription={setNewCaseDescription}
|
||||||
setRange={setRange}
|
setRange={setRange}
|
||||||
setMode={(mode) => {
|
setMode={(mode) => {
|
||||||
setMode(mode);
|
setMode(mode);
|
||||||
@@ -455,12 +482,16 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
setDrawerMode("none");
|
setDrawerMode("none");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onSelectFromTimeline={onSelectFromTimeline}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setMode("none");
|
setMode("none");
|
||||||
setRange(undefined);
|
setRange(undefined);
|
||||||
setSelectedCaseId(undefined);
|
setSelectedCaseId(undefined);
|
||||||
setSingleNewCaseName("");
|
setSingleNewCaseName("");
|
||||||
setSingleNewCaseDescription("");
|
setSingleNewCaseDescription("");
|
||||||
|
setBatchCaseSelection("new");
|
||||||
|
setNewCaseName("");
|
||||||
|
setNewCaseDescription("");
|
||||||
setExportTab("export");
|
setExportTab("export");
|
||||||
setDrawerMode("select");
|
setDrawerMode("select");
|
||||||
}}
|
}}
|
||||||
@@ -639,6 +670,14 @@ export default function MobileReviewSettingsDrawer({
|
|||||||
void onStartExport();
|
void onStartExport();
|
||||||
}}
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
|
if (mode == "timeline_multi") {
|
||||||
|
setRange(preTimelineRangeRef.current);
|
||||||
|
setExportTab("multi");
|
||||||
|
setMode("select");
|
||||||
|
setDrawerMode("export");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setExportTab("export");
|
setExportTab("export");
|
||||||
setRange(undefined);
|
setRange(undefined);
|
||||||
setMode("none");
|
setMode("none");
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { isDesktop } from "react-device-detect";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +42,7 @@ import {
|
|||||||
ExportCase,
|
ExportCase,
|
||||||
} from "@/types/export";
|
} from "@/types/export";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import { baseUrl } from "@/api/baseUrl";
|
||||||
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
|
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
|
||||||
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||||
@@ -65,7 +65,6 @@ export default function MultiExportDialog({
|
|||||||
}: MultiExportDialogProps) {
|
}: MultiExportDialogProps) {
|
||||||
const { t } = useTranslation(["components/dialog", "common"]);
|
const { t } = useTranslation(["components/dialog", "common"]);
|
||||||
const locale = useDateLocale();
|
const locale = useDateLocale();
|
||||||
const navigate = useNavigate();
|
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@@ -203,19 +202,24 @@ export default function MultiExportDialog({
|
|||||||
const results = response.data.results ?? [];
|
const results = response.data.results ?? [];
|
||||||
const successful = results.filter((r) => r.success);
|
const successful = results.filter((r) => r.success);
|
||||||
const failed = results.filter((r) => !r.success);
|
const failed = results.filter((r) => !r.success);
|
||||||
|
const exportCaseId = response.data.export_case_id;
|
||||||
|
const viewCaseAction = exportCaseId ? (
|
||||||
|
<a
|
||||||
|
href={`${baseUrl}export?caseId=${exportCaseId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Button>{t("export.toast.view", { ns: "components/dialog" })}</Button>
|
||||||
|
</a>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
if (successful.length > 0 && failed.length === 0) {
|
if (successful.length > 0 && failed.length === 0) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t("export.multi.toast.started", {
|
||||||
isAdmin
|
ns: "components/dialog",
|
||||||
? "export.multi.toast.started"
|
count: successful.length,
|
||||||
: "export.multi.toast.startedNoCase",
|
}),
|
||||||
{
|
{ position: "top-center", action: viewCaseAction },
|
||||||
ns: "components/dialog",
|
|
||||||
count: successful.length,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
{ position: "top-center" },
|
|
||||||
);
|
);
|
||||||
} else if (successful.length > 0 && failed.length > 0) {
|
} else if (successful.length > 0 && failed.length > 0) {
|
||||||
// Resolve each failure to its review via item_index so same-camera
|
// Resolve each failure to its review via item_index so same-camera
|
||||||
@@ -229,7 +233,7 @@ export default function MultiExportDialog({
|
|||||||
total: results.length,
|
total: results.length,
|
||||||
failedItems: failedLabels,
|
failedItems: failedLabels,
|
||||||
}),
|
}),
|
||||||
{ position: "top-center" },
|
{ position: "top-center", action: viewCaseAction },
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const failedLabels = failed.map(formatFailureLabel).join(", ");
|
const failedLabels = failed.map(formatFailureLabel).join(", ");
|
||||||
@@ -247,9 +251,6 @@ export default function MultiExportDialog({
|
|||||||
onStarted();
|
onStarted();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
resetState();
|
resetState();
|
||||||
if (response.data.export_case_id) {
|
|
||||||
navigate(`/export?caseId=${response.data.export_case_id}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const apiError = error as {
|
const apiError = error as {
|
||||||
@@ -275,7 +276,6 @@ export default function MultiExportDialog({
|
|||||||
formatFailureLabel,
|
formatFailureLabel,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isNewCase,
|
isNewCase,
|
||||||
navigate,
|
|
||||||
newCaseDescription,
|
newCaseDescription,
|
||||||
newCaseName,
|
newCaseName,
|
||||||
onStarted,
|
onStarted,
|
||||||
|
|||||||
@@ -318,12 +318,12 @@ export const formatSecondsToDuration = (
|
|||||||
* @param timezone string representation of the timezone the user is requesting
|
* @param timezone string representation of the timezone the user is requesting
|
||||||
* @returns number of minutes offset from UTC
|
* @returns number of minutes offset from UTC
|
||||||
*/
|
*/
|
||||||
export const getUTCOffset = (
|
export const getUTCOffset = (date: Date, timezone?: string | null): number => {
|
||||||
date: Date,
|
// ui.timezone comes back as null until the user sets one
|
||||||
timezone: string = getResolvedTimeZone(),
|
const resolvedTimezone = timezone || getResolvedTimeZone();
|
||||||
): number => {
|
|
||||||
// If timezone is in UTC±HH:MM format, parse it to get offset
|
// If timezone is in UTC±HH:MM format, parse it to get offset
|
||||||
const utcOffsetMatch = timezone.match(/^UTC([+-])(\d{2}):(\d{2})$/);
|
const utcOffsetMatch = resolvedTimezone.match(/^UTC([+-])(\d{2}):(\d{2})$/);
|
||||||
if (utcOffsetMatch) {
|
if (utcOffsetMatch) {
|
||||||
const hours = parseInt(utcOffsetMatch[2], 10);
|
const hours = parseInt(utcOffsetMatch[2], 10);
|
||||||
const minutes = parseInt(utcOffsetMatch[3], 10);
|
const minutes = parseInt(utcOffsetMatch[3], 10);
|
||||||
@@ -334,7 +334,7 @@ export const getUTCOffset = (
|
|||||||
const utcDate = new Date(date.getTime());
|
const utcDate = new Date(date.getTime());
|
||||||
// locale of en-CA is required for proper locale format
|
// locale of en-CA is required for proper locale format
|
||||||
let iso = utcDate
|
let iso = utcDate
|
||||||
.toLocaleString("en-CA", { timeZone: timezone, hour12: false })
|
.toLocaleString("en-CA", { timeZone: resolvedTimezone, hour12: false })
|
||||||
.replace(", ", "T");
|
.replace(", ", "T");
|
||||||
iso += `.${utcDate.getMilliseconds().toString().padStart(3, "0")}`;
|
iso += `.${utcDate.getMilliseconds().toString().padStart(3, "0")}`;
|
||||||
let target = new Date(`${iso}Z`);
|
let target = new Date(`${iso}Z`);
|
||||||
|
|||||||
@@ -1356,6 +1356,7 @@ export default function MotionSearchView({
|
|||||||
camera={selectedCamera}
|
camera={selectedCamera}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
latestTime={timeRange.before}
|
latestTime={timeRange.before}
|
||||||
|
earliestTime={timeRange.after}
|
||||||
mode={exportMode}
|
mode={exportMode}
|
||||||
range={exportRange}
|
range={exportRange}
|
||||||
showPreview={showExportPreview}
|
showPreview={showExportPreview}
|
||||||
@@ -1476,6 +1477,7 @@ export default function MotionSearchView({
|
|||||||
camera={selectedCamera}
|
camera={selectedCamera}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
latestTime={timeRange.before}
|
latestTime={timeRange.before}
|
||||||
|
earliestTime={timeRange.after}
|
||||||
mode={exportMode}
|
mode={exportMode}
|
||||||
range={exportRange}
|
range={exportRange}
|
||||||
showPreview={showExportPreview}
|
showPreview={showExportPreview}
|
||||||
|
|||||||
@@ -677,6 +677,7 @@ export function RecordingView({
|
|||||||
camera={mainCamera}
|
camera={mainCamera}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
latestTime={timeRange.before}
|
latestTime={timeRange.before}
|
||||||
|
earliestTime={timeRange.after}
|
||||||
mode={exportMode}
|
mode={exportMode}
|
||||||
range={exportRange}
|
range={exportRange}
|
||||||
showPreview={showExportPreview}
|
showPreview={showExportPreview}
|
||||||
@@ -810,6 +811,7 @@ export function RecordingView({
|
|||||||
filter={filter}
|
filter={filter}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
latestTime={timeRange.before}
|
latestTime={timeRange.before}
|
||||||
|
earliestTime={timeRange.after}
|
||||||
recordingsSummary={recordingsSummary}
|
recordingsSummary={recordingsSummary}
|
||||||
mode={exportMode}
|
mode={exportMode}
|
||||||
range={exportRange}
|
range={exportRange}
|
||||||
|
|||||||
Reference in New Issue
Block a user