Files
fofos-admin/web/sections/RecordsPanel.tsx
T
pastilhasandClaude Opus 5 abdb519e8b one Save, in the panel's bar
Every section had its own Save buttons — four on About, two on Home, one per record — and on a page
long enough to scroll, the button you wanted was the one that had just left the screen.

Now there is one, beside Publish, pinned above the scroll. The section announces whether it has unsaved
changes, whether it is busy, and how to save; the bar renders that. Neither knows what the other is,
which is what keeps DetailPanel a map of keys to components rather than a place that has to know what
each section can do.

── What this replaces ──

The per-card saves existed because the API treats an absent key as "leave it alone", so a Save carrying
everything meant a half-written manifesto shipping with a photograph somebody meant to change. With one
Save that is the model rather than an accident: one press writes the page you are looking at.

The cost, stated plainly: there is no longer a way to commit one part of a page and leave the rest.
Nothing here needed that, and if something ever does it wants its own verb rather than four buttons all
called Save.

── Dirty is a comparison, not a flag ──

Against what the server last returned, field by field. A flag says "something was typed" and stays true
after you undo it by hand; a comparison says "this differs from what is stored", which is the question
a Save button is actually answering — and it is what lets the button be dead when there is nothing to
do, which is the whole value of having one.

The gallery picker on Home is deliberately outside it and still writes on selection: a <select> that
needs a second press to mean anything is a control people believe they have already used.

Sections with nothing to save render no button rather than a dead one. A permanently disabled control
invites a press and teaches that pressing does nothing.

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

391 lines
16 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { ArrowDown, ArrowUp, FolderOpen, Loader2, Plus, Trash2 } from 'lucide-react';
import { useClient, getHeaders } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { readableError } from '../errors';
import { useFlash } from '../useFlash';
import { MediaPicker } from '../MediaPicker';
import { useSaveTarget } from '../SaveTarget';
import type { RecordsConfig } from './records';
/**
* A titled record with an ordered list of photographs — a gallery piece, a drop, a collab.
*
* One panel, three sections, mirroring the factory the site's API already is. See `records.ts` for
* why, and for what each section carries beyond the common fields.
*
* ── It edits DEVELOPMENT ──
*
* Like every other section here. An edit lands on the development server immediately and becomes
* public when somebody presses Publish; production is a directory of static files with no API to
* write to. Worth knowing before wondering why a change is not on the live site.
*
* ── Which record is open lives in the URL ──
*
* `?record=<id>`, per `docs/navigation-audit.md`: what is on screen is addressable, survives a reload
* and can be linked. It sits beside `?section=` rather than replacing it, because this panel is one
* screen of a workspace and the section is still what chose it.
*
* ── The server owns the shape ──
*
* Every write answers with the row or the whole media list, and nothing here patches its own state
* from what it sent. Reordering renumbers, deleting closes a gap, and creating appends — a client
* keeping its own copy drifts on the first of those with nothing to tell it.
*/
type MediaItem = { id: number; kind: string; url: string; crop: string | null; fit: string | null; sort_order: number };
type RecordRow = { id: number; title: string; sort_order: number } & Record<string, unknown>;
/**
* A thumbnail URL, authenticated by `?token=`.
*
* An `<img>` cannot send an Authorization header, which is why `user-middleware` and the permission
* gate both accept the token in the query string. The same thing GalleriesPanel and the file browser
* do — built without it first, and every picture 401s while the layout looks perfectly correct.
*/
const thumbUrl = (path: string): string => {
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
const encoded = path.split('/').map(encodeURIComponent).join('/');
return `/api/fofos-admin/derived/${encoded}/320.webp?token=${encodeURIComponent(token)}`;
};
const asText = (value: unknown): string => (typeof value === 'string' ? value : '');
export const RecordsPanel = ({ config }: { config: RecordsConfig }) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [rows, setRows] = useState<RecordRow[] | null>(null);
const [open, setOpen] = useState<RecordRow | null>(null);
const [openMedia, setOpenMedia] = useState<MediaItem[]>([]);
/** The form, separate from `open` so typing does not fight the server's copy. */
const [draft, setDraft] = useState<Record<string, string>>({});
const [title, setTitle] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [picking, setPicking] = useState(false);
const [saved, flashSaved] = useFlash();
const [params, setParams] = useSearchParams();
const openId = Number(params.get('record')) || null;
const load = useCallback(async () => {
try {
setRows((await clientRef.current.get<{ data: RecordRow[] }>(config.path)).data);
} catch (failure) {
setError(readableError(failure));
setRows([]);
}
}, [config.path]);
useEffect(() => {
void load();
}, [load]);
/*
The open record is loaded from its id, not carried over from the list.
The list is a summary and the detail route is the authority — and this is also what makes a typed
or reloaded `?record=7` work, where reading from the list would show nothing until the list had
arrived and then show a row that may be missing fields.
*/
useEffect(() => {
if (!openId) {
setOpen(null);
setOpenMedia([]);
return;
}
let live = true;
void (async () => {
try {
const [row, media] = await Promise.all([
clientRef.current.get<{ data: RecordRow }>(`${config.path}/${openId}`),
clientRef.current.get<{ data: MediaItem[] }>(`${config.path}/${openId}/media`),
]);
if (!live) return;
setOpen(row.data);
setOpenMedia(media.data);
const next: Record<string, string> = { title: asText(row.data.title) };
for (const field of config.fields) next[field.key] = asText(row.data[field.key]);
setDraft(next);
} catch (failure) {
if (live) setError(readableError(failure));
}
})();
// Guards against a slow response for a record nobody is looking at any more overwriting a fast
// one for the record they opened next.
return () => {
live = false;
};
}, [openId, config.path, config.fields]);
const select = (id: number | null) => {
const next = new URLSearchParams(params);
if (id === null) next.delete('record');
else next.set('record', String(id));
setParams(next, { replace: true });
};
const send = async (label: string, call: () => Promise<unknown>) => {
setBusy(label);
setError(null);
try {
await call();
flashSaved();
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(null);
}
};
const create = async () => {
const name = title.trim();
if (!name) return;
await send('create', async () => {
const made = await clientRef.current.post<{ data: RecordRow }>(config.path, { title: name });
setTitle('');
await load();
select(made.data.id);
});
};
const save = () =>
send('save', async () => {
if (!open) return;
// Only this record's own fields, and `title` always — the server treats an absent key as "leave
// it alone", so sending everything would be harmless but sending nothing would save nothing.
const body: Record<string, string | null> = { title: draft.title?.trim() || open.title };
for (const field of config.fields) body[field.key] = draft[field.key]?.trim() || null;
const row = await clientRef.current.patch<{ data: RecordRow }>(`${config.path}/${open.id}`, body);
setOpen(row.data);
await load();
});
const remove = async (id: number) => {
await send(`delete:${id}`, async () => {
await clientRef.current.delete(`${config.path}/${id}`);
if (openId === id) select(null);
await load();
});
};
/** MediaPicker hands over one path at a time. The server appends, so each lands at the end. */
const addMedia = async (path: string) => {
if (!open) return;
await send('media', async () => {
await clientRef.current.post(`${config.path}/${open.id}/media`, { url: path, kind: 'photo' });
setOpenMedia((await clientRef.current.get<{ data: MediaItem[] }>(`${config.path}/${open.id}/media`)).data);
});
};
const removeMedia = (mediaId: number) =>
send(`media:${mediaId}`, async () => {
if (!open) return;
await clientRef.current.delete(`${config.path}/${open.id}/media/${mediaId}`);
setOpenMedia((await clientRef.current.get<{ data: MediaItem[] }>(`${config.path}/${open.id}/media`)).data);
});
/** Every id, in order — which is what makes two items claiming a position impossible. */
const move = (index: number, by: number) => {
if (!open) return;
const next = [...openMedia];
const target = index + by;
if (target < 0 || target >= next.length) return;
[next[index], next[target]] = [next[target]!, next[index]!];
setOpenMedia(next);
return send('order', async () => {
const result = await clientRef.current.put<{ data: MediaItem[] }>(
`${config.path}/${open.id}/media/order`,
{ ids: next.map((item) => item.id) },
);
setOpenMedia(result.data);
});
};
const field = (key: string, value: string) => setDraft((current) => ({ ...current, [key]: value }));
/*
Dirty is the open record's form against what the server last returned for it.
Only the fields this section declares, so a column the API carries and this panel does not show
cannot make a record look edited forever. With no record open there is nothing to save and the bar
renders no button at all.
*/
const dirty = Boolean(
open &&
(asText(draft.title) !== asText(open.title) ||
config.fields.some((f) => asText(draft[f.key]) !== asText(open[f.key]))),
);
useSaveTarget(dirty, busy !== null, save);
return (
<div className="flex size-full flex-col gap-4 overflow-auto p-4">
<p className="text-xs text-muted-foreground">{config.blurb}</p>
{/* The list, and the one field that makes another. A record is created with a title alone and
filled in afterwards — asking for six fields before anything exists is a form nobody
finishes. */}
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<div className="flex gap-2">
<Input
value={title}
placeholder={`New ${config.singular}`}
onChange={(event) => setTitle(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void create();
}}
/>
<Button size="sm" className="shrink-0" onClick={() => void create()} disabled={busy !== null || !title.trim()}>
{busy === 'create' ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
Add
</Button>
</div>
{rows === null ? (
<p className="text-xs text-muted-foreground">Loading…</p>
) : rows.length === 0 ? (
<p className="text-xs text-muted-foreground">Nothing here yet.</p>
) : (
<ul className="flex flex-col">
{rows.map((row) => (
<li key={row.id} className="flex items-center gap-2 border-b border-border/60 py-1.5 last:border-0">
{/* A button rather than a <Link>: the target is a query parameter on the page already
open, so there is no document to navigate to and nothing to middle-click into. */}
<button
type="button"
className={`min-w-0 flex-1 truncate text-left text-sm ${openId === row.id ? 'font-medium' : ''}`}
onClick={() => select(openId === row.id ? null : row.id)}
>
{row.title}
</button>
<Button
variant="ghost"
size="sm"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => void remove(row.id)}
disabled={busy !== null}
title={`Delete this ${config.singular}`}
>
{busy === `delete:${row.id}` ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
</Button>
</li>
))}
</ul>
)}
</section>
{open && (
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">{config.titleLabel}</label>
<Input value={draft.title ?? ''} onChange={(event) => field('title', event.currentTarget.value)} />
</div>
{config.fields.map((spec) => (
<div key={spec.key} className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">{spec.label}</label>
{spec.long ? (
<textarea
className="min-h-20 rounded-md border border-input bg-transparent px-3 py-2 text-sm"
value={draft[spec.key] ?? ''}
placeholder={spec.hint}
onChange={(event) => field(spec.key, event.currentTarget.value)}
/>
) : (
<Input
value={draft[spec.key] ?? ''}
placeholder={spec.hint}
onChange={(event) => field(spec.key, event.currentTarget.value)}
/>
)}
</div>
))}
{/* Confirmation only. The Save itself is in the panel's bar, where it stays reachable on a
record with six fields. */}
{saved && <p className="text-xs text-success">Saved</p>}
{/* The photographs, in the order the page shows them. */}
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{openMedia.length === 0
? 'No photographs yet.'
: `${openMedia.length} photograph${openMedia.length === 1 ? '' : 's'}`}
</p>
<Button variant="outline" size="sm" onClick={() => setPicking(true)} disabled={busy !== null}>
<FolderOpen className="size-3.5" />
Add
</Button>
</div>
{openMedia.length > 0 && (
<ul className="flex flex-col gap-2">
{openMedia.map((item, index) => (
<li key={item.id} className="flex items-center gap-3 rounded border border-border/60 p-2">
<img
src={thumbUrl(item.url)}
alt=""
className="size-14 shrink-0 rounded object-cover"
loading="lazy"
/>
<span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">{item.url}</span>
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => void move(index, -1)}
disabled={busy !== null || index === 0}
>
<ArrowUp className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => void move(index, 1)}
disabled={busy !== null || index === openMedia.length - 1}
>
<ArrowDown className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => void removeMedia(item.id)}
disabled={busy !== null}
>
<Trash2 className="size-3.5" />
</Button>
</li>
))}
</ul>
)}
</div>
</section>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
<MediaPicker
open={picking}
onClose={() => setPicking(false)}
onPick={(path) => {
setPicking(false);
void addMedia(path);
}}
/>
</div>
);
};