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 (
+
+ );
+}
+
+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 (
+
+ );
+}
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 = {