From 12f9774a4b56786838d06439ccada3a46f830216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 4 Sep 2026 14:10:56 +0100 Subject: [PATCH] a collection is made, and points at a gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version before this made a collection out of every folder under originals/COLLECTIONS/. It kept the disk and the database in step for free, and it also let whoever last tidied a directory decide the website's structure. A collection is editorial: it exists because somebody made it, named it and put photographs in it. Its photographs are a GALLERY — the feature that already means "a named, ordered set of media", with a picker and an order somebody dragged into place. A collection points at one rather than growing a private copy, so ordering stays maintained in the one screen that owns ordering, and the same set can appear in a collection and on a page without being chosen twice. That is why the grid here is not draggable and has no Add: both belong to Galleries. What is decided HERE is which photograph leads, because that is a fact about the collection rather than about the set. Creating is a name and a button under the list it adds to, and it selects what it made — a dialog for one field is a dialog nobody thanks you for. Deleting removes the collection and never the photographs, which the button says out loud rather than leaving somebody to find out. Changing the gallery clears the cover. The new set is different photographs, so a cover chosen from the old one is a path that may not be there, and falling back silently would be a choice quietly reverting itself. Co-Authored-By: Claude Opus 5 (1M context) --- web/sections/CollectionFields.tsx | 12 +-- web/sections/CollectionsPanel.tsx | 153 ++++++++++++++++++++++++------ web/sections/collections.ts | 5 +- 3 files changed, 131 insertions(+), 39 deletions(-) diff --git a/web/sections/CollectionFields.tsx b/web/sections/CollectionFields.tsx index b79e56c..8860b59 100644 --- a/web/sections/CollectionFields.tsx +++ b/web/sections/CollectionFields.tsx @@ -14,9 +14,9 @@ import type { Collection } from './collections'; * 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. + * 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 + * control whose effect you cannot see. * * ── One draft, one save ── * @@ -103,12 +103,6 @@ export const CollectionFields = ({ ))} - {/* Read-only, and shown rather than hidden: it is the only thing that says which folder this is, - and the API refuses to write it because renaming one moves files on disk. */} -
- -

{collection.folder ?? '—'}

-
); }; diff --git a/web/sections/CollectionsPanel.tsx b/web/sections/CollectionsPanel.tsx index 139f912..606ec7f 100644 --- a/web/sections/CollectionsPanel.tsx +++ b/web/sections/CollectionsPanel.tsx @@ -1,7 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; -import { Boxes, Check, Loader2 } from 'lucide-react'; +import { Boxes, Check, 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'; @@ -36,6 +39,8 @@ export const CollectionsPanel = () => { 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(); @@ -90,23 +95,6 @@ export const CollectionsPanel = () => { useEffect(() => () => setEditorRef.current(null), []); - const chooseCover = async (name: string) => { - if (!selected) return; - setBusy(true); - setError(null); - try { - // Pressing the one already chosen clears it, which is how "no choice, use the first" is reachable - // again without a second control that only does that. - const next = selected.cover === name ? null : name; - await clientRef.current.patch(`/fofos-admin/collections/${selected.id}`, { cover: next }); - await load(); - } catch (failure) { - setError(readableError(failure)); - } finally { - setBusy(false); - } - }; - const select = (id: number) => setParams( (current) => { @@ -117,14 +105,70 @@ export const CollectionsPanel = () => { { 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 }); + }); + + const chooseCover = (path: string) => + send(async () => { + if (!selected) return; + // Pressing the one already chosen clears it, which is how "no choice, use the first" is reachable + // again without a second control that only does that. + await clientRef.current.patch(`/fofos-admin/collections/${selected.id}`, { + cover: selected.cover === path ? null : path, + }); + }); + + return (
-
    +
    +
      {collections === null ? (
    • Reading the folders…
    • ) : collections.length === 0 ? (
    • - Nothing under originals/COLLECTIONS yet. + No collections yet.
    • ) : ( collections.map((collection) => ( @@ -149,33 +193,76 @@ export const CollectionsPanel = () => { )}
    + {/* 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.

    +

    Choose a collection, or make one.

    + ) : !selected.gallery_id ? ( +

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

    ) : photos === null ? (

    - Reading {selected.folder}… + Reading the gallery…

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

    - That folder is empty. Put photographs in {selected.folder}. -

    +

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

    ) : ( <>

    - {photos.length} photographs, in filename order. Click one to make it the main photo. + {photos.length} photographs, in the gallery’s order. Click one to make it the main photo.

      {photos.map((photo) => { - const isCover = selected.cover ? photo.name === selected.cover : photo === photos[0]; + const isCover = selected.cover ? photo.path === selected.cover : photo === photos[0]; return (
    + {/* `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} + /> + )}
); }; diff --git a/web/sections/collections.ts b/web/sections/collections.ts index c3bf5be..7e42164 100644 --- a/web/sections/collections.ts +++ b/web/sections/collections.ts @@ -1,10 +1,11 @@ /** A collection, as the site's admin API serialises one. Snake case: it is the wire, not our shape. */ export type Collection = { id: number; - folder: string | null; + /** The gallery holding its photographs, or null while nobody has chosen one. */ + gallery_id: number | null; title: string; slug: string | null; - /** A FILENAME within the folder, not a path — see the site's schema. */ + /** A media PATH, the same string a gallery item holds — see the site's schema. */ cover: string | null; description: string | null; descriptive_text: string | null;