mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-31 07:27:57 +00:00
parent
0735a8ac75
commit
c75611b4df
@ -1,4 +1,4 @@
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||
import {
|
||||
expectBodyInteractive,
|
||||
waitForBodyInteractive,
|
||||
@ -575,7 +575,7 @@ test.describe("Multi-Review Export @high", () => {
|
||||
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,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Desktop multi-select flow");
|
||||
@ -673,9 +673,15 @@ test.describe("Multi-Review Export @high", () => {
|
||||
"mex-review-002",
|
||||
]);
|
||||
|
||||
await expect(frigateApp.page).toHaveURL(/caseId=new-case-xyz/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
// Creating a case must not pull the user off the review they were
|
||||
// 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 ({
|
||||
@ -834,12 +840,128 @@ test.describe("Multi-Review Export @high", () => {
|
||||
expect(payload.new_case_description).toBeUndefined();
|
||||
expect(payload.items).toHaveLength(2);
|
||||
|
||||
// Navigate should hit /export. useSearchEffect consumes the caseId
|
||||
// query param and strips it once the case is found in the cases list,
|
||||
// so we assert on the path, not the query string.
|
||||
await expect(frigateApp.page).toHaveURL(/\/export(\?|$)/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
// Attaching to a case leaves the user on the review page; the case is
|
||||
// reachable from the toast action.
|
||||
const viewCase = frigateApp.page.getByRole("link", { name: /view/i });
|
||||
await expect(viewCase).toBeVisible({ 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",
|
||||
"exportingButton": "Exporting...",
|
||||
"toast": {
|
||||
"started_one": "Started 1 export. Opening the case now.",
|
||||
"started_other": "Started {{count}} exports. Opening the case now.",
|
||||
"startedNoCase_one": "Started 1 export.",
|
||||
"startedNoCase_other": "Started {{count}} exports.",
|
||||
"started_one": "Started 1 export.",
|
||||
"started_other": "Started {{count}} exports.",
|
||||
"partial": "Started {{successful}} of {{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.",
|
||||
"batchPartial": "Started {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||
"batchFailed": "Failed to start {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||
"batchQueuedSuccess_one": "Queued 1 export. Opening the case now.",
|
||||
"batchQueuedSuccess_other": "Queued {{count}} exports. Opening the case now.",
|
||||
"batchQueuedSuccess_one": "Queued 1 export.",
|
||||
"batchQueuedSuccess_other": "Queued {{count}} exports.",
|
||||
"batchQueuedPartial": "Queued {{successful}} of {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||
"batchQueueFailed": "Failed to queue {{total}} exports. Failed cameras: {{failedCameras}}",
|
||||
"error": {
|
||||
|
||||
@ -258,6 +258,7 @@ export default function ReviewFilterGroup({
|
||||
// not applicable as exports are not used
|
||||
camera=""
|
||||
latestTime={0}
|
||||
earliestTime={0}
|
||||
currentTime={0}
|
||||
mode="none"
|
||||
setMode={() => {}}
|
||||
|
||||
@ -39,6 +39,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
@ -62,7 +63,6 @@ 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";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import { isValidIconName } from "@/utils/iconUtil";
|
||||
@ -79,9 +79,14 @@ const EXPORT_OPTIONS = [
|
||||
type ExportOption = (typeof EXPORT_OPTIONS)[number];
|
||||
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 = {
|
||||
camera: string;
|
||||
latestTime: number;
|
||||
earliestTime: number;
|
||||
currentTime: number;
|
||||
range?: TimeRange;
|
||||
mode: ExportMode;
|
||||
@ -94,6 +99,7 @@ type ExportDialogProps = {
|
||||
export default function ExportDialog({
|
||||
camera,
|
||||
latestTime,
|
||||
earliestTime,
|
||||
currentTime,
|
||||
range,
|
||||
mode,
|
||||
@ -107,9 +113,13 @@ export default function ExportDialog({
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>();
|
||||
const [singleNewCaseName, setSingleNewCaseName] = 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 [isStartingExport, setIsStartingExport] = useState(false);
|
||||
const previousModeRef = useRef<ExportMode>(mode);
|
||||
const preTimelineRangeRef = useRef<TimeRange | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const previousMode = previousModeRef.current;
|
||||
@ -188,6 +198,9 @@ export default function ExportDialog({
|
||||
setSelectedCaseId(undefined);
|
||||
setSingleNewCaseName("");
|
||||
setSingleNewCaseDescription("");
|
||||
setBatchCaseSelection("new");
|
||||
setNewCaseName("");
|
||||
setNewCaseDescription("");
|
||||
setRange(undefined);
|
||||
setMode("none");
|
||||
return true;
|
||||
@ -223,14 +236,32 @@ export default function ExportDialog({
|
||||
]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (mode == "timeline_multi") {
|
||||
setRange(preTimelineRangeRef.current);
|
||||
setMode("select");
|
||||
return;
|
||||
}
|
||||
|
||||
setName("");
|
||||
setSelectedCaseId(undefined);
|
||||
setSingleNewCaseName("");
|
||||
setSingleNewCaseDescription("");
|
||||
setBatchCaseSelection("new");
|
||||
setNewCaseName("");
|
||||
setNewCaseDescription("");
|
||||
setMode("none");
|
||||
setRange(undefined);
|
||||
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 Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
|
||||
@ -304,12 +335,16 @@ export default function ExportDialog({
|
||||
>
|
||||
<ExportContent
|
||||
latestTime={latestTime}
|
||||
earliestTime={earliestTime}
|
||||
currentTime={currentTime}
|
||||
range={range}
|
||||
name={name}
|
||||
selectedCaseId={selectedCaseId}
|
||||
singleNewCaseName={singleNewCaseName}
|
||||
singleNewCaseDescription={singleNewCaseDescription}
|
||||
batchCaseSelection={batchCaseSelection}
|
||||
newCaseName={newCaseName}
|
||||
newCaseDescription={newCaseDescription}
|
||||
activeTab={activeTab}
|
||||
isStartingExport={isStartingExport}
|
||||
onStartExport={onStartExport}
|
||||
@ -318,8 +353,12 @@ export default function ExportDialog({
|
||||
setSelectedCaseId={setSelectedCaseId}
|
||||
setSingleNewCaseName={setSingleNewCaseName}
|
||||
setSingleNewCaseDescription={setSingleNewCaseDescription}
|
||||
setBatchCaseSelection={setBatchCaseSelection}
|
||||
setNewCaseName={setNewCaseName}
|
||||
setNewCaseDescription={setNewCaseDescription}
|
||||
setRange={setRange}
|
||||
setMode={setMode}
|
||||
onSelectFromTimeline={onSelectFromTimeline}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</Content>
|
||||
@ -330,12 +369,16 @@ export default function ExportDialog({
|
||||
|
||||
type ExportContentProps = {
|
||||
latestTime: number;
|
||||
earliestTime: number;
|
||||
currentTime: number;
|
||||
range?: TimeRange;
|
||||
name: string;
|
||||
selectedCaseId?: string;
|
||||
singleNewCaseName: string;
|
||||
singleNewCaseDescription: string;
|
||||
batchCaseSelection: string;
|
||||
newCaseName: string;
|
||||
newCaseDescription: string;
|
||||
activeTab: ExportTab;
|
||||
isStartingExport: boolean;
|
||||
onStartExport: () => Promise<boolean>;
|
||||
@ -344,19 +387,27 @@ type ExportContentProps = {
|
||||
setSelectedCaseId: (caseId: string | undefined) => void;
|
||||
setSingleNewCaseName: (name: string) => void;
|
||||
setSingleNewCaseDescription: (description: string) => void;
|
||||
setBatchCaseSelection: (caseId: string) => void;
|
||||
setNewCaseName: (name: string) => void;
|
||||
setNewCaseDescription: (description: string) => void;
|
||||
setRange: (range: TimeRange | undefined) => void;
|
||||
setMode: (mode: ExportMode) => void;
|
||||
onSelectFromTimeline: (range: TimeRange) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function ExportContent({
|
||||
latestTime,
|
||||
earliestTime,
|
||||
currentTime,
|
||||
range,
|
||||
name,
|
||||
selectedCaseId,
|
||||
singleNewCaseName,
|
||||
singleNewCaseDescription,
|
||||
batchCaseSelection,
|
||||
newCaseName,
|
||||
newCaseDescription,
|
||||
activeTab,
|
||||
isStartingExport,
|
||||
onStartExport,
|
||||
@ -365,12 +416,15 @@ export function ExportContent({
|
||||
setSelectedCaseId,
|
||||
setSingleNewCaseName,
|
||||
setSingleNewCaseDescription,
|
||||
setBatchCaseSelection,
|
||||
setNewCaseName,
|
||||
setNewCaseDescription,
|
||||
setRange,
|
||||
setMode,
|
||||
onSelectFromTimeline,
|
||||
onCancel,
|
||||
}: ExportContentProps) {
|
||||
const { t } = useTranslation(["components/dialog"]);
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = useIsAdmin();
|
||||
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
|
||||
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
|
||||
@ -379,13 +433,8 @@ export function ExportContent({
|
||||
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 [cameraSearch, setCameraSearch] = useState("");
|
||||
const [cameraMenuOpen, setCameraMenuOpen] = useState(false);
|
||||
@ -416,38 +465,47 @@ export function ExportContent({
|
||||
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]);
|
||||
|
||||
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(() => {
|
||||
if (activeTab !== "multi" || range) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRange({
|
||||
before: currentTime + 1800,
|
||||
after: currentTime - 1800,
|
||||
});
|
||||
}, [activeTab, currentTime, range, setRange]);
|
||||
setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS));
|
||||
}, [activeTab, buildRangeAroundCurrentTime, range, setRange]);
|
||||
|
||||
const { data: events, isLoading: isEventsLoading } = useSWR<Event[]>(
|
||||
activeTab === "multi" && debouncedRange
|
||||
@ -715,6 +773,16 @@ export function ExportContent({
|
||||
return result.error ? `${cameraName}: ${result.error}` : cameraName;
|
||||
})
|
||||
.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) {
|
||||
toast.success(
|
||||
@ -728,6 +796,7 @@ export function ExportContent({
|
||||
{
|
||||
position: "top-center",
|
||||
description: failedSummary,
|
||||
action: viewCaseAction,
|
||||
},
|
||||
);
|
||||
} else if (failedResults.length > 0) {
|
||||
@ -748,7 +817,7 @@ export function ExportContent({
|
||||
t("export.toast.batchQueuedSuccess", {
|
||||
count: successfulResults.length,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
{ position: "top-center", action: viewCaseAction },
|
||||
);
|
||||
}
|
||||
|
||||
@ -761,9 +830,6 @@ export function ExportContent({
|
||||
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 {
|
||||
@ -794,12 +860,14 @@ export function ExportContent({
|
||||
range,
|
||||
selectedCameraIds,
|
||||
setActiveTab,
|
||||
setBatchCaseSelection,
|
||||
setMode,
|
||||
setName,
|
||||
setNewCaseDescription,
|
||||
setNewCaseName,
|
||||
setRange,
|
||||
setSelectedCaseId,
|
||||
t,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
return (
|
||||
@ -820,10 +888,8 @@ export function ExportContent({
|
||||
onValueChange={(value) => {
|
||||
const tab = value as ExportTab;
|
||||
if (tab === "multi") {
|
||||
setRange({
|
||||
before: currentTime + 1800,
|
||||
after: currentTime - 1800,
|
||||
});
|
||||
setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS));
|
||||
setBatchCaseSelection(selectedCaseId ?? "new");
|
||||
} else {
|
||||
onSelectTime(selectedOption);
|
||||
}
|
||||
@ -975,23 +1041,18 @@ export function ExportContent({
|
||||
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");
|
||||
onSelectFromTimeline(clampRangeToTimeline(range));
|
||||
}}
|
||||
>
|
||||
<LuAudioLines className="size-4 -rotate-90" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("export.multiCamera.selectFromTimeline")}
|
||||
</TooltipContent>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>
|
||||
{t("export.multiCamera.selectFromTimeline")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@ -1256,7 +1317,9 @@ export function ExportContent({
|
||||
disabled={isStartingExport}
|
||||
onClick={async () => {
|
||||
if (selectedOption == "timeline") {
|
||||
setRange({ before: currentTime + 30, after: currentTime - 30 });
|
||||
setRange(
|
||||
buildRangeAroundCurrentTime(TIMELINE_SELECTION_SECONDS),
|
||||
);
|
||||
setMode("timeline");
|
||||
} else {
|
||||
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 { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import { Button } from "../ui/button";
|
||||
@ -65,6 +65,7 @@ type MobileReviewSettingsDrawerProps = {
|
||||
filter?: ReviewFilter;
|
||||
currentSeverity?: ReviewSeverity;
|
||||
latestTime: number;
|
||||
earliestTime: number;
|
||||
currentTime: number;
|
||||
range?: TimeRange;
|
||||
mode: ExportMode;
|
||||
@ -90,6 +91,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
filter,
|
||||
currentSeverity,
|
||||
latestTime,
|
||||
earliestTime,
|
||||
currentTime,
|
||||
range,
|
||||
mode,
|
||||
@ -142,7 +144,22 @@ export default function MobileReviewSettingsDrawer({
|
||||
);
|
||||
const [singleNewCaseName, setSingleNewCaseName] = useState("");
|
||||
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
|
||||
const [batchCaseSelection, setBatchCaseSelection] = useState("new");
|
||||
const [newCaseName, setNewCaseName] = useState("");
|
||||
const [newCaseDescription, setNewCaseDescription] = useState("");
|
||||
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 () => {
|
||||
if (isStartingExport) {
|
||||
return false;
|
||||
@ -214,6 +231,9 @@ export default function MobileReviewSettingsDrawer({
|
||||
setSelectedCaseId(undefined);
|
||||
setSingleNewCaseName("");
|
||||
setSingleNewCaseDescription("");
|
||||
setBatchCaseSelection("new");
|
||||
setNewCaseName("");
|
||||
setNewCaseDescription("");
|
||||
setRange(undefined);
|
||||
setMode("none");
|
||||
return true;
|
||||
@ -433,12 +453,16 @@ export default function MobileReviewSettingsDrawer({
|
||||
content = (
|
||||
<ExportContent
|
||||
latestTime={latestTime}
|
||||
earliestTime={earliestTime}
|
||||
currentTime={currentTime}
|
||||
range={range}
|
||||
name={name}
|
||||
selectedCaseId={selectedCaseId}
|
||||
singleNewCaseName={singleNewCaseName}
|
||||
singleNewCaseDescription={singleNewCaseDescription}
|
||||
batchCaseSelection={batchCaseSelection}
|
||||
newCaseName={newCaseName}
|
||||
newCaseDescription={newCaseDescription}
|
||||
activeTab={exportTab}
|
||||
isStartingExport={isStartingExport}
|
||||
onStartExport={onStartExport}
|
||||
@ -447,6 +471,9 @@ export default function MobileReviewSettingsDrawer({
|
||||
setSelectedCaseId={setSelectedCaseId}
|
||||
setSingleNewCaseName={setSingleNewCaseName}
|
||||
setSingleNewCaseDescription={setSingleNewCaseDescription}
|
||||
setBatchCaseSelection={setBatchCaseSelection}
|
||||
setNewCaseName={setNewCaseName}
|
||||
setNewCaseDescription={setNewCaseDescription}
|
||||
setRange={setRange}
|
||||
setMode={(mode) => {
|
||||
setMode(mode);
|
||||
@ -455,12 +482,16 @@ export default function MobileReviewSettingsDrawer({
|
||||
setDrawerMode("none");
|
||||
}
|
||||
}}
|
||||
onSelectFromTimeline={onSelectFromTimeline}
|
||||
onCancel={() => {
|
||||
setMode("none");
|
||||
setRange(undefined);
|
||||
setSelectedCaseId(undefined);
|
||||
setSingleNewCaseName("");
|
||||
setSingleNewCaseDescription("");
|
||||
setBatchCaseSelection("new");
|
||||
setNewCaseName("");
|
||||
setNewCaseDescription("");
|
||||
setExportTab("export");
|
||||
setDrawerMode("select");
|
||||
}}
|
||||
@ -639,6 +670,14 @@ export default function MobileReviewSettingsDrawer({
|
||||
void onStartExport();
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (mode == "timeline_multi") {
|
||||
setRange(preTimelineRangeRef.current);
|
||||
setExportTab("multi");
|
||||
setMode("select");
|
||||
setDrawerMode("export");
|
||||
return;
|
||||
}
|
||||
|
||||
setExportTab("export");
|
||||
setRange(undefined);
|
||||
setMode("none");
|
||||
|
||||
@ -3,7 +3,6 @@ import { isDesktop } from "react-device-detect";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
|
||||
import {
|
||||
@ -43,6 +42,7 @@ import {
|
||||
ExportCase,
|
||||
} from "@/types/export";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
|
||||
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
@ -65,7 +65,6 @@ export default function MultiExportDialog({
|
||||
}: MultiExportDialogProps) {
|
||||
const { t } = useTranslation(["components/dialog", "common"]);
|
||||
const locale = useDateLocale();
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
@ -203,19 +202,24 @@ export default function MultiExportDialog({
|
||||
const results = response.data.results ?? [];
|
||||
const successful = 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) {
|
||||
toast.success(
|
||||
t(
|
||||
isAdmin
|
||||
? "export.multi.toast.started"
|
||||
: "export.multi.toast.startedNoCase",
|
||||
{
|
||||
ns: "components/dialog",
|
||||
count: successful.length,
|
||||
},
|
||||
),
|
||||
{ position: "top-center" },
|
||||
t("export.multi.toast.started", {
|
||||
ns: "components/dialog",
|
||||
count: successful.length,
|
||||
}),
|
||||
{ position: "top-center", action: viewCaseAction },
|
||||
);
|
||||
} else if (successful.length > 0 && failed.length > 0) {
|
||||
// Resolve each failure to its review via item_index so same-camera
|
||||
@ -229,7 +233,7 @@ export default function MultiExportDialog({
|
||||
total: results.length,
|
||||
failedItems: failedLabels,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
{ position: "top-center", action: viewCaseAction },
|
||||
);
|
||||
} else {
|
||||
const failedLabels = failed.map(formatFailureLabel).join(", ");
|
||||
@ -247,9 +251,6 @@ export default function MultiExportDialog({
|
||||
onStarted();
|
||||
setOpen(false);
|
||||
resetState();
|
||||
if (response.data.export_case_id) {
|
||||
navigate(`/export?caseId=${response.data.export_case_id}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const apiError = error as {
|
||||
@ -275,7 +276,6 @@ export default function MultiExportDialog({
|
||||
formatFailureLabel,
|
||||
isAdmin,
|
||||
isNewCase,
|
||||
navigate,
|
||||
newCaseDescription,
|
||||
newCaseName,
|
||||
onStarted,
|
||||
|
||||
@ -318,12 +318,12 @@ export const formatSecondsToDuration = (
|
||||
* @param timezone string representation of the timezone the user is requesting
|
||||
* @returns number of minutes offset from UTC
|
||||
*/
|
||||
export const getUTCOffset = (
|
||||
date: Date,
|
||||
timezone: string = getResolvedTimeZone(),
|
||||
): number => {
|
||||
export const getUTCOffset = (date: Date, timezone?: string | null): number => {
|
||||
// ui.timezone comes back as null until the user sets one
|
||||
const resolvedTimezone = timezone || getResolvedTimeZone();
|
||||
|
||||
// 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) {
|
||||
const hours = parseInt(utcOffsetMatch[2], 10);
|
||||
const minutes = parseInt(utcOffsetMatch[3], 10);
|
||||
@ -334,7 +334,7 @@ export const getUTCOffset = (
|
||||
const utcDate = new Date(date.getTime());
|
||||
// locale of en-CA is required for proper locale format
|
||||
let iso = utcDate
|
||||
.toLocaleString("en-CA", { timeZone: timezone, hour12: false })
|
||||
.toLocaleString("en-CA", { timeZone: resolvedTimezone, hour12: false })
|
||||
.replace(", ", "T");
|
||||
iso += `.${utcDate.getMilliseconds().toString().padStart(3, "0")}`;
|
||||
let target = new Date(`${iso}Z`);
|
||||
|
||||
@ -1356,6 +1356,7 @@ export default function MotionSearchView({
|
||||
camera={selectedCamera}
|
||||
currentTime={currentTime}
|
||||
latestTime={timeRange.before}
|
||||
earliestTime={timeRange.after}
|
||||
mode={exportMode}
|
||||
range={exportRange}
|
||||
showPreview={showExportPreview}
|
||||
@ -1476,6 +1477,7 @@ export default function MotionSearchView({
|
||||
camera={selectedCamera}
|
||||
currentTime={currentTime}
|
||||
latestTime={timeRange.before}
|
||||
earliestTime={timeRange.after}
|
||||
mode={exportMode}
|
||||
range={exportRange}
|
||||
showPreview={showExportPreview}
|
||||
|
||||
@ -677,6 +677,7 @@ export function RecordingView({
|
||||
camera={mainCamera}
|
||||
currentTime={currentTime}
|
||||
latestTime={timeRange.before}
|
||||
earliestTime={timeRange.after}
|
||||
mode={exportMode}
|
||||
range={exportRange}
|
||||
showPreview={showExportPreview}
|
||||
@ -810,6 +811,7 @@ export function RecordingView({
|
||||
filter={filter}
|
||||
currentTime={currentTime}
|
||||
latestTime={timeRange.before}
|
||||
earliestTime={timeRange.after}
|
||||
recordingsSummary={recordingsSummary}
|
||||
mode={exportMode}
|
||||
range={exportRange}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user