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>
389 lines
16 KiB
TypeScript
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 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]))),
|
|
);
|
|
|
|
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>
|
|
);
|
|
};
|