Rework artwork list

This commit is contained in:
2025-12-23 19:55:08 +01:00
parent 784153e9f6
commit 1363697103
9 changed files with 869 additions and 31 deletions

View File

@ -0,0 +1,490 @@
"use client";
import {
ColumnDef,
SortingState,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import Image from "next/image";
import Link from "next/link";
import * as React from "react";
import { getArtworksTablePage } from "@/actions/artworks/getArtworksTablePage";
import { getArtworkFilterOptions } from "@/actions/artworks/getArtworkFilterOptions";
import { Button } from "@/components/ui/button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ArtworkTableRow } from "@/schemas/artworks/tableSchema";
import { MultiSelectFilter } from "./MultiSelectFilter";
type TriState = "any" | "true" | "false";
function useDebouncedValue<T>(value: T, delayMs: number) {
const [debounced, setDebounced] = React.useState(value);
React.useEffect(() => {
const t = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(t);
}, [value, delayMs]);
return debounced;
}
type Filters = {
name: string;
slug: string;
published: TriState;
nsfw: TriState;
needsWork: TriState;
albumIds: string[];
categoryIds: string[];
};
export function ArtworksTable() {
const [sorting, setSorting] = React.useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [pageIndex, setPageIndex] = React.useState(0);
const [pageSize, setPageSize] = React.useState(25);
const [albumOptions, setAlbumOptions] = React.useState<{ id: string; name: string }[]>([]);
const [categoryOptions, setCategoryOptions] = React.useState<{ id: string; name: string }[]>([]);
const [filters, setFilters] = React.useState<Filters>({
name: "",
slug: "",
published: "any",
nsfw: "any",
needsWork: "any",
albumIds: [],
categoryIds: [],
});
const debouncedFilters = {
...filters,
name: useDebouncedValue(filters.name, 300),
slug: useDebouncedValue(filters.slug, 300),
};
const [rows, setRows] = React.useState<ArtworkTableRow[]>([]);
const [total, setTotal] = React.useState(0);
const [isLoading, startTransition] = React.useTransition();
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const columns = React.useMemo<ColumnDef<ArtworkTableRow>[]>(
() => [
{
id: "preview",
header: "Preview",
cell: ({ row }) => {
const fileKey = row.original.fileKey;
const url = `/api/image/thumbnail/${fileKey}.webp`;
return (
<HoverCard openDelay={150} closeDelay={100}>
<HoverCardTrigger asChild>
<div className="relative h-12 w-12 overflow-hidden rounded border bg-muted">
<Image
src={url}
alt={row.original.name}
fill
sizes="48px"
className="object-cover"
/>
</div>
</HoverCardTrigger>
<HoverCardContent className="w-[420px]">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded border bg-muted">
<Image
src={url}
alt={row.original.name}
fill
sizes="420px"
className="object-contain"
/>
</div>
<div className="mt-2 text-sm font-medium">{row.original.name}</div>
<div className="text-xs text-muted-foreground">{row.original.slug}</div>
</HoverCardContent>
</HoverCard>
);
},
enableSorting: false,
},
{
accessorKey: "name",
header: ({ column }) => (
<button
className="text-left font-medium"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Name
</button>
),
cell: ({ row }) => (
<Link
href={`/artworks/${row.original.id}`}
className="max-w-[420px] truncate underline-offset-4 hover:underline"
>
{row.original.name}
</Link>
),
},
{
accessorKey: "slug",
header: "Slug",
cell: ({ row }) => (
<div className="max-w-[240px] truncate text-muted-foreground">
{row.original.slug}
</div>
),
},
{
id: "gallery",
header: "Gallery",
cell: ({ row }) => row.original.gallery?.name ?? "—",
enableSorting: false,
},
{
id: "albums",
header: "Albums",
cell: ({ row }) => (
<div className="max-w-[260px] truncate">
{row.original.albums.map((a) => a.name).join(", ") || "—"}
</div>
),
enableSorting: false,
},
{
accessorKey: "albumsCount",
header: "Albums #",
cell: ({ row }) => row.original.albumsCount,
},
{
id: "categories",
header: "Categories",
cell: ({ row }) => (
<div className="max-w-[260px] truncate">
{row.original.categories.map((c) => c.name).join(", ") || "—"}
</div>
),
enableSorting: false,
},
{
accessorKey: "categoriesCount",
header: "Categories #",
cell: ({ row }) => row.original.categoriesCount,
},
{
accessorKey: "published",
header: "Published",
cell: ({ row }) => (row.original.published ? "Yes" : "No"),
},
{
accessorKey: "nsfw",
header: "NSFW",
cell: ({ row }) => (row.original.nsfw ? "Yes" : "No"),
},
{
accessorKey: "needsWork",
header: "Needs Work",
cell: ({ row }) => (row.original.needsWork ? "Yes" : "No"),
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<button
className="text-left font-medium"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
created
</button>
),
cell: ({ row }) => new Date(row.original.createdAt).toLocaleString(),
},
],
[],
);
const table = useReactTable({
data: rows,
columns,
state: { sorting },
manualPagination: true,
manualSorting: true,
pageCount,
onSortingChange: (updater) => {
setSorting((prev) => {
const next = typeof updater === "function" ? updater(prev) : updater;
return next;
});
setPageIndex(0);
},
getCoreRowModel: getCoreRowModel(),
});
React.useEffect(() => {
startTransition(async () => {
const res = await getArtworkFilterOptions();
setAlbumOptions(res.albums);
setCategoryOptions(res.categories);
});
}, []);
React.useEffect(() => {
startTransition(async () => {
const res = await getArtworksTablePage({
pagination: { pageIndex, pageSize },
sorting,
filters: {
name: debouncedFilters.name || undefined,
slug: debouncedFilters.slug || undefined,
published: debouncedFilters.published,
nsfw: debouncedFilters.nsfw,
needsWork: debouncedFilters.needsWork,
albumIds: filters.albumIds.length ? filters.albumIds : undefined,
categoryIds: filters.categoryIds.length ? filters.categoryIds : undefined,
},
});
setRows(res.rows);
setTotal(res.total);
});
}, [
pageIndex,
pageSize,
sorting,
debouncedFilters.name,
debouncedFilters.slug,
debouncedFilters.published,
debouncedFilters.nsfw,
debouncedFilters.needsWork,
]);
// Render: header row + filter row (only)
const headerGroup = table.getHeaderGroups()[0];
return (
<div className="space-y-4">
<div className="rounded border">
<Table>
<TableHeader>
<TableRow>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="whitespace-nowrap">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
{/* column filter row */}
<TableRow>
{headerGroup.headers.map((header) => {
const colId = header.column.id;
return (
<TableHead key={header.id}>
{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 === "nsfw" ? (
<TriSelectInline
value={filters.nsfw}
onChange={(v) => {
setFilters((f) => ({ ...f, nsfw: v }));
setPageIndex(0);
}}
/>
) : colId === "needsWork" ? (
<TriSelectInline
value={filters.needsWork}
onChange={(v) => {
setFilters((f) => ({ ...f, needsWork: v }));
setPageIndex(0);
}}
/>
) : colId === "albums" ? (
<MultiSelectFilter
placeholder="Filter albums…"
options={albumOptions}
value={filters.albumIds}
onChange={(next) => {
setFilters((f) => ({ ...f, albumIds: next }));
setPageIndex(0);
}}
/>
) : colId === "categories" ? (
<MultiSelectFilter
placeholder="Filter categories…"
options={categoryOptions}
value={filters.categoryIds}
onChange={(next) => {
setFilters((f) => ({ ...f, categoryIds: next }));
setPageIndex(0);
}}
/>
) : (
<div className="h-9" />
)}
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="py-10 text-center">
{isLoading ? "Loading…" : "No results."}
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((r) => (
<TableRow key={r.id}>
{r.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* pagination only (no redundant filters) */}
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-muted-foreground">
{isLoading ? "Updating…" : null} Total: {total}
</div>
<div className="flex items-center gap-2">
<Select
value={String(pageSize)}
onValueChange={(v) => {
setPageSize(Number(v));
setPageIndex(0);
}}
>
<SelectTrigger className="h-9 w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[10, 25, 50, 100].map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / page
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
className="h-9"
onClick={() => setPageIndex(0)}
disabled={pageIndex === 0 || isLoading}
>
First
</Button>
<Button
variant="outline"
className="h-9"
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
disabled={pageIndex === 0 || isLoading}
>
Prev
</Button>
<div className="text-sm tabular-nums">
Page {pageIndex + 1} / {pageCount}
</div>
<Button
variant="outline"
className="h-9"
onClick={() => setPageIndex((p) => Math.min(pageCount - 1, p + 1))}
disabled={pageIndex >= pageCount - 1 || isLoading}
>
Next
</Button>
<Button
variant="outline"
className="h-9"
onClick={() => setPageIndex(Math.max(0, pageCount - 1))}
disabled={pageIndex >= pageCount - 1 || isLoading}
>
Last
</Button>
</div>
</div>
</div>
);
}
function TriSelectInline(props: {
value: TriState;
onChange: (v: TriState) => void;
}) {
return (
<Select value={props.value} onValueChange={(v) => props.onChange(v as TriState)}>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any</SelectItem>
<SelectItem value="true">Yes</SelectItem>
<SelectItem value="false">No</SelectItem>
</SelectContent>
</Select>
);
}

View File

@ -0,0 +1,61 @@
"use client";
import { Check, ChevronsUpDown } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
type Option = { id: string; name: string };
export function MultiSelectFilter(props: {
placeholder: string;
options: Option[];
value: string[]; // selected ids
onChange: (next: string[]) => void;
}) {
const selected = React.useMemo(() => new Set(props.value), [props.value]);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between">
<span className="truncate">
{props.value.length === 0
? props.placeholder
: `${props.value.length} selected`}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="Search…" />
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup>
{props.options.map((opt) => {
const isOn = selected.has(opt.id);
return (
<CommandItem
key={opt.id}
value={opt.name}
onSelect={() => {
const next = new Set(selected);
if (isOn) next.delete(opt.id);
else next.add(opt.id);
props.onChange(Array.from(next));
}}
>
<Check className={`mr-2 h-4 w-4 ${isOn ? "opacity-100" : "opacity-0"}`} />
<span className="truncate">{opt.name}</span>
</CommandItem>
);
})}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }