diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index f06aa38..c584914 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -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 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: () => , + drops: () => , + collabs: () => , galleries: GalleriesPanel, messages: MessagesPanel, administration: AdministrationPanel, diff --git a/web/sections.ts b/web/sections.ts index bdd6fc8..1e486e3 100644 --- a/web/sections.ts +++ b/web/sections.ts @@ -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', }, { diff --git a/web/sections/RecordsPanel.tsx b/web/sections/RecordsPanel.tsx new file mode 100644 index 0000000..89472ce --- /dev/null +++ b/web/sections/RecordsPanel.tsx @@ -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=`, 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 })); + + return ( +
+

{config.blurb}

+ + {/* 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 ? ( +