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",
|
"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": {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user