every block is selectable, and has a Title

canEditComponent answers "is there content to type", and a heading is not
content — so the blocks with no editor of their own are now selectable too, and
get the heading alone.

The live draft writes into [data-block] when there is one. A titled component
renders as a wrapper, and writing into the wrapper would replace the heading with
the paragraph as somebody typed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 19:46:59 +01:00
co-authored by Claude Opus 5
parent aaf767087e
commit 361684b8c5
3 changed files with 123 additions and 5 deletions
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useRef, useState } from 'react';
import { Input } from '@/components/ui/input';
import { useClient } from 'hooks/useClient';
import { readableError } from '../errors';
import { useAutosave } from '../useAutosave';
import { useFlash } from '../useFlash';
/**
* A block's own heading. Optional, and above whatever editor the block has.
*
* ── Why it is not inside the free text ──
*
* Typing a heading into the text works for one kind of block and no others. A gallery, a video and a
* partner row all want to announce themselves and have nowhere to put the words — so the heading is a
* property of the BLOCK rather than of its contents, and every kind gets one.
*
* That is also why this is announced for every selected block, including the ones with no editor of
* their own: `canEditComponent` answers "is there content to type", and a heading is not content.
*/
export const BlockSettings = ({
pageId,
sectionId,
block,
onSaved,
}: {
pageId: number;
sectionId: number;
block: { id: number; title?: string | null };
onSaved: () => void;
}) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [draft, setDraft] = useState(block.title ?? '');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, flashSaved] = useFlash();
// Keyed on the id, so re-reading the same block after a save does not throw away what is being typed.
useEffect(() => setDraft(block.title ?? ''), [block.id]);
const save = async () => {
setBusy(true);
setError(null);
try {
// Empty is sent as an empty string, which the API reads as "take the heading off".
await clientRef.current.patch(
`/fofos-admin/pages/${pageId}/sections/${sectionId}/components/${block.id}`,
{ title: draft },
);
flashSaved();
onSaved();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
useAutosave(draft !== (block.title ?? '') ? draft : null, busy, save);
return (
<div className="flex flex-col gap-1">
<label className="text-xs">Title</label>
<Input value={draft} placeholder="No heading" onChange={(event) => setDraft(event.currentTarget.value)} />
{saved && <p className="text-xs text-success">Saved</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
};
+45 -1
View File
@@ -33,6 +33,7 @@ import { AddSectionDialog, type SectionLayout } from './AddSectionDialog';
import { FreeTextEditor } from './FreeTextEditor';
import { SpacingEditor, type Padding } from './SpacingEditor';
import { BandSettings } from './BandSettings';
import { BlockSettings } from './BlockSettings';
import { useEditorSlot } from '../useEditorSlot';
import { AddComponentDialog, type ComponentKind } from './AddComponentDialog';
import { MediaPicker } from '../MediaPicker';
@@ -462,7 +463,14 @@ export const PageBuilderPanel = () => {
if (!node) return;
const tools = node.querySelector('[data-fofos-admin-canvas="component-tools"]');
node.innerHTML = editing.html;
/*
The BLOCK inside, when the component has a heading.
A titled component renders as a wrapper — heading, then `[data-block]` — and writing into the
wrapper would replace the heading with the paragraph as somebody typed. Untitled ones are not
wrapped, so the node itself is the block.
*/
(node.querySelector<HTMLElement>('[data-block]') ?? node).innerHTML = editing.html;
if (tools) node.append(tools);
}, [editing?.id, editing?.html]);
@@ -577,6 +585,42 @@ export const PageBuilderPanel = () => {
return;
}
/*
The selected block's heading, above whatever editor its kind has.
Announced together, because they are one selection: the heading belongs to the block and the
editor is what is inside it. A block with no editor gets the heading alone, which is why every
block is selectable now.
*/
const owner = piece ? sections.find((one) => one.components.some((c) => String(c.id) === piece)) : null;
const block = owner?.components.find((c) => String(c.id) === piece);
if (block && owner && page) {
setEditorRef.current(
<div className="flex flex-col gap-4">
<BlockSettings
pageId={page.id}
sectionId={owner.id}
block={block}
onSaved={() => setRendered((n) => n + 1)}
/>
{editing && (
<FreeTextEditor
html={editing.html}
busy={creating}
onChange={(html) => setEditing((was) => (was ? { ...was, html } : was))}
onCancel={() => {
setEditing(null);
setRendered((n) => n + 1);
}}
onSave={() => void saveTextRef.current()}
/>
)}
</div>,
);
return;
}
if (editing) {
setEditorRef.current(
<FreeTextEditor