1 Commits

Author SHA1 Message Date
2971fb298e Change artwork table to be persistent in its filters and sorting 2026-02-04 12:39:25 +01:00
5 changed files with 210 additions and 96 deletions

View File

@ -5,8 +5,8 @@
"": {
"name": "admin.gaertan.art",
"dependencies": {
"@aws-sdk/client-s3": "^3.974.0",
"@aws-sdk/s3-request-presigner": "^3.974.0",
"@aws-sdk/client-s3": "^3.980.0",
"@aws-sdk/s3-request-presigner": "^3.980.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@ -39,7 +39,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"archiver": "^7.0.1",
"better-auth": "^1.4.17",
"better-auth": "^1.4.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -50,9 +50,9 @@
"lucide-react": "^0.561.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"node-vibrant": "^4.0.3",
"nodemailer": "^7.0.12",
"pg": "^8.17.2",
"node-vibrant": "^4.0.4",
"nodemailer": "^7.0.13",
"pg": "^8.18.0",
"platejs": "^52.0.17",
"react": "19.2.4",
"react-day-picker": "^9.13.0",
@ -65,6 +65,7 @@
"tailwind-scrollbar-hide": "^4.0.0",
"uuid": "^13.0.0",
"zod": "^4.3.6",
"zustand": "^5.0.8",
},
"devDependencies": {
"@biomejs/biome": "2.2.0",
@ -73,7 +74,7 @@
"@types/culori": "^4.0.1",
"@types/date-fns": "^2.6.3",
"@types/node": "^20.19.30",
"@types/nodemailer": "^7.0.5",
"@types/nodemailer": "^7.0.9",
"@types/pg": "^8.16.0",
"@types/react": "19.2.10",
"@types/react-dom": "19.2.3",

View File

@ -71,6 +71,7 @@
"tailwind-merge": "^3.4.0",
"tailwind-scrollbar-hide": "^4.0.0",
"uuid": "^13.0.0",
"zustand": "^5.0.8",
"zod": "^4.3.6"
},
"devDependencies": {

View File

@ -5,8 +5,7 @@ import {
type ColumnDef,
flexRender,
getCoreRowModel,
type SortingState,
useReactTable,
useReactTable
} from "@tanstack/react-table";
import {
ArrowUpDown,
@ -48,6 +47,7 @@ import {
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
@ -64,6 +64,7 @@ import {
TableRow,
} from "@/components/ui/table";
import type { ArtworkTableRow } from "@/schemas/artworks/tableSchema";
import { useArtworksTableStore } from "@/stores/artworksTableStore";
import { MultiSelectFilter } from "./MultiSelectFilter";
// Client-side table for filtering, sorting, and managing artworks.
@ -141,6 +142,7 @@ function Chips(props: { items: { id: string; name: string }[]; max?: number }) {
}
function TriSelectInline(props: {
id?: string;
value: TriState;
onChange: (v: TriState) => void;
}) {
@ -149,7 +151,7 @@ function TriSelectInline(props: {
value={props.value}
onValueChange={(v) => props.onChange(v as TriState)}
>
<SelectTrigger className="h-9">
<SelectTrigger id={props.id} className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -163,29 +165,23 @@ function TriSelectInline(props: {
type Filters = {
name: string;
slug: string;
published: TriState;
needsWork: TriState;
categoryIds: string[];
};
export function ArtworksTable() {
const [sorting, setSorting] = useState<SortingState>([
{ id: "updatedAt", desc: true },
]);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(25);
const [filters, setFilters] = useState<Filters>({
name: "",
slug: "",
published: "any",
needsWork: "any",
categoryIds: [],
});
const sorting = useArtworksTableStore((s) => s.sorting);
const setSorting = useArtworksTableStore((s) => s.setSorting);
const pageIndex = useArtworksTableStore((s) => s.pageIndex);
const setPageIndex = useArtworksTableStore((s) => s.setPageIndex);
const pageSize = useArtworksTableStore((s) => s.pageSize);
const setPageSize = useArtworksTableStore((s) => s.setPageSize);
const filters = useArtworksTableStore((s) => s.filters) as Filters;
const setFilters = useArtworksTableStore((s) => s.setFilters);
const resetTable = useArtworksTableStore((s) => s.reset);
const debouncedName = useDebouncedValue(filters.name, 300);
const debouncedSlug = useDebouncedValue(filters.slug, 300);
const [rows, setRows] = useState<ArtworkTableRow[]>([]);
const [total, setTotal] = useState(0);
@ -401,9 +397,8 @@ export function ArtworksTable() {
manualSorting: true,
pageCount,
onSortingChange: (updater) => {
setSorting((prev) =>
typeof updater === "function" ? updater(prev) : updater,
);
const next = typeof updater === "function" ? updater(sorting) : updater;
setSorting(next);
setPageIndex(0);
},
getCoreRowModel: getCoreRowModel(),
@ -416,7 +411,6 @@ export function ArtworksTable() {
sorting,
filters: {
name: debouncedName || undefined,
slug: debouncedSlug || undefined,
published: filters.published,
needsWork: filters.needsWork,
categoryIds: filters.categoryIds.length
@ -433,16 +427,120 @@ export function ArtworksTable() {
pageSize,
sorting,
debouncedName,
debouncedSlug,
filters.published,
filters.needsWork,
filters.categoryIds,
]);
const headerGroup = table.getHeaderGroups()[0];
const hasChanges =
filters.name ||
filters.published !== "any" ||
filters.needsWork !== "any" ||
filters.categoryIds.length > 0 ||
pageIndex !== 0 ||
pageSize !== 25 ||
sorting.length !== 1 ||
sorting[0]?.id !== "updatedAt" ||
sorting[0]?.desc !== true;
return (
<div className="space-y-4">
<div className="rounded-2xl border border-border/60 bg-card p-4 shadow-sm">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-52 flex-1">
<Label
htmlFor="artworks-filter-name"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Name
</Label>
<Input
id="artworks-filter-name"
className="mt-2 h-9"
placeholder="Filter by name…"
value={filters.name}
onChange={(e) => {
setFilters((f) => ({ ...f, name: e.target.value }));
setPageIndex(0);
}}
/>
</div>
<div className="min-w-60 flex-1">
<Label
htmlFor="artworks-filter-categories"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Categories
</Label>
<div className="mt-2">
<MultiSelectFilter
id="artworks-filter-categories"
placeholder="Filter categories…"
options={categoryOptions}
value={filters.categoryIds}
onChange={(next) => {
setFilters((f) => ({ ...f, categoryIds: next }));
setPageIndex(0);
}}
/>
</div>
</div>
<div className="min-w-36">
<Label
htmlFor="artworks-filter-published"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Published
</Label>
<div className="mt-2">
<TriSelectInline
id="artworks-filter-published"
value={filters.published}
onChange={(v) => {
setFilters((f) => ({ ...f, published: v }));
setPageIndex(0);
}}
/>
</div>
</div>
<div className="min-w-36">
<Label
htmlFor="artworks-filter-needs-work"
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
Needs Work
</Label>
<div className="mt-2">
<TriSelectInline
id="artworks-filter-needs-work"
value={filters.needsWork}
onChange={(v) => {
setFilters((f) => ({ ...f, needsWork: v }));
setPageIndex(0);
}}
/>
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
className="h-9"
disabled={!hasChanges || isPending}
onClick={() => {
resetTable();
}}
>
Reset to default
</Button>
</div>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-sm ring-1 ring-border/40">
<div className="relative">
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-linear-to-b from-background/60 to-transparent" />
@ -464,70 +562,6 @@ export function ArtworksTable() {
</TableHead>
))}
</TableRow>
{/* filter row */}
<TableRow className="hover:bg-transparent">
{headerGroup.headers.map((header) => {
const colId = header.column.id;
return (
<TableHead
key={header.id}
className="border-b border-border/70 bg-muted/30 py-2"
>
{colId === "name" ? (
<Input
className="h-9"
placeholder="Filter name…"
value={filters.name}
onChange={(e) => {
setFilters((f) => ({ ...f, name: e.target.value }));
setPageIndex(0);
}}
/>
) : colId === "slug" ? (
<Input
className="h-9"
placeholder="Filter slug…"
value={filters.slug}
onChange={(e) => {
setFilters((f) => ({ ...f, slug: e.target.value }));
setPageIndex(0);
}}
/>
) : colId === "published" ? (
<TriSelectInline
value={filters.published}
onChange={(v) => {
setFilters((f) => ({ ...f, published: v }));
setPageIndex(0);
}}
/>
) : colId === "needsWork" ? (
<TriSelectInline
value={filters.needsWork}
onChange={(v) => {
setFilters((f) => ({ ...f, needsWork: v }));
setPageIndex(0);
}}
/>
) : colId === "categories" ? (
<MultiSelectFilter
placeholder="Categories…"
options={categoryOptions}
value={filters.categoryIds}
onChange={(next) => {
setFilters((f) => ({ ...f, categoryIds: next }));
setPageIndex(0);
}}
/>
) : (
<div className="h-9" />
)}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
@ -671,7 +705,6 @@ export function ArtworksTable() {
sorting,
filters: {
name: debouncedName || undefined,
slug: debouncedSlug || undefined,
published: filters.published,
needsWork: filters.needsWork,
categoryIds: filters.categoryIds.length

View File

@ -11,6 +11,7 @@ type Option = { id: string; name: string };
// Simple multi-select filter control for artwork filters.
export function MultiSelectFilter(props: {
id?: string;
placeholder: string;
options: Option[];
value: string[]; // selected ids
@ -21,7 +22,11 @@ export function MultiSelectFilter(props: {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between">
<Button
id={props.id}
variant="outline"
className="h-9 w-full justify-between"
>
<span className="truncate">
{props.value.length === 0
? props.placeholder

View File

@ -0,0 +1,74 @@
import type { SortingState } from "@tanstack/react-table";
import { create } from "zustand";
import { persist } from "zustand/middleware";
const defaultSorting: SortingState = [{ id: "updatedAt", desc: true }];
type ArtworksTableState = {
sorting: SortingState;
pageIndex: number;
pageSize: number;
filters: {
name: string;
published: "any" | "true" | "false";
needsWork: "any" | "true" | "false";
categoryIds: string[];
};
setSorting: (next: SortingState) => void;
setPageIndex: (next: number | ((prev: number) => number)) => void;
setPageSize: (next: number) => void;
setFilters: (
next:
| ArtworksTableState["filters"]
| ((prev: ArtworksTableState["filters"]) => ArtworksTableState["filters"]),
) => void;
reset: () => void;
};
export const useArtworksTableStore = create<ArtworksTableState>()(
persist(
(set) => ({
sorting: defaultSorting,
pageIndex: 0,
pageSize: 25,
filters: {
name: "",
published: "any",
needsWork: "any",
categoryIds: [],
},
setSorting: (next) => set({ sorting: next }),
setPageIndex: (next) =>
set((state) => {
const value = typeof next === "function" ? next(state.pageIndex) : next;
return { pageIndex: Math.max(0, value) };
}),
setPageSize: (next) => set({ pageSize: next }),
setFilters: (next) =>
set((state) => ({
filters: typeof next === "function" ? next(state.filters) : next,
})),
reset: () =>
set({
sorting: defaultSorting,
pageIndex: 0,
pageSize: 25,
filters: {
name: "",
published: "any",
needsWork: "any",
categoryIds: [],
},
}),
}),
{
name: "admin-artworks-table",
partialize: (state) => ({
sorting: state.sorting,
pageIndex: state.pageIndex,
pageSize: state.pageSize,
filters: state.filters,
}),
},
),
);