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

+