diff --git a/web/public/locales/en/views/exports.json b/web/public/locales/en/views/exports.json index 4a79d20e1..8f9e8205e 100644 --- a/web/public/locales/en/views/exports.json +++ b/web/public/locales/en/views/exports.json @@ -2,6 +2,10 @@ "documentTitle": "Export - Frigate", "search": "Search", "noExports": "No exports found", + "headings": { + "cases": "Cases", + "uncategorizedExports": "Uncategorized Exports" + }, "deleteExport": "Delete Export", "deleteExport.desc": "Are you sure you want to delete {{exportName}}?", "editExport": { @@ -13,11 +17,21 @@ "shareExport": "Share export", "downloadVideo": "Download video", "editName": "Edit name", - "deleteExport": "Delete export" + "deleteExport": "Delete export", + "assignToCase": "Add to case" }, "toast": { "error": { - "renameExportFailed": "Failed to rename export: {{errorMessage}}" + "renameExportFailed": "Failed to rename export: {{errorMessage}}", + "assignCaseFailed": "Failed to update case assignment: {{errorMessage}}" } + }, + "caseDialog": { + "title": "Add to case", + "description": "Choose an existing case or create a new one.", + "selectLabel": "Case", + "newCaseOption": "Create new case", + "nameLabel": "Case name", + "descriptionLabel": "Description" } } diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index 021524532..fc7964c18 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -1,9 +1,8 @@ import ActivityIndicator from "../indicators/activity-indicator"; -import { LuTrash } from "react-icons/lu"; import { Button } from "../ui/button"; import { useCallback, useState } from "react"; -import { isDesktop, isMobile } from "react-device-detect"; -import { FaDownload, FaPlay, FaShareAlt } from "react-icons/fa"; +import { isMobile } from "react-device-detect"; +import { FiMoreVertical } from "react-icons/fi"; import { Skeleton } from "../ui/skeleton"; import { Dialog, @@ -14,35 +13,62 @@ import { } from "../ui/dialog"; import { Input } from "../ui/input"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; -import { DeleteClipType, Export } from "@/types/export"; -import { MdEditSquare } from "react-icons/md"; +import { DeleteClipType, Export, ExportCase } from "@/types/export"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import { shareOrCopy } from "@/utils/browserUtil"; import { useTranslation } from "react-i18next"; import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay"; import BlurredIconButton from "../button/BlurredIconButton"; -import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { useIsAdmin } from "@/hooks/use-is-admin"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { FaFolder } from "react-icons/fa"; -type ExportProps = { +type CaseCardProps = { + className: string; + exportCase: ExportCase; + onSelect: () => void; +}; +export function CaseCard({ className, exportCase, onSelect }: CaseCardProps) { + return ( +
onSelect()} + > +
+ +
{exportCase.name}
+
+
+ ); +} + +type ExportCardProps = { className: string; exportedRecording: Export; onSelect: (selected: Export) => void; onRename: (original: string, update: string) => void; onDelete: ({ file, exportName }: DeleteClipType) => void; + onAssignToCase?: (selected: Export) => void; }; - -export default function ExportCard({ +export function ExportCard({ className, exportedRecording, onSelect, onRename, onDelete, -}: ExportProps) { + onAssignToCase, +}: ExportCardProps) { const { t } = useTranslation(["views/exports"]); const isAdmin = useIsAdmin(); - const [hovered, setHovered] = useState(false); const [loading, setLoading] = useState( exportedRecording.thumb_path.length > 0, ); @@ -136,12 +162,14 @@ export default function ExportCard({
setHovered(true) : undefined} - onMouseLeave={isDesktop ? () => setHovered(false) : undefined} - onClick={isDesktop ? undefined : () => setHovered(!hovered)} + onClick={() => { + if (!exportedRecording.in_progress) { + onSelect(exportedRecording); + } + }} > {exportedRecording.in_progress ? ( @@ -158,95 +186,88 @@ export default function ExportCard({ )} )} - {hovered && ( - <> -
-
-
- {!exportedRecording.in_progress && ( - - - - shareOrCopy( - `${baseUrl}export?id=${exportedRecording.id}`, - exportedRecording.name.replaceAll("_", " "), - ) - } - > - - - - {t("tooltip.shareExport")} - - )} - {!exportedRecording.in_progress && ( + {!exportedRecording.in_progress && ( +
+ + + e.stopPropagation()} + > + + + + + { + e.stopPropagation(); + shareOrCopy( + `${baseUrl}export?id=${exportedRecording.id}`, + exportedRecording.name.replaceAll("_", " "), + ); + }} + > + {t("tooltip.shareExport")} + + e.stopPropagation()} > - - - - - - - - {t("tooltip.downloadVideo")} - - + {t("tooltip.downloadVideo")} - )} - {isAdmin && !exportedRecording.in_progress && ( - - - - setEditName({ - original: exportedRecording.name, - update: undefined, - }) - } - > - - - - {t("tooltip.editName")} - + + {isAdmin && onAssignToCase && ( + { + e.stopPropagation(); + onAssignToCase(exportedRecording); + }} + > + {t("tooltip.assignToCase")} + )} {isAdmin && ( - - - - onDelete({ - file: exportedRecording.id, - exportName: exportedRecording.name, - }) - } - > - - - - {t("tooltip.deleteExport")} - + { + e.stopPropagation(); + setEditName({ + original: exportedRecording.name, + update: undefined, + }); + }} + > + {t("tooltip.editName")} + )} -
-
- - {!exportedRecording.in_progress && ( - - )} - + {isAdmin && ( + { + e.stopPropagation(); + onDelete({ + file: exportedRecording.id, + exportName: exportedRecording.name, + }); + }} + > + {t("tooltip.deleteExport")} + + )} + + +
)} {loading && ( diff --git a/web/src/components/overlay/dialog/OptionAndInputDialog.tsx b/web/src/components/overlay/dialog/OptionAndInputDialog.tsx new file mode 100644 index 000000000..cb6b23907 --- /dev/null +++ b/web/src/components/overlay/dialog/OptionAndInputDialog.tsx @@ -0,0 +1,166 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { isMobile } from "react-device-detect"; +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +type Option = { + value: string; + label: string; +}; + +type OptionAndInputDialogProps = { + open: boolean; + title: string; + description?: string; + options: Option[]; + newValueKey: string; + initialValue?: string; + nameLabel: string; + descriptionLabel: string; + setOpen: (open: boolean) => void; + onSave: (value: string) => void; + onCreateNew: (name: string, description: string) => void; +}; + +export default function OptionAndInputDialog({ + open, + title, + description, + options, + newValueKey, + initialValue, + nameLabel, + descriptionLabel, + setOpen, + onSave, + onCreateNew, +}: OptionAndInputDialogProps) { + const { t } = useTranslation("common"); + const firstOption = useMemo(() => options[0]?.value, [options]); + + const [selectedValue, setSelectedValue] = useState( + initialValue ?? firstOption, + ); + const [name, setName] = useState(""); + const [descriptionValue, setDescriptionValue] = useState(""); + + useEffect(() => { + if (open) { + setSelectedValue(initialValue ?? firstOption); + setName(""); + setDescriptionValue(""); + } + }, [open, initialValue, firstOption]); + + const isNew = selectedValue === newValueKey; + const disableSave = !selectedValue || (isNew && name.trim().length === 0); + + const handleSave = () => { + if (!selectedValue) { + return; + } + + const trimmedName = name.trim(); + const trimmedDescription = descriptionValue.trim(); + + if (isNew) { + onCreateNew(trimmedName, trimmedDescription); + } else { + onSave(selectedValue); + } + setOpen(false); + }; + + return ( + + { + if (isMobile) { + e.preventDefault(); + } + }} + > + + {title} + {description && {description}} + + +
+ +
+ + {isNew && ( +
+
+ + setName(e.target.value)} /> +
+
+ + setDescriptionValue(e.target.value)} + /> +
+
+ )} + + + + + +
+
+ ); +} diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 7e4e820d9..70fa5d5b8 100644 --- a/web/src/pages/Exports.tsx +++ b/web/src/pages/Exports.tsx @@ -1,5 +1,5 @@ import { baseUrl } from "@/api/baseUrl"; -import ExportCard from "@/components/card/ExportCard"; +import { CaseCard, ExportCard } from "@/components/card/ExportCard"; import { AlertDialog, AlertDialogCancel, @@ -11,15 +11,24 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import Heading from "@/components/ui/heading"; import { Input } from "@/components/ui/input"; import { Toaster } from "@/components/ui/sonner"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; -import { useSearchEffect } from "@/hooks/use-overlay-state"; +import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; import { cn } from "@/lib/utils"; -import { DeleteClipType, Export } from "@/types/export"; +import { DeleteClipType, Export, ExportCase } from "@/types/export"; +import OptionAndInputDialog from "@/components/overlay/dialog/OptionAndInputDialog"; import axios from "axios"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + MutableRefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { isMobile } from "react-device-detect"; import { useTranslation } from "react-i18next"; @@ -29,32 +38,37 @@ import useSWR from "swr"; function Exports() { const { t } = useTranslation(["views/exports"]); - const { data: exports, mutate } = useSWR("exports"); useEffect(() => { document.title = t("documentTitle"); }, [t]); + // Data + + const { data: cases, mutate: updateCases } = useSWR("cases"); + const { data: rawExports, mutate: updateExports } = + useSWR("exports"); + + const exports = useMemo( + () => (rawExports ?? []).filter((e) => !e.export_case), + [rawExports], + ); + + const mutate = useCallback(() => { + updateExports(); + updateCases(); + }, [updateExports, updateCases]); + // Search const [search, setSearch] = useState(""); - const filteredExports = useMemo(() => { - if (!search || !exports) { - return exports; - } - - return exports.filter((exp) => - exp.name - .toLowerCase() - .replaceAll("_", " ") - .includes(search.toLowerCase()), - ); - }, [exports, search]); - // Viewing const [selected, setSelected] = useState(); + const [selectedCaseId, setSelectedCaseId] = useOverlayState< + string | undefined + >("caseId", undefined); const [selectedAspect, setSelectedAspect] = useState(0.0); useSearchEffect("id", (id) => { @@ -66,9 +80,25 @@ function Exports() { return true; }); - // Deleting + useSearchEffect("caseId", (caseId: string) => { + if (!cases) { + return false; + } + + const exists = cases.some((c) => c.id === caseId); + + if (!exists) { + return false; + } + + setSelectedCaseId(caseId); + return true; + }); + + // Modifying const [deleteClip, setDeleteClip] = useState(); + const [exportToAssign, setExportToAssign] = useState(); const onHandleDelete = useCallback(() => { if (!deleteClip) { @@ -83,8 +113,6 @@ function Exports() { }); }, [deleteClip, mutate]); - // Renaming - const onHandleRename = useCallback( (id: string, update: string) => { axios @@ -107,7 +135,7 @@ function Exports() { }); }); }, - [mutate, t], + [mutate, setDeleteClip, t], ); // Keyboard Listener @@ -115,10 +143,27 @@ function Exports() { const contentRef = useRef(null); useKeyboardListener([], undefined, contentRef); + const selectedCase = useMemo( + () => cases?.find((c) => c.id === selectedCaseId), + [cases, selectedCaseId], + ); + + const resetCaseDialog = useCallback(() => { + setExportToAssign(undefined); + }, []); + return (
+ + setDeleteClip(undefined)} @@ -187,7 +232,7 @@ function Exports() { - {exports && ( + {(exports?.length || cases?.length) && (
)} -
- {exports && filteredExports && filteredExports.length > 0 ? ( -
- {Object.values(exports).map((item) => ( - - setDeleteClip({ file, exportName }) - } - /> - ))} -
- ) : ( -
- - {t("noExports")} -
- )} + {selectedCase ? ( + + ) : ( + + )} +
+ ); +} + +type AllExportsViewProps = { + contentRef: MutableRefObject; + search: string; + cases?: ExportCase[]; + exports: Export[]; + setSelectedCaseId: (id: string) => void; + setSelected: (e: Export) => void; + renameClip: (id: string, update: string) => void; + setDeleteClip: (d: DeleteClipType | undefined) => void; + onAssignToCase: (e: Export) => void; +}; +function AllExportsView({ + contentRef, + search, + cases, + exports, + setSelectedCaseId, + setSelected, + renameClip, + setDeleteClip, + onAssignToCase, +}: AllExportsViewProps) { + const { t } = useTranslation(["views/exports"]); + + // Filter + + const filteredCases = useMemo(() => { + if (!search || !cases) { + return cases || []; + } + + return cases.filter( + (caseItem) => + caseItem.name.toLowerCase().includes(search.toLowerCase()) || + (caseItem.description && + caseItem.description.toLowerCase().includes(search.toLowerCase())), + ); + }, [search, cases]); + + const filteredExports = useMemo(() => { + if (!search) { + return exports; + } + + return exports.filter((exp) => + exp.name + .toLowerCase() + .replaceAll("_", " ") + .includes(search.toLowerCase()), + ); + }, [exports, search]); + + return ( +
+ {filteredCases?.length || filteredExports.length ? ( +
+ {filteredCases.length > 0 && ( +
+ {t("headings.cases")} +
+ {cases?.map((item) => ( + { + setSelectedCaseId(item.id); + }} + /> + ))} +
+
+ )} + + {filteredExports.length > 0 && ( +
+ {t("headings.uncategorizedExports")} +
+ {exports.map((item) => ( + + setDeleteClip({ file, exportName }) + } + onAssignToCase={onAssignToCase} + /> + ))} +
+
+ )} +
+ ) : ( +
+ + {t("noExports")} +
+ )} +
+ ); +} + +type CaseViewProps = { + contentRef: MutableRefObject; + selectedCase: ExportCase; + exports?: Export[]; + search: string; + setSelected: (e: Export) => void; + renameClip: (id: string, update: string) => void; + setDeleteClip: (d: DeleteClipType | undefined) => void; + onAssignToCase: (e: Export) => void; +}; +function CaseView({ + contentRef, + selectedCase, + exports, + search, + setSelected, + renameClip, + setDeleteClip, + onAssignToCase, +}: CaseViewProps) { + const filteredExports = useMemo(() => { + const caseExports = (exports || []).filter( + (e) => e.export_case == selectedCase.id, + ); + + if (!search) { + return caseExports; + } + + return caseExports.filter((exp) => + exp.name + .toLowerCase() + .replaceAll("_", " ") + .includes(search.toLowerCase()), + ); + }, [selectedCase, exports, search]); + + return ( +
+
+ + {selectedCase.name} + +
+ {selectedCase.description} +
+
+
+ {exports?.map((item) => ( + + setDeleteClip({ file, exportName }) + } + onAssignToCase={onAssignToCase} + /> + ))}
); } +type CaseAssignmentDialogProps = { + exportToAssign?: Export; + cases?: ExportCase[]; + selectedCaseId?: string; + onClose: () => void; + mutate: () => void; +}; +function CaseAssignmentDialog({ + exportToAssign, + cases, + selectedCaseId, + onClose, + mutate, +}: CaseAssignmentDialogProps) { + const { t } = useTranslation(["views/exports"]); + const caseOptions = useMemo( + () => [ + ...(cases ?? []) + .map((c) => ({ + value: c.id, + label: c.name, + })) + .sort((cA, cB) => cA.label.localeCompare(cB.label)), + { + value: "new", + label: t("caseDialog.newCaseOption"), + }, + ], + [cases, t], + ); + + const handleSave = useCallback( + async (caseId: string) => { + if (!exportToAssign) return; + + try { + await axios.patch(`export/${exportToAssign.id}/case`, { + export_case_id: caseId, + }); + mutate(); + onClose(); + } catch (error: unknown) { + 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", + }); + } + }, + [exportToAssign, mutate, onClose, t], + ); + + const handleCreateNew = useCallback( + async (name: string, description: string) => { + if (!exportToAssign) return; + + try { + const createResp = await axios.post("cases", { + name, + description, + }); + + const newCaseId: string | undefined = createResp.data?.id; + + if (newCaseId) { + await axios.patch(`export/${exportToAssign.id}/case`, { + export_case_id: newCaseId, + }); + } + + mutate(); + onClose(); + } catch (error: unknown) { + 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", + }); + } + }, + [exportToAssign, mutate, onClose, t], + ); + + if (!exportToAssign) { + return null; + } + + return ( + { + if (!open) { + onClose(); + } + }} + options={caseOptions} + nameLabel={t("caseDialog.nameLabel")} + descriptionLabel={t("caseDialog.descriptionLabel")} + initialValue={selectedCaseId} + newValueKey="new" + onSave={handleSave} + onCreateNew={handleCreateNew} + /> + ); +} + export default Exports; diff --git a/web/src/types/export.ts b/web/src/types/export.ts index fc62bbeec..1184becf0 100644 --- a/web/src/types/export.ts +++ b/web/src/types/export.ts @@ -6,6 +6,15 @@ export type Export = { video_path: string; thumb_path: string; in_progress: boolean; + export_case?: string; +}; + +export type ExportCase = { + id: string; + name: string; + description: string; + created_at: number; + updated_at: number; }; export type DeleteClipType = {