From dfc22d2d2cc366c1a78a58a84ece5a4b67a7ceab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 1 Sep 2026 09:09:35 +0100 Subject: [PATCH] a page's settings sit above the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menu item, address and page title — what the retired /fofos-admin/pages panel held, moved to the column where everything else about that page is edited. Above the device toggle, because the toggle scopes what is under it to one width and none of these three have widths. Read from the URL rather than announced: they belong to the page whatever is selected inside it, so picking a block must not replace them. A changed slug changes the page's own address, so the panel follows it — left alone the URL would name a page the list no longer holds. Co-Authored-By: Claude Opus 5 (1M context) --- web/EditorPanel.tsx | 21 ++++++ web/sections/PageSettings.tsx | 131 ++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 web/sections/PageSettings.tsx 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 ( +
+
+ + edit({ menu_label: event.currentTarget.value || null })} + /> +
+ +
+ + {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. +
+ / +
+ ) : ( + edit({ slug: event.currentTarget.value })} /> + )} +
+ +
+ + edit({ title: event.currentTarget.value })} /> +
+ + {saved &&

Saved

} + {error &&

{error}

} +
+ ); +};