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>
291 lines
12 KiB
TypeScript
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>
|
|
);
|
|
};
|