Add filtering
This commit is contained in:
287
src/components/artworks/TagFilterDialog.tsx
Normal file
287
src/components/artworks/TagFilterDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user