a page's settings sit above the editor

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 09:09:35 +01:00
co-authored by Claude Opus 5
parent d2ac0aca1b
commit dfc22d2d2c
2 changed files with 152 additions and 0 deletions
+21
View File
@@ -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 (
<div className="flex size-full flex-col overflow-hidden">
{page && (
<div className="shrink-0 border-b border-border px-3 py-3">
<PageSettings page={page} />
</div>
)}
{/* Outside the scroll area, so a long editor cannot carry it off the screen. */}
<div className="shrink-0 border-b border-border px-3 py-2">
<DeviceToggle />
+131
View File
@@ -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<string | null>(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<Page>) => 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 (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs">Menu item</label>
<Input
value={draft.menu_label ?? ''}
placeholder="Not in the menu"
onChange={(event) => edit({ menu_label: event.currentTarget.value || null })}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs">Address</label>
{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.
<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={draft.slug} onChange={(event) => edit({ slug: event.currentTarget.value })} />
)}
</div>
<div className="flex flex-col gap-1">
<label className="text-xs">Page title</label>
<Input value={draft.title} onChange={(event) => edit({ title: event.currentTarget.value })} />
</div>
{saved && <p className="text-xs text-success">Saved</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
};