Add filtering

This commit is contained in:
2025-12-21 20:20:07 +01:00
parent 6fc8c41a83
commit b41a4d2908
11 changed files with 707 additions and 24 deletions

View File

@ -0,0 +1,287 @@
"use client";
import { FilterIcon, XIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
type Tag = {
id: string;
name: string;
slug: string;
sortIndex: number;
parentId: string | null;
// these may exist, but we do NOT rely on them:
parent?: { id: string; name: string; slug: string; sortIndex: number } | null;
children?: { id: string; name: string; slug: string; sortIndex: number; parentId: string | null }[];
};
function toTagsParam(slugs: string[]) {
return slugs.join(",");
}
function uniq(arr: string[]) {
return Array.from(new Set(arr));
}
function sortTags(a: Tag, b: Tag) {
return (a.sortIndex - b.sortIndex) || a.name.localeCompare(b.name);
}
export default function TagFilterDialog({
tags,
selectedTagSlugs,
}: {
tags: Tag[];
selectedTagSlugs: string[];
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [open, setOpen] = React.useState(false);
const [draft, setDraft] = React.useState<string[]>(() => selectedTagSlugs);
React.useEffect(() => {
setDraft(selectedTagSlugs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTagSlugs.join(",")]);
const hasDraft = draft.length > 0;
const selectedSet = React.useMemo(() => new Set(draft), [draft]);
const byId = React.useMemo(() => new Map(tags.map((t) => [t.id, t])), [tags]);
// Build children mapping from the flat list: parentId -> Tag[]
const childrenByParentId = React.useMemo(() => {
const map = new Map<string, Tag[]>();
for (const t of tags) {
if (!t.parentId) continue;
const arr = map.get(t.parentId) ?? [];
arr.push(t);
map.set(t.parentId, arr);
}
// sort each child list
for (const [k, arr] of map) {
map.set(k, arr.slice().sort(sortTags));
}
return map;
}, [tags]);
const rootGroups = React.useMemo(() => {
return tags
.filter((t) => t.parentId === null)
.slice()
.sort(sortTags);
}, [tags]);
const orphanChildren = React.useMemo(() => {
return tags
.filter((t) => t.parentId !== null && !byId.has(t.parentId))
.slice()
.sort(sortTags);
}, [tags, byId]);
const onToggleParent = (parent: Tag, next: boolean) => {
const children = childrenByParentId.get(parent.id) ?? [];
setDraft((prev) => {
const s = new Set(prev);
if (next) {
s.add(parent.slug);
// when selecting parent, remove child selections (redundant)
for (const c of children) s.delete(c.slug);
} else {
s.delete(parent.slug);
}
return Array.from(s);
});
};
const onToggleChild = (childSlug: string, next: boolean) => {
setDraft((prev) => {
const s = new Set(prev);
if (next) s.add(childSlug);
else s.delete(childSlug);
return Array.from(s);
});
};
const clearAll = () => setDraft([]);
const apply = () => {
const sp = new URLSearchParams(searchParams?.toString());
const normalized = uniq(draft).sort();
if (normalized.length) sp.set("tags", toTagsParam(normalized));
else sp.delete("tags");
router.replace(`${pathname}?${sp.toString()}`, { scroll: false });
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="default" className="h-11 gap-2">
<FilterIcon className="h-4 w-4" />
Filter by tag
{selectedTagSlugs.length > 0 ? (
<Badge variant="secondary" className="ml-1">
{selectedTagSlugs.length}
</Badge>
) : null}
</Button>
</DialogTrigger>
<DialogContent className="p-0 sm:max-w-2xl">
<DialogHeader className="px-6 pt-6">
<DialogTitle className="flex items-center justify-between gap-3">
<span>Filter artworks by tag</span>
{hasDraft ? (
<Button
variant="ghost"
size="sm"
onClick={clearAll}
className="gap-2"
aria-label="Clear all selected tags"
>
<XIcon className="h-4 w-4" />
Clear all tags
</Button>
) : null}
</DialogTitle>
<p className="text-sm text-muted-foreground">
Select one or more tags. Selecting a parent also adds its children.
</p>
</DialogHeader>
<Separator />
<ScrollArea className="max-h-[60vh] px-6 py-4">
<div className="space-y-5">
{rootGroups.map((p) => {
const children = childrenByParentId.get(p.id) ?? [];
const parentSelected = selectedSet.has(p.slug);
return (
<div key={p.id} className="rounded-lg border p-4">
<div className="flex items-start justify-between gap-3">
<label className="flex cursor-pointer items-center gap-3">
<Checkbox
checked={parentSelected}
onCheckedChange={(v) => onToggleParent(p, Boolean(v))}
/>
<div className="min-w-0">
<div className="truncate font-medium">{p.name}</div>
{/* <div className="text-xs text-muted-foreground">
{children.length ? "Parent tag" : "Tag"}
</div> */}
</div>
</label>
<Badge variant={parentSelected ? "default" : "outline"}>
{children.length} sub
</Badge>
</div>
{children.length ? (
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
{children.map((c) => {
const childSelected = selectedSet.has(c.slug);
const disabled = parentSelected;
return (
<label
key={c.id}
className={cn(
"flex items-center gap-3 rounded-md border px-3 py-2",
disabled ? "opacity-50" : "hover:bg-muted/50",
disabled ? "cursor-not-allowed" : "cursor-pointer"
)}
>
<Checkbox
checked={childSelected}
disabled={disabled}
onCheckedChange={(v) => onToggleChild(c.slug, Boolean(v))}
/>
<span className="min-w-0 truncate text-sm">{c.name}</span>
</label>
);
})}
</div>
) : null}
</div>
);
})}
{orphanChildren.length ? (
<div className="rounded-lg border p-4">
{/* <div className="mb-2 font-medium">Other tags</div> */}
{/* <div className="mb-3 text-xs text-muted-foreground">
These tags are not currently assigned to a visible parent.
</div> */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{orphanChildren.map((t) => {
const checked = selectedSet.has(t.slug);
return (
<label
key={t.id}
className="flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2 hover:bg-muted/50"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => onToggleChild(t.slug, Boolean(v))}
/>
<span className="min-w-0 truncate text-sm">{t.name}</span>
</label>
);
})}
</div>
</div>
) : null}
{rootGroups.length === 0 && orphanChildren.length === 0 ? (
<div className="text-sm text-muted-foreground">
No tags available for filtering.
</div>
) : null}
</div>
</ScrollArea>
<Separator />
<div className="flex flex-col gap-3 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
Selected: <span className="font-medium text-foreground">{draft.length}</span>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:gap-2">
<Button variant="ghost" onClick={clearAll} disabled={!hasDraft}>
Clear all tags
</Button>
<Button variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={apply}>Apply</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}