diff --git a/bun.lock b/bun.lock index 333c1b2..5e4e6cc 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^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-dropdown-menu": "^2.1.16", "@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/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-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=="], + "@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-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=="], diff --git a/package.json b/package.json index 3fcc9a6..609c5ba 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^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-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", diff --git a/src/components/artworks/ArtworksTable.tsx b/src/components/artworks/ArtworksTable.tsx index 938090a..260b5cc 100644 --- a/src/components/artworks/ArtworksTable.tsx +++ b/src/components/artworks/ArtworksTable.tsx @@ -7,14 +7,43 @@ import { getCoreRowModel, useReactTable, } from "@tanstack/react-table"; +import { + ArrowUpDown, + ChevronDown, + ChevronUp, + MoreHorizontal, + Pencil, + Trash2, +} from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import * as React from "react"; -import { getArtworksTablePage } from "@/actions/artworks/getArtworksTablePage"; - +import { deleteArtwork } from "@/actions/artworks/deleteArtwork"; 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 { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { HoverCard, HoverCardContent, @@ -50,13 +79,86 @@ function useDebouncedValue(value: T, delayMs: number) { return debounced; } +function SortHeader(props: { + title: string; + column: any; // TanStack Column +}) { + const sorted = props.column.getIsSorted() as false | "asc" | "desc"; + + return ( + + ); +} + +function YesNoBadge(props: { value: boolean; variant?: "default" | "secondary" }) { + return ( + + {props.value ? "Yes" : "No"} + + ); +} + +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 ( +
+ {shown.map((i) => ( + + {i.name} + + ))} + {rest > 0 ? ( + + +{rest} + + ) : null} +
+ ); +} + +function TriSelectInline(props: { + value: TriState; + onChange: (v: TriState) => void; +}) { + return ( + + ); +} + type Filters = { name: string; slug: string; published: TriState; nsfw: TriState; needsWork: TriState; - albumIds: string[]; categoryIds: string[]; }; @@ -67,8 +169,6 @@ export function ArtworksTable() { ]); 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({ name: "", @@ -80,18 +180,32 @@ export function ArtworksTable() { categoryIds: [], }); - const debouncedFilters = { - ...filters, - name: useDebouncedValue(filters.name, 300), - slug: useDebouncedValue(filters.slug, 300), - }; + const debouncedName = useDebouncedValue(filters.name, 300); + const debouncedSlug = useDebouncedValue(filters.slug, 300); const [rows, setRows] = React.useState([]); 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)); + 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[]>( () => [ { @@ -102,9 +216,9 @@ export function ArtworksTable() { const url = `/api/image/thumbnail/${fileKey}.webp`; return ( - + -
+
{row.original.name}
- -
- {row.original.name} + +
+
+ {row.original.name} +
+
+
{row.original.name}
+
{row.original.slug}
+ +
+ + + +
+ +
+
+ Created: {new Date(row.original.createdAt).toLocaleString()} +
+
+ Updated: {new Date(row.original.updatedAt).toLocaleString()} +
+
+
-
{row.original.name}
-
{row.original.slug}
); @@ -134,94 +267,123 @@ export function ArtworksTable() { }, { accessorKey: "name", - header: ({ column }) => ( - - ), + header: ({ column }) => , cell: ({ row }) => ( - - {row.original.name} - - ), - }, - { - accessorKey: "slug", - header: "Slug", - cell: ({ row }) => ( -
- {row.original.slug} +
+ + {row.original.name} + +
+ {row.original.slug} +
), }, { id: "gallery", header: "Gallery", - cell: ({ row }) => row.original.gallery?.name ?? "—", + cell: ({ row }) => ( + + {row.original.gallery?.name ?? "—"} + + ), enableSorting: false, }, { id: "albums", - header: "Albums", + header: ({ column }) => , + accessorKey: "albumsCount", cell: ({ row }) => ( -
- {row.original.albums.map((a) => a.name).join(", ") || "—"} +
+
{row.original.albumsCount}
+ {row.original.albums.length ? : }
), - enableSorting: false, - }, - { - accessorKey: "albumsCount", - header: "Albums #", - cell: ({ row }) => row.original.albumsCount, }, { id: "categories", - header: "Categories", + header: ({ column }) => , + accessorKey: "categoriesCount", cell: ({ row }) => ( -
- {row.original.categories.map((c) => c.name).join(", ") || "—"} +
+
{row.original.categoriesCount}
+ {row.original.categories.length ? ( + + ) : ( + + )}
), - enableSorting: false, - }, - { - accessorKey: "categoriesCount", - header: "Categories #", - cell: ({ row }) => row.original.categoriesCount, }, { accessorKey: "published", - header: "Published", - cell: ({ row }) => (row.original.published ? "Yes" : "No"), + header: ({ column }) => , + cell: ({ row }) => , }, { accessorKey: "nsfw", - header: "NSFW", - cell: ({ row }) => (row.original.nsfw ? "Yes" : "No"), + header: ({ column }) => , + cell: ({ row }) => , }, { accessorKey: "needsWork", - header: "Needs Work", - cell: ({ row }) => (row.original.needsWork ? "Yes" : "No"), + header: ({ column }) => , + cell: ({ row }) => , }, { accessorKey: "createdAt", - header: ({ column }) => ( - + header: ({ column }) => , + cell: ({ row }) => ( + + {new Date(row.original.createdAt).toLocaleDateString()} + ), - cell: ({ row }) => new Date(row.original.createdAt).toLocaleString(), + }, + { + id: "actions", + header: "", + enableSorting: false, + cell: ({ row }) => { + const item = row.original; + + return ( +
+ + + + + + + + + + Edit + + + + { + e.preventDefault(); + setDeleteTarget({ id: item.id, name: item.name }); + setDeleteOpen(true); + }} + > + + Delete + + + +
+ ); + }, }, ], [], @@ -235,34 +397,23 @@ export function ArtworksTable() { manualSorting: true, pageCount, onSortingChange: (updater) => { - setSorting((prev) => { - const next = typeof updater === "function" ? updater(prev) : updater; - return next; - }); + setSorting((prev) => (typeof updater === "function" ? updater(prev) : updater)); 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, + 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, }, @@ -275,137 +426,157 @@ export function ArtworksTable() { pageIndex, pageSize, sorting, - debouncedFilters.name, - debouncedFilters.slug, - debouncedFilters.published, - debouncedFilters.nsfw, - debouncedFilters.needsWork, + debouncedName, + debouncedSlug, + filters.published, + filters.nsfw, + filters.needsWork, + filters.albumIds, + filters.categoryIds, ]); - // Render: header row + filter row (only) const headerGroup = table.getHeaderGroups()[0]; return (
-
- - - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - - {/* column filter row */} - - {headerGroup.headers.map((header) => { - const colId = header.column.id; - - return ( - - {colId === "name" ? ( - { - setFilters((f) => ({ ...f, name: e.target.value })); - setPageIndex(0); - }} - /> - ) : colId === "slug" ? ( - { - setFilters((f) => ({ ...f, slug: e.target.value })); - setPageIndex(0); - }} - /> - ) : colId === "published" ? ( - { - setFilters((f) => ({ ...f, published: v })); - setPageIndex(0); - }} - /> - ) : colId === "nsfw" ? ( - { - setFilters((f) => ({ ...f, nsfw: v })); - setPageIndex(0); - }} - /> - ) : colId === "needsWork" ? ( - { - setFilters((f) => ({ ...f, needsWork: v })); - setPageIndex(0); - }} - /> - ) : colId === "albums" ? ( - { - setFilters((f) => ({ ...f, albumIds: next })); - setPageIndex(0); - }} - /> - ) : colId === "categories" ? ( - { - setFilters((f) => ({ ...f, categoryIds: next })); - setPageIndex(0); - }} - /> - ) : ( -
- )} +
+
+
+
+ + {/* main header */} + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} - ); - })} - - - - - {rows.length === 0 ? ( - - - {isLoading ? "Loading…" : "No results."} - + ))} - ) : ( - table.getRowModel().rows.map((r) => ( - - {r.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + + {/* filter row */} + + {headerGroup.headers.map((header) => { + const colId = header.column.id; + + return ( + + {colId === "name" ? ( + { + setFilters((f) => ({ ...f, name: e.target.value })); + setPageIndex(0); + }} + /> + ) : colId === "slug" ? ( + { + setFilters((f) => ({ ...f, slug: e.target.value })); + setPageIndex(0); + }} + /> + ) : colId === "published" ? ( + { + setFilters((f) => ({ ...f, published: v })); + setPageIndex(0); + }} + /> + ) : colId === "nsfw" ? ( + { + setFilters((f) => ({ ...f, nsfw: v })); + setPageIndex(0); + }} + /> + ) : colId === "needsWork" ? ( + { + setFilters((f) => ({ ...f, needsWork: v })); + setPageIndex(0); + }} + /> + ) : colId === "albums" ? ( + { + setFilters((f) => ({ ...f, albumIds: next })); + setPageIndex(0); + }} + /> + ) : colId === "categories" ? ( + { + setFilters((f) => ({ ...f, categoryIds: next })); + setPageIndex(0); + }} + /> + ) : ( +
+ )} + + ); + })} + + + + + {rows.length === 0 ? ( + + +
+ {isPending ? "Loading…" : "No results."} +
+
+ Adjust filters or change page size. +
+
- )) - )} -
-
+ ) : ( + table.getRowModel().rows.map((r, idx) => ( + + {r.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + )} + + +
- {/* pagination only (no redundant filters) */} + {/* pagination */}
- {isLoading ? "Updating…" : null} Total: {total} + {isPending ? "Updating…" : null} Total: {total}
@@ -432,7 +603,7 @@ export function ArtworksTable() { variant="outline" className="h-9" onClick={() => setPageIndex(0)} - disabled={pageIndex === 0 || isLoading} + disabled={pageIndex === 0 || isPending} > First @@ -440,12 +611,12 @@ export function ArtworksTable() { variant="outline" className="h-9" onClick={() => setPageIndex((p) => Math.max(0, p - 1))} - disabled={pageIndex === 0 || isLoading} + disabled={pageIndex === 0 || isPending} > Prev -
+
Page {pageIndex + 1} / {pageCount}
@@ -453,7 +624,7 @@ export function ArtworksTable() { variant="outline" className="h-9" onClick={() => setPageIndex((p) => Math.min(pageCount - 1, p + 1))} - disabled={pageIndex >= pageCount - 1 || isLoading} + disabled={pageIndex >= pageCount - 1 || isPending} > Next @@ -461,30 +632,60 @@ export function ArtworksTable() { variant="outline" className="h-9" onClick={() => setPageIndex(Math.max(0, pageCount - 1))} - disabled={pageIndex >= pageCount - 1 || isLoading} + disabled={pageIndex >= pageCount - 1 || isPending} > Last
+ + {/* delete confirmation */} + + + + Delete artwork? + + This will delete {deleteTarget?.name}. This action cannot be undone. + + + + Cancel + { + 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 + + + +
); } - -function TriSelectInline(props: { - value: TriState; - onChange: (v: TriState) => void; -}) { - return ( - - ); -} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..0863e40 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -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) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}