From 949d61efc67d007ea97e3ffbc2e2ded443e51677 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 1 Sep 2026 09:49:44 +0100 Subject: [PATCH] a band lists its blocks, and selection is the URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fourth level under the open band only — a page of six bands with four blocks each is thirty rows, and the nav stops being somewhere you can find anything. Blocks are named by the same list the picker offers, so a thing is called the same word wherever it appears. Clicking a block in the PAGE now writes that address too, rather than calling the handler directly. Both ways of picking a block are the same act, so neither can disagree with the other about what is selected — and closing it is the band row. Co-Authored-By: Claude Opus 5 (1M context) --- web/NavPanel.tsx | 50 ++++++++++++++++++++------ web/routes.ts | 27 ++++++++++---- web/sections/AddComponentDialog.tsx | 2 +- web/sections/PageBuilderPanel.tsx | 55 ++++++++++++++++++++++++++--- web/usePages.ts | 7 +++- 5 files changed, 117 insertions(+), 24 deletions(-) diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx index 56cd284..41c1374 100644 --- a/web/NavPanel.tsx +++ b/web/NavPanel.tsx @@ -6,6 +6,7 @@ import { SECTIONS, sectionOpen } from './sections'; import { href, useNav } from './routes'; import { useReady } from './useReady'; import { pageParam, usePages, useSections, type Page } from './usePages'; +import { CHOICES } from './sections/AddComponentDialog'; import { useFofosChanged } from './channel'; /** @@ -54,7 +55,7 @@ const rowClass = (active: boolean) => export const NavPanel = () => { const { pages, announce } = usePages(); - const { section: active, item, part } = useNav(); + const { section: active, item, part, piece } = useNav(); const { sections } = useSections(); const { ready, rootSet } = useReady(); @@ -265,16 +266,43 @@ export const NavPanel = () => { */} {current && sections?.map((band) => ( - - {band.name} - +
+ + {band.name} + + + {/* + The band's blocks, one level further in again. + + Under the OPEN band only, for the same reason the bands are under the open page: + a page of six bands with four blocks each is thirty rows, and the nav stops being + a place you can find anything. + + Named by kind, using the same list the block picker offers — so a thing is called + the same word wherever it appears. + */} + {part === String(band.id) && + band.components.map((block) => ( + + + {CHOICES.find((choice) => choice.kind === block.kind)?.label ?? block.kind} + + + ))} +
))} ); diff --git a/web/routes.ts b/web/routes.ts index e814abd..2620c66 100644 --- a/web/routes.ts +++ b/web/routes.ts @@ -32,16 +32,24 @@ 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; part: string | null; atIndex: boolean } => { +export const useNav = (): { + section: SectionKey; + item: string | null; + part: string | null; + piece: string | null; + atIndex: boolean; +} => { const { pathname } = useLocation(); const rest = pathname.startsWith(MOUNT) ? pathname.slice(MOUNT.length) : ''; - const [first = '', second = '', third = ''] = rest.replace(/^\//, '').split('/'); + const [first = '', second = '', third = '', fourth = ''] = 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. */ + /** Something inside the item — a band of the open page. */ part: third ? decodeURIComponent(third) : null, + /** Something inside THAT — a block in the band. This is what selection writes. */ + piece: fourth ? decodeURIComponent(fourth) : null, atIndex: first === '', }; }; @@ -53,8 +61,15 @@ export const useNav = (): { section: SectionKey; item: string | null; part: stri * 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, part?: string | null): string => { +export const href = ( + section: SectionKey, + item?: string | null, + part?: string | null, + piece?: string | null, +): string => { if (!item) return `${MOUNT}/${section}`; - const base = `${MOUNT}/${section}/${encodeURIComponent(item)}`; - return part ? `${base}/${encodeURIComponent(part)}` : base; + let path = `${MOUNT}/${section}/${encodeURIComponent(item)}`; + if (!part) return path; + path += `/${encodeURIComponent(part)}`; + return piece ? `${path}/${encodeURIComponent(piece)}` : path; }; diff --git a/web/sections/AddComponentDialog.tsx b/web/sections/AddComponentDialog.tsx index 58dfac6..ce3f4c2 100644 --- a/web/sections/AddComponentDialog.tsx +++ b/web/sections/AddComponentDialog.tsx @@ -40,7 +40,7 @@ type Choice = { * inventing a roadmap. Gallery stopped being one of them on 2026-08-31: the set, its ordering and the * carousel that draws it were all already built, so the block is a reference to one. */ -const CHOICES: Choice[] = [ +export const CHOICES: Choice[] = [ { kind: 'free-text', label: 'Text', hint: 'Words, headings and links', Icon: Type }, /* One entry, either kind, because the block does not store which. diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index 6ca181e..e610329 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { useNav } from '../routes'; +import { useNavigate } from 'react-router'; +import { href, useNav } from '../routes'; import { useClient } from 'hooks/useClient'; import { readableError } from '../errors'; import { SitePreview, useDevPort } from '../SitePreview'; @@ -35,7 +36,7 @@ import { useEditorSlot } from '../useEditorSlot'; import { AddComponentDialog, type ComponentKind } from './AddComponentDialog'; import { MediaPicker } from '../MediaPicker'; import { GalleryPicker } from '../GalleryPicker'; -import { pageFromParam, usePages, useSections, type Page } from '../usePages'; +import { pageFromParam, pageParam, usePages, useSections, type Page } from '../usePages'; /** * One page, being built. @@ -57,7 +58,7 @@ export const PageBuilderPanel = () => { const clientRef = useRef(client); clientRef.current = client; - const { item: slug, part } = useNav(); + const { item: slug, part, piece } = useNav(); const devPort = useDevPort(); const { pages, announce } = usePages(); @@ -164,6 +165,31 @@ export const PageBuilderPanel = () => { */ const frameDoc = useRef(null); + /* + Picking a block writes the fourth path segment; this opens the editor for whatever it names. + + Through a ref for the same reason the other handlers are: `onDocument` must depend on the sections + and nothing else, or it repaints on every render. + */ + const navigate = useNavigate(); + const navigateRef = useRef<(component: CanvasComponent) => void>(() => {}); + navigateRef.current = (component) => { + if (!page) return; + const owner = sections.find((section) => section.components.some((one) => one.id === component.id)); + if (!owner) return; + navigate(href('pages', pageParam(page), String(owner.id), String(component.id)), { replace: true }); + }; + + useEffect(() => { + if (!piece) { + // The band row was pressed, or the page was. Nothing is selected, so nothing should be open. + setEditing(null); + return; + } + const found = sections.flatMap((section) => section.components).find((one) => String(one.id) === piece); + if (found) editComponentRef.current(found); + }, [piece, sections]); + /* The band named by the third URL segment, brought into view. @@ -187,7 +213,14 @@ export const PageBuilderPanel = () => { onAddSection: () => handlersRef.current.add(), onDeleteSection: (section) => handlersRef.current.remove(section.id), onAddComponent: (section, column) => handlersRef.current.addComponent(section, column), - onEditComponent: (component) => handlersRef.current.editComponent(component), + /* + Selection goes through the URL, not straight to the handler. + + The nav's rows write the same address, so both ways of picking a block are the same act and + neither can disagree with the other about what is selected. The effect below is what opens + the editor, whichever wrote it. + */ + onEditComponent: (component) => navigateRef.current(component), // Nothing to edit, so no button. See PLACES_ONLY. canEditComponent: (component) => !PLACES_ONLY.includes(component.kind), onDeleteComponent: (component) => handlersRef.current.removeComponent(component), @@ -267,7 +300,13 @@ export const PageBuilderPanel = () => { 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 }))); + announceSections( + response.data.map((section) => ({ + id: section.id, + name: section.name, + components: section.components.map((component) => ({ id: component.id, kind: component.kind })), + })), + ); if (response.steps) setSteps(response.steps); }) .catch((failure: unknown) => setError(readableError(failure))); @@ -371,6 +410,9 @@ export const PageBuilderPanel = () => { } }; + /* Held so the effect that reads the URL can call it without depending on its identity. */ + const editComponentRef = useRef<(component: CanvasComponent) => void>(() => {}); + const editComponent = (component: CanvasComponent) => { const owner = sections.find((section) => section.components.some((one) => one.id === component.id)); if (!owner) return; @@ -561,6 +603,9 @@ export const PageBuilderPanel = () => { // Cleared on unmount, or the panel goes on offering an editor for a page nobody is looking at. useEffect(() => () => setEditorRef.current(null), []); + // Both refs are assigned during render, so the effects above always call the current closures. + editComponentRef.current = editComponent; + handlersRef.current = { add: () => setAdding(true), remove: (sectionId: number) => void deleteSection(sectionId), diff --git a/web/usePages.ts b/web/usePages.ts index c136fbc..1503a71 100644 --- a/web/usePages.ts +++ b/web/usePages.ts @@ -76,7 +76,12 @@ export const pageFromParam = (pages: Page[] | null, value: string | null): Page * 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 }; +export type NamedSection = { + id: number; + name: string; + /** What is in it, in the order the page renders them. The nav lists these under the band. */ + components: { id: number; kind: string }[]; +}; const useSectionsList = defineChannel('fofos-admin:sections', null);