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(null); const [heroUrl, setHeroUrl] = useState(''); const [intro, setIntro] = useState(''); const [addUrl, setAddUrl] = useState(''); const [busy, setBusy] = useState(null); const [error, setError] = useState(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); } }; const saveContent = () => send('content', () => clientRef.current.patch<{ data: Home }>('/fofos-admin/home', { hero_url: heroUrl.trim() || null, intro_text: intro.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 (
{error ?? 'Loading…'}
); } const contentDirty = heroUrl.trim() !== (home.hero.url ?? '') || intro.trim() !== (home.intro_text ?? ''); return (

Hero

The video or image across the top of the page.

{saved && ( Saved )}
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 && (

Framed at {home.hero.crop} — changing the hero clears that.

)}

Intro

{/* HTML in and HTML out — it is what the site's database already holds and renders. */}
{error &&

{error}

}

Gallery

The carousel below the intro. {home.gallery.length} item{home.gallery.length === 1 ? '' : 's'}, in order.

    {home.gallery.map((item, index) => (
  • {index + 1} {/* 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' ? ( ) : ( {item.kind} )} {item.url} { if (event.target.value.trim() !== (item.alt ?? '')) void setAlt(item, event.target.value); }} placeholder="Describe it, for screen readers" className="h-7 text-xs" />
  • ))}
setAddUrl(event.target.value)} placeholder="/media/…" className="min-w-0 flex-1 font-mono text-xs" autoComplete="off" spellCheck={false} />
); };