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;