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', () => One PATCH per card, carrying only that card's field.
clientRef.current.patch<{ data: Home }>('/fofos-admin/home', {
hero_url: heroUrl.trim() || null, `take()` on the server treats an absent key as "leave it alone", so sending the whole object from
intro_text: intro.trim() || null, 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 = () => const add = () =>
send('add', async () => { 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 ( return (
<div className="flex size-full flex-col gap-4 overflow-auto p-5"> <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"> <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="flex items-start justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium">Hero</h3> <h3 className="text-sm font-medium">Intro</h3>
<p className="text-xs text-muted-foreground">The video or image across the top of the page.</p> <p className="text-xs text-muted-foreground">The paragraph under the hero.</p>
</div> </div>
{saved && ( {saved && (
<span className="flex shrink-0 items-center gap-1 text-xs text-success"> <span className="flex shrink-0 items-center gap-1 text-xs text-success">
@@ -155,6 +168,23 @@ export const HomePanel = () => {
)} )}
</div> </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 <Input
value={heroUrl} value={heroUrl}
onChange={(event) => setHeroUrl(event.target.value)} onChange={(event) => setHeroUrl(event.target.value)}
@@ -172,17 +202,11 @@ export const HomePanel = () => {
</p> </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>} {error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex justify-end border-t border-border pt-3"> <div className="flex justify-end border-t border-border pt-3">
<Button size="sm" onClick={() => void saveContent()} disabled={busy !== null || !contentDirty}> <Button size="sm" onClick={() => void saveHero()} disabled={busy !== null || !heroDirty}>
{busy === 'content' ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />} {busy === 'hero' ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
Save Save
</Button> </Button>
</div> </div>