Three versions of this now, and the reasons are worth keeping.
key={reloadToken} replaced the ELEMENT, so the panel went white between
documents. contentWindow.location.reload() kept the element but did not reliably
re-fetch. A changing src does both: the browser navigates the same element — so
it keeps painting the current document until the next commits — and the URL
differs, so nothing is served from cache.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
387 lines
17 KiB
TypeScript
387 lines
17 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { readableError } from './errors';
|
|
import { DEVICES, usePreviewable, useViewMode } from './ViewMode';
|
|
import { useFofosChanged } from './channel';
|
|
|
|
/**
|
|
* 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 } = useViewMode();
|
|
/*
|
|
`null` means fluid — Desktop takes the panel's width. See DEVICES.
|
|
|
|
Everything below reads `frame`: the real number once the panel has been measured. Until then it is
|
|
the panel's own width, which is what a fluid frame is anyway.
|
|
*/
|
|
const { width: deviceWidth } = DEVICES[device];
|
|
|
|
const [ready, setReady] = useState(false);
|
|
const [failed, setFailed] = useState<string | null>(null);
|
|
|
|
/*
|
|
How much room there is. The frame is always the real device width and is NEVER scaled to fit.
|
|
|
|
It used to shrink with a transform, and that was worse than it sounds: the preview changed apparent
|
|
size as the panel was dragged, so the same page looked different at every panel width and nothing
|
|
could be judged against anything. A preview that is 1:1 or nothing is a preview you can trust.
|
|
|
|
So it stays 390, 834 or 1440 and OVERFLOWS when there is not room, which is a scrollbar rather than
|
|
a lie. The width was never negotiable anyway: the site's media queries answer it, so a frame given
|
|
the panel's width would render the tablet layout and call it desktop.
|
|
|
|
Still measured, because the height is whatever the panel has.
|
|
*/
|
|
const box = useRef<HTMLDivElement | null>(null);
|
|
const [available, setAvailable] = useState({ width: 0, height: 0 });
|
|
|
|
// The width the frame is actually rendering at, for the caption and the centring maths.
|
|
const frameWidth = deviceWidth ?? Math.round(available.width);
|
|
|
|
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);
|
|
|
|
/*
|
|
A remount is a NEW document, so `loaded` has to go back to nothing before it arrives.
|
|
|
|
In an effect keyed on the token, and NOT in the iframe's ref. It was a ref callback, written inline
|
|
— which React treats as a different ref every render, so it detaches and reattaches on each one,
|
|
calling the callback with null and then the node. That reset `loaded` to 0 every render, which
|
|
re-rendered, which reset it again: the paint never survived long enough to happen and the page came
|
|
up with no controls at all.
|
|
|
|
The lesson is the same one as `useClient` two files along: a value rebuilt every render must not be
|
|
the thing something else keys on.
|
|
*/
|
|
/*
|
|
A change anywhere on the site reloads every preview of it.
|
|
|
|
`reloadToken` is the panel's own — the page builder bumps it after its own writes. This is the
|
|
other half: something ELSE changed the site, and a frame showing the old one is a frame lying. The
|
|
nav reordering the menu is the case that made it necessary; the Layout preview draws that menu and
|
|
had no token at all.
|
|
|
|
Its purpose is exactly this — "anyone reading it should read it again" — and a preview is a reader.
|
|
*/
|
|
const [changed] = useFofosChanged();
|
|
|
|
useEffect(() => {
|
|
setLoaded(0);
|
|
}, [reloadToken, changed]);
|
|
|
|
/*
|
|
Reloading, as a URL.
|
|
|
|
Three versions of this, and the reasons are worth keeping.
|
|
|
|
`key={reloadToken}` replaced the ELEMENT, so React unmounted the old frame and the panel went white
|
|
until the next document arrived — a second or two after a crop change, because the site is building
|
|
a fresh ladder. That reads as the preview breaking.
|
|
|
|
`contentWindow.location.reload()` kept the element but did not reliably re-fetch.
|
|
|
|
A changing `src` does both: the browser navigates the SAME element, so it keeps painting the current
|
|
document until the next one commits, and the URL is different so nothing is served from cache. The
|
|
parameter means nothing to the site and is ignored.
|
|
*/
|
|
const reloadKey = `${reloadToken ?? 0}.${changed}`;
|
|
|
|
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;
|
|
|
|
|
|
/*
|
|
The page's own height, measured inside the frame.
|
|
|
|
── Why it is measured and not just "the room" ──
|
|
|
|
It was `available.height` — all the room the panel had — which is right for a page longer than the
|
|
panel and wrong for every other one: a short page left a band of white below the footer with the
|
|
measure guides running down through it. Nothing was broken; the frame was simply taller than the
|
|
document, and an iframe has no `height: fit-content`.
|
|
|
|
── The WRAPPER, not the body ──
|
|
|
|
The site's `<body>` is `min-h-screen`, so its height is the FRAME's height and measuring it would
|
|
ask the frame how tall the frame is. Its one child is the page — header, main, footer — and that is
|
|
a real content height. It stopped being `min-h-dvh` on the same day this changed, for the same
|
|
reason: the stretch belonged to nothing anybody could see or set.
|
|
*/
|
|
const [contentHeight, setContentHeight] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!loaded) return;
|
|
const frameDocument = frame.current?.contentDocument;
|
|
const page = frameDocument?.body.firstElementChild as HTMLElement | null;
|
|
const view = frameDocument?.defaultView;
|
|
if (!page || !view) return;
|
|
|
|
const measure = () => setContentHeight(Math.ceil(page.getBoundingClientRect().height));
|
|
measure();
|
|
|
|
/*
|
|
Observed, because the page changes height without reloading: choosing a spacing rewrites classes
|
|
in this document, and the device buttons reflow it at a new width.
|
|
|
|
No feedback loop — the frame's height is set from this, and the wrapper's height does not depend
|
|
on the frame's.
|
|
*/
|
|
const observer = new view.ResizeObserver(measure);
|
|
observer.observe(page);
|
|
return () => observer.disconnect();
|
|
}, [loaded]);
|
|
|
|
/*
|
|
As tall as the page, or as tall as the room, whichever is less.
|
|
|
|
A long page still fills the panel and scrolls, exactly as it did. WIDTH is the device and height is
|
|
not: media queries answer width, so the layout at 390 is the phone's layout whatever the height.
|
|
*/
|
|
const shownHeight = contentHeight ? Math.min(contentHeight, Math.max(0, available.height)) : Math.max(0, available.height);
|
|
|
|
/*
|
|
When the frame is wider than the room, show its MIDDLE.
|
|
|
|
Left-anchored, a narrow panel showed the left margin and the start of the content — the least
|
|
informative part of a centred layout, and never the part anybody is looking at. The page is centred
|
|
on a real screen, so the preview should be centred too; the scrollbar is still there for the rest.
|
|
|
|
Set on the scroller rather than with a transform, because a transform is the scaling this just
|
|
stopped doing.
|
|
*/
|
|
const scroller = useRef<HTMLDivElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
const element = scroller.current;
|
|
if (!element) return;
|
|
element.scrollLeft = Math.max(0, (frameWidth - element.clientWidth) / 2);
|
|
}, [frameWidth, available.width, loaded]);
|
|
|
|
return (
|
|
<section className="flex min-h-0 flex-1 flex-col gap-2">
|
|
<div ref={box} className="min-h-0 flex-1">
|
|
<div
|
|
ref={scroller}
|
|
/*
|
|
`overflow-auto`, not hidden: at Desktop in a narrow panel the frame is wider than the room,
|
|
and scrolling to the rest of it is the honest answer where scaling was not.
|
|
|
|
THE SCROLLBARS ARE HIDDEN, and that is not cosmetic. A classic scrollbar — Linux, Windows —
|
|
is laid out INSIDE the element, so it takes width from the frame: the preview then shows
|
|
less than the device width it claims to be, and the last menu item runs off the edge. Worse,
|
|
it cascades: a horizontal bar costs height, which can force a vertical bar, which costs more
|
|
width.
|
|
|
|
The same trade the site makes in its own stylesheet, for the same reason and with the same
|
|
cost: wheel, trackpad and touch still scroll, and the visual position indicator is gone on
|
|
the platforms where scrollbars are always classic.
|
|
*/
|
|
className="mx-auto overflow-auto rounded-lg border border-border bg-white [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
// Fluid fills the panel; a fixed device is capped by it so a 1440 frame in a 900 panel
|
|
// scrolls rather than overflowing the workspace.
|
|
style={{ width: deviceWidth === null ? '100%' : Math.min(frameWidth, available.width || frameWidth), 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
|
|
// No key. See the reload effect above: replacing the element is what made the panel go
|
|
// white between documents.
|
|
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();
|
|
setLoaded((n) => n + 1);
|
|
}}
|
|
src={`/api/dev-proxy/${port}/${path}?_=${reloadKey}`}
|
|
title={`The site at /${path}`}
|
|
className="border-0"
|
|
// The device's real width, and the room's real height. No transform: see the note above.
|
|
style={{ width: deviceWidth === null ? '100%' : frameWidth, height: shownHeight }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
</section>
|
|
);
|
|
};
|