Files
fofos-admin/web/sections/BlockSettings.tsx
T
pastilhasandClaude Opus 5 1ec361d006 a block's spacing sits with its title; page settings need the page selected
Spacing had a link in the chrome painted onto the page and lost its door with the
rest of it. It belongs beside the heading: both are properties of the selected
block, and one of the two is not worth a place of its own to go to.

The page's name and address now show only when the page row itself is selected.
With a band or a block open they sat above an editor about something else and
read as belonging to it.

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

153 lines
5.6 KiB
TypeScript

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';
import { useDevice } from '../DeviceToggle';
/**
* 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,
steps,
onSaved,
}: {
pageId: number;
sectionId: number;
block: { id: number; title?: string | null; padding?: Record<string, { x?: number; y?: number }> };
/** The padding scale, sent by the API so this file holds no copy of it. */
steps: number[];
onSaved: () => void;
}) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [device] = useDevice();
const breakpoint = ({ desktop: 'lg', tablet: 'md', mobile: 'base' } as const)[device];
const [draft, setDraft] = useState({ title: block.title ?? '', padding: block.padding ?? {} });
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({ title: block.title ?? '', padding: block.padding ?? {} }), [block.id]);
/** What an axis falls back to at this width: the nearest smaller one that has a value. */
const inherited = (axis: 'x' | 'y'): number | null => {
const below = { base: [], md: ['base'], lg: ['md', 'base'] } as const;
for (const smaller of below[breakpoint]) {
const step = draft.padding[smaller]?.[axis];
if (typeof step === 'number') return step;
}
return null;
};
const setPadding = (axis: 'x' | 'y', value: string) => {
const at = { ...(draft.padding[breakpoint] ?? {}) };
if (value === '') delete at[axis];
else at[axis] = Number(value);
const next = { ...draft.padding };
// An empty breakpoint is removed rather than kept as `{}`, so "inherit everything" stays an
// absence instead of a thing that has to be told apart from one later.
if (Object.keys(at).length) next[breakpoint] = at;
else delete next[breakpoint];
setDraft((was) => ({ ...was, padding: next }));
};
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.title, padding: draft.padding },
);
flashSaved();
onSaved();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const stored = JSON.stringify({ title: block.title ?? '', padding: block.padding ?? {} });
const pending = JSON.stringify(draft);
useAutosave(pending !== stored ? pending : null, busy, save);
return (
<div className="flex flex-col gap-1">
<label className="text-xs">Title</label>
<Input
value={draft.title}
placeholder="No heading"
onChange={(event) => setDraft((was) => ({ ...was, title: event.currentTarget.value }))}
/>
{/*
The block's own spacing, at the width the toggle above is showing.
It had a Spacing link in the chrome painted onto the page, which went with the rest of it. It
belongs here: it is a property of the selected block, like the heading, and one of the two is
not worth a separate place to go.
Unset inherits the smaller width, and 0 is the other instruction — none HERE even though the
phone has some. So they are separate options.
*/}
<div className="mt-3 grid grid-cols-2 gap-3">
{(
[
['x', 'Horizontal padding'],
['y', 'Vertical padding'],
] as const
).map(([axis, label]) => {
const own = draft.padding[breakpoint]?.[axis];
const from = inherited(axis);
return (
<div key={axis} className="flex flex-col gap-1">
<label className="text-xs">{label}</label>
<select
value={own === undefined ? '' : String(own)}
onChange={(event) => setPadding(axis, event.currentTarget.value)}
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
>
<option value="">
{breakpoint === 'base' ? 'None' : from === null ? 'Inherit (none)' : `Inherit (${from})`}
</option>
{steps.map((step) => (
<option key={step} value={String(step)}>
{step}
</option>
))}
</select>
</div>
);
})}
</div>
{saved && <p className="text-xs text-success">Saved</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
};