diff --git a/web/EditorPanel.tsx b/web/EditorPanel.tsx
index 350ad80..dbb7437 100644
--- a/web/EditorPanel.tsx
+++ b/web/EditorPanel.tsx
@@ -1,4 +1,7 @@
import { DeviceToggle } from './DeviceToggle';
+import { PageSettings } from './sections/PageSettings';
+import { useNav } from './routes';
+import { pageFromParam, usePages } from './usePages';
import { useEditorSlot } from './useEditorSlot';
/**
@@ -29,8 +32,26 @@ import { useEditorSlot } from './useEditorSlot';
export const EditorPanel = () => {
const [editor] = useEditorSlot();
+ /*
+ The selected page's own settings, above everything.
+
+ Read here rather than announced like the editor below it, because they are not the editor: they
+ belong to the page whatever is selected inside it, and a block being picked must not replace them.
+ Above the device toggle, because the toggle scopes what is under it to one width and none of these
+ three have widths.
+ */
+ const { section, item } = useNav();
+ const { pages } = usePages();
+ const page = section === 'pages' ? pageFromParam(pages, item) : null;
+
return (
+ {page && (
+
+
+
+ )}
+
{/* Outside the scroll area, so a long editor cannot carry it off the screen. */}
diff --git a/web/sections/PageSettings.tsx b/web/sections/PageSettings.tsx
new file mode 100644
index 0000000..972733a
--- /dev/null
+++ b/web/sections/PageSettings.tsx
@@ -0,0 +1,131 @@
+import { useEffect, useRef, useState } from 'react';
+import { useNavigate } from 'react-router';
+import { Lock } from 'lucide-react';
+import { Input } from '@/components/ui/input';
+import { useClient } from 'hooks/useClient';
+import { readableError } from '../errors';
+import { useAutosave } from '../useAutosave';
+import { useFlash } from '../useFlash';
+import { useFofosChanged } from '../channel';
+import { pageParam, usePages, type Page } from '../usePages';
+import { href } from '../routes';
+
+/**
+ * What a page IS: its menu item, its address, its title.
+ *
+ * ── Why it is here and not on a page of its own ──
+ *
+ * It was a list of cards at `/fofos-admin/pages` — one per page, three fields each, beside a nav that
+ * already listed the same pages. Two lists of the same thing, and the card you were editing was never
+ * the row you had clicked.
+ *
+ * These are properties of the page you are looking at, so they belong in the column where everything
+ * else about it is edited. Above the device toggle, deliberately: the toggle scopes the settings under
+ * it to one width, and none of these three have widths.
+ *
+ * ── The order is the order somebody fills them in ──
+ *
+ * The menu item is what you type first and the address falls out of it, so they sit together. The
+ * title is last because it usually stays as it is — it is the browser tab and the search result, not
+ * anything on the screen.
+ */
+export const PageSettings = ({ page }: { page: Page }) => {
+ const client = useClient();
+ const clientRef = useRef(client);
+ clientRef.current = client;
+
+ const { announce, pages } = usePages();
+ // Read at save time rather than captured: the list can move while a form is open.
+ const pagesRef = useRef(pages);
+ pagesRef.current = pages;
+ const navigate = useNavigate();
+ const [, bump] = useFofosChanged();
+ const [draft, setDraft] = useState(page);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [saved, flashSaved] = useFlash();
+
+ // A different page selected is a different form. Without this, clicking the next row in the nav
+ // would show the previous page's words in the fields.
+ useEffect(() => setDraft(page), [page]);
+
+ const edit = (values: Partial) => setDraft((was) => ({ ...was, ...values }));
+
+ const writable = (row: Page) =>
+ JSON.stringify({ title: row.title, slug: row.slug, menu_label: row.menu_label });
+ const dirty = writable(draft) !== writable(page);
+
+ const save = async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ const { data } = await clientRef.current.patch<{ data: Page }>(`/fofos-admin/pages/${draft.id}`, {
+ title: draft.title,
+ menu_label: draft.menu_label,
+ // Home's is dropped by the API, so it costs nothing to send and keeps one code path here.
+ slug: draft.slug,
+ });
+
+ // One page comes back, not the list. Spliced in, so the nav renames itself without a second read.
+ const list = (pagesRef.current ?? []).map((row) => (row.id === data.id ? data : row));
+ announce(list);
+ // The menu is the site's, so every preview is showing the old one.
+ bump();
+ flashSaved();
+
+ /*
+ A changed slug changes the page's own ADDRESS, and the URL still names the old one.
+
+ Left alone the panel would empty: `pageFromParam` looks the slug up in the list, the list now
+ holds the new one, and nothing matches. `replace`, because the old address no longer exists and
+ Back should not offer it.
+
+ The server slugifies, so this also lands you on what it actually stored rather than what was
+ typed.
+ */
+ if (pageParam(data) !== pageParam(draft)) navigate(href('pages', pageParam(data)), { replace: true });
+ } catch (failure) {
+ setError(readableError(failure));
+ // Back to what the server has. A failed save that left the fields as typed would look saved.
+ setDraft(pages?.find((row) => row.id === draft.id) ?? page);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ useAutosave(dirty ? writable(draft) : null, busy, save);
+
+ return (
+
+
+ {draft.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.
+