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