- 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", "downloadVideo": "Download video",
"editName": "Edit name", "editName": "Edit name",
"deleteExport": "Delete export", "deleteExport": "Delete export",
"assignToCase": "Add to case" "assignToCase": "Add to case",
"removeFromCase": "Remove from case"
}, },
"toolbar": { "toolbar": {
"newCase": "New Case", "newCase": "New Case",
@ -49,10 +50,6 @@
"descriptionLabel": "Description" "descriptionLabel": "Description"
}, },
"caseCard": { "caseCard": {
"exportCount_one": "1 export",
"exportCount_other": "{{count}} exports",
"cameraCount_one": "1 camera",
"cameraCount_other": "{{count}} cameras",
"emptyCase": "No exports yet" "emptyCase": "No exports yet"
}, },
"jobCard": { "jobCard": {

View File

@ -27,11 +27,9 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../ui/dropdown-menu"; } 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 { 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 = { type CaseCardProps = {
className: string; 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="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="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="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="rounded-full bg-black/55 px-2 py-1"> <div className="flex items-center gap-1">
{t("caseCard.exportCount", { count: exports.length })} <HiSquare2Stack className="size-3" />
<div>{exports.length}</div>
</div> </div>
<div className="rounded-full bg-black/55 px-2 py-1"> <div className="flex items-center gap-1">
{t("caseCard.cameraCount", { count: cameraCount })} <FaVideo className="size-3" />
<div>{cameraCount}</div>
</div> </div>
</div> </div>
<div className="absolute inset-x-2 bottom-2 z-20 text-white"> <div className="absolute inset-x-2 bottom-2 z-20 text-white">
@ -104,6 +104,7 @@ type ExportCardProps = {
onRename: (original: string, update: string) => void; onRename: (original: string, update: string) => void;
onDelete: ({ file, exportName }: DeleteClipType) => void; onDelete: ({ file, exportName }: DeleteClipType) => void;
onAssignToCase?: (selected: Export) => void; onAssignToCase?: (selected: Export) => void;
onRemoveFromCase?: (selected: Export) => void;
}; };
export function ExportCard({ export function ExportCard({
className, className,
@ -112,19 +113,10 @@ export function ExportCard({
onRename, onRename,
onDelete, onDelete,
onAssignToCase, onAssignToCase,
onRemoveFromCase,
}: ExportCardProps) { }: ExportCardProps) {
const { t } = useTranslation(["views/exports"]); const { t } = useTranslation(["views/exports"]);
const isAdmin = useIsAdmin(); 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( const [loading, setLoading] = useState(
exportedRecording.thumb_path.length > 0, exportedRecording.thumb_path.length > 0,
); );
@ -291,6 +283,18 @@ export function ExportCard({
{t("tooltip.assignToCase")} {t("tooltip.assignToCase")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && onRemoveFromCase && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("tooltip.removeFromCase")}
onClick={(e) => {
e.stopPropagation();
onRemoveFromCase(exportedRecording);
}}
>
{t("tooltip.removeFromCase")}
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"
@ -328,15 +332,11 @@ export function ExportCard({
{loading && ( {loading && (
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" /> <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 /> <ImageShadowOverlay />
<div className="absolute bottom-2 left-3 z-30 text-white"> <div className="absolute bottom-2 left-3 z-30 text-white">
<div className="flex items-end smart-capitalize"> <div className="flex items-end smart-capitalize">
{exportedRecording.name.replaceAll("_", " ")} {exportedRecording.name.replaceAll("_", " ")}
</div> </div>
<div className="mt-1 text-xs text-white/80">{formattedDate}</div>
</div> </div>
</div> </div>
</> </>
@ -354,15 +354,6 @@ export function ActiveExportJobCard({
}: ActiveExportJobCardProps) { }: ActiveExportJobCardProps) {
const { t } = useTranslation(["views/exports", "common"]); const { t } = useTranslation(["views/exports", "common"]);
const cameraName = useCameraFriendlyName(job.camera); 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(() => { const displayName = useMemo(() => {
if (job.name && job.name.length > 0) { if (job.name && job.name.length > 0) {
return job.name.replaceAll("_", " "); return job.name.replaceAll("_", " ");
@ -382,16 +373,12 @@ export function ActiveExportJobCard({
className, 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"> <div className="absolute right-3 top-3 z-30 rounded-full bg-selected/90 px-2 py-1 text-xs text-selected-foreground">
{statusLabel} {statusLabel}
</div> </div>
<div className="flex flex-col items-center gap-3 px-6 text-center"> <div className="flex flex-col items-center gap-3 px-6 text-center">
<ActivityIndicator /> <ActivityIndicator />
<div className="text-sm font-medium text-primary">{displayName}</div> <div className="text-sm font-medium text-primary">{displayName}</div>
<div className="text-xs text-muted-foreground">{formattedDate}</div>
</div> </div>
</div> </div>
); );

View File

@ -48,7 +48,13 @@ import { isMobile, isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IoMdArrowRoundBack } from "react-icons/io"; 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 { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
import ExportFilterGroup from "@/components/filter/ExportFilterGroup"; import ExportFilterGroup from "@/components/filter/ExportFilterGroup";
@ -74,12 +80,25 @@ function Exports() {
const { data: activeExportJobs } = useSWR<ExportJob[]>("jobs/export", { const { data: activeExportJobs } = useSWR<ExportJob[]>("jobs/export", {
refreshInterval: 2000, 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[]>( const { data: rawExports, mutate: updateExports } = useSWR<Export[]>(
exportSearchParams && Object.keys(exportSearchParams).length > 0 exportSearchParams && Object.keys(exportSearchParams).length > 0
? ["exports", exportSearchParams] ? ["exports", exportSearchParams]
: "exports", : "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], [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(() => { const resetCaseDialog = useCallback(() => {
setExportToAssign(undefined); setExportToAssign(undefined);
}, []); }, []);
@ -515,11 +557,13 @@ function Exports() {
onUpdateFilter={setExportFilter} onUpdateFilter={setExportFilter}
/> />
<Button <Button
className="flex items-center gap-2.5 rounded-lg"
variant="default" variant="default"
size="sm" size="sm"
onClick={() => setCaseDialog({ mode: "create" })} onClick={() => setCaseDialog({ mode: "create" })}
> >
{t("toolbar.newCase")} <LuFolderPlus className="size-4 text-secondary-foreground" />
<div className="text-primary">{t("toolbar.newCase")}</div>
</Button> </Button>
</div> </div>
)} )}
@ -530,6 +574,7 @@ function Exports() {
size="sm" size="sm"
onClick={() => setCaseForAddExport(selectedCase)} onClick={() => setCaseForAddExport(selectedCase)}
> >
<LuPlus className="size-4 text-secondary-foreground" />
<div className="text-primary">{t("toolbar.addExport")}</div> <div className="text-primary">{t("toolbar.addExport")}</div>
</Button> </Button>
<Button <Button
@ -539,6 +584,7 @@ function Exports() {
setCaseDialog({ mode: "edit", exportCase: selectedCase }) setCaseDialog({ mode: "edit", exportCase: selectedCase })
} }
> >
<LuPencil className="size-4 text-secondary-foreground" />
<div className="text-primary">{t("toolbar.editCase")}</div> <div className="text-primary">{t("toolbar.editCase")}</div>
</Button> </Button>
<Button <Button
@ -546,6 +592,7 @@ function Exports() {
size="sm" size="sm"
onClick={() => setCaseToDelete(selectedCase)} onClick={() => setCaseToDelete(selectedCase)}
> >
<LuTrash2 className="size-4 text-secondary-foreground" />
<div className="text-primary">{t("toolbar.deleteCase")}</div> <div className="text-primary">{t("toolbar.deleteCase")}</div>
</Button> </Button>
</div> </div>
@ -564,6 +611,7 @@ function Exports() {
renameClip={onHandleRename} renameClip={onHandleRename}
setDeleteClip={setDeleteClip} setDeleteClip={setDeleteClip}
onAssignToCase={setExportToAssign} onAssignToCase={setExportToAssign}
onRemoveFromCase={handleRemoveExportFromCase}
onAddExport={() => setCaseForAddExport(selectedCase)} onAddExport={() => setCaseForAddExport(selectedCase)}
/> />
) : ( ) : (
@ -730,6 +778,7 @@ type CaseViewProps = {
renameClip: (id: string, update: string) => void; renameClip: (id: string, update: string) => void;
setDeleteClip: (d: DeleteClipType | undefined) => void; setDeleteClip: (d: DeleteClipType | undefined) => void;
onAssignToCase: (e: Export) => void; onAssignToCase: (e: Export) => void;
onRemoveFromCase: (e: Export) => void;
onAddExport: () => void; onAddExport: () => void;
}; };
function CaseView({ function CaseView({
@ -743,6 +792,7 @@ function CaseView({
renameClip, renameClip,
setDeleteClip, setDeleteClip,
onAssignToCase, onAssignToCase,
onRemoveFromCase,
onAddExport, onAddExport,
}: CaseViewProps) { }: CaseViewProps) {
const { t } = useTranslation(["views/exports", "common"]); const { t } = useTranslation(["views/exports", "common"]);
@ -868,6 +918,7 @@ function CaseView({
setDeleteClip({ file, exportName }) setDeleteClip({ file, exportName })
} }
onAssignToCase={onAssignToCase} onAssignToCase={onAssignToCase}
onRemoveFromCase={onRemoveFromCase}
/> />
))} ))}
</div> </div>
@ -939,7 +990,7 @@ function CaseEditorDialog({
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</Button> </Button>
<Button <Button
variant="default" variant="select"
disabled={name.trim().length === 0} disabled={name.trim().length === 0}
onClick={() => onClick={() =>
void onSave({ void onSave({
@ -979,11 +1030,15 @@ function CaseAddExportDialog({
}, [exportCase?.id]); }, [exportCase?.id]);
const filteredExports = useMemo(() => { const filteredExports = useMemo(() => {
const completedExports = availableExports.filter(
(exportItem) => !exportItem.in_progress,
);
if (!search) { if (!search) {
return availableExports; return completedExports;
} }
return availableExports.filter((exportItem) => return completedExports.filter((exportItem) =>
exportItem.name.toLowerCase().includes(search.toLowerCase()), exportItem.name.toLowerCase().includes(search.toLowerCase()),
); );
}, [availableExports, search]); }, [availableExports, search]);