This commit is contained in:
Josh Hawkins 2026-04-11 18:21:24 -05:00
parent a6c405a2a5
commit 5beac2c91f
6 changed files with 542 additions and 59 deletions

View File

@ -54,6 +54,7 @@
"newCaseNamePlaceholder": "New case name",
"newCaseDescriptionPlaceholder": "Case description",
"label": "Case",
"nonAdminHelp": "A new case will be created for these exports.",
"placeholder": "Select a case"
},
"select": "Select",
@ -79,6 +80,21 @@
"exportButton_one": "Export 1 Camera",
"exportButton_other": "Export {{count}} Cameras"
},
"multi": {
"title_one": "Export 1 review",
"title_other": "Export {{count}} reviews",
"description": "Export each selected review. All exports will be grouped under a single case.",
"caseNamePlaceholder": "Review export - {{date}}",
"exportButton_one": "Export 1 review",
"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.",
"partial": "Started {{successful}} of {{total}} exports. Failed: {{failedItems}}",
"failed": "Failed to start {{total}} exports. Failed: {{failedItems}}"
}
},
"toast": {
"success": "Successfully started export. View the file in the exports page.",
"queued": "Export queued. View progress in the exports page.",

View File

@ -81,7 +81,7 @@ export default function ReviewCard({
axios
.post(
`export/${event.camera}/start/${event.start_time + REVIEW_PADDING}/end/${endTime}`,
`export/${event.camera}/start/${event.start_time - REVIEW_PADDING}/end/${endTime}`,
{ playback: "realtime" },
)
.then((response) => {

View File

@ -6,6 +6,7 @@ import { isDesktop } from "react-device-detect";
import { FaCompactDisc } from "react-icons/fa";
import { HiTrash } from "react-icons/hi";
import { ReviewSegment } from "@/types/review";
import { MAX_BATCH_EXPORT_ITEMS } from "@/types/export";
import {
AlertDialog,
AlertDialogAction,
@ -20,6 +21,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
import MultiExportDialog from "../overlay/MultiExportDialog";
type ReviewActionGroupProps = {
selectedReviews: ReviewSegment[];
@ -164,6 +166,29 @@ export default function ReviewActionGroup({
)}
</Button>
)}
{selectedReviews.length >= 2 &&
selectedReviews.length <= MAX_BATCH_EXPORT_ITEMS && (
<MultiExportDialog
selectedReviews={selectedReviews}
onStarted={() => {
onClearSelected();
pullLatestData();
}}
>
<Button
className="flex items-center gap-2 p-2"
aria-label={t("recording.button.export")}
size="sm"
>
<FaCompactDisc className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("recording.button.export")}
</div>
)}
</Button>
</MultiExportDialog>
)}
<Button
className="flex items-center gap-2 p-2"
aria-label={

View File

@ -53,6 +53,7 @@ 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";
const EXPORT_OPTIONS = [
"1",
@ -322,8 +323,9 @@ export function ExportContent({
}: ExportContentProps) {
const { t } = useTranslation(["components/dialog"]);
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
const { data: cases } = useSWR<ExportCase[]>("cases");
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
const { data: config } = useSWR<FrigateConfig>("config");
const [debouncedRange, setDebouncedRange] = useState<TimeRange | undefined>(
range,
@ -555,16 +557,20 @@ export function ExportContent({
}
const payload: BatchExportBody = {
items: selectedCameraIds.map((cameraId) => ({
camera: cameraId,
start_time: Math.round(range.after),
end_time: Math.round(range.before),
camera_ids: selectedCameraIds,
name: name || undefined,
friendly_name: name
? `${name} - ${resolveCameraName(config, cameraId)}`
: undefined,
})),
};
if (batchCaseSelection === "new") {
payload.new_case_name = newCaseName.trim();
payload.new_case_description = newCaseDescription.trim() || undefined;
} else if (batchCaseSelection !== "none") {
} else {
payload.export_case_id = batchCaseSelection;
}
@ -752,6 +758,7 @@ export function ExportContent({
onChange={(e) => setName(e.target.value)}
/>
{isAdmin && (
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
{t("export.case.label")}
@ -779,6 +786,7 @@ export function ExportContent({
</SelectContent>
</Select>
</div>
)}
</TabsContent>
<TabsContent
@ -927,6 +935,8 @@ export function ExportContent({
<Label className="text-sm text-secondary-foreground">
{t("export.case.label")}
</Label>
{isAdmin ? (
<>
<Select
value={batchCaseSelection}
onValueChange={(value) => setBatchCaseSelection(value)}
@ -950,6 +960,30 @@ export function ExportContent({
</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 className="space-y-2 pt-1">
<div className="text-xs text-muted-foreground">
{t("export.case.nonAdminHelp")}
</div>
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}

View File

@ -0,0 +1,398 @@
import { useCallback, useMemo, useState } from "react";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "../ui/drawer";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "../ui/select";
import { Textarea } from "../ui/textarea";
import {
BatchExportBody,
BatchExportResponse,
BatchExportResult,
ExportCase,
} from "@/types/export";
import { FrigateConfig } from "@/types/frigateConfig";
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
type MultiExportDialogProps = {
selectedReviews: ReviewSegment[];
onStarted: () => void;
children: React.ReactNode;
};
const NEW_CASE_OPTION = "new";
export default function MultiExportDialog({
selectedReviews,
onStarted,
children,
}: MultiExportDialogProps) {
const { t } = useTranslation(["components/dialog", "common"]);
const locale = useDateLocale();
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const { data: config } = useSWR<FrigateConfig>("config");
// Only admins can attach exports to an existing case (enforced server-side
// by POST /exports/batch). Skip fetching the case list entirely for
// non-admins — they can only ever use the "Create new case" branch.
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
const [open, setOpen] = useState(false);
// Single unified state: either NEW_CASE_OPTION or an existing case id.
// Defaults to NEW_CASE_OPTION, which is also the only valid value for
// non-admins since they can't attach to existing cases.
const [caseSelection, setCaseSelection] = useState<string>(NEW_CASE_OPTION);
const [newCaseName, setNewCaseName] = useState("");
const [newCaseDescription, setNewCaseDescription] = useState("");
const [isExporting, setIsExporting] = useState(false);
const count = selectedReviews.length;
// Resolve a failed batch result back to a human-readable label via the
// client-provided review id when available. Falls back to item_index and
// finally camera name for defensive compatibility.
const formatFailureLabel = useCallback(
(result: BatchExportResult): string => {
const cameraName = resolveCameraName(config, result.camera);
if (result.client_item_id) {
const review = selectedReviews.find(
(item) => item.id === result.client_item_id,
);
if (review) {
const time = formatUnixTimestampToDateTime(review.start_time, {
date_style: "short",
time_style: "short",
locale,
});
return `${cameraName}${time}`;
}
}
if (
typeof result.item_index === "number" &&
result.item_index >= 0 &&
result.item_index < selectedReviews.length
) {
const review = selectedReviews[result.item_index];
const time = formatUnixTimestampToDateTime(review.start_time, {
date_style: "short",
time_style: "short",
locale,
});
return `${cameraName}${time}`;
}
return cameraName;
},
[config, locale, selectedReviews],
);
const defaultCaseName = useMemo(() => {
const formattedDate = formatUnixTimestampToDateTime(Date.now() / 1000, {
date_style: "medium",
time_style: "short",
locale,
});
return t("export.multi.caseNamePlaceholder", {
ns: "components/dialog",
date: formattedDate,
});
}, [t, locale]);
const resetState = useCallback(() => {
setCaseSelection(NEW_CASE_OPTION);
setNewCaseName("");
setNewCaseDescription("");
setIsExporting(false);
}, []);
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) {
resetState();
} else {
// Freshly reset each time so the default name reflects "now"
setCaseSelection(NEW_CASE_OPTION);
setNewCaseName(defaultCaseName);
setNewCaseDescription("");
setIsExporting(false);
}
setOpen(next);
},
[defaultCaseName, resetState],
);
const existingCases = useMemo(() => {
return (cases ?? []).slice().sort((a, b) => a.name.localeCompare(b.name));
}, [cases]);
const isNewCase = caseSelection === NEW_CASE_OPTION;
const canSubmit = useMemo(() => {
if (isExporting) return false;
if (count === 0) return false;
if (isNewCase) {
return newCaseName.trim().length > 0;
}
return caseSelection.length > 0;
}, [caseSelection, count, isExporting, isNewCase, newCaseName]);
const handleSubmit = useCallback(async () => {
if (!canSubmit) return;
const items = selectedReviews.map((review) => ({
camera: review.camera,
start_time: review.start_time - REVIEW_PADDING,
end_time: (review.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
image_path: review.thumb_path || undefined,
client_item_id: review.id,
}));
const payload: BatchExportBody = {
items,
...(isNewCase
? {
new_case_name: newCaseName.trim(),
new_case_description: newCaseDescription.trim() || undefined,
}
: { export_case_id: caseSelection }),
};
setIsExporting(true);
try {
const response = await axios.post<BatchExportResponse>(
"exports/batch",
payload,
);
const results = response.data.results ?? [];
const successful = results.filter((r) => r.success);
const failed = results.filter((r) => !r.success);
if (successful.length > 0 && failed.length === 0) {
toast.success(
t("export.multi.toast.started", {
ns: "components/dialog",
count: successful.length,
}),
{ position: "top-center" },
);
} else if (successful.length > 0 && failed.length > 0) {
// Resolve each failure to its review via item_index so same-camera
// items are disambiguated by time. Falls back to camera-only if the
// server didn't populate item_index.
const failedLabels = failed.map(formatFailureLabel).join(", ");
toast.success(
t("export.multi.toast.partial", {
ns: "components/dialog",
successful: successful.length,
total: results.length,
failedItems: failedLabels,
}),
{ position: "top-center" },
);
} else {
const failedLabels = failed.map(formatFailureLabel).join(", ");
toast.error(
t("export.multi.toast.failed", {
ns: "components/dialog",
total: results.length,
failedItems: failedLabels,
}),
{ position: "top-center" },
);
}
if (successful.length > 0) {
onStarted();
setOpen(false);
resetState();
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", {
ns: "components/dialog",
error: errorMessage,
}),
{ position: "top-center" },
);
} finally {
setIsExporting(false);
}
}, [
canSubmit,
caseSelection,
formatFailureLabel,
isNewCase,
navigate,
newCaseDescription,
newCaseName,
onStarted,
resetState,
selectedReviews,
t,
]);
// New-case inputs: rendered below the Select when caseSelection === "new",
// or rendered standalone for non-admins (who never see the Select since
// they cannot attach to an existing case).
const newCaseInputs = (
<div className="space-y-2 pt-1">
<Input
className="text-md"
placeholder={t("export.case.newCaseNamePlaceholder")}
value={newCaseName}
onChange={(event) => setNewCaseName(event.target.value)}
maxLength={100}
autoFocus={isDesktop}
/>
<Textarea
className="text-md"
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
value={newCaseDescription}
onChange={(event) => setNewCaseDescription(event.target.value)}
rows={2}
/>
</div>
);
const body = (
<div className="flex flex-col gap-4">
{isAdmin ? (
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
{t("export.case.label")}
</Label>
<Select
value={caseSelection}
onValueChange={(value) => setCaseSelection(value)}
>
<SelectTrigger>
<SelectValue placeholder={t("export.case.placeholder")} />
</SelectTrigger>
<SelectContent>
{existingCases.map((caseItem) => (
<SelectItem key={caseItem.id} value={caseItem.id}>
{caseItem.name}
</SelectItem>
))}
{existingCases.length > 0 && <SelectSeparator />}
<SelectItem value={NEW_CASE_OPTION}>
{t("export.case.newCaseOption")}
</SelectItem>
</SelectContent>
</Select>
{isNewCase && newCaseInputs}
</div>
) : (
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
{t("export.case.label")}
</Label>
<div className="text-xs text-muted-foreground">
{t("export.case.nonAdminHelp")}
</div>
{newCaseInputs}
</div>
)}
</div>
);
const footer = (
<>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isExporting}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
onClick={handleSubmit}
disabled={!canSubmit}
aria-label={t("export.multi.exportButton", { count })}
>
{isExporting
? t("export.multi.exportingButton")
: t("export.multi.exportButton", { count })}
</Button>
</>
);
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("export.multi.title", { count })}</DialogTitle>
<DialogDescription>
{t("export.multi.description")}
</DialogDescription>
</DialogHeader>
{body}
<DialogFooter className="gap-2">{footer}</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent className="px-4 pb-6">
<DrawerHeader className="px-0">
<DrawerTitle>{t("export.multi.title", { count })}</DrawerTitle>
<DrawerDescription>{t("export.multi.description")}</DrawerDescription>
</DrawerHeader>
{body}
<div className="mt-4 flex flex-col-reverse gap-2">{footer}</div>
</DrawerContent>
</Drawer>
);
}

View File

@ -19,21 +19,31 @@ export type ExportCase = {
};
export type BatchExportBody = {
start_time: number;
end_time: number;
camera_ids: string[];
name?: string;
items: BatchExportItem[];
export_case_id?: string;
new_case_name?: string;
new_case_description?: string;
};
export const MAX_BATCH_EXPORT_ITEMS = 50;
export type BatchExportItem = {
camera: string;
start_time: number;
end_time: number;
image_path?: string;
friendly_name?: string;
client_item_id?: string;
};
export type BatchExportResult = {
camera: string;
export_id?: string | null;
success: boolean;
status?: string | null;
error?: string | null;
item_index?: number | null;
client_item_id?: string | null;
};
export type BatchExportResponse = {