The section is real, and the nav draws the site's pages as rows beneath it. That is the one place this admin has a hierarchy and it earns it: a page is a thing you go to, and the list of them is the site. Drawn whether or not Pages is the open section — hiding the list until you click makes the nav a menu of menus. Three kinds of change, saved three different ways, and the split is deliberate. TEXT — a title, a menu label, an address — is drafted and written by the one Save in the bar, because typing is something people do halfway and change their minds about. ADDING, DELETING and REORDERING are immediate: each is a single deliberate gesture with an obvious result, and holding them in a draft would put a list on screen that does not exist yet, then leave Save to decide what order to apply three kinds of change in. Only the rows that actually changed are sent, so a save touches what somebody edited and nothing else. A failed save reloads from the server rather than leaving the typed values on screen, where they would look saved. Home's address and delete button are ABSENT, not disabled. A greyed control invites somebody to work out why they cannot use it; there is nothing to work out, the front page is always `/`. The two panels share the list through a channel rather than both fetching it. They are siblings and neither can pass the other a prop, and two fetches means two lists that agree until somebody renames a page. Selection stays in the URL — `?page=` — because that is addressable and the list is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
44 lines
1.7 KiB
TypeScript
44 lines
1.7 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 };
|
|
};
|