diff --git a/web/sections/PagesPanel.tsx b/web/sections/PagesPanel.tsx index 6499375..107fa24 100644 --- a/web/sections/PagesPanel.tsx +++ b/web/sections/PagesPanel.tsx @@ -28,6 +28,24 @@ import { usePages, type Page } from '../usePages'; * 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". */ +/** + * The same slug rule the server applies, so the field can fill in as you type. + * + * A copy, and the kind that is worth having: the alternative is a request per keystroke to be told what + * the address will be. It is only ever a SUGGESTION — the server slugifies again on the way in, so a + * disagreement here produces a tidier address, never a wrong one. + * + * Kept in step with `slugify` in the site's src/servers/api/admin/pages.ts. + */ +const slugify = (value: string): string => + value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + export const PagesPanel = () => { const client = useClient(); const clientRef = useRef(client); @@ -65,7 +83,33 @@ export const PagesPanel = () => { }, [load]); const edit = (id: number, patch: Partial) => - setDraft((rows) => rows.map((row) => (row.id === id ? { ...row, ...patch } : row))); + setDraft((rows) => + rows.map((row) => { + if (row.id !== id) return row; + const next = { ...row, ...patch }; + + /* + The address follows the menu item — but only while the two still AGREE. + + "Derive it, unless it was changed after" needs to know whether it was changed, and there is no + such flag in the database. There does not need to be: an address that still equals what the + menu item would produce has plainly not been given one of its own, and one that differs + plainly has. + + That distinction matters more than it sounds. Renaming "Collabs" to "Collaborations" should + move /collabs; renaming "Exclusive Drops" must NOT move /drops, because somebody chose that + address and every link to it would break silently. The rule tells those two apart without + being told. + + Home is exempt: its address is always `/` and there is nothing to derive. + */ + if ('menu_label' in patch && !row.is_home && row.slug === slugify(row.menu_label ?? '')) { + next.slug = slugify(next.menu_label ?? '') || next.slug; + } + + return next; + }), + ); /** Only what actually changed, so a save touches the rows somebody edited and no others. */ const changed = () => @@ -129,7 +173,9 @@ export const PagesPanel = () => { const move = (index: number, by: number) => { const to = index + by; - if (to < 0 || to >= draft.length) return; + // 1, not 0: Home holds the first place and nothing may take it. The server pins it too — this is + // the half that keeps the arrow from doing something the save would then undo. + if (to < 1 || to >= draft.length) return; const ids = draft.map((row) => row.id); const [id] = ids.splice(index, 1); ids.splice(to, 0, id!); @@ -147,32 +193,41 @@ export const PagesPanel = () => { {draft.map((page, index) => (
-
- - + {/* Home has no arrows at all rather than two dead ones. It is always first, and a + disabled control invites somebody to work out why. */} +
+ {!page.is_home && ( + <> + + + + )}
-
- - edit(page.id, { title: event.currentTarget.value })} /> -
+ {/* + Menu item, then address, then page title — 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 page title is last because it is the one that usually stays as it is: + it is the browser tab and the search result, not anything on the screen. + */}
{ )}
+
+ + edit(page.id, { title: event.currentTarget.value })} /> +
+ {page.is_home ? (
) : (