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>
539 lines
21 KiB
TypeScript
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’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>
|
|
);
|
|
};
|