- 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:
Josh Hawkins 2026-04-11 22:09:42 -05:00
parent 05913891e1
commit ae8be19b5b
4 changed files with 105 additions and 43 deletions

View File

@ -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",

View File

@ -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>

View File

@ -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>

View File

@ -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>