Two things the guides made visible the moment they existed. THE SKELETON CROSSED THE LINES. Add section was appended to `main`, which is the full width of the page — the content column is what holds it to its measure. So the drop zone ran edge to edge and straight past the guides, describing a place a section could go that no section will ever occupy. It goes in the column now, falling back to main for a page that opts out of the measure, where the column genuinely is the page. The section outline had the same fault in miniature: `outline-offset: 4px` puts it OUTSIDE the element, so a band filling the column drew its edges past the guides. Inset now. Editor chrome that overflows the thing it describes is chrome that lies about it. THE VIEW WAS LEFT-ANCHORED. Narrowing the 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 scroller now starts in the middle, because the page is centred on a real screen and the preview should be too. On the scroller, not a transform. A transform is the scaling this just stopped doing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
306 lines
13 KiB
TypeScript
306 lines
13 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. 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 });
|
|
|
|
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.
|
|
*/
|
|
useEffect(() => {
|
|
setLoaded(0);
|
|
}, [reloadToken]);
|
|
|
|
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;
|
|
|
|
|
|
/*
|
|
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);
|
|
|
|
/*
|
|
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, (width - element.clientWidth) / 2);
|
|
}, [width, 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.
|
|
className="mx-auto overflow-auto rounded-lg border border-border bg-white"
|
|
style={{ width: Math.min(width, 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();
|
|
setLoaded((n) => n + 1);
|
|
}}
|
|
src={`/api/dev-proxy/${port}/${path}`}
|
|
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, height: shownHeight }}
|
|
/>
|
|
)}
|
|
</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>
|
|
);
|
|
};
|