Files
pastilhasandClaude Opus 5 b5ca67e68e every control writes itself
The Save button is gone, and so is the SaveTarget context that announced it.
Every screen here is a preview of the thing being edited, so a change is already
visible before it is written — a button then asks somebody to confirm what they
can see.

useAutosave debounces a burst into one request. It takes the unsaved state as a
string rather than a flag, so a failed save is not retried on the next render,
and it flushes on unmount so a section change loses nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 07:34:34 +01:00

291 lines
12 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronUp, Loader2, Lock, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useClient } from 'hooks/useClient';
import { readableError } from '../errors';
import { useFlash } from '../useFlash';
import { useAutosave } from '../useAutosave';
import { usePages, type Page } from '../usePages';
/**
* The site's pages: what they are called, where they live, and in what order.
*
* ── Three kinds of change, and they are not saved the same way ──
*
* The TEXT of a page — its title, its menu label, its address — is drafted and written by the one Save
* in the bar, like every other section. Typing is a thing people do halfway and change their minds
* about, and a field that wrote on every keystroke would fill the site with half-typed page names.
*
* ADDING, DELETING and REORDERING are immediate. Each is a single deliberate gesture with an obvious
* result, and holding them in a draft would mean a list on screen that does not exist yet — then a
* Save that has to decide what order to apply three kinds of change in, which is a bug waiting to be
* written.
*
* ── Home ──
*
* Its address is fixed and it cannot be deleted, so those two controls are absent rather than disabled
* 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);
clientRef.current = client;
const [stored, setStored] = useState<Page[] | null>(null);
const [draft, setDraft] = useState<Page[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, flashSaved] = useFlash();
const { announce } = usePages();
const apply = useCallback(
(rows: Page[]) => {
setStored(rows);
setDraft(rows);
// The nav draws these too, and it is a sibling panel — so the list is announced rather than
// fetched twice and left to drift.
announce(rows);
},
[announce],
);
const load = useCallback(async () => {
try {
apply((await clientRef.current.get<{ data: Page[] }>('/fofos-admin/pages')).data);
} catch (failure) {
setError(readableError(failure));
}
}, [apply]);
useEffect(() => {
void load();
}, [load]);
const edit = (id: number, patch: Partial<Page>) =>
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 = () =>
draft.filter((row) => {
const was = stored?.find((other) => other.id === row.id);
return was && (was.title !== row.title || was.menu_label !== row.menu_label || was.slug !== row.slug);
});
const dirty = Boolean(stored) && changed().length > 0;
const save = async () => {
setBusy(true);
setError(null);
try {
for (const row of changed()) {
await clientRef.current.patch(`/fofos-admin/pages/${row.id}`, {
title: row.title,
menu_label: row.menu_label,
// Home's is dropped by the API, so it costs nothing to send and keeps one code path here.
slug: row.slug,
});
}
await load();
flashSaved();
} catch (failure) {
setError(readableError(failure));
// Back to what the server has. A failed save that left the fields as typed would look saved.
await load();
} finally {
setBusy(false);
}
};
// The rows that differ, serialised — so one set of edits is attempted once. See useAutosave.
useAutosave(dirty ? JSON.stringify(changed()) : null, busy, save);
const act = async (run: () => Promise<unknown>) => {
setBusy(true);
setError(null);
try {
await run();
await load();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const add = () =>
act(async () => {
const created = await clientRef.current.post<{ data: Page }>('/fofos-admin/pages', { title: 'New page' });
// Deliberately does NOT open the new page. The next thing anybody does is name it, and the
// name lives on this screen — jumping into the builder would mean coming straight back.
void created;
});
const remove = (page: Page) =>
act(async () => {
await clientRef.current.delete(`/fofos-admin/pages/${page.id}`);
});
const move = (index: number, by: number) => {
const to = index + by;
// 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!);
return act(() => clientRef.current.put('/fofos-admin/pages/order', { ids }));
};
if (!stored) return <div className="p-4 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
return (
<div className="flex size-full flex-col gap-3 overflow-auto p-4">
{saved && <p className="shrink-0 text-xs text-success">Saved</p>}
{error && <p className="shrink-0 text-xs text-destructive">{error}</p>}
<div className="flex flex-col gap-2">
{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">
{/* 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>
{/*
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
value={page.menu_label ?? ''}
placeholder="Not in the menu"
onChange={(event) => edit(page.id, { menu_label: event.currentTarget.value || null })}
/>
</div>
<div className="flex w-52 shrink-0 flex-col gap-1">
<label className="text-[10px] text-muted-foreground">Address</label>
{page.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={page.slug}
onChange={(event) => edit(page.id, { slug: event.currentTarget.value })}
// Said, not enforced: the server cleans it, and a field that fights while you type
// is worse than one that tidies up after you.
placeholder="about-us"
/>
)}
</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" />
) : (
<Button
size="sm"
variant="ghost"
className="size-7 shrink-0 p-0 text-muted-foreground hover:text-destructive"
onClick={() => void remove(page)}
disabled={busy}
title={`Delete ${page.title}`}
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
</div>
))}
</div>
<Button size="sm" variant="outline" className="self-start" onClick={() => void add()} disabled={busy}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
Add page
</Button>
</div>
);
};