Files
fofos-admin/web/sections/PagesPanel.tsx
T
pastilhasandClaude Opus 5 a4c77c642f a selected page opens the builder, not a highlighted row
The nav already said it: Pages is a section, and the pages are rows under it. Two things, two screens.
The detail panel was showing one screen with a row lit up, which made the list the answer to both
questions.

  ?section=pages              the list — what exists, what it is called, where it lives, in what order
  ?section=pages&page=4       that page — what is on it

Both addressable, which is why the distinction is a query parameter rather than state.

Pressing PAGES itself now clears `page`. Without that the section row and the row for whichever page
was last open lead to the same screen and there is no way back to the list but the browser's back
button. Scoped exactly like `media`, and for the same reason: a parameter belonging to one section
should not ride along into the others.

The list stops having a selection — selecting a page leaves the screen entirely — so its rows are
plain rows again and the panel no longer reads or writes the URL at all.

Adding a page deliberately does NOT open it. The next thing anybody does is name it, and the name lives
on the list; jumping into the builder would mean coming straight back.

SitePreview is now shared, lifted out of LayoutPanel with the dev-port lookup beside it. Layout passes
the theme it is editing; a page passes nothing and just gets its own page. The builder shows the real
page at its real address today, which is what the blocks will be judged against when they exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 03:30:44 +01:00

230 lines
8.7 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 { useSaveTarget } from '../SaveTarget';
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".
*/
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) => (row.id === id ? { ...row, ...patch } : row)));
/** 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);
}
};
useSaveTarget(dirty, 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;
if (to < 0 || 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">
<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>
</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>
<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>
{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>
);
};