From 984511f166e86527ff9c9bc90d30d35216ad4894 Mon Sep 17 00:00:00 2001 From: Citali Date: Thu, 12 Feb 2026 22:46:04 +0100 Subject: [PATCH] feat(portfolio): add grouping visibility and ordering controls --- TODO.md | 3 +- apps/admin/src/app/portfolio/page.tsx | 164 +++++++++++++++++- packages/content/src/media.ts | 17 ++ .../migration.sql | 4 + packages/db/prisma/schema.prisma | 3 + packages/db/src/index.ts | 2 + packages/db/src/media-foundation.ts | 80 ++++++++- 7 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 packages/db/prisma/migrations/20260213003000_tag_group_controls/migration.sql diff --git a/TODO.md b/TODO.md index 3c5f2eb..b6c0c77 100644 --- a/TODO.md +++ b/TODO.md @@ -141,7 +141,7 @@ This file is the single source of truth for roadmap and delivery progress. - [~] [P1] Navigation management (menus, nested items, order, visibility) - [~] [P1] Media library (upload, browse, replace, delete) with media-type classification (artwork, banner, promo, generic, video/gif) - [x] [P1] Media enrichment metadata (alt text, copyright, author, source, tags, licensing, usage context) -- [ ] [P1] Portfolio grouping primitives (galleries, albums, categories, tags) with ordering/visibility controls +- [x] [P1] Portfolio grouping primitives (galleries, albums, categories, tags) with ordering/visibility controls - [ ] [P1] Artwork refinement fields (medium, dimensions, year, framing, availability, price visibility) - [ ] [P1] Artwork rendition management (thumbnail, card, full, retina/custom sizes) - [ ] [P1] Type-specific processing presets (artwork/banner/promo/video/gif) with validation rules @@ -361,6 +361,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-12] Page editor now supports locale translations in `/pages/:id`; public page rendering uses locale-aware page lookup with base-content fallback. - [2026-02-12] Public rendering integration advanced with locale-aware navigation/news translations and a new public commission request entry route (`/[locale]/commissions`) that creates/reuses customer records and opens a `new` commission. - [2026-02-12] Public portfolio baseline added with `/{locale}/portfolio` and `/{locale}/portfolio/{slug}`, including published-artwork filters (gallery/album/category/tag), rendition image streaming via web `/api/media/file/:id`, and media-aware artwork detail rendering. +- [2026-02-12] Portfolio grouping controls completed in admin `/portfolio`: galleries/albums/categories/tags now support visibility and sort-order management (create/update/delete), and public tag filters now respect visibility. - [2026-02-12] Public UX pass: commission request flow now reports explicit invalid budget range errors, and header navigation now falls back to localized defaults (`home`, `portfolio`, `news`, `commissions`) when no CMS menu exists; seed data now creates those default menu entries. - [2026-02-12] Added `e2e/public-rendering.pw.ts` web coverage for fallback navigation visibility, portfolio routes, and commission submission validation (invalid budget range + successful submission path). - [2026-02-12] Testing execution is temporarily paused for delivery velocity: root test scripts are stubbed and CI test steps are disabled; all testing backlog is consolidated under `MVP 3: Testing and Quality`. diff --git a/apps/admin/src/app/portfolio/page.tsx b/apps/admin/src/app/portfolio/page.tsx index 85595ad..8e5f838 100644 --- a/apps/admin/src/app/portfolio/page.tsx +++ b/apps/admin/src/app/portfolio/page.tsx @@ -5,10 +5,12 @@ import { createCategory, createGallery, createTag, + deleteGrouping, linkArtworkToGrouping, listArtworks, listMediaAssets, listMediaFoundationGroups, + updateGrouping, } from "@cms/db" import { Button } from "@cms/ui/button" import { revalidatePath } from "next/cache" @@ -32,6 +34,21 @@ function readOptionalField(formData: FormData, key: string): string | undefined return value.length > 0 ? value : undefined } +function readOptionalNullableField(formData: FormData, key: string): string | null { + const value = readField(formData, key) + return value.length > 0 ? value : null +} + +function readNonNegativeInt(formData: FormData, key: string): number { + const raw = readField(formData, key) + const value = Number(raw) + return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0 +} + +function readBooleanField(formData: FormData, key: string): boolean { + return formData.get(key) === "on" || readField(formData, key) === "true" +} + function readFirstValue(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { return value[0] ?? null @@ -117,23 +134,32 @@ async function createGroupAction(formData: FormData) { name, slug, description: readOptionalField(formData, "description"), + sortOrder: readNonNegativeInt(formData, "sortOrder"), + isVisible: readBooleanField(formData, "isVisible"), }) } else if (type === "album") { await createAlbum({ name, slug, description: readOptionalField(formData, "description"), + sortOrder: readNonNegativeInt(formData, "sortOrder"), + isVisible: readBooleanField(formData, "isVisible"), }) } else if (type === "category") { await createCategory({ name, slug, description: readOptionalField(formData, "description"), + sortOrder: readNonNegativeInt(formData, "sortOrder"), + isVisible: readBooleanField(formData, "isVisible"), }) } else { await createTag({ name, slug, + description: readOptionalField(formData, "description"), + sortOrder: readNonNegativeInt(formData, "sortOrder"), + isVisible: readBooleanField(formData, "isVisible"), }) } } catch { @@ -144,6 +170,47 @@ async function createGroupAction(formData: FormData) { redirectWithState({ notice: `${type} created.` }) } +async function updateGroupAction(formData: FormData) { + "use server" + + await requireWritePermission() + + try { + await updateGrouping({ + groupType: readField(formData, "groupType"), + groupId: readField(formData, "groupId"), + name: readField(formData, "name"), + slug: slugify(readField(formData, "slug")), + description: readOptionalNullableField(formData, "description"), + sortOrder: readNonNegativeInt(formData, "sortOrder"), + isVisible: readBooleanField(formData, "isVisible"), + }) + } catch { + redirectWithState({ error: "Failed to update grouping entity." }) + } + + revalidatePath("/portfolio") + redirectWithState({ notice: "Grouping entity updated." }) +} + +async function deleteGroupAction(formData: FormData) { + "use server" + + await requireWritePermission() + + try { + await deleteGrouping({ + groupType: readField(formData, "groupType"), + groupId: readField(formData, "groupId"), + }) + } catch { + redirectWithState({ error: "Failed to delete grouping entity." }) + } + + revalidatePath("/portfolio") + redirectWithState({ notice: "Grouping entity deleted." }) +} + async function linkArtworkGroupAction(formData: FormData) { "use server" @@ -297,7 +364,7 @@ export default async function PortfolioPage({

Create Group Entity

-
+
+