mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-27 14:19:01 +03:00
Export improvements (#22867)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* backend * frontend + i18n * tests + api spec * tweak backend to use Job infrastructure for exports * frontend tweaks and Job infrastructure * tests * tweaks - add ability to remove from case - change location of counts in case card * add stale export reaper on startup * fix toaster close button color * improve add dialog * formatting * hide max_concurrent from camera config export settings * remove border * refactor batch endpoint for multiple review items * frontend * tests and fastapi spec * fix deletion of in-progress exports in a case * tweaks - hide cases when filtering cameras that have no exports from those cameras - remove description from case card - use textarea instead of input for case description in add new case dialog * add auth exceptions for exports * add e2e test for deleting cases with exports * refactor delete and case endpoints allow bulk deleting and reassigning * frontend - bulk selection like Review - gate admin-only actions - consolidate dialogs - spacing/padding tweaks * i18n and tests * update openapi spec * tweaks - add None to case selection list - allow new case creation from single cam export dialog * fix codeql * fix i18n * remove unused * fix frontend tests
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import { Button } from "../ui/button";
|
||||
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
|
||||
import { LuBug } from "react-icons/lu";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
|
||||
import { ExportContent, ExportPreviewDialog, ExportTab } from "./ExportDialog";
|
||||
import {
|
||||
DebugReplayContent,
|
||||
SaveDebugReplayOverlay,
|
||||
@@ -26,6 +26,7 @@ import SaveExportOverlay from "./SaveExportOverlay";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { StartExportResponse } from "@/types/export";
|
||||
|
||||
type DrawerMode =
|
||||
| "none"
|
||||
@@ -102,6 +103,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
]);
|
||||
const navigate = useNavigate();
|
||||
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
|
||||
const [exportTab, setExportTab] = useState<ExportTab>("export");
|
||||
const [selectedReplayOption, setSelectedReplayOption] = useState<
|
||||
"1" | "5" | "custom" | "timeline"
|
||||
>("1");
|
||||
@@ -113,67 +115,112 @@ export default function MobileReviewSettingsDrawer({
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const onStartExport = useCallback(() => {
|
||||
const [singleNewCaseName, setSingleNewCaseName] = useState("");
|
||||
const [singleNewCaseDescription, setSingleNewCaseDescription] = useState("");
|
||||
const [isStartingExport, setIsStartingExport] = useState(false);
|
||||
const onStartExport = useCallback(async () => {
|
||||
if (isStartingExport) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!range) {
|
||||
toast.error(t("toast.error.noValidTimeSelected"), {
|
||||
position: "top-center",
|
||||
});
|
||||
return;
|
||||
toast.error(
|
||||
t("export.toast.error.noVaildTimeSelected", {
|
||||
ns: "components/dialog",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (range.before < range.after) {
|
||||
toast.error(t("toast.error.endTimeMustAfterStartTime"), {
|
||||
position: "top-center",
|
||||
});
|
||||
return;
|
||||
toast.error(
|
||||
t("export.toast.error.endTimeMustAfterStartTime", {
|
||||
ns: "components/dialog",
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
axios
|
||||
.post(
|
||||
setIsStartingExport(true);
|
||||
|
||||
try {
|
||||
let exportCaseId: string | undefined = selectedCaseId;
|
||||
|
||||
if (selectedCaseId === "new" && singleNewCaseName.trim().length > 0) {
|
||||
const caseResp = await axios.post("cases", {
|
||||
name: singleNewCaseName.trim(),
|
||||
description: singleNewCaseDescription.trim() || undefined,
|
||||
});
|
||||
exportCaseId = caseResp.data?.id;
|
||||
} else if (selectedCaseId === "new" || selectedCaseId === "none") {
|
||||
exportCaseId = undefined;
|
||||
}
|
||||
|
||||
await axios.post<StartExportResponse>(
|
||||
`export/${camera}/start/${Math.round(range.after)}/end/${Math.round(range.before)}`,
|
||||
{
|
||||
playback: "realtime",
|
||||
source: "recordings",
|
||||
name,
|
||||
export_case_id: selectedCaseId || undefined,
|
||||
export_case_id: exportCaseId,
|
||||
},
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
toast.success(
|
||||
t("export.toast.success", { ns: "components/dialog" }),
|
||||
{
|
||||
position: "top-center",
|
||||
action: (
|
||||
<a href="/export" target="_blank" rel="noopener noreferrer">
|
||||
<Button>
|
||||
{t("export.toast.view", { ns: "components/dialog" })}
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
},
|
||||
);
|
||||
setName("");
|
||||
setSelectedCaseId(undefined);
|
||||
setRange(undefined);
|
||||
setMode("none");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(
|
||||
t("export.toast.error.failed", {
|
||||
ns: "components/dialog",
|
||||
errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
toast.success(t("export.toast.queued", { ns: "components/dialog" }), {
|
||||
position: "top-center",
|
||||
action: (
|
||||
<a href="/export" target="_blank" rel="noopener noreferrer">
|
||||
<Button>
|
||||
{t("export.toast.view", { ns: "components/dialog" })}
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
});
|
||||
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
|
||||
setName("");
|
||||
setSelectedCaseId(undefined);
|
||||
setSingleNewCaseName("");
|
||||
setSingleNewCaseDescription("");
|
||||
setRange(undefined);
|
||||
setMode("none");
|
||||
return true;
|
||||
} 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",
|
||||
},
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setIsStartingExport(false);
|
||||
}
|
||||
}, [
|
||||
camera,
|
||||
isStartingExport,
|
||||
name,
|
||||
range,
|
||||
selectedCaseId,
|
||||
singleNewCaseDescription,
|
||||
singleNewCaseName,
|
||||
setRange,
|
||||
setMode,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onStartDebugReplay = useCallback(async () => {
|
||||
if (
|
||||
@@ -267,6 +314,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label={t("export")}
|
||||
onClick={() => {
|
||||
setExportTab("export");
|
||||
setDrawerMode("export");
|
||||
setMode("select");
|
||||
}}
|
||||
@@ -331,14 +379,21 @@ export default function MobileReviewSettingsDrawer({
|
||||
range={range}
|
||||
name={name}
|
||||
selectedCaseId={selectedCaseId}
|
||||
singleNewCaseName={singleNewCaseName}
|
||||
singleNewCaseDescription={singleNewCaseDescription}
|
||||
activeTab={exportTab}
|
||||
isStartingExport={isStartingExport}
|
||||
onStartExport={onStartExport}
|
||||
setActiveTab={setExportTab}
|
||||
setName={setName}
|
||||
setSelectedCaseId={setSelectedCaseId}
|
||||
setSingleNewCaseName={setSingleNewCaseName}
|
||||
setSingleNewCaseDescription={setSingleNewCaseDescription}
|
||||
setRange={setRange}
|
||||
setMode={(mode) => {
|
||||
setMode(mode);
|
||||
|
||||
if (mode == "timeline") {
|
||||
if (mode == "timeline" || mode == "timeline_multi") {
|
||||
setDrawerMode("none");
|
||||
}
|
||||
}}
|
||||
@@ -346,6 +401,9 @@ export default function MobileReviewSettingsDrawer({
|
||||
setMode("none");
|
||||
setRange(undefined);
|
||||
setSelectedCaseId(undefined);
|
||||
setSingleNewCaseName("");
|
||||
setSingleNewCaseDescription("");
|
||||
setExportTab("export");
|
||||
setDrawerMode("select");
|
||||
}}
|
||||
/>
|
||||
@@ -483,9 +541,29 @@ export default function MobileReviewSettingsDrawer({
|
||||
<>
|
||||
<SaveExportOverlay
|
||||
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
|
||||
show={mode == "timeline"}
|
||||
onSave={() => onStartExport()}
|
||||
onCancel={() => setMode("none")}
|
||||
show={mode == "timeline" || mode == "timeline_multi"}
|
||||
hidePreview={mode == "timeline_multi"}
|
||||
isSaving={isStartingExport}
|
||||
saveLabel={
|
||||
mode == "timeline_multi"
|
||||
? t("export.fromTimeline.useThisRange", { ns: "components/dialog" })
|
||||
: undefined
|
||||
}
|
||||
onSave={() => {
|
||||
if (mode == "timeline_multi") {
|
||||
setExportTab("multi");
|
||||
setDrawerMode("export");
|
||||
setMode("select");
|
||||
return;
|
||||
}
|
||||
|
||||
void onStartExport();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setExportTab("export");
|
||||
setRange(undefined);
|
||||
setMode("none");
|
||||
}}
|
||||
onPreview={() => setShowExportPreview(true)}
|
||||
/>
|
||||
<SaveDebugReplayOverlay
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
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 NONE_CASE_OPTION = "none";
|
||||
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);
|
||||
const [caseSelection, setCaseSelection] = useState<string>(NONE_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(NONE_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(NONE_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 (!isAdmin) return true;
|
||||
if (isNewCase) {
|
||||
return newCaseName.trim().length > 0;
|
||||
}
|
||||
return caseSelection.length > 0;
|
||||
}, [caseSelection, count, isAdmin, 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 };
|
||||
|
||||
if (isAdmin && caseSelection !== NONE_CASE_OPTION) {
|
||||
if (isNewCase) {
|
||||
payload.new_case_name = newCaseName.trim();
|
||||
payload.new_case_description = newCaseDescription.trim() || undefined;
|
||||
} else {
|
||||
payload.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(
|
||||
isAdmin
|
||||
? "export.multi.toast.started"
|
||||
: "export.multi.toast.startedNoCase",
|
||||
{
|
||||
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,
|
||||
isAdmin,
|
||||
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>
|
||||
<SelectItem value={NONE_CASE_OPTION}>
|
||||
{t("label.none", { ns: "common" })}
|
||||
</SelectItem>
|
||||
{existingCases.map((caseItem) => (
|
||||
<SelectItem key={caseItem.id} value={caseItem.id}>
|
||||
{caseItem.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<SelectItem value={NEW_CASE_OPTION}>
|
||||
{t("export.case.newCaseOption")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isNewCase && 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>
|
||||
{isAdmin
|
||||
? t("export.multi.description")
|
||||
: t("export.multi.descriptionNoCase")}
|
||||
</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>
|
||||
{isAdmin
|
||||
? t("export.multi.description")
|
||||
: t("export.multi.descriptionNoCase")}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
{body}
|
||||
<div className="mt-4 flex flex-col-reverse gap-2">{footer}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import { useTranslation } from "react-i18next";
|
||||
type SaveExportOverlayProps = {
|
||||
className: string;
|
||||
show: boolean;
|
||||
hidePreview?: boolean;
|
||||
saveLabel?: string;
|
||||
isSaving?: boolean;
|
||||
onPreview: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -14,6 +17,9 @@ type SaveExportOverlayProps = {
|
||||
export default function SaveExportOverlay({
|
||||
className,
|
||||
show,
|
||||
hidePreview = false,
|
||||
saveLabel,
|
||||
isSaving = false,
|
||||
onPreview,
|
||||
onSave,
|
||||
onCancel,
|
||||
@@ -32,29 +38,36 @@ export default function SaveExportOverlay({
|
||||
className="flex items-center gap-1 text-primary"
|
||||
aria-label={t("button.cancel", { ns: "common" })}
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<LuX />
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
{!hidePreview && (
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label={t("export.fromTimeline.previewExport")}
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
onClick={onPreview}
|
||||
>
|
||||
<LuVideo />
|
||||
{t("export.fromTimeline.previewExport")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label={t("export.fromTimeline.previewExport")}
|
||||
size="sm"
|
||||
onClick={onPreview}
|
||||
>
|
||||
<LuVideo />
|
||||
{t("export.fromTimeline.previewExport")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-1"
|
||||
aria-label={t("export.fromTimeline.saveExport")}
|
||||
aria-label={saveLabel || t("export.fromTimeline.saveExport")}
|
||||
variant="select"
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
<FaCompactDisc />
|
||||
{t("export.fromTimeline.saveExport")}
|
||||
{isSaving
|
||||
? t("export.fromTimeline.queueingExport")
|
||||
: saveLabel || t("export.fromTimeline.saveExport")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -15,9 +16,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Option = {
|
||||
@@ -35,8 +37,8 @@ type OptionAndInputDialogProps = {
|
||||
nameLabel: string;
|
||||
descriptionLabel: string;
|
||||
setOpen: (open: boolean) => void;
|
||||
onSave: (value: string) => void;
|
||||
onCreateNew: (name: string, description: string) => void;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
onCreateNew: (name: string, description: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export default function OptionAndInputDialog({
|
||||
@@ -69,10 +71,12 @@ export default function OptionAndInputDialog({
|
||||
}
|
||||
}, [open, initialValue, firstOption]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const isNew = selectedValue === newValueKey;
|
||||
const disableSave = !selectedValue || (isNew && name.trim().length === 0);
|
||||
const disableSave =
|
||||
!selectedValue || (isNew && name.trim().length === 0) || isLoading;
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selectedValue) {
|
||||
return;
|
||||
}
|
||||
@@ -80,13 +84,26 @@ export default function OptionAndInputDialog({
|
||||
const trimmedName = name.trim();
|
||||
const trimmedDescription = descriptionValue.trim();
|
||||
|
||||
if (isNew) {
|
||||
onCreateNew(trimmedName, trimmedDescription);
|
||||
} else {
|
||||
onSave(selectedValue);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (isNew) {
|
||||
await onCreateNew(trimmedName, trimmedDescription);
|
||||
} else {
|
||||
await onSave(selectedValue);
|
||||
}
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
}, [
|
||||
selectedValue,
|
||||
name,
|
||||
descriptionValue,
|
||||
isNew,
|
||||
onCreateNew,
|
||||
onSave,
|
||||
setOpen,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
|
||||
@@ -127,15 +144,21 @@ export default function OptionAndInputDialog({
|
||||
<label className="text-sm font-medium text-secondary-foreground">
|
||||
{nameLabel}
|
||||
</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input
|
||||
className="text-md"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-secondary-foreground">
|
||||
{descriptionLabel}
|
||||
</label>
|
||||
<Input
|
||||
<Textarea
|
||||
className="text-md"
|
||||
value={descriptionValue}
|
||||
onChange={(e) => setDescriptionValue(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,6 +168,7 @@ export default function OptionAndInputDialog({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
@@ -155,9 +179,13 @@ export default function OptionAndInputDialog({
|
||||
type="button"
|
||||
variant="select"
|
||||
disabled={disableSave}
|
||||
onClick={handleSave}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{t("button.save")}
|
||||
{isLoading ? (
|
||||
<ActivityIndicator className="size-4" />
|
||||
) : (
|
||||
t("button.save")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user