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 } 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 [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, { onAddSection: () => setAdding(true) }), [], ); 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; 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. */} setAdding(false)} onCreate={(values) => void createSection(values)} />
); };