From 46e488653b6a2649b38ee4c875b2b4c77a892409 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 15 Dec 2025 10:16:29 -0700 Subject: [PATCH] Show cases separately from exports --- web/public/locales/en/views/exports.json | 4 + web/src/components/card/ExportCard.tsx | 34 +++++-- web/src/pages/Exports.tsx | 109 +++++++++++++++++------ web/src/types/export.ts | 9 ++ 4 files changed, 126 insertions(+), 30 deletions(-) diff --git a/web/public/locales/en/views/exports.json b/web/public/locales/en/views/exports.json index 4a79d20e1..6fca40cef 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": { diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx index a7f5e7657..7158a5bb9 100644 --- a/web/src/components/card/ExportCard.tsx +++ b/web/src/components/card/ExportCard.tsx @@ -13,7 +13,7 @@ import { } from "../ui/dialog"; import { Input } from "../ui/input"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; -import { DeleteClipType, Export } from "@/types/export"; +import { DeleteClipType, Export, ExportCase } from "@/types/export"; import { baseUrl } from "@/api/baseUrl"; import { cn } from "@/lib/utils"; import { shareOrCopy } from "@/utils/browserUtil"; @@ -27,22 +27,46 @@ import { 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) { + const { t } = useTranslation(["views/exports"]); + + 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; }; - -export default function ExportCard({ +export function ExportCard({ className, exportedRecording, onSelect, onRename, onDelete, -}: ExportProps) { +}: ExportCardProps) { const { t } = useTranslation(["views/exports"]); const isAdmin = useIsAdmin(); const [loading, setLoading] = useState( diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx index 7e4e820d9..5a5aa19c7 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,12 +11,13 @@ 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 { cn } from "@/lib/utils"; -import { DeleteClipType, Export } from "@/types/export"; +import { DeleteClipType, Export, ExportCase } from "@/types/export"; import axios from "axios"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -29,18 +30,46 @@ 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) { + 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; } @@ -187,7 +216,7 @@ function Exports() { - {exports && ( + {(exports?.length || cases?.length) && (
- {exports && filteredExports && filteredExports.length > 0 ? ( -
- {Object.values(exports).map((item) => ( - - setDeleteClip({ file, exportName }) - } - /> - ))} + {filteredCases?.length || filteredExports.length ? ( +
+ {cases?.length && ( +
+ {t("headings.cases")} +
+ {cases.map((item) => ( + {}} + /> + ))} +
+
+ )} + +
+ {t("headings.uncategorizedExports")} +
+ {exports.map((item) => ( + + setDeleteClip({ file, exportName }) + } + /> + ))} +
+
) : (
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 = {