Files
fofos-admin/web/sections/RecordsPanel.tsx
T
pastilhasandClaude Opus 5 15042ccc76 a gallery is a carousel
It always rendered as one — data-carousel, a snap track, two arrows, driven by
carousel.client.js. The entity was the last thing still calling it something
else, and that collided with the site's own Gallery section: two nav rows
reading Gallery and Galleries, meaning a list of objects and a list of
photograph sets.

GalleriesPanel, GalleryPicker and GalleryItems become Carousels*, the endpoint
becomes /fofos-admin/carousels, the block kind becomes carousel and
subject-gallery becomes subject-carousel. The publish allowlist follows the
renamed tables.

What did NOT move: GALLERY_CONFIG at /fofos-admin/gallery, subjects.ts's
base '/gallery', and home_gallery_media. Those are the Gallery section and the
legacy rows, and both are correct.

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

389 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 { useAutosave } from '../useAutosave';
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 CarouselsPanel 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]))),
);
useAutosave(dirty ? 'dirty' : null, busy !== null, save);
return (
<div className="flex size-full flex-col gap-4 overflow-auto p-4">
{/* 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>
);
};