The bar is three groups now: how you are looking at it, at what width, and what to do with it. The middle is centred against the BAR rather than against its neighbours, because the outer two change width as sections come and go and a middle that drifted with them would never be in the same place twice. A section OPTS IN, the same way it opts into Save. Most have no preview — Media is a file browser, Administration is a connection form, Messages is an inbox — and offering to view any of them at tablet width offers something that means nothing. A control that does nothing teaches people that controls do nothing. The widths are REAL viewport sizes and the frame is SCALED to fit, never squeezed into whatever the panel has. That distinction is the whole point: an iframe given the panel's width has the site's own media queries answering the PANEL's size, so "Desktop" in a narrow panel renders the tablet layout and labels it desktop — the one thing a preview must not do. Never scaled up either: a 390px phone blown up to fill a wide panel is a phone nobody has, and every judgement made against it is wrong in the direction of "looks fine". 390 is the iPhone width slots.ts already assumes. 834 is iPad portrait, deliberately above the site's md: breakpoint at 768 so it shows the desktop nav rather than the drawer — 768 exactly sits on the boundary and is the least informative width available. 1440 is past the 1280 the content column caps at, so the margins either side are visible rather than implied. View gives the preview the whole panel. That is what makes it worth having: at edit height a phone is a letterbox, and a phone is the width somebody most wants to look at properly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
383 lines
16 KiB
TypeScript
383 lines
16 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[];
|
|
};
|
|
|
|
/** 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` is the API saying which faces the build subsets, not a value anybody edits. Including it
|
|
would be harmless today and wrong the first time a face is added — the panel would show unsaved
|
|
changes for a decision made in the build script.
|
|
*/
|
|
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>;
|
|
|
|
/*
|
|
Viewing: the preview and nothing else, filling the panel.
|
|
|
|
Not the same thing as hiding the controls. The preview is given the whole height, which is what
|
|
makes View worth having — at edit height a phone is a letterbox, and a phone is the width somebody
|
|
most wants to look at properly.
|
|
*/
|
|
if (mode === 'view') {
|
|
return (
|
|
<div className="flex size-full flex-col p-4">
|
|
<LayoutPreview port={devPort} fill />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex size-full flex-col gap-4 overflow-auto p-4">
|
|
{saved && <p className="text-xs text-success">Saved</p>}
|
|
{error && <p className="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} />
|
|
|
|
{/* ── 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"
|
|
>
|
|
{/* "Default" is not a font. It means the stylesheet decides, which is what an
|
|
untouched site already does — so choosing it puts things back rather than
|
|
setting something new. */}
|
|
<option value="">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>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 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, fill }: { port: number | null; fill?: boolean }) => {
|
|
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 } = 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;
|
|
|
|
// In View the frame gets the panel's height; editing, a fixed band, so the controls below stay put
|
|
// instead of moving every time somebody switches device.
|
|
const shownHeight = fill ? Math.max(0, available.height) : Math.min(height * scale, 420);
|
|
|
|
return (
|
|
<section className={`flex flex-col gap-2 ${fill ? 'min-h-0 flex-1' : ''}`}>
|
|
<div ref={box} className={`min-h-0 ${fill ? '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>
|
|
|
|
{!fill && (
|
|
<p className="text-[10px] text-muted-foreground">
|
|
The development server, live at {width}px. Save to see a change here.
|
|
</p>
|
|
)}
|
|
</section>
|
|
);
|
|
};
|