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=`, 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; /** * A thumbnail URL, authenticated by `?token=`. * * An `` 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(null); const [open, setOpen] = useState(null); const [openMedia, setOpenMedia] = useState([]); /** The form, separate from `open` so typing does not fight the server's copy. */ const [draft, setDraft] = useState>({}); const [title, setTitle] = useState(''); const [busy, setBusy] = useState(null); const [error, setError] = useState(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 = { 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) => { 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 = { 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 (
{/* 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. */}
setTitle(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === 'Enter') void create(); }} />
{rows === null ? (

Loading…

) : rows.length === 0 ? (

Nothing here yet.

) : (
    {rows.map((row) => (
  • {/* A button rather than a : 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. */}
  • ))}
)}
{open && (
field('title', event.currentTarget.value)} />
{config.fields.map((spec) => (
{spec.long ? (