From 296e8a178710e3759cd72f8220d0b17f27299888 Mon Sep 17 00:00:00 2001 From: Citali Date: Sun, 21 Dec 2025 22:11:43 +0100 Subject: [PATCH] Change schema --- bun.lock | 5 + package.json | 1 + prisma/schema.prisma | 1 + .../artworks/animalstudies/index/page.tsx | 293 ++++++++++++++++++ .../(normal)/artworks/animalstudies/page.tsx | 6 +- src/components/ui/accordion.tsx | 66 ++++ 6 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 src/app/(normal)/artworks/animalstudies/index/page.tsx create mode 100644 src/components/ui/accordion.tsx diff --git a/bun.lock b/bun.lock index ad81221..884ba94 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@aws-sdk/s3-request-presigner": "^3.954.0", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -283,10 +284,14 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@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-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-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + "@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-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "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-id": "1.1.1", "@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-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-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + "@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=="], diff --git a/package.json b/package.json index a113bc9..3ca9b1a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@aws-sdk/s3-request-presigner": "^3.954.0", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 59def6a..68c4edb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -103,6 +103,7 @@ model ArtTag { name String @unique slug String @unique + isParent Boolean @default(false) showOnAnimalPage Boolean @default(false) description String? diff --git a/src/app/(normal)/artworks/animalstudies/index/page.tsx b/src/app/(normal)/artworks/animalstudies/index/page.tsx new file mode 100644 index 0000000..169ceec --- /dev/null +++ b/src/app/(normal)/artworks/animalstudies/index/page.tsx @@ -0,0 +1,293 @@ +import { prisma } from "@/lib/prisma"; +import Link from "next/link"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { ArrowLeftIcon } from "lucide-react"; + +type SimpleArtwork = { + id: string; + name: string; + sortKey: number | null; +}; + +function sortBySortIndexName(a: T, b: T) { + return a.sortIndex - b.sortIndex || a.name.localeCompare(b.name); +} + +function sortArtworks(a: SimpleArtwork, b: SimpleArtwork) { + const ak = a.sortKey ?? 999999; + const bk = b.sortKey ?? 999999; + return ak - bk || a.name.localeCompare(b.name) || a.id.localeCompare(b.id); +} + +export default async function AnimalListPage() { + /** + * We fetch all "animal page" tags and only artworks that are: + * - published + * - in "Animal Studies" category + * + * This makes the list reflect exactly what the user sees on the Animal Studies page. + */ + const tags = await prisma.artTag.findMany({ + where: { showOnAnimalPage: true }, + select: { + id: true, + name: true, + slug: true, + sortIndex: true, + parentId: true, + + // artworks tagged with THIS tag + artworks: { + where: { + published: true, + categories: { some: { name: "Animal Studies" } }, + }, + select: { id: true, name: true, sortKey: true }, + orderBy: [{ sortKey: "asc" }, { name: "asc" }, { id: "asc" }], + }, + }, + orderBy: [{ sortIndex: "asc" }, { name: "asc" }], + }); + + // Build maps to render a robust parent/child hierarchy (no reliance on Prisma nested children) + const byId = new Map(tags.map((t) => [t.id, t])); + const childrenByParentId = new Map(); + + for (const t of tags) { + if (!t.parentId) continue; + const arr = childrenByParentId.get(t.parentId) ?? []; + arr.push(t); + childrenByParentId.set(t.parentId, arr); + } + + for (const [pid, arr] of childrenByParentId) { + childrenByParentId.set(pid, arr.slice().sort(sortBySortIndexName)); + } + + const parents = tags + .filter((t) => t.parentId === null) + .slice() + .sort(sortBySortIndexName); + + // Orphans: child references a parentId that isn't present (or isn't showOnAnimalPage) + const orphans = tags + .filter((t) => t.parentId !== null && !byId.has(t.parentId)) + .slice() + .sort(sortBySortIndexName); + + // Small helper to render artworks list (linked) + const ArtworkList = ({ items }: { items: SimpleArtwork[] }) => { + const list = items.slice().sort(sortArtworks); + if (list.length === 0) { + return

No artworks found.

; + } + + return ( +
    + {list.map((a) => ( +
  • + + + → + + + {a.name} + +
  • + ))} +
+ ); + }; + + // Count helper for badges + const countArtworks = (tagId: string) => { + const t = byId.get(tagId); + return t?.artworks?.length ?? 0; + }; + + const countArtworksInChildren = (tagId: string) => { + const children = childrenByParentId.get(tagId) ?? []; + let sum = 0; + for (const c of children) sum += c.artworks.length; + return sum; + }; + + return ( +
+
+ + +
+

+ Animal index +

+ {/*

+ Click to expand and browse linked artworks. +

*/} +
+
+ +
+ + + Grouped animals + {/*

+ Parent tags expand into children; standalone tags appear as single entries. +

*/} +

+ Click to expand and browse linked artworks. +

+
+ + + {parents.length === 0 ? ( +

No parent tags found.

+ ) : ( + + {parents.map((p) => { + const children = childrenByParentId.get(p.id) ?? []; + const parentDirectCount = p.artworks.length; + const childrenCount = countArtworksInChildren(p.id); + + // Standalone root tag: no children + const isStandalone = children.length === 0; + + return ( + + +
+
+ {p.name} + {isStandalone ? ( + single + ) : ( + {children.length} sub + )} +
+ +
+ {parentDirectCount > 0 ? ( + {parentDirectCount} direct + ) : null} + {!isStandalone ? ( + {childrenCount} in sub + ) : ( + {parentDirectCount} artworks + )} +
+
+
+ + + {/* If standalone root: just list its artworks */} + {isStandalone ? ( +
+
+
Artworks
+ {p.artworks.length} +
+ +
+ ) : ( +
+ {/* Optional: artworks directly on the parent */} +
+
+
Directly tagged
+ {p.artworks.length} +
+ +
+ + + + {/* Children blocks */} +
+ {children.map((c) => ( +
+
+
+
{c.name}
+
+ Sub-tag of {p.name} +
+
+ {c.artworks.length} +
+ + +
+ ))} +
+
+ )} +
+
+ ); + })} +
+ )} +
+
+ + {orphans.length ? ( + + + Ungrouped animals + {/*

+ Tags whose parent is not visible (or not configured for the animal page). +

*/} +
+ +
+ {orphans.map((t) => ( +
+
+
{t.name}
+ {t.artworks.length} +
+ +
+ ))} +
+
+
+ ) : null} +
+
+ ); +} diff --git a/src/app/(normal)/artworks/animalstudies/page.tsx b/src/app/(normal)/artworks/animalstudies/page.tsx index 4714cb5..524a56c 100644 --- a/src/app/(normal)/artworks/animalstudies/page.tsx +++ b/src/app/(normal)/artworks/animalstudies/page.tsx @@ -96,10 +96,10 @@ export default async function AnimalStudiesPage({ searchParams }: { searchParams selectedTagSlugs={selectedTagSlugs} /> - diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..4a8cca4 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }