From 281b1d7a1be72af4cff790ca7e97d51cafcd8139 Mon Sep 17 00:00:00 2001 From: Citali Date: Thu, 12 Feb 2026 19:30:09 +0100 Subject: [PATCH] feat(pages): add pages and navigation builder baseline --- TODO.md | 7 +- apps/admin/src/app/navigation/page.tsx | 446 ++++++++++++++++++ apps/admin/src/app/pages/[id]/page.tsx | 242 ++++++++++ apps/admin/src/app/pages/page.tsx | 222 ++++++++- apps/admin/src/components/admin-shell.tsx | 1 + apps/admin/src/lib/access.test.ts | 4 + apps/admin/src/lib/access.ts | 7 + packages/content/src/index.ts | 1 + packages/content/src/pages-navigation.ts | 57 +++ .../migration.sql | 75 +++ packages/db/prisma/schema.prisma | 50 ++ packages/db/prisma/seed.ts | 63 +++ packages/db/src/index.ts | 12 + packages/db/src/pages-navigation.test.ts | 92 ++++ packages/db/src/pages-navigation.ts | 109 +++++ 15 files changed, 1372 insertions(+), 16 deletions(-) create mode 100644 apps/admin/src/app/navigation/page.tsx create mode 100644 apps/admin/src/app/pages/[id]/page.tsx create mode 100644 packages/content/src/pages-navigation.ts create mode 100644 packages/db/prisma/migrations/20260212190000_pages_navigation_builder/migration.sql create mode 100644 packages/db/src/pages-navigation.test.ts create mode 100644 packages/db/src/pages-navigation.ts diff --git a/TODO.md b/TODO.md index ff78d37..ea28945 100644 --- a/TODO.md +++ b/TODO.md @@ -122,7 +122,7 @@ This file is the single source of truth for roadmap and delivery progress. media model, artwork entity, grouping primitives (gallery/album/category/tag), rendition slots - [~] [P1] `todo/mvp1-media-upload-pipeline`: S3/local upload adapter, media processing presets, metadata input flows, admin media CRUD UI -- [ ] [P1] `todo/mvp1-pages-navigation-builder`: +- [~] [P1] `todo/mvp1-pages-navigation-builder`: page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds) - [ ] [P1] `todo/mvp1-commissions-customers`: commission request intake + admin CRUD + kanban + customer entity/linking @@ -144,9 +144,9 @@ This file is the single source of truth for roadmap and delivery progress. ### Admin App (Primary Focus) -- [ ] [P1] Page management (create/edit/publish/unpublish/schedule) +- [~] [P1] Page management (create/edit/publish/unpublish/schedule) - [ ] [P1] Page builder with reusable content blocks (hero, rich text, gallery, CTA, forms, price cards) -- [ ] [P1] Navigation management (menus, nested items, order, visibility) +- [~] [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) - [ ] [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 @@ -274,6 +274,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-12] Upload storage is now provider-based (`local` + `s3`) via `CMS_MEDIA_STORAGE_PROVIDER`; admin-side GUI toggle remains a later MVP item. - [2026-02-12] Media storage keys now use asset-centric layout (`tenant//asset///__.`) with DB-managed media taxonomy. - [2026-02-12] Admin media CRUD now includes list-to-detail flow (`/media/:id`) with metadata edit and delete actions. +- [2026-02-12] MVP1 pages/navigation baseline started: `Page`, `NavigationMenu`, and `NavigationItem` models plus admin CRUD routes (`/pages`, `/pages/:id`, `/navigation`). ## How We Use This File diff --git a/apps/admin/src/app/navigation/page.tsx b/apps/admin/src/app/navigation/page.tsx new file mode 100644 index 0000000..b6b31ab --- /dev/null +++ b/apps/admin/src/app/navigation/page.tsx @@ -0,0 +1,446 @@ +import { + createNavigationItem, + createNavigationMenu, + deleteNavigationItem, + listNavigationMenus, + listPages, + updateNavigationItem, +} from "@cms/db" +import { Button } from "@cms/ui/button" +import { revalidatePath } from "next/cache" +import { redirect } from "next/navigation" + +import { AdminShell } from "@/components/admin-shell" +import { requirePermissionForRoute } from "@/lib/route-guards" + +export const dynamic = "force-dynamic" + +type SearchParamsInput = Record + +function readFirstValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null + } + + return value ?? null +} + +function readInputString(formData: FormData, field: string): string { + const value = formData.get(field) + return typeof value === "string" ? value.trim() : "" +} + +function readNullableString(formData: FormData, field: string): string | null { + const value = readInputString(formData, field) + return value.length > 0 ? value : null +} + +function readInt(formData: FormData, field: string, fallback = 0): number { + const value = readInputString(formData, field) + + if (!value) { + return fallback + } + + const parsed = Number.parseInt(value, 10) + + if (!Number.isFinite(parsed)) { + return fallback + } + + return parsed +} + +function redirectWithState(params: { notice?: string; error?: string }) { + const query = new URLSearchParams() + + if (params.notice) { + query.set("notice", params.notice) + } + + if (params.error) { + query.set("error", params.error) + } + + const value = query.toString() + redirect(value ? `/navigation?${value}` : "/navigation") +} + +async function createMenuAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/navigation", + permission: "navigation:write", + scope: "team", + }) + + try { + await createNavigationMenu({ + name: readInputString(formData, "name"), + slug: readInputString(formData, "slug"), + location: readInputString(formData, "location"), + isVisible: readInputString(formData, "isVisible") === "true", + }) + } catch { + redirectWithState({ error: "Failed to create navigation menu." }) + } + + revalidatePath("/navigation") + redirectWithState({ notice: "Navigation menu created." }) +} + +async function createItemAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/navigation", + permission: "navigation:write", + scope: "team", + }) + + try { + await createNavigationItem({ + menuId: readInputString(formData, "menuId"), + label: readInputString(formData, "label"), + href: readNullableString(formData, "href"), + pageId: readNullableString(formData, "pageId"), + parentId: readNullableString(formData, "parentId"), + sortOrder: readInt(formData, "sortOrder", 0), + isVisible: readInputString(formData, "isVisible") === "true", + }) + } catch { + redirectWithState({ error: "Failed to create navigation item." }) + } + + revalidatePath("/navigation") + redirectWithState({ notice: "Navigation item created." }) +} + +async function updateItemAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/navigation", + permission: "navigation:write", + scope: "team", + }) + + try { + await updateNavigationItem({ + id: readInputString(formData, "id"), + label: readInputString(formData, "label"), + href: readNullableString(formData, "href"), + pageId: readNullableString(formData, "pageId"), + parentId: readNullableString(formData, "parentId"), + sortOrder: readInt(formData, "sortOrder", 0), + isVisible: readInputString(formData, "isVisible") === "true", + }) + } catch { + redirectWithState({ error: "Failed to update navigation item." }) + } + + revalidatePath("/navigation") + redirectWithState({ notice: "Navigation item updated." }) +} + +async function deleteItemAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/navigation", + permission: "navigation:write", + scope: "team", + }) + + try { + await deleteNavigationItem(readInputString(formData, "id")) + } catch { + redirectWithState({ error: "Failed to delete navigation item." }) + } + + revalidatePath("/navigation") + redirectWithState({ notice: "Navigation item deleted." }) +} + +export default async function NavigationManagementPage({ + searchParams, +}: { + searchParams: Promise +}) { + const role = await requirePermissionForRoute({ + nextPath: "/navigation", + permission: "navigation:read", + scope: "team", + }) + + const [resolvedSearchParams, menus, pages] = await Promise.all([ + searchParams, + listNavigationMenus(), + listPages(200), + ]) + + const notice = readFirstValue(resolvedSearchParams.notice) + const error = readFirstValue(resolvedSearchParams.error) + + return ( + + {notice ? ( +
+ {notice} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + +
+
+

Create Menu

+
+ + + + + +
+
+ +
+

Create Navigation Item

+
+ + +
+ + +
+
+ + +
+ + +
+
+
+ +
+ {menus.length === 0 ? ( +
+ No navigation menus yet. +
+ ) : ( + menus.map((menu) => ( +
+
+

+ {menu.name} ({menu.location}) +

+ + {menu.isVisible ? "visible" : "hidden"} + +
+ +
+ {menu.items.length === 0 ? ( +

No items in this menu.

+ ) : ( + menu.items.map((item) => ( +
+ +
+ + + +
+ +
+ + +
+ +
+ +
+ + +
+
+
+ )) + )} +
+
+ )) + )} +
+
+ ) +} diff --git a/apps/admin/src/app/pages/[id]/page.tsx b/apps/admin/src/app/pages/[id]/page.tsx new file mode 100644 index 0000000..2230520 --- /dev/null +++ b/apps/admin/src/app/pages/[id]/page.tsx @@ -0,0 +1,242 @@ +import { deletePage, getPageById, updatePage } from "@cms/db" +import { Button } from "@cms/ui/button" +import Link from "next/link" +import { redirect } from "next/navigation" + +import { AdminShell } from "@/components/admin-shell" +import { requirePermissionForRoute } from "@/lib/route-guards" + +export const dynamic = "force-dynamic" + +type SearchParamsInput = Record + +type PageProps = { + params: Promise<{ id: string }> + searchParams: Promise +} + +function readFirstValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null + } + + return value ?? null +} + +function readInputString(formData: FormData, field: string): string { + const value = formData.get(field) + return typeof value === "string" ? value.trim() : "" +} + +function readNullableString(formData: FormData, field: string): string | null { + const value = readInputString(formData, field) + return value.length > 0 ? value : null +} + +function redirectWithState(pageId: string, params: { notice?: string; error?: string }) { + const query = new URLSearchParams() + + if (params.notice) { + query.set("notice", params.notice) + } + + if (params.error) { + query.set("error", params.error) + } + + const value = query.toString() + redirect(value ? `/pages/${pageId}?${value}` : `/pages/${pageId}`) +} + +export default async function PageEditorPage({ params, searchParams }: PageProps) { + const role = await requirePermissionForRoute({ + nextPath: "/pages", + permission: "pages:read", + scope: "team", + }) + const resolvedParams = await params + const pageId = resolvedParams.id + + const [resolvedSearchParams, pageRecord] = await Promise.all([searchParams, getPageById(pageId)]) + + if (!pageRecord) { + redirect("/pages?error=Page+not+found") + } + + const page = pageRecord + const notice = readFirstValue(resolvedSearchParams.notice) + const error = readFirstValue(resolvedSearchParams.error) + + async function updatePageAction(formData: FormData) { + "use server" + + await requirePermissionForRoute({ + nextPath: "/pages", + permission: "pages:write", + scope: "team", + }) + + try { + await updatePage({ + id: pageId, + title: readInputString(formData, "title"), + slug: readInputString(formData, "slug"), + status: readInputString(formData, "status"), + summary: readNullableString(formData, "summary"), + content: readInputString(formData, "content"), + seoTitle: readNullableString(formData, "seoTitle"), + seoDescription: readNullableString(formData, "seoDescription"), + }) + } catch { + redirectWithState(pageId, { + error: "Failed to update page. Validate values and try again.", + }) + } + + redirectWithState(pageId, { + notice: "Page updated.", + }) + } + + async function deletePageAction() { + "use server" + + await requirePermissionForRoute({ + nextPath: "/pages", + permission: "pages:write", + scope: "team", + }) + + try { + await deletePage(pageId) + } catch { + redirectWithState(pageId, { + error: "Failed to delete page. Remove linked navigation references first.", + }) + } + + redirect("/pages?notice=Page+deleted") + } + + return ( + + {notice ? ( +
+ {notice} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + +
+
+
+

{page.title}

+

ID: {page.id}

+
+ + Back to pages + +
+ +
+
+ + +
+ + + + + +