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}

} +
+ ); +};