Three tabs became three collapsible sections in a column, because there are going to be more than three. A tab strip works while the names fit on one line and degrades the moment they do not — it wraps, or it scrolls, and either way the set stops being visible at a glance. `type="multiple"`, so more than one can be open. Tabs enforced exactly one, which is right when the panels are alternatives and wrong when they are parts of the same thing: the colours and the padding of a layout are read together as often as separately. The grid overlay went with them. Every panel shared one cell so the block stayed as tall as the tallest and did not jump when the tab changed — a real problem that stacked sections do not have, because a section that is closed occupies nothing and one that is open is as tall as it is. `?tab=` still seeds which section starts open, so an old link keeps working and lands on the thing it named. 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">Colours</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">Typefaces</AccordionTrigger>
|
|
<AccordionContent>
|
|
<>
|
|
{/*
|
|
Four across, one row.
|
|
|
|
At `sm:` and not `lg:`. The breakpoint here is the BROWSER's, not the panel's — and this panel
|
|
is wide in every window anybody runs the admin in — but lg is 1024 and there was no reason to
|
|
make four short selects wait for it.
|
|
|
|
Two columns meant Typefaces was twice the height of Colours, so switching tabs moved
|
|
everything below it — and the preview above it changed size to match. A tab strip whose
|
|
contents jump is a tab strip that has to be re-read every time it is used.
|
|
|
|
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 sm:grid-cols-4">
|
|
{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.
|
|
*/
|