Files
fofos-admin/web/sections/PageBuilderPanel.tsx
T
pastilhasandClaude Opus 5 e2a74e3337 Add section asks what shape, and creates one
Pressing the button drawn inside the page opens a dialog: a name, and a choice between one column and
two, then Create section. The row lands in the database and the frame re-renders to show it.

The layouts are DRAWN rather than named. "One column" is words for a shape, and a shape is faster to
recognise than to read; the words stay underneath for anybody the picture does not reach.

The name is asked for and not generated. It never appears on the site — it exists so a page of eight
bands is navigable, and "Section 4" tells nobody anything, which is exactly the state a generated name
would leave every page in. Asked at the only moment somebody knows what they are about to build.

The frame is remounted after a write rather than the admin drawing the new band itself. The site
renders from the database; the admin does not know how to render a page, and if it learnt it would be
a second implementation of the very thing this preview exists to avoid.

The canvas now finds the empty-page placeholder by its `data-page-empty` marker instead of taking
`firstElementChild`. That was right for exactly as long as an empty page was the only kind — the moment
a page has a section, the first child IS that section, and it would have hidden the thing being edited.

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

121 lines
4.7 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 } 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 [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 <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. */}
<SitePreview port={devPort} path={page.slug} onDocument={onDocument} reloadToken={rendered} />
<AddSectionDialog
open={adding}
busy={creating}
onCancel={() => setAdding(false)}
onCreate={(values) => void createSection(values)}
/>
</div>
);
};