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(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, }: { 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; }) => { 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(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(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(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)) { 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]); 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 (
{message ? (

{message}

) : ( /* 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. */