- add ability to remove from case
- change location of counts in case card
This commit is contained in:
Josh Hawkins 2026-04-11 14:29:46 -05:00
parent eeff59216b
commit 0c70818a32
3 changed files with 86 additions and 47 deletions

View File

@ -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": {

View File

@ -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({
<div className="absolute inset-0 bg-gradient-to-br from-secondary via-secondary/80 to-muted" />
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-16 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute left-2 top-2 z-20 flex flex-wrap gap-2 text-xs text-white">
<div className="rounded-full bg-black/55 px-2 py-1">
{t("caseCard.exportCount", { count: exports.length })}
<div className="absolute right-1 top-1 z-40 flex items-center gap-2 rounded-lg bg-black/50 px-2 py-1 text-xs text-white">
<div className="flex items-center gap-1">
<HiSquare2Stack className="size-3" />
<div>{exports.length}</div>
</div>
<div className="rounded-full bg-black/55 px-2 py-1">
{t("caseCard.cameraCount", { count: cameraCount })}
<div className="flex items-center gap-1">
<FaVideo className="size-3" />
<div>{cameraCount}</div>
</div>
</div>
<div className="absolute inset-x-2 bottom-2 z-20 text-white">
@ -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<FrigateConfig>("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")}
</DropdownMenuItem>
)}
{isAdmin && onRemoveFromCase && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.removeFromCase")}
onClick={(e) => {
e.stopPropagation();
onRemoveFromCase(exportedRecording);
}}
>
{t("tooltip.removeFromCase")}
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
className="cursor-pointer"
@ -328,15 +332,11 @@ export function ExportCard({
{loading && (
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
)}
<div className="absolute left-3 top-3 z-30 rounded-full bg-black/55 px-2 py-1 text-xs text-white">
{cameraName}
</div>
<ImageShadowOverlay />
<div className="absolute bottom-2 left-3 z-30 text-white">
<div className="flex items-end smart-capitalize">
{exportedRecording.name.replaceAll("_", " ")}
</div>
<div className="mt-1 text-xs text-white/80">{formattedDate}</div>
</div>
</div>
</>
@ -354,15 +354,6 @@ export function ActiveExportJobCard({
}: ActiveExportJobCardProps) {
const { t } = useTranslation(["views/exports", "common"]);
const cameraName = useCameraFriendlyName(job.camera);
const { data: config } = useSWR<FrigateConfig>("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,
)}
>
<div className="absolute left-3 top-3 z-30 rounded-full bg-black/55 px-2 py-1 text-xs text-white">
{cameraName}
</div>
<div className="absolute right-3 top-3 z-30 rounded-full bg-selected/90 px-2 py-1 text-xs text-selected-foreground">
{statusLabel}
</div>
<div className="flex flex-col items-center gap-3 px-6 text-center">
<ActivityIndicator />
<div className="text-sm font-medium text-primary">{displayName}</div>
<div className="text-xs text-muted-foreground">{formattedDate}</div>
</div>
</div>
);

View File

@ -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<ExportJob[]>("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<Export[]>(
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}
/>
<Button
className="flex items-center gap-2.5 rounded-lg"
variant="default"
size="sm"
onClick={() => setCaseDialog({ mode: "create" })}
>
{t("toolbar.newCase")}
<LuFolderPlus className="size-4 text-secondary-foreground" />
<div className="text-primary">{t("toolbar.newCase")}</div>
</Button>
</div>
)}
@ -530,6 +574,7 @@ function Exports() {
size="sm"
onClick={() => setCaseForAddExport(selectedCase)}
>
<LuPlus className="size-4 text-secondary-foreground" />
<div className="text-primary">{t("toolbar.addExport")}</div>
</Button>
<Button
@ -539,6 +584,7 @@ function Exports() {
setCaseDialog({ mode: "edit", exportCase: selectedCase })
}
>
<LuPencil className="size-4 text-secondary-foreground" />
<div className="text-primary">{t("toolbar.editCase")}</div>
</Button>
<Button
@ -546,6 +592,7 @@ function Exports() {
size="sm"
onClick={() => setCaseToDelete(selectedCase)}
>
<LuTrash2 className="size-4 text-secondary-foreground" />
<div className="text-primary">{t("toolbar.deleteCase")}</div>
</Button>
</div>
@ -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}
/>
))}
</div>
@ -939,7 +990,7 @@ function CaseEditorDialog({
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="default"
variant="select"
disabled={name.trim().length === 0}
onClick={() =>
void onSave({
@ -979,11 +1030,15 @@ function CaseAddExportDialog({
}, [exportCase?.id]);
const filteredExports = useMemo(() => {
const completedExports = availableExports.filter(
(exportItem) => !exportItem.in_progress,
);
if (!search) {
return availableExports;
return completedExports;
}
return availableExports.filter((exportItem) =>
return completedExports.filter((exportItem) =>
exportItem.name.toLowerCase().includes(search.toLowerCase()),
);
}, [availableExports, search]);