Files
fofos-admin/web/sections/PageBuilderPanel.tsx
T
pastilhasandClaude Opus 5 9eb17c8d03 an editor opens beside the page, not under it
The spacing and free-text editors rendered below the preview, so they took their height from the thing
being edited: choosing a spacing shrank the page you were choosing it for, and on a laptop two
dropdowns cost a third of the frame.

They are a third panel now, to the right of the preview. The workspace already knows how to size and
move panels; a drawer was a second, worse layout system living inside one of them.

The panel holds nothing. Whichever panel owns a selection BUILDS the editor and announces it through a
`useGlobal` slot — the two are siblings and neither may own the other, and a provider around both would
mean something above them knowing they exist.

The slot carries the ELEMENT, not a description of one. A descriptor plus a switch would move every
editor's loading, saving and cancelling into a panel that would have to learn what a section is — a
second place that understands the page builder, kept in step by hand. SaveTarget makes the same trade
for the same reason.

The hazard is real and worth naming: an element captures the props it was built with, so the announcing
effect must depend on everything the editor DISPLAYS. The save handlers sit in refs so they cannot drag
that list wider — depending on them would re-announce on every keystroke, and omitting them without
refs would announce a stale one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 02:29:08 +01:00

601 lines
24 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 { usePreviewable, useViewMode } from '../ViewMode';
import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas';
/*
What the Spacing dialog calls a block, by kind.
A map rather than a chain of ternaries: it was two kinds and readable, then three and not. Unknown
falls back to 'Text' — a label is a heading on a dialog, and the worst a wrong one does is read oddly
for a kind nobody has finished adding.
*/
const LABELS: Record<string, string> = {
'free-text': 'Text',
media: 'Media',
gallery: 'Gallery',
'contact-form': 'Contact form',
partners: 'Partners',
};
/*
The kinds that hold nothing, named once.
Both the picker and the canvas ask the same question — one to decide whether to open an editor, the
other whether to draw an Edit button — and two lists would drift into a button that opens nothing.
*/
const PLACES_ONLY: string[] = ['contact-form', 'partners'];
import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
import { FreeTextEditor } from './FreeTextEditor';
import { SpacingEditor, type Padding } from './SpacingEditor';
import { useEditorSlot } from '../useEditorSlot';
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
import { MediaPicker } from '../MediaPicker';
import { GalleryPicker } from '../GalleryPicker';
import { pageFromParam, 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 slug = params.get('page');
const devPort = useDevPort();
const { pages, announce } = usePages();
// This section has a preview, so the bar offers Edit/View and the three widths — the same opt-in
// Layout makes. Pages, the list, does not: there is nothing there to view at tablet width.
usePreviewable();
const { device } = useViewMode();
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;
addComponent: (section: CanvasSection, column: number) => void;
editComponent: (component: CanvasComponent) => void;
removeComponent: (component: CanvasComponent) => void;
spaceSection: (section: CanvasSection) => void;
spaceComponent: (component: CanvasComponent) => void;
}>({
add: () => {},
remove: () => {},
addComponent: () => {},
editComponent: () => {},
removeComponent: () => {},
spaceSection: () => {},
spaceComponent: () => {},
});
const [adding, setAdding] = useState(false);
const [creating, setCreating] = useState(false);
/** Where a component is about to be added, while the kind is being chosen. */
const [choosing, setChoosing] = useState<{ sectionId: number; column: number } | null>(null);
/** The component being written, and where a new one would go. Null when the dialog is shut. */
/*
Which gallery a block shows, while it is being chosen.
The same shape as `picking` and for the same reason: choosing a set is one act with one outcome, so
there is nothing to draft and nothing to cancel out of afterwards. `galleryId` rides along so
reopening the picker can show where the block already stands.
*/
const [choosingGallery, setChoosingGallery] = useState<{
id: number | null;
sectionId: number;
column: number;
galleryId: number | null;
} | null>(null);
const [editing, setEditing] = useState<{ id: number | null; sectionId: number; column: number; html: string } | null>(
null,
);
/** Choosing the file for a video — new when `id` is null, otherwise replacing an existing one. */
const [picking, setPicking] = useState<{ id: number | null; sectionId: number; column: number } | null>(null);
/** Whose spacing is being changed. A section or one component, never both. */
const [spacing, setSpacing] = useState<
{ kind: 'section' | 'component'; id: number; sectionId: number; label: string; padding: Padding } | null
>(null);
/** The scale the API publishes, so this file holds no copy of it. */
const [steps, setSteps] = useState<number[]>([]);
/*
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.
*/
/*
In VIEW, nothing is painted at all.
Not the chrome dimmed or the buttons hidden — no injection, so the frame holds exactly the document
the site served. That is what View has to mean here: outlines, labels and a drop zone are the
editor's, and a preview that keeps any of them is not showing you the page.
The check is INSIDE the callback and `mode` is a dependency, so switching to View hands SitePreview
a new function: its effect runs the previous paint's cleanup and then paints nothing. That is what
takes the chrome away, with no reload and no second code path.
*/
/*
The frame's own document, kept between paints.
It is how a draft reaches the page: the editor writes each change into the component's node rather
than waiting for a save and a reload. Held in a ref because it is not state — nothing re-renders
when the document changes, and the effect below reads whatever is current.
*/
const frameDoc = useRef<Document | null>(null);
const onDocument = useCallback(
(frameDocument: Document) => {
frameDoc.current = frameDocument;
return paintCanvas(frameDocument, {
sections,
onAddSection: () => handlersRef.current.add(),
onDeleteSection: (section) => handlersRef.current.remove(section.id),
onAddComponent: (section, column) => handlersRef.current.addComponent(section, column),
onEditComponent: (component) => handlersRef.current.editComponent(component),
// Nothing to edit, so no button. See PLACES_ONLY.
canEditComponent: (component) => !PLACES_ONLY.includes(component.kind),
onDeleteComponent: (component) => handlersRef.current.removeComponent(component),
onSpaceSection: (section) => handlersRef.current.spaceSection(section),
onSpaceComponent: (component) => handlersRef.current.spaceComponent(component),
});
},
// `mode` is in here so switching to View gives SitePreview a NEW function, which runs the previous
// paint's cleanup and then paints nothing. That is what removes the chrome without a reload.
[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 = pageFromParam(pages, slug);
/*
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[]; steps?: number[] }>(`/fofos-admin/pages/${page.id}/sections`)
.then((response) => {
setSections(response.data);
if (response.steps) setSteps(response.steps);
})
.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.
/*
Add component asks WHAT, even though only one kind can be chosen today.
It opened the text editor straight away, on the reasoning that one option is not a choice. That
optimised the state of the app rather than its shape: somebody adding a component could not see what
a component is or that there will be others, and the day the second kind lands the interaction
changes underneath them. The picker names the ones that are coming, the way the nav does.
*/
const addComponent = (section: CanvasSection, column: number) =>
setChoosing({ sectionId: section.id, column });
/*
A block that holds nothing is CREATED on the spot.
No dialog, because there is nothing to fill in: the contact form is the site's form and the partner
row is the partner table. Asking for a confirmation with no field on it would be a step that exists
only to be got past — the same argument the picker itself answers the other way, where there IS a
choice to make.
*/
const createEmpty = async (kind: ComponentKind, at: { sectionId: number; column: number }) => {
if (!page) return;
setError(null);
try {
await clientRef.current.post(`/fofos-admin/pages/${page.id}/sections/${at.sectionId}/components`, {
kind,
column: at.column,
content: {},
});
setRendered((n) => n + 1);
} catch (failure) {
setError(readableError(failure));
}
};
const chooseKind = (kind: ComponentKind) => {
if (!choosing) return;
const at = choosing;
setChoosing(null);
// Each kind opens its own editor. A kind with no case here cannot be created, which is the point.
if (kind === 'free-text') setEditing({ id: null, ...at, html: '' });
if (kind === 'media') setPicking({ id: null, ...at });
if (kind === 'gallery') setChoosingGallery({ id: null, ...at, galleryId: null });
if (PLACES_ONLY.includes(kind)) void createEmpty(kind, at);
};
/*
A media block is a PATH, saved as soon as it is chosen.
Free text needs a dialog because writing is a thing people do halfway; picking a file is one act
with one outcome, so there is nothing to draft and nothing to cancel out of afterwards.
Photograph or video is not asked. The file already knows, and the site reads it off the extension
the way a gallery item always has — a kind stored here would be a second answer able to disagree.
*/
const saveVideo = async (url: string) => {
if (!page || !picking) return;
const base = `/fofos-admin/pages/${page.id}/sections/${picking.sectionId}/components`;
setPicking(null);
setError(null);
try {
if (picking.id === null) {
await clientRef.current.post(base, { kind: 'media', column: picking.column, content: { url } });
} else {
await clientRef.current.patch(`${base}/${picking.id}`, { content: { url } });
}
setRendered((n) => n + 1);
} catch (failure) {
setError(readableError(failure));
}
};
/*
A gallery block is a REFERENCE, saved as soon as the set is chosen — see GalleryPicker.
Identical in shape to saveVideo because it is the same interaction: pick one thing, store one
value. The two are left as two functions rather than one generic saver, because what they store
happens to be one field each today and a shared helper would be an abstraction over a coincidence.
*/
const saveGallery = async (galleryId: number) => {
if (!page || !choosingGallery) return;
const base = `/fofos-admin/pages/${page.id}/sections/${choosingGallery.sectionId}/components`;
const target = choosingGallery;
setChoosingGallery(null);
setError(null);
try {
if (target.id === null) {
await clientRef.current.post(base, { kind: 'gallery', column: target.column, content: { galleryId } });
} else {
await clientRef.current.patch(`${base}/${target.id}`, { content: { galleryId } });
}
setRendered((n) => n + 1);
} catch (failure) {
setError(readableError(failure));
}
};
const editComponent = (component: CanvasComponent) => {
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
if (!owner) return;
// Edit means whatever editing THIS kind means. The canvas draws one button; which door it opens is
// decided here, where the kinds are known.
// `video` too — no row holds it since the rename, but one written by something older should
// still be editable rather than open nothing.
if (component.kind === 'media' || component.kind === 'video') {
setPicking({ id: component.id, sectionId: owner.id, column: component.column });
return;
}
if (component.kind === 'gallery') {
const held = owner.components.find((one) => one.id === component.id)?.content?.galleryId;
setChoosingGallery({
id: component.id,
sectionId: owner.id,
column: component.column,
galleryId: typeof held === 'number' ? held : null,
});
return;
}
const stored = owner.components.find((one) => one.id === component.id);
const html = typeof stored?.content?.html === 'string' ? stored.content.html : '';
setEditing({ id: component.id, sectionId: owner.id, column: component.column, html });
};
/*
The draft, written into the component's node as it is typed.
The node's own children are replaced and the injected tools put back — those are the admin's and
live inside the component, so an innerHTML that forgot them would take the Edit button away
mid-edit.
Not sanitised here. The server cleans on write, so what is on screen while typing can be marginally
more permissive than what is stored — and the difference is visible the moment it is saved, which
is the right place to find out.
*/
useEffect(() => {
const doc = frameDoc.current;
if (!doc || !editing?.id) return;
const node = doc.querySelector<HTMLElement>(`[data-fofos-component="${editing.id}"]`);
if (!node) return;
const tools = node.querySelector('[data-fofos-admin-canvas="component-tools"]');
node.innerHTML = editing.html;
if (tools) node.append(tools);
}, [editing?.id, editing?.html]);
const saveText = async () => {
if (!page || !editing) return;
const html = editing.html;
setCreating(true);
setError(null);
const base = `/fofos-admin/pages/${page.id}/sections/${editing.sectionId}/components`;
try {
if (editing.id === null) {
await clientRef.current.post(base, { kind: 'free-text', column: editing.column, content: { html } });
} else {
await clientRef.current.patch(`${base}/${editing.id}`, { content: { html } });
}
setEditing(null);
setRendered((n) => n + 1);
} catch (failure) {
setError(readableError(failure));
} finally {
setCreating(false);
}
};
const deleteComponent = async (component: CanvasComponent) => {
if (!page) return;
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
if (!owner) return;
setError(null);
try {
await clientRef.current.delete(`/fofos-admin/pages/${page.id}/sections/${owner.id}/components/${component.id}`);
setRendered((n) => n + 1);
} catch (failure) {
setError(readableError(failure));
}
};
/*
Spacing is written IMMEDIATELY, like adding and deleting.
There is no draft to lose: a step is one choice from a short list, and the effect is on screen before
the menu closes. A Save for it would mean a second thing to remember on a panel whose other verbs
already act.
*/
const saveSpacing = async (padding: Padding) => {
if (!page || !spacing) return;
setSpacing({ ...spacing, padding });
const base = `/fofos-admin/pages/${page.id}/sections/${spacing.sectionId}`;
const url = spacing.kind === 'section' ? base : `${base}/components/${spacing.id}`;
try {
await clientRef.current.patch(url, { padding });
setRendered((n) => n + 1);
} catch (failure) {
setError(readableError(failure));
}
};
const spaceSection = (section: CanvasSection) =>
setSpacing({
kind: 'section',
id: section.id,
sectionId: section.id,
label: section.name,
padding: (section.padding ?? {}) as Padding,
});
const spaceComponent = (component: CanvasComponent) => {
const owner = sections.find((section) => section.components.some((one) => one.id === component.id));
if (!owner) return;
const stored = owner.components.find((one) => one.id === component.id);
setSpacing({
kind: 'component',
id: component.id,
sectionId: owner.id,
label: LABELS[component.kind] ?? 'Text',
padding: (stored?.padding ?? {}) as Padding,
});
};
const [, setEditor] = useEditorSlot();
const setEditorRef = useRef(setEditor);
setEditorRef.current = setEditor;
const saveSpacingRef = useRef(saveSpacing);
saveSpacingRef.current = saveSpacing;
const saveTextRef = useRef(saveText);
saveTextRef.current = saveText;
/*
Hand the open editor to the editor panel.
Announced rather than rendered, because the panel is a SIBLING — see useEditorSlot. The dependency
list is everything either editor DISPLAYS, which is the part that is easy to get wrong: an element
captures the props it was built with, so one left out here shows up as a control that has quietly
stopped responding rather than as an error.
The handlers are in refs for that reason. They are rebuilt every render, and depending on them
would re-announce on every keystroke; leaving them out without refs would announce a stale one.
*/
useEffect(() => {
if (spacing) {
setEditorRef.current(
<SpacingEditor
label={spacing.label}
padding={spacing.padding}
device={device}
steps={steps}
busy={false}
onChange={(padding) => void saveSpacingRef.current(padding)}
onClose={() => setSpacing(null)}
/>,
);
return;
}
if (editing) {
setEditorRef.current(
<FreeTextEditor
html={editing.html}
busy={creating}
onChange={(html) => setEditing((was) => (was ? { ...was, html } : was))}
/*
Cancelling reloads the frame rather than putting the old markup back by hand. The node has
been written into on every keystroke, and restoring it correctly means remembering what was
there before the first one — a reload is the same answer with nothing to get wrong.
*/
onCancel={() => {
setEditing(null);
setRendered((n) => n + 1);
}}
onSave={() => void saveTextRef.current()}
/>,
);
return;
}
setEditorRef.current(null);
}, [spacing, editing, creating, device, steps]);
// Cleared on unmount, or the panel goes on offering an editor for a page nobody is looking at.
useEffect(() => () => setEditorRef.current(null), []);
handlersRef.current = {
add: () => setAdding(true),
remove: (sectionId: number) => void deleteSection(sectionId),
addComponent,
editComponent,
removeComponent: (component) => void deleteComponent(component),
spaceSection,
spaceComponent,
};
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)}
/>
<AddComponentDialog open={choosing !== null} onCancel={() => setChoosing(null)} onChoose={chooseKind} />
<MediaPicker
open={picking !== null}
onClose={() => setPicking(null)}
onPick={(path) => void saveVideo(path)}
title="Choose a photograph or a video"
/>
<GalleryPicker
open={choosingGallery !== null}
onClose={() => setChoosingGallery(null)}
onPick={(galleryId) => void saveGallery(galleryId)}
selected={choosingGallery?.galleryId ?? null}
/>
</div>
);
};