Edit put a fixed 420px band above a scrolling column, so the preview was a letterbox in the mode where changes are actually being made — and there was empty panel below the controls while the thing being judged was squeezed. One column now, for both modes, and it 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. Capped at 55% with their own scroll, because shrink-0 alone would let a short window hand the controls everything and leave the preview at nothing — the same failure in the other direction. The frame is also capped at the device's real height. A 390x844 phone in a 1200px panel would otherwise be drawn as a phone 1200 pixels tall, which is not a phone. Past that it stops growing and the space stays empty, which is honest: there is no device that shape. Two modes were two return paths; now switching modes hides a block rather than rendering a different component. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
412 lines
18 KiB
TypeScript
412 lines
18 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { readableError } from '../errors';
|
|
import { useFlash } from '../useFlash';
|
|
import { useSaveTarget } from '../SaveTarget';
|
|
import { DEVICES, usePreviewable, useViewMode } from '../ViewMode';
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* ── 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' },
|
|
];
|
|
|
|
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);
|
|
/*
|
|
Which port the site's development server is on — the plugin's own setting, not the site's.
|
|
|
|
Read here rather than passed in because the preview is the only thing that needs it, and the
|
|
servers endpoint is where the plugin already keeps it.
|
|
*/
|
|
const [devPort, setDevPort] = useState<number | null>(null);
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
void clientRef.current
|
|
.get<{ name: string; port: number | null }[]>('/fofos-admin/_config/servers')
|
|
.then((servers) => setDevPort(servers.find((server) => server.name === 'development')?.port ?? null))
|
|
.catch(() => setDevPort(null));
|
|
}, []);
|
|
|
|
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();
|
|
|
|
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.
|
|
*/}
|
|
<LayoutPreview port={devPort} />
|
|
|
|
{/*
|
|
The controls, and only in Edit.
|
|
|
|
`shrink-0` so they never give up height to the preview, `max-h-[55%]` so they can never take
|
|
the whole panel either — on a short window the preview would otherwise be squeezed to nothing,
|
|
which is the failure this whole change is about. Past that they scroll on their own rather than
|
|
scrolling the preview off the top.
|
|
*/}
|
|
{mode === 'edit' && (
|
|
<div className="flex max-h-[55%] shrink-0 flex-col gap-4 overflow-auto">
|
|
{/* ── 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"
|
|
>
|
|
{/* 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="text-[10px] text-muted-foreground">{hint}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
</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.
|
|
*/
|
|
const LayoutPreview = ({ port }: { port: number | null }) => {
|
|
const client = useClient();
|
|
/*
|
|
Through a ref, and this is the whole reason the preview hung.
|
|
|
|
`useClient()` rebuilds its verbs on EVERY render, so `get` has a new identity each time. With it in
|
|
the dependency list the effect re-ran on every render, and its first act is `setReady(false)` —
|
|
which renders again, which makes another `get`, which re-runs the effect. The frame never mounted
|
|
and the panel said "Connecting…" forever.
|
|
|
|
It is a known trap here: the same thing silently disabled every playback report in the Jellyfin
|
|
player. The platform's own Preview panel avoids it by depending on neither the client nor the URL.
|
|
*/
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const { device, mode } = useViewMode();
|
|
const { width, height } = DEVICES[device];
|
|
|
|
const [ready, setReady] = useState(false);
|
|
const [failed, setFailed] = useState<string | null>(null);
|
|
|
|
/*
|
|
How much room there is, so the frame can be SCALED rather than squeezed.
|
|
|
|
The iframe is always the real device width — 390, 834, 1440 — and shrunk to fit with a transform.
|
|
Giving it the panel's width instead would mean the site's own media queries answered the panel's
|
|
size, so "Desktop" in a narrow panel would render the tablet layout and label it desktop. That is
|
|
the one thing a preview must not do.
|
|
|
|
Measured rather than assumed because the panel is resizable and the workspace splits it.
|
|
*/
|
|
const box = useRef<HTMLDivElement | null>(null);
|
|
const [available, setAvailable] = useState({ width: 0, height: 0 });
|
|
|
|
useEffect(() => {
|
|
const element = box.current;
|
|
if (!element) return;
|
|
const observer = new ResizeObserver(([entry]) => {
|
|
if (entry) setAvailable({ width: entry.contentRect.width, height: entry.contentRect.height });
|
|
});
|
|
observer.observe(element);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!port) return;
|
|
let alive = true;
|
|
setReady(false);
|
|
setFailed(null);
|
|
void clientRef.current
|
|
.get<{ ok: boolean }>('/dev-proxy/session')
|
|
.then(() => alive && setReady(true))
|
|
.catch((failure: unknown) => alive && setFailed(readableError(failure)));
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [port]);
|
|
|
|
const message =
|
|
!port ? 'No development server is configured — set its port under Administration.'
|
|
: failed ? `Could not authorise the preview: ${failed}`
|
|
: !ready ? 'Connecting…'
|
|
: null;
|
|
|
|
/*
|
|
Never scaled UP. A 390px phone blown up to fill a wide panel is a phone nobody has, and every
|
|
judgement made against it — how big that headline is, how much fits above the fold — would be wrong
|
|
in the direction of "looks fine".
|
|
*/
|
|
const scale = available.width ? Math.min(1, available.width / width) : 1;
|
|
|
|
/*
|
|
Whatever room is left, capped at the device's own height.
|
|
|
|
The cap matters: a 390x844 phone in a 1200px-tall panel would otherwise be drawn as a phone 1200
|
|
pixels tall, which is not a phone. Past its real height the frame stops growing and the space stays
|
|
empty, which is honest — there is no device that shape.
|
|
|
|
It was a fixed 420px band in Edit. That made the preview a letterbox in the mode where changes are
|
|
being made, and left empty panel below the controls.
|
|
*/
|
|
const shownHeight = Math.max(0, Math.min(available.height, height * scale));
|
|
|
|
return (
|
|
<section className="flex min-h-0 flex-1 flex-col gap-2">
|
|
<div ref={box} className="min-h-0 flex-1">
|
|
<div
|
|
className="mx-auto overflow-hidden rounded-lg border border-border bg-white"
|
|
// The scaled footprint, so the border hugs the frame and the page below does not jump when
|
|
// the device changes.
|
|
style={{ width: Math.min(width * scale, available.width || width), height: shownHeight }}
|
|
>
|
|
{message ? (
|
|
<p className="p-6 text-center text-xs text-muted-foreground">{message}</p>
|
|
) : (
|
|
/*
|
|
No `sandbox`, for the reason the platform's own Preview gives: a sandbox WITHOUT
|
|
allow-same-origin has an opaque origin and therefore no storage, and one WITH
|
|
allow-same-origin and allow-scripts is worth nothing, because the frame can reach up and
|
|
remove the attribute. This is the owner's own development server on this machine.
|
|
*/
|
|
<iframe
|
|
src={`/api/dev-proxy/${port}/layout`}
|
|
title="The site's layout"
|
|
className="border-0"
|
|
style={{
|
|
width,
|
|
height: shownHeight / scale,
|
|
transform: `scale(${scale})`,
|
|
transformOrigin: 'top left',
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Only while editing. In View the preview is the whole point, and a caption is a line of height
|
|
taken from it to say something the device buttons already show. */}
|
|
{mode === 'edit' && (
|
|
<p className="shrink-0 text-[10px] text-muted-foreground">
|
|
The development server, live at {width}px. Save to see a change here.
|
|
</p>
|
|
)}
|
|
</section>
|
|
);
|
|
};
|