the open page lists its bands in the nav

A third level, under the selected page only — every page has bands, and listing
all of them would make the nav longer than the site.

Announced by the builder rather than fetched again: it already reads them to know
what it is looking at in the document, and it mounts exactly when a page is open.
Cleared on unmount, or the nav keeps listing the last page's bands.

Clicking one writes a third URL segment and the preview scrolls to it, keyed on
the render count too so the position survives a save replacing the frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 09:45:07 +01:00
co-authored by Claude Opus 5
parent 2eb9142484
commit 7c4c3f48a8
4 changed files with 89 additions and 10 deletions
+27 -4
View File
@@ -5,7 +5,7 @@ import { GripVertical, Lock } from 'lucide-react';
import { SECTIONS, sectionOpen } from './sections';
import { href, useNav } from './routes';
import { useReady } from './useReady';
import { pageParam, usePages, type Page } from './usePages';
import { pageParam, usePages, useSections, type Page } from './usePages';
import { useFofosChanged } from './channel';
/**
@@ -54,7 +54,8 @@ const rowClass = (active: boolean) =>
export const NavPanel = () => {
const { pages, announce } = usePages();
const { section: active, item } = useNav();
const { section: active, item, part } = useNav();
const { sections } = useSections();
const { ready, rootSet } = useReady();
/*
@@ -202,8 +203,8 @@ export const NavPanel = () => {
const current = active === 'pages' && item === pageParam(page);
return (
<div
key={page.id}
<div key={page.id} className="flex flex-col">
<div
/*
The ROW is the drop target and the HANDLE is what drags.
@@ -253,6 +254,28 @@ export const NavPanel = () => {
tells them apart. Home's is `/`, which is short enough not to need explaining. */}
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{page.path}</span>
</Link>
</div>
{/*
The open page's bands, one level further in.
Only under the page that is SELECTED — every page has bands, and listing all of them
would make the nav longer than the site. Announced by the builder, which is mounted
exactly when a page is open; see useSections.
*/}
{current &&
sections?.map((band) => (
<Link
key={band.id}
to={href('pages', pageParam(page), String(band.id))}
replace
className={`flex items-baseline border-b border-border py-1.5 pr-3 pl-14 text-left transition-colors ${
part === String(band.id) ? 'bg-muted' : 'hover:bg-muted/50'
}`}
>
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{band.name}</span>
</Link>
))}
</div>
);
})}
+9 -4
View File
@@ -32,14 +32,16 @@ export const MOUNT = '/fofos-admin';
* redirects, so the address bar ends up saying where you are. `section` is still defaulted, so
* everything that only wants to draw a highlight has one answer rather than a branch.
*/
export const useNav = (): { section: SectionKey; item: string | null; atIndex: boolean } => {
export const useNav = (): { section: SectionKey; item: string | null; part: string | null; atIndex: boolean } => {
const { pathname } = useLocation();
const rest = pathname.startsWith(MOUNT) ? pathname.slice(MOUNT.length) : '';
const [first = '', second = ''] = rest.replace(/^\//, '').split('/');
const [first = '', second = '', third = ''] = rest.replace(/^\//, '').split('/');
return {
// An unknown segment falls back rather than 404s: the URL is a thing people type and edit.
section: isSectionKey(first) ? first : DEFAULT_SECTION,
item: second ? decodeURIComponent(second) : null,
/** Something inside the item — a band of the open page. Nothing else uses a third segment yet. */
part: third ? decodeURIComponent(third) : null,
atIndex: first === '',
};
};
@@ -51,5 +53,8 @@ export const useNav = (): { section: SectionKey; item: string | null; atIndex: b
* you are looking at the Layout preview; carrying it into Galleries would be state from one screen
* deciding something on another. A panel that wants a parameter to survive says so at its own link.
*/
export const href = (section: SectionKey, item?: string | null): string =>
item ? `${MOUNT}/${section}/${encodeURIComponent(item)}` : `${MOUNT}/${section}`;
export const href = (section: SectionKey, item?: string | null, part?: string | null): string => {
if (!item) return `${MOUNT}/${section}`;
const base = `${MOUNT}/${section}/${encodeURIComponent(item)}`;
return part ? `${base}/${encodeURIComponent(part)}` : base;
};
+24 -2
View File
@@ -35,7 +35,7 @@ import { useEditorSlot } from '../useEditorSlot';
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
import { MediaPicker } from '../MediaPicker';
import { GalleryPicker } from '../GalleryPicker';
import { pageFromParam, usePages, type Page } from '../usePages';
import { pageFromParam, usePages, useSections, type Page } from '../usePages';
/**
* One page, being built.
@@ -57,10 +57,14 @@ export const PageBuilderPanel = () => {
const clientRef = useRef(client);
clientRef.current = client;
const { item: slug } = useNav();
const { item: slug, part } = useNav();
const devPort = useDevPort();
const { pages, announce } = usePages();
const { announceSections } = useSections();
// Cleared when this unmounts, or the nav goes on listing the last page's bands under whatever is
// selected next.
useEffect(() => () => announceSections(null), [announceSections]);
// This section has a preview, so the bar offers Edit/View and the three widths — the same opt-in
// Layout makes. Pages, the list, does not: there is nothing there to view at tablet width.
@@ -160,6 +164,21 @@ export const PageBuilderPanel = () => {
*/
const frameDoc = useRef<Document | null>(null);
/*
The band named by the third URL segment, brought into view.
That segment is what the nav's rows under the open page write, and this is what makes them do
something. `rendered` is a dependency as well as `part`: the frame is replaced on every write, and
a scroll issued against the old document would be lost — this way the position survives a save.
The band is found by `data-section-id`, the marker the site already carries for the admin.
*/
useEffect(() => {
if (!part) return;
const band = frameDoc.current?.querySelector(`[data-section-id="${CSS.escape(part)}"]`);
band?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, [part, rendered]);
const onDocument = useCallback(
(frameDocument: Document) => {
frameDoc.current = frameDocument;
@@ -246,6 +265,9 @@ export const PageBuilderPanel = () => {
.get<{ data: CanvasSection[]; steps?: number[] }>(`/fofos-admin/pages/${page.id}/sections`)
.then((response) => {
setSections(response.data);
// The nav draws these as rows under the open page — see useSections for why it is announced
// rather than fetched a second time.
announceSections(response.data.map((section) => ({ id: section.id, name: section.name })));
if (response.steps) setSteps(response.steps);
})
.catch((failure: unknown) => setError(readableError(failure)));
+29
View File
@@ -60,3 +60,32 @@ export const pageFromParam = (pages: Page[] | null, value: string | null): Page
if (!pages || !value) return null;
return pages.find((page) => pageParam(page) === value) ?? null;
};
/**
* The selected page's sections, shared between the builder and the nav.
*
* ── Announced, not fetched twice ──
*
* The page builder already reads them — it needs their names and columns to know what it is looking at
* in the rendered document — and the nav draws them as rows under the open page. Two fetchers would
* agree until somebody renamed a band.
*
* The builder mounts whenever a page is selected, and the nav only draws these under the page that is
* selected, so there is no state where the nav wants them and nothing has spoken.
*
* Null means nobody has said yet: the nav then draws no rows rather than an empty list, which would
* read as "this page has no sections".
*/
export type NamedSection = { id: number; name: string };
const useSectionsList = defineChannel<NamedSection[] | null>('fofos-admin:sections', null);
export const useSections = () => {
const [sections, setSections] = useSectionsList();
const setRef = useRef(setSections);
setRef.current = setSections;
const announceSections = useCallback((rows: NamedSection[] | null) => setRef.current(rows), []);
return { sections, announceSections };
};