From 09b914c43065d118b5e4704d2bb08369798c56b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 31 Aug 2026 01:38:05 +0100 Subject: [PATCH] Layout replaces one section per page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin was a section each for Home, About Us, Collections, Gallery, Collabs, Drops and Services, because the site's pages were a constant in site-map.ts and a developer was the only one who could add one. A page builder is going to replace that, so the nav stops enumerating pages. What is left is the two things a page is NOT: Layout — the shell every page renders inside — and Pages, listed but not built. Layout is first because it is first in the work: building pages before the frame they sit in means building them twice. The panel is a sketch of the page with the controls under it. Menu across the top, a body, the footer at the bottom, in the chosen colours and typefaces — the same principle the header editor already follows, where the admin renders the structure as the page will show it. It is honest about being a sketch: drawing the logo and the rules would be a second implementation of the layout, and the two would drift the first time either changed. Fonts are a mapping, not free text. styles.css already declares four ROLES — body, sub, title, display — and every template asks for a role rather than a typeface, so choosing a font is choosing which of the four faces the build subsets plays each part. The list travels with the payload rather than being copied here, because a picker offering a face the build never subsets renders as a fallback nobody chose. The per-page panels are NOT deleted. AboutPanel, HomePanel and RecordsPanel are still in the tree and still work; they are unreachable, which is a line in sections.ts rather than a rewrite. The page builder is not finished, and removing the only way to edit the live site's content before its replacement exists would leave a gap with nothing in it. Co-Authored-By: Claude Opus 5 (1M context) --- web/DetailPanel.tsx | 15 +- web/sections.ts | 142 ++++------ web/sections/LayoutPanel.tsx | 504 +++++++++++++++++++++++++++++++++++ 3 files changed, 551 insertions(+), 110 deletions(-) create mode 100644 web/sections/LayoutPanel.tsx diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index a56d761..41088b4 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -4,13 +4,10 @@ import { Button } from '@/components/ui/button'; import { DEFAULT_SECTION, isSectionKey, sectionFor, sectionOpen, type SectionKey } from './sections'; import { AdministrationPanel } from './AdministrationPanel'; import { MediaPanel } from './MediaPanel'; -import { HomePanel } from './sections/HomePanel'; import { GalleriesPanel } from './sections/GalleriesPanel'; +import { LayoutPanel } from './sections/LayoutPanel'; import { PublishButton } from './PublishButton'; import { SaveTargetProvider, useSaveButton } from './SaveTarget'; -import { AboutPanel } from './sections/AboutPanel'; -import { RecordsPanel } from './sections/RecordsPanel'; -import { COLLABS_CONFIG, DROPS_CONFIG, GALLERY_CONFIG } from './sections/records'; import { MessagesPanel } from './MessagesPanel'; import { PlannedSection } from './PlannedSection'; import { useReady } from './useReady'; @@ -47,15 +44,7 @@ import { useReady } from './useReady'; * silently showing somebody else's. */ const PANELS: Partial React.JSX.Element>> = { - home: HomePanel, - about: AboutPanel, - /* - Three sections, one panel, configured — the same split the site's API already makes between - `records.ts` and `sections.ts`. See web/sections/records.ts for what each one carries. - */ - gallery: () => , - drops: () => , - collabs: () => , + layout: LayoutPanel, galleries: GalleriesPanel, messages: MessagesPanel, administration: AdministrationPanel, diff --git a/web/sections.ts b/web/sections.ts index 0537d91..85f6b77 100644 --- a/web/sections.ts +++ b/web/sections.ts @@ -1,21 +1,30 @@ import type { LucideIcon } from 'lucide-react'; -import { Home, Info, Layers, Images, Handshake, Sparkles, Wrench, Settings, Mail, Cog, FolderOpen } from 'lucide-react'; +import { Images, Mail, Cog, FolderOpen, PanelsTopLeft, Files } from 'lucide-react'; /** * The sections of the fofos admin, in nav order. * - * ── The order is the client's site, not the database's ── + * ── There is no longer one section per page ── * - * It follows the sitemap: the pages someone edits most, first. Site settings, Messages and - * Administration sit at the bottom because they are not pages — the site's settings, an inbox, and the - * plugin's own machinery. + * There was: Home, About Us, Collections, Gallery, Collabs, Drops, Services — a section each, because + * the site's pages were a constant in `site-map.ts` and a developer was the only one who could add + * one. Every new page meant a new entry here, a new panel, and a new API route. * - * ── `site` and `administration` are different things ── + * That model is being replaced by a page builder, so the nav stops enumerating pages. What is left is + * the two things a page is not: * - * "Site settings" is the CLIENT's — partners, social links, contact details, all of it public copy that - * whoever edits the site edits. "Administration" is OFFICER's end: where fofos is, how to reach it, and - * whatever else this plugin needs to know to do its job. One is content, the other is plumbing, and the - * people who touch them are not the same people. Nothing in Administration ever appears on the site. + * Layout the shell every page renders inside — the menu, the footer, the colours, the typefaces + * Pages the pages themselves, built rather than hard-coded + * + * The per-page panels have NOT been deleted. `AboutPanel`, `HomePanel` and `RecordsPanel` are still in + * the tree and still work; they are simply not reachable, which is a line in this file rather than a + * rewrite. That is deliberate — the page builder is not finished, and deleting the only way to edit + * the live site's content before its replacement exists would be a gap with nothing in it. + * + * ── `administration` ── + * + * OFFICER's end: where fofos is, how to reach it, and whatever else this plugin needs to do its job. + * Nothing in it ever appears on the site. * * ── `built` ── * @@ -27,19 +36,7 @@ import { Home, Info, Layers, Images, Handshake, Sparkles, Wrench, Settings, Mail * the nav and what actually renders cannot disagree. */ -export type SectionKey = - | 'administration' - | 'galleries' - | 'media' - | 'messages' - | 'site' - | 'about' - | 'home' - | 'gallery' - | 'drops' - | 'collabs' - | 'collections' - | 'services'; +export type SectionKey = 'administration' | 'galleries' | 'media' | 'messages' | 'layout' | 'pages'; export type Section = { key: SectionKey; @@ -75,86 +72,37 @@ export type Section = { export const SECTIONS: Section[] = [ { - key: 'home', - label: 'Home', - icon: Home, - hint: 'The hero, the intro, and the gallery', - path: '/fofos-admin/home', + /* + The shell every page renders inside, and therefore the first thing to decide. + + First in the nav because it is first in the work: the menu, the footer, the colours and the + typefaces are the frame every page is built into, and building pages before the frame exists + means building them twice. + */ + key: 'layout', + label: 'Layout', + icon: PanelsTopLeft, + hint: 'The menu, the footer, the colours and the typefaces', + path: '/fofos-admin/layout', built: true, requires: 'connection', }, { - key: 'about', - label: 'About Us', - icon: Info, - hint: 'The header photograph and the copy', - path: '/fofos-admin/about', - built: true, - requires: 'connection', - }, - { - key: 'collections', - label: 'Collections', - icon: Layers, - hint: 'Collections, and the items in them', - path: '/fofos-admin/collections', + /* + The pages, built rather than hard-coded. Not started. + + This is what replaced the seven per-page sections that used to be here. Listed while unbuilt on + purpose: the nav says what is coming, which is a better answer than a nav that quietly lost seven + entries and offers no account of where they went. + */ + key: 'pages', + label: 'Pages', + icon: Files, + hint: 'Build a page from blocks, and choose where it sits', + path: null, built: false, requires: 'connection', }, - { - key: 'gallery', - label: 'Gallery', - icon: Images, - hint: 'Standalone pieces', - path: '/fofos-admin/gallery', - built: true, - requires: 'connection', - }, - { - key: 'collabs', - label: 'Collabs', - icon: Handshake, - hint: 'Projects with other people', - path: '/fofos-admin/collabs', - built: true, - requires: 'connection', - }, - { - key: 'drops', - label: 'Drops', - icon: Sparkles, - hint: 'Releases', - path: '/fofos-admin/drops', - built: true, - requires: 'connection', - }, - { - key: 'services', - label: 'Services', - icon: Wrench, - hint: 'A fixed tree — the words, not the shape', - path: '/fofos-admin/services', - built: false, - requires: 'connection', - }, - { - key: 'site', - label: 'Site settings', - icon: Settings, - hint: 'Partners, social links, contact details', - path: '/fofos-admin/site', - built: false, - requires: 'connection', - }, - { - key: 'messages', - label: 'Messages', - icon: Mail, - hint: 'What people sent through the contact form', - path: '/fofos-admin/messages', - built: true, - requires: 'connection', - }, { /* Galleries — an entity of their own, not a list hanging off a page. @@ -223,7 +171,7 @@ export const SECTIONS: Section[] = [ ]; /** Where the detail panel goes when the URL says nothing, or says something it does not recognise. */ -export const DEFAULT_SECTION: SectionKey = 'messages'; +export const DEFAULT_SECTION: SectionKey = 'layout'; export const isSectionKey = (value: string | null): value is SectionKey => SECTIONS.some((section) => section.key === value); diff --git a/web/sections/LayoutPanel.tsx b/web/sections/LayoutPanel.tsx new file mode 100644 index 0000000..5a910e7 --- /dev/null +++ b/web/sections/LayoutPanel.tsx @@ -0,0 +1,504 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { GripVertical, Plus, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useClient } from 'hooks/useClient'; +import { readableError } from '../errors'; +import { useFlash } from '../useFlash'; +import { useSaveTarget } from '../SaveTarget'; + +/** + * The shell every page renders inside: the menu, the footer, the colours and the typefaces. + * + * ── Why this is the first section now ── + * + * The admin was one section per page — Home, About, Gallery, Drops — because the site's pages were a + * constant in a file. They are becoming something somebody builds, so a nav that enumerates them is a + * nav that has to be edited by a developer every time the client adds a page. Layout is what is left + * once you take the pages out: the part of every page that is not the page. + * + * ── The preview is the point ── + * + * It draws the menu across the top, a body, and the footer at the bottom, in the chosen colours and + * typefaces. That is the same principle the header editor already follows — the admin renders the + * structure as the page will show it, so what is arranged is what is published. + * + * It is a SKETCH and says so: the real page has a logo, a rule, a specific measure. Drawing those here + * would be a second implementation of the layout, and the two would drift the first time either + * changed. What it is honest about is the thing being decided — order, wording, colour, typeface. + */ + +type LayoutLink = { label: string; href: string }; +type FooterColumn = { title: string; links: LayoutLink[] }; + +type Layout = { + menu_items: LayoutLink[]; + footer_columns: FooterColumn[]; + footer_note: string | null; + background: string | null; + foreground: string | null; + accent: string | null; + font_body: string | null; + font_sub: string | null; + font_title: string | null; + font_display: string | null; + /** What the picker may offer — sent by the API so this file holds no third copy of the list. */ + fonts: string[]; + /** The pages that exist, for the menu's href suggestions. */ + pages: { path: string; label: string }[]; +}; + +/** The four roles styles.css declares. A template asks for a role; this decides which face plays it. */ +const ROLES = [ + { key: 'font_title' as const, label: 'Headings', hint: 'Page and section titles' }, + { key: 'font_body' as const, label: 'Body', hint: 'Paragraphs and most text' }, + { key: 'font_sub' as const, label: 'Menu', hint: 'The navigation and small labels' }, + { key: 'font_display' as const, label: 'Display', hint: 'The largest statements' }, +]; + +const COLOURS = [ + { key: 'background' as const, label: 'Background', fallback: '#ffffff' }, + { key: 'foreground' as const, label: 'Text', fallback: '#000000' }, + { key: 'accent' as const, label: 'Accent', fallback: '#000000' }, +]; + +export const LayoutPanel = () => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [stored, setStored] = useState(null); + const [draft, setDraft] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [saved, flashSaved] = useFlash(); + + const apply = useCallback((data: Layout) => { + setStored(data); + setDraft(data); + }, []); + + const load = useCallback(async () => { + try { + apply((await clientRef.current.get<{ data: Layout }>('/fofos-admin/layout')).data); + } catch (failure) { + setError(readableError(failure)); + } + }, [apply]); + + useEffect(() => { + void load(); + }, [load]); + + const patch = (values: Partial) => setDraft((was) => (was ? { ...was, ...values } : was)); + + /* + Compared field by field rather than whole, because the payload carries two things this does not + own: `fonts` and `pages` are the API telling the client what exists, not values anybody edits. + Including them would be harmless today and wrong the first time a page is added — the panel would + show unsaved changes for a page somebody else created. + */ + const WRITABLE = [ + 'menu_items', + 'footer_columns', + 'footer_note', + 'background', + 'foreground', + 'accent', + 'font_body', + 'font_sub', + 'font_title', + 'font_display', + ] as const; + + const only = (source: Layout | null) => + source ? JSON.stringify(Object.fromEntries(WRITABLE.map((key) => [key, source[key]]))) : null; + + const dirty = Boolean(stored && draft) && only(stored) !== only(draft); + + const save = async () => { + if (!draft) return; + setBusy(true); + setError(null); + try { + const body = Object.fromEntries(WRITABLE.map((key) => [key, draft[key]])); + apply((await clientRef.current.patch<{ data: Layout }>('/fofos-admin/layout', body)).data); + flashSaved(); + } catch (failure) { + setError(readableError(failure)); + } finally { + setBusy(false); + } + }; + + useSaveTarget(dirty, busy, save); + + if (!draft) return
{error ?? 'Loading…'}
; + + const menu = draft.menu_items; + const setMenu = (items: LayoutLink[]) => patch({ menu_items: items }); + + const columns = draft.footer_columns; + const setColumns = (next: FooterColumn[]) => patch({ footer_columns: next }); + + /** Falls back to what the page itself falls back to, so the sketch is not a different design. */ + const bg = draft.background ?? '#ffffff'; + const fg = draft.foreground ?? '#000000'; + + return ( +
+ {saved &&

Saved

} + {error &&

{error}

} + + {/* ── The sketch ───────────────────────────────────────────────────────────────────────── */} +
+
+ {/* The menu, in the order it will appear, in the face chosen for it. */} +
+ {/* The menu when there is one, the site's own pages when there is not — the same fallback + the page itself applies, so the sketch never shows a menu the site would not draw. */} + {(menu.length ? menu : draft.pages.map((page) => ({ label: page.label, href: page.path }))).map( + (item, index) => ( + {item.label} + ), + )} + {!menu.length && !draft.pages.length && No menu yet} +
+ + {/* The body. Deliberately empty: what goes here is the page, and the page is what this + section is NOT about. Its only job is to give the two bands something between them. */} +
+

The page

+

+ Whatever is built for it goes here +

+
+ + {/* The footer, with the columns as they will fall. */} +
+ {columns.length ? ( +
+ {columns.map((column, index) => ( +
+ {column.title && ( +

+ {column.title} +

+ )} + {column.links.map((link, i) => ( +

+ {link.label} +

+ ))} +
+ ))} +
+ ) : ( +

No footer columns yet

+ )} + {draft.footer_note &&

{draft.footer_note}

} +
+
+
+ + {/* ── Menu ─────────────────────────────────────────────────────────────────────────────── */} +
+
+

Menu

+ {!menu.length && ( + Showing the site’s own pages + )} +
+ + {menu.map((item, index) => ( + setMenu(menu.map((was, i) => (i === index ? next : was)))} + onRemove={() => setMenu(menu.filter((_, i) => i !== index))} + onMove={(by) => setMenu(moved(menu, index, by))} + first={index === 0} + last={index === menu.length - 1} + /> + ))} + +
+ + + {/* + Start from the site's own pages rather than from nothing. + + The menu falls back to them until somebody sets one, so the first edit is almost always + "the pages, with one change". Typing all eight back in to make that change is the kind of + small cruelty that makes people leave a panel alone. + */} + {!menu.length && draft.pages.length > 0 && ( + + )} +
+
+ + {/* ── Footer ───────────────────────────────────────────────────────────────────────────── */} +
+

Footer

+ + {columns.map((column, index) => ( +
+
+ + setColumns(columns.map((was, i) => (i === index ? { ...was, title: event.currentTarget.value } : was))) + } + /> + +
+ + {column.links.map((link, i) => ( + + setColumns( + columns.map((was, c) => + c === index ? { ...was, links: was.links.map((l, li) => (li === i ? next : l)) } : was, + ), + ) + } + onRemove={() => + setColumns( + columns.map((was, c) => (c === index ? { ...was, links: was.links.filter((_, li) => li !== i) } : was)), + ) + } + onMove={(by) => + setColumns(columns.map((was, c) => (c === index ? { ...was, links: moved(was.links, i, by) } : was))) + } + first={i === 0} + last={i === column.links.length - 1} + /> + ))} + + +
+ ))} + + + +
+ + patch({ footer_note: event.currentTarget.value || null })} + /> +
+
+ + {/* ── Colours ──────────────────────────────────────────────────────────────────────────── */} +
+

Colours

+ +
+ {COLOURS.map(({ key, label, fallback }) => ( +
+ {/* + The browser's own picker. It sits on the LEFT of a wide row here, which is the whole + reason it works: the same control at the right-hand edge of this panel opened its popup + off the side of the window. See OverlayEditor for the version that had to move. + */} + patch({ [key]: event.currentTarget.value } as Partial)} + className="size-8 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5" + title={label} + /> +
+ {label} + +
+
+ ))} +
+
+ + {/* ── Typefaces ────────────────────────────────────────────────────────────────────────── */} +
+

Typefaces

+ +
+ {ROLES.map(({ key, label, hint }) => ( +
+ + + {hint} +
+ ))} +
+
+
+ ); +}; + +/** Reordering, which is the one thing a menu needs more than editing. */ +const moved = (items: T[], from: number, by: number): T[] => { + const to = from + by; + if (to < 0 || to >= items.length) return items; + const next = [...items]; + const [item] = next.splice(from, 1); + next.splice(to, 0, item!); + return next; +}; + +/** + * One link: what it says, and where it goes. + * + * The href is a text field with the site's own pages offered beside it rather than a dropdown of + * them. A menu item can point at an anchor, an external address, or a page that does not exist yet — + * and while a site is being built the last of those is the common case, so a control that could only + * express "one of the pages that already exist" would be in the way most of the time. + */ +const LinkRow = ({ + item, + pages, + onChange, + onRemove, + onMove, + first, + last, +}: { + item: LayoutLink; + pages: { path: string; label: string }[]; + onChange: (item: LayoutLink) => void; + onRemove: () => void; + onMove: (by: number) => void; + first: boolean; + last: boolean; +}) => { + const known = pages.some((page) => page.path === item.href); + + return ( +
+
+ + +
+ + + + onChange({ ...item, label: event.currentTarget.value })} + /> + +
+ onChange({ ...item, href: event.currentTarget.value })} + /> + {/* Said rather than enforced. An unknown href is usually a page not built yet, not a mistake, + and refusing it would make this panel unusable during exactly the phase it is for. */} + {item.href && !known && !item.href.startsWith('http') && !item.href.startsWith('#') && ( + + no page there yet + + )} +
+ + + {pages.map((page) => ( + + ))} + + + +
+ ); +};