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);