Files
fofos-admin/web/SitePreview.tsx
T
pastilhasandClaude Opus 5 5af0d9d9d1 the builder's chrome is drawn inside the rendered page
A component that lives at the PLUGIN level and appears on the site's page: an empty drop zone with an
Add section button, injected into the frame at runtime. Nothing of it exists in the website's source,
so the site never carries code only an editor would see, and the builder can change without the site
being rebuilt.

It works because the preview frame is SAME ORIGIN — through the platform's dev proxy, on Officer's own
host — so contentDocument is readable and writable. That single fact is what the page builder rests on,
and it is why swapping this plugin's hand-rolled cross-origin proxy for the platform's mattered beyond
the bugs it fixed.

SitePreview grew one prop, `onDocument`, called with the frame's document after every load and
returning a cleanup that runs before the next one. So a reload never leaves two sets of controls
behind, and nothing injected outlives the document it went into.

Plain DOM rather than React. Portalling would mean running a reconciler against a document that reloads
underneath it whenever the dev server rebuilds; these are a handful of elements with a click handler,
and this way a reload is a document that no longer exists rather than a tree React still thinks it owns.

Inline styles, not classes. The frame carries the SITE's stylesheet, built by scanning the site's own
templates — a class invented in this file was never scanned and would silently do nothing. The button
takes its colours from --site-fg and --site-bg, so it belongs to whatever theme the site is wearing.

The page's existing content is HIDDEN rather than removed, so the same call will leave real blocks
visible once there are any.

The panel counts the presses for now. That is the only visible proof the wire runs both ways, and it
becomes the section picker when there are sections to pick.

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

255 lines
10 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,
}: {
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);
}) => {
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
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>
);
};