import { useCallback, useEffect, useRef, useState } from 'react'; import { GripVertical, 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 { previewUrl } from '../thumbnail'; /** * The shell every page renders inside: the menu, the footer, the colours and the typefaces. * * ── Why this is the first section now ── * * The admin was one section per page — Home, About, Gallery, Drops — because the site's pages were a * constant in a file. They are becoming something somebody builds, so a nav that enumerates them is a * nav that has to be edited by a developer every time the client adds a page. Layout is what is left * once you take the pages out: the part of every page that is not the page. * * ── The preview is the point ── * * It draws the menu across the top, a body, and the footer at the bottom, in the chosen colours and * typefaces. That is the same principle the header editor already follows — the admin renders the * structure as the page will show it, so what is arranged is what is published. * * It is a SKETCH and says so: the real page has a logo, a rule, a specific measure. Drawing those here * would be a second implementation of the layout, and the two would drift the first time either * changed. What it is honest about is the thing being decided — order, wording, colour, typeface. */ type LayoutLink = { label: string; href: string }; type FooterColumn = { title: string; links: LayoutLink[] }; type Layout = { menu_items: LayoutLink[]; footer_columns: FooterColumn[]; footer_note: string | null; background: string | null; foreground: string | null; accent: string | null; font_body: string | null; font_sub: string | null; font_title: string | null; font_display: string | null; /** What the picker may offer — sent by the API so this file holds no third copy of the list. */ fonts: string[]; /** The pages that exist, for the menu's href suggestions. */ pages: { path: string; label: string }[]; }; /** The four roles styles.css declares. A template asks for a role; this decides which face plays it. */ const ROLES = [ { key: 'font_title' as const, label: 'Headings', hint: 'Page and section titles' }, { key: 'font_body' as const, label: 'Body', hint: 'Paragraphs and most text' }, { key: 'font_sub' as const, label: 'Menu', hint: 'The navigation and small labels' }, { key: 'font_display' as const, label: 'Display', hint: 'The largest statements' }, ]; const COLOURS = [ { key: 'background' as const, label: 'Background', fallback: '#ffffff' }, { key: 'foreground' as const, label: 'Text', fallback: '#000000' }, { key: 'accent' as const, label: 'Accent', fallback: '#000000' }, ]; export const LayoutPanel = () => { const client = useClient(); const clientRef = useRef(client); clientRef.current = client; const [stored, setStored] = useState(null); const [draft, setDraft] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [saved, flashSaved] = useFlash(); const apply = useCallback((data: Layout) => { setStored(data); setDraft(data); }, []); const load = useCallback(async () => { try { apply((await clientRef.current.get<{ data: Layout }>('/fofos-admin/layout')).data); } catch (failure) { setError(readableError(failure)); } }, [apply]); useEffect(() => { void load(); }, [load]); const patch = (values: Partial) => setDraft((was) => (was ? { ...was, ...values } : was)); /* Compared field by field rather than whole, because the payload carries two things this does not own: `fonts` and `pages` are the API telling the client what exists, not values anybody edits. Including them would be harmless today and wrong the first time a page is added — the panel would show unsaved changes for a page somebody else created. */ const WRITABLE = [ 'menu_items', 'footer_columns', 'footer_note', 'background', 'foreground', 'accent', 'font_body', 'font_sub', 'font_title', 'font_display', ] as const; const only = (source: Layout | null) => source ? JSON.stringify(Object.fromEntries(WRITABLE.map((key) => [key, source[key]]))) : null; const dirty = Boolean(stored && draft) && only(stored) !== only(draft); const save = async () => { if (!draft) return; setBusy(true); setError(null); try { const body = Object.fromEntries(WRITABLE.map((key) => [key, draft[key]])); apply((await clientRef.current.patch<{ data: Layout }>('/fofos-admin/layout', body)).data); flashSaved(); } catch (failure) { setError(readableError(failure)); } finally { setBusy(false); } }; useSaveTarget(dirty, busy, save); if (!draft) return
{error ?? 'Loading…'}
; const menu = draft.menu_items; const setMenu = (items: LayoutLink[]) => patch({ menu_items: items }); const columns = draft.footer_columns; const setColumns = (next: FooterColumn[]) => patch({ footer_columns: next }); /** Falls back to what the page itself falls back to, so the sketch is not a different design. */ const bg = draft.background ?? '#ffffff'; const fg = draft.foreground ?? '#000000'; return (
{saved &&

Saved

} {error &&

{error}

} {/* The REAL page, embedded — not a drawing of it. It was a React sketch: a centred row of labels, a body, a band at the bottom. It looked plausible and it was wrong, because the site's header has a logo hanging past a rule and a nav that sits in the content column. Worse, it was a second implementation of the layout, so it would have gone on being wrong differently every time either side changed. This is `/layout` on the development server — the site's own shell with no page in it — proxied through Officer so it is same origin. See the preview route in api/router.ts. */}