diff --git a/web/canvas.ts b/web/canvas.ts index 7b8ee1f..159b11b 100644 --- a/web/canvas.ts +++ b/web/canvas.ts @@ -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; diff --git a/web/sections/AddComponentDialog.tsx b/web/sections/AddComponentDialog.tsx index cdf859d..2fa4615 100644 --- a/web/sections/AddComponentDialog.tsx +++ b/web/sections/AddComponentDialog.tsx @@ -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 = ({ diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index 6903955..4ab0537 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -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 = { + '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, }); };