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>
This commit is contained in:
2026-08-31 04:06:27 +01:00
co-authored by Claude Opus 5
parent 5af0d9d9d1
commit e2a74e3337
4 changed files with 181 additions and 26 deletions
+15
View File
@@ -45,6 +45,7 @@ export const SitePreview = ({
path, path,
theme, theme,
onDocument, onDocument,
reloadToken,
}: { }: {
port: number | null; port: number | null;
/** The site path to show — `layout`, `about`, or `` for the front page. No leading slash. */ /** The site path to show — `layout`, `about`, or `` for the front page. No leading slash. */
@@ -67,6 +68,17 @@ export const SitePreview = ({
* document it was injected into. * document it was injected into.
*/ */
onDocument?: (document: Document) => void | (() => void); onDocument?: (document: Document) => void | (() => void);
/**
* Change this to fetch the page again.
*
* The site renders from the database, so anything written there only appears when the document is
* requested anew. The admin cannot draw the change itself — it does not know how to render a page,
* and if it did it would be a second implementation of the one thing this preview exists to avoid.
*
* Remounts the frame rather than calling `contentWindow.location.reload()`. Both work while the frame
* is same-origin, and the remount is one mechanism that would still work if it ever were not.
*/
reloadToken?: number;
}) => { }) => {
const client = useClient(); const client = useClient();
/* /*
@@ -221,6 +233,9 @@ export const SitePreview = ({
remove the attribute. This is the owner's own development server on this machine. remove the attribute. This is the owner's own development server on this machine.
*/ */
<iframe <iframe
// The key is what makes `reloadToken` reload: a new key is a new element, so the browser
// fetches the document again and `onLoad` runs, which re-injects the builder's chrome.
key={reloadToken ?? 0}
ref={frame} ref={frame}
// Applied on load as well as on change: the document is replaced on a reload, and the // Applied on load as well as on change: the document is replaced on a reload, and the
// properties go with it. // properties go with it.
+10 -8
View File
@@ -58,15 +58,17 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
root.querySelectorAll(`[${MARK}]`).forEach((node) => node.remove()); root.querySelectorAll(`[${MARK}]`).forEach((node) => node.remove());
/* /*
The page's own content is hidden rather than removed. The site's EMPTY-PAGE placeholder, hidden while the builder draws its own controls in that space.
Removed, it would be gone until the frame reloaded — and the builder wants to put controls AROUND Found by its marker rather than by position. It was `firstElementChild`, which was right for exactly
what is there, not instead of it. Today the page is the empty placeholder and hiding it is the whole as long as an empty page was the only page — the moment a page has a section, the first child IS
effect; tomorrow, when a page has blocks, the same call leaves them visible and this hides nothing. that section and hiding it would hide the thing being edited.
Hidden rather than removed, so the cleanup can simply put it back. A page whose last section is
deleted then shows it again without a reload.
*/ */
const placeholder = root.firstElementChild as HTMLElement | null; const placeholder = root.querySelector<HTMLElement>('[data-page-empty]');
const hadPlaceholder = Boolean(placeholder && !placeholder.hasAttribute(MARK)); if (placeholder) placeholder.style.display = 'none';
if (hadPlaceholder && placeholder) placeholder.style.display = 'none';
const zone = document.createElement('div'); const zone = document.createElement('div');
zone.setAttribute(MARK, 'add-section'); zone.setAttribute(MARK, 'add-section');
@@ -104,6 +106,6 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
return () => { return () => {
button.removeEventListener('click', canvas.onAddSection); button.removeEventListener('click', canvas.onAddSection);
zone.remove(); zone.remove();
if (hadPlaceholder && placeholder) placeholder.style.display = ''; if (placeholder) placeholder.style.display = '';
}; };
} }
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { Columns2, Loader2, Square } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
export type SectionLayout = 'one' | 'two';
/**
* The layouts, drawn rather than named.
*
* "One column" and "Two columns" are 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.
*
* This list and the site's own are two copies of the same fact — the database has it as a check
* constraint and PageSection.tsx has it as a branch. A layout offered here that the site cannot draw
* would render as nothing, so adding one means all three.
*/
const LAYOUTS: { value: SectionLayout; label: string; Icon: typeof Square; columns: number }[] = [
{ value: 'one', label: 'One column', Icon: Square, columns: 1 },
{ value: 'two', label: 'Two columns', Icon: Columns2, columns: 2 },
];
/**
* Naming a section and choosing its shape, before it exists.
*
* ── Why a name is asked for and not generated ──
*
* The name is never rendered on the site. It exists so a page of eight bands is navigable, and
* "Section 4" tells nobody anything — which is precisely the state a generated name would leave every
* page in. Asked once, at the only moment somebody knows what they are about to build.
*/
export const AddSectionDialog = ({
open,
busy,
onCancel,
onCreate,
}: {
open: boolean;
busy: boolean;
onCancel: () => void;
onCreate: (values: { name: string; layout: SectionLayout }) => void;
}) => {
const [name, setName] = useState('');
const [layout, setLayout] = useState<SectionLayout>('one');
// Fresh every time it opens. A dialog that remembers the last thing somebody typed and abandoned is
// a dialog that quietly suggests they meant it.
useEffect(() => {
if (open) {
setName('');
setLayout('one');
}
}, [open]);
const create = () => {
const trimmed = name.trim();
if (!trimmed || busy) return;
onCreate({ name: trimmed, layout });
};
return (
<Dialog open={open} onOpenChange={(next) => !next && onCancel()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add a section</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Section name</label>
<Input
autoFocus
value={name}
placeholder="Intro"
onChange={(event) => setName(event.currentTarget.value)}
// Enter creates, because a two-field form is one somebody expects to finish from the
// keyboard. Guarded by the same check the button uses.
onKeyDown={(event) => event.key === 'Enter' && create()}
/>
<span className="text-[10px] text-muted-foreground">
For you, not for the site. It never appears on the page.
</span>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Layout</label>
<div className="grid grid-cols-2 gap-2">
{LAYOUTS.map(({ value, label, Icon, columns }) => (
<button
key={value}
type="button"
onClick={() => setLayout(value)}
className={`flex flex-col items-center gap-2 rounded-lg border p-4 transition-colors ${
layout === value ? 'border-primary bg-muted' : 'border-border hover:bg-muted/50'
}`}
>
<Icon className="size-6 text-muted-foreground" />
<span className="text-xs">{label}</span>
<span className="sr-only">{columns} column preview</span>
</button>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onCancel} disabled={busy}>
Cancel
</Button>
{/* Dead without a name. The server refuses an empty one, and a button that fires a request
only to show an error is a slower way of saying what the button could have said. */}
<Button onClick={create} disabled={!name.trim() || busy}>
{busy && <Loader2 className="size-3.5 animate-spin" />}
Create section
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+35 -18
View File
@@ -4,6 +4,7 @@ import { useClient } from 'hooks/useClient';
import { readableError } from '../errors'; import { readableError } from '../errors';
import { SitePreview, useDevPort } from '../SitePreview'; import { SitePreview, useDevPort } from '../SitePreview';
import { paintCanvas } from '../canvas'; import { paintCanvas } from '../canvas';
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
import { usePages, type Page } from '../usePages'; import { usePages, type Page } from '../usePages';
/** /**
@@ -33,8 +34,16 @@ export const PageBuilderPanel = () => {
const { pages, announce } = usePages(); const { pages, announce } = usePages();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
/** Proof the frame is talking back. Replaced by the section picker once there are sections to pick. */ const [adding, setAdding] = useState(false);
const [asked, setAsked] = useState(0); 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. The builder's chrome, painted into the rendered page.
@@ -43,10 +52,26 @@ export const PageBuilderPanel = () => {
be a new injection each render. be a new injection each render.
*/ */
const onDocument = useCallback( const onDocument = useCallback(
(frameDocument: Document) => paintCanvas(frameDocument, { onAddSection: () => setAsked((n) => n + 1) }), (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. The list, if nobody has announced it yet.
@@ -82,22 +107,14 @@ export const PageBuilderPanel = () => {
{/* The real page, at its own address, with the builder's own controls drawn inside it. `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. */} 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} /> <SitePreview port={devPort} path={page.slug} onDocument={onDocument} reloadToken={rendered} />
{/* <AddSectionDialog
The other end of the wire. open={adding}
busy={creating}
Temporary, and it earns its place until the section picker exists: it is the only visible proof onCancel={() => setAdding(false)}
that a button drawn inside the rendered page reaches React out here. When there are sections to onCreate={(values) => void createSection(values)}
choose, this becomes the chooser. />
*/}
<div className="shrink-0 rounded-lg border border-dashed border-border p-3 text-center">
<p className="text-xs text-muted-foreground">
{asked === 0
? 'Press Add section on the page.'
: `Add section pressed ${asked} time${asked === 1 ? '' : 's'} — the page is talking to the panel.`}
</p>
</div>
</div> </div>
); );
}; };