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>
This commit is contained in:
+9
-2
@@ -40,9 +40,16 @@ export const EditorPanel = () => {
|
||||
Above the device toggle, because the toggle scopes what is under it to one width and none of these
|
||||
three have widths.
|
||||
*/
|
||||
const { section, item } = useNav();
|
||||
const { section, item, part } = useNav();
|
||||
const { pages } = usePages();
|
||||
const page = section === 'pages' ? pageFromParam(pages, item) : null;
|
||||
/*
|
||||
Only when the PAGE row itself is selected.
|
||||
|
||||
A band or a block selected means the editor below is about that, and the page's own name and
|
||||
address sitting above it read as belonging to whatever is open. They are reached by pressing the
|
||||
page again, which is what the nav already implies.
|
||||
*/
|
||||
const page = section === 'pages' && !part ? pageFromParam(pages, item) : null;
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
|
||||
@@ -4,6 +4,7 @@ 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.
|
||||
@@ -21,24 +22,54 @@ export const BlockSettings = ({
|
||||
pageId,
|
||||
sectionId,
|
||||
block,
|
||||
steps,
|
||||
onSaved,
|
||||
}: {
|
||||
pageId: number;
|
||||
sectionId: number;
|
||||
block: { id: number; title?: string | null };
|
||||
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 [draft, setDraft] = useState(block.title ?? '');
|
||||
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(block.title ?? ''), [block.id]);
|
||||
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);
|
||||
@@ -47,7 +78,7 @@ export const BlockSettings = ({
|
||||
// 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: draft.title, padding: draft.padding },
|
||||
);
|
||||
flashSaved();
|
||||
onSaved();
|
||||
@@ -58,12 +89,62 @@ export const BlockSettings = ({
|
||||
}
|
||||
};
|
||||
|
||||
useAutosave(draft !== (block.title ?? '') ? draft : null, busy, save);
|
||||
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} placeholder="No heading" onChange={(event) => setDraft(event.currentTarget.value)} />
|
||||
<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>
|
||||
|
||||
@@ -602,6 +602,7 @@ export const PageBuilderPanel = () => {
|
||||
pageId={page.id}
|
||||
sectionId={owner.id}
|
||||
block={block}
|
||||
steps={steps}
|
||||
onSaved={() => setRendered((n) => n + 1)}
|
||||
/>
|
||||
{editing && (
|
||||
|
||||
Reference in New Issue
Block a user