diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index cadfcf8..2adb103 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -6,6 +6,7 @@ import { AdministrationPanel } from './AdministrationPanel'; import { MediaPanel } from './MediaPanel'; import { GalleriesPanel } from './sections/GalleriesPanel'; import { LayoutPanel } from './sections/LayoutPanel'; +import { PagesPanel } from './sections/PagesPanel'; import { PublishButton } from './PublishButton'; import { SaveTargetProvider, useSaveButton } from './SaveTarget'; import { DEVICES, ViewModeProvider, useViewMode, type DeviceName, type Mode } from './ViewMode'; @@ -46,6 +47,7 @@ import { useReady } from './useReady'; */ const PANELS: Partial React.JSX.Element>> = { layout: LayoutPanel, + pages: PagesPanel, galleries: GalleriesPanel, messages: MessagesPanel, administration: AdministrationPanel, diff --git a/web/NavPanel.tsx b/web/NavPanel.tsx index cf801f6..37d0332 100644 --- a/web/NavPanel.tsx +++ b/web/NavPanel.tsx @@ -2,6 +2,7 @@ import { Link, useSearchParams } from 'react-router'; import { Lock } from 'lucide-react'; import { DEFAULT_SECTION, SECTIONS, isSectionKey, sectionOpen, type SectionKey } from './sections'; import { useReady } from './useReady'; +import { usePages } from './usePages'; /** * The left column: which part of the fofos site the detail panel is showing. @@ -49,6 +50,10 @@ const rowClass = (active: boolean) => export const NavPanel = () => { const [params] = useSearchParams(); + // Announced by PagesPanel rather than fetched here — see usePages. Null until it has spoken, which + // is why nothing is drawn instead of an empty list. + const { pages } = usePages(); + const selectedPage = Number(params.get('page')) || null; const raw = params.get('section'); const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION; const { ready, rootSet } = useReady(); @@ -100,7 +105,7 @@ export const NavPanel = () => { ); } - return ( + const row = ( { {body} ); + + /* + The site's pages, as rows under Pages. + + Indented and quieter than a section, because they are not sections — they are the contents of + one. The indent is a left border rather than padding alone, so the relationship survives a row + wrapping onto two lines. + + Drawn whether or not Pages is the open section. The list of pages IS the site, and hiding it + until you click makes the nav a menu of menus. + */ + if (section.key !== 'pages' || !pages?.length) return row; + + return ( +
+ {row} + {pages.map((page) => { + const childSearch = new URLSearchParams(params); + childSearch.set('section', 'pages'); + childSearch.set('page', String(page.id)); + childSearch.delete('media'); + + const current = active === 'pages' && selectedPage === page.id; + + return ( + + {page.title} + {/* The address, because two pages can be called nearly the same thing and this is what + tells them apart. Home's is `/`, which is short enough not to need explaining. */} + {page.path} + + ); + })} +
+ ); }; return ( diff --git a/web/sections.ts b/web/sections.ts index 85f6b77..e171cbd 100644 --- a/web/sections.ts +++ b/web/sections.ts @@ -89,18 +89,18 @@ export const SECTIONS: Section[] = [ }, { /* - The pages, built rather than hard-coded. Not started. + The pages, built rather than hard-coded — what replaced the seven per-page sections. - This is what replaced the seven per-page sections that used to be here. Listed while unbuilt on - purpose: the nav says what is coming, which is a better answer than a nav that quietly lost seven - entries and offers no account of where they went. + The nav draws its rows UNDER this one, so the site's pages are visible without opening it. That is + the one place this admin has a hierarchy, and it earns it: a page is a thing you go to, and the + list of them is the site. */ key: 'pages', label: 'Pages', icon: Files, - hint: 'Build a page from blocks, and choose where it sits', - path: null, - built: false, + hint: 'Every page: what it is called, and where it lives', + path: '/fofos-admin/pages', + built: true, requires: 'connection', }, { diff --git a/web/sections/PagesPanel.tsx b/web/sections/PagesPanel.tsx new file mode 100644 index 0000000..21b1661 --- /dev/null +++ b/web/sections/PagesPanel.tsx @@ -0,0 +1,249 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ChevronDown, ChevronUp, Loader2, Lock, Plus, Trash2 } from 'lucide-react'; +import { useSearchParams } from 'react-router'; +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 [params, setParams] = useSearchParams(); + const selected = Number(params.get('page')) || null; + + 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' }); + // Selected on arrival: a new row at the bottom of a long list is easy to lose, and the next + // thing anybody does is rename it. + const next = new URLSearchParams(params); + next.set('page', String(created.data.id)); + setParams(next, { replace: true }); + }); + + const remove = (page: Page) => + act(async () => { + await clientRef.current.delete(`/fofos-admin/pages/${page.id}`); + if (selected === page.id) { + const next = new URLSearchParams(params); + next.delete('page'); + setParams(next, { replace: true }); + } + }); + + 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) => ( +
{ + const next = new URLSearchParams(params); + next.set('page', String(page.id)); + setParams(next, { replace: true }); + }} + className={`flex flex-col gap-2 rounded-lg border p-3 ${ + selected === page.id ? 'border-primary' : 'border-border' + }`} + > +
+
+ + +
+ +
+ + 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 ? ( +
+ ) : ( + + )} +
+
+ ))} +
+ + +
+ ); +}; diff --git a/web/usePages.ts b/web/usePages.ts new file mode 100644 index 0000000..2e4f693 --- /dev/null +++ b/web/usePages.ts @@ -0,0 +1,43 @@ +import { useCallback, useRef } from 'react'; +import { defineChannel } from 'hooks/usePanelChannel'; + +/** A page, as the site's admin API describes it. */ +export type Page = { + id: number; + slug: string; + /** `/` for Home, `/about` for the rest — built by the server so nothing here reasons about slugs. */ + path: string; + title: string; + menu_label: string | null; + position: number; + is_home: boolean; +}; + +/** + * The page list, shared between the two panels. + * + * The nav draws the pages as rows under Pages, and the editor owns them — and they are SIBLINGS in a + * workspace, so neither can pass the other a prop. Both fetching would give two lists that agree until + * somebody renames a page, at which point the nav keeps the old name until something reloads it. + * + * So the editor announces what it has and the nav listens. A channel is the right tool for this and + * the wrong one for SELECTION: which page is open lives in the URL, per docs/navigation-audit.md. + * This carries data, not state. + * + * Null means nobody has said yet — the nav then shows no children rather than an empty list, which + * would read as "this site has no pages". + */ +const usePagesList = defineChannel('fofos-admin:pages', null); + +export const usePages = () => { + const [pages, setPages] = usePagesList(); + + // The setter is a fresh closure every render and `announce` goes into dependency lists, so it is + // read through a ref — stable identity without freezing the first render's setter. Same reasoning + // as channel.ts. + const setRef = useRef(setPages); + setRef.current = setPages; + const announce = useCallback((rows: Page[] | null) => setRef.current(rows), []); + + return { pages, announce }; +};