a band lists its blocks, and selection is the URL

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 09:49:44 +01:00
co-authored by Claude Opus 5
parent 7c4c3f48a8
commit 949d61efc6
5 changed files with 117 additions and 24 deletions
+31 -3
View File
@@ -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) => (
<div key={band.id} className="flex flex-col">
<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'
part === String(band.id) && !piece ? 'bg-muted' : 'hover:bg-muted/50'
}`}
>
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{band.name}</span>
</Link>
{/*
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) => (
<Link
key={block.id}
to={href('pages', pageParam(page), String(band.id), String(block.id))}
replace
className={`flex items-baseline border-b border-border py-1 pr-3 pl-20 text-left transition-colors ${
piece === String(block.id) ? 'bg-muted' : 'hover:bg-muted/50'
}`}
>
<span className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground/80">
{CHOICES.find((choice) => choice.kind === block.kind)?.label ?? block.kind}
</span>
</Link>
))}
</div>
))}
</div>
);
+21 -6
View File
@@ -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;
};
+1 -1
View File
@@ -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.
+50 -5
View File
@@ -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<Document | null>(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),
+6 -1
View File
@@ -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<NamedSection[] | null>('fofos-admin:sections', null);