a selected page opens the builder, not a highlighted row

The nav already said it: Pages is a section, and the pages are rows under it. Two things, two screens.
The detail panel was showing one screen with a row lit up, which made the list the answer to both
questions.

  ?section=pages              the list — what exists, what it is called, where it lives, in what order
  ?section=pages&page=4       that page — what is on it

Both addressable, which is why the distinction is a query parameter rather than state.

Pressing PAGES itself now clears `page`. Without that the section row and the row for whichever page
was last open lead to the same screen and there is no way back to the list but the browser's back
button. Scoped exactly like `media`, and for the same reason: a parameter belonging to one section
should not ride along into the others.

The list stops having a selection — selecting a page leaves the screen entirely — so its rows are
plain rows again and the panel no longer reads or writes the URL at all.

Adding a page deliberately does NOT open it. The next thing anybody does is name it, and the name lives
on the list; jumping into the builder would mean coming straight back.

SitePreview is now shared, lifted out of LayoutPanel with the dev-port lookup beside it. Layout passes
the theme it is editing; a page passes nothing and just gets its own page. The builder shows the real
page at its real address today, which is what the blocks will be judged against when they exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 03:30:44 +01:00
co-authored by Claude Opus 5
parent a0f23d70bb
commit a4c77c642f
6 changed files with 329 additions and 201 deletions
+10 -1
View File
@@ -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 (
<div className="flex size-full flex-col overflow-hidden">
+10
View File
@@ -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 = (
<>
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
+217
View File
@@ -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<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,
}: {
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>;
}) => {
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, 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<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]);
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 (
<section className="flex min-h-0 flex-1 flex-col gap-2">
<div ref={box} className="min-h-0 flex-1">
<div
className="mx-auto overflow-hidden rounded-lg border border-border bg-white"
// The scaled footprint, so the border hugs the frame and the page below does not jump when
// the device changes.
style={{ width: Math.min(width * scale, 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
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}
src={`/api/dev-proxy/${port}/${path}`}
title={`The site at /${path}`}
className="border-0"
style={{
width,
height: shownHeight / scale,
transform: `scale(${scale})`,
transformOrigin: 'top left',
}}
/>
)}
</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>
);
};
+4 -176
View File
@@ -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<Layout | null>(null);
const [draft, setDraft] = useState<Layout | null>(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<number | null>(null);
const devPort = useDevPort();
const [error, setError] = useState<string | null>(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<Layout>) => 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.
*/}
<LayoutPreview port={devPort} theme={themeProperties(draft)} />
<SitePreview port={devPort} path="layout" theme={themeProperties(draft)} />
{/*
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<string, string | null> }) => {
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, 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<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]);
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 (
<section className="flex min-h-0 flex-1 flex-col gap-2">
<div ref={box} className="min-h-0 flex-1">
<div
className="mx-auto overflow-hidden rounded-lg border border-border bg-white"
// The scaled footprint, so the border hugs the frame and the page below does not jump when
// the device changes.
style={{ width: Math.min(width * scale, 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
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}
src={`/api/dev-proxy/${port}/layout`}
title="The site's layout"
className="border-0"
style={{
width,
height: shownHeight / scale,
transform: `scale(${scale})`,
transformOrigin: 'top left',
}}
/>
)}
</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>
);
};
+84
View File
@@ -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<string | null>(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 <div className="p-4 text-xs text-destructive">{error}</div>;
if (!pages) return <div className="p-4 text-xs text-muted-foreground">Loading…</div>;
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 <div className="p-4 text-xs text-muted-foreground">That page no longer exists.</div>;
}
return (
<div className="flex size-full flex-col gap-3 p-4">
<div className="flex shrink-0 items-baseline gap-2">
<h3 className="text-sm font-medium">{page.title}</h3>
<span className="font-mono text-[10px] text-muted-foreground">{page.path}</span>
</div>
{/* 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. */}
<SitePreview port={devPort} path={page.slug} />
{/*
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.
*/}
<div className="shrink-0 rounded-lg border border-dashed border-border p-4 text-center">
<p className="text-xs text-muted-foreground">The blocks that make up this page go here.</p>
</div>
</div>
);
};
+4 -24
View File
@@ -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<Page[] | null>(null);
const [draft, setDraft] = useState<Page[]>([]);
@@ -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 = () => {
<div className="flex flex-col gap-2">
{draft.map((page, index) => (
<div
key={page.id}
onPointerDown={() => {
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'
}`}
>
<div key={page.id} className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<div className="flex shrink-0 flex-col text-muted-foreground">
<button