From 0c70818a326b6f22509d87ca5c65dc5441a740e4 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Sat, 11 Apr 2026 14:29:46 -0500
Subject: [PATCH] tweaks
- add ability to remove from case
- change location of counts in case card
---
web/public/locales/en/views/exports.json | 7 +--
web/src/components/card/ExportCard.tsx | 59 ++++++++-------------
web/src/pages/Exports.tsx | 67 +++++++++++++++++++++---
3 files changed, 86 insertions(+), 47 deletions(-)
diff --git a/web/public/locales/en/views/exports.json b/web/public/locales/en/views/exports.json
index 03ae38fae..fd6136c63 100644
--- a/web/public/locales/en/views/exports.json
+++ b/web/public/locales/en/views/exports.json
@@ -20,7 +20,8 @@
"downloadVideo": "Download video",
"editName": "Edit name",
"deleteExport": "Delete export",
- "assignToCase": "Add to case"
+ "assignToCase": "Add to case",
+ "removeFromCase": "Remove from case"
},
"toolbar": {
"newCase": "New Case",
@@ -49,10 +50,6 @@
"descriptionLabel": "Description"
},
"caseCard": {
- "exportCount_one": "1 export",
- "exportCount_other": "{{count}} exports",
- "cameraCount_one": "1 camera",
- "cameraCount_other": "{{count}} cameras",
"emptyCase": "No exports yet"
},
"jobCard": {
diff --git a/web/src/components/card/ExportCard.tsx b/web/src/components/card/ExportCard.tsx
index d7925de4c..c1689139b 100644
--- a/web/src/components/card/ExportCard.tsx
+++ b/web/src/components/card/ExportCard.tsx
@@ -27,11 +27,9 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
-import { FaFolder } from "react-icons/fa";
+import { FaFolder, FaVideo } from "react-icons/fa";
+import { HiSquare2Stack } from "react-icons/hi2";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
-import { useFormattedTimestamp, useTimeFormat } from "@/hooks/use-date-utils";
-import useSWR from "swr";
-import { FrigateConfig } from "@/types/frigateConfig";
type CaseCardProps = {
className: string;
@@ -74,12 +72,14 @@ export function CaseCard({
)}
-
-
- {t("caseCard.exportCount", { count: exports.length })}
+
+
-
- {t("caseCard.cameraCount", { count: cameraCount })}
+
@@ -104,6 +104,7 @@ type ExportCardProps = {
onRename: (original: string, update: string) => void;
onDelete: ({ file, exportName }: DeleteClipType) => void;
onAssignToCase?: (selected: Export) => void;
+ onRemoveFromCase?: (selected: Export) => void;
};
export function ExportCard({
className,
@@ -112,19 +113,10 @@ export function ExportCard({
onRename,
onDelete,
onAssignToCase,
+ onRemoveFromCase,
}: ExportCardProps) {
const { t } = useTranslation(["views/exports"]);
const isAdmin = useIsAdmin();
- const cameraName = useCameraFriendlyName(exportedRecording.camera);
- const { data: config } = useSWR
("config");
- const timeFormat = useTimeFormat(config);
- const formattedDate = useFormattedTimestamp(
- exportedRecording.date,
- t(`time.formattedTimestampMonthDayYearHourMinute.${timeFormat}`, {
- ns: "common",
- }),
- config?.ui.timezone,
- );
const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0,
);
@@ -291,6 +283,18 @@ export function ExportCard({
{t("tooltip.assignToCase")}
)}
+ {isAdmin && onRemoveFromCase && (
+ {
+ e.stopPropagation();
+ onRemoveFromCase(exportedRecording);
+ }}
+ >
+ {t("tooltip.removeFromCase")}
+
+ )}
{isAdmin && (
)}
-
- {cameraName}
-
{exportedRecording.name.replaceAll("_", " ")}
-
{formattedDate}
>
@@ -354,15 +354,6 @@ export function ActiveExportJobCard({
}: ActiveExportJobCardProps) {
const { t } = useTranslation(["views/exports", "common"]);
const cameraName = useCameraFriendlyName(job.camera);
- const { data: config } = useSWR
("config");
- const timeFormat = useTimeFormat(config);
- const formattedDate = useFormattedTimestamp(
- job.request_start_time,
- t(`time.formattedTimestampMonthDayYearHourMinute.${timeFormat}`, {
- ns: "common",
- }),
- config?.ui.timezone,
- );
const displayName = useMemo(() => {
if (job.name && job.name.length > 0) {
return job.name.replaceAll("_", " ");
@@ -382,16 +373,12 @@ export function ActiveExportJobCard({
className,
)}
>
-
- {cameraName}
-
{statusLabel}
{displayName}
-
{formattedDate}
);
diff --git a/web/src/pages/Exports.tsx b/web/src/pages/Exports.tsx
index 8b11766fe..539d93207 100644
--- a/web/src/pages/Exports.tsx
+++ b/web/src/pages/Exports.tsx
@@ -48,7 +48,13 @@ import { isMobile, isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { IoMdArrowRoundBack } from "react-icons/io";
-import { LuFolderX } from "react-icons/lu";
+import {
+ LuFolderPlus,
+ LuFolderX,
+ LuPencil,
+ LuPlus,
+ LuTrash2,
+} from "react-icons/lu";
import { toast } from "sonner";
import useSWR from "swr";
import ExportFilterGroup from "@/components/filter/ExportFilterGroup";
@@ -74,12 +80,25 @@ function Exports() {
const { data: activeExportJobs } = useSWR
("jobs/export", {
refreshInterval: 2000,
});
+ // Keep polling exports while there are queued/running jobs OR while any
+ // existing export is still marked in_progress. Without the second clause,
+ // a stale in_progress=true snapshot can stick if the activeExportJobs poll
+ // clears before the rawExports poll fires — SWR cancels the pending
+ // rawExports refresh and the UI freezes on spinners until a manual reload.
const { data: rawExports, mutate: updateExports } = useSWR(
exportSearchParams && Object.keys(exportSearchParams).length > 0
? ["exports", exportSearchParams]
: "exports",
{
- refreshInterval: (activeExportJobs?.length ?? 0) > 0 ? 2000 : 0,
+ refreshInterval: (latestExports) => {
+ if ((activeExportJobs?.length ?? 0) > 0) {
+ return 2000;
+ }
+ if ((latestExports ?? []).some((exp) => exp.in_progress)) {
+ return 2000;
+ }
+ return 0;
+ },
},
);
@@ -352,6 +371,29 @@ function Exports() {
[mutate, t],
);
+ const handleRemoveExportFromCase = useCallback(
+ async (exportedRecording: Export) => {
+ try {
+ await axios.patch(`export/${exportedRecording.id}/case`, {
+ export_case_id: null,
+ });
+ mutate();
+ } catch (error) {
+ 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",
+ });
+ }
+ },
+ [mutate, t],
+ );
+
const resetCaseDialog = useCallback(() => {
setExportToAssign(undefined);
}, []);
@@ -515,11 +557,13 @@ function Exports() {
onUpdateFilter={setExportFilter}
/>
)}
@@ -530,6 +574,7 @@ function Exports() {
size="sm"
onClick={() => setCaseForAddExport(selectedCase)}
>
+
{t("toolbar.addExport")}
@@ -564,6 +611,7 @@ function Exports() {
renameClip={onHandleRename}
setDeleteClip={setDeleteClip}
onAssignToCase={setExportToAssign}
+ onRemoveFromCase={handleRemoveExportFromCase}
onAddExport={() => setCaseForAddExport(selectedCase)}
/>
) : (
@@ -730,6 +778,7 @@ type CaseViewProps = {
renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void;
onAssignToCase: (e: Export) => void;
+ onRemoveFromCase: (e: Export) => void;
onAddExport: () => void;
};
function CaseView({
@@ -743,6 +792,7 @@ function CaseView({
renameClip,
setDeleteClip,
onAssignToCase,
+ onRemoveFromCase,
onAddExport,
}: CaseViewProps) {
const { t } = useTranslation(["views/exports", "common"]);
@@ -868,6 +918,7 @@ function CaseView({
setDeleteClip({ file, exportName })
}
onAssignToCase={onAssignToCase}
+ onRemoveFromCase={onRemoveFromCase}
/>
))}
@@ -939,7 +990,7 @@ function CaseEditorDialog({
{t("button.cancel", { ns: "common" })}