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>
97 lines
3.8 KiB
TypeScript
97 lines
3.8 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;
|
|
/** 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);
|
|
|
|
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 };
|
|
};
|