Add filtering

This commit is contained in:
2025-12-21 20:20:07 +01:00
parent 6fc8c41a83
commit b41a4d2908
11 changed files with 707 additions and 24 deletions

View File

@ -8,9 +8,12 @@
"@aws-sdk/s3-request-presigner": "^3.954.0", "@aws-sdk/s3-request-presigner": "^3.954.0",
"@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-checkbox": "^1.3.3",
"@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-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@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",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -276,10 +279,14 @@
"@prisma/studio-core": ["@prisma/studio-core@0.9.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-xA2zoR/ADu/NCSQuriBKTh6Ps4XjU0bErkEcgMfnSGh346K1VI7iWKnoq1l2DoxUqiddPHIEWwtxJ6xCHG6W7g=="], "@prisma/studio-core": ["@prisma/studio-core@0.9.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-xA2zoR/ADu/NCSQuriBKTh6Ps4XjU0bErkEcgMfnSGh346K1VI7iWKnoq1l2DoxUqiddPHIEWwtxJ6xCHG6W7g=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@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-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-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "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-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "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-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@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=="],
"@radix-ui/react-compose-refs": ["@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-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], "@radix-ui/react-compose-refs": ["@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-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
@ -314,6 +321,10 @@
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@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-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@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-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "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-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.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-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "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-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "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-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "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-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "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-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "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-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "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-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
@ -760,6 +771,8 @@
"@radix-ui/react-primitive/@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-primitive/@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-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.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-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],

View File

@ -14,9 +14,12 @@
"@aws-sdk/s3-request-presigner": "^3.954.0", "@aws-sdk/s3-request-presigner": "^3.954.0",
"@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-checkbox": "^1.3.3",
"@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-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@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",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

View File

@ -101,8 +101,9 @@ model ArtTag {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sortIndex Int @default(0) sortIndex Int @default(0)
name String @unique name String @unique
slug String @unique slug String @unique
showOnAnimalPage Boolean @default(false)
description String? description String?

View File

@ -1,32 +1,109 @@
import ArtworkThumbGallery from "@/components/artworks/ArtworkThumbGallery"; import ArtworkThumbGallery from "@/components/artworks/ArtworkThumbGallery";
import TagFilterDialog from "@/components/artworks/TagFilterDialog";
import { Button } from "@/components/ui/button";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { ListIcon } from "lucide-react";
import Link from "next/link";
function parseTagsParam(tags: string | string[] | undefined): string[] {
if (!tags) return [];
const raw = Array.isArray(tags) ? tags.join(",") : tags;
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
function expandSelectedWithChildren(
selectedSlugs: string[],
tagsForFilter: Array<{
slug: string;
children: Array<{ slug: string }>;
}>
) {
const bySlug = new Map(tagsForFilter.map((t) => [t.slug, t]));
const out = new Set(selectedSlugs);
for (const slug of selectedSlugs) {
const t = bySlug.get(slug);
if (!t) continue;
for (const c of t.children ?? []) out.add(c.slug);
}
return Array.from(out);
}
export default async function AnimalStudiesPage({ searchParams }: { searchParams: { tags?: string | string[] } }) {
const { tags } = await searchParams;
const selectedTagSlugs = parseTagsParam(tags);
const tagsForFilter = await prisma.artTag.findMany({
where: { showOnAnimalPage: true },
select: {
id: true,
name: true,
slug: true,
sortIndex: true,
parentId: true,
parent: { select: { id: true, name: true, slug: true, sortIndex: true } },
children: {
where: { showOnAnimalPage: true },
select: { id: true, name: true, slug: true, sortIndex: true, parentId: true },
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
},
},
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
});
const expandedTagSlugs = expandSelectedWithChildren(selectedTagSlugs, tagsForFilter);
export default async function AnimalStudiesPage() {
const artworks = await prisma.artwork.findMany({ const artworks = await prisma.artwork.findMany({
where: { where: {
categories: { categories: { some: { name: "Animal Studies" } },
some: { published: true,
name: "Animal Studies" ...(expandedTagSlugs.length
} ? { tags: { some: { slug: { in: expandedTagSlugs } } } }
}, : {}),
published: true
}, },
include: { include: {
file: true, file: true,
metadata: true, metadata: true,
tags: true, tags: true,
variants: true variants: true,
}, },
orderBy: [{ sortKey: "asc" }, { id: "asc" }] orderBy: [{ sortKey: "asc" }, { id: "asc" }],
}) });
// console.log(JSON.stringify(artworks, null, 4)) // console.log(JSON.stringify(artworks, null, 4))
return ( return (
<div className="flex flex-col gap-4 p-4"> <div className="flex flex-col gap-4 p-4">
<div> <header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<h1 className="text-2xl font-bold text-center">Animal studies</h1> <div className="space-y-1">
</div> <h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
Animal studies
</h1>
<p className="text-sm text-muted-foreground">
{selectedTagSlugs.length > 0
? `Filtered by ${selectedTagSlugs.length} tag${selectedTagSlugs.length === 1 ? "" : "s"}`
: "Browse all published artworks in this category."}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<TagFilterDialog
tags={tagsForFilter}
selectedTagSlugs={selectedTagSlugs}
/>
<Button asChild variant="secondary" className="h-11 gap-2">
<Link href="/animallist">
<ListIcon className="h-4 w-4" />
List all used animals
</Link>
</Button>
</div>
</header>
<ArtworkThumbGallery items={artworks} fit={{ mode: "fixedWidth", width: 300 }} /> <ArtworkThumbGallery items={artworks} fit={{ mode: "fixedWidth", width: 300 }} />
</div> </div>

View File

@ -1,8 +1,55 @@
import { ArrowBigRight } from "lucide-react";
export default function Home() { export default function Home() {
return ( return (
<div> <div className="flex flex-col gap-10 px-4 py-8">
Home {/* Hero Section */}
</div> <div className="text-center flex flex-col items-center justify-center mt-10 px-4">
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Welcome to my place!
</h1>
<p className="text-muted-foreground max-w-xl text-lg mb-6">
I&apos;m an illustrator, character designer, miniature painter, 3d modeller, makeup artist and much more and happy to show you things i&apos;ve created.
</p>
<p className="text-muted-foreground max-w-xl text-lg mb-6">
If you want to commission me<br />you can find all the information you need here:<br /> <a href="https://linktr.ee/gaertan" target="_blank" className="underline text-primary" ><ArrowBigRight className="w-4 h-4 inline" /> Linktree</a>
</p>
{/* Search */}
{/* <div className="relative w-full max-w-lg">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
<Search className="w-4 h-4" />
</span>
<Input
type="text"
placeholder="Search artworks, commissions, pages..."
className="p-6 pl-10"
/>
</div> */}
</div>
{/* Section Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{/* <p>
If you want to commission me you can find all the information you need under following link: <a href="https://linktr.ee/gaertan" target="_blank">Linktree</a>
</p> */}
{/* {sections.map((section) => (
<Link href={section.href} key={section.title}>
<Card className="hover:shadow-xl transition-shadow group">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<section.icon className="w-5 h-5 text-muted-foreground group-hover:text-primary" />
{section.title}
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{section.description}
</CardContent>
</Card>
</Link>
))} */}
</div>
</div >
); );
} }

View File

@ -0,0 +1,287 @@
"use client";
import { FilterIcon, XIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
type Tag = {
id: string;
name: string;
slug: string;
sortIndex: number;
parentId: string | null;
// these may exist, but we do NOT rely on them:
parent?: { id: string; name: string; slug: string; sortIndex: number } | null;
children?: { id: string; name: string; slug: string; sortIndex: number; parentId: string | null }[];
};
function toTagsParam(slugs: string[]) {
return slugs.join(",");
}
function uniq(arr: string[]) {
return Array.from(new Set(arr));
}
function sortTags(a: Tag, b: Tag) {
return (a.sortIndex - b.sortIndex) || a.name.localeCompare(b.name);
}
export default function TagFilterDialog({
tags,
selectedTagSlugs,
}: {
tags: Tag[];
selectedTagSlugs: string[];
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [open, setOpen] = React.useState(false);
const [draft, setDraft] = React.useState<string[]>(() => selectedTagSlugs);
React.useEffect(() => {
setDraft(selectedTagSlugs);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTagSlugs.join(",")]);
const hasDraft = draft.length > 0;
const selectedSet = React.useMemo(() => new Set(draft), [draft]);
const byId = React.useMemo(() => new Map(tags.map((t) => [t.id, t])), [tags]);
// Build children mapping from the flat list: parentId -> Tag[]
const childrenByParentId = React.useMemo(() => {
const map = new Map<string, Tag[]>();
for (const t of tags) {
if (!t.parentId) continue;
const arr = map.get(t.parentId) ?? [];
arr.push(t);
map.set(t.parentId, arr);
}
// sort each child list
for (const [k, arr] of map) {
map.set(k, arr.slice().sort(sortTags));
}
return map;
}, [tags]);
const rootGroups = React.useMemo(() => {
return tags
.filter((t) => t.parentId === null)
.slice()
.sort(sortTags);
}, [tags]);
const orphanChildren = React.useMemo(() => {
return tags
.filter((t) => t.parentId !== null && !byId.has(t.parentId))
.slice()
.sort(sortTags);
}, [tags, byId]);
const onToggleParent = (parent: Tag, next: boolean) => {
const children = childrenByParentId.get(parent.id) ?? [];
setDraft((prev) => {
const s = new Set(prev);
if (next) {
s.add(parent.slug);
// when selecting parent, remove child selections (redundant)
for (const c of children) s.delete(c.slug);
} else {
s.delete(parent.slug);
}
return Array.from(s);
});
};
const onToggleChild = (childSlug: string, next: boolean) => {
setDraft((prev) => {
const s = new Set(prev);
if (next) s.add(childSlug);
else s.delete(childSlug);
return Array.from(s);
});
};
const clearAll = () => setDraft([]);
const apply = () => {
const sp = new URLSearchParams(searchParams?.toString());
const normalized = uniq(draft).sort();
if (normalized.length) sp.set("tags", toTagsParam(normalized));
else sp.delete("tags");
router.replace(`${pathname}?${sp.toString()}`, { scroll: false });
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="default" className="h-11 gap-2">
<FilterIcon className="h-4 w-4" />
Filter by tag
{selectedTagSlugs.length > 0 ? (
<Badge variant="secondary" className="ml-1">
{selectedTagSlugs.length}
</Badge>
) : null}
</Button>
</DialogTrigger>
<DialogContent className="p-0 sm:max-w-2xl">
<DialogHeader className="px-6 pt-6">
<DialogTitle className="flex items-center justify-between gap-3">
<span>Filter artworks by tag</span>
{hasDraft ? (
<Button
variant="ghost"
size="sm"
onClick={clearAll}
className="gap-2"
aria-label="Clear all selected tags"
>
<XIcon className="h-4 w-4" />
Clear all tags
</Button>
) : null}
</DialogTitle>
<p className="text-sm text-muted-foreground">
Select one or more tags. Selecting a parent also adds its children.
</p>
</DialogHeader>
<Separator />
<ScrollArea className="max-h-[60vh] px-6 py-4">
<div className="space-y-5">
{rootGroups.map((p) => {
const children = childrenByParentId.get(p.id) ?? [];
const parentSelected = selectedSet.has(p.slug);
return (
<div key={p.id} className="rounded-lg border p-4">
<div className="flex items-start justify-between gap-3">
<label className="flex cursor-pointer items-center gap-3">
<Checkbox
checked={parentSelected}
onCheckedChange={(v) => onToggleParent(p, Boolean(v))}
/>
<div className="min-w-0">
<div className="truncate font-medium">{p.name}</div>
{/* <div className="text-xs text-muted-foreground">
{children.length ? "Parent tag" : "Tag"}
</div> */}
</div>
</label>
<Badge variant={parentSelected ? "default" : "outline"}>
{children.length} sub
</Badge>
</div>
{children.length ? (
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
{children.map((c) => {
const childSelected = selectedSet.has(c.slug);
const disabled = parentSelected;
return (
<label
key={c.id}
className={cn(
"flex items-center gap-3 rounded-md border px-3 py-2",
disabled ? "opacity-50" : "hover:bg-muted/50",
disabled ? "cursor-not-allowed" : "cursor-pointer"
)}
>
<Checkbox
checked={childSelected}
disabled={disabled}
onCheckedChange={(v) => onToggleChild(c.slug, Boolean(v))}
/>
<span className="min-w-0 truncate text-sm">{c.name}</span>
</label>
);
})}
</div>
) : null}
</div>
);
})}
{orphanChildren.length ? (
<div className="rounded-lg border p-4">
{/* <div className="mb-2 font-medium">Other tags</div> */}
{/* <div className="mb-3 text-xs text-muted-foreground">
These tags are not currently assigned to a visible parent.
</div> */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{orphanChildren.map((t) => {
const checked = selectedSet.has(t.slug);
return (
<label
key={t.id}
className="flex cursor-pointer items-center gap-3 rounded-md border px-3 py-2 hover:bg-muted/50"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => onToggleChild(t.slug, Boolean(v))}
/>
<span className="min-w-0 truncate text-sm">{t.name}</span>
</label>
);
})}
</div>
</div>
) : null}
{rootGroups.length === 0 && orphanChildren.length === 0 ? (
<div className="text-sm text-muted-foreground">
No tags available for filtering.
</div>
) : null}
</div>
</ScrollArea>
<Separator />
<div className="flex flex-col gap-3 px-6 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-muted-foreground">
Selected: <span className="font-medium text-foreground">{draft.length}</span>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:gap-2">
<Button variant="ghost" onClick={clearAll} disabled={!hasDraft}>
Clear all tags
</Button>
<Button variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={apply}>Apply</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -11,12 +11,6 @@ export default async function Banner() {
include: { include: {
file: true file: true
} }
// include: {
// variants: {
// where: { type: "modified" },
// take: 1,
// },
// },
}) })
const alt = headerImage?.altText ?? "Header Image" const alt = headerImage?.altText ?? "Header Image"

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="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 DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="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 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }