The two headings take the site's own spelling and the shorter word. "Typefaces" is the more precise term and "Fonts" is the one everybody reaches for; a section heading is a signpost, not a definition. The fonts go two by two. Four across existed to keep this section the same height as Colours, because as tabs they shared one grid cell and a taller panel moved everything below it. Stacked sections do not have that constraint — each is as tall as it is and nothing above it moves — so the reason expired with the tabs. What four across cost was the labels: in a panel this narrow every select truncated to "Martia…", so the row said four times that a font had been chosen and never which one. Two columns give each the room to be read, which is the only thing the control is for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
438 lines
20 KiB
TypeScript
438 lines
20 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
|
import { useSearchParams } from 'react-router';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { readableError } from '../errors';
|
|
import { useFlash } from '../useFlash';
|
|
import { useSaveTarget } from '../SaveTarget';
|
|
import { usePreviewable, useViewMode } from '../ViewMode';
|
|
import { applyGutter, paintMeasure } from '../canvas';
|
|
import { SitePreview, useDevPort } from '../SitePreview';
|
|
import { useEditorSlot } from '../useEditorSlot';
|
|
import { type LayoutBand } from './PaddingTab';
|
|
import { GutterTab, type Gutter } from './GutterTab';
|
|
|
|
/**
|
|
* 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 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, as tabs — one at
|
|
* a time, so the preview keeps the height.
|
|
*
|
|
* ── 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 Layout = {
|
|
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 layout PAGE and its three bands. A different thing from the colours above — see the API. */
|
|
layout_page_id: number | null;
|
|
sections: LayoutBand[];
|
|
/** The steps a padding may take, sent by the API so this file holds no copy of the scale. */
|
|
padding_steps: number[];
|
|
/** The site's horizontal gutter, per breakpoint. */
|
|
padding_x: Gutter;
|
|
/**
|
|
* Which face plays each role when nothing has been chosen, read from the site's own stylesheet.
|
|
*
|
|
* So the empty option can NAME the typeface instead of saying "Default". The question somebody is
|
|
* actually asking of that dropdown is "what is it now" — and a label that does not answer it makes
|
|
* them change the setting to find out, which is the one way to lose the answer.
|
|
*/
|
|
font_defaults: Record<string, 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' },
|
|
];
|
|
|
|
/**
|
|
* The draft, as the CSS custom properties the page reads.
|
|
*
|
|
* ── Why this can be done at all ──
|
|
*
|
|
* The frame is SAME ORIGIN — it comes through the platform's dev proxy on Officer's own host — so the
|
|
* panel can reach into its document and set properties on `<html>`. That is the whole trick: no save,
|
|
* no request, no reload, and the page restyles itself because these are the variables it already reads.
|
|
*
|
|
* ── The duplication, stated plainly ──
|
|
*
|
|
* These names and this exact font syntax mirror `themeStyle()` in the site's `src/apps/web/layout.tsx`.
|
|
* The two live in different repositories and cannot share code, so this is a real copy that can drift —
|
|
* and if it does, the symptom is a preview that lies, which is worse than one that does not work.
|
|
*
|
|
* The site is the source of truth. A property renamed there must be renamed here, and the way to check
|
|
* is to save: the saved page is rendered by the site's own function, so if the live preview and the
|
|
* page after a save disagree, this copy is the one that is wrong.
|
|
*
|
|
* A chosen face keeps `sans-serif` behind it, exactly as the site writes it — dropping the fallback
|
|
* would leave a face that failed to load with nothing to substitute.
|
|
*/
|
|
const themeProperties = (draft: Layout): Record<string, string | null> => ({
|
|
'--site-bg': draft.background,
|
|
'--site-fg': draft.foreground,
|
|
'--site-accent': draft.accent,
|
|
'--font-body': draft.font_body && `"${draft.font_body}", sans-serif`,
|
|
'--font-sub': draft.font_sub && `"${draft.font_sub}", sans-serif`,
|
|
'--font-title': draft.font_title && `"${draft.font_title}", sans-serif`,
|
|
'--font-display': draft.font_display && `"${draft.font_display}", sans-serif`,
|
|
});
|
|
|
|
/**
|
|
* The gutter as the classes the site would emit.
|
|
*
|
|
* Mirrors `gutterClasses` in the site's layout-settings.ts, and is a copy for the same reason
|
|
* `themeProperties` is: showing a change before it is saved means producing it here. The check is the
|
|
* same — save, and if the live preview and the saved page disagree, this copy is the wrong one.
|
|
*/
|
|
const gutterClasses = (gutter: Gutter): string => {
|
|
const prefix = { base: '', md: 'md:', lg: 'lg:' } as const;
|
|
return (['base', 'md', 'lg'] as const)
|
|
.filter((breakpoint) => typeof gutter[breakpoint] === 'number')
|
|
.map((breakpoint) => `${prefix[breakpoint]}px-${gutter[breakpoint]}`)
|
|
.join(' ');
|
|
};
|
|
|
|
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 [params, setParams] = useSearchParams();
|
|
const devPort = useDevPort();
|
|
/** Controlled, because the grid stack below decides visibility rather than Radix unmounting. */
|
|
/*
|
|
The open tab, in the URL — `?tab=padding`.
|
|
|
|
Same rule as the mode and the device: it is addressable. "Look at the typefaces" is a link somebody
|
|
sends, and a reload that drops you back on Colours loses the place you were working in.
|
|
|
|
Absent means Colours, the first one, so an ordinary link carries nothing.
|
|
*/
|
|
const tab = params.get('tab') ?? 'colours';
|
|
const setTab = (next: string) => {
|
|
const search = new URLSearchParams(params);
|
|
search.set('tab', next);
|
|
setParams(search, { replace: true });
|
|
};
|
|
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 something this does not own:
|
|
`fonts`, `font_defaults` and `padding_steps` are the API describing the site — which faces the build subsets, and
|
|
which one plays each role today — not values anybody edits here. Including them would be harmless
|
|
today and wrong the first time either changed: the panel would show unsaved changes for a decision
|
|
made in a build script or a stylesheet.
|
|
*/
|
|
const WRITABLE = [
|
|
'padding_x',
|
|
'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);
|
|
// This section has a preview, so the bar offers Edit/View and the three widths.
|
|
usePreviewable();
|
|
const { device } = useViewMode();
|
|
|
|
/*
|
|
The measure, and nothing else.
|
|
|
|
The page builder also draws sections, columns and a drop zone. None of those belong here: the
|
|
layout's bands are code and nothing in this panel can rearrange them. What it shares is the
|
|
question of where the content column ends, which is just as invisible on this page as on any other.
|
|
|
|
Nothing in View, for the same reason as the builder — chrome of any kind means you are not looking
|
|
at the page.
|
|
*/
|
|
/*
|
|
The measure, and the gutter being drafted.
|
|
|
|
`gutter` is in the dependencies, so changing it hands SitePreview a new function: its effect runs
|
|
the previous application's cleanup — which puts the original classes back — and then applies the new
|
|
ones. A padding shows in the page as it is chosen, like a colour does, with no save and no reload.
|
|
*/
|
|
const gutter = draft ? gutterClasses(draft.padding_x) : '';
|
|
|
|
const onDocument = useCallback(
|
|
(frameDocument: Document) => {
|
|
const measure = paintMeasure(frameDocument);
|
|
const spacing = applyGutter(frameDocument, gutter);
|
|
return () => {
|
|
spacing();
|
|
measure();
|
|
};
|
|
},
|
|
[gutter],
|
|
);
|
|
|
|
const [, setEditor] = useEditorSlot();
|
|
const setEditorRef = useRef(setEditor);
|
|
setEditorRef.current = setEditor;
|
|
|
|
/*
|
|
The layout's controls, handed to the editor panel.
|
|
|
|
They were below the preview and took their height from it — the same trade the page builder's
|
|
editors made, and the same reason for undoing it: this screen exists to be looked at, and the
|
|
controls were eating the thing being judged.
|
|
|
|
Announced rather than rendered, because the panel is a SIBLING — see useEditorSlot. The dependency
|
|
list is everything the tabs DISPLAY; an element captures the props it was built with, so one left
|
|
out here reads as a control that has stopped responding rather than as an error.
|
|
*/
|
|
useEffect(() => {
|
|
if (!draft) {
|
|
setEditorRef.current(null);
|
|
return;
|
|
}
|
|
|
|
setEditorRef.current(
|
|
<Accordion type="multiple" defaultValue={[tab]} className="flex flex-col gap-2">
|
|
<AccordionItem value="colours" className="rounded-lg border border-border px-4">
|
|
<AccordionTrigger className="text-xs">Colors</AccordionTrigger>
|
|
<AccordionContent>
|
|
<>
|
|
<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>
|
|
</>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
<AccordionItem value="typefaces" className="rounded-lg border border-border px-4">
|
|
<AccordionTrigger className="text-xs">Fonts</AccordionTrigger>
|
|
<AccordionContent>
|
|
<>
|
|
{/*
|
|
Two by two.
|
|
|
|
It was four across, to keep this section the same height as Colours — because they shared a
|
|
grid cell as tabs and a taller one moved everything below it. Stacked sections do not have
|
|
that constraint: each is as tall as it is, and nothing above it moves.
|
|
|
|
What four across cost was the labels. In a panel this narrow they truncated to "Martia…" on
|
|
every select, so the row said four times that a font was chosen and never which. Two columns
|
|
give each one room to be read, which is the only thing this control is for.
|
|
|
|
The hint moves into the option list's own space rather than a line under each select, which
|
|
is where the second row of height was really going.
|
|
*/}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{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"
|
|
>
|
|
{/* Not "Default" — the face the stylesheet actually uses, named. Choosing it puts
|
|
things back rather than setting something new, and now you can see what back is. */}
|
|
<option value="">
|
|
{draft.font_defaults[key] ? `${draft.font_defaults[key]} (site default)` : 'Default'}
|
|
</option>
|
|
{draft.fonts.map((font) => (
|
|
<option key={font} value={font}>
|
|
{font}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<span className="truncate text-[10px] text-muted-foreground">{hint}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
<AccordionItem value="padding" className="rounded-lg border border-border px-4">
|
|
<AccordionTrigger className="text-xs">Padding</AccordionTrigger>
|
|
<AccordionContent>
|
|
{/*
|
|
The site's gutter, not a per-band padding.
|
|
|
|
PaddingTab is the per-band control and it stays — for the pages, where a band really
|
|
does want its own spacing. Here the three wrappers share one measure and should share
|
|
one number.
|
|
|
|
All three widths at once, and deliberately NOT tied to the device toggle: a gutter is a
|
|
progression, 4 then 8 then 12, and it is judged as one. Reading it off three separate
|
|
visits to the same control is how a site ends up tighter on a tablet than on a phone.
|
|
*/}
|
|
<GutterTab
|
|
gutter={draft.padding_x}
|
|
steps={draft.padding_steps}
|
|
onChange={(padding_x) => patch({ padding_x })}
|
|
/>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
);
|
|
}, [draft, tab, device]);
|
|
|
|
// Cleared on unmount, or the panel goes on offering Layout's controls over somebody else's page.
|
|
useEffect(() => () => setEditorRef.current(null), []);
|
|
|
|
if (!draft) return <div className="p-4 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
|
|
|
|
/*
|
|
ONE layout for both modes: the preview takes the room, the controls take what they need.
|
|
|
|
It used to be two — View returned the preview alone, and Edit put a fixed 420px band above a
|
|
scrolling column. That made the preview a letterbox in the mode where you are actually changing
|
|
things, with empty panel below the controls, and it meant switching modes was a different component
|
|
rather than a smaller one.
|
|
|
|
Now the column fills the panel and never scrolls as a whole. The preview is `flex-1`, so it is
|
|
always as tall as whatever is left; the controls are `shrink-0`, so they are exactly as tall as
|
|
their content and no taller. Edit takes height from the preview, and only as much as it needs.
|
|
*/
|
|
return (
|
|
<div className="flex size-full flex-col gap-3 p-4">
|
|
{saved && <p className="shrink-0 text-xs text-success">Saved</p>}
|
|
{error && <p className="shrink-0 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.
|
|
*/}
|
|
<SitePreview port={devPort} path="layout" theme={themeProperties(draft)} onDocument={onDocument} />
|
|
</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.
|
|
*/
|