the Menu and Footer editors are gone

The header and the footer are designed — they belong to whoever writes the site's code, not to whoever
edits its content. A panel offering to rearrange them offers a power that should not exist, and every
use of it would be a decision made in the wrong place.

What is left in Layout is the part that really is a content decision: the colours and the four typeface
roles. The preview above them is still the real page, so what the code decides is visible here even
though nothing here can change it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 02:19:02 +01:00
co-authored by Claude Opus 5
parent 8a152f5b63
commit 9565d24536
+13 -258
View File
@@ -1,7 +1,4 @@
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';
@@ -17,6 +14,15 @@ import { useSaveTarget } from '../SaveTarget';
* 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 menu and the footer are NOT here, and that is the design ──
*
* They were, briefly: rows of links, footer columns, a bottom line. They are gone because the header
* and the footer are DESIGNED — they belong to whoever writes the site's code, not to whoever edits its
* content. A panel offering to rearrange them offers a power that should not exist, and every use of it
* would be a decision made in the wrong place.
*
* What is left is the part that IS a content decision: the colours and the typefaces.
*
* ── 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
@@ -28,13 +34,7 @@ import { useSaveTarget } from '../SaveTarget';
* 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;
@@ -44,8 +44,6 @@ type Layout = {
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. */
@@ -107,15 +105,12 @@ export const LayoutPanel = () => {
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.
Compared field by field rather than whole, because the payload carries something this does not own:
`fonts` is the API saying which faces the build subsets, not a value anybody edits. Including it
would be harmless today and wrong the first time a face is added — the panel would show unsaved
changes for a decision made in the build script.
*/
const WRITABLE = [
'menu_items',
'footer_columns',
'footer_note',
'background',
'foreground',
'accent',
@@ -149,16 +144,6 @@ export const LayoutPanel = () => {
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>}
@@ -174,142 +159,6 @@ export const LayoutPanel = () => {
*/}
<LayoutPreview port={devPort} />
{/* ── 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>
@@ -376,100 +225,6 @@ export const LayoutPanel = () => {
);
};
/** 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>
);
};
/**
* The site's own `/layout` page, through the PLATFORM's dev proxy.
*