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>
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { ArrowDown, ArrowUp, Check, ImagePlus, Loader2, Trash2 } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { readableError } from '../errors';
|
|
import { useFlash } from '../useFlash';
|
|
import { RichText } from '../RichText';
|
|
|
|
/**
|
|
* The Home page: a hero, an intro, and an ordered gallery.
|
|
*
|
|
* ── It edits DEVELOPMENT, and that is the whole model ──
|
|
*
|
|
* Every request here goes through this plugin to the development server's `/api/admin`. Production is
|
|
* a directory of static files with no API at all, so there is nothing to write to there — an edit
|
|
* lands on dev immediately and becomes public when somebody presses Build. Saying that once, here, is
|
|
* better than a person discovering it when a change does not appear on the live site.
|
|
*
|
|
* ── The server owns the shape, and every write returns it ──
|
|
*
|
|
* `PATCH /home` and every gallery route answer with the WHOLE payload, so this never patches its own
|
|
* state from what it sent. That matters more than it looks: the server clears the hero crop when the
|
|
* hero changes, and a client that kept its own copy would go on showing a framing the server had
|
|
* already discarded.
|
|
*
|
|
* ── Ordering is a list, not a field ──
|
|
*
|
|
* `PUT /gallery/order` takes every id in the order they should be. So moving one item sends all of
|
|
* them, which is what makes it impossible to end up with two things claiming the same position.
|
|
*/
|
|
|
|
type GalleryItem = {
|
|
id: number;
|
|
kind: string;
|
|
url: string;
|
|
crop: string | null;
|
|
fit: string | null;
|
|
sort_order: number;
|
|
alt: string | null;
|
|
};
|
|
|
|
type Home = {
|
|
hero: { url: string | null; crop: string | null; fit: string | null };
|
|
intro_text: string | null;
|
|
gallery: GalleryItem[];
|
|
};
|
|
|
|
export const HomePanel = () => {
|
|
const client = useClient();
|
|
const clientRef = useRef(client);
|
|
clientRef.current = client;
|
|
|
|
const [home, setHome] = useState<Home | null>(null);
|
|
const [heroUrl, setHeroUrl] = useState('');
|
|
const [intro, setIntro] = useState('');
|
|
const [addUrl, setAddUrl] = useState('');
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [saved, flashSaved] = useFlash();
|
|
|
|
/** Every write answers with the whole payload, so this is the only place state is set. */
|
|
const apply = useCallback((data: Home) => {
|
|
setHome(data);
|
|
setHeroUrl(data.hero.url ?? '');
|
|
setIntro(data.intro_text ?? '');
|
|
}, []);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const response = await clientRef.current.get<{ data: Home }>('/fofos-admin/home');
|
|
apply(response.data);
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
}
|
|
}, [apply]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const send = async (label: string, call: () => Promise<{ data: Home }>) => {
|
|
setBusy(label);
|
|
setError(null);
|
|
try {
|
|
apply((await call()).data);
|
|
flashSaved();
|
|
} catch (failure) {
|
|
setError(readableError(failure));
|
|
} finally {
|
|
setBusy(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 () => {
|
|
const response = await clientRef.current.post<{ data: Home }>('/fofos-admin/home/gallery', {
|
|
url: addUrl.trim(),
|
|
});
|
|
setAddUrl('');
|
|
return response;
|
|
});
|
|
|
|
const remove = (item: GalleryItem) =>
|
|
send(`del:${item.id}`, () => clientRef.current.delete<{ data: Home }>(`/fofos-admin/home/gallery/${item.id}`));
|
|
|
|
const setAlt = (item: GalleryItem, alt: string) =>
|
|
send(`alt:${item.id}`, () =>
|
|
clientRef.current.patch<{ data: Home }>(`/fofos-admin/home/gallery/${item.id}`, { alt: alt.trim() || null }),
|
|
);
|
|
|
|
/** Moving one sends them ALL, which is what makes two items claiming a position impossible. */
|
|
const move = (index: number, by: -1 | 1) => {
|
|
if (!home) return;
|
|
const next = [...home.gallery];
|
|
const target = index + by;
|
|
if (target < 0 || target >= next.length) return;
|
|
[next[index], next[target]] = [next[target]!, next[index]!];
|
|
return send('order', () =>
|
|
clientRef.current.put<{ data: Home }>('/fofos-admin/home/gallery/order', { ids: next.map((item) => item.id) }),
|
|
);
|
|
};
|
|
|
|
if (!home) {
|
|
return (
|
|
<div className="p-5 text-xs text-muted-foreground">
|
|
{error ?? 'Loading…'}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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">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">
|
|
<Check className="size-3.5" />
|
|
Saved
|
|
</span>
|
|
)}
|
|
</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)}
|
|
placeholder="/media/…"
|
|
className="font-mono text-xs"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
{/* The server clears the crop when the hero changes, because a rectangle describing a region of
|
|
a different picture would crop the new one somewhere arbitrary. Said here so it is not a
|
|
surprise. */}
|
|
{home.hero.crop && (
|
|
<p className="text-xs text-muted-foreground/70">
|
|
Framed at {home.hero.crop} — changing the hero clears that.
|
|
</p>
|
|
)}
|
|
|
|
{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 saveHero()} disabled={busy !== null || !heroDirty}>
|
|
{busy === 'hero' ? <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">Gallery</h3>
|
|
<p className="text-xs text-muted-foreground">
|
|
The carousel below the intro. {home.gallery.length} item{home.gallery.length === 1 ? '' : 's'}, in order.
|
|
</p>
|
|
</div>
|
|
|
|
<ul className="space-y-2">
|
|
{home.gallery.map((item, index) => (
|
|
<li key={item.id} className="flex flex-wrap items-center gap-2 rounded border border-border/60 p-2">
|
|
<span className="w-6 shrink-0 text-center font-mono text-xs text-muted-foreground/60">{index + 1}</span>
|
|
|
|
{/* Through the plugin's own proxy, so it loads with the session rather than needing the
|
|
dev server to be reachable from the browser. */}
|
|
{item.kind === 'photo' ? (
|
|
<img
|
|
src={`/api/fofos-admin${item.url}`}
|
|
alt=""
|
|
className="size-10 shrink-0 rounded object-cover"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<span className="flex size-10 shrink-0 items-center justify-center rounded bg-muted text-[10px]">
|
|
{item.kind}
|
|
</span>
|
|
)}
|
|
|
|
<span className="min-w-0 flex-1 space-y-1">
|
|
<span className="block truncate font-mono text-xs text-muted-foreground">{item.url}</span>
|
|
<Input
|
|
defaultValue={item.alt ?? ''}
|
|
onBlur={(event) => {
|
|
if (event.target.value.trim() !== (item.alt ?? '')) void setAlt(item, event.target.value);
|
|
}}
|
|
placeholder="Describe it, for screen readers"
|
|
className="h-7 text-xs"
|
|
/>
|
|
</span>
|
|
|
|
<span className="flex shrink-0 gap-1">
|
|
<Button variant="ghost" size="sm" onClick={() => void move(index, -1)} disabled={busy !== null || index === 0}>
|
|
<ArrowUp className="size-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => void move(index, 1)}
|
|
disabled={busy !== null || index === home.gallery.length - 1}
|
|
>
|
|
<ArrowDown className="size-3.5" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => void remove(item)} disabled={busy !== null}>
|
|
{busy === `del:${item.id}` ? (
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
) : (
|
|
<Trash2 className="size-3.5 text-destructive" />
|
|
)}
|
|
</Button>
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<div className="flex gap-2 border-t border-border pt-3">
|
|
<Input
|
|
value={addUrl}
|
|
onChange={(event) => setAddUrl(event.target.value)}
|
|
placeholder="/media/…"
|
|
className="min-w-0 flex-1 font-mono text-xs"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-28 shrink-0 justify-center"
|
|
onClick={() => void add()}
|
|
disabled={busy !== null || !addUrl.trim()}
|
|
>
|
|
{busy === 'add' ? <Loader2 className="size-3.5 animate-spin" /> : <ImagePlus className="size-3.5" />}
|
|
Add
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|