Contact form and Partners, placed in one click

Both hold nothing, so adding one opens no editor: a dialog with no field on it is a step that exists
only to be got past. The picker itself answers the opposite way for the kinds that DO have a choice
to make, which is the distinction worth keeping.

The canvas draws no Edit button for them either — an Edit that opens nothing is worse than no Edit.
It asks the panel rather than holding a list of kinds, because which kinds are editable is the same
knowledge that decides which editor opens, and that already lives there. Absent means editable, so a
caller that has never thought about it gets what it had.

The Spacing dialog's label became a map. It was two kinds and readable as a ternary, then three and
not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 07:08:30 +01:00
co-authored by Claude Opus 5
parent 48c744372b
commit a4dfb3c81a
3 changed files with 85 additions and 9 deletions
+20 -3
View File
@@ -80,6 +80,17 @@ export type Canvas = {
onAddComponent: (section: CanvasSection, column: number) => void;
/** Something already in a column was pressed. */
onEditComponent: (component: CanvasComponent) => void;
/**
* Whether this component has anything to edit — no button if not.
*
* A predicate from the panel rather than a list of kinds here, because which kinds are editable is
* the same knowledge that decides WHICH editor opens, and that already lives there. A contact form
* and a partner row are code the site owns: the block places them and there is nothing to type, so
* an Edit button would be a door onto an empty room.
*
* Absent means editable, so a caller that has never thought about it gets what it had before.
*/
canEditComponent?: (component: CanvasComponent) => boolean;
onDeleteComponent: (component: CanvasComponent) => void;
};
@@ -324,11 +335,17 @@ export function paintCanvas(document: Document, canvas: Canvas): () => void {
'z-index:5',
]);
for (const [label, run] of [
['Edit', () => canvas.onEditComponent(component)],
const actions: [string, () => void][] = [
['Spacing', () => canvas.onSpaceComponent(component)],
['Delete', () => canvas.onDeleteComponent(component)],
] as const) {
];
// First, so the tools read Edit / Spacing / Delete — the order they were in when Edit was
// unconditional, rather than the order the array happens to be built in.
if (canvas.canEditComponent?.(component) ?? true) {
actions.unshift(['Edit', () => canvas.onEditComponent(component)]);
}
for (const [label, run] of actions) {
const action = document.createElement('button');
action.type = 'button';
action.textContent = label;
+11 -2
View File
@@ -1,4 +1,4 @@
import { Images, Type, Video, type LucideIcon } from 'lucide-react';
import { Handshake, Images, Mail, Type, Video, type LucideIcon } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
/**
@@ -23,7 +23,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
* A kind the server would accept and this has never heard of is not creatable anyway.
*/
export type ComponentKind = 'free-text' | 'video' | 'gallery';
export type ComponentKind = 'free-text' | 'video' | 'gallery' | 'contact-form' | 'partners';
type Choice = {
kind: ComponentKind | null;
@@ -45,6 +45,15 @@ const CHOICES: Choice[] = [
{ kind: null, label: 'Image', hint: 'One picture from the media store', Icon: Images },
{ kind: 'video', label: 'Video', hint: 'No controls, a poster, autoplay and loop', Icon: Video },
{ kind: 'gallery', label: 'Gallery', hint: 'A named set of media', Icon: Images },
/*
Two that place something the site already draws.
Nothing to choose and nothing to type, so adding one is a single click and there is no editor
behind it. They are here rather than hidden because a page is built by picking blocks, and a block
somebody cannot find is a block that does not exist.
*/
{ kind: 'contact-form', label: 'Contact form', hint: 'The site\'s form, as it is', Icon: Mail },
{ kind: 'partners', label: 'Partners', hint: 'The partner logos, from Site', Icon: Handshake },
];
export const AddComponentDialog = ({
+54 -4
View File
@@ -5,6 +5,29 @@ 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',
video: 'Video',
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';
@@ -147,6 +170,8 @@ export const PageBuilderPanel = () => {
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),
@@ -240,13 +265,38 @@ export const PageBuilderPanel = () => {
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, ...choosing, html: '' });
if (kind === 'video') setPicking({ id: null, ...choosing });
if (kind === 'gallery') setChoosingGallery({ id: null, ...choosing, galleryId: null });
if (kind === 'free-text') setEditing({ id: null, ...at, html: '' });
if (kind === 'video') setPicking({ id: null, ...at });
if (kind === 'gallery') setChoosingGallery({ id: null, ...at, galleryId: null });
if (PLACES_ONLY.includes(kind)) void createEmpty(kind, at);
};
/*
@@ -418,7 +468,7 @@ export const PageBuilderPanel = () => {
kind: 'component',
id: component.id,
sectionId: owner.id,
label: component.kind === 'video' ? 'Video' : component.kind === 'gallery' ? 'Gallery' : 'Text',
label: LABELS[component.kind] ?? 'Text',
padding: (stored?.padding ?? {}) as Padding,
});
};