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:
+42
-18
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user