Files
fofos-admin/web/sections/LayoutPanel.tsx
T
pastilhasandClaude Opus 5 6f8dc83984 the preview iframe was rendering Officer inside Officer
Two mistakes, and the second would have bitten immediately after the first was fixed.

The iframe pointed at /fofos-admin/preview/layout. But /fofos-admin is a FRONTEND route — the
single-page app owns it, and anything under it no router claims is answered with the app's own
index.html. So the iframe was served Officer. The plugin's API is mounted at /api/fofos-admin, which
every other call already knew: useClient defaults its base to /api, and thumbnail.ts spells the full
path out for <img> tags that cannot use it.

And an iframe cannot send an Authorization header. Neither can the stylesheet, the fonts or the images
the page goes on to request — those are all Officer requests now, so all of them need ?token=, the
same reason every <img> in this plugin carries one. The token comes off the iframe's own query string
and is propagated into every URL the rewrite touches, with the separator decided per URL because many
already carry the site's ?v= stamp.

It is stripped before the request reaches the site. The token is Officer's and the site has never
heard of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:01:18 +01:00

475 lines
19 KiB
TypeScript

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';
import { previewUrl } from '../thumbnail';
/**
* 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 REAL page, embedded — not a drawing of it.
It was a React sketch: a centred row of labels, a body, a band at the bottom. It looked
plausible and it was wrong, because the site's header has a logo hanging past a rule and a nav
that sits in the content column. Worse, it was a second implementation of the layout, so it
would have gone on being wrong differently every time either side changed.
This is `/layout` on the development server — the site's own shell with no page in it — proxied
through Officer so it is same origin. See the preview route in api/router.ts.
*/}
<section className="flex flex-col gap-2">
<div className="overflow-hidden rounded-lg border border-border bg-white">
<iframe
src={previewUrl('layout')}
title="The site's layout"
className="h-[420px] w-full border-0"
/* Nothing is typed into it and nothing is submitted from it. It is a window. */
sandbox="allow-same-origin allow-scripts"
/>
</div>
<p className="text-[10px] text-muted-foreground">
The development server, live. Save to see a change here.
</p>
</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>
);
};