diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 9f11f52..0859704 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -6,6 +6,7 @@ import { sectionFor, sectionOpen, type SectionKey } from './sections'; import { AdministrationPanel } from './AdministrationPanel'; import { MediaPanel } from './MediaPanel'; import { CollectionsPanel } from './sections/CollectionsPanel'; +import { PiecesPanel } from './sections/PiecesPanel'; import { GalleriesPanel } from './sections/GalleriesPanel'; import { LayoutPanel } from './sections/LayoutPanel'; import { PageBuilderPanel } from './sections/PageBuilderPanel'; @@ -48,6 +49,7 @@ import { useReady } from './useReady'; const PANELS: Partial React.JSX.Element>> = { layout: LayoutPanel, collections: CollectionsPanel, + pieces: PiecesPanel, galleries: GalleriesPanel, messages: MessagesPanel, administration: AdministrationPanel, diff --git a/web/sections.ts b/web/sections.ts index d76004f..edebb6d 100644 --- a/web/sections.ts +++ b/web/sections.ts @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react'; -import { Boxes, Cog, Files, FolderOpen, Images, Mail, PanelsTopLeft } from 'lucide-react'; +import { Boxes, Cog, Files, FolderOpen, Images, Mail, PanelsTopLeft, Shapes } from 'lucide-react'; /** * The sections of the fofos admin, in nav order. @@ -39,6 +39,7 @@ import { Boxes, Cog, Files, FolderOpen, Images, Mail, PanelsTopLeft } from 'luci export type SectionKey = | 'administration' | 'collections' + | 'pieces' | 'galleries' | 'media' | 'messages' @@ -147,6 +148,29 @@ export const SECTIONS: Section[] = [ requires: 'connection', pinned: true, }, + { + /* + The Gallery's pieces. + + Called Pieces, not Gallery. The section on the site is Gallery and its items are peças, and there + is already a `galleries` section here meaning something else entirely — a named set of media. Two + nav rows called Gallery and Galleries, meaning a list of objects and a list of photograph sets, + is a question somebody would have to answer every time they looked at it. + + Below Collections because it is the same object one level flatter, and it shares its panel — see + sections/subjects.ts. + */ + key: 'pieces', + label: 'Pieces', + icon: Shapes, + hint: 'One object each, shown in the Gallery', + path: '/fofos-admin/pieces', + built: true, + // A piece is a ROW read through the site's admin API, so this is useless without the development + // server answering. Same as Collections above it. + requires: 'connection', + pinned: true, + }, { key: 'galleries', label: 'Galleries', diff --git a/web/sections/AddComponentDialog.tsx b/web/sections/AddComponentDialog.tsx index 20cc6c4..59d827e 100644 --- a/web/sections/AddComponentDialog.tsx +++ b/web/sections/AddComponentDialog.tsx @@ -30,11 +30,12 @@ export type ComponentKind = | 'contact-form' | 'partners' | 'collections' - | 'collection-cover' - | 'collection-title' - | 'collection-description' - | 'collection-specs' - | 'collection-gallery'; + | 'pieces' + | 'subject-cover' + | 'subject-title' + | 'subject-description' + | 'subject-specs' + | 'subject-gallery'; /* The blocks that draw whatever the page is ABOUT. @@ -43,11 +44,11 @@ export type ComponentKind = block: one that is available, adds cleanly, and silently does nothing. */ export const SUBJECT_KINDS: ComponentKind[] = [ - 'collection-cover', - 'collection-title', - 'collection-description', - 'collection-specs', - 'collection-gallery', + 'subject-cover', + 'subject-title', + 'subject-description', + 'subject-specs', + 'subject-gallery', ]; type Choice = { @@ -91,18 +92,23 @@ export const CHOICES: Choice[] = [ { kind: 'contact-form', label: 'Contact form', hint: 'The site\'s form, as it is', Icon: Mail }, { kind: 'partners', label: 'Partners', hint: 'The partner logos, from Site', Icon: Handshake }, { kind: 'collections', label: 'Collections', hint: 'Every collection, as a grid', Icon: LayoutGrid }, + { kind: 'pieces', label: 'Pieces', hint: 'Every gallery piece, as a grid', Icon: LayoutGrid }, /* - The four that draw THIS collection, on the template. + The five that draw whatever the page is ABOUT, on a template. They hold nothing — not even a reference. The subject is the URL, so one block draws a different collection on every request, and that is what lets one arrangement serve all of them. + + Called `subject-` rather than `collection-`: the same five draw a gallery piece, and the labels say + "it" for that reason. What differs between the sections is a path and a few spec labels, and both + travel with the subject. */ - { kind: 'collection-cover', label: 'Its lead photograph', hint: 'The first in its gallery, large', Icon: Image }, - { kind: 'collection-title', label: 'Its name', hint: 'The collection this page is about', Icon: Heading }, - { kind: 'collection-description', label: 'Its description', hint: 'What was written about it', Icon: Type }, - { kind: 'collection-specs', label: 'Its specs', hint: 'Measurement, material, colours, delivery', Icon: Ruler }, - { kind: 'collection-gallery', label: 'Its photographs', hint: 'The gallery it points at', Icon: Images }, + { kind: 'subject-cover', label: 'Its lead photograph', hint: 'The first in its gallery, large', Icon: Image }, + { kind: 'subject-title', label: 'Its name', hint: 'What this page is about', Icon: Heading }, + { kind: 'subject-description', label: 'Its description', hint: 'What was written about it', Icon: Type }, + { kind: 'subject-specs', label: 'Its specs', hint: 'Measurement, material, colours, and the rest', Icon: Ruler }, + { kind: 'subject-gallery', label: 'Its photographs', hint: 'The gallery it points at', Icon: Images }, ]; export const AddComponentDialog = ({ diff --git a/web/sections/CollectionsPanel.tsx b/web/sections/CollectionsPanel.tsx index 9764c9b..1b39fde 100644 --- a/web/sections/CollectionsPanel.tsx +++ b/web/sections/CollectionsPanel.tsx @@ -1,301 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { useSearchParams } from 'react-router'; -import { Boxes, Images, Loader2, Plus, Trash2 } from 'lucide-react'; -import { useClient } from 'hooks/useClient'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { GalleryPicker } from '../GalleryPicker'; -import { readableError } from '../errors'; -import { useEditorSlot } from '../useEditorSlot'; -import { thumbUrl } from '../thumbnail'; -import { CollectionFields } from './CollectionFields'; -import type { Collection, CollectionPhoto } from './collections'; +import { SubjectPanel } from './SubjectPanel'; +import { COLLECTIONS } from './subjects'; -/** - * Collections — one folder of photographs each, and what is said about it. - * - * ── The list is the DISK ── - * - * Which collections exist is a question about `originals/COLLECTIONS/`, not about a table. The API - * registers every folder as it reads them, so a collection appears here the moment its folder does and - * there is no "create" button: making one means making a folder, in Media or in Finder, and both work. - * - * Deleting is the same in reverse and is deliberately not offered. A button here would either remove a - * row and leave the photographs — so the collection reappears on the next read — or delete somebody's - * pictures. Neither is what a Delete button in an admin should mean. - * - * ── The grid is for choosing the COVER ── - * - * The photographs are already ordered by their filenames and the gallery is the folder, so there is - * nothing to reorder and nothing to add. The one decision is which picture leads, and that is a click. - */ - -export const CollectionsPanel = () => { - const client = useClient(); - const clientRef = useRef(client); - clientRef.current = client; - - const [collections, setCollections] = useState(null); - const [photos, setPhotos] = useState(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [name, setName] = useState(''); - const [picking, setPicking] = useState(false); - - // Selection in the URL, like every other screen here — see docs/navigation-audit.md. - const [params, setParams] = useSearchParams(); - const selectedId = Number(params.get('collection')) || null; - const selected = collections?.find((one) => one.id === selectedId) ?? null; - - const load = useCallback(async () => { - try { - const response = await clientRef.current.get<{ data: Collection[] }>('/fofos-admin/collections'); - setCollections(response.data); - } catch (failure) { - setError(readableError(failure)); - setCollections([]); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - /* - A selection naming something that is not there any more. - - The id lives in the URL, so it outlives the row: delete a collection with it open, reload, and the - address still asks for it. Left alone that became a 404 from the media fetch, rendered as "Not - found" over a panel that was otherwise working — an error about the URL, phrased as an error about - the server. - - Dropped rather than reported. There is nothing for anybody to do about it and the correct state is - simply "nothing selected". - */ - useEffect(() => { - if (!collections || !selectedId) return; - if (collections.some((one) => one.id === selectedId)) return; - setParams( - (current) => { - const next = new URLSearchParams(current); - next.delete('collection'); - return next; - }, - { replace: true }, - ); - }, [collections, selectedId, setParams]); - - // The gallery's photographs, re-read when the selection moves. Null while nothing is chosen, so the - // empty state and "still loading" are not the same thing. - useEffect(() => { - if (!selectedId || !selected) { - setPhotos(null); - return; - } - let alive = true; - setPhotos(null); - // Cleared here rather than only on success: an error from the last selection has nothing to say - // about this one, and leaving it up reads as the new one having failed too. - setError(null); - void clientRef.current - .get<{ data: CollectionPhoto[] }>(`/fofos-admin/collections/${selectedId}/media`) - .then((response) => alive && setPhotos(response.data)) - .catch((failure: unknown) => alive && setError(readableError(failure))); - return () => { - alive = false; - }; - }, [selectedId, selected]); - - /* - The fields, in the editor column — a SIBLING panel, so they are announced rather than rendered. - See useEditorSlot. - */ - const [, setEditor] = useEditorSlot(); - const setEditorRef = useRef(setEditor); - setEditorRef.current = setEditor; - - useEffect(() => { - setEditorRef.current( - selected ? void load()} /> : null, - ); - }, [selected, load]); - - useEffect(() => () => setEditorRef.current(null), []); - - const select = (id: number) => - setParams( - (current) => { - const next = new URLSearchParams(current); - next.set('collection', String(id)); - return next; - }, - { replace: true }, - ); - - const send = async (call: () => Promise) => { - setBusy(true); - setError(null); - try { - await call(); - await load(); - } catch (failure) { - setError(readableError(failure)); - } finally { - setBusy(false); - } - }; - - const create = () => - send(async () => { - const created = await clientRef.current.post<{ data: Collection }>('/fofos-admin/collections', { - title: name.trim(), - }); - setName(''); - // Straight to it: making one and then having to find it in the list is a step nobody wants. - select(created.data.id); - }); - - const remove = () => - send(async () => { - if (!selected) return; - await clientRef.current.delete(`/fofos-admin/collections/${selected.id}`); - setParams( - (current) => { - const next = new URLSearchParams(current); - next.delete('collection'); - return next; - }, - { replace: true }, - ); - }); - - const chooseGallery = (galleryId: number) => - send(async () => { - if (!selected) return; - setPicking(false); - await clientRef.current.patch(`/fofos-admin/collections/${selected.id}`, { gallery_id: galleryId }); - }); - - return ( -
-
-
    - {collections === null ? ( -
  • Reading the folders…
  • - ) : collections.length === 0 ? ( -
  • - No collections yet. -
  • - ) : ( - collections.map((collection) => ( -
  • - -
  • - )) - )} -
- - {/* Creating is a name and a button, at the bottom of the list it adds to. A dialog for one field - is a dialog nobody thanks you for. */} -
- setName(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter' && name.trim()) void create(); - }} - /> - -
-
- -
- {error &&

{error}

} - - {selected && ( -
- - - {/* Deleting removes the COLLECTION, never the photographs — they belong to a gallery, which - belongs to Galleries. Said on the button so nobody has to find out. */} - -
- )} - - {!selected ? ( -

Choose a collection, or make one.

- ) : !selected.gallery_id ? ( -

- No gallery yet. Choose one and its photographs appear here. -

- ) : photos === null ? ( -

- Reading the gallery… -

- ) : photos.length === 0 ? ( -

That gallery is empty. Add photographs to it in Galleries.

- ) : ( - <> -

- {photos.length} photographs, in the gallery’s order — the first one leads. Reorder - them in Galleries. -

- {/* Not buttons. Nothing here is a decision — the order is the gallery's and so is which one - leads. Making them look pressable would promise a control that lives elsewhere. */} -
    - {photos.map((photo) => ( -
  • -
    - -
    -

    {photo.name}

    -
  • - ))} -
- - )} -
- {/* `selected` guarded: the picker writes to whatever is open, and there is nothing to write to - when nothing is. */} - {selected && ( - setPicking(false)} - onPick={(galleryId) => void chooseGallery(galleryId)} - selected={selected.gallery_id} - /> - )} -
- ); -}; +/** Collections. The panel is shared with the Gallery's pieces — see subjects.ts. */ +export const CollectionsPanel = () => ; diff --git a/web/sections/PageBuilderPanel.tsx b/web/sections/PageBuilderPanel.tsx index 4b0a79d..37bdd7d 100644 --- a/web/sections/PageBuilderPanel.tsx +++ b/web/sections/PageBuilderPanel.tsx @@ -23,11 +23,12 @@ const LABELS: Record = { 'contact-form': 'Contact form', partners: 'Partners', collections: 'Collections', - 'collection-cover': 'Lead photograph', - 'collection-title': 'Name', - 'collection-description': 'Description', - 'collection-specs': 'Specs', - 'collection-gallery': 'Photographs', + pieces: 'Pieces', + 'subject-cover': 'Lead photograph', + 'subject-title': 'Name', + 'subject-description': 'Description', + 'subject-specs': 'Specs', + 'subject-gallery': 'Photographs', }; /* @@ -40,12 +41,13 @@ const PLACES_ONLY: string[] = [ 'contact-form', 'partners', 'collections', - // The subject blocks hold nothing either: what they draw is the collection the URL names. - 'collection-cover', - 'collection-title', - 'collection-description', - 'collection-specs', - 'collection-gallery', + 'pieces', + // The subject blocks hold nothing either: what they draw is the row the URL names. + 'subject-cover', + 'subject-title', + 'subject-description', + 'subject-specs', + 'subject-gallery', ]; import { AddSectionDialog, type SectionLayout } from './AddSectionDialog'; import { FreeTextEditor } from './FreeTextEditor'; diff --git a/web/sections/PiecesPanel.tsx b/web/sections/PiecesPanel.tsx new file mode 100644 index 0000000..b93b048 --- /dev/null +++ b/web/sections/PiecesPanel.tsx @@ -0,0 +1,5 @@ +import { SubjectPanel } from './SubjectPanel'; +import { PIECES } from './subjects'; + +/** The Gallery's pieces. The panel is shared with Collections — see subjects.ts. */ +export const PiecesPanel = () => ; diff --git a/web/sections/CollectionFields.tsx b/web/sections/SubjectFields.tsx similarity index 63% rename from web/sections/CollectionFields.tsx rename to web/sections/SubjectFields.tsx index 8860b59..f792708 100644 --- a/web/sections/CollectionFields.tsx +++ b/web/sections/SubjectFields.tsx @@ -3,16 +3,15 @@ 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'; +import type { SubjectRecord, SubjectSection } from './subjects'; /** - * What somebody says about a collection, in the editor column. + * What somebody says about a collection or a piece, in the editor column. * - * ── Everything here is what a FOLDER cannot tell you ── + * ── One form, two sections ── * - * 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. + * The fields below the description come from the section — see subjects.ts. Everything above them is + * shared because it is the same in both: what it is called, and where it lives. * * The gallery is not here. It is chosen beside the photographs it puts on screen, in the panel, where * the consequence of choosing it is visible — a dropdown in a column of text fields would be the one @@ -24,42 +23,34 @@ import type { Collection } from './collections'; * 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, +export const SubjectFields = ({ + section, + record, onSaved, }: { - collection: Collection; + section: SubjectSection; + record: T; onSaved: () => void; }) => { const client = useClient(); - const [draft, setDraft] = useState>({}); + 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]); + useEffect(() => setDraft({}), [record.id]); - const value = (key: keyof Collection): string => - (draft[key] as string | null | undefined) ?? (collection[key] as string | null) ?? ''; + const value = (key: string): string => + draft[key] ?? ((record as Record)[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); + await client.patch(`${section.endpoint}/${record.id}`, draft); setDraft({}); onSaved(); }); - const set = (key: keyof Collection) => (next: string) => setDraft((was) => ({ ...was, [key]: next })); + const set = (key: string) => (next: string) => setDraft((was) => ({ ...was, [key]: next })); return (
@@ -71,7 +62,9 @@ export const CollectionFields = ({
set('slug')(event.target.value)} /> -

/collections/{value('slug')}

+

+ {section.base}/{value('slug')} +

@@ -83,10 +76,10 @@ export const CollectionFields = ({ />
- {SPECS.map(([key, label, placeholder]) => ( + {section.specs.map(([key, label, placeholder]) => (
- {key === 'descriptive_text' ? ( + {section.long?.includes(key) ? (