diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 2adb103..51661c6 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -7,6 +7,7 @@ import { MediaPanel } from './MediaPanel'; import { GalleriesPanel } from './sections/GalleriesPanel'; import { LayoutPanel } from './sections/LayoutPanel'; import { PagesPanel } from './sections/PagesPanel'; +import { PageBuilderPanel } from './sections/PageBuilderPanel'; import { PublishButton } from './PublishButton'; import { SaveTargetProvider, useSaveButton } from './SaveTarget'; import { DEVICES, ViewModeProvider, useViewMode, type DeviceName, type Mode } from './ViewMode'; @@ -151,6 +152,13 @@ const DeviceToggle = () => { export const DetailPanel = () => { const [params] = useSearchParams(); const raw = params.get('section'); + /* + A page selected inside Pages opens the BUILDER, not the list with a row lit up. + + The nav says the same thing — Pages is a section and the pages are rows under it — so the detail + panel follows it. `?page=` is what distinguishes the two, which keeps both addressable. + */ + const selectedPage = Number(params.get('page')) || null; const selected: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION; const { ready, rootSet, loading } = useReady(); @@ -172,7 +180,8 @@ export const DetailPanel = () => { const wanted = sectionFor(selected); const current = sectionOpen(wanted, { ready, rootSet }) ? wanted : sectionFor('administration'); - const Panel = current.built ? PANELS[current.key] : undefined; + const Panel = + current.key === 'pages' && selectedPage ? PageBuilderPanel : current.built ? PANELS[current.key] : undefined; return (
diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx index 37d0332..84d0a04 100644 --- a/web/NavPanel.tsx +++ b/web/NavPanel.tsx @@ -79,6 +79,16 @@ export const NavPanel = () => { */ if (section.key !== 'media') search.delete('media'); + /* + `page` is Pages' own, and pressing PAGES ITSELF clears it. + + Without this the section row and the row for whichever page was last open lead to the same + screen — the builder — and there is no way back to the list except the browser's back button. + Scoped like `media` above, and for the same reason: a parameter that belongs to one section + should not ride along into the others. + */ + search.delete('page'); + const body = ( <> diff --git a/web/SitePreview.tsx b/web/SitePreview.tsx new file mode 100644 index 0000000..8032fdf --- /dev/null +++ b/web/SitePreview.tsx @@ -0,0 +1,217 @@ +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. + */ +