From 3ae8c960e575f073f6a0831ce4e86d5ec4e43cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 20:09:29 +0100 Subject: [PATCH] the Home section, built against the site's own admin API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hero, intro and an ordered gallery — the shape the old htmx admin had, as a panel here. Every write returns the WHOLE payload and 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, because a rectangle describing a region of a different picture would crop the new one somewhere arbitrary. A client keeping its own copy would go on showing a framing the server had already discarded. Ordering sends every id rather than one item's position, which is what makes it impossible to end up with two things claiming the same place. Thumbnails load through this plugin's proxy rather than straight from the dev server, so they arrive with the session instead of requiring the browser to reach a port on the host. It edits DEVELOPMENT, because production is a directory of static files with no API to write to. An edit lands on dev immediately and becomes public when somebody presses Build. Co-Authored-By: Claude Opus 5 --- web/DetailPanel.tsx | 2 + web/sections.ts | 2 +- web/sections/HomePanel.tsx | 283 +++++++++++++++++++++++++++++++++++++ 3 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 web/sections/HomePanel.tsx diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 3831a80..e2dfd62 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -2,6 +2,7 @@ import { useSearchParams } from 'react-router'; import { DEFAULT_SECTION, isSectionKey, sectionFor, sectionOpen, type SectionKey } from './sections'; import { AdministrationPanel } from './AdministrationPanel'; import { MediaPanel } from './MediaPanel'; +import { HomePanel } from './sections/HomePanel'; import { MessagesPanel } from './MessagesPanel'; import { PlannedSection } from './PlannedSection'; import { useReady } from './useReady'; @@ -38,6 +39,7 @@ import { useReady } from './useReady'; * silently showing somebody else's. */ const PANELS: Partial React.JSX.Element>> = { + home: HomePanel, messages: MessagesPanel, administration: AdministrationPanel, media: MediaPanel, diff --git a/web/sections.ts b/web/sections.ts index 5a1291e..5e59476 100644 --- a/web/sections.ts +++ b/web/sections.ts @@ -79,7 +79,7 @@ export const SECTIONS: Section[] = [ icon: Home, hint: 'The hero, the intro, and the gallery', path: '/fofos-admin/home', - built: false, + built: true, requires: 'connection', }, { diff --git a/web/sections/HomePanel.tsx b/web/sections/HomePanel.tsx new file mode 100644 index 0000000..9506c5f --- /dev/null +++ b/web/sections/HomePanel.tsx @@ -0,0 +1,283 @@ +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 { Textarea } from '@/components/ui/textarea'; +import { readableError } from '../errors'; +import { useFlash } from '../useFlash'; + +/** + * 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

+