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 ActivityIndicator from "../indicators/activity-indicator";
|
||||||
import { Button } from "../ui/button";
|
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 { isMobile } from "react-device-detect";
|
||||||
import { FiMoreVertical } from "react-icons/fi";
|
import { FiMoreVertical } from "react-icons/fi";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { Skeleton } from "../ui/skeleton";
|
||||||
@ -30,6 +30,7 @@ import {
|
|||||||
import { FaFolder, FaVideo } from "react-icons/fa";
|
import { FaFolder, FaVideo } from "react-icons/fa";
|
||||||
import { HiSquare2Stack } from "react-icons/hi2";
|
import { HiSquare2Stack } from "react-icons/hi2";
|
||||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||||
|
import useContextMenu from "@/hooks/use-contextmenu";
|
||||||
|
|
||||||
type CaseCardProps = {
|
type CaseCardProps = {
|
||||||
className: string;
|
className: string;
|
||||||
@ -100,7 +101,10 @@ export function CaseCard({
|
|||||||
type ExportCardProps = {
|
type ExportCardProps = {
|
||||||
className: string;
|
className: string;
|
||||||
exportedRecording: Export;
|
exportedRecording: Export;
|
||||||
|
isSelected?: boolean;
|
||||||
|
selectionMode?: boolean;
|
||||||
onSelect: (selected: Export) => void;
|
onSelect: (selected: Export) => void;
|
||||||
|
onContextSelect?: (selected: Export) => void;
|
||||||
onRename: (original: string, update: string) => void;
|
onRename: (original: string, update: string) => void;
|
||||||
onDelete: ({ file, exportName }: DeleteClipType) => void;
|
onDelete: ({ file, exportName }: DeleteClipType) => void;
|
||||||
onAssignToCase?: (selected: Export) => void;
|
onAssignToCase?: (selected: Export) => void;
|
||||||
@ -109,7 +113,10 @@ type ExportCardProps = {
|
|||||||
export function ExportCard({
|
export function ExportCard({
|
||||||
className,
|
className,
|
||||||
exportedRecording,
|
exportedRecording,
|
||||||
|
isSelected,
|
||||||
|
selectionMode,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onContextSelect,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
onAssignToCase,
|
onAssignToCase,
|
||||||
@ -121,6 +128,15 @@ export function ExportCard({
|
|||||||
exportedRecording.thumb_path.length > 0,
|
exportedRecording.thumb_path.length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// selection
|
||||||
|
|
||||||
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useContextMenu(cardRef, () => {
|
||||||
|
if (!exportedRecording.in_progress && onContextSelect) {
|
||||||
|
onContextSelect(exportedRecording);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// editing name
|
// editing name
|
||||||
|
|
||||||
const [editName, setEditName] = useState<{
|
const [editName, setEditName] = useState<{
|
||||||
@ -209,13 +225,18 @@ export function ExportCard({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref={cardRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
|
"relative flex aspect-video cursor-pointer items-center justify-center rounded-lg bg-black md:rounded-2xl",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
if (!exportedRecording.in_progress) {
|
if (!exportedRecording.in_progress) {
|
||||||
onSelect(exportedRecording);
|
if ((selectionMode || e.ctrlKey || e.metaKey) && onContextSelect) {
|
||||||
|
onContextSelect(exportedRecording);
|
||||||
|
} else {
|
||||||
|
onSelect(exportedRecording);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -234,7 +255,7 @@ export function ExportCard({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!exportedRecording.in_progress && (
|
{!exportedRecording.in_progress && !selectionMode && (
|
||||||
<div className="absolute bottom-2 right-3 z-40">
|
<div className="absolute bottom-2 right-3 z-40">
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
@ -333,6 +354,14 @@ export function ExportCard({
|
|||||||
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
||||||
)}
|
)}
|
||||||
<ImageShadowOverlay />
|
<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="absolute bottom-2 left-3 right-12 z-30 text-white">
|
||||||
<div className="truncate smart-capitalize">
|
<div className="truncate smart-capitalize">
|
||||||
{exportedRecording.name.replaceAll("_", " ")}
|
{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,11 +567,13 @@ export function ExportContent({
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (batchCaseSelection === "new") {
|
if (isAdmin) {
|
||||||
payload.new_case_name = newCaseName.trim();
|
if (batchCaseSelection === "new") {
|
||||||
payload.new_case_description = newCaseDescription.trim() || undefined;
|
payload.new_case_name = newCaseName.trim();
|
||||||
} else {
|
payload.new_case_description = newCaseDescription.trim() || undefined;
|
||||||
payload.export_case_id = batchCaseSelection;
|
} else {
|
||||||
|
payload.export_case_id = batchCaseSelection;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsStartingBatchExport(true);
|
setIsStartingBatchExport(true);
|
||||||
@ -661,6 +663,7 @@ export function ExportContent({
|
|||||||
}, [
|
}, [
|
||||||
batchCaseSelection,
|
batchCaseSelection,
|
||||||
config,
|
config,
|
||||||
|
isAdmin,
|
||||||
isStartingBatchExport,
|
isStartingBatchExport,
|
||||||
name,
|
name,
|
||||||
newCaseDescription,
|
newCaseDescription,
|
||||||
@ -931,76 +934,52 @@ export function ExportContent({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{isAdmin && (
|
||||||
<Label className="text-sm text-secondary-foreground">
|
<div className="space-y-2">
|
||||||
{t("export.case.label")}
|
<Label className="text-sm text-secondary-foreground">
|
||||||
</Label>
|
{t("export.case.label")}
|
||||||
{isAdmin ? (
|
</Label>
|
||||||
<>
|
<Select
|
||||||
<Select
|
value={batchCaseSelection}
|
||||||
value={batchCaseSelection}
|
onValueChange={(value) => setBatchCaseSelection(value)}
|
||||||
onValueChange={(value) => setBatchCaseSelection(value)}
|
>
|
||||||
>
|
<SelectTrigger>
|
||||||
<SelectTrigger>
|
<SelectValue placeholder={t("export.case.placeholder")} />
|
||||||
<SelectValue placeholder={t("export.case.placeholder")} />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
{cases
|
||||||
{cases
|
?.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
?.sort((a, b) => a.name.localeCompare(b.name))
|
.map((caseItem) => (
|
||||||
.map((caseItem) => (
|
<SelectItem key={caseItem.id} value={caseItem.id}>
|
||||||
<SelectItem key={caseItem.id} value={caseItem.id}>
|
{caseItem.name}
|
||||||
{caseItem.name}
|
</SelectItem>
|
||||||
</SelectItem>
|
))}
|
||||||
))}
|
<SelectSeparator />
|
||||||
<SelectSeparator />
|
<SelectItem value="new">
|
||||||
<SelectItem value="new">
|
{t("export.case.newCaseOption")}
|
||||||
{t("export.case.newCaseOption")}
|
</SelectItem>
|
||||||
</SelectItem>
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
{batchCaseSelection === "new" && (
|
||||||
{batchCaseSelection === "new" && (
|
<div className="space-y-2 pt-1">
|
||||||
<div className="space-y-2 pt-1">
|
<Input
|
||||||
<Input
|
className="text-md"
|
||||||
className="text-md"
|
placeholder={t("export.case.newCaseNamePlaceholder")}
|
||||||
placeholder={t("export.case.newCaseNamePlaceholder")}
|
value={newCaseName}
|
||||||
value={newCaseName}
|
onChange={(event) => setNewCaseName(event.target.value)}
|
||||||
onChange={(event) => setNewCaseName(event.target.value)}
|
/>
|
||||||
/>
|
<Textarea
|
||||||
<Textarea
|
className="text-md"
|
||||||
className="text-md"
|
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
|
||||||
placeholder={t(
|
value={newCaseDescription}
|
||||||
"export.case.newCaseDescriptionPlaceholder",
|
onChange={(event) =>
|
||||||
)}
|
setNewCaseDescription(event.target.value)
|
||||||
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>
|
</div>
|
||||||
<Input
|
)}
|
||||||
className="text-md"
|
</div>
|
||||||
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>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@ -165,11 +165,12 @@ export default function MultiExportDialog({
|
|||||||
const canSubmit = useMemo(() => {
|
const canSubmit = useMemo(() => {
|
||||||
if (isExporting) return false;
|
if (isExporting) return false;
|
||||||
if (count === 0) return false;
|
if (count === 0) return false;
|
||||||
|
if (!isAdmin) return true;
|
||||||
if (isNewCase) {
|
if (isNewCase) {
|
||||||
return newCaseName.trim().length > 0;
|
return newCaseName.trim().length > 0;
|
||||||
}
|
}
|
||||||
return caseSelection.length > 0;
|
return caseSelection.length > 0;
|
||||||
}, [caseSelection, count, isExporting, isNewCase, newCaseName]);
|
}, [caseSelection, count, isAdmin, isExporting, isNewCase, newCaseName]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
@ -182,15 +183,16 @@ export default function MultiExportDialog({
|
|||||||
client_item_id: review.id,
|
client_item_id: review.id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const payload: BatchExportBody = {
|
const payload: BatchExportBody = { items };
|
||||||
items,
|
|
||||||
...(isNewCase
|
if (isAdmin) {
|
||||||
? {
|
if (isNewCase) {
|
||||||
new_case_name: newCaseName.trim(),
|
payload.new_case_name = newCaseName.trim();
|
||||||
new_case_description: newCaseDescription.trim() || undefined,
|
payload.new_case_description = newCaseDescription.trim() || undefined;
|
||||||
}
|
} else {
|
||||||
: { export_case_id: caseSelection }),
|
payload.export_case_id = caseSelection;
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
try {
|
try {
|
||||||
@ -205,10 +207,15 @@ export default function MultiExportDialog({
|
|||||||
|
|
||||||
if (successful.length > 0 && failed.length === 0) {
|
if (successful.length > 0 && failed.length === 0) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("export.multi.toast.started", {
|
t(
|
||||||
ns: "components/dialog",
|
isAdmin
|
||||||
count: successful.length,
|
? "export.multi.toast.started"
|
||||||
}),
|
: "export.multi.toast.startedNoCase",
|
||||||
|
{
|
||||||
|
ns: "components/dialog",
|
||||||
|
count: successful.length,
|
||||||
|
},
|
||||||
|
),
|
||||||
{ position: "top-center" },
|
{ position: "top-center" },
|
||||||
);
|
);
|
||||||
} else if (successful.length > 0 && failed.length > 0) {
|
} else if (successful.length > 0 && failed.length > 0) {
|
||||||
@ -267,6 +274,7 @@ export default function MultiExportDialog({
|
|||||||
canSubmit,
|
canSubmit,
|
||||||
caseSelection,
|
caseSelection,
|
||||||
formatFailureLabel,
|
formatFailureLabel,
|
||||||
|
isAdmin,
|
||||||
isNewCase,
|
isNewCase,
|
||||||
navigate,
|
navigate,
|
||||||
newCaseDescription,
|
newCaseDescription,
|
||||||
@ -302,7 +310,7 @@ export default function MultiExportDialog({
|
|||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{isAdmin ? (
|
{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")}
|
||||||
@ -328,16 +336,6 @@ export default function MultiExportDialog({
|
|||||||
</Select>
|
</Select>
|
||||||
{isNewCase && newCaseInputs}
|
{isNewCase && newCaseInputs}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
@ -372,7 +370,9 @@ export default function MultiExportDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("export.multi.title", { count })}</DialogTitle>
|
<DialogTitle>{t("export.multi.title", { count })}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("export.multi.description")}
|
{isAdmin
|
||||||
|
? t("export.multi.description")
|
||||||
|
: t("export.multi.descriptionNoCase")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{body}
|
{body}
|
||||||
@ -388,7 +388,11 @@ export default function MultiExportDialog({
|
|||||||
<DrawerContent className="px-4 pb-6">
|
<DrawerContent className="px-4 pb-6">
|
||||||
<DrawerHeader className="px-0">
|
<DrawerHeader className="px-0">
|
||||||
<DrawerTitle>{t("export.multi.title", { count })}</DrawerTitle>
|
<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>
|
</DrawerHeader>
|
||||||
{body}
|
{body}
|
||||||
<div className="mt-4 flex flex-col-reverse gap-2">{footer}</div>
|
<div className="mt-4 flex flex-col-reverse gap-2">{footer}</div>
|
||||||
|
|||||||
@ -16,9 +16,10 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
type Option = {
|
type Option = {
|
||||||
@ -36,8 +37,8 @@ type OptionAndInputDialogProps = {
|
|||||||
nameLabel: string;
|
nameLabel: string;
|
||||||
descriptionLabel: string;
|
descriptionLabel: string;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
onSave: (value: string) => void;
|
onSave: (value: string) => Promise<void>;
|
||||||
onCreateNew: (name: string, description: string) => void;
|
onCreateNew: (name: string, description: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function OptionAndInputDialog({
|
export default function OptionAndInputDialog({
|
||||||
@ -70,10 +71,12 @@ export default function OptionAndInputDialog({
|
|||||||
}
|
}
|
||||||
}, [open, initialValue, firstOption]);
|
}, [open, initialValue, firstOption]);
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const isNew = selectedValue === newValueKey;
|
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) {
|
if (!selectedValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -81,13 +84,26 @@ export default function OptionAndInputDialog({
|
|||||||
const trimmedName = name.trim();
|
const trimmedName = name.trim();
|
||||||
const trimmedDescription = descriptionValue.trim();
|
const trimmedDescription = descriptionValue.trim();
|
||||||
|
|
||||||
if (isNew) {
|
setIsLoading(true);
|
||||||
onCreateNew(trimmedName, trimmedDescription);
|
try {
|
||||||
} else {
|
if (isNew) {
|
||||||
onSave(selectedValue);
|
await onCreateNew(trimmedName, trimmedDescription);
|
||||||
|
} else {
|
||||||
|
await onSave(selectedValue);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
setOpen(false);
|
}, [
|
||||||
};
|
selectedValue,
|
||||||
|
name,
|
||||||
|
descriptionValue,
|
||||||
|
isNew,
|
||||||
|
onCreateNew,
|
||||||
|
onSave,
|
||||||
|
setOpen,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
|
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
|
||||||
@ -128,13 +144,18 @@ export default function OptionAndInputDialog({
|
|||||||
<label className="text-sm font-medium text-secondary-foreground">
|
<label className="text-sm font-medium text-secondary-foreground">
|
||||||
{nameLabel}
|
{nameLabel}
|
||||||
</label>
|
</label>
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
<Input
|
||||||
|
className="text-md"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium text-secondary-foreground">
|
<label className="text-sm font-medium text-secondary-foreground">
|
||||||
{descriptionLabel}
|
{descriptionLabel}
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
className="text-md"
|
||||||
value={descriptionValue}
|
value={descriptionValue}
|
||||||
onChange={(e) => setDescriptionValue(e.target.value)}
|
onChange={(e) => setDescriptionValue(e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
@ -147,6 +168,7 @@ export default function OptionAndInputDialog({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
disabled={isLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
@ -157,9 +179,13 @@ export default function OptionAndInputDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="select"
|
variant="select"
|
||||||
disabled={disableSave}
|
disabled={disableSave}
|
||||||
onClick={handleSave}
|
onClick={() => void handleSave()}
|
||||||
>
|
>
|
||||||
{t("button.save")}
|
{isLoading ? (
|
||||||
|
<ActivityIndicator className="size-4" />
|
||||||
|
) : (
|
||||||
|
t("button.save")
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -64,13 +64,16 @@ import {
|
|||||||
} from "react-icons/lu";
|
} from "react-icons/lu";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import ExportActionGroup from "@/components/filter/ExportActionGroup";
|
||||||
import ExportFilterGroup from "@/components/filter/ExportFilterGroup";
|
import ExportFilterGroup from "@/components/filter/ExportFilterGroup";
|
||||||
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
|
||||||
// always parse these as string arrays
|
// always parse these as string arrays
|
||||||
const EXPORT_FILTER_ARRAY_KEYS = ["cameras"];
|
const EXPORT_FILTER_ARRAY_KEYS = ["cameras"];
|
||||||
|
|
||||||
function Exports() {
|
function Exports() {
|
||||||
const { t } = useTranslation(["views/exports"]);
|
const { t } = useTranslation(["views/exports"]);
|
||||||
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t("documentTitle");
|
document.title = t("documentTitle");
|
||||||
@ -224,6 +227,54 @@ function Exports() {
|
|||||||
return true;
|
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
|
// Modifying
|
||||||
|
|
||||||
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
|
||||||
@ -242,12 +293,14 @@ function Exports() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
axios.delete(`export/${deleteClip.file}`).then((response) => {
|
axios
|
||||||
if (response.status == 200) {
|
.post("exports/delete", { ids: [deleteClip.file] })
|
||||||
setDeleteClip(undefined);
|
.then((response) => {
|
||||||
mutate();
|
if (response.status == 200) {
|
||||||
}
|
setDeleteClip(undefined);
|
||||||
});
|
mutate();
|
||||||
|
}
|
||||||
|
});
|
||||||
}, [deleteClip, mutate]);
|
}, [deleteClip, mutate]);
|
||||||
|
|
||||||
const onHandleRename = useCallback(
|
const onHandleRename = useCallback(
|
||||||
@ -278,7 +331,27 @@ function Exports() {
|
|||||||
// Keyboard Listener
|
// Keyboard Listener
|
||||||
|
|
||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
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(
|
const selectedCase = useMemo(
|
||||||
() => cases?.find((c) => c.id === selectedCaseId),
|
() => cases?.find((c) => c.id === selectedCaseId),
|
||||||
@ -372,33 +445,11 @@ function Exports() {
|
|||||||
}
|
}
|
||||||
}, [caseToDelete, deleteExportsWithCase, mutate, selectedCaseId, t]);
|
}, [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(
|
const handleRemoveExportFromCase = useCallback(
|
||||||
async (exportedRecording: Export) => {
|
async (exportedRecording: Export) => {
|
||||||
try {
|
try {
|
||||||
await axios.patch(`export/${exportedRecording.id}/case`, {
|
await axios.post("exports/reassign", {
|
||||||
|
ids: [exportedRecording.id],
|
||||||
export_case_id: null,
|
export_case_id: null,
|
||||||
});
|
});
|
||||||
mutate();
|
mutate();
|
||||||
@ -436,7 +487,7 @@ function Exports() {
|
|||||||
exportCase={caseForAddExport}
|
exportCase={caseForAddExport}
|
||||||
availableExports={uncategorizedExports}
|
availableExports={uncategorizedExports}
|
||||||
onClose={() => setCaseForAddExport(undefined)}
|
onClose={() => setCaseForAddExport(undefined)}
|
||||||
onAssign={handleAssignExportToCase}
|
mutate={mutate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CaseAssignmentDialog
|
<CaseAssignmentDialog
|
||||||
@ -570,94 +621,120 @@ function Exports() {
|
|||||||
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
|
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center gap-2">
|
{selectionMode ? (
|
||||||
{selectedCase && (
|
<ExportActionGroup
|
||||||
<Button
|
selectedExports={selectedExports}
|
||||||
className="flex items-center gap-2.5 rounded-lg"
|
setSelectedExports={setSelectedExports}
|
||||||
aria-label={t("label.back", { ns: "common" })}
|
context={selectedCase ? "case" : "uncategorized"}
|
||||||
size="sm"
|
cases={cases}
|
||||||
onClick={() => setSelectedCaseId(undefined)}
|
currentCaseId={selectedCaseId}
|
||||||
>
|
mutate={mutate}
|
||||||
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
|
||||||
{!isMobileOnly && (
|
|
||||||
<div className="text-primary">
|
|
||||||
{t("button.back", { ns: "common" })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
className="text-md w-full bg-muted md:w-1/2"
|
|
||||||
placeholder={t("search")}
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</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 gap-2">
|
||||||
<ExportFilterGroup
|
{selectedCase && (
|
||||||
className="justify-start"
|
<Button
|
||||||
filter={exportFilter}
|
className="flex items-center gap-2.5 rounded-lg"
|
||||||
filters={["cameras"]}
|
aria-label={t("label.back", { ns: "common" })}
|
||||||
onUpdateFilter={setExportFilter}
|
size="sm"
|
||||||
/>
|
onClick={() => setSelectedCaseId(undefined)}
|
||||||
<Button
|
>
|
||||||
className="flex items-center gap-2.5 rounded-lg"
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
||||||
variant="default"
|
{!isMobileOnly && (
|
||||||
size="sm"
|
<div className="text-primary">
|
||||||
onClick={() => setCaseDialog({ mode: "create" })}
|
{t("button.back", { ns: "common" })}
|
||||||
>
|
</div>
|
||||||
<LuFolderPlus className="size-4 text-secondary-foreground" />
|
)}
|
||||||
<div className="text-primary">{t("toolbar.newCase")}</div>
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
<Input
|
||||||
)}
|
className="text-md w-full bg-muted md:w-1/2"
|
||||||
{selectedCase && (
|
placeholder={t("search")}
|
||||||
<div className="flex w-full items-center justify-end gap-2">
|
value={search}
|
||||||
<ExportFilterGroup
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="justify-start"
|
/>
|
||||||
filter={exportFilter}
|
|
||||||
filters={["cameras"]}
|
|
||||||
onUpdateFilter={setExportFilter}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-1 md:gap-2">
|
|
||||||
<Button
|
|
||||||
className="flex items-center gap-2 p-2"
|
|
||||||
size="sm"
|
|
||||||
aria-label={t("toolbar.addExport")}
|
|
||||||
onClick={() => setCaseForAddExport(selectedCase)}
|
|
||||||
>
|
|
||||||
<LuPlus className="size-4 text-secondary-foreground" />
|
|
||||||
{!isMobile && (
|
|
||||||
<div className="text-primary">{t("toolbar.addExport")}</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex items-center gap-2 p-2"
|
|
||||||
size="sm"
|
|
||||||
aria-label={t("toolbar.editCase")}
|
|
||||||
onClick={() =>
|
|
||||||
setCaseDialog({ mode: "edit", exportCase: selectedCase })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<LuPencil className="size-4 text-secondary-foreground" />
|
|
||||||
{!isMobile && (
|
|
||||||
<div className="text-primary">{t("toolbar.editCase")}</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex items-center gap-2 p-2"
|
|
||||||
size="sm"
|
|
||||||
aria-label={t("toolbar.deleteCase")}
|
|
||||||
onClick={() => setCaseToDelete(selectedCase)}
|
|
||||||
>
|
|
||||||
<LuTrash2 className="size-4 text-secondary-foreground" />
|
|
||||||
{!isMobile && (
|
|
||||||
<div className="text-primary">{t("toolbar.deleteCase")}</div>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{!selectedCase && (
|
||||||
|
<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="text-secondary-foreground" />
|
||||||
|
<div className="text-primary">{t("toolbar.newCase")}</div>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedCase && (
|
||||||
|
<div className="flex w-full items-center justify-end gap-2">
|
||||||
|
<ExportFilterGroup
|
||||||
|
className="justify-start"
|
||||||
|
filter={exportFilter}
|
||||||
|
filters={["cameras"]}
|
||||||
|
onUpdateFilter={setExportFilter}
|
||||||
|
/>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex items-center gap-1 md:gap-2">
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2 p-2"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t("toolbar.addExport")}
|
||||||
|
onClick={() => setCaseForAddExport(selectedCase)}
|
||||||
|
>
|
||||||
|
<LuPlus className="text-secondary-foreground" />
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="text-primary">
|
||||||
|
{t("toolbar.addExport")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2 p-2"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t("toolbar.editCase")}
|
||||||
|
onClick={() =>
|
||||||
|
setCaseDialog({
|
||||||
|
mode: "edit",
|
||||||
|
exportCase: selectedCase,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LuPencil className="text-secondary-foreground" />
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="text-primary">
|
||||||
|
{t("toolbar.editCase")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-2 p-2"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t("toolbar.deleteCase")}
|
||||||
|
onClick={() => setCaseToDelete(selectedCase)}
|
||||||
|
>
|
||||||
|
<LuTrash2 className="text-secondary-foreground" />
|
||||||
|
{!isMobile && (
|
||||||
|
<div className="text-primary">
|
||||||
|
{t("toolbar.deleteCase")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -669,6 +746,9 @@ function Exports() {
|
|||||||
availableExports={uncategorizedExports}
|
availableExports={uncategorizedExports}
|
||||||
activeJobs={activeJobsByCase[selectedCase.id] || []}
|
activeJobs={activeJobsByCase[selectedCase.id] || []}
|
||||||
search={search}
|
search={search}
|
||||||
|
selectedExports={selectedExports}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
onSelectExport={onSelectExport}
|
||||||
setSelected={setSelected}
|
setSelected={setSelected}
|
||||||
renameClip={onHandleRename}
|
renameClip={onHandleRename}
|
||||||
setDeleteClip={setDeleteClip}
|
setDeleteClip={setDeleteClip}
|
||||||
@ -684,6 +764,9 @@ function Exports() {
|
|||||||
exports={exports}
|
exports={exports}
|
||||||
exportsByCase={exportsByCase}
|
exportsByCase={exportsByCase}
|
||||||
activeJobs={activeJobsByCase["none"] || []}
|
activeJobs={activeJobsByCase["none"] || []}
|
||||||
|
selectedExports={selectedExports}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
onSelectExport={onSelectExport}
|
||||||
setSelectedCaseId={setSelectedCaseId}
|
setSelectedCaseId={setSelectedCaseId}
|
||||||
setSelected={setSelected}
|
setSelected={setSelected}
|
||||||
renameClip={onHandleRename}
|
renameClip={onHandleRename}
|
||||||
@ -702,6 +785,9 @@ type AllExportsViewProps = {
|
|||||||
exports: Export[];
|
exports: Export[];
|
||||||
exportsByCase: { [caseId: string]: Export[] };
|
exportsByCase: { [caseId: string]: Export[] };
|
||||||
activeJobs: ExportJob[];
|
activeJobs: ExportJob[];
|
||||||
|
selectedExports: Export[];
|
||||||
|
selectionMode: boolean;
|
||||||
|
onSelectExport: (e: Export) => void;
|
||||||
setSelectedCaseId: (id: string) => void;
|
setSelectedCaseId: (id: string) => void;
|
||||||
setSelected: (e: Export) => void;
|
setSelected: (e: Export) => void;
|
||||||
renameClip: (id: string, update: string) => void;
|
renameClip: (id: string, update: string) => void;
|
||||||
@ -715,6 +801,9 @@ function AllExportsView({
|
|||||||
exports,
|
exports,
|
||||||
exportsByCase,
|
exportsByCase,
|
||||||
activeJobs,
|
activeJobs,
|
||||||
|
selectedExports,
|
||||||
|
selectionMode,
|
||||||
|
onSelectExport,
|
||||||
setSelectedCaseId,
|
setSelectedCaseId,
|
||||||
setSelected,
|
setSelected,
|
||||||
renameClip,
|
renameClip,
|
||||||
@ -807,6 +896,9 @@ function AllExportsView({
|
|||||||
key={item.name}
|
key={item.name}
|
||||||
className=""
|
className=""
|
||||||
exportedRecording={item}
|
exportedRecording={item}
|
||||||
|
isSelected={selectedExports.some((e) => e.id === item.id)}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
onContextSelect={onSelectExport}
|
||||||
onSelect={setSelected}
|
onSelect={setSelected}
|
||||||
onRename={renameClip}
|
onRename={renameClip}
|
||||||
onDelete={({ file, exportName }) =>
|
onDelete={({ file, exportName }) =>
|
||||||
@ -836,6 +928,9 @@ type CaseViewProps = {
|
|||||||
availableExports: Export[];
|
availableExports: Export[];
|
||||||
activeJobs: ExportJob[];
|
activeJobs: ExportJob[];
|
||||||
search: string;
|
search: string;
|
||||||
|
selectedExports: Export[];
|
||||||
|
selectionMode: boolean;
|
||||||
|
onSelectExport: (e: Export) => void;
|
||||||
setSelected: (e: Export) => void;
|
setSelected: (e: Export) => void;
|
||||||
renameClip: (id: string, update: string) => void;
|
renameClip: (id: string, update: string) => void;
|
||||||
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
setDeleteClip: (d: DeleteClipType | undefined) => void;
|
||||||
@ -850,6 +945,9 @@ function CaseView({
|
|||||||
availableExports,
|
availableExports,
|
||||||
activeJobs,
|
activeJobs,
|
||||||
search,
|
search,
|
||||||
|
selectedExports,
|
||||||
|
selectionMode,
|
||||||
|
onSelectExport,
|
||||||
setSelected,
|
setSelected,
|
||||||
renameClip,
|
renameClip,
|
||||||
setDeleteClip,
|
setDeleteClip,
|
||||||
@ -974,6 +1072,9 @@ function CaseView({
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
className=""
|
className=""
|
||||||
exportedRecording={item}
|
exportedRecording={item}
|
||||||
|
isSelected={selectedExports.some((e) => e.id === item.id)}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
onContextSelect={onSelectExport}
|
||||||
onSelect={setSelected}
|
onSelect={setSelected}
|
||||||
onRename={renameClip}
|
onRename={renameClip}
|
||||||
onDelete={({ file, exportName }) =>
|
onDelete={({ file, exportName }) =>
|
||||||
@ -1076,13 +1177,13 @@ type CaseAddExportDialogProps = {
|
|||||||
exportCase?: ExportCase;
|
exportCase?: ExportCase;
|
||||||
availableExports: Export[];
|
availableExports: Export[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAssign: (exportId: string, caseId: string) => Promise<void>;
|
mutate: () => void;
|
||||||
};
|
};
|
||||||
function CaseAddExportDialog({
|
function CaseAddExportDialog({
|
||||||
exportCase,
|
exportCase,
|
||||||
availableExports,
|
availableExports,
|
||||||
onClose,
|
onClose,
|
||||||
onAssign,
|
mutate,
|
||||||
}: CaseAddExportDialogProps) {
|
}: CaseAddExportDialogProps) {
|
||||||
const { t } = useTranslation(["views/exports", "common"]);
|
const { t } = useTranslation(["views/exports", "common"]);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@ -1125,12 +1226,27 @@ function CaseAddExportDialog({
|
|||||||
|
|
||||||
setIsAdding(true);
|
setIsAdding(true);
|
||||||
try {
|
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();
|
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 {
|
} finally {
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
}
|
}
|
||||||
}, [exportCase, isAdding, onAssign, onClose, selectedIds]);
|
}, [exportCase, isAdding, mutate, onClose, selectedIds, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@ -1240,7 +1356,8 @@ function CaseAssignmentDialog({
|
|||||||
if (!exportToAssign) return;
|
if (!exportToAssign) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.patch(`export/${exportToAssign.id}/case`, {
|
await axios.post("exports/reassign", {
|
||||||
|
ids: [exportToAssign.id],
|
||||||
export_case_id: caseId,
|
export_case_id: caseId,
|
||||||
});
|
});
|
||||||
mutate();
|
mutate();
|
||||||
@ -1256,6 +1373,7 @@ function CaseAssignmentDialog({
|
|||||||
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
|
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[exportToAssign, mutate, onClose, t],
|
[exportToAssign, mutate, onClose, t],
|
||||||
@ -1274,7 +1392,8 @@ function CaseAssignmentDialog({
|
|||||||
const newCaseId: string | undefined = createResp.data?.id;
|
const newCaseId: string | undefined = createResp.data?.id;
|
||||||
|
|
||||||
if (newCaseId) {
|
if (newCaseId) {
|
||||||
await axios.patch(`export/${exportToAssign.id}/case`, {
|
await axios.post("exports/reassign", {
|
||||||
|
ids: [exportToAssign.id],
|
||||||
export_case_id: newCaseId,
|
export_case_id: newCaseId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1292,6 +1411,7 @@ function CaseAssignmentDialog({
|
|||||||
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
|
toast.error(t("toast.error.assignCaseFailed", { errorMessage }), {
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[exportToAssign, mutate, onClose, t],
|
[exportToAssign, mutate, onClose, t],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user