A third level, under the selected page only — every page has bands, and listing all of them would make the nav longer than the site. Announced by the builder rather than fetched again: it already reads them to know what it is looking at in the document, and it mounts exactly when a page is open. Cleared on unmount, or the nav keeps listing the last page's bands. Clicking one writes a third URL segment and the preview scrolls to it, keyed on the render count too so the position survives a save replacing the frame. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
92 lines
3.6 KiB
TypeScript
92 lines
3.6 KiB
TypeScript
import { useCallback, useRef } from 'react';
|
|
import { defineChannel } from 'hooks/usePanelChannel';
|
|
|
|
/** A page, as the site's admin API describes it. */
|
|
export type Page = {
|
|
id: number;
|
|
slug: string;
|
|
/** `/` for Home, `/about` for the rest — built by the server so nothing here reasons about slugs. */
|
|
path: string;
|
|
title: string;
|
|
menu_label: string | null;
|
|
position: number;
|
|
is_home: boolean;
|
|
};
|
|
|
|
/**
|
|
* The page list, shared between the two panels.
|
|
*
|
|
* The nav draws the pages as rows under Pages, and the editor owns them — and they are SIBLINGS in a
|
|
* workspace, so neither can pass the other a prop. Both fetching would give two lists that agree until
|
|
* somebody renames a page, at which point the nav keeps the old name until something reloads it.
|
|
*
|
|
* So the editor announces what it has and the nav listens. A channel is the right tool for this and
|
|
* the wrong one for SELECTION: which page is open lives in the URL, per docs/navigation-audit.md.
|
|
* This carries data, not state.
|
|
*
|
|
* Null means nobody has said yet — the nav then shows no children rather than an empty list, which
|
|
* would read as "this site has no pages".
|
|
*/
|
|
const usePagesList = defineChannel<Page[] | null>('fofos-admin:pages', null);
|
|
|
|
export const usePages = () => {
|
|
const [pages, setPages] = usePagesList();
|
|
|
|
// The setter is a fresh closure every render and `announce` goes into dependency lists, so it is
|
|
// read through a ref — stable identity without freezing the first render's setter. Same reasoning
|
|
// as channel.ts.
|
|
const setRef = useRef(setPages);
|
|
setRef.current = setPages;
|
|
const announce = useCallback((rows: Page[] | null) => setRef.current(rows), []);
|
|
|
|
return { pages, announce };
|
|
};
|
|
|
|
/**
|
|
* The value `?page=` carries, and how to read it back.
|
|
*
|
|
* The SLUG rather than the id, so a URL says which page it is: `?page=about` can be read, sent to
|
|
* somebody, and still means the right thing after a database is restored from a dump with different
|
|
* ids — which happened once already in this project.
|
|
*
|
|
* Home is the exception and needs a word, because its slug is empty by design. `home` is reserved by
|
|
* the site's API, so no page can take that address and the mapping is total in both directions.
|
|
*/
|
|
export const HOME_PARAM = 'home';
|
|
|
|
export const pageParam = (page: Page): string => page.slug || HOME_PARAM;
|
|
|
|
export const pageFromParam = (pages: Page[] | null, value: string | null): Page | null => {
|
|
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<NamedSection[] | null>('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 };
|
|
};
|