Pages, with the pages under it in the nav

The section is real, and the nav draws the site's pages as rows beneath 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.

Drawn whether or not Pages is the open section — hiding the list until you click makes the nav a menu
of menus.

Three kinds of change, saved three different ways, and the split is deliberate. TEXT — a title, a menu
label, an address — is drafted and written by the one Save in the bar, because typing is something
people do halfway and change their minds about. ADDING, DELETING and REORDERING are immediate: each is
a single deliberate gesture with an obvious result, and holding them in a draft would put a list on
screen that does not exist yet, then leave Save to decide what order to apply three kinds of change in.

Only the rows that actually changed are sent, so a save touches what somebody edited and nothing else.
A failed save reloads from the server rather than leaving the typed values on screen, where they would
look saved.

Home's address and delete button are ABSENT, not disabled. A greyed control invites somebody to work
out why they cannot use it; there is nothing to work out, the front page is always `/`.

The two panels share the list through a channel rather than both fetching it. They are siblings and
neither can pass the other a prop, and two fetches means two lists that agree until somebody renames a
page. Selection stays in the URL — `?page=` — because that is addressable and the list is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-31 03:25:03 +01:00
co-authored by Claude Opus 5
parent dfe99d5232
commit a0f23d70bb
5 changed files with 349 additions and 8 deletions
+2
View File
@@ -6,6 +6,7 @@ import { AdministrationPanel } from './AdministrationPanel';
import { MediaPanel } from './MediaPanel'; import { MediaPanel } from './MediaPanel';
import { GalleriesPanel } from './sections/GalleriesPanel'; import { GalleriesPanel } from './sections/GalleriesPanel';
import { LayoutPanel } from './sections/LayoutPanel'; import { LayoutPanel } from './sections/LayoutPanel';
import { PagesPanel } from './sections/PagesPanel';
import { PublishButton } from './PublishButton'; import { PublishButton } from './PublishButton';
import { SaveTargetProvider, useSaveButton } from './SaveTarget'; import { SaveTargetProvider, useSaveButton } from './SaveTarget';
import { DEVICES, ViewModeProvider, useViewMode, type DeviceName, type Mode } from './ViewMode'; import { DEVICES, ViewModeProvider, useViewMode, type DeviceName, type Mode } from './ViewMode';
@@ -46,6 +47,7 @@ import { useReady } from './useReady';
*/ */
const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = { const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
layout: LayoutPanel, layout: LayoutPanel,
pages: PagesPanel,
galleries: GalleriesPanel, galleries: GalleriesPanel,
messages: MessagesPanel, messages: MessagesPanel,
administration: AdministrationPanel, administration: AdministrationPanel,
+48 -1
View File
@@ -2,6 +2,7 @@ import { Link, useSearchParams } from 'react-router';
import { Lock } from 'lucide-react'; import { Lock } from 'lucide-react';
import { DEFAULT_SECTION, SECTIONS, isSectionKey, sectionOpen, type SectionKey } from './sections'; import { DEFAULT_SECTION, SECTIONS, isSectionKey, sectionOpen, type SectionKey } from './sections';
import { useReady } from './useReady'; import { useReady } from './useReady';
import { usePages } from './usePages';
/** /**
* The left column: which part of the fofos site the detail panel is showing. * 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 = () => { export const NavPanel = () => {
const [params] = useSearchParams(); 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 raw = params.get('section');
const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION; const active: SectionKey = isSectionKey(raw) ? raw : DEFAULT_SECTION;
const { ready, rootSet } = useReady(); const { ready, rootSet } = useReady();
@@ -100,7 +105,7 @@ export const NavPanel = () => {
); );
} }
return ( const row = (
<Link <Link
key={section.key} key={section.key}
to={{ search: `?${search.toString()}` }} to={{ search: `?${search.toString()}` }}
@@ -110,6 +115,48 @@ export const NavPanel = () => {
{body} {body}
</Link> </Link>
); );
/*
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 (
<div key={section.key} className="flex flex-col">
{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 (
<Link
key={page.id}
to={{ search: `?${childSearch.toString()}` }}
replace
className={`flex items-baseline gap-2 border-b border-border py-1.5 pr-3 pl-9 text-left transition-colors ${
current ? 'bg-muted' : 'hover:bg-muted/50'
}`}
>
<span className="min-w-0 flex-1 truncate text-xs">{page.title}</span>
{/* 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. */}
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{page.path}</span>
</Link>
);
})}
</div>
);
}; };
return ( return (
+7 -7
View File
@@ -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 The nav draws its rows UNDER this one, so the site's pages are visible without opening it. That is
purpose: the nav says what is coming, which is a better answer than a nav that quietly lost seven the one place this admin has a hierarchy, and it earns it: a page is a thing you go to, and the
entries and offers no account of where they went. list of them is the site.
*/ */
key: 'pages', key: 'pages',
label: 'Pages', label: 'Pages',
icon: Files, icon: Files,
hint: 'Build a page from blocks, and choose where it sits', hint: 'Every page: what it is called, and where it lives',
path: null, path: '/fofos-admin/pages',
built: false, built: true,
requires: 'connection', requires: 'connection',
}, },
{ {
+249
View File
@@ -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<Page[] | null>(null);
const [draft, setDraft] = useState<Page[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<Page>) =>
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<unknown>) => {
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 <div className="p-4 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
return (
<div className="flex size-full flex-col gap-3 overflow-auto p-4">
{saved && <p className="shrink-0 text-xs text-success">Saved</p>}
{error && <p className="shrink-0 text-xs text-destructive">{error}</p>}
<div className="flex flex-col gap-2">
{draft.map((page, index) => (
<div
key={page.id}
onPointerDown={() => {
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'
}`}
>
<div className="flex items-center gap-2">
<div className="flex shrink-0 flex-col text-muted-foreground">
<button
type="button"
className="leading-none hover:text-foreground disabled:opacity-30"
onClick={() => void move(index, -1)}
disabled={index === 0 || busy}
title="Move up"
>
<ChevronUp className="size-3.5" />
</button>
<button
type="button"
className="leading-none hover:text-foreground disabled:opacity-30"
onClick={() => void move(index, 1)}
disabled={index === draft.length - 1 || busy}
title="Move down"
>
<ChevronDown className="size-3.5" />
</button>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<label className="text-[10px] text-muted-foreground">Page title</label>
<Input value={page.title} onChange={(event) => edit(page.id, { title: event.currentTarget.value })} />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<label className="text-[10px] text-muted-foreground">Menu item</label>
<Input
value={page.menu_label ?? ''}
placeholder="Not in the menu"
onChange={(event) => edit(page.id, { menu_label: event.currentTarget.value || null })}
/>
</div>
<div className="flex w-52 shrink-0 flex-col gap-1">
<label className="text-[10px] text-muted-foreground">Address</label>
{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.
<div className="flex h-9 items-center gap-1.5 rounded-md border border-input px-3 text-xs text-muted-foreground">
<Lock className="size-3" />/
</div>
) : (
<Input
value={page.slug}
onChange={(event) => 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"
/>
)}
</div>
{page.is_home ? (
<div className="size-7 shrink-0" />
) : (
<Button
size="sm"
variant="ghost"
className="size-7 shrink-0 p-0 text-muted-foreground hover:text-destructive"
onClick={() => void remove(page)}
disabled={busy}
title={`Delete ${page.title}`}
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
</div>
))}
</div>
<Button size="sm" variant="outline" className="self-start" onClick={() => void add()} disabled={busy}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
Add page
</Button>
</div>
);
};
+43
View File
@@ -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<Page[] | null>('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 };
};