import { useCallback, useEffect, useRef, useState } from 'react'; import { ChevronDown, ChevronUp, Loader2, Lock, Plus, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { useClient } from 'hooks/useClient'; import { readableError } from '../errors'; import { useFlash } from '../useFlash'; import { useSaveTarget } from '../SaveTarget'; import { usePages, type Page } from '../usePages'; /** * The site's pages: what they are called, where they live, and in what order. * * ── Three kinds of change, and they are not saved the same way ── * * The TEXT of a page — its title, its menu label, its address — is drafted and written by the one Save * in the bar, like every other section. Typing is a thing people do halfway and change their minds * about, and a field that wrote on every keystroke would fill the site with half-typed page names. * * ADDING, DELETING and REORDERING are immediate. Each is a single deliberate gesture with an obvious * result, and holding them in a draft would mean a list on screen that does not exist yet — then a * Save that has to decide what order to apply three kinds of change in, which is a bug waiting to be * written. * * ── Home ── * * Its address is fixed and it cannot be deleted, so those two controls are absent rather than disabled * for it. Its title is a field like any other: that is what the browser tab says, and it is the field * most likely to be wrong on a site whose front page is currently called "Home". */ export const PagesPanel = () => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [stored, setStored] = useState(null); const [draft, setDraft] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [saved, flashSaved] = useFlash(); const { announce } = usePages(); const apply = useCallback( (rows: Page[]) => { setStored(rows); setDraft(rows); // The nav draws these too, and it is a sibling panel — so the list is announced rather than // fetched twice and left to drift. announce(rows); }, [announce], ); const load = useCallback(async () => { try { apply((await clientRef.current.get<{ data: Page[] }>('/fofos-admin/pages')).data); } catch (failure) { setError(readableError(failure)); } }, [apply]); useEffect(() => { void load(); }, [load]); const edit = (id: number, patch: Partial) => setDraft((rows) => rows.map((row) => (row.id === id ? { ...row, ...patch } : row))); /** Only what actually changed, so a save touches the rows somebody edited and no others. */ const changed = () => draft.filter((row) => { const was = stored?.find((other) => other.id === row.id); return was && (was.title !== row.title || was.menu_label !== row.menu_label || was.slug !== row.slug); }); const dirty = Boolean(stored) && changed().length > 0; const save = async () => { setBusy(true); setError(null); try { for (const row of changed()) { await clientRef.current.patch(`/fofos-admin/pages/${row.id}`, { title: row.title, menu_label: row.menu_label, // Home's is dropped by the API, so it costs nothing to send and keeps one code path here. slug: row.slug, }); } await load(); flashSaved(); } catch (failure) { setError(readableError(failure)); // Back to what the server has. A failed save that left the fields as typed would look saved. await load(); } finally { setBusy(false); } }; useSaveTarget(dirty, busy, save); const act = async (run: () => Promise) => { setBusy(true); setError(null); try { await run(); await load(); } catch (failure) { setError(readableError(failure)); } finally { setBusy(false); } }; const add = () => act(async () => { const created = await clientRef.current.post<{ data: Page }>('/fofos-admin/pages', { title: 'New page' }); // Deliberately does NOT open the new page. The next thing anybody does is name it, and the // name lives on this screen — jumping into the builder would mean coming straight back. void created; }); const remove = (page: Page) => act(async () => { await clientRef.current.delete(`/fofos-admin/pages/${page.id}`); }); const move = (index: number, by: number) => { const to = index + by; if (to < 0 || to >= draft.length) return; const ids = draft.map((row) => row.id); const [id] = ids.splice(index, 1); ids.splice(to, 0, id!); return act(() => clientRef.current.put('/fofos-admin/pages/order', { ids })); }; if (!stored) return
{error ?? 'Loading…'}
; return (
{saved &&

Saved

} {error &&

{error}

}
{draft.map((page, index) => (
edit(page.id, { title: event.currentTarget.value })} />
edit(page.id, { menu_label: event.currentTarget.value || null })} />
{page.is_home ? ( // Absent rather than disabled. The front page is always `/`, and a greyed field // invites a person to work out why they cannot use it.
/
) : ( edit(page.id, { slug: event.currentTarget.value })} // Said, not enforced: the server cleans it, and a field that fights while you type // is worse than one that tidies up after you. placeholder="about-us" /> )}
{page.is_home ? (
) : ( )}
))}
); };