The panel drew its own header and footer in React. It looked plausible and it was wrong — the site's header has a logo hanging past a rule and a nav in the content column — and worse, it was a second implementation of the layout, so it would have gone on being wrong differently every time either side moved. So the admin embeds the site's own `/layout` route instead: the real shell with no page in it. A designer changing the header in code changes what the admin shows, with no admin change at all. That is the bridge. Proxied through Officer rather than linked, because everything this plugin knows about where the site lives is loopback — 127.0.0.1:<devPort> and nothing else. There is no public hostname stored anywhere and adding one would be a second fact about the same thing, kept in step by hand. The request goes the way every other request here goes, server-side, and the browser talks only to Officer. Same origin, which is also what keeps the door open: the day the page builder wants to draw handles on top of the real page, it can. A cross-origin iframe never could. The rewrite is narrow and its limits are the interesting part. The site writes root-relative URLs and those resolve against Officer's root inside the iframe; a <base> tag does not help, because it moves relative URLs and leaves root-relative ones exactly where they were wrong. So href, src, srcset and CSS url() are moved under the prefix — and `//` is excluded deliberately, because that names another host and prefixing one would turn it into a path on this server. Only HTML is rewritten. Stylesheets, fonts and images are returned as they came: rewriting bytes we do not understand is how a proxy corrupts a font. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
474 lines
19 KiB
TypeScript
474 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';
|
|
|
|
/**
|
|
* 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="/fofos-admin/preview/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’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>
|
|
);
|
|
};
|