Rework artwork list

This commit is contained in:
2025-12-23 20:10:42 +01:00
parent 1363697103
commit 56142dbe73
4 changed files with 601 additions and 237 deletions

View File

@ -9,6 +9,7 @@
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
@ -322,6 +323,8 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
@ -930,6 +933,8 @@
"@prisma/get-platform/@prisma/debug": ["@prisma/debug@6.8.2", "", {}, "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg=="], "@prisma/get-platform/@prisma/debug": ["@prisma/debug@6.8.2", "", {}, "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],

View File

@ -15,6 +15,7 @@
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",

View File

@ -7,14 +7,43 @@ import {
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import {
ArrowUpDown,
ChevronDown,
ChevronUp,
MoreHorizontal,
Pencil,
Trash2,
} from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import * as React from "react"; import * as React from "react";
import { getArtworksTablePage } from "@/actions/artworks/getArtworksTablePage"; import { deleteArtwork } from "@/actions/artworks/deleteArtwork";
import { getArtworkFilterOptions } from "@/actions/artworks/getArtworkFilterOptions"; import { getArtworkFilterOptions } from "@/actions/artworks/getArtworkFilterOptions";
import { getArtworksTablePage } from "@/actions/artworks/getArtworksTablePage";
// import type { ArtworkTableRow } from "@/lib/artworks/artworkTableSchema";
// import { MultiSelectFilter } from "@/components/admin/MultiSelectFilter";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
HoverCard, HoverCard,
HoverCardContent, HoverCardContent,
@ -50,13 +79,86 @@ function useDebouncedValue<T>(value: T, delayMs: number) {
return debounced; return debounced;
} }
function SortHeader(props: {
title: string;
column: any; // TanStack Column<TData, TValue>
}) {
const sorted = props.column.getIsSorted() as false | "asc" | "desc";
return (
<button
type="button"
onClick={() => props.column.toggleSorting(sorted === "asc")}
className="group inline-flex items-center gap-2 font-semibold text-foreground/90 hover:text-foreground"
>
<span>{props.title}</span>
{sorted === "asc" ? (
<ChevronUp className="h-4 w-4" />
) : sorted === "desc" ? (
<ChevronDown className="h-4 w-4" />
) : (
<ArrowUpDown className="h-4 w-4 opacity-60 group-hover:opacity-100" />
)}
</button>
);
}
function YesNoBadge(props: { value: boolean; variant?: "default" | "secondary" }) {
return (
<Badge variant={props.value ? "default" : "secondary"} className="px-2 py-0.5">
{props.value ? "Yes" : "No"}
</Badge>
);
}
function Chips(props: { items: { id: string; name: string }[]; max?: number }) {
const max = props.max ?? 2;
const shown = props.items.slice(0, max);
const rest = props.items.length - shown.length;
return (
<div className="flex flex-wrap gap-1">
{shown.map((i) => (
<span
key={i.id}
className="inline-flex items-center rounded-md border bg-muted/40 px-2 py-0.5 text-xs text-foreground/80"
>
{i.name}
</span>
))}
{rest > 0 ? (
<span className="inline-flex items-center rounded-md border bg-muted/40 px-2 py-0.5 text-xs text-foreground/70">
+{rest}
</span>
) : null}
</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>
);
}
type Filters = { type Filters = {
name: string; name: string;
slug: string; slug: string;
published: TriState; published: TriState;
nsfw: TriState; nsfw: TriState;
needsWork: TriState; needsWork: TriState;
albumIds: string[]; albumIds: string[];
categoryIds: string[]; categoryIds: string[];
}; };
@ -67,8 +169,6 @@ export function ArtworksTable() {
]); ]);
const [pageIndex, setPageIndex] = React.useState(0); const [pageIndex, setPageIndex] = React.useState(0);
const [pageSize, setPageSize] = React.useState(25); 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>({ const [filters, setFilters] = React.useState<Filters>({
name: "", name: "",
@ -80,18 +180,32 @@ export function ArtworksTable() {
categoryIds: [], categoryIds: [],
}); });
const debouncedFilters = { const debouncedName = useDebouncedValue(filters.name, 300);
...filters, const debouncedSlug = useDebouncedValue(filters.slug, 300);
name: useDebouncedValue(filters.name, 300),
slug: useDebouncedValue(filters.slug, 300),
};
const [rows, setRows] = React.useState<ArtworkTableRow[]>([]); const [rows, setRows] = React.useState<ArtworkTableRow[]>([]);
const [total, setTotal] = React.useState(0); const [total, setTotal] = React.useState(0);
const [isLoading, startTransition] = React.useTransition();
const [albumOptions, setAlbumOptions] = React.useState<{ id: string; name: string }[]>([]);
const [categoryOptions, setCategoryOptions] = React.useState<{ id: string; name: string }[]>([]);
const [isPending, startTransition] = React.useTransition();
// Delete dialog
const [deleteOpen, setDeleteOpen] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState<{ id: string; name: string } | null>(null);
const pageCount = Math.max(1, Math.ceil(total / pageSize)); const pageCount = Math.max(1, Math.ceil(total / pageSize));
React.useEffect(() => {
startTransition(async () => {
const res = await getArtworkFilterOptions();
setAlbumOptions(res.albums);
setCategoryOptions(res.categories);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const columns = React.useMemo<ColumnDef<ArtworkTableRow>[]>( const columns = React.useMemo<ColumnDef<ArtworkTableRow>[]>(
() => [ () => [
{ {
@ -102,9 +216,9 @@ export function ArtworksTable() {
const url = `/api/image/thumbnail/${fileKey}.webp`; const url = `/api/image/thumbnail/${fileKey}.webp`;
return ( return (
<HoverCard openDelay={150} closeDelay={100}> <HoverCard openDelay={140} closeDelay={80}>
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
<div className="relative h-12 w-12 overflow-hidden rounded border bg-muted"> <div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg border bg-muted shadow-sm">
<Image <Image
src={url} src={url}
alt={row.original.name} alt={row.original.name}
@ -114,18 +228,37 @@ export function ArtworksTable() {
/> />
</div> </div>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent className="w-[420px]"> <HoverCardContent className="w-[520px]">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded border bg-muted"> <div className="flex gap-3">
<div className="relative aspect-[4/3] w-[320px] overflow-hidden rounded-lg border bg-muted">
<Image <Image
src={url} src={url}
alt={row.original.name} alt={row.original.name}
fill fill
sizes="420px" sizes="320px"
className="object-contain" className="object-contain"
/> />
</div> </div>
<div className="mt-2 text-sm font-medium">{row.original.name}</div> <div className="min-w-0 flex-1">
<div className="text-xs text-muted-foreground">{row.original.slug}</div> <div className="truncate text-sm font-semibold">{row.original.name}</div>
<div className="truncate text-xs text-muted-foreground">{row.original.slug}</div>
<div className="mt-3 flex flex-wrap gap-2">
<YesNoBadge value={row.original.published} />
<YesNoBadge value={row.original.nsfw} variant="secondary" />
<YesNoBadge value={row.original.needsWork} variant="secondary" />
</div>
<div className="mt-3 space-y-2 text-xs">
<div className="text-muted-foreground">
Created: {new Date(row.original.createdAt).toLocaleString()}
</div>
<div className="text-muted-foreground">
Updated: {new Date(row.original.updatedAt).toLocaleString()}
</div>
</div>
</div>
</div>
</HoverCardContent> </HoverCardContent>
</HoverCard> </HoverCard>
); );
@ -134,94 +267,123 @@ export function ArtworksTable() {
}, },
{ {
accessorKey: "name", accessorKey: "name",
header: ({ column }) => ( header: ({ column }) => <SortHeader title="Name" column={column} />,
<button
className="text-left font-medium"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Name
</button>
),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="min-w-0 max-w-[340px]">
<Link <Link
href={`/artworks/${row.original.id}`} href={`/artworks/${row.original.id}`}
className="max-w-[420px] truncate underline-offset-4 hover:underline" className="block truncate text-sm font-medium leading-5 underline-offset-4 hover:underline"
title={row.original.name}
> >
{row.original.name} {row.original.name}
</Link> </Link>
), <div className="truncate text-[11px] leading-4 text-muted-foreground">
},
{
accessorKey: "slug",
header: "Slug",
cell: ({ row }) => (
<div className="max-w-[240px] truncate text-muted-foreground">
{row.original.slug} {row.original.slug}
</div> </div>
</div>
), ),
}, },
{ {
id: "gallery", id: "gallery",
header: "Gallery", header: "Gallery",
cell: ({ row }) => row.original.gallery?.name ?? "—", cell: ({ row }) => (
<span className="text-sm text-foreground/80">
{row.original.gallery?.name ?? "—"}
</span>
),
enableSorting: false, enableSorting: false,
}, },
{ {
id: "albums", id: "albums",
header: "Albums", header: ({ column }) => <SortHeader title="Albums #" column={column} />,
accessorKey: "albumsCount",
cell: ({ row }) => ( cell: ({ row }) => (
<div className="max-w-[260px] truncate"> <div className="space-y-1">
{row.original.albums.map((a) => a.name).join(", ") || "—"} <div className="text-sm font-medium tabular-nums">{row.original.albumsCount}</div>
{row.original.albums.length ? <Chips items={row.original.albums} /> : <span className="text-xs text-muted-foreground"></span>}
</div> </div>
), ),
enableSorting: false,
},
{
accessorKey: "albumsCount",
header: "Albums #",
cell: ({ row }) => row.original.albumsCount,
}, },
{ {
id: "categories", id: "categories",
header: "Categories", header: ({ column }) => <SortHeader title="Categories #" column={column} />,
accessorKey: "categoriesCount",
cell: ({ row }) => ( cell: ({ row }) => (
<div className="max-w-[260px] truncate"> <div className="space-y-1">
{row.original.categories.map((c) => c.name).join(", ") || "—"} <div className="text-sm font-medium tabular-nums">{row.original.categoriesCount}</div>
{row.original.categories.length ? (
<Chips items={row.original.categories} />
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div> </div>
), ),
enableSorting: false,
},
{
accessorKey: "categoriesCount",
header: "Categories #",
cell: ({ row }) => row.original.categoriesCount,
}, },
{ {
accessorKey: "published", accessorKey: "published",
header: "Published", header: ({ column }) => <SortHeader title="Published" column={column} />,
cell: ({ row }) => (row.original.published ? "Yes" : "No"), cell: ({ row }) => <YesNoBadge value={row.original.published} />,
}, },
{ {
accessorKey: "nsfw", accessorKey: "nsfw",
header: "NSFW", header: ({ column }) => <SortHeader title="NSFW" column={column} />,
cell: ({ row }) => (row.original.nsfw ? "Yes" : "No"), cell: ({ row }) => <YesNoBadge value={row.original.nsfw} variant="secondary" />,
}, },
{ {
accessorKey: "needsWork", accessorKey: "needsWork",
header: "Needs Work", header: ({ column }) => <SortHeader title="Needs Work" column={column} />,
cell: ({ row }) => (row.original.needsWork ? "Yes" : "No"), cell: ({ row }) => <YesNoBadge value={row.original.needsWork} variant="secondary" />,
}, },
{ {
accessorKey: "createdAt", accessorKey: "createdAt",
header: ({ column }) => ( header: ({ column }) => <SortHeader title="Created" column={column} />,
<button cell: ({ row }) => (
className="text-left font-medium" <span className="text-sm text-foreground/80">
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} {new Date(row.original.createdAt).toLocaleDateString()}
> </span>
created
</button>
), ),
cell: ({ row }) => new Date(row.original.createdAt).toLocaleString(), },
{
id: "actions",
header: "",
enableSorting: false,
cell: ({ row }) => {
const item = row.original;
return (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open row actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem asChild>
<Link href={`/artworks/${item.id}`} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-destructive focus:text-destructive"
onSelect={(e) => {
e.preventDefault();
setDeleteTarget({ id: item.id, name: item.name });
setDeleteOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
}, },
], ],
[], [],
@ -235,34 +397,23 @@ export function ArtworksTable() {
manualSorting: true, manualSorting: true,
pageCount, pageCount,
onSortingChange: (updater) => { onSortingChange: (updater) => {
setSorting((prev) => { setSorting((prev) => (typeof updater === "function" ? updater(prev) : updater));
const next = typeof updater === "function" ? updater(prev) : updater;
return next;
});
setPageIndex(0); setPageIndex(0);
}, },
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
React.useEffect(() => {
startTransition(async () => {
const res = await getArtworkFilterOptions();
setAlbumOptions(res.albums);
setCategoryOptions(res.categories);
});
}, []);
React.useEffect(() => { React.useEffect(() => {
startTransition(async () => { startTransition(async () => {
const res = await getArtworksTablePage({ const res = await getArtworksTablePage({
pagination: { pageIndex, pageSize }, pagination: { pageIndex, pageSize },
sorting, sorting,
filters: { filters: {
name: debouncedFilters.name || undefined, name: debouncedName || undefined,
slug: debouncedFilters.slug || undefined, slug: debouncedSlug || undefined,
published: debouncedFilters.published, published: filters.published,
nsfw: debouncedFilters.nsfw, nsfw: filters.nsfw,
needsWork: debouncedFilters.needsWork, needsWork: filters.needsWork,
albumIds: filters.albumIds.length ? filters.albumIds : undefined, albumIds: filters.albumIds.length ? filters.albumIds : undefined,
categoryIds: filters.categoryIds.length ? filters.categoryIds : undefined, categoryIds: filters.categoryIds.length ? filters.categoryIds : undefined,
}, },
@ -275,24 +426,31 @@ export function ArtworksTable() {
pageIndex, pageIndex,
pageSize, pageSize,
sorting, sorting,
debouncedFilters.name, debouncedName,
debouncedFilters.slug, debouncedSlug,
debouncedFilters.published, filters.published,
debouncedFilters.nsfw, filters.nsfw,
debouncedFilters.needsWork, filters.needsWork,
filters.albumIds,
filters.categoryIds,
]); ]);
// Render: header row + filter row (only)
const headerGroup = table.getHeaderGroups()[0]; const headerGroup = table.getHeaderGroups()[0];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded border"> <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-gradient-to-b from-background/60 to-transparent" />
<Table> <Table>
<TableHeader> <TableHeader className="sticky top-0 z-20 bg-card">
<TableRow> {/* main header */}
<TableRow className="hover:bg-transparent">
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<TableHead key={header.id} className="whitespace-nowrap"> <TableHead
key={header.id}
className="whitespace-nowrap border-b border-border/70 bg-muted/40 py-3 text-xs font-semibold uppercase tracking-wide text-foreground/80"
>
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender(header.column.columnDef.header, header.getContext())} : flexRender(header.column.columnDef.header, header.getContext())}
@ -300,13 +458,13 @@ export function ArtworksTable() {
))} ))}
</TableRow> </TableRow>
{/* column filter row */} {/* filter row */}
<TableRow> <TableRow className="hover:bg-transparent">
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
const colId = header.column.id; const colId = header.column.id;
return ( return (
<TableHead key={header.id}> <TableHead key={header.id} className="border-b border-border/70 bg-muted/30 py-2">
{colId === "name" ? ( {colId === "name" ? (
<Input <Input
className="h-9" className="h-9"
@ -353,7 +511,7 @@ export function ArtworksTable() {
/> />
) : colId === "albums" ? ( ) : colId === "albums" ? (
<MultiSelectFilter <MultiSelectFilter
placeholder="Filter albums…" placeholder="Albums…"
options={albumOptions} options={albumOptions}
value={filters.albumIds} value={filters.albumIds}
onChange={(next) => { onChange={(next) => {
@ -363,7 +521,7 @@ export function ArtworksTable() {
/> />
) : colId === "categories" ? ( ) : colId === "categories" ? (
<MultiSelectFilter <MultiSelectFilter
placeholder="Filter categories…" placeholder="Categories…"
options={categoryOptions} options={categoryOptions}
value={filters.categoryIds} value={filters.categoryIds}
onChange={(next) => { onChange={(next) => {
@ -383,15 +541,27 @@ export function ArtworksTable() {
<TableBody> <TableBody>
{rows.length === 0 ? ( {rows.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={columns.length} className="py-10 text-center"> <TableCell colSpan={columns.length} className="py-14 text-center">
{isLoading ? "Loading…" : "No results."} <div className="text-sm font-medium">
{isPending ? "Loading…" : "No results."}
</div>
<div className="mt-1 text-xs text-muted-foreground">
Adjust filters or change page size.
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
table.getRowModel().rows.map((r) => ( table.getRowModel().rows.map((r, idx) => (
<TableRow key={r.id}> <TableRow
key={r.id}
className={[
"transition-colors",
"hover:bg-muted/50",
idx % 2 === 0 ? "bg-background" : "bg-muted/10",
].join(" ")}
>
{r.getVisibleCells().map((cell) => ( {r.getVisibleCells().map((cell) => (
<TableCell key={cell.id}> <TableCell key={cell.id} className="py-3 align-top border-b border-border/40">
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell> </TableCell>
))} ))}
@ -401,11 +571,12 @@ export function ArtworksTable() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
{/* pagination only (no redundant filters) */} {/* pagination */}
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{isLoading ? "Updating…" : null} Total: {total} {isPending ? "Updating…" : null} Total: {total}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -432,7 +603,7 @@ export function ArtworksTable() {
variant="outline" variant="outline"
className="h-9" className="h-9"
onClick={() => setPageIndex(0)} onClick={() => setPageIndex(0)}
disabled={pageIndex === 0 || isLoading} disabled={pageIndex === 0 || isPending}
> >
First First
</Button> </Button>
@ -440,12 +611,12 @@ export function ArtworksTable() {
variant="outline" variant="outline"
className="h-9" className="h-9"
onClick={() => setPageIndex((p) => Math.max(0, p - 1))} onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
disabled={pageIndex === 0 || isLoading} disabled={pageIndex === 0 || isPending}
> >
Prev Prev
</Button> </Button>
<div className="text-sm tabular-nums"> <div className="min-w-[120px] text-center text-sm tabular-nums">
Page {pageIndex + 1} / {pageCount} Page {pageIndex + 1} / {pageCount}
</div> </div>
@ -453,7 +624,7 @@ export function ArtworksTable() {
variant="outline" variant="outline"
className="h-9" className="h-9"
onClick={() => setPageIndex((p) => Math.min(pageCount - 1, p + 1))} onClick={() => setPageIndex((p) => Math.min(pageCount - 1, p + 1))}
disabled={pageIndex >= pageCount - 1 || isLoading} disabled={pageIndex >= pageCount - 1 || isPending}
> >
Next Next
</Button> </Button>
@ -461,30 +632,60 @@ export function ArtworksTable() {
variant="outline" variant="outline"
className="h-9" className="h-9"
onClick={() => setPageIndex(Math.max(0, pageCount - 1))} onClick={() => setPageIndex(Math.max(0, pageCount - 1))}
disabled={pageIndex >= pageCount - 1 || isLoading} disabled={pageIndex >= pageCount - 1 || isPending}
> >
Last Last
</Button> </Button>
</div> </div>
</div> </div>
{/* delete confirmation */}
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete artwork?</AlertDialogTitle>
<AlertDialogDescription>
This will delete <span className="font-medium">{deleteTarget?.name}</span>. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isPending || !deleteTarget}
onClick={() => {
const target = deleteTarget;
if (!target) return;
startTransition(async () => {
await deleteArtwork(target.id);
setDeleteOpen(false);
setDeleteTarget(null);
// Refresh current page (simple approach)
const res = await getArtworksTablePage({
pagination: { pageIndex, pageSize },
sorting,
filters: {
name: debouncedName || undefined,
slug: debouncedSlug || undefined,
published: filters.published,
nsfw: filters.nsfw,
needsWork: filters.needsWork,
albumIds: filters.albumIds.length ? filters.albumIds : undefined,
categoryIds: filters.categoryIds.length ? filters.categoryIds : undefined,
},
});
setRows(res.rows);
setTotal(res.total);
});
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</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,157 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}