mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
tweaks
- add ability to remove from case - change location of counts in case card
This commit is contained in:
parent
eeff59216b
commit
0c70818a32
@ -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": {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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]);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user