Two columns made Typefaces twice the height of Colours, so switching tabs moved everything below it and resized the preview above. A tab strip whose contents jump is one that has to be re-read every time it is used — which is the opposite of what tabs were for here. Four across on a wide panel, two on a narrow one, and the hints truncate rather than wrapping into the second row of height they were really costing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
343 lines
15 KiB
TypeScript
343 lines
15 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { readableError } from '../errors';
|
|
import { useFlash } from '../useFlash';
|
|
import { useSaveTarget } from '../SaveTarget';
|
|
import { usePreviewable, useViewMode } from '../ViewMode';
|
|
import { paintMeasure } from '../canvas';
|
|
import { SitePreview, useDevPort } from '../SitePreview';
|
|
|
|
/**
|
|
* 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[];
|
|
/**
|
|
* 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`,
|
|
});
|
|
|
|
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` and `font_defaults` 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 = [
|
|
'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 { mode } = 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.
|
|
*/
|
|
const onDocument = useCallback(
|
|
(frameDocument: Document) => (mode === 'view' ? undefined : paintMeasure(frameDocument)),
|
|
[mode],
|
|
);
|
|
|
|
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} />
|
|
|
|
{/*
|
|
The controls as TABS, and only in Edit.
|
|
|
|
They were two stacked cards, which took as much height as both of them together — and every
|
|
pixel of it came off the preview, which is the thing being judged. One at a time costs a click
|
|
and gives the page back most of the panel.
|
|
|
|
`shrink-0` so they never take height from the preview, and no `max-h` any more: one tab is short
|
|
enough that it does not need capping, which is the other thing this bought.
|
|
*/}
|
|
{mode === 'edit' && (
|
|
<Tabs defaultValue="colours" className="shrink-0 gap-2">
|
|
<TabsList className="h-7">
|
|
<TabsTrigger value="colours" className="text-xs">
|
|
Colours
|
|
</TabsTrigger>
|
|
<TabsTrigger value="typefaces" className="text-xs">
|
|
Typefaces
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="colours" className="rounded-lg border border-border p-4">
|
|
<>
|
|
<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>
|
|
</>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="typefaces" className="rounded-lg border border-border p-4">
|
|
<>
|
|
{/*
|
|
Four across, one row.
|
|
|
|
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 lg: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>
|
|
</>
|
|
</TabsContent>
|
|
</Tabs>
|
|
)}
|
|
|
|
</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.
|
|
*/
|