diff --git a/web/public/locales/en/views/exports.json b/web/public/locales/en/views/exports.json
index e0ace7d24..6745d3578 100644
--- a/web/public/locales/en/views/exports.json
+++ b/web/public/locales/en/views/exports.json
@@ -39,7 +39,10 @@
},
"deleteCase": {
"label": "Delete Case",
- "desc": "Are you sure you want to delete {{caseName}}? Exports will remain available as uncategorized exports."
+ "desc": "Are you sure you want to delete {{caseName}}?",
+ "descKeepExports": "Exports will remain available as uncategorized exports.",
+ "descDeleteExports": "All exports in this case will be permanently deleted.",
+ "deleteExports": "Also delete exports"
},
"caseDialog": {
"title": "Add to case",
diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx
index c1689139b..9411dd7a8 100644
--- a/web/src/components/card/ExportCard.tsx
+++ b/web/src/components/card/ExportCard.tsx
@@ -87,11 +87,11 @@ export function CaseCard({
{exportCase.name}
-
- {exports.length === 0
- ? t("caseCard.emptyCase")
- : exportCase.description}
-
+ {exports.length === 0 && (
+
+ {t("caseCard.emptyCase")}
+
+ )}
);
@@ -333,8 +333,8 @@ export function ExportCard({
)}
-
-
+
+
{exportedRecording.name.replaceAll("_", " ")}
diff --git a/web/src/components/overlay/dialog/OptionAndInputDialog.tsx b/web/src/components/overlay/dialog/OptionAndInputDialog.tsx
index cb6b23907..19cffc806 100644
--- a/web/src/components/overlay/dialog/OptionAndInputDialog.tsx
+++ b/web/src/components/overlay/dialog/OptionAndInputDialog.tsx
@@ -8,6 +8,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
@@ -133,9 +134,10 @@ export default function OptionAndInputDialog({
{descriptionLabel}
-
setDescriptionValue(e.target.value)}
+ rows={2}
/>
diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx
index 43e767d51..9a7ce9077 100644
--- a/web/src/pages/Exports.tsx
+++ b/web/src/pages/Exports.tsx
@@ -23,6 +23,8 @@ import {
import Heading from "@/components/ui/heading";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
import { Toaster } from "@/components/ui/sonner";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { useSearchEffect } from "@/hooks/use-overlay-state";
@@ -83,7 +85,7 @@ function Exports() {
const { data: cases, mutate: updateCases } = useSWR("cases");
const { data: activeExportJobs } = useSWR("jobs/export", {
- refreshInterval: 2000,
+ refreshInterval: (latestJobs) => ((latestJobs ?? []).length > 0 ? 2000 : 0),
});
// Keep polling exports while there are queued/running jobs OR while any
// existing export is still marked in_progress. Without the second clause,
@@ -153,8 +155,21 @@ function Exports() {
}, [rawExports]);
const filteredCases = useMemo(() => {
- return cases || [];
- }, [cases]);
+ if (!cases) return [];
+
+ const hasCameraFilter =
+ exportFilter?.cameras && exportFilter.cameras.length > 0;
+
+ if (!hasCameraFilter) return cases;
+
+ // When a camera filter is active, hide cases that have zero exports
+ // and zero active jobs matching the filter — they're just noise.
+ return cases.filter(
+ (c) =>
+ (exportsByCase[c.id]?.length ?? 0) > 0 ||
+ (activeJobsByCase[c.id]?.length ?? 0) > 0,
+ );
+ }, [activeJobsByCase, cases, exportFilter?.cameras, exportsByCase]);
const exports = useMemo(
() => exportsByCase["none"] || [],
@@ -217,6 +232,7 @@ function Exports() {
{ mode: "create" | "edit"; exportCase?: ExportCase } | undefined
>();
const [caseToDelete, setCaseToDelete] = useState();
+ const [deleteExportsWithCase, setDeleteExportsWithCase] = useState(false);
const [caseForAddExport, setCaseForAddExport] = useState<
ExportCase | undefined
>();
@@ -333,11 +349,14 @@ function Exports() {
}
try {
- await axios.delete(`cases/${caseToDelete.id}`);
+ await axios.delete(`cases/${caseToDelete.id}`, {
+ params: deleteExportsWithCase ? { delete_exports: true } : undefined,
+ });
if (selectedCaseId === caseToDelete.id) {
setSelectedCaseId(undefined);
}
setCaseToDelete(undefined);
+ setDeleteExportsWithCase(false);
mutate();
} catch (error) {
const apiError = error as {
@@ -351,7 +370,7 @@ function Exports() {
position: "top-center",
});
}
- }, [caseToDelete, mutate, selectedCaseId, t]);
+ }, [caseToDelete, deleteExportsWithCase, mutate, selectedCaseId, t]);
const handleAssignExportToCase = useCallback(
async (exportId: string, caseId: string) => {
@@ -457,7 +476,12 @@ function Exports() {
setCaseToDelete(undefined)}
+ onOpenChange={(open) => {
+ if (!open) {
+ setCaseToDelete(undefined);
+ setDeleteExportsWithCase(false);
+ }
+ }}
>
@@ -465,9 +489,25 @@ function Exports() {
{t("deleteCase.desc", {
caseName: caseToDelete?.name,
- })}
+ })}{" "}
+ {deleteExportsWithCase
+ ? t("deleteCase.descDeleteExports")
+ : t("deleteCase.descKeepExports")}
+
+
+ {t("deleteCase.deleteExports")}
+
+
+
{t("button.cancel", { ns: "common" })}
@@ -526,7 +566,7 @@ function Exports() {
@@ -574,32 +614,49 @@ function Exports() {
)}
{selectedCase && (
-
setCaseForAddExport(selectedCase)}
- >
-
- {t("toolbar.addExport")}
-
-
- setCaseDialog({ mode: "edit", exportCase: selectedCase })
- }
- >
-
- {t("toolbar.editCase")}
-
-
setCaseToDelete(selectedCase)}
- >
-
- {t("toolbar.deleteCase")}
-
+
+
+
setCaseForAddExport(selectedCase)}
+ >
+
+ {!isMobile && (
+ {t("toolbar.addExport")}
+ )}
+
+
+ setCaseDialog({ mode: "edit", exportCase: selectedCase })
+ }
+ >
+
+ {!isMobile && (
+ {t("toolbar.editCase")}
+ )}
+
+
setCaseToDelete(selectedCase)}
+ >
+
+ {!isMobile && (
+ {t("toolbar.deleteCase")}
+ )}
+
+
)}