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'; /** * 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 sketch ───────────────────────────────────────────────────────────────────────── */}
{/* The menu, in the order it will appear, in the face chosen for it. */}
{/* The menu when there is one, the site's own pages when there is not — the same fallback the page itself applies, so the sketch never shows a menu the site would not draw. */} {(menu.length ? menu : draft.pages.map((page) => ({ label: page.label, href: page.path }))).map( (item, index) => ( {item.label} ), )} {!menu.length && !draft.pages.length && No menu yet}
{/* The body. Deliberately empty: what goes here is the page, and the page is what this section is NOT about. Its only job is to give the two bands something between them. */}

The page

Whatever is built for it goes here

{/* The footer, with the columns as they will fall. */}
{columns.length ? (
{columns.map((column, index) => (
{column.title && (

{column.title}

)} {column.links.map((link, i) => (

{link.label}

))}
))}
) : (

No footer columns yet

)} {draft.footer_note &&

{draft.footer_note}

}
{/* ── Menu ─────────────────────────────────────────────────────────────────────────────── */}

Menu

{!menu.length && ( Showing the site’s own pages )}
{menu.map((item, index) => ( setMenu(menu.map((was, i) => (i === index ? next : was)))} onRemove={() => setMenu(menu.filter((_, i) => i !== index))} onMove={(by) => setMenu(moved(menu, index, by))} first={index === 0} last={index === menu.length - 1} /> ))}
{/* Start from the site's own pages rather than from nothing. The menu falls back to them until somebody sets one, so the first edit is almost always "the pages, with one change". Typing all eight back in to make that change is the kind of small cruelty that makes people leave a panel alone. */} {!menu.length && draft.pages.length > 0 && ( )}
{/* ── Footer ───────────────────────────────────────────────────────────────────────────── */}

Footer

{columns.map((column, index) => (
setColumns(columns.map((was, i) => (i === index ? { ...was, title: event.currentTarget.value } : was))) } />
{column.links.map((link, i) => ( setColumns( columns.map((was, c) => c === index ? { ...was, links: was.links.map((l, li) => (li === i ? next : l)) } : was, ), ) } onRemove={() => setColumns( columns.map((was, c) => (c === index ? { ...was, links: was.links.filter((_, li) => li !== i) } : was)), ) } onMove={(by) => setColumns(columns.map((was, c) => (c === index ? { ...was, links: moved(was.links, i, by) } : was))) } first={i === 0} last={i === column.links.length - 1} /> ))}
))}
patch({ footer_note: event.currentTarget.value || null })} />
{/* ── Colours ──────────────────────────────────────────────────────────────────────────── */}

Colours

{COLOURS.map(({ key, label, fallback }) => (
{/* The browser's own picker. It sits on the LEFT of a wide row here, which is the whole reason it works: the same control at the right-hand edge of this panel opened its popup off the side of the window. See OverlayEditor for the version that had to move. */} patch({ [key]: event.currentTarget.value } as Partial)} className="size-8 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5" title={label} />
{label}
))}
{/* ── Typefaces ────────────────────────────────────────────────────────────────────────── */}

Typefaces

{ROLES.map(({ key, label, hint }) => (
{hint}
))}
); }; /** Reordering, which is the one thing a menu needs more than editing. */ const moved = (items: T[], from: number, by: number): T[] => { const to = from + by; if (to < 0 || to >= items.length) return items; const next = [...items]; const [item] = next.splice(from, 1); next.splice(to, 0, item!); return next; }; /** * One link: what it says, and where it goes. * * The href is a text field with the site's own pages offered beside it rather than a dropdown of * them. A menu item can point at an anchor, an external address, or a page that does not exist yet — * and while a site is being built the last of those is the common case, so a control that could only * express "one of the pages that already exist" would be in the way most of the time. */ const LinkRow = ({ item, pages, onChange, onRemove, onMove, first, last, }: { item: LayoutLink; pages: { path: string; label: string }[]; onChange: (item: LayoutLink) => void; onRemove: () => void; onMove: (by: number) => void; first: boolean; last: boolean; }) => { const known = pages.some((page) => page.path === item.href); return (
onChange({ ...item, label: event.currentTarget.value })} />
onChange({ ...item, href: event.currentTarget.value })} /> {/* Said rather than enforced. An unknown href is usually a page not built yet, not a mistake, and refusing it would make this panel unusable during exactly the phase it is for. */} {item.href && !known && !item.href.startsWith('http') && !item.href.startsWith('#') && ( no page there yet )}
{pages.map((page) => ( ))}
); };