- 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 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("_", " ")}

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") { 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>

View File

@ -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>

View File

@ -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>

View File

@ -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],