Fonts and Font Sizes are the same four things, so they now read in the same order under the same names — Menu, Titles, Headings, Body — from one list. font_display is the largest statements and font_title is a section heading, which is the reverse of what the words suggest. The labels say what the reader sees. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
474 lines
21 KiB
TypeScript
474 lines
21 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { readableError } from '../errors';
|
|
import { useFlash } from '../useFlash';
|
|
import { useAutosave } from '../useAutosave';
|
|
import { DEVICES, usePreviewable, useViewMode } from '../ViewMode';
|
|
import { applyFontSizes, applySpacing, paintMeasure, type SpacingDraft } from '../canvas';
|
|
import { SitePreview, useDevPort } from '../SitePreview';
|
|
import { useEditorSlot } from '../useEditorSlot';
|
|
import { type LayoutBand } from './PaddingTab';
|
|
import { SpacingTab } from './SpacingTab';
|
|
import { FontSizesTab, type FontSizes } from './FontSizesTab';
|
|
import { ROLES } from './roles';
|
|
|
|
/**
|
|
* 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[];
|
|
/** Where the space goes, band by band. See SpacingTab, and the site's own spacingClasses. */
|
|
spacing: SpacingDraft;
|
|
/** How big each role is, per breakpoint. See FontSizesTab. */
|
|
font_sizes: FontSizes;
|
|
/** The type scale, sent by the API so this file holds no copy of it. */
|
|
text_steps: string[];
|
|
/** Each step's length, so the live preview can build the same CSS the site would. */
|
|
text_sizes: Record<string, string>;
|
|
/**
|
|
* 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 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`,
|
|
});
|
|
|
|
/** Every section, open from the start. The values below must match the AccordionItems. */
|
|
const SECTIONS_OPEN = ['colours', 'typefaces', 'sizes', 'padding'];
|
|
|
|
/**
|
|
* A section's title.
|
|
*
|
|
* It was `text-xs`, the same size as the labels under it, so the three headings read as one more row
|
|
* of form furniture. They are the structure of the column — everything else is a field inside one of
|
|
* them — and a heading that does not outrank its own contents is not a heading.
|
|
*
|
|
* Bigger and heavier rather than uppercase or coloured: the panel is dense, and weight is the one
|
|
* signal that survives at this size without adding a second thing to look at.
|
|
*/
|
|
const SECTION_TITLE = 'text-sm font-semibold tracking-wide';
|
|
|
|
/**
|
|
* The chosen sizes as CSS.
|
|
*
|
|
* Mirrors `fontSizeRules` 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.
|
|
*
|
|
* The lengths come from the API rather than a list held here, so only the SHAPE is duplicated.
|
|
*/
|
|
const fontSizeRules = (sizes: FontSizes, lengths: Record<string, string>): string => {
|
|
const queries = [
|
|
['base', null],
|
|
['md', '768px'],
|
|
['lg', '1024px'],
|
|
] as const;
|
|
|
|
return queries
|
|
.map(([breakpoint, width]) => {
|
|
const declarations = (['title', 'body', 'sub', 'display'] as const)
|
|
.map((role) => [role, lengths[sizes[role]?.[breakpoint] ?? '']] as const)
|
|
.filter(([, length]) => length)
|
|
.map(([role, length]) => `--text-${role}:${length}`)
|
|
.join(';');
|
|
|
|
if (!declarations) return '';
|
|
return width ? `@media (min-width:${width}){:root{${declarations}}}` : `:root{${declarations}}`;
|
|
})
|
|
.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 devPort = useDevPort();
|
|
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 = [
|
|
'spacing',
|
|
'font_sizes',
|
|
'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);
|
|
}
|
|
};
|
|
|
|
// The serialised draft, or null when it matches what the server has. See useAutosave.
|
|
useAutosave(dirty ? only(draft) : null, 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 spacing being drafted.
|
|
|
|
`spacingKey` is in the dependencies, so changing a number 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 gap shows in the page as it is chosen, like a colour does, with no save.
|
|
*/
|
|
// Serialised, so the callback is rebuilt when a NUMBER changes rather than when the object identity
|
|
// does — a fresh `{}` every render would re-apply the whole preview on every keystroke elsewhere.
|
|
const spacingKey = JSON.stringify(draft?.spacing ?? {});
|
|
const sizesCss = draft ? fontSizeRules(draft.font_sizes, draft.text_sizes) : '';
|
|
|
|
const onDocument = useCallback(
|
|
(frameDocument: Document) => {
|
|
const measure = paintMeasure(frameDocument);
|
|
const space = applySpacing(frameDocument, JSON.parse(spacingKey) as SpacingDraft);
|
|
const sizes = applyFontSizes(frameDocument, sizesCss);
|
|
return () => {
|
|
sizes();
|
|
space();
|
|
measure();
|
|
};
|
|
},
|
|
[spacingKey, sizesCss],
|
|
);
|
|
|
|
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(
|
|
/*
|
|
Every section open to begin with.
|
|
|
|
It opened on one, from `?tab=`, which is what a set of TABS wants — one at a time, and which
|
|
one is addressable. These are not tabs: they are the whole of what this screen decides, and a
|
|
column that starts collapsed asks somebody to go looking for the setting they came for.
|
|
*/
|
|
<Accordion type="multiple" defaultValue={SECTIONS_OPEN} className="flex flex-col gap-2">
|
|
<AccordionItem value="colours" className="rounded-lg border border-border px-4">
|
|
<AccordionTrigger className={SECTION_TITLE}>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={SECTION_TITLE}>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.
|
|
|
|
*/}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{ROLES.map(({ font, label }) => (
|
|
<div key={font} className="flex flex-col gap-1">
|
|
<label className="text-xs">{label}</label>
|
|
<select
|
|
value={draft[font] ?? ''}
|
|
onChange={(event) => patch({ [font]: 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[font] ? `${draft.font_defaults[font]} (site default)` : 'Default'}
|
|
</option>
|
|
{draft.fonts.map((font) => (
|
|
<option key={font} value={font}>
|
|
{font}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
<AccordionItem value="sizes" className="rounded-lg border border-border px-4">
|
|
<AccordionTrigger className={SECTION_TITLE}>
|
|
<span>
|
|
Font Sizes <span className="font-normal text-muted-foreground">({DEVICES[device].label})</span>
|
|
</span>
|
|
</AccordionTrigger>
|
|
<AccordionContent>
|
|
<FontSizesTab
|
|
sizes={draft.font_sizes}
|
|
steps={draft.text_steps}
|
|
onChange={(font_sizes) => patch({ font_sizes })}
|
|
/>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
<AccordionItem value="padding" className="rounded-lg border border-border px-4">
|
|
<AccordionTrigger className={SECTION_TITLE}>
|
|
{/*
|
|
ONE child, because the trigger is `justify-between` and puts the chevron last — two would
|
|
spread the title and the width apart with the arrow beyond them.
|
|
*/}
|
|
<span>
|
|
Spacing <span className="font-normal text-muted-foreground">({DEVICES[device].label})</span>
|
|
</span>
|
|
</AccordionTrigger>
|
|
<AccordionContent>
|
|
{/* Three bands. PaddingTab is still the per-SECTION control, for the pages. */}
|
|
<SpacingTab
|
|
spacing={draft.spacing}
|
|
steps={draft.padding_steps}
|
|
onChange={(spacing) => patch({ spacing })}
|
|
/>
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
);
|
|
}, [draft, 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.
|
|
*/
|