Files
fofos-admin/web/sections/BlockSettings.tsx
T
pastilhasandClaude Opus 5 96d917f9e1 Width, instead of a negative number to match by hand
Inside the band, whole width of main, or whole page width. The negatives are
gone from the padding select — padding is padding again.

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

197 lines
6.9 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;
width?: 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 ?? '',
width: block.width ?? '',
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 ?? '', width: block.width ?? '', 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, width: draft.width || null, padding: draft.padding },
);
flashSaved();
onSaved();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const stored = JSON.stringify({
title: block.title ?? '',
width: block.width ?? '',
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) => {
// Read NOW — see the note in BandSettings. A functional updater runs after the handler has
// returned, and `currentTarget` is null by then.
const value = event.currentTarget.value;
setDraft((was) => ({ ...was, title: value }));
}}
/>
{/*
How wide it is allowed to be.
It was a NEGATIVE horizontal padding, which worked and asked the wrong thing: you had to know
the band's gutter and type its opposite, and it broke quietly the day somebody changed the
gutter. These two say what is wanted, and the page works out the number.
*/}
<div className="mt-3 flex flex-col gap-1">
<label className="text-xs">Width</label>
<select
value={draft.width}
onChange={(event) => {
const value = event.currentTarget.value;
setDraft((was) => ({ ...was, width: value }));
}}
className="h-8 rounded-md border border-input bg-transparent px-2 text-xs"
>
<option value="">Inside the band</option>
<option value="main">Whole width of main</option>
<option value="page">Whole page width</option>
</select>
</div>
{/*
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>
);
};