From aaf767087e56d732e9642fb9de0e1c2cd7f0879b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 1 Sep 2026 19:41:26 +0100 Subject: [PATCH] selecting a band opens its settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name and Title, in the editor column — the band row in the nav had only scrolled the preview until now. Both, not the title alone: a form showing one of two names invites somebody to change the wrong one, and `name` could only be set when the band was created. Last in the editor's order, so a block or a spacing editor wins — those were opened deliberately, and the band is merely still selected underneath them. Co-Authored-By: Claude Opus 5 (1M context) --- web/canvas.ts | 2 + web/sections/BandSettings.tsx | 89 +++++++++++++++++++++++++++++++ web/sections/PageBuilderPanel.tsx | 17 +++++- 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 web/sections/BandSettings.tsx diff --git a/web/canvas.ts b/web/canvas.ts index 8fe0584..5f0335b 100644 --- a/web/canvas.ts +++ b/web/canvas.ts @@ -64,6 +64,8 @@ export type CanvasComponent = { export type CanvasSection = { id: number; name: string; + /** The heading the page draws above the band, or null. Not `name` — see the site's schema. */ + title?: string | null; layout: 'one' | 'two'; padding?: Record; components: CanvasComponent[]; diff --git a/web/sections/BandSettings.tsx b/web/sections/BandSettings.tsx new file mode 100644 index 0000000..d9a8dc3 --- /dev/null +++ b/web/sections/BandSettings.tsx @@ -0,0 +1,89 @@ +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'; + +/** + * What a band is: what the editor calls it, and what the page shows. + * + * ── Two names, and they are different things ── + * + * The NAME is for whoever is editing — it is the row in the nav, and it is what makes a page of eight + * bands navigable. It never reaches a reader. + * + * The TITLE is the heading the page draws above the band, and most bands do not want one: a band that + * opens with its own words is the common case. Empty takes it off. + * + * Both are here rather than the title alone, because a form that shows one of two names invites + * somebody to change the wrong one — and until now `name` could only be set when the band was created. + */ +export const BandSettings = ({ + pageId, + band, + onSaved, +}: { + pageId: number; + band: { id: number; name: string; title?: string | null }; + /** The builder re-reads its sections: the nav's row is this name, and the page draws this title. */ + onSaved: () => void; +}) => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [draft, setDraft] = useState({ name: band.name, title: band.title ?? '' }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [saved, flashSaved] = useFlash(); + + // A different band selected is a different form. Keyed on the id so re-reading the same band after a + // save does not throw away what is being typed. + useEffect(() => setDraft({ name: band.name, title: band.title ?? '' }), [band.id]); + + const stored = JSON.stringify({ name: band.name, title: band.title ?? '' }); + const pending = JSON.stringify(draft); + + const save = async () => { + setBusy(true); + setError(null); + try { + // An empty title is sent as an empty string, which the API reads as "take the heading off" — + // presence, not truthiness. See the note in its PATCH handler. + await clientRef.current.patch(`/fofos-admin/pages/${pageId}/sections/${band.id}`, draft); + flashSaved(); + onSaved(); + } catch (failure) { + setError(readableError(failure)); + } finally { + setBusy(false); + } + }; + + useAutosave(pending !== stored ? pending : null, busy, save); + + return ( +
+
+ + setDraft((was) => ({ ...was, name: event.currentTarget.value }))} + /> +
+ +
+ + setDraft((was) => ({ ...was, title: event.currentTarget.value }))} + /> +
+ + {saved &&

Saved

} + {error &&

{error}

} +
+ ); +}; diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index e610329..ad6eaa1 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -32,6 +32,7 @@ const PLACES_ONLY: string[] = ['contact-form', 'partners']; import { AddSectionDialog, type SectionLayout } from './AddSectionDialog'; import { FreeTextEditor } from './FreeTextEditor'; import { SpacingEditor, type Padding } from './SpacingEditor'; +import { BandSettings } from './BandSettings'; import { useEditorSlot } from '../useEditorSlot'; import { AddComponentDialog, type ComponentKind } from './AddComponentDialog'; import { MediaPicker } from '../MediaPicker'; @@ -597,8 +598,22 @@ export const PageBuilderPanel = () => { return; } + /* + A band selected and nothing inside it: its own settings. + + Last, so a block or a spacing editor wins — those are things you opened deliberately, and the + band is merely still selected underneath them. + */ + const band = !piece && part ? sections.find((section) => String(section.id) === part) : null; + if (band && page) { + setEditorRef.current( + setRendered((n) => n + 1)} />, + ); + return; + } + setEditorRef.current(null); - }, [spacing, editing, creating, device, steps]); + }, [spacing, editing, creating, device, steps, part, piece, sections, page]); // Cleared on unmount, or the panel goes on offering an editor for a page nobody is looking at. useEffect(() => () => setEditorRef.current(null), []);