From 7c4c3f48a8a947c10f85225b9dac3b48919a2b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 1 Sep 2026 09:45:07 +0100 Subject: [PATCH] the open page lists its bands in the nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/NavPanel.tsx | 31 +++++++++++++++++++++++++++---- web/routes.ts | 13 +++++++++---- web/sections/PageBuilderPanel.tsx | 26 ++++++++++++++++++++++++-- web/usePages.ts | 29 +++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx index 528a0c7..56cd284 100644 --- a/web/NavPanel.tsx +++ b/web/NavPanel.tsx @@ -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 ( -
+
{ tells them apart. Home's is `/`, which is short enough not to need explaining. */} {page.path} +
+ + {/* + 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) => ( + + {band.name} + + ))}
); })} diff --git a/web/routes.ts b/web/routes.ts index 3edd82e..e814abd 100644 --- a/web/routes.ts +++ b/web/routes.ts @@ -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; +}; diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index b5a887b..6ca181e 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -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(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))); diff --git a/web/usePages.ts b/web/usePages.ts index 4580b37..c136fbc 100644 --- a/web/usePages.ts +++ b/web/usePages.ts @@ -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('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 }; +};