Files
fofos-admin/web/sections/PageBuilderPanel.tsx
T
pastilhasandClaude Opus 5 2ba9d5f303 the builder draws the sections it finds
An empty section renders as nothing. That is correct on the public site and useless in an editor:
somebody who has just chosen "two columns" sees a blank page and has to take it on trust.

So the canvas outlines every band it finds, names it, says its shape, and offers Delete — and outlines
the columns inside it, because a one-column band and a two-column band are identical until something is
in them, which is exactly when it is too late to notice the wrong choice.

The site provides three attributes and no behaviour: data-page-empty, data-section-id, data-column.
Markers rather than classes, so they cannot be mistaken for styling, cannot be purged by a build that
has never heard of this file, and read as what they are. That is the whole contract — one attribute on
the public site buys the entire editor.

Section names and layouts are FETCHED, not read out of the DOM. The markup says which row a band came
from and nothing else; inferring the layout from its classes would be the admin reading the site's
stylesheet, which is the coupling all of this exists to avoid.

── The race, and why the fix is not another ref ──

Creating a section starts two things that do not finish in a fixed order: the frame fetches a new
document, and the panel fetches the sections to draw on it. Painting only on load meant painting with
whatever had arrived by then, which after a create was usually the data from before it existed.

So SitePreview re-runs the injection when EITHER moves — `loaded` is state, `onDocument` is a
dependency, and whichever arrives second triggers the paint that counts. The caller then controls
repainting through its own useCallback dependencies, which is what those are for.

The handlers still go through a ref, and the data does not. Callbacks are rebuilt every render and
would repaint on every one; the sections are the thing being drawn and should be exactly what decides
when to redraw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 04:11:41 +01:00

177 lines
7.3 KiB
TypeScript

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<string | null>(null);
const [sections, setSections] = useState<CanvasSection[]>([]);
/*
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 <div className="p-4 text-xs text-destructive">{error}</div>;
if (!pages) return <div className="p-4 text-xs text-muted-foreground">Loading…</div>;
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 <div className="p-4 text-xs text-muted-foreground">That page no longer exists.</div>;
}
return (
<div className="flex size-full flex-col gap-3 p-4">
<div className="flex shrink-0 items-baseline gap-2">
<h3 className="text-sm font-medium">{page.title}</h3>
<span className="font-mono text-[10px] text-muted-foreground">{page.path}</span>
</div>
{/* 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. */}
<SitePreview port={devPort} path={page.slug} onDocument={onDocument} reloadToken={rendered} />
<AddSectionDialog
open={adding}
busy={creating}
onCancel={() => setAdding(false)}
onCreate={(values) => void createSection(values)}
/>
</div>
);
};