From 60eb6ecfc9a20f0cda4e3be129ea3776641a22ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 4 Sep 2026 14:03:54 +0100 Subject: [PATCH] a place to manage each collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collections, above Galleries and Media, where the other things made of media already are. There is no Create button and no Delete, and that is the design rather than an omission. Which collections exist is a question about `originals/COLLECTIONS/`, so making one means making a folder — in Media or in Finder, and both work. A Delete here would either drop the row and leave the photographs, so the collection reappears on the next read, or delete somebody's pictures. Neither is what Delete should mean in an admin. The grid is for choosing the MAIN photo and nothing else. The photographs are already in filename order and the gallery is the folder, so there is nothing to reorder and nothing to add — the one decision is which picture leads. Pressing the chosen one again clears it, which is how "no choice, use the first" stays reachable without a second control that only does that. The badge says Main rather than Cover because it has to be true of the first photograph, which leads by default, as well as of one somebody picked. The fields are the other half — everything a directory cannot tell you: the name it goes by, the address, and the specs from the client's sitemap. `folder` is shown and not editable, and the API refuses to write it: it is an address, and renaming one moves files. Shown anyway, because it is the only thing that says WHICH folder a collection is. One draft, one save. Per-field writes would be eight requests for somebody filling in a form, and the last to land would win in an order nobody controls. Co-Authored-By: Claude Opus 5 (1M context) --- web/DetailPanel.tsx | 2 + web/sections.ts | 27 +++- web/sections/CollectionFields.tsx | 114 +++++++++++++++++ web/sections/CollectionsPanel.tsx | 204 ++++++++++++++++++++++++++++++ web/sections/collections.ts | 19 +++ 5 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 web/sections/CollectionFields.tsx create mode 100644 web/sections/CollectionsPanel.tsx create mode 100644 web/sections/collections.ts diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index fa4a39f..9f11f52 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -5,6 +5,7 @@ import { DEFAULT_SECTION } from './sections'; import { sectionFor, sectionOpen, type SectionKey } from './sections'; import { AdministrationPanel } from './AdministrationPanel'; import { MediaPanel } from './MediaPanel'; +import { CollectionsPanel } from './sections/CollectionsPanel'; import { GalleriesPanel } from './sections/GalleriesPanel'; import { LayoutPanel } from './sections/LayoutPanel'; import { PageBuilderPanel } from './sections/PageBuilderPanel'; @@ -46,6 +47,7 @@ import { useReady } from './useReady'; */ const PANELS: Partial React.JSX.Element>> = { layout: LayoutPanel, + collections: CollectionsPanel, galleries: GalleriesPanel, messages: MessagesPanel, administration: AdministrationPanel, diff --git a/web/sections.ts b/web/sections.ts index 13a58d4..d76004f 100644 --- a/web/sections.ts +++ b/web/sections.ts @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react'; -import { Images, Mail, Cog, FolderOpen, PanelsTopLeft, Files } from 'lucide-react'; +import { Boxes, Cog, Files, FolderOpen, Images, Mail, PanelsTopLeft } from 'lucide-react'; /** * The sections of the fofos admin, in nav order. @@ -36,7 +36,14 @@ import { Images, Mail, Cog, FolderOpen, PanelsTopLeft, Files } from 'lucide-reac * the nav and what actually renders cannot disagree. */ -export type SectionKey = 'administration' | 'galleries' | 'media' | 'messages' | 'layout' | 'pages'; +export type SectionKey = + | 'administration' + | 'collections' + | 'galleries' + | 'media' + | 'messages' + | 'layout' + | 'pages'; export type Section = { key: SectionKey; @@ -125,6 +132,22 @@ export const SECTIONS: Section[] = [ this section is useless without the development server answering, and opening it there would be a screen where every action fails. */ + key: 'collections', + label: 'Collections', + icon: Boxes, + hint: 'A folder of photographs, and what is said about it', + path: '/fofos-admin/collections', + built: true, + /* + Same reason as Galleries: a collection is a ROW read through the site's admin API, so this is + useless without the development server answering. What is different is that the row is only half + of it — which collections EXIST is a question about `originals/COLLECTIONS/`, and the folders are + registered as this list is read. + */ + requires: 'connection', + pinned: true, + }, + { key: 'galleries', label: 'Galleries', icon: Images, diff --git a/web/sections/CollectionFields.tsx b/web/sections/CollectionFields.tsx new file mode 100644 index 0000000..b79e56c --- /dev/null +++ b/web/sections/CollectionFields.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from 'react'; +import { useClient } from 'hooks/useClient'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { useAutosave } from '../useAutosave'; +import type { Collection } from './collections'; + +/** + * What somebody says about a collection, in the editor column. + * + * ── Everything here is what a FOLDER cannot tell you ── + * + * Which photographs are in a collection is a question about `originals/COLLECTIONS//`, answered + * by reading it. This is the other half: the name it goes by, the address it lives at, and the specs + * from the client's sitemap. None of it can be derived, so all of it is typed. + * + * `folder` is shown and not editable. It is an address — renaming it moves files — and the API refuses + * to write it for the same reason. Showing it anyway matters: it is the only thing that says WHICH + * folder a collection is, and somebody looking at two similarly named ones needs to be able to tell. + * + * ── One draft, one save ── + * + * Every field writes into one draft object and `useAutosave` sends it whole. Per-field saves would be + * eight requests for somebody filling in a form, and the last one to land would win in an order nobody + * controls. + */ + +/** The fields, in the order the client's sitemap lists them. */ +const SPECS: [keyof Collection, string, string][] = [ + ['descriptive_text', 'Descriptive text', 'What this collection is'], + ['measurement', 'Measurement', '45cm x 45cm'], + ['material', 'Raw material', 'Acrylic yarn'], + ['colors', 'Colours', ''], + ['delivery_time', 'Delivery time', '4-6 weeks'], +]; + +export const CollectionFields = ({ + collection, + onSaved, +}: { + collection: Collection; + onSaved: () => void; +}) => { + const client = useClient(); + const [draft, setDraft] = useState>({}); + + // Keyed on the ID, not the values: keyed on the values it would fight the typing, pulling every + // keystroke back to the last saved one until the save landed. + useEffect(() => setDraft({}), [collection.id]); + + const value = (key: keyof Collection): string => + (draft[key] as string | null | undefined) ?? (collection[key] as string | null) ?? ''; + + const dirty = Object.keys(draft).length > 0; + + useAutosave(dirty ? JSON.stringify(draft) : null, false, async () => { + await client.patch(`/fofos-admin/collections/${collection.id}`, draft); + setDraft({}); + onSaved(); + }); + + const set = (key: keyof Collection) => (next: string) => setDraft((was) => ({ ...was, [key]: next })); + + return ( +
+
+ + set('title')(event.target.value)} /> +
+ +
+ + set('slug')(event.target.value)} /> +

/collections/{value('slug')}

+
+ +
+ +