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