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