+
+ {t("export.case.nonAdminHelp")}
+
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
("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(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(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(
+ "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 = (
+
+ setNewCaseName(event.target.value)}
+ maxLength={100}
+ autoFocus={isDesktop}
+ />
+
+ );
+
+ const body = (
+
+ {isAdmin ? (
+
+
+
+ {isNewCase && newCaseInputs}
+
+ ) : (
+
+
+
+ {t("export.case.nonAdminHelp")}
+
+ {newCaseInputs}
+
+ )}
+
+ );
+
+ const footer = (
+ <>
+
+
+ >
+ );
+
+ if (isDesktop) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {children}
+
+
+ {t("export.multi.title", { count })}
+ {t("export.multi.description")}
+
+ {body}
+ {footer}
+
+
+ );
+}
diff --git a/web/src/types/export.ts b/web/src/types/export.ts
index d0b8aa852..1926b533b 100644
--- a/web/src/types/export.ts
+++ b/web/src/types/export.ts
@@ -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 = {