Files
fofos-admin/web/usePages.ts
T
pastilhasandClaude Opus 5 cb6026059a a template previews against something real
The builder loaded the template's own slug — `/collection` — which is not an address, so the frame
showed a 404 while the same arrangement rendered perfectly at /collections/70s in a tab. The page was
fine; the preview was asking for the wrong URL.

It previews an instance now: whichever the server names in `preview_slug`, the first of whatever the
template draws. Which row it is does not matter and deliberately is not a choice — a template IS the
arrangement, and the point of the preview is to see the arrangement with something real in it.

Null when there is nothing to draw yet, which shows as an empty preview rather than a broken one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:03:32 +01:00

113 lines
4.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 arrangement a whole CLASS of pages is drawn with, rather than a page anybody visits.
*
* `collection` is the one today: every `/collections/<slug>` is drawn through it. Listed beside the
* pages because it is built exactly like one — a separate section for a single row would be a second
* place to learn — and marked so nothing offers to rename it, reorder it or put it in a menu.
*/
is_template?: boolean;
/**
* A real page to preview a template against — `collections/70s`.
*
* A template has no page of its own, so previewing its own slug loaded `/collection` and got a 404
* in the frame while the same arrangement rendered perfectly in a tab. Null when there is nothing
* for it to draw yet, which shows as an empty preview rather than a broken one.
*/
preview_slug?: string | null;
};
/**
* 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 };
};