An empty section renders as nothing. That is correct on the public site and useless in an editor: somebody who has just chosen "two columns" sees a blank page and has to take it on trust. So the canvas outlines every band it finds, names it, says its shape, and offers Delete — and outlines the columns inside it, because a one-column band and a two-column band are identical until something is in them, which is exactly when it is too late to notice the wrong choice. The site provides three attributes and no behaviour: data-page-empty, data-section-id, data-column. Markers rather than classes, so they cannot be mistaken for styling, cannot be purged by a build that has never heard of this file, and read as what they are. That is the whole contract — one attribute on the public site buys the entire editor. Section names and layouts are FETCHED, not read out of the DOM. The markup says which row a band came from and nothing else; inferring the layout from its classes would be the admin reading the site's stylesheet, which is the coupling all of this exists to avoid. ── The race, and why the fix is not another ref ── Creating a section starts two things that do not finish in a fixed order: the frame fetches a new document, and the panel fetches the sections to draw on it. Painting only on load meant painting with whatever had arrived by then, which after a create was usually the data from before it existed. So SitePreview re-runs the injection when EITHER moves — `loaded` is state, `onDocument` is a dependency, and whichever arrives second triggers the paint that counts. The caller then controls repainting through its own useCallback dependencies, which is what those are for. The handlers still go through a ref, and the data does not. Callbacks are rebuilt every render and would repaint on every one; the sections are the thing being drawn and should be exactly what decides when to redraw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
283 lines
12 KiB
TypeScript
283 lines
12 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.
|
|
*
|
|
* ── Give it a stable identity, and change that identity to REPAINT ──
|
|
*
|
|
* It is re-run whenever it changes, so a `useCallback` whose dependencies are the data being drawn is
|
|
* exactly right: new data, new function, redrawn. An inline arrow is a new function every render and
|
|
* would reinject on every one.
|
|
*/
|
|
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 — on load, and again whenever they change.
|
|
|
|
Two things have to happen and they race: the frame fetches a new document, and the caller fetches
|
|
whatever it means to draw on it. Neither reliably finishes first. Running the injection ONLY on load
|
|
meant painting with whatever data had arrived by then, which after creating a section was usually
|
|
the data from before it existed.
|
|
|
|
So `loaded` is state rather than a callback: the effect below depends on both it and `onDocument`,
|
|
and runs again when either moves. Whichever arrives second triggers the paint that counts.
|
|
*/
|
|
const [loaded, setLoaded] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!loaded) return;
|
|
const frameDocument = frame.current?.contentDocument;
|
|
if (!frameDocument) return;
|
|
|
|
const cleanup = onDocument?.(frameDocument);
|
|
// Runs before the next paint and on unmount, so nothing injected outlives its document — and two
|
|
// paints never leave two sets of controls.
|
|
return () => cleanup?.();
|
|
}, [loaded, onDocument]);
|
|
|
|
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}
|
|
// A remount is a NEW document, and the effect above must not paint into the old one on
|
|
// the strength of a `loaded` that referred to it.
|
|
ref={(node) => {
|
|
frame.current = node;
|
|
if (!node) setLoaded(0);
|
|
}}
|
|
// Applied on load as well as on change: the document is replaced on a reload, and the
|
|
// properties go with it.
|
|
onLoad={() => {
|
|
applyTheme();
|
|
setLoaded((n) => n + 1);
|
|
}}
|
|
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>
|
|
);
|
|
};
|