Files
fofos-admin/web/SitePreview.tsx
T
pastilhasandClaude Opus 5 e2a74e3337 Add section asks what shape, and creates one
Pressing the button drawn inside the page opens a dialog: a name, and a choice between one column and
two, then Create section. The row lands in the database and the frame re-renders to show it.

The layouts are DRAWN rather than named. "One column" is words for a shape, and a shape is faster to
recognise than to read; the words stay underneath for anybody the picture does not reach.

The name is asked for and not generated. It never appears on the site — it exists so a page of eight
bands is navigable, and "Section 4" tells nobody anything, which is exactly the state a generated name
would leave every page in. Asked at the only moment somebody knows what they are about to build.

The frame is remounted after a write rather than the admin drawing the new band itself. The site
renders from the database; the admin does not know how to render a page, and if it learnt it would be
a second implementation of the very thing this preview exists to avoid.

The canvas now finds the empty-page placeholder by its `data-page-empty` marker instead of taking
`firstElementChild`. That was right for exactly as long as an empty page was the only kind — the moment
a page has a section, the first child IS that section, and it would have hidden the thing being edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 04:06:27 +01:00

270 lines
11 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { readableError } from './errors';
import { DEVICES, usePreviewable, useViewMode } from './ViewMode';
/**
* A page of the real site, embedded.
*
* ── Not a drawing of it ──
*
* The Layout section began with a React sketch of the header and footer. It looked plausible and it
* was wrong, and worse it was a second implementation that would go on being wrong differently every
* time either side changed. This renders the site's own page instead, so a change made in code shows
* up here with no change here at all.
*
* ── Through the PLATFORM's dev proxy ──
*
* `/api/dev-proxy/:port/*` reaches any loopback port, rewrites the HTML, and injects a script that
* patches fetch, XHR and WebSocket so URLs the page builds at RUNTIME are moved too. This plugin had
* its own proxy for an afternoon and it was a worse copy of that.
*
* Same origin, which is what lets `theme` work at all — and what will let the page builder draw
* handles on top of the real page when it gets there.
*/
/** Where the site's development server is. Asked once; it is a setting, not a live value. */
export const useDevPort = (): number | null => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [port, setPort] = useState<number | null>(null);
useEffect(() => {
void clientRef.current
.get<{ name: string; port: number | null }[]>('/fofos-admin/_config/servers')
.then((servers) => setPort(servers.find((server) => server.name === 'development')?.port ?? null))
.catch(() => setPort(null));
}, []);
return port;
};
export const SitePreview = ({
port,
path,
theme,
onDocument,
reloadToken,
}: {
port: number | null;
/** The site path to show — `layout`, `about`, or `` for the front page. No leading slash. */
path: string;
/**
* CSS custom properties to push into the frame, live.
*
* Optional because most previews have nothing to override. The Layout section is the exception: it
* is editing the variables the page reads, so it can show a change without a save or a reload.
*/
theme?: Record<string, string | null>;
/**
* Called with the frame's own document once it has loaded, and again after every reload.
*
* This is the door the page builder goes through. The frame is same-origin, so a caller can read the
* rendered page's DOM and put its own elements inside it — chrome that belongs to the ADMIN, drawn on
* the real page, without a line of it existing in the website's source.
*
* Return a cleanup and it runs before the next load and on unmount, so nothing injected outlives the
* document it was injected into.
*/
onDocument?: (document: Document) => void | (() => void);
/**
* Change this to fetch the page again.
*
* The site renders from the database, so anything written there only appears when the document is
* requested anew. The admin cannot draw the change itself — it does not know how to render a page,
* and if it did it would be a second implementation of the one thing this preview exists to avoid.
*
* Remounts the frame rather than calling `contentWindow.location.reload()`. Both work while the frame
* is same-origin, and the remount is one mechanism that would still work if it ever were not.
*/
reloadToken?: number;
}) => {
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 } = 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]);
/*
The draft, pushed into the frame's own document.
`setProperty` where there is a value and `removeProperty` where there is not — an empty string would
set the variable to nothing rather than unsetting it, and the page's `var(--site-bg, #fff)` fallback
would never come back. Choosing "default" has to actually restore the default.
Keyed on the serialised theme rather than the object, which is a new identity every render. That is
the same trap that hung this component on "Connecting…", one level down.
*/
const frame = useRef<HTMLIFrameElement | null>(null);
const serialised = JSON.stringify(theme ?? {});
const applyTheme = useCallback(() => {
const root = frame.current?.contentDocument?.documentElement;
if (!root) return;
for (const [property, value] of Object.entries(JSON.parse(serialised) as Record<string, string | null>)) {
if (value) root.style.setProperty(property, value);
else root.style.removeProperty(property);
}
}, [serialised]);
// On every change, and again whenever the frame is (re)mounted — switching device remounts nothing,
// but a reconnect or a section change does, and the draft has to survive that.
useEffect(() => {
applyTheme();
}, [applyTheme, ready]);
/*
Hand the loaded document to whoever asked for it.
Through a ref so a caller's inline arrow — which is a new function every render — does not re-run
the injection on every render. Same trap as `useClient`, one level along.
*/
const onDocumentRef = useRef(onDocument);
onDocumentRef.current = onDocument;
const cleanupRef = useRef<(() => void) | void>(undefined);
const handOverDocument = useCallback(() => {
// Whatever was injected into the PREVIOUS document goes first. A reload replaces the document, so
// a cleanup that runs after the fact is cleaning something that no longer exists.
cleanupRef.current?.();
cleanupRef.current = undefined;
const frameDocument = frame.current?.contentDocument;
if (frameDocument) cleanupRef.current = onDocumentRef.current?.(frameDocument);
}, []);
useEffect(() => () => cleanupRef.current?.(), []);
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;
/*
All the room there is. WIDTH is the device; height is just how much of the page you can see.
It was capped at the device's own height, on the reasoning that a 390x844 phone drawn 1200 pixels
tall is not a phone. That was wrong, and the empty band under the preview was the tell: media
queries answer WIDTH, so the layout at 390 is the phone's layout whatever the height. A taller frame
shows more page, which is a scroll position rather than a different device.
One real consequence, and it is the right one: the footer is `min-h-dvh`, so it sits at the bottom
of the FRAME. A taller frame therefore puts it lower — exactly as a taller browser window does.
*/
const shownHeight = Math.max(0, available.height);
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
// The key is what makes `reloadToken` reload: a new key is a new element, so the browser
// fetches the document again and `onLoad` runs, which re-injects the builder's chrome.
key={reloadToken ?? 0}
ref={frame}
// Applied on load as well as on change: the document is replaced on a reload, and the
// properties go with it.
onLoad={() => {
applyTheme();
handOverDocument();
}}
src={`/api/dev-proxy/${port}/${path}`}
title={`The site at /${path}`}
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>
);
};