Intro and Hero are their own sections, and Intro comes first

The page reads hero-then-intro and this deliberately does not. The intro is the
thing that gets edited; the hero is a path that changes once a season. Ordering
by what somebody came to do beats mirroring the layout of the page they are
describing.

One PATCH per card, carrying only that card's field. `take()` on the server
treats an absent key as leave-it-alone, so sending the whole object from either
card would have each one saving the other's unsaved edits — a half-finished
intro riding along with a hero somebody meant to change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 20:24:00 +01:00
co-authored by Claude Opus 5
parent d57bd3e72f
commit 95d5661e73
+42 -18
View File
@@ -92,13 +92,18 @@ export const HomePanel = () => {
}
};
const saveContent = () =>
send('content', () =>
clientRef.current.patch<{ data: Home }>('/fofos-admin/home', {
hero_url: heroUrl.trim() || null,
intro_text: intro.trim() || null,
}),
);
/*
One PATCH per card, carrying only that card's field.
`take()` on the server treats an absent key as "leave it alone", so sending the whole object from
either card would have each one saving the other's unsaved edits. Two cards, two independent
saves, and a half-finished intro cannot ride along with a hero somebody meant to change.
*/
const saveIntro = () =>
send('intro', () => clientRef.current.patch<{ data: Home }>('/fofos-admin/home', { intro_text: intro.trim() || null }));
const saveHero = () =>
send('hero', () => clientRef.current.patch<{ data: Home }>('/fofos-admin/home', { hero_url: heroUrl.trim() || null }));
const add = () =>
send('add', async () => {
@@ -137,15 +142,23 @@ export const HomePanel = () => {
);
}
const contentDirty = heroUrl.trim() !== (home.hero.url ?? '') || intro.trim() !== (home.intro_text ?? '');
const introDirty = intro.trim() !== (home.intro_text ?? '');
const heroDirty = heroUrl.trim() !== (home.hero.url ?? '');
return (
<div className="flex size-full flex-col gap-4 overflow-auto p-5">
{/*
Intro first, then Hero.
The page reads hero-then-intro, and this does not, deliberately: the intro is the thing that
gets edited, and the hero is a path that changes once a season. Ordering by what somebody came
to do beats mirroring the layout of the page they are describing.
*/}
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="text-sm font-medium">Hero</h3>
<p className="text-xs text-muted-foreground">The video or image across the top of the page.</p>
<h3 className="text-sm font-medium">Intro</h3>
<p className="text-xs text-muted-foreground">The paragraph under the hero.</p>
</div>
{saved && (
<span className="flex shrink-0 items-center gap-1 text-xs text-success">
@@ -155,6 +168,23 @@ export const HomePanel = () => {
)}
</div>
{/* HTML in and HTML out — it is what the site's database already holds and renders. */}
<RichText value={intro} onChange={setIntro} placeholder="The paragraph under the hero." />
<div className="flex justify-end border-t border-border pt-3">
<Button size="sm" onClick={() => void saveIntro()} disabled={busy !== null || !introDirty}>
{busy === 'intro' ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
Save
</Button>
</div>
</section>
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<div className="space-y-1">
<h3 className="text-sm font-medium">Hero</h3>
<p className="text-xs text-muted-foreground">The video or image across the top of the page.</p>
</div>
<Input
value={heroUrl}
onChange={(event) => setHeroUrl(event.target.value)}
@@ -172,17 +202,11 @@ export const HomePanel = () => {
</p>
)}
<div className="space-y-1">
<p className="text-xs font-medium">Intro</p>
{/* HTML in and HTML out — it is what the site's database already holds and renders. */}
<RichText value={intro} onChange={setIntro} placeholder="The paragraph under the hero." />
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex justify-end border-t border-border pt-3">
<Button size="sm" onClick={() => void saveContent()} disabled={busy !== null || !contentDirty}>
{busy === 'content' ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
<Button size="sm" onClick={() => void saveHero()} disabled={busy !== null || !heroDirty}>
{busy === 'hero' ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
Save
</Button>
</div>