mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
frontend
- bulk selection like Review - gate admin-only actions - consolidate dialogs - spacing/padding tweaks
This commit is contained in:
parent
0f2c461cd2
commit
317ca7bb1c
@ -1,6 +1,6 @@
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { Button } from "../ui/button";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { FiMoreVertical } from "react-icons/fi";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
@ -30,6 +30,7 @@ import {
|
||||
import { FaFolder, FaVideo } from "react-icons/fa";
|
||||
import { HiSquare2Stack } from "react-icons/hi2";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import useContextMenu from "@/hooks/use-contextmenu";
|
||||
|
||||
type CaseCardProps = {
|
||||
className: string;
|
||||
@ -100,7 +101,10 @@ export function CaseCard({
|
||||
type ExportCardProps = {
|
||||
className: string;
|
||||
exportedRecording: Export;
|
||||
isSelected?: boolean;
|
||||
selectionMode?: boolean;
|
||||
onSelect: (selected: Export) => void;
|
||||
onContextSelect?: (selected: Export) => void;
|
||||
onRename: (original: string, update: string) => void;
|
||||
onDelete: ({ file, exportName }: DeleteClipType) => void;
|
||||
onAssignToCase?: (selected: Export) => void;
|
||||
@ -109,7 +113,10 @@ type ExportCardProps = {
|
||||
export function ExportCard({
|
||||
className,
|
||||
exportedRecording,
|
||||
isSelected,
|
||||
selectionMode,
|
||||
onSelect,
|
||||
onContextSelect,
|
||||
onRename,
|
||||
onDelete,
|
||||
onAssignToCase,
|
||||
@ -121,6 +128,15 @@ export function ExportCard({
|
||||
exportedRecording.thumb_path.length > 0,
|
||||
);
|
||||
|
||||
// selection
|
||||
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
useContextMenu(cardRef, () => {
|
||||
if (!exportedRecording.in_progress && onContextSelect) {
|
||||
onContextSelect(exportedRecording);
|
||||
}
|
||||
});
|
||||
|
||||
// editing name
|
||||
|
||||
const [editName, setEditName] = useState<{
|
||||
@ -209,14 +225,19 @@ export function ExportCard({
|
||||
</Dialog>
|
||||
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={cn(
|
||||
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (!exportedRecording.in_progress) {
|
||||
if ((selectionMode || e.ctrlKey || e.metaKey) && onContextSelect) {
|
||||
onContextSelect(exportedRecording);
|
||||
} else {
|
||||
onSelect(exportedRecording);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{exportedRecording.in_progress ? (
|
||||
@ -234,7 +255,7 @@ export function ExportCard({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!exportedRecording.in_progress && (
|
||||
{!exportedRecording.in_progress && !selectionMode && (
|
||||
<div className="absolute bottom-2 right-3 z-40">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
@ -333,6 +354,14 @@ export function ExportCard({
|
||||
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
||||
)}
|
||||
<ImageShadowOverlay />
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 z-10 size-full rounded-lg outline outline-[3px] -outline-offset-[2.8px] md:rounded-2xl",
|
||||
isSelected
|
||||
? "shadow-selected outline-selected"
|
||||
: "outline-transparent duration-500",
|
||||
)}
|
||||
/>
|
||||
<div className="absolute bottom-2 left-3 right-12 z-30 text-white">
|
||||
<div className="truncate smart-capitalize">
|
||||
{exportedRecording.name.replaceAll("_", " ")}
|
||||
|
||||
384
web/src/components/filter/ExportActionGroup.tsx
Normal file
384
web/src/components/filter/ExportActionGroup.tsx
Normal file
@ -0,0 +1,384 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { HiTrash } from "react-icons/hi";
|
||||
import { LuFolderPlus, LuFolderX } from "react-icons/lu";
|
||||
import { Export, ExportCase } from "@/types/export";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../ui/alert-dialog";
|
||||
import { Label } from "../ui/label";
|
||||
import { Switch } from "../ui/switch";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import OptionAndInputDialog from "../overlay/dialog/OptionAndInputDialog";
|
||||
|
||||
type ExportActionGroupProps = {
|
||||
selectedExports: Export[];
|
||||
setSelectedExports: (exports: Export[]) => void;
|
||||
context: "uncategorized" | "case";
|
||||
cases?: ExportCase[];
|
||||
currentCaseId?: string;
|
||||
mutate: () => void;
|
||||
};
|
||||
export default function ExportActionGroup({
|
||||
selectedExports,
|
||||
setSelectedExports,
|
||||
context,
|
||||
cases,
|
||||
currentCaseId,
|
||||
mutate,
|
||||
}: ExportActionGroupProps) {
|
||||
const { t } = useTranslation(["views/exports", "common"]);
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const onClearSelected = useCallback(() => {
|
||||
setSelectedExports([]);
|
||||
}, [setSelectedExports]);
|
||||
|
||||
// ── Delete ──────────────────────────────────────────────────────
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
const ids = selectedExports.map((e) => e.id);
|
||||
axios
|
||||
.post("exports/delete", { ids })
|
||||
.then((resp) => {
|
||||
if (resp.status === 200) {
|
||||
toast.success(t("bulkToast.success.delete"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setSelectedExports([]);
|
||||
mutate();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(t("bulkToast.error.deleteFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [selectedExports, setSelectedExports, mutate, t]);
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [bypassDialog, setBypassDialog] = useState(false);
|
||||
|
||||
useKeyboardListener(["Shift"], (_, modifiers) => {
|
||||
setBypassDialog(modifiers.shift);
|
||||
return false;
|
||||
});
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (bypassDialog) {
|
||||
onDelete();
|
||||
} else {
|
||||
setDeleteDialogOpen(true);
|
||||
}
|
||||
}, [bypassDialog, onDelete]);
|
||||
|
||||
// ── Remove from case ────────────────────────────────────────────
|
||||
|
||||
const [removeDialogOpen, setRemoveDialogOpen] = useState(false);
|
||||
const [deleteExportsOnRemove, setDeleteExportsOnRemove] = useState(false);
|
||||
|
||||
const handleRemoveFromCase = useCallback(() => {
|
||||
const ids = selectedExports.map((e) => e.id);
|
||||
|
||||
const request = deleteExportsOnRemove
|
||||
? axios.post("exports/delete", { ids })
|
||||
: axios.post("exports/reassign", { ids, export_case_id: null });
|
||||
|
||||
request
|
||||
.then((resp) => {
|
||||
if (resp.status === 200) {
|
||||
toast.success(t("bulkToast.success.remove"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setSelectedExports([]);
|
||||
mutate();
|
||||
setRemoveDialogOpen(false);
|
||||
setDeleteExportsOnRemove(false);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.detail ||
|
||||
"Unknown error";
|
||||
toast.error(t("bulkToast.error.reassignFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
});
|
||||
}, [selectedExports, deleteExportsOnRemove, setSelectedExports, mutate, t]);
|
||||
|
||||
// ── Case picker ─────────────────────────────────────────────────
|
||||
|
||||
const [casePickerOpen, setCasePickerOpen] = useState(false);
|
||||
|
||||
const caseOptions = useMemo(
|
||||
() => [
|
||||
...(cases ?? [])
|
||||
.filter((c) => c.id !== currentCaseId)
|
||||
.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
{
|
||||
value: "new",
|
||||
label: t("caseDialog.newCaseOption"),
|
||||
},
|
||||
],
|
||||
[cases, currentCaseId, t],
|
||||
);
|
||||
|
||||
const handleAssignToCase = useCallback(
|
||||
async (caseId: string) => {
|
||||
const ids = selectedExports.map((e) => e.id);
|
||||
try {
|
||||
await axios.post("exports/reassign", {
|
||||
ids,
|
||||
export_case_id: caseId,
|
||||
});
|
||||
toast.success(t("bulkToast.success.reassign"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setSelectedExports([]);
|
||||
mutate();
|
||||
} 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("bulkToast.error.reassignFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[selectedExports, setSelectedExports, mutate, t],
|
||||
);
|
||||
|
||||
const handleCreateNewCase = useCallback(
|
||||
async (name: string, description: string) => {
|
||||
const ids = selectedExports.map((e) => e.id);
|
||||
try {
|
||||
const createResp = await axios.post("cases", { name, description });
|
||||
const newCaseId: string | undefined = createResp.data?.id;
|
||||
|
||||
if (newCaseId) {
|
||||
await axios.post("exports/reassign", {
|
||||
ids,
|
||||
export_case_id: newCaseId,
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(t("bulkToast.success.reassign"), {
|
||||
position: "top-center",
|
||||
});
|
||||
setSelectedExports([]);
|
||||
mutate();
|
||||
} 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("bulkToast.error.reassignFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[selectedExports, setSelectedExports, mutate, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Delete confirmation dialog */}
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={() => setDeleteDialogOpen(!deleteDialogOpen)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("bulkDelete.title")}</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>
|
||||
{t("bulkDelete.desc", { count: selectedExports.length })}
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Remove from case dialog */}
|
||||
{context === "case" && (
|
||||
<AlertDialog
|
||||
open={removeDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setRemoveDialogOpen(false);
|
||||
setDeleteExportsOnRemove(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("bulkRemoveFromCase.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("bulkRemoveFromCase.desc", {
|
||||
count: selectedExports.length,
|
||||
})}{" "}
|
||||
{deleteExportsOnRemove
|
||||
? t("bulkRemoveFromCase.descDeleteExports")
|
||||
: t("bulkRemoveFromCase.descKeepExports")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex items-center justify-start gap-6">
|
||||
<Label
|
||||
htmlFor="bulk-delete-exports-switch"
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
{t("bulkRemoveFromCase.deleteExports")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="bulk-delete-exports-switch"
|
||||
checked={deleteExportsOnRemove}
|
||||
onCheckedChange={setDeleteExportsOnRemove}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={handleRemoveFromCase}
|
||||
>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{/* Case picker dialog */}
|
||||
<OptionAndInputDialog
|
||||
open={casePickerOpen}
|
||||
title={t("caseDialog.title")}
|
||||
description={t("caseDialog.description")}
|
||||
setOpen={setCasePickerOpen}
|
||||
options={caseOptions}
|
||||
nameLabel={t("caseDialog.nameLabel")}
|
||||
descriptionLabel={t("caseDialog.descriptionLabel")}
|
||||
initialValue={caseOptions[0]?.value}
|
||||
newValueKey="new"
|
||||
onSave={handleAssignToCase}
|
||||
onCreateNew={handleCreateNewCase}
|
||||
/>
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
<div className="mx-1 flex items-center justify-center text-sm text-muted-foreground">
|
||||
<div className="p-1">
|
||||
{t("selected", { count: selectedExports.length })}
|
||||
</div>
|
||||
<div className="p-1">{"|"}</div>
|
||||
<div
|
||||
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
|
||||
onClick={onClearSelected}
|
||||
>
|
||||
{t("button.unselect", { ns: "common" })}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
{/* Add to Case / Move to Case */}
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label={
|
||||
context === "case"
|
||||
? t("bulkActions.moveToCase")
|
||||
: t("bulkActions.addToCase")
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => setCasePickerOpen(true)}
|
||||
>
|
||||
<LuFolderPlus className="text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{context === "case"
|
||||
? t("bulkActions.moveToCase")
|
||||
: t("bulkActions.addToCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Remove from Case (case context only) */}
|
||||
{context === "case" && (
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label={t("bulkActions.removeFromCase")}
|
||||
size="sm"
|
||||
onClick={() => setRemoveDialogOpen(true)}
|
||||
>
|
||||
<LuFolderX className="text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{t("bulkActions.removeFromCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<HiTrash className="text-secondary-foreground" />
|
||||
{isDesktop && (
|
||||
<div className="text-primary">
|
||||
{bypassDialog
|
||||
? t("bulkActions.deleteNow")
|
||||
: t("bulkActions.delete")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -567,12 +567,14 @@ export function ExportContent({
|
||||
})),
|
||||
};
|
||||
|
||||
if (isAdmin) {
|
||||
if (batchCaseSelection === "new") {
|
||||
payload.new_case_name = newCaseName.trim();
|
||||
payload.new_case_description = newCaseDescription.trim() || undefined;
|
||||
} else {
|
||||
payload.export_case_id = batchCaseSelection;
|
||||
}
|
||||
}
|
||||
|
||||
setIsStartingBatchExport(true);
|
||||
|
||||
@ -661,6 +663,7 @@ export function ExportContent({
|
||||
}, [
|
||||
batchCaseSelection,
|
||||
config,
|
||||
isAdmin,
|
||||
isStartingBatchExport,
|
||||
name,
|
||||
newCaseDescription,
|
||||
@ -931,12 +934,11 @@ export function ExportContent({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-secondary-foreground">
|
||||
{t("export.case.label")}
|
||||
</Label>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<Select
|
||||
value={batchCaseSelection}
|
||||
onValueChange={(value) => setBatchCaseSelection(value)}
|
||||
@ -960,30 +962,6 @@ 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")}
|
||||
@ -1001,6 +979,7 @@ export function ExportContent({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@ -165,11 +165,12 @@ export default function MultiExportDialog({
|
||||
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, isExporting, isNewCase, newCaseName]);
|
||||
}, [caseSelection, count, isAdmin, isExporting, isNewCase, newCaseName]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!canSubmit) return;
|
||||
@ -182,15 +183,16 @@ export default function MultiExportDialog({
|
||||
client_item_id: review.id,
|
||||
}));
|
||||
|
||||
const payload: BatchExportBody = {
|
||||
items,
|
||||
...(isNewCase
|
||||
? {
|
||||
new_case_name: newCaseName.trim(),
|
||||
new_case_description: newCaseDescription.trim() || undefined,
|
||||
const payload: BatchExportBody = { items };
|
||||
|
||||
if (isAdmin) {
|
||||
if (isNewCase) {
|
||||
payload.new_case_name = newCaseName.trim();
|
||||
payload.new_case_description = newCaseDescription.trim() || undefined;
|
||||
} else {
|
||||
payload.export_case_id = caseSelection;
|
||||
}
|
||||
}
|
||||
: { export_case_id: caseSelection }),
|
||||
};
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
@ -205,10 +207,15 @@ export default function MultiExportDialog({
|
||||
|
||||
if (successful.length > 0 && failed.length === 0) {
|
||||
toast.success(
|
||||
t("export.multi.toast.started", {
|
||||
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) {
|
||||
@ -267,6 +274,7 @@ export default function MultiExportDialog({
|
||||
canSubmit,
|
||||
caseSelection,
|
||||
formatFailureLabel,
|
||||
isAdmin,
|
||||
isNewCase,
|
||||
navigate,
|
||||
newCaseDescription,
|
||||
@ -302,7 +310,7 @@ export default function MultiExportDialog({
|
||||
|
||||
const body = (
|
||||
<div className="flex flex-col gap-4">
|
||||
{isAdmin ? (
|
||||
{isAdmin && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-secondary-foreground">
|
||||
{t("export.case.label")}
|
||||
@ -328,16 +336,6 @@ export default function MultiExportDialog({
|
||||
</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>
|
||||
);
|
||||
@ -372,7 +370,9 @@ export default function MultiExportDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("export.multi.title", { count })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("export.multi.description")}
|
||||
{isAdmin
|
||||
? t("export.multi.description")
|
||||
: t("export.multi.descriptionNoCase")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{body}
|
||||
@ -388,7 +388,11 @@ export default function MultiExportDialog({
|
||||
<DrawerContent className="px-4 pb-6">
|
||||
<DrawerHeader className="px-0">
|
||||
<DrawerTitle>{t("export.multi.title", { count })}</DrawerTitle>
|
||||
<DrawerDescription>{t("export.multi.description")}</DrawerDescription>
|
||||
<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>
|
||||
|
||||
@ -16,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 = {
|
||||
@ -36,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({
|
||||
@ -70,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;
|
||||
}
|
||||
@ -81,13 +84,26 @@ export default function OptionAndInputDialog({
|
||||
const trimmedName = name.trim();
|
||||
const trimmedDescription = descriptionValue.trim();
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (isNew) {
|
||||
onCreateNew(trimmedName, trimmedDescription);
|
||||
await onCreateNew(trimmedName, trimmedDescription);
|
||||
} else {
|
||||
onSave(selectedValue);
|
||||
await onSave(selectedValue);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [
|
||||
selectedValue,
|
||||
name,
|
||||
descriptionValue,
|
||||
isNew,
|
||||
onCreateNew,
|
||||
onSave,
|
||||
setOpen,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
|
||||
@ -128,13 +144,18 @@ 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>
|
||||
<Textarea
|
||||
className="text-md"
|
||||
value={descriptionValue}
|
||||
onChange={(e) => setDescriptionValue(e.target.value)}
|
||||
rows={2}
|
||||
@ -147,6 +168,7 @@ export default function OptionAndInputDialog({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
@ -157,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>
|
||||
|
||||
@ -64,13 +64,16 @@ import {
|
||||
} from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import ExportActionGroup from "@/components/filter/ExportActionGroup";
|
||||
import ExportFilterGroup from "@/components/filter/ExportFilterGroup";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
|
||||
// always parse these as string arrays
|
||||
const EXPORT_FILTER_ARRAY_KEYS = ["cameras"];
|
||||
|
||||
function Exports() {
|
||||
const { t } = useTranslation(["views/exports"]);
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle");
|
||||
@ -224,6 +227,54 @@ function Exports() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Bulk selection
|
||||
|
||||
const [selectedExports, setSelectedExports] = useState<Export[]>([]);
|
||||
const selectionMode = selectedExports.length > 0;
|
||||
|
||||
// Clear selection when switching views
|
||||
useEffect(() => {
|
||||
setSelectedExports([]);
|
||||
}, [selectedCaseId]);
|
||||
|
||||
const onSelectExport = useCallback(
|
||||
(exportItem: Export) => {
|
||||
const index = selectedExports.findIndex((e) => e.id === exportItem.id);
|
||||
if (index !== -1) {
|
||||
if (selectedExports.length === 1) {
|
||||
setSelectedExports([]);
|
||||
} else {
|
||||
setSelectedExports([
|
||||
...selectedExports.slice(0, index),
|
||||
...selectedExports.slice(index + 1),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
setSelectedExports([...selectedExports, exportItem]);
|
||||
}
|
||||
},
|
||||
[selectedExports],
|
||||
);
|
||||
|
||||
const onSelectAllExports = useCallback(() => {
|
||||
const currentExports = selectedCaseId
|
||||
? exportsByCase[selectedCaseId] || []
|
||||
: exports;
|
||||
const visibleExports = currentExports.filter((e) => {
|
||||
if (e.in_progress) return false;
|
||||
if (!search) return true;
|
||||
return e.name
|
||||
.toLowerCase()
|
||||
.replaceAll("_", " ")
|
||||
.includes(search.toLowerCase());
|
||||
});
|
||||
if (selectedExports.length < visibleExports.length) {
|
||||
setSelectedExports(visibleExports);
|
||||
} else {
|
||||
setSelectedExports([]);
|
||||
}
|
||||
}, [selectedCaseId, exportsByCase, exports, search, selectedExports]);
|
||||
|
||||
// Modifying
|
||||
|
||||
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
||||
@ -242,7 +293,9 @@ function Exports() {
|
||||
return;
|
||||
}
|
||||
|
||||
axios.delete(`export/${deleteClip.file}`).then((response) => {
|
||||
axios
|
||||
.post("exports/delete", { ids: [deleteClip.file] })
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
setDeleteClip(undefined);
|
||||
mutate();
|
||||
@ -278,7 +331,27 @@ function Exports() {
|
||||
// Keyboard Listener
|
||||
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
useKeyboardListener([], undefined, contentRef);
|
||||
useKeyboardListener(
|
||||
["a", "Escape"],
|
||||
(key, modifiers) => {
|
||||
if (!modifiers.down) return true;
|
||||
|
||||
switch (key) {
|
||||
case "a":
|
||||
if (modifiers.ctrl && !modifiers.repeat) {
|
||||
onSelectAllExports();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
setSelectedExports([]);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
contentRef,
|
||||
);
|
||||
|
||||
const selectedCase = useMemo(
|
||||
() => cases?.find((c) => c.id === selectedCaseId),
|
||||
@ -372,33 +445,11 @@ function Exports() {
|
||||
}
|
||||
}, [caseToDelete, deleteExportsWithCase, mutate, selectedCaseId, t]);
|
||||
|
||||
const handleAssignExportToCase = useCallback(
|
||||
async (exportId: string, caseId: string) => {
|
||||
try {
|
||||
await axios.patch(`export/${exportId}/case`, {
|
||||
export_case_id: caseId,
|
||||
});
|
||||
mutate();
|
||||
} 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("toast.error.assignCaseFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
},
|
||||
[mutate, t],
|
||||
);
|
||||
|
||||
const handleRemoveExportFromCase = useCallback(
|
||||
async (exportedRecording: Export) => {
|
||||
try {
|
||||
await axios.patch(`export/${exportedRecording.id}/case`, {
|
||||
await axios.post("exports/reassign", {
|
||||
ids: [exportedRecording.id],
|
||||
export_case_id: null,
|
||||
});
|
||||
mutate();
|
||||
@ -436,7 +487,7 @@ function Exports() {
|
||||
exportCase={caseForAddExport}
|
||||
availableExports={uncategorizedExports}
|
||||
onClose={() => setCaseForAddExport(undefined)}
|
||||
onAssign={handleAssignExportToCase}
|
||||
mutate={mutate}
|
||||
/>
|
||||
|
||||
<CaseAssignmentDialog
|
||||
@ -570,6 +621,17 @@ function Exports() {
|
||||
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
|
||||
)}
|
||||
>
|
||||
{selectionMode ? (
|
||||
<ExportActionGroup
|
||||
selectedExports={selectedExports}
|
||||
setSelectedExports={setSelectedExports}
|
||||
context={selectedCase ? "case" : "uncategorized"}
|
||||
cases={cases}
|
||||
currentCaseId={selectedCaseId}
|
||||
mutate={mutate}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{selectedCase && (
|
||||
<Button
|
||||
@ -594,22 +656,24 @@ function Exports() {
|
||||
/>
|
||||
</div>
|
||||
{!selectedCase && (
|
||||
<div className="flex w-full items-center justify-between gap-2 md:justify-start lg:justify-end">
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
<ExportFilterGroup
|
||||
className="justify-start"
|
||||
filter={exportFilter}
|
||||
filters={["cameras"]}
|
||||
onUpdateFilter={setExportFilter}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
className="flex items-center gap-2.5 rounded-lg"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setCaseDialog({ mode: "create" })}
|
||||
>
|
||||
<LuFolderPlus className="size-4 text-secondary-foreground" />
|
||||
<LuFolderPlus className="text-secondary-foreground" />
|
||||
<div className="text-primary">{t("toolbar.newCase")}</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedCase && (
|
||||
@ -620,6 +684,7 @@ function Exports() {
|
||||
filters={["cameras"]}
|
||||
onUpdateFilter={setExportFilter}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
@ -627,9 +692,11 @@ function Exports() {
|
||||
aria-label={t("toolbar.addExport")}
|
||||
onClick={() => setCaseForAddExport(selectedCase)}
|
||||
>
|
||||
<LuPlus className="size-4 text-secondary-foreground" />
|
||||
<LuPlus className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">{t("toolbar.addExport")}</div>
|
||||
<div className="text-primary">
|
||||
{t("toolbar.addExport")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
@ -637,12 +704,17 @@ function Exports() {
|
||||
size="sm"
|
||||
aria-label={t("toolbar.editCase")}
|
||||
onClick={() =>
|
||||
setCaseDialog({ mode: "edit", exportCase: selectedCase })
|
||||
setCaseDialog({
|
||||
mode: "edit",
|
||||
exportCase: selectedCase,
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuPencil className="size-4 text-secondary-foreground" />
|
||||
<LuPencil className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">{t("toolbar.editCase")}</div>
|
||||
<div className="text-primary">
|
||||
{t("toolbar.editCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
@ -651,14 +723,19 @@ function Exports() {
|
||||
aria-label={t("toolbar.deleteCase")}
|
||||
onClick={() => setCaseToDelete(selectedCase)}
|
||||
>
|
||||
<LuTrash2 className="size-4 text-secondary-foreground" />
|
||||
<LuTrash2 className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">{t("toolbar.deleteCase")}</div>
|
||||
<div className="text-primary">
|
||||
{t("toolbar.deleteCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedCase ? (
|
||||
@ -669,6 +746,9 @@ function Exports() {
|
||||
availableExports={uncategorizedExports}
|
||||
activeJobs={activeJobsByCase[selectedCase.id] || []}
|
||||
search={search}
|
||||
selectedExports={selectedExports}
|
||||
selectionMode={selectionMode}
|
||||
onSelectExport={onSelectExport}
|
||||
setSelected={setSelected}
|
||||
renameClip={onHandleRename}
|
||||
setDeleteClip={setDeleteClip}
|
||||
@ -684,6 +764,9 @@ function Exports() {
|
||||
exports={exports}
|
||||
exportsByCase={exportsByCase}
|
||||
activeJobs={activeJobsByCase["none"] || []}
|
||||
selectedExports={selectedExports}
|
||||
selectionMode={selectionMode}
|
||||
onSelectExport={onSelectExport}
|
||||
setSelectedCaseId={setSelectedCaseId}
|
||||
setSelected={setSelected}
|
||||
renameClip={onHandleRename}
|
||||
@ -702,6 +785,9 @@ type AllExportsViewProps = {
|
||||
exports: Export[];
|
||||
exportsByCase: { [caseId: string]: Export[] };
|
||||
activeJobs: ExportJob[];
|
||||
selectedExports: Export[];
|
||||
selectionMode: boolean;
|
||||
onSelectExport: (e: Export) => void;
|
||||
setSelectedCaseId: (id: string) => void;
|
||||
setSelected: (e: Export) => void;
|
||||
renameClip: (id: string, update: string) => void;
|
||||
@ -715,6 +801,9 @@ function AllExportsView({
|
||||
exports,
|
||||
exportsByCase,
|
||||
activeJobs,
|
||||
selectedExports,
|
||||
selectionMode,
|
||||
onSelectExport,
|
||||
setSelectedCaseId,
|
||||
setSelected,
|
||||
renameClip,
|
||||
@ -807,6 +896,9 @@ function AllExportsView({
|
||||
key={item.name}
|
||||
className=""
|
||||
exportedRecording={item}
|
||||
isSelected={selectedExports.some((e) => e.id === item.id)}
|
||||
selectionMode={selectionMode}
|
||||
onContextSelect={onSelectExport}
|
||||
onSelect={setSelected}
|
||||
onRename={renameClip}
|
||||
onDelete={({ file, exportName }) =>
|
||||
@ -836,6 +928,9 @@ type CaseViewProps = {
|
||||
availableExports: Export[];
|
||||
activeJobs: ExportJob[];
|
||||
search: string;
|
||||
selectedExports: Export[];
|
||||
selectionMode: boolean;
|
||||
onSelectExport: (e: Export) => void;
|
||||
setSelected: (e: Export) => void;
|
||||
renameClip: (id: string, update: string) => void;
|
||||
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
||||
@ -850,6 +945,9 @@ function CaseView({
|
||||
availableExports,
|
||||
activeJobs,
|
||||
search,
|
||||
selectedExports,
|
||||
selectionMode,
|
||||
onSelectExport,
|
||||
setSelected,
|
||||
renameClip,
|
||||
setDeleteClip,
|
||||
@ -974,6 +1072,9 @@ function CaseView({
|
||||
key={item.id}
|
||||
className=""
|
||||
exportedRecording={item}
|
||||
isSelected={selectedExports.some((e) => e.id === item.id)}
|
||||
selectionMode={selectionMode}
|
||||
onContextSelect={onSelectExport}
|
||||
onSelect={setSelected}
|
||||
onRename={renameClip}
|
||||
onDelete={({ file, exportName }) =>
|
||||
@ -1076,13 +1177,13 @@ type CaseAddExportDialogProps = {
|
||||
exportCase?: ExportCase;
|
||||
availableExports: Export[];
|
||||
onClose: () => void;
|
||||
onAssign: (exportId: string, caseId: string) => Promise<void>;
|
||||
mutate: () => void;
|
||||
};
|
||||
function CaseAddExportDialog({
|
||||
exportCase,
|
||||
availableExports,
|
||||
onClose,
|
||||
onAssign,
|
||||
mutate,
|
||||
}: CaseAddExportDialogProps) {
|
||||
const { t } = useTranslation(["views/exports", "common"]);
|
||||
const [search, setSearch] = useState("");
|
||||
@ -1125,12 +1226,27 @@ function CaseAddExportDialog({
|
||||
|
||||
setIsAdding(true);
|
||||
try {
|
||||
await Promise.all(selectedIds.map((id) => onAssign(id, exportCase.id)));
|
||||
await axios.post("exports/reassign", {
|
||||
ids: selectedIds,
|
||||
export_case_id: exportCase.id,
|
||||
});
|
||||
mutate();
|
||||
onClose();
|
||||
} 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("toast.error.assignCaseFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
}, [exportCase, isAdding, onAssign, onClose, selectedIds]);
|
||||
}, [exportCase, isAdding, mutate, onClose, selectedIds, t]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@ -1240,7 +1356,8 @@ function CaseAssignmentDialog({
|
||||
if (!exportToAssign) return;
|
||||
|
||||
try {
|
||||
await axios.patch(`export/${exportToAssign.id}/case`, {
|
||||
await axios.post("exports/reassign", {
|
||||
ids: [exportToAssign.id],
|
||||
export_case_id: caseId,
|
||||
});
|
||||
mutate();
|
||||
@ -1256,6 +1373,7 @@ function CaseAssignmentDialog({
|
||||
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[exportToAssign, mutate, onClose, t],
|
||||
@ -1274,7 +1392,8 @@ function CaseAssignmentDialog({
|
||||
const newCaseId: string | undefined = createResp.data?.id;
|
||||
|
||||
if (newCaseId) {
|
||||
await axios.patch(`export/${exportToAssign.id}/case`, {
|
||||
await axios.post("exports/reassign", {
|
||||
ids: [exportToAssign.id],
|
||||
export_case_id: newCaseId,
|
||||
});
|
||||
}
|
||||
@ -1292,6 +1411,7 @@ function CaseAssignmentDialog({
|
||||
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
|
||||
position: "top-center",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[exportToAssign, mutate, onClose, t],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user