diff --git a/bun.lock b/bun.lock index 3fb7bb9..333c1b2 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@prisma/client": "^7.2.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-popover": "^1.1.15", @@ -18,6 +19,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -340,6 +342,8 @@ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "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-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@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-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "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-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="], + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "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-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], @@ -526,6 +530,10 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="], + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="], diff --git a/package.json b/package.json index 133a55c..3fcc9a6 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@prisma/client": "^7.2.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-popover": "^1.1.15", @@ -24,6 +25,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/src/actions/artworks/getArtworkFilterOptions.ts b/src/actions/artworks/getArtworkFilterOptions.ts new file mode 100644 index 0000000..92f1149 --- /dev/null +++ b/src/actions/artworks/getArtworkFilterOptions.ts @@ -0,0 +1,15 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; + +export async function getArtworkFilterOptions() { + const [albums, categories] = await Promise.all([ + prisma.album.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }), + prisma.artCategory.findMany({ + select: { id: true, name: true }, + orderBy: { name: "asc" }, + }), + ]); + + return { albums, categories }; +} diff --git a/src/actions/artworks/getArtworksTablePage.ts b/src/actions/artworks/getArtworksTablePage.ts new file mode 100644 index 0000000..c7d3099 --- /dev/null +++ b/src/actions/artworks/getArtworksTablePage.ts @@ -0,0 +1,120 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { ArtworkTableInput, artworkTableInputSchema, artworkTableOutputSchema } from "@/schemas/artworks/tableSchema"; + +function triToBool(tri: "any" | "true" | "false"): boolean | undefined { + if (tri === "any") return undefined; + return tri === "true"; +} + +function mapSortingToOrderBy(sorting: ArtworkTableInput["sorting"]) { + const allowed: Record any> = { + createdAt: (desc) => ({ createdAt: desc ? "desc" : "asc" }), + updatedAt: (desc) => ({ updatedAt: desc ? "desc" : "asc" }), + sortIndex: (desc) => ({ sortIndex: desc ? "desc" : "asc" }), + name: (desc) => ({ name: desc ? "desc" : "asc" }), + slug: (desc) => ({ slug: desc ? "desc" : "asc" }), + published: (desc) => ({ published: desc ? "desc" : "asc" }), + nsfw: (desc) => ({ nsfw: desc ? "desc" : "asc" }), + needsWork: (desc) => ({ needsWork: desc ? "desc" : "asc" }), + + // relation counts: Prisma supports ordering by _count + albumsCount: (desc) => ({ albums: { _count: desc ? "desc" : "asc" } }), + categoriesCount: (desc) => ({ categories: { _count: desc ? "desc" : "asc" } }), + tagsCount: (desc) => ({ tags: { _count: desc ? "desc" : "asc" } }), + }; + + const orderBy = sorting + .map((s) => allowed[s.id]?.(s.desc)) + .filter(Boolean); + + orderBy.push({ id: "desc" }); + return orderBy; +} + +export async function getArtworksTablePage(input: unknown) { + const parsed = artworkTableInputSchema.safeParse(input); + if (!parsed.success) throw new Error(parsed.error.message); + + const { pagination, sorting, filters } = parsed.data; + const { pageIndex, pageSize } = pagination; + + const published = triToBool(filters.published); + const nsfw = triToBool(filters.nsfw); + const needsWork = triToBool(filters.needsWork); + + const where: any = { + ...(typeof published === "boolean" ? { published } : {}), + ...(typeof nsfw === "boolean" ? { nsfw } : {}), + ...(typeof needsWork === "boolean" ? { needsWork } : {}), + + ...(filters.name + ? { name: { contains: filters.name, mode: "insensitive" } } + : {}), + ...(filters.slug + ? { slug: { contains: filters.slug, mode: "insensitive" } } + : {}), + + ...(filters.galleryId ? { galleryId: filters.galleryId } : {}), + + ...(filters.albumIds?.length + ? { albums: { some: { id: { in: filters.albumIds } } } } + : {}), + ...(filters.categoryIds?.length + ? { categories: { some: { id: { in: filters.categoryIds } } } } + : {}), + }; + + const orderBy = mapSortingToOrderBy(sorting); + + const [total, items] = await Promise.all([ + prisma.artwork.count({ where }), + prisma.artwork.findMany({ + where, + orderBy, + skip: pageIndex * pageSize, + take: pageSize, + select: { + id: true, + name: true, + slug: true, + published: true, + nsfw: true, + needsWork: true, + createdAt: true, + updatedAt: true, + sortIndex: true, + file: { select: { fileKey: true } }, + gallery: { select: { id: true, name: true } }, + albums: { select: { id: true, name: true } }, + categories: { select: { id: true, name: true } }, + _count: { select: { albums: true, categories: true, tags: true } }, + }, + }), + ]); + + const rows = items.map((a) => ({ + id: a.id, + name: a.name, + slug: a.slug, + published: a.published, + nsfw: a.nsfw, + needsWork: a.needsWork, + createdAt: a.createdAt.toISOString(), + updatedAt: a.updatedAt.toISOString(), + fileKey: a.file.fileKey, + gallery: a.gallery ? { id: a.gallery.id, name: a.gallery.name } : null, + albums: a.albums, + categories: a.categories, + albumsCount: a._count.albums, + categoriesCount: a._count.categories, + tagsCount: a._count.tags, + })); + + const out = { rows, total, pageIndex, pageSize }; + const outParsed = artworkTableOutputSchema.safeParse(out); + if (!outParsed.success) throw new Error(outParsed.error.message); + + return outParsed.data; +} diff --git a/src/app/artworks/page.tsx b/src/app/artworks/page.tsx index 1e497af..a9cd47f 100644 --- a/src/app/artworks/page.tsx +++ b/src/app/artworks/page.tsx @@ -1,8 +1,5 @@ -import ArtworkGallery from "@/components/artworks/ArtworkGallery"; -import FilterBar from "@/components/artworks/FilterBar"; +import { ArtworksTable } from "@/components/artworks/ArtworksTable"; import { getArtworksPage } from "@/lib/queryArtworks"; -import { PlusCircleIcon } from "lucide-react"; -import Link from "next/link"; export default async function ArtworksPage({ searchParams @@ -57,32 +54,36 @@ export default async function ArtworksPage({ ) return ( -
-
-

Artworks

- - Upload new artwork - - - Upload many artwork - -
- -
- {items.length > 0 ? ( - - ) : ( -

No artworks found.

- )} -
-
+
+

Artworks

+ +
+ //
+ //
+ //

Artworks

+ // + // Upload new artwork + // + // + // Upload many artwork + // + //
+ // + //
+ // {items.length > 0 ? ( + // + // ) : ( + //

No artworks found.

+ // )} + //
+ //
); } \ No newline at end of file diff --git a/src/components/artworks/ArtworksTable.tsx b/src/components/artworks/ArtworksTable.tsx new file mode 100644 index 0000000..938090a --- /dev/null +++ b/src/components/artworks/ArtworksTable.tsx @@ -0,0 +1,490 @@ +"use client"; + +import { + ColumnDef, + SortingState, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import Image from "next/image"; +import Link from "next/link"; +import * as React from "react"; + +import { getArtworksTablePage } from "@/actions/artworks/getArtworksTablePage"; + +import { getArtworkFilterOptions } from "@/actions/artworks/getArtworkFilterOptions"; +import { Button } from "@/components/ui/button"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ArtworkTableRow } from "@/schemas/artworks/tableSchema"; +import { MultiSelectFilter } from "./MultiSelectFilter"; + +type TriState = "any" | "true" | "false"; + +function useDebouncedValue(value: T, delayMs: number) { + const [debounced, setDebounced] = React.useState(value); + React.useEffect(() => { + const t = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(t); + }, [value, delayMs]); + return debounced; +} + +type Filters = { + name: string; + slug: string; + published: TriState; + nsfw: TriState; + needsWork: TriState; + + albumIds: string[]; + categoryIds: string[]; +}; + +export function ArtworksTable() { + const [sorting, setSorting] = React.useState([ + { id: "createdAt", desc: true }, + ]); + const [pageIndex, setPageIndex] = React.useState(0); + const [pageSize, setPageSize] = React.useState(25); + const [albumOptions, setAlbumOptions] = React.useState<{ id: string; name: string }[]>([]); + const [categoryOptions, setCategoryOptions] = React.useState<{ id: string; name: string }[]>([]); + + const [filters, setFilters] = React.useState({ + name: "", + slug: "", + published: "any", + nsfw: "any", + needsWork: "any", + albumIds: [], + categoryIds: [], + }); + + const debouncedFilters = { + ...filters, + name: useDebouncedValue(filters.name, 300), + slug: useDebouncedValue(filters.slug, 300), + }; + + const [rows, setRows] = React.useState([]); + const [total, setTotal] = React.useState(0); + const [isLoading, startTransition] = React.useTransition(); + + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + + const columns = React.useMemo[]>( + () => [ + { + id: "preview", + header: "Preview", + cell: ({ row }) => { + const fileKey = row.original.fileKey; + const url = `/api/image/thumbnail/${fileKey}.webp`; + + return ( + + +
+ {row.original.name} +
+
+ +
+ {row.original.name} +
+
{row.original.name}
+
{row.original.slug}
+
+
+ ); + }, + enableSorting: false, + }, + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.name} + + ), + }, + { + accessorKey: "slug", + header: "Slug", + cell: ({ row }) => ( +
+ {row.original.slug} +
+ ), + }, + { + id: "gallery", + header: "Gallery", + cell: ({ row }) => row.original.gallery?.name ?? "—", + enableSorting: false, + }, + { + id: "albums", + header: "Albums", + cell: ({ row }) => ( +
+ {row.original.albums.map((a) => a.name).join(", ") || "—"} +
+ ), + enableSorting: false, + }, + { + accessorKey: "albumsCount", + header: "Albums #", + cell: ({ row }) => row.original.albumsCount, + }, + { + id: "categories", + header: "Categories", + cell: ({ row }) => ( +
+ {row.original.categories.map((c) => c.name).join(", ") || "—"} +
+ ), + enableSorting: false, + }, + { + accessorKey: "categoriesCount", + header: "Categories #", + cell: ({ row }) => row.original.categoriesCount, + }, + { + accessorKey: "published", + header: "Published", + cell: ({ row }) => (row.original.published ? "Yes" : "No"), + }, + { + accessorKey: "nsfw", + header: "NSFW", + cell: ({ row }) => (row.original.nsfw ? "Yes" : "No"), + }, + { + accessorKey: "needsWork", + header: "Needs Work", + cell: ({ row }) => (row.original.needsWork ? "Yes" : "No"), + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => new Date(row.original.createdAt).toLocaleString(), + }, + ], + [], + ); + + const table = useReactTable({ + data: rows, + columns, + state: { sorting }, + manualPagination: true, + manualSorting: true, + pageCount, + onSortingChange: (updater) => { + setSorting((prev) => { + const next = typeof updater === "function" ? updater(prev) : updater; + return next; + }); + setPageIndex(0); + }, + getCoreRowModel: getCoreRowModel(), + }); + + React.useEffect(() => { + startTransition(async () => { + const res = await getArtworkFilterOptions(); + setAlbumOptions(res.albums); + setCategoryOptions(res.categories); + }); + }, []); + + React.useEffect(() => { + startTransition(async () => { + const res = await getArtworksTablePage({ + pagination: { pageIndex, pageSize }, + sorting, + filters: { + name: debouncedFilters.name || undefined, + slug: debouncedFilters.slug || undefined, + published: debouncedFilters.published, + nsfw: debouncedFilters.nsfw, + needsWork: debouncedFilters.needsWork, + albumIds: filters.albumIds.length ? filters.albumIds : undefined, + categoryIds: filters.categoryIds.length ? filters.categoryIds : undefined, + }, + }); + + setRows(res.rows); + setTotal(res.total); + }); + }, [ + pageIndex, + pageSize, + sorting, + debouncedFilters.name, + debouncedFilters.slug, + debouncedFilters.published, + debouncedFilters.nsfw, + debouncedFilters.needsWork, + ]); + + // Render: header row + filter row (only) + const headerGroup = table.getHeaderGroups()[0]; + + return ( +
+
+ + + + {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); + }} + /> + ) : ( +
+ )} + + ); + })} + + + + + {rows.length === 0 ? ( + + + {isLoading ? "Loading…" : "No results."} + + + ) : ( + table.getRowModel().rows.map((r) => ( + + {r.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + )} + +
+
+ + {/* pagination only (no redundant filters) */} +
+
+ {isLoading ? "Updating…" : null} Total: {total} +
+ +
+ + + + + +
+ Page {pageIndex + 1} / {pageCount} +
+ + + +
+
+
+ ); +} + +function TriSelectInline(props: { + value: TriState; + onChange: (v: TriState) => void; +}) { + return ( + + ); +} diff --git a/src/components/artworks/MultiSelectFilter.tsx b/src/components/artworks/MultiSelectFilter.tsx new file mode 100644 index 0000000..e426b17 --- /dev/null +++ b/src/components/artworks/MultiSelectFilter.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Check, ChevronsUpDown } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; + +type Option = { id: string; name: string }; + +export function MultiSelectFilter(props: { + placeholder: string; + options: Option[]; + value: string[]; // selected ids + onChange: (next: string[]) => void; +}) { + const selected = React.useMemo(() => new Set(props.value), [props.value]); + + return ( + + + + + + + + + No results. + + {props.options.map((opt) => { + const isOn = selected.has(opt.id); + return ( + { + const next = new Set(selected); + if (isOn) next.delete(opt.id); + else next.add(opt.id); + props.onChange(Array.from(next)); + }} + > + + {opt.name} + + ); + })} + + + + + ); +} diff --git a/src/components/ui/hover-card.tsx b/src/components/ui/hover-card.tsx new file mode 100644 index 0000000..e754186 --- /dev/null +++ b/src/components/ui/hover-card.tsx @@ -0,0 +1,44 @@ +"use client" + +import * as React from "react" +import * as HoverCardPrimitive from "@radix-ui/react-hover-card" + +import { cn } from "@/lib/utils" + +function HoverCard({ + ...props +}: React.ComponentProps) { + return +} + +function HoverCardTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function HoverCardContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/src/schemas/artworks/tableSchema.ts b/src/schemas/artworks/tableSchema.ts new file mode 100644 index 0000000..5ccb8d8 --- /dev/null +++ b/src/schemas/artworks/tableSchema.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +export const triStateSchema = z.enum(["any", "true", "false"]); + +// Allowlisted sorting ids (server will enforce) +export const artworkSortIdSchema = z.enum([ + "createdAt", + "updatedAt", + "sortIndex", + "name", + "slug", + "published", + "nsfw", + "needsWork", + // relation-derived sorts + "albumsCount", + "categoriesCount", + "tagsCount", +]); + +export const artworkTableInputSchema = z.object({ + pagination: z.object({ + pageIndex: z.number().int().min(0).default(0), + pageSize: z.number().int().min(1).max(200).default(25), + }), + + sorting: z + .array( + z.object({ + id: artworkSortIdSchema, + desc: z.boolean(), + }), + ) + .default([]), + + filters: z + .object({ + name: z.string().trim().max(200).optional(), + slug: z.string().trim().max(200).optional(), + + published: triStateSchema.default("any"), + nsfw: triStateSchema.default("any"), + needsWork: triStateSchema.default("any"), + + galleryId: z.string().min(1).optional(), + albumIds: z.array(z.string().min(1)).max(200).optional(), + categoryIds: z.array(z.string().min(1)).max(200).optional(), + }) + .default({ + published: "any", + nsfw: "any", + needsWork: "any", + }), +}); + +export type ArtworkTableInput = z.infer; + +export const artworkTableRowSchema = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + + published: z.boolean(), + nsfw: z.boolean(), + needsWork: z.boolean(), + + createdAt: z.string(), + updatedAt: z.string(), + + fileKey: z.string(), + + gallery: z + .object({ + id: z.string(), + name: z.string(), + }) + .nullable(), + + albums: z.array(z.object({ id: z.string(), name: z.string() })), + categories: z.array(z.object({ id: z.string(), name: z.string() })), + + // counts are useful both for display and for sorting sanity + albumsCount: z.number().int().min(0), + categoriesCount: z.number().int().min(0), + tagsCount: z.number().int().min(0), +}); + +export type ArtworkTableRow = z.infer; + +export const artworkTableOutputSchema = z.object({ + rows: z.array(artworkTableRowSchema), + total: z.number().int().min(0), + pageIndex: z.number().int().min(0), + pageSize: z.number().int().min(1), +}); + +export type ArtworkTableOutput = z.infer;