Gallery, Drops and Collabs
Three sections, one panel, configured — the same split the site's API already makes. Each is a titled record with an ordered list of media hanging off it, and the only real differences are what the title is called and which extra fields the record carries. The htmx admin had three screens and that was defensible there, because each carried its own markup as well. There is no markup to differ here, so three panels would be three places to fix one ordering bug. The `fields` list in records.ts is the FORM for what the server allows, not a second authority over it. What may actually be written is decided by `valuesFrom` in the site's sections.ts, per section, as an allowlist — a key added here that the server does not accept is an input that silently saves nothing. Which record is open lives in `?record=<id>` beside `?section=`, per docs/navigation-audit.md, and the detail is loaded from that id rather than carried over from the list. That is what makes a typed or reloaded URL work, and it is guarded against a slow response for a record nobody is looking at any more overwriting a fast one for the record they opened next. Ordering sends every id in the order it should be, so two items cannot claim one position. Media is added one path at a time because the server appends, and a parallel add would land in whatever order the requests happened to finish. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@ import { AdministrationPanel } from './AdministrationPanel';
|
||||
import { MediaPanel } from './MediaPanel';
|
||||
import { HomePanel } from './sections/HomePanel';
|
||||
import { GalleriesPanel } from './sections/GalleriesPanel';
|
||||
import { RecordsPanel } from './sections/RecordsPanel';
|
||||
import { COLLABS_CONFIG, DROPS_CONFIG, GALLERY_CONFIG } from './sections/records';
|
||||
import { MessagesPanel } from './MessagesPanel';
|
||||
import { PlannedSection } from './PlannedSection';
|
||||
import { useReady } from './useReady';
|
||||
@@ -41,6 +43,13 @@ import { useReady } from './useReady';
|
||||
*/
|
||||
const PANELS: Partial<Record<SectionKey, () => React.JSX.Element>> = {
|
||||
home: HomePanel,
|
||||
/*
|
||||
Three sections, one panel, configured — the same split the site's API already makes between
|
||||
`records.ts` and `sections.ts`. See web/sections/records.ts for what each one carries.
|
||||
*/
|
||||
gallery: () => <RecordsPanel config={GALLERY_CONFIG} />,
|
||||
drops: () => <RecordsPanel config={DROPS_CONFIG} />,
|
||||
collabs: () => <RecordsPanel config={COLLABS_CONFIG} />,
|
||||
galleries: GalleriesPanel,
|
||||
messages: MessagesPanel,
|
||||
administration: AdministrationPanel,
|
||||
|
||||
+3
-3
@@ -107,7 +107,7 @@ export const SECTIONS: Section[] = [
|
||||
icon: Images,
|
||||
hint: 'Standalone pieces',
|
||||
path: '/fofos-admin/gallery',
|
||||
built: false,
|
||||
built: true,
|
||||
requires: 'connection',
|
||||
},
|
||||
{
|
||||
@@ -116,7 +116,7 @@ export const SECTIONS: Section[] = [
|
||||
icon: Handshake,
|
||||
hint: 'Projects with other people',
|
||||
path: '/fofos-admin/collabs',
|
||||
built: false,
|
||||
built: true,
|
||||
requires: 'connection',
|
||||
},
|
||||
{
|
||||
@@ -125,7 +125,7 @@ export const SECTIONS: Section[] = [
|
||||
icon: Sparkles,
|
||||
hint: 'Releases',
|
||||
path: '/fofos-admin/drops',
|
||||
built: false,
|
||||
built: true,
|
||||
requires: 'connection',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
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 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 }));
|
||||
|
||||
return (
|
||||
<div className="flex 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>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => void save()} disabled={busy !== null}>
|
||||
{busy === 'save' ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
{saved && <span className="text-xs text-success">Saved</span>}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Gallery pieces, drops and collabs, as configurations of one panel.
|
||||
*
|
||||
* ── Why a configuration and not three screens ──
|
||||
*
|
||||
* The site's API already made this decision. `src/servers/api/admin/records.ts` is a factory and
|
||||
* these three sections are configurations of it, because each is a titled record with an ordered list
|
||||
* of media hanging off it and the only real differences are what the title is called and which extra
|
||||
* fields the record carries.
|
||||
*
|
||||
* The htmx admin did have three screens — `src/apps/admin/{gallery,drops,collabs}/routes.tsx` are
|
||||
* three copies of one file with the words changed — and that was defensible there, because each also
|
||||
* carried its own markup. There is no markup to differ here either, so three panels would be three
|
||||
* places to fix the same ordering bug.
|
||||
*
|
||||
* ── `fields` mirrors the server's allowlist, and only mirrors it ──
|
||||
*
|
||||
* What may actually be written is decided by `valuesFrom` in the site's `sections.ts`, per section,
|
||||
* as an allowlist. This list is the FORM for those fields, not a second authority over them: adding a
|
||||
* key here that the server does not accept produces an input that silently saves nothing. When they
|
||||
* disagree, the server is right.
|
||||
*/
|
||||
|
||||
export type FieldSpec = {
|
||||
/** The wire name, snake_case, exactly as the server's allowlist spells it. */
|
||||
key: string;
|
||||
label: string;
|
||||
/** A textarea rather than a single line. For prose that runs to a paragraph. */
|
||||
long?: boolean;
|
||||
/** Placeholder text, where the label alone leaves the intent unclear. */
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export type RecordsConfig = {
|
||||
/** The plugin route, which the catch-all forwards to the site's `/api/admin/<same>`. */
|
||||
path: string;
|
||||
/** "piece", used in buttons and empty states. Lower case; the UI capitalises where it needs to. */
|
||||
singular: string;
|
||||
/** What this section calls its title. Collabs say "Project name" and that is the client's word. */
|
||||
titleLabel: string;
|
||||
/** Shown once at the top, so a screen explains itself without a manual. */
|
||||
blurb: string;
|
||||
fields: FieldSpec[];
|
||||
};
|
||||
|
||||
export const GALLERY_CONFIG: RecordsConfig = {
|
||||
path: '/fofos-admin/gallery',
|
||||
singular: 'piece',
|
||||
titleLabel: 'Title',
|
||||
blurb: 'Standalone pieces, each with its own page and its own photographs.',
|
||||
fields: [
|
||||
{ key: 'descriptive_text', label: 'Description', long: true },
|
||||
{ key: 'measurement', label: 'Measurements', hint: '120 × 80 cm' },
|
||||
{ key: 'material', label: 'Material', hint: 'Wool, cotton backing' },
|
||||
{ key: 'colors', label: 'Colours' },
|
||||
{ key: 'production_notes', label: 'Production notes', long: true },
|
||||
{ key: 'shipping_notes', label: 'Shipping notes', long: true },
|
||||
],
|
||||
};
|
||||
|
||||
export const DROPS_CONFIG: RecordsConfig = {
|
||||
path: '/fofos-admin/drops',
|
||||
singular: 'drop',
|
||||
titleLabel: 'Title',
|
||||
blurb: 'Releases, in the order they should appear.',
|
||||
fields: [{ key: 'description', label: 'Description', long: true }],
|
||||
};
|
||||
|
||||
/**
|
||||
* Collabs call their title `project_name`, and it stays that way.
|
||||
*
|
||||
* The server publishes it under `title` as well so a generic client can read one field, and accepts
|
||||
* `title` as an alias when creating. So this panel writes `title` like the other two and the column
|
||||
* written is still project_name — see the note in the site's sections.ts.
|
||||
*/
|
||||
export const COLLABS_CONFIG: RecordsConfig = {
|
||||
path: '/fofos-admin/collabs',
|
||||
singular: 'collab',
|
||||
titleLabel: 'Project name',
|
||||
blurb: 'Projects with other people.',
|
||||
fields: [{ key: 'project_description', label: 'Description', long: true }],
|
||||
};
|
||||
Reference in New Issue
Block a user