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.
+ */
+
+ )}
+
+
+
+ {/* 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' && (
+
+ The development server, live at {width}px. Save to see a change here.
+
+ )}
+
+ );
+};
diff --git a/web/sections/LayoutPanel.tsx b/web/sections/LayoutPanel.tsx
index ac8f55a..4952f19 100644
--- a/web/sections/LayoutPanel.tsx
+++ b/web/sections/LayoutPanel.tsx
@@ -3,7 +3,8 @@ import { useClient } from 'hooks/useClient';
import { readableError } from '../errors';
import { useFlash } from '../useFlash';
import { useSaveTarget } from '../SaveTarget';
-import { DEVICES, usePreviewable, useViewMode } from '../ViewMode';
+import { usePreviewable, useViewMode } from '../ViewMode';
+import { SitePreview, useDevPort } from '../SitePreview';
/**
* The shell every page renders inside: the menu, the footer, the colours and the typefaces.
@@ -109,13 +110,7 @@ export const LayoutPanel = () => {
const [stored, setStored] = useState(null);
const [draft, setDraft] = useState(null);
const [busy, setBusy] = useState(false);
- /*
- Which port the site's development server is on — the plugin's own setting, not the site's.
-
- Read here rather than passed in because the preview is the only thing that needs it, and the
- servers endpoint is where the plugin already keeps it.
- */
- const [devPort, setDevPort] = useState(null);
+ const devPort = useDevPort();
const [error, setError] = useState(null);
const [saved, flashSaved] = useFlash();
@@ -136,13 +131,6 @@ export const LayoutPanel = () => {
void load();
}, [load]);
- useEffect(() => {
- void clientRef.current
- .get<{ name: string; port: number | null }[]>('/fofos-admin/_config/servers')
- .then((servers) => setDevPort(servers.find((server) => server.name === 'development')?.port ?? null))
- .catch(() => setDevPort(null));
- }, []);
-
const patch = (values: Partial) => setDraft((was) => (was ? { ...was, ...values } : was));
/*
@@ -214,7 +202,7 @@ export const LayoutPanel = () => {
that sits in the content column. Worse, it was a second implementation of the layout, so it
would have gone on being wrong differently every time either side changed.
*/}
-
+
{/*
The controls, and only in Edit.
@@ -314,163 +302,3 @@ export const LayoutPanel = () => {
* was the other option and it is a guess about where the browser kept it; the platform tried that and
* says so in its own comments.
*/
-const LayoutPreview = ({ port, theme }: { port: number | null; 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.
- */
-
- )}
-
-
-
- {/* 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' && (
-
- The development server, live at {width}px. Save to see a change here.
-
- )}
-
- );
-};
diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx
new file mode 100644
index 0000000..e952a05
--- /dev/null
+++ b/web/sections/PageBuilderPanel.tsx
@@ -0,0 +1,84 @@
+import { useEffect, useRef, useState } from 'react';
+import { useSearchParams } from 'react-router';
+import { useClient } from 'hooks/useClient';
+import { readableError } from '../errors';
+import { SitePreview, useDevPort } from '../SitePreview';
+import { usePages, type Page } from '../usePages';
+
+/**
+ * One page, being built.
+ *
+ * ── Why this is a different panel from Pages ──
+ *
+ * Pages is the LIST: what exists, what it is called, where it lives, in what order. This is one PAGE:
+ * what is on it. They are different jobs and the nav already says so — Pages is a section and the pages
+ * are rows underneath it — so the detail panel follows the same shape rather than showing a list with
+ * one row highlighted.
+ *
+ * ── It renders the real page ──
+ *
+ * The preview is the site's own page at its own address, through the platform's dev proxy. Whatever
+ * the blocks end up being, this is what they will be judged against, and it is already true today.
+ */
+export const PageBuilderPanel = () => {
+ const client = useClient();
+ const clientRef = useRef(client);
+ clientRef.current = client;
+
+ const [params] = useSearchParams();
+ const id = Number(params.get('page')) || null;
+
+ const devPort = useDevPort();
+ const { pages, announce } = usePages();
+
+ const [error, setError] = useState(null);
+
+ /*
+ The list, if nobody has announced it yet.
+
+ Landing here directly — a bookmark, a reload, a URL typed with `?page=4` — means Pages has never
+ rendered and the channel is empty. So this fetches once and announces, which also fills the nav.
+ Arriving from the nav, `pages` is already there and nothing is requested.
+ */
+ useEffect(() => {
+ if (pages) return;
+ void clientRef.current
+ .get<{ data: Page[] }>('/fofos-admin/pages')
+ .then((response) => announce(response.data))
+ .catch((failure: unknown) => setError(readableError(failure)));
+ }, [pages, announce]);
+
+ const page = pages?.find((row) => row.id === id) ?? null;
+
+ if (error) return {error}
;
+ if (!pages) return Loading…
;
+
+ if (!page) {
+ // A page that was deleted in another panel, or an id somebody typed. Said plainly rather than
+ // redirected: a silent bounce to the list leaves you wondering whether you clicked the wrong thing.
+ return That page no longer exists.
;
+ }
+
+ return (
+
+
+
{page.title}
+ {page.path}
+
+
+ {/* The real page, at its own address. `path` has no leading slash — Home's is the empty string,
+ which the proxy resolves to the site's root. */}
+
+
+ {/*
+ Where the blocks go.
+
+ Said rather than left blank, because an empty panel under a working preview reads as something
+ that failed to load. This is the next thing to build and the panel should say so.
+ */}
+
+
The blocks that make up this page go here.
+
+
+ );
+};
diff --git a/web/sections/PagesPanel.tsx b/web/sections/PagesPanel.tsx
index 21b1661..6499375 100644
--- a/web/sections/PagesPanel.tsx
+++ b/web/sections/PagesPanel.tsx
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronUp, Loader2, Lock, Plus, Trash2 } from 'lucide-react';
-import { useSearchParams } from 'react-router';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useClient } from 'hooks/useClient';
@@ -34,8 +33,6 @@ export const PagesPanel = () => {
const clientRef = useRef(client);
clientRef.current = client;
- const [params, setParams] = useSearchParams();
- const selected = Number(params.get('page')) || null;
const [stored, setStored] = useState(null);
const [draft, setDraft] = useState([]);
@@ -120,21 +117,14 @@ export const PagesPanel = () => {
const add = () =>
act(async () => {
const created = await clientRef.current.post<{ data: Page }>('/fofos-admin/pages', { title: 'New page' });
- // Selected on arrival: a new row at the bottom of a long list is easy to lose, and the next
- // thing anybody does is rename it.
- const next = new URLSearchParams(params);
- next.set('page', String(created.data.id));
- setParams(next, { replace: true });
+ // Deliberately does NOT open the new page. The next thing anybody does is name it, and the
+ // name lives on this screen — jumping into the builder would mean coming straight back.
+ void created;
});
const remove = (page: Page) =>
act(async () => {
await clientRef.current.delete(`/fofos-admin/pages/${page.id}`);
- if (selected === page.id) {
- const next = new URLSearchParams(params);
- next.delete('page');
- setParams(next, { replace: true });
- }
});
const move = (index: number, by: number) => {
@@ -155,17 +145,7 @@ export const PagesPanel = () => {
{draft.map((page, index) => (
-
{
- const next = new URLSearchParams(params);
- next.set('page', String(page.id));
- setParams(next, { replace: true });
- }}
- className={`flex flex-col gap-2 rounded-lg border p-3 ${
- selected === page.id ? 'border-primary' : 'border-border'
- }`}
- >
+