diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index 9411dd7a8..724179128 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -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(null); + useContextMenu(cardRef, () => { + if (!exportedRecording.in_progress && onContextSelect) { + onContextSelect(exportedRecording); + } + }); + // editing name const [editName, setEditName] = useState<{ @@ -209,13 +225,18 @@ export function ExportCard({
{ + 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 && (
@@ -333,6 +354,14 @@ export function ExportCard({ )} +
{exportedRecording.name.replaceAll("_", " ")} diff --git a/web/src/components/filter/ExportActionGroup.tsx b/web/src/components/filter/ExportActionGroup.tsx new file mode 100644 index 000000000..92e5f251b --- /dev/null +++ b/web/src/components/filter/ExportActionGroup.tsx @@ -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 */} + setDeleteDialogOpen(!deleteDialogOpen)} + > + + + {t("bulkDelete.title")} + + + {t("bulkDelete.desc", { count: selectedExports.length })} + + + + {t("button.cancel", { ns: "common" })} + + + {t("button.delete", { ns: "common" })} + + + + + + {/* Remove from case dialog */} + {context === "case" && ( + { + if (!open) { + setRemoveDialogOpen(false); + setDeleteExportsOnRemove(false); + } + }} + > + + + + {t("bulkRemoveFromCase.title")} + + + {t("bulkRemoveFromCase.desc", { + count: selectedExports.length, + })}{" "} + {deleteExportsOnRemove + ? t("bulkRemoveFromCase.descDeleteExports") + : t("bulkRemoveFromCase.descKeepExports")} + + +
+ + +
+ + + {t("button.cancel", { ns: "common" })} + + + {t("button.delete", { ns: "common" })} + + +
+
+ )} + + {/* Case picker dialog */} + + + {/* Action bar */} +
+
+
+ {t("selected", { count: selectedExports.length })} +
+
{"|"}
+
+ {t("button.unselect", { ns: "common" })} +
+
+ {isAdmin && ( +
+ {/* Add to Case / Move to Case */} + + + {/* Remove from Case (case context only) */} + {context === "case" && ( + + )} + + {/* Delete */} + +
+ )} +
+ + ); +} diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index fd2956527..f835b9d75 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -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({ />
-
- - {isAdmin ? ( - <> - - {batchCaseSelection === "new" && ( -
- setNewCaseName(event.target.value)} - /> -