import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; import { useClient } from 'hooks/useClient'; import { readableError } from '../errors'; import { SitePreview, useDevPort } from '../SitePreview'; import { paintCanvas, type CanvasSection } from '../canvas'; import { AddSectionDialog, type SectionLayout } from './AddSectionDialog'; import { usePages, type Page } from '../usePages'; /** * One page, being built. * * ── Why this is a different panel from Pages ── * * Pages is the LIST: what exists, what it is called, where it lives, in what order. This is one PAGE: * what is on it. They are different jobs and the nav already says so — Pages is a section and the pages * are rows underneath it — so the detail panel follows the same shape rather than showing a list with * one row highlighted. * * ── It renders the real page ── * * The preview is the site's own page at its own address, through the platform's dev proxy. Whatever * the blocks end up being, this is what they will be judged against, and it is already true today. */ export const PageBuilderPanel = () => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [params] = useSearchParams(); const id = Number(params.get('page')) || null; const devPort = useDevPort(); const { pages, announce } = usePages(); const [error, setError] = useState(null); const [sections, setSections] = useState([]); /* The handlers, through a ref; the DATA, as a dependency. `onDocument` is re-run whenever its identity changes, so what it should depend on is the thing being drawn — the sections — and nothing else. The callbacks are ordinary functions rebuilt every render, so putting them in the dependency list would repaint on every render; through a ref they are always the current ones without being part of the identity. */ const handlersRef = useRef<{ add: () => void; remove: (id: number) => void }>({ add: () => {}, remove: () => {} }); const [adding, setAdding] = useState(false); const [creating, setCreating] = useState(false); /* Bumped after a write, and passed to SitePreview to remount the frame. The page is rendered by the SITE, from the database, so a new section only appears when the document is fetched again. Nothing else can show it: the admin does not know how to draw a section, and if it did it would be drawing a second implementation of the page. */ const [rendered, setRendered] = useState(0); /* The builder's chrome, painted into the rendered page. Stable, because SitePreview holds it and re-runs it on every load. A new function each render would be a new injection each render. */ const onDocument = useCallback( (frameDocument: Document) => paintCanvas(frameDocument, { sections, onAddSection: () => handlersRef.current.add(), onDeleteSection: (section) => handlersRef.current.remove(section.id), }), [sections], ); /* The handlers, through a ref; the DATA, as a dependency. `onDocument` is re-run whenever its identity changes, so what it should depend on is the thing being drawn — the sections — and nothing else. The callbacks are ordinary functions rebuilt every render, so putting them in the dependency list would repaint on every render; through a ref they are always the current ones without being part of the identity. */ const deleteSection = async (sectionId: number) => { if (!page) return; setError(null); try { await clientRef.current.delete(`/fofos-admin/pages/${page.id}/sections/${sectionId}`); setRendered((n) => n + 1); } catch (failure) { setError(readableError(failure)); } }; const createSection = async (values: { name: string; layout: SectionLayout }) => { if (!page) return; setCreating(true); setError(null); try { await clientRef.current.post(`/fofos-admin/pages/${page.id}/sections`, values); setAdding(false); // The site has to re-render for the new band to exist. See `rendered` above. setRendered((n) => n + 1); } catch (failure) { setError(readableError(failure)); } finally { setCreating(false); } }; /* The list, if nobody has announced it yet. Landing here directly — a bookmark, a reload, a URL typed with `?page=4` — means Pages has never rendered and the channel is empty. So this fetches once and announces, which also fills the nav. Arriving from the nav, `pages` is already there and nothing is requested. */ useEffect(() => { if (pages) return; void clientRef.current .get<{ data: Page[] }>('/fofos-admin/pages') .then((response) => announce(response.data)) .catch((failure: unknown) => setError(readableError(failure))); }, [pages, announce]); const page = pages?.find((row) => row.id === id) ?? null; /* The page's sections, which the canvas needs to name and outline what it finds in the document. Fetched here rather than read out of the DOM. The markup says which id a band came from and nothing else — no name, no layout — and inferring the layout from the classes would be the admin reading the site's stylesheet, which is exactly the coupling all of this avoids. */ useEffect(() => { if (!page) return; void clientRef.current .get<{ data: CanvasSection[] }>(`/fofos-admin/pages/${page.id}/sections`) .then((response) => setSections(response.data)) .catch((failure: unknown) => setError(readableError(failure))); }, [page, rendered]); // Assigned on every render, after both functions above exist, so the ref always holds the current // pair without either being part of `onDocument`'s identity. handlersRef.current = { add: () => setAdding(true), remove: (sectionId: number) => void deleteSection(sectionId) }; if (error) return
{error}
; if (!pages) return
Loading…
; if (!page) { // A page that was deleted in another panel, or an id somebody typed. Said plainly rather than // redirected: a silent bounce to the list leaves you wondering whether you clicked the wrong thing. return
That page no longer exists.
; } return (

{page.title}

{page.path}
{/* The real page, at its own address, with the builder's own controls drawn inside it. `path` has no leading slash — Home's is the empty string, which the proxy resolves to the root. */} {/* `reloadToken` moves on every write, which refetches the document AND re-runs `onDocument` — so the decoration is redrawn against the sections that now exist. */} setAdding(false)} onCreate={(values) => void createSection(values)} />
); };