- bulk selection like Review
- gate admin-only actions
- consolidate dialogs
- spacing/padding tweaks
This commit is contained in:
Josh Hawkins 2026-04-12 13:55:15 -05:00
parent 0f2c461cd2
commit 317ca7bb1c
6 changed files with 785 additions and 243 deletions

View File

@ -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,13 +225,18 @@ 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) {
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">
<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("_", " ")}

View 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>
</>
);
}

View File

@ -567,11 +567,13 @@ export function ExportContent({
})),
};
if (batchCaseSelection === "new") {
payload.new_case_name = newCaseName.trim();
payload.new_case_description = newCaseDescription.trim() || undefined;
} else {
payload.export_case_id = batchCaseSelection;
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,76 +934,52 @@ export function ExportContent({
/>
</div>
<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)}
>
<SelectTrigger>
<SelectValue placeholder={t("export.case.placeholder")} />
</SelectTrigger>
<SelectContent>
{cases
?.sort((a, b) => a.name.localeCompare(b.name))
.map((caseItem) => (
<SelectItem key={caseItem.id} value={caseItem.id}>
{caseItem.name}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value="new">
{t("export.case.newCaseOption")}
</SelectItem>
</SelectContent>
</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")}
{isAdmin && (
<div className="space-y-2">
<Label className="text-sm text-secondary-foreground">
{t("export.case.label")}
</Label>
<Select
value={batchCaseSelection}
onValueChange={(value) => setBatchCaseSelection(value)}
>
<SelectTrigger>
<SelectValue placeholder={t("export.case.placeholder")} />
</SelectTrigger>
<SelectContent>
{cases
?.sort((a, b) => a.name.localeCompare(b.name))
.map((caseItem) => (
<SelectItem key={caseItem.id} value={caseItem.id}>
{caseItem.name}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value="new">
{t("export.case.newCaseOption")}
</SelectItem>
</SelectContent>
</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>
<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>
)}
</div>
)}
</TabsContent>
</Tabs>

View File

@ -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,
}
: { export_case_id: caseSelection }),
};
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;
}
}
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", {
ns: "components/dialog",
count: successful.length,
}),
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>

View File

@ -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();
if (isNew) {
onCreateNew(trimmedName, trimmedDescription);
} else {
onSave(selectedValue);
setIsLoading(true);
try {
if (isNew) {
await onCreateNew(trimmedName, trimmedDescription);
} else {
await onSave(selectedValue);
}
setOpen(false);
} finally {
setIsLoading(false);
}
setOpen(false);
};
}, [
selectedValue,
name,
descriptionValue,
isNew,
onCreateNew,
onSave,
setOpen,
]);
return (
<Dialog open={open} defaultOpen={false} onOpenChange={setOpen}>
@ -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>

View File

@ -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,12 +293,14 @@ function Exports() {
return;
}
axios.delete(`export/${deleteClip.file}`).then((response) => {
if (response.status == 200) {
setDeleteClip(undefined);
mutate();
}
});
axios
.post("exports/delete", { ids: [deleteClip.file] })
.then((response) => {
if (response.status == 200) {
setDeleteClip(undefined);
mutate();
}
});
}, [deleteClip, mutate]);
const onHandleRename = useCallback(
@ -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,94 +621,120 @@ function Exports() {
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
)}
>
<div className="flex w-full items-center gap-2">
{selectedCase && (
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => setSelectedCaseId(undefined)}
>
<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)}
{selectionMode ? (
<ExportActionGroup
selectedExports={selectedExports}
setSelectedExports={setSelectedExports}
context={selectedCase ? "case" : "uncategorized"}
cases={cases}
currentCaseId={selectedCaseId}
mutate={mutate}
/>
</div>
{!selectedCase && (
<div className="flex w-full items-center justify-between gap-2 md:justify-start lg:justify-end">
<ExportFilterGroup
className="justify-start"
filter={exportFilter}
filters={["cameras"]}
onUpdateFilter={setExportFilter}
/>
<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" />
<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}
/>
<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 className="flex w-full items-center gap-2">
{selectedCase && (
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => setSelectedCaseId(undefined)}
>
<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>
</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>
@ -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],