Rework artwork list
This commit is contained in:
8
bun.lock
8
bun.lock
@ -11,6 +11,7 @@
|
|||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "^7.2.0",
|
||||||
"@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-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
@ -18,6 +19,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.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-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-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=="],
|
"@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=="],
|
"@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=="],
|
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||||
|
|
||||||
"@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="],
|
"@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="],
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "^7.2.0",
|
||||||
"@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-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
@ -24,6 +25,7 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|||||||
15
src/actions/artworks/getArtworkFilterOptions.ts
Normal file
15
src/actions/artworks/getArtworkFilterOptions.ts
Normal file
@ -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 };
|
||||||
|
}
|
||||||
120
src/actions/artworks/getArtworksTablePage.ts
Normal file
120
src/actions/artworks/getArtworksTablePage.ts
Normal file
@ -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<string, (desc: boolean) => 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;
|
||||||
|
}
|
||||||
@ -1,8 +1,5 @@
|
|||||||
import ArtworkGallery from "@/components/artworks/ArtworkGallery";
|
import { ArtworksTable } from "@/components/artworks/ArtworksTable";
|
||||||
import FilterBar from "@/components/artworks/FilterBar";
|
|
||||||
import { getArtworksPage } from "@/lib/queryArtworks";
|
import { getArtworksPage } from "@/lib/queryArtworks";
|
||||||
import { PlusCircleIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default async function ArtworksPage({
|
export default async function ArtworksPage({
|
||||||
searchParams
|
searchParams
|
||||||
@ -57,32 +54,36 @@ export default async function ArtworksPage({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between pb-4 items-end">
|
<h1 className="text-2xl font-bold">Artworks</h1>
|
||||||
<h1 className="text-2xl font-bold mb-4">Artworks</h1>
|
<ArtworksTable />
|
||||||
<Link href="/uploads/single" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
|
|
||||||
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Upload new artwork
|
|
||||||
</Link>
|
|
||||||
<Link href="/uploads/bulk" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
|
|
||||||
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Upload many artwork
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<FilterBar
|
// <div>
|
||||||
// types={types}
|
// <div className="flex justify-between pb-4 items-end">
|
||||||
// albums={albums}
|
// <h1 className="text-2xl font-bold mb-4">Artworks</h1>
|
||||||
// years={years}
|
// <Link href="/uploads/single" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
|
||||||
// currentType={type}
|
// <PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Upload new artwork
|
||||||
currentPublished={published}
|
// </Link>
|
||||||
// groupBy={groupMode}
|
// <Link href="/uploads/bulk" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
|
||||||
// groupId={groupId}
|
// <PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Upload many artwork
|
||||||
/>
|
// </Link>
|
||||||
<div className="mt-6">
|
// </div>
|
||||||
{items.length > 0 ? (
|
// <FilterBar
|
||||||
<ArtworkGallery initialArtworks={items} initialCursor={nextCursor} />
|
// // types={types}
|
||||||
) : (
|
// // albums={albums}
|
||||||
<p className="text-muted-foreground italic">No artworks found.</p>
|
// // years={years}
|
||||||
)}
|
// // currentType={type}
|
||||||
</div>
|
// currentPublished={published}
|
||||||
</div >
|
// // groupBy={groupMode}
|
||||||
|
// // groupId={groupId}
|
||||||
|
// />
|
||||||
|
// <div className="mt-6">
|
||||||
|
// {items.length > 0 ? (
|
||||||
|
// <ArtworkGallery initialArtworks={items} initialCursor={nextCursor} />
|
||||||
|
// ) : (
|
||||||
|
// <p className="text-muted-foreground italic">No artworks found.</p>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// </div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
490
src/components/artworks/ArtworksTable.tsx
Normal file
490
src/components/artworks/ArtworksTable.tsx
Normal file
@ -0,0 +1,490 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
SortingState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { getArtworksTablePage } from "@/actions/artworks/getArtworksTablePage";
|
||||||
|
|
||||||
|
import { getArtworkFilterOptions } from "@/actions/artworks/getArtworkFilterOptions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
|
} from "@/components/ui/hover-card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { ArtworkTableRow } from "@/schemas/artworks/tableSchema";
|
||||||
|
import { MultiSelectFilter } from "./MultiSelectFilter";
|
||||||
|
|
||||||
|
type TriState = "any" | "true" | "false";
|
||||||
|
|
||||||
|
function useDebouncedValue<T>(value: T, delayMs: number) {
|
||||||
|
const [debounced, setDebounced] = React.useState(value);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebounced(value), delayMs);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [value, delayMs]);
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Filters = {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
published: TriState;
|
||||||
|
nsfw: TriState;
|
||||||
|
needsWork: TriState;
|
||||||
|
|
||||||
|
albumIds: string[];
|
||||||
|
categoryIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ArtworksTable() {
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([
|
||||||
|
{ id: "createdAt", desc: true },
|
||||||
|
]);
|
||||||
|
const [pageIndex, setPageIndex] = React.useState(0);
|
||||||
|
const [pageSize, setPageSize] = React.useState(25);
|
||||||
|
const [albumOptions, setAlbumOptions] = React.useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [categoryOptions, setCategoryOptions] = React.useState<{ id: string; name: string }[]>([]);
|
||||||
|
|
||||||
|
const [filters, setFilters] = React.useState<Filters>({
|
||||||
|
name: "",
|
||||||
|
slug: "",
|
||||||
|
published: "any",
|
||||||
|
nsfw: "any",
|
||||||
|
needsWork: "any",
|
||||||
|
albumIds: [],
|
||||||
|
categoryIds: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const debouncedFilters = {
|
||||||
|
...filters,
|
||||||
|
name: useDebouncedValue(filters.name, 300),
|
||||||
|
slug: useDebouncedValue(filters.slug, 300),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [rows, setRows] = React.useState<ArtworkTableRow[]>([]);
|
||||||
|
const [total, setTotal] = React.useState(0);
|
||||||
|
const [isLoading, startTransition] = React.useTransition();
|
||||||
|
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
const columns = React.useMemo<ColumnDef<ArtworkTableRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "preview",
|
||||||
|
header: "Preview",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const fileKey = row.original.fileKey;
|
||||||
|
const url = `/api/image/thumbnail/${fileKey}.webp`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HoverCard openDelay={150} closeDelay={100}>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
<div className="relative h-12 w-12 overflow-hidden rounded border bg-muted">
|
||||||
|
<Image
|
||||||
|
src={url}
|
||||||
|
alt={row.original.name}
|
||||||
|
fill
|
||||||
|
sizes="48px"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent className="w-[420px]">
|
||||||
|
<div className="relative aspect-[4/3] w-full overflow-hidden rounded border bg-muted">
|
||||||
|
<Image
|
||||||
|
src={url}
|
||||||
|
alt={row.original.name}
|
||||||
|
fill
|
||||||
|
sizes="420px"
|
||||||
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm font-medium">{row.original.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{row.original.slug}</div>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<button
|
||||||
|
className="text-left font-medium"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
href={`/artworks/${row.original.id}`}
|
||||||
|
className="max-w-[420px] truncate underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{row.original.name}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "slug",
|
||||||
|
header: "Slug",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="max-w-[240px] truncate text-muted-foreground">
|
||||||
|
{row.original.slug}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gallery",
|
||||||
|
header: "Gallery",
|
||||||
|
cell: ({ row }) => row.original.gallery?.name ?? "—",
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "albums",
|
||||||
|
header: "Albums",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="max-w-[260px] truncate">
|
||||||
|
{row.original.albums.map((a) => a.name).join(", ") || "—"}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "albumsCount",
|
||||||
|
header: "Albums #",
|
||||||
|
cell: ({ row }) => row.original.albumsCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "categories",
|
||||||
|
header: "Categories",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="max-w-[260px] truncate">
|
||||||
|
{row.original.categories.map((c) => c.name).join(", ") || "—"}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "categoriesCount",
|
||||||
|
header: "Categories #",
|
||||||
|
cell: ({ row }) => row.original.categoriesCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "published",
|
||||||
|
header: "Published",
|
||||||
|
cell: ({ row }) => (row.original.published ? "Yes" : "No"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "nsfw",
|
||||||
|
header: "NSFW",
|
||||||
|
cell: ({ row }) => (row.original.nsfw ? "Yes" : "No"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "needsWork",
|
||||||
|
header: "Needs Work",
|
||||||
|
cell: ({ row }) => (row.original.needsWork ? "Yes" : "No"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<button
|
||||||
|
className="text-left font-medium"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
created
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => new Date(row.original.createdAt).toLocaleString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
state: { sorting },
|
||||||
|
manualPagination: true,
|
||||||
|
manualSorting: true,
|
||||||
|
pageCount,
|
||||||
|
onSortingChange: (updater) => {
|
||||||
|
setSorting((prev) => {
|
||||||
|
const next = typeof updater === "function" ? updater(prev) : updater;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setPageIndex(0);
|
||||||
|
},
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await getArtworkFilterOptions();
|
||||||
|
setAlbumOptions(res.albums);
|
||||||
|
setCategoryOptions(res.categories);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await getArtworksTablePage({
|
||||||
|
pagination: { pageIndex, pageSize },
|
||||||
|
sorting,
|
||||||
|
filters: {
|
||||||
|
name: debouncedFilters.name || undefined,
|
||||||
|
slug: debouncedFilters.slug || undefined,
|
||||||
|
published: debouncedFilters.published,
|
||||||
|
nsfw: debouncedFilters.nsfw,
|
||||||
|
needsWork: debouncedFilters.needsWork,
|
||||||
|
albumIds: filters.albumIds.length ? filters.albumIds : undefined,
|
||||||
|
categoryIds: filters.categoryIds.length ? filters.categoryIds : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setRows(res.rows);
|
||||||
|
setTotal(res.total);
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
sorting,
|
||||||
|
debouncedFilters.name,
|
||||||
|
debouncedFilters.slug,
|
||||||
|
debouncedFilters.published,
|
||||||
|
debouncedFilters.nsfw,
|
||||||
|
debouncedFilters.needsWork,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Render: header row + filter row (only)
|
||||||
|
const headerGroup = table.getHeaderGroups()[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id} className="whitespace-nowrap">
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
|
||||||
|
{/* column filter row */}
|
||||||
|
<TableRow>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const colId = header.column.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{colId === "name" ? (
|
||||||
|
<Input
|
||||||
|
className="h-9"
|
||||||
|
placeholder="Filter name…"
|
||||||
|
value={filters.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilters((f) => ({ ...f, name: e.target.value }));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : colId === "slug" ? (
|
||||||
|
<Input
|
||||||
|
className="h-9"
|
||||||
|
placeholder="Filter slug…"
|
||||||
|
value={filters.slug}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFilters((f) => ({ ...f, slug: e.target.value }));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : colId === "published" ? (
|
||||||
|
<TriSelectInline
|
||||||
|
value={filters.published}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFilters((f) => ({ ...f, published: v }));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : colId === "nsfw" ? (
|
||||||
|
<TriSelectInline
|
||||||
|
value={filters.nsfw}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFilters((f) => ({ ...f, nsfw: v }));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : colId === "needsWork" ? (
|
||||||
|
<TriSelectInline
|
||||||
|
value={filters.needsWork}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFilters((f) => ({ ...f, needsWork: v }));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : colId === "albums" ? (
|
||||||
|
<MultiSelectFilter
|
||||||
|
placeholder="Filter albums…"
|
||||||
|
options={albumOptions}
|
||||||
|
value={filters.albumIds}
|
||||||
|
onChange={(next) => {
|
||||||
|
setFilters((f) => ({ ...f, albumIds: next }));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : colId === "categories" ? (
|
||||||
|
<MultiSelectFilter
|
||||||
|
placeholder="Filter categories…"
|
||||||
|
options={categoryOptions}
|
||||||
|
value={filters.categoryIds}
|
||||||
|
onChange={(next) => {
|
||||||
|
setFilters((f) => ({ ...f, categoryIds: next }));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-9" />
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="py-10 text-center">
|
||||||
|
{isLoading ? "Loading…" : "No results."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
{r.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* pagination only (no redundant filters) */}
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{isLoading ? "Updating…" : null} Total: {total}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setPageSize(Number(v));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-[120px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[10, 25, 50, 100].map((n) => (
|
||||||
|
<SelectItem key={n} value={String(n)}>
|
||||||
|
{n} / page
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-9"
|
||||||
|
onClick={() => setPageIndex(0)}
|
||||||
|
disabled={pageIndex === 0 || isLoading}
|
||||||
|
>
|
||||||
|
First
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-9"
|
||||||
|
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={pageIndex === 0 || isLoading}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-sm tabular-nums">
|
||||||
|
Page {pageIndex + 1} / {pageCount}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-9"
|
||||||
|
onClick={() => setPageIndex((p) => Math.min(pageCount - 1, p + 1))}
|
||||||
|
disabled={pageIndex >= pageCount - 1 || isLoading}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-9"
|
||||||
|
onClick={() => setPageIndex(Math.max(0, pageCount - 1))}
|
||||||
|
disabled={pageIndex >= pageCount - 1 || isLoading}
|
||||||
|
>
|
||||||
|
Last
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TriSelectInline(props: {
|
||||||
|
value: TriState;
|
||||||
|
onChange: (v: TriState) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Select value={props.value} onValueChange={(v) => props.onChange(v as TriState)}>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="any">Any</SelectItem>
|
||||||
|
<SelectItem value="true">Yes</SelectItem>
|
||||||
|
<SelectItem value="false">No</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/components/artworks/MultiSelectFilter.tsx
Normal file
61
src/components/artworks/MultiSelectFilter.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
||||||
|
type Option = { id: string; name: string };
|
||||||
|
|
||||||
|
export function MultiSelectFilter(props: {
|
||||||
|
placeholder: string;
|
||||||
|
options: Option[];
|
||||||
|
value: string[]; // selected ids
|
||||||
|
onChange: (next: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const selected = React.useMemo(() => new Set(props.value), [props.value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="h-9 w-full justify-between">
|
||||||
|
<span className="truncate">
|
||||||
|
{props.value.length === 0
|
||||||
|
? props.placeholder
|
||||||
|
: `${props.value.length} selected`}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 opacity-60" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent className="w-[280px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search…" />
|
||||||
|
<CommandEmpty>No results.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{props.options.map((opt) => {
|
||||||
|
const isOn = selected.has(opt.id);
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={opt.id}
|
||||||
|
value={opt.name}
|
||||||
|
onSelect={() => {
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (isOn) next.delete(opt.id);
|
||||||
|
else next.add(opt.id);
|
||||||
|
props.onChange(Array.from(next));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={`mr-2 h-4 w-4 ${isOn ? "opacity-100" : "opacity-0"}`} />
|
||||||
|
<span className="truncate">{opt.name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/components/ui/hover-card.tsx
Normal file
44
src/components/ui/hover-card.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function HoverCard({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||||
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
data-slot="hover-card-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
97
src/schemas/artworks/tableSchema.ts
Normal file
97
src/schemas/artworks/tableSchema.ts
Normal file
@ -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<typeof artworkTableInputSchema>;
|
||||||
|
|
||||||
|
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<typeof artworkTableRowSchema>;
|
||||||
|
|
||||||
|
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<typeof artworkTableOutputSchema>;
|
||||||
Reference in New Issue
Block a user