Rework artwork list
This commit is contained in:
5
bun.lock
5
bun.lock
@ -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=="],
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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">
|
||||||
<Image
|
<div className="relative aspect-[4/3] w-[320px] overflow-hidden rounded-lg border bg-muted">
|
||||||
src={url}
|
<Image
|
||||||
alt={row.original.name}
|
src={url}
|
||||||
fill
|
alt={row.original.name}
|
||||||
sizes="420px"
|
fill
|
||||||
className="object-contain"
|
sizes="320px"
|
||||||
/>
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<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>
|
</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>
|
</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 }) => (
|
||||||
<Link
|
<div className="min-w-0 max-w-[340px]">
|
||||||
href={`/artworks/${row.original.id}`}
|
<Link
|
||||||
className="max-w-[420px] truncate underline-offset-4 hover:underline"
|
href={`/artworks/${row.original.id}`}
|
||||||
>
|
className="block truncate text-sm font-medium leading-5 underline-offset-4 hover:underline"
|
||||||
{row.original.name}
|
title={row.original.name}
|
||||||
</Link>
|
>
|
||||||
),
|
{row.original.name}
|
||||||
},
|
</Link>
|
||||||
{
|
<div className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||||
accessorKey: "slug",
|
{row.original.slug}
|
||||||
header: "Slug",
|
</div>
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="max-w-[240px] truncate text-muted-foreground">
|
|
||||||
{row.original.slug}
|
|
||||||
</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,137 +426,157 @@ 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">
|
||||||
<Table>
|
<div className="relative">
|
||||||
<TableHeader>
|
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-gradient-to-b from-background/60 to-transparent" />
|
||||||
<TableRow>
|
<Table>
|
||||||
{headerGroup.headers.map((header) => (
|
<TableHeader className="sticky top-0 z-20 bg-card">
|
||||||
<TableHead key={header.id} className="whitespace-nowrap">
|
{/* main header */}
|
||||||
{header.isPlaceholder
|
<TableRow className="hover:bg-transparent">
|
||||||
? null
|
{headerGroup.headers.map((header) => (
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
<TableHead
|
||||||
</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"
|
||||||
</TableRow>
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
{/* column filter row */}
|
? null
|
||||||
<TableRow>
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
{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>
|
</TableHead>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
|
|
||||||
<TableBody>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={columns.length} className="py-10 text-center">
|
|
||||||
{isLoading ? "Loading…" : "No results."}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
|
||||||
table.getRowModel().rows.map((r) => (
|
{/* filter row */}
|
||||||
<TableRow key={r.id}>
|
<TableRow className="hover:bg-transparent">
|
||||||
{r.getVisibleCells().map((cell) => (
|
{headerGroup.headers.map((header) => {
|
||||||
<TableCell key={cell.id}>
|
const colId = header.column.id;
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
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 === "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="Albums…"
|
||||||
|
options={albumOptions}
|
||||||
|
value={filters.albumIds}
|
||||||
|
onChange={(next) => {
|
||||||
|
setFilters((f) => ({ ...f, albumIds: next }));
|
||||||
|
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>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="py-14 text-center">
|
||||||
|
<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>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
) : (
|
||||||
)}
|
table.getRowModel().rows.map((r, idx) => (
|
||||||
</TableBody>
|
<TableRow
|
||||||
</Table>
|
key={r.id}
|
||||||
|
className={[
|
||||||
|
"transition-colors",
|
||||||
|
"hover:bg-muted/50",
|
||||||
|
idx % 2 === 0 ? "bg-background" : "bg-muted/10",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{r.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id} className="py-3 align-top border-b border-border/40">
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user