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 })} +
+
+ +
{exports.length}
-
- {t("caseCard.cameraCount", { count: cameraCount })} +
+ +
{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" })}