mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
frontend
This commit is contained in:
parent
a6c405a2a5
commit
5beac2c91f
@ -54,6 +54,7 @@
|
|||||||
"newCaseNamePlaceholder": "New case name",
|
"newCaseNamePlaceholder": "New case name",
|
||||||
"newCaseDescriptionPlaceholder": "Case description",
|
"newCaseDescriptionPlaceholder": "Case description",
|
||||||
"label": "Case",
|
"label": "Case",
|
||||||
|
"nonAdminHelp": "A new case will be created for these exports.",
|
||||||
"placeholder": "Select a case"
|
"placeholder": "Select a case"
|
||||||
},
|
},
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
@ -79,6 +80,21 @@
|
|||||||
"exportButton_one": "Export 1 Camera",
|
"exportButton_one": "Export 1 Camera",
|
||||||
"exportButton_other": "Export {{count}} Cameras"
|
"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": {
|
"toast": {
|
||||||
"success": "Successfully started export. View the file in the exports page.",
|
"success": "Successfully started export. View the file in the exports page.",
|
||||||
"queued": "Export queued. View progress in the exports page.",
|
"queued": "Export queued. View progress in the exports page.",
|
||||||
|
|||||||
@ -81,7 +81,7 @@ export default function ReviewCard({
|
|||||||
|
|
||||||
axios
|
axios
|
||||||
.post(
|
.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" },
|
{ playback: "realtime" },
|
||||||
)
|
)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { isDesktop } from "react-device-detect";
|
|||||||
import { FaCompactDisc } from "react-icons/fa";
|
import { FaCompactDisc } from "react-icons/fa";
|
||||||
import { HiTrash } from "react-icons/hi";
|
import { HiTrash } from "react-icons/hi";
|
||||||
import { ReviewSegment } from "@/types/review";
|
import { ReviewSegment } from "@/types/review";
|
||||||
|
import { MAX_BATCH_EXPORT_ITEMS } from "@/types/export";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -20,6 +21,7 @@ import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
|||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
import MultiExportDialog from "../overlay/MultiExportDialog";
|
||||||
|
|
||||||
type ReviewActionGroupProps = {
|
type ReviewActionGroupProps = {
|
||||||
selectedReviews: ReviewSegment[];
|
selectedReviews: ReviewSegment[];
|
||||||
@ -164,6 +166,29 @@ export default function ReviewActionGroup({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
className="flex items-center gap-2 p-2"
|
className="flex items-center gap-2 p-2"
|
||||||
aria-label={
|
aria-label={
|
||||||
|
|||||||
@ -53,6 +53,7 @@ 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 { useNavigate } from "react-router-dom";
|
||||||
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
|
||||||
const EXPORT_OPTIONS = [
|
const EXPORT_OPTIONS = [
|
||||||
"1",
|
"1",
|
||||||
@ -322,8 +323,9 @@ export function ExportContent({
|
|||||||
}: ExportContentProps) {
|
}: ExportContentProps) {
|
||||||
const { t } = useTranslation(["components/dialog"]);
|
const { t } = useTranslation(["components/dialog"]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
|
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 { data: config } = useSWR<FrigateConfig>("config");
|
||||||
const [debouncedRange, setDebouncedRange] = useState<TimeRange | undefined>(
|
const [debouncedRange, setDebouncedRange] = useState<TimeRange | undefined>(
|
||||||
range,
|
range,
|
||||||
@ -555,16 +557,20 @@ export function ExportContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload: BatchExportBody = {
|
const payload: BatchExportBody = {
|
||||||
|
items: selectedCameraIds.map((cameraId) => ({
|
||||||
|
camera: cameraId,
|
||||||
start_time: Math.round(range.after),
|
start_time: Math.round(range.after),
|
||||||
end_time: Math.round(range.before),
|
end_time: Math.round(range.before),
|
||||||
camera_ids: selectedCameraIds,
|
friendly_name: name
|
||||||
name: name || undefined,
|
? `${name} - ${resolveCameraName(config, cameraId)}`
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (batchCaseSelection === "new") {
|
if (batchCaseSelection === "new") {
|
||||||
payload.new_case_name = newCaseName.trim();
|
payload.new_case_name = newCaseName.trim();
|
||||||
payload.new_case_description = newCaseDescription.trim() || undefined;
|
payload.new_case_description = newCaseDescription.trim() || undefined;
|
||||||
} else if (batchCaseSelection !== "none") {
|
} else {
|
||||||
payload.export_case_id = batchCaseSelection;
|
payload.export_case_id = batchCaseSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -752,6 +758,7 @@ export function ExportContent({
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm text-secondary-foreground">
|
<Label className="text-sm text-secondary-foreground">
|
||||||
{t("export.case.label")}
|
{t("export.case.label")}
|
||||||
@ -779,6 +786,7 @@ export function ExportContent({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent
|
||||||
@ -927,6 +935,8 @@ export function ExportContent({
|
|||||||
<Label className="text-sm text-secondary-foreground">
|
<Label className="text-sm text-secondary-foreground">
|
||||||
{t("export.case.label")}
|
{t("export.case.label")}
|
||||||
</Label>
|
</Label>
|
||||||
|
{isAdmin ? (
|
||||||
|
<>
|
||||||
<Select
|
<Select
|
||||||
value={batchCaseSelection}
|
value={batchCaseSelection}
|
||||||
onValueChange={(value) => setBatchCaseSelection(value)}
|
onValueChange={(value) => setBatchCaseSelection(value)}
|
||||||
@ -950,6 +960,30 @@ export function ExportContent({
|
|||||||
</Select>
|
</Select>
|
||||||
{batchCaseSelection === "new" && (
|
{batchCaseSelection === "new" && (
|
||||||
<div className="space-y-2 pt-1">
|
<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
|
<Input
|
||||||
className="text-md"
|
className="text-md"
|
||||||
placeholder={t("export.case.newCaseNamePlaceholder")}
|
placeholder={t("export.case.newCaseNamePlaceholder")}
|
||||||
|
|||||||
398
web/src/components/overlay/MultiExportDialog.tsx
Normal file
398
web/src/components/overlay/MultiExportDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -19,21 +19,31 @@ export type ExportCase = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type BatchExportBody = {
|
export type BatchExportBody = {
|
||||||
start_time: number;
|
items: BatchExportItem[];
|
||||||
end_time: number;
|
|
||||||
camera_ids: string[];
|
|
||||||
name?: string;
|
|
||||||
export_case_id?: string;
|
export_case_id?: string;
|
||||||
new_case_name?: string;
|
new_case_name?: string;
|
||||||
new_case_description?: 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 = {
|
export type BatchExportResult = {
|
||||||
camera: string;
|
camera: string;
|
||||||
export_id?: string | null;
|
export_id?: string | null;
|
||||||
success: boolean;
|
success: boolean;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
item_index?: number | null;
|
||||||
|
client_item_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BatchExportResponse = {
|
export type BatchExportResponse = {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user