mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
tweaks
- hide cases when filtering cameras that have no exports from those cameras - remove description from case card - use textarea instead of input for case description in add new case dialog
This commit is contained in:
parent
05913891e1
commit
ae8be19b5b
@ -39,7 +39,10 @@
|
|||||||
},
|
},
|
||||||
"deleteCase": {
|
"deleteCase": {
|
||||||
"label": "Delete Case",
|
"label": "Delete Case",
|
||||||
"desc": "Are you sure you want to delete {{caseName}}? Exports will remain available as uncategorized exports."
|
"desc": "Are you sure you want to delete {{caseName}}?",
|
||||||
|
"descKeepExports": "Exports will remain available as uncategorized exports.",
|
||||||
|
"descDeleteExports": "All exports in this case will be permanently deleted.",
|
||||||
|
"deleteExports": "Also delete exports"
|
||||||
},
|
},
|
||||||
"caseDialog": {
|
"caseDialog": {
|
||||||
"title": "Add to case",
|
"title": "Add to case",
|
||||||
|
|||||||
@ -87,11 +87,11 @@ export function CaseCard({
|
|||||||
<FaFolder />
|
<FaFolder />
|
||||||
<div className="truncate smart-capitalize">{exportCase.name}</div>
|
<div className="truncate smart-capitalize">{exportCase.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 line-clamp-2 text-xs text-white/80">
|
{exports.length === 0 && (
|
||||||
{exports.length === 0
|
<div className="mt-1 text-xs text-white/80">
|
||||||
? t("caseCard.emptyCase")
|
{t("caseCard.emptyCase")}
|
||||||
: exportCase.description}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -333,8 +333,8 @@ export function ExportCard({
|
|||||||
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
<Skeleton className="absolute inset-0 aspect-video rounded-lg md:rounded-2xl" />
|
||||||
)}
|
)}
|
||||||
<ImageShadowOverlay />
|
<ImageShadowOverlay />
|
||||||
<div className="absolute bottom-2 left-3 z-30 text-white">
|
<div className="absolute bottom-2 left-3 right-12 z-30 text-white">
|
||||||
<div className="flex items-end smart-capitalize">
|
<div className="truncate smart-capitalize">
|
||||||
{exportedRecording.name.replaceAll("_", " ")}
|
{exportedRecording.name.replaceAll("_", " ")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -133,9 +134,10 @@ export default function OptionAndInputDialog({
|
|||||||
<label className="text-sm font-medium text-secondary-foreground">
|
<label className="text-sm font-medium text-secondary-foreground">
|
||||||
{descriptionLabel}
|
{descriptionLabel}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Textarea
|
||||||
value={descriptionValue}
|
value={descriptionValue}
|
||||||
onChange={(e) => setDescriptionValue(e.target.value)}
|
onChange={(e) => setDescriptionValue(e.target.value)}
|
||||||
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import {
|
|||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
||||||
@ -83,7 +85,7 @@ function Exports() {
|
|||||||
|
|
||||||
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases");
|
const { data: cases, mutate: updateCases } = useSWR<ExportCase[]>("cases");
|
||||||
const { data: activeExportJobs } = useSWR<ExportJob[]>("jobs/export", {
|
const { data: activeExportJobs } = useSWR<ExportJob[]>("jobs/export", {
|
||||||
refreshInterval: 2000,
|
refreshInterval: (latestJobs) => ((latestJobs ?? []).length > 0 ? 2000 : 0),
|
||||||
});
|
});
|
||||||
// Keep polling exports while there are queued/running jobs OR while any
|
// Keep polling exports while there are queued/running jobs OR while any
|
||||||
// existing export is still marked in_progress. Without the second clause,
|
// existing export is still marked in_progress. Without the second clause,
|
||||||
@ -153,8 +155,21 @@ function Exports() {
|
|||||||
}, [rawExports]);
|
}, [rawExports]);
|
||||||
|
|
||||||
const filteredCases = useMemo<ExportCase[]>(() => {
|
const filteredCases = useMemo<ExportCase[]>(() => {
|
||||||
return cases || [];
|
if (!cases) return [];
|
||||||
}, [cases]);
|
|
||||||
|
const hasCameraFilter =
|
||||||
|
exportFilter?.cameras && exportFilter.cameras.length > 0;
|
||||||
|
|
||||||
|
if (!hasCameraFilter) return cases;
|
||||||
|
|
||||||
|
// When a camera filter is active, hide cases that have zero exports
|
||||||
|
// and zero active jobs matching the filter — they're just noise.
|
||||||
|
return cases.filter(
|
||||||
|
(c) =>
|
||||||
|
(exportsByCase[c.id]?.length ?? 0) > 0 ||
|
||||||
|
(activeJobsByCase[c.id]?.length ?? 0) > 0,
|
||||||
|
);
|
||||||
|
}, [activeJobsByCase, cases, exportFilter?.cameras, exportsByCase]);
|
||||||
|
|
||||||
const exports = useMemo<Export[]>(
|
const exports = useMemo<Export[]>(
|
||||||
() => exportsByCase["none"] || [],
|
() => exportsByCase["none"] || [],
|
||||||
@ -217,6 +232,7 @@ function Exports() {
|
|||||||
{ mode: "create" | "edit"; exportCase?: ExportCase } | undefined
|
{ mode: "create" | "edit"; exportCase?: ExportCase } | undefined
|
||||||
>();
|
>();
|
||||||
const [caseToDelete, setCaseToDelete] = useState<ExportCase | undefined>();
|
const [caseToDelete, setCaseToDelete] = useState<ExportCase | undefined>();
|
||||||
|
const [deleteExportsWithCase, setDeleteExportsWithCase] = useState(false);
|
||||||
const [caseForAddExport, setCaseForAddExport] = useState<
|
const [caseForAddExport, setCaseForAddExport] = useState<
|
||||||
ExportCase | undefined
|
ExportCase | undefined
|
||||||
>();
|
>();
|
||||||
@ -333,11 +349,14 @@ function Exports() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.delete(`cases/${caseToDelete.id}`);
|
await axios.delete(`cases/${caseToDelete.id}`, {
|
||||||
|
params: deleteExportsWithCase ? { delete_exports: true } : undefined,
|
||||||
|
});
|
||||||
if (selectedCaseId === caseToDelete.id) {
|
if (selectedCaseId === caseToDelete.id) {
|
||||||
setSelectedCaseId(undefined);
|
setSelectedCaseId(undefined);
|
||||||
}
|
}
|
||||||
setCaseToDelete(undefined);
|
setCaseToDelete(undefined);
|
||||||
|
setDeleteExportsWithCase(false);
|
||||||
mutate();
|
mutate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const apiError = error as {
|
const apiError = error as {
|
||||||
@ -351,7 +370,7 @@ function Exports() {
|
|||||||
position: "top-center",
|
position: "top-center",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [caseToDelete, mutate, selectedCaseId, t]);
|
}, [caseToDelete, deleteExportsWithCase, mutate, selectedCaseId, t]);
|
||||||
|
|
||||||
const handleAssignExportToCase = useCallback(
|
const handleAssignExportToCase = useCallback(
|
||||||
async (exportId: string, caseId: string) => {
|
async (exportId: string, caseId: string) => {
|
||||||
@ -457,7 +476,12 @@ function Exports() {
|
|||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={caseToDelete != undefined}
|
open={caseToDelete != undefined}
|
||||||
onOpenChange={() => setCaseToDelete(undefined)}
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setCaseToDelete(undefined);
|
||||||
|
setDeleteExportsWithCase(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@ -465,9 +489,25 @@ function Exports() {
|
|||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{t("deleteCase.desc", {
|
{t("deleteCase.desc", {
|
||||||
caseName: caseToDelete?.name,
|
caseName: caseToDelete?.name,
|
||||||
})}
|
})}{" "}
|
||||||
|
{deleteExportsWithCase
|
||||||
|
? t("deleteCase.descDeleteExports")
|
||||||
|
: t("deleteCase.descKeepExports")}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
|
<div className="flex items-center justify-start gap-6">
|
||||||
|
<Label
|
||||||
|
htmlFor="delete-exports-switch"
|
||||||
|
className="cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
{t("deleteCase.deleteExports")}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="delete-exports-switch"
|
||||||
|
checked={deleteExportsWithCase}
|
||||||
|
onCheckedChange={setDeleteExportsWithCase}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>
|
<AlertDialogCancel>
|
||||||
{t("button.cancel", { ns: "common" })}
|
{t("button.cancel", { ns: "common" })}
|
||||||
@ -526,7 +566,7 @@ function Exports() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full flex-col items-start space-y-2 pr-2 md:mb-2 lg:relative lg:h-10 lg:flex-row lg:items-center lg:space-y-0",
|
"flex w-full flex-col items-start space-y-2 md:mb-2 lg:relative lg:h-10 lg:flex-row lg:items-center lg:space-y-0",
|
||||||
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
|
isMobileOnly && "mb-2 h-auto flex-wrap gap-2 space-y-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -574,33 +614,50 @@ function Exports() {
|
|||||||
)}
|
)}
|
||||||
{selectedCase && (
|
{selectedCase && (
|
||||||
<div className="flex w-full items-center justify-end gap-2">
|
<div className="flex w-full items-center justify-end gap-2">
|
||||||
|
<ExportFilterGroup
|
||||||
|
className="justify-start"
|
||||||
|
filter={exportFilter}
|
||||||
|
filters={["cameras"]}
|
||||||
|
onUpdateFilter={setExportFilter}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1 md:gap-2">
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-2.5 rounded-lg"
|
className="flex items-center gap-2 p-2"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
aria-label={t("toolbar.addExport")}
|
||||||
onClick={() => setCaseForAddExport(selectedCase)}
|
onClick={() => setCaseForAddExport(selectedCase)}
|
||||||
>
|
>
|
||||||
<LuPlus className="size-4 text-secondary-foreground" />
|
<LuPlus className="size-4 text-secondary-foreground" />
|
||||||
|
{!isMobile && (
|
||||||
<div className="text-primary">{t("toolbar.addExport")}</div>
|
<div className="text-primary">{t("toolbar.addExport")}</div>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-2.5 rounded-lg"
|
className="flex items-center gap-2 p-2"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
aria-label={t("toolbar.editCase")}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setCaseDialog({ mode: "edit", exportCase: selectedCase })
|
setCaseDialog({ mode: "edit", exportCase: selectedCase })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LuPencil className="size-4 text-secondary-foreground" />
|
<LuPencil className="size-4 text-secondary-foreground" />
|
||||||
|
{!isMobile && (
|
||||||
<div className="text-primary">{t("toolbar.editCase")}</div>
|
<div className="text-primary">{t("toolbar.editCase")}</div>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-2.5 rounded-lg"
|
className="flex items-center gap-2 p-2"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
aria-label={t("toolbar.deleteCase")}
|
||||||
onClick={() => setCaseToDelete(selectedCase)}
|
onClick={() => setCaseToDelete(selectedCase)}
|
||||||
>
|
>
|
||||||
<LuTrash2 className="size-4 text-secondary-foreground" />
|
<LuTrash2 className="size-4 text-secondary-foreground" />
|
||||||
|
{!isMobile && (
|
||||||
<div className="text-primary">{t("toolbar.deleteCase")}</div>
|
<div className="text-primary">{t("toolbar.deleteCase")}</div>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user