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')}

+
+ +
+ +