the address follows the menu item, and Home stays put
Three changes to the page list. MENU ITEM, ADDRESS, 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 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. THE ADDRESS DERIVES FROM THE MENU ITEM, while the two still AGREE. "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 is the whole value. Renaming "Collabs" to "Collaborations" moves /collabs, which is what anybody would expect. Renaming "Exclusive Drops" does NOT move /drops — somebody chose that address, and every link to it would break silently. The rule tells those apart without being told. HOME HAS NO ARROWS, rather than two dead ones, and the first movable row cannot climb past it. A disabled control invites somebody to work out why; there is nothing to work out. The slug rule is a copy of the server's, and the kind 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 produces a tidier address, never a wrong one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+85
-25
@@ -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<Page>) =>
|
||||
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) => (
|
||||
<div key={page.id} className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex shrink-0 flex-col text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="leading-none hover:text-foreground disabled:opacity-30"
|
||||
onClick={() => void move(index, -1)}
|
||||
disabled={index === 0 || busy}
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="leading-none hover:text-foreground disabled:opacity-30"
|
||||
onClick={() => void move(index, 1)}
|
||||
disabled={index === draft.length - 1 || busy}
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</button>
|
||||
{/* 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. */}
|
||||
<div className="flex w-4 shrink-0 flex-col text-muted-foreground">
|
||||
{!page.is_home && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="leading-none hover:text-foreground disabled:opacity-30"
|
||||
onClick={() => void move(index, -1)}
|
||||
// index 1 is the first movable row: above it is Home.
|
||||
disabled={index <= 1 || busy}
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="leading-none hover:text-foreground disabled:opacity-30"
|
||||
onClick={() => void move(index, 1)}
|
||||
disabled={index === draft.length - 1 || busy}
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<label className="text-[10px] text-muted-foreground">Page title</label>
|
||||
<Input value={page.title} onChange={(event) => edit(page.id, { title: event.currentTarget.value })} />
|
||||
</div>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<label className="text-[10px] text-muted-foreground">Menu item</label>
|
||||
<Input
|
||||
@@ -201,6 +256,11 @@ export const PagesPanel = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<label className="text-[10px] text-muted-foreground">Page title</label>
|
||||
<Input value={page.title} onChange={(event) => edit(page.id, { title: event.currentTarget.value })} />
|
||||
</div>
|
||||
|
||||
{page.is_home ? (
|
||||
<div className="size-7 shrink-0" />
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user