Files
fofos-admin/web/sections/LayoutPanel.tsx
T
pastilhasandClaude Opus 5 82862ed340 use the platform's dev proxy instead of a worse copy of it
The preview went through a proxy written into this plugin. The platform already had one —
/api/dev-proxy/:port/* — and it is better in exactly the ways the copy was worst.

Authentication. An iframe navigation cannot carry an Authorization header, and the copy answered that
by putting a token on the URL and on every asset the page then requested. The platform's answer is a
short-lived cookie obtained by an ordinary authenticated request, and its own comments say why: a
token on an iframe URL is a guess about which key the browser stored it under. It had been tried
there and rejected before this plugin repeated it.

Coverage. The copy rewrote URLs with a regex over the markup, which catches only what is written into
the document. The platform injects a script that patches fetch, XMLHttpRequest and WebSocket, so URLs
the page BUILDS at runtime are moved as well — which is most of them in a modern dev server. It also
normalises `..`, forwards HMR, and has nineteen tests behind it.

So the plugin's proxy is gone, previewUrl with it, and the panel does what the platform's Preview
panel does: ask for the session, then mount the frame at the dev port.

No sandbox attribute, for the reason that panel gives. Without allow-same-origin a frame has an opaque
origin and no storage; with allow-same-origin and allow-scripts it can reach up and remove its own
sandbox attribute. Claiming a protection that does not hold is worse than not claiming it.

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

539 lines
21 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';
/**
* 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);
/*
Which port the site's development server is on — the plugin's own setting, not the site's.
Read here rather than passed in because the preview is the only thing that needs it, and the
servers endpoint is where the plugin already keeps it.
*/
const [devPort, setDevPort] = useState<number | null>(null);
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]);
useEffect(() => {
void clientRef.current
.get<{ name: string; port: number | null }[]>('/fofos-admin/_config/servers')
.then((servers) => setDevPort(servers.find((server) => server.name === 'development')?.port ?? null))
.catch(() => setDevPort(null));
}, []);
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.
*/}
<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>
<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>
);
};
/**
* The site's own `/layout` page, through the PLATFORM's dev proxy.
*
* ── Not a proxy of our own ──
*
* This plugin had one for an afternoon, and it was a worse copy of something that already existed.
* `/api/dev-proxy/:port/*` reaches any loopback port, rewrites the HTML, and — the part a hand-rolled
* version does not get right — injects a script that patches `fetch`, `XMLHttpRequest` and `WebSocket`,
* so URLs the page BUILDS at runtime are moved too. A regex over the markup only ever catches the ones
* written into it.
*
* ── The session call, and why it comes first ──
*
* An iframe navigation cannot carry an Authorization header. The proxy's answer is a short-lived cookie
* obtained by an ordinary authenticated request — so the panel asks for one with the API client, which
* already knows how to authenticate, and only then mounts the frame. Putting a token on the iframe URL
* was the other option and it is a guess about where the browser kept it; the platform tried that and
* says so in its own comments.
*/
const LayoutPreview = ({ port }: { port: number | null }) => {
const { get } = useClient();
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState<string | null>(null);
useEffect(() => {
if (!port) return;
let alive = true;
setReady(false);
setFailed(null);
void get<{ ok: boolean }>('/dev-proxy/session')
.then(() => alive && setReady(true))
.catch((failure: unknown) => alive && setFailed(readableError(failure)));
return () => {
alive = false;
};
}, [port, get]);
const message =
!port ? 'No development server is configured — set its port under Administration.'
: failed ? `Could not authorise the preview: ${failed}`
: !ready ? 'Connecting…'
: null;
return (
<section className="flex flex-col gap-2">
<div className="overflow-hidden rounded-lg border border-border bg-white">
{message ? (
<p className="p-6 text-center text-xs text-muted-foreground">{message}</p>
) : (
/*
No `sandbox`, for the reason the platform's own Preview gives: a sandbox WITHOUT
allow-same-origin has an opaque origin and no storage, and one WITH allow-same-origin and
allow-scripts is worth nothing, because the frame can reach up and remove the attribute.
This is the owner's own development server on this machine.
*/
<iframe
src={`/api/dev-proxy/${port}/layout`}
title="The site's layout"
className="h-[420px] w-full border-0"
/>
)}
</div>
<p className="text-[10px] text-muted-foreground">The development server, live. Save to see a change here.</p>
</section>
);
};