From 987843d96b91bca62136011579dff8f531ea4acc Mon Sep 17 00:00:00 2001
From: Citali
Date: Thu, 12 Feb 2026 22:53:00 +0100
Subject: [PATCH] feat(pages): complete reusable page block editor controls
---
TODO.md | 3 +-
.../components/pages/page-block-editor.tsx | 238 +++++++++++++-----
apps/web/src/components/public-page-view.tsx | 17 ++
3 files changed, 197 insertions(+), 61 deletions(-)
diff --git a/TODO.md b/TODO.md
index af522fd..22742fa 100644
--- a/TODO.md
+++ b/TODO.md
@@ -137,7 +137,7 @@ 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 builder with reusable content blocks (hero, rich text, gallery, CTA, forms, price cards)
+- [x] [P1] Page builder with reusable content blocks (hero, rich text, gallery, CTA, forms, price cards)
- [~] [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)
@@ -365,6 +365,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-12] Artwork refinement baseline completed: admin `/portfolio` now captures/edits medium, dimensions, year, framing, availability, publish state, and optional price visibility (`priceAmountCents` + `priceCurrency`), with public artwork detail rendering visible prices only.
- [2026-02-12] Artwork rendition management completed: admin `/portfolio` supports `thumbnail/card/full/retina/custom` slot assignment with dimensions and primary flag, plus per-artwork rendition listing and delete controls.
- [2026-02-12] Media type presets baseline completed in upload API: server-side validation now uses shared per-type rules (mime + max size) for `artwork/banner/promotion/video/gif/generic`, with optional env cap override via `CMS_MEDIA_UPLOAD_MAX_BYTES`.
+- [2026-02-12] Page builder reusable blocks completed: admin block editor now supports full field editing + ordering controls for hero/rich-text/gallery/cta/form/price-cards; public renderer includes form-link behavior for `contact`/`commission` keys.
- [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/components/pages/page-block-editor.tsx b/apps/admin/src/components/pages/page-block-editor.tsx
index 31ab562..6e02294 100644
--- a/apps/admin/src/components/pages/page-block-editor.tsx
+++ b/apps/admin/src/components/pages/page-block-editor.tsx
@@ -43,6 +43,25 @@ function updateBlock(blocks: PageBlocks, blockId: string, next: Partial entry.id === blockId)
+
+ if (index < 0) {
+ return blocks
+ }
+
+ const nextIndex = direction === "up" ? index - 1 : index + 1
+ if (nextIndex < 0 || nextIndex >= blocks.length) {
+ return blocks
+ }
+
+ const next = [...blocks]
+ const current = next[index]
+ next[index] = next[nextIndex]
+ next[nextIndex] = current
+ return next
+}
+
export function PageBlockEditor({
name,
initialContent,
@@ -156,13 +175,29 @@ export function PageBlockEditor({
#{index + 1} {block.type}
-
+
+
+
+
+
{block.type === "hero" ? (
@@ -187,6 +222,26 @@ export function PageBlockEditor({
placeholder="Subheading"
className="rounded border border-neutral-300 px-2 py-1 text-sm"
/>
+
+ setBlocks((prev) =>
+ updateBlock(prev, block.id, { ctaLabel: event.target.value || null }),
+ )
+ }
+ placeholder="CTA label"
+ className="rounded border border-neutral-300 px-2 py-1 text-sm"
+ />
+
+ setBlocks((prev) =>
+ updateBlock(prev, block.id, { ctaHref: event.target.value || null }),
+ )
+ }
+ placeholder="CTA href"
+ className="rounded border border-neutral-300 px-2 py-1 text-sm"
+ />
) : null}
@@ -203,22 +258,34 @@ export function PageBlockEditor({
) : null}
{block.type === "gallery" ? (
-
formKey: {block.formKey}
+
+ {formLink.label}
+
)
}