Layout replaces one section per page

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 01:38:05 +01:00
co-authored by Claude Opus 5
parent 72edabdc8f
commit 09b914c430
3 changed files with 551 additions and 110 deletions
+2 -13
View File
@@ -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<Record<SectionKey, () => 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: () => <RecordsPanel config={GALLERY_CONFIG} />,
drops: () => <RecordsPanel config={DROPS_CONFIG} />,
collabs: () => <RecordsPanel config={COLLABS_CONFIG} />,
layout: LayoutPanel,
galleries: GalleriesPanel,
messages: MessagesPanel,
administration: AdministrationPanel,
+45 -97
View File
@@ -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);
+504
View File
@@ -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<Layout | null>(null);
const [draft, setDraft] = useState<Layout | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<Layout>) => 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 <div className="p-4 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
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 (
<div className="flex size-full flex-col gap-4 overflow-auto p-4">
{saved && <p className="text-xs text-success">Saved</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
{/* ── The sketch ───────────────────────────────────────────────────────────────────────── */}
<section className="flex flex-col gap-2">
<div
className="overflow-hidden rounded-lg border border-border"
style={{ backgroundColor: bg, color: fg }}
>
{/* The menu, in the order it will appear, in the face chosen for it. */}
<div
className="flex flex-wrap items-center justify-center gap-x-5 gap-y-1 border-b px-4 py-3 text-[11px]"
style={{ borderColor: fg, fontFamily: draft.font_sub ?? undefined }}
>
{/* 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) => (
<span key={index}>{item.label}</span>
),
)}
{!menu.length && !draft.pages.length && <span className="opacity-40">No menu yet</span>}
</div>
{/* 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. */}
<div className="px-4 py-10 text-center" style={{ fontFamily: draft.font_title ?? undefined }}>
<p className="text-lg">The page</p>
<p className="mt-1 text-[11px] opacity-50" style={{ fontFamily: draft.font_body ?? undefined }}>
Whatever is built for it goes here
</p>
</div>
{/* The footer, with the columns as they will fall. */}
<div className="border-t px-4 py-4" style={{ borderColor: fg }}>
{columns.length ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{columns.map((column, index) => (
<div key={index}>
{column.title && (
<p className="mb-1 text-[11px]" style={{ fontFamily: draft.font_sub ?? undefined }}>
{column.title}
</p>
)}
{column.links.map((link, i) => (
<p key={i} className="text-[11px] opacity-70">
{link.label}
</p>
))}
</div>
))}
</div>
) : (
<p className="text-[11px] opacity-40">No footer columns yet</p>
)}
{draft.footer_note && <p className="mt-3 text-[10px] opacity-60">{draft.footer_note}</p>}
</div>
</div>
</section>
{/* ── Menu ─────────────────────────────────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<div className="flex items-baseline justify-between">
<h3 className="text-sm font-medium">Menu</h3>
{!menu.length && (
<span className="text-xs text-muted-foreground">Showing the site&rsquo;s own pages</span>
)}
</div>
{menu.map((item, index) => (
<LinkRow
key={index}
item={item}
pages={draft.pages}
onChange={(next) => 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}
/>
))}
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setMenu([...menu, { label: '', href: '' }])} disabled={menu.length >= 12}>
<Plus className="size-3.5" />
Add item
</Button>
{/*
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 && (
<Button
size="sm"
variant="ghost"
onClick={() => setMenu(draft.pages.map((page) => ({ label: page.label, href: page.path })))}
>
Start from the current pages
</Button>
)}
</div>
</section>
{/* ── Footer ───────────────────────────────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<h3 className="text-sm font-medium">Footer</h3>
{columns.map((column, index) => (
<div key={index} className="flex flex-col gap-2 rounded-md border border-border p-3">
<div className="flex items-center gap-2">
<Input
value={column.title}
placeholder="Column heading"
className="min-w-0 flex-1"
onChange={(event) =>
setColumns(columns.map((was, i) => (i === index ? { ...was, title: event.currentTarget.value } : was)))
}
/>
<Button
size="sm"
variant="ghost"
className="size-7 shrink-0 p-0 text-muted-foreground hover:text-destructive"
onClick={() => setColumns(columns.filter((_, i) => i !== index))}
title="Remove this column"
>
<Trash2 className="size-3.5" />
</Button>
</div>
{column.links.map((link, i) => (
<LinkRow
key={i}
item={link}
pages={draft.pages}
onChange={(next) =>
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}
/>
))}
<Button
size="sm"
variant="ghost"
className="self-start"
onClick={() =>
setColumns(
columns.map((was, c) => (c === index ? { ...was, links: [...was.links, { label: '', href: '' }] } : was)),
)
}
disabled={column.links.length >= 12}
>
<Plus className="size-3.5" />
Add link
</Button>
</div>
))}
<Button
size="sm"
variant="outline"
className="self-start"
onClick={() => setColumns([...columns, { title: '', links: [] }])}
disabled={columns.length >= 6}
>
<Plus className="size-3.5" />
Add column
</Button>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">The line at the very bottom</label>
<Input
value={draft.footer_note ?? ''}
placeholder="© Fofos Fluffy Bastards"
onChange={(event) => patch({ footer_note: event.currentTarget.value || null })}
/>
</div>
</section>
{/* ── Colours ──────────────────────────────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<h3 className="text-sm font-medium">Colours</h3>
<div className="flex flex-wrap gap-4">
{COLOURS.map(({ key, label, fallback }) => (
<div key={key} className="flex items-center gap-2">
{/*
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.
*/}
<input
type="color"
value={draft[key] ?? fallback}
onChange={(event) => patch({ [key]: event.currentTarget.value } as Partial<Layout>)}
className="size-8 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5"
title={label}
/>
<div className="flex flex-col">
<span className="text-xs">{label}</span>
<button
type="button"
className="text-left text-[10px] text-muted-foreground hover:underline"
onClick={() => patch({ [key]: null } as Partial<Layout>)}
>
{draft[key] ?? 'default'}
</button>
</div>
</div>
))}
</div>
</section>
{/* ── Typefaces ────────────────────────────────────────────────────────────────────────── */}
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<h3 className="text-sm font-medium">Typefaces</h3>
<div className="grid gap-3 sm:grid-cols-2">
{ROLES.map(({ key, label, hint }) => (
<div key={key} className="flex flex-col gap-1">
<label className="text-xs">{label}</label>
<select
value={draft[key] ?? ''}
onChange={(event) => patch({ [key]: event.currentTarget.value || null } as Partial<Layout>)}
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
>
{/* "Default" is not a font. It means the stylesheet decides, which is what an
untouched site already does — so choosing it puts things back rather than
setting something new. */}
<option value="">Default</option>
{draft.fonts.map((font) => (
<option key={font} value={font}>
{font}
</option>
))}
</select>
<span className="text-[10px] text-muted-foreground">{hint}</span>
</div>
))}
</div>
</section>
</div>
);
};
/** Reordering, which is the one thing a menu needs more than editing. */
const moved = <T,>(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 (
<div className="flex items-center gap-2">
<div className="flex shrink-0 flex-col text-muted-foreground">
<button type="button" className="leading-none hover:text-foreground disabled:opacity-30" onClick={() => onMove(-1)} disabled={first} title="Move up">
▴
</button>
<button type="button" className="leading-none hover:text-foreground disabled:opacity-30" onClick={() => onMove(1)} disabled={last} title="Move down">
▾
</button>
</div>
<GripVertical className="size-3.5 shrink-0 text-muted-foreground/40" />
<Input
value={item.label}
placeholder="Label"
className="min-w-0 flex-1"
onChange={(event) => onChange({ ...item, label: event.currentTarget.value })}
/>
<div className="relative min-w-0 flex-1">
<Input
value={item.href}
placeholder="/where-it-goes"
list="fofos-pages"
onChange={(event) => 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('#') && (
<span className="pointer-events-none absolute -bottom-4 left-0 text-[10px] text-muted-foreground">
no page there yet
</span>
)}
</div>
<datalist id="fofos-pages">
{pages.map((page) => (
<option key={page.path} value={page.path}>
{page.label}
</option>
))}
</datalist>
<Button
size="sm"
variant="ghost"
className="size-7 shrink-0 p-0 text-muted-foreground hover:text-destructive"
onClick={onRemove}
title="Remove"
>
<Trash2 className="size-3.5" />
</Button>
</div>
);
};