diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index e2dfd62..f06aa38 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -3,6 +3,7 @@ import { DEFAULT_SECTION, isSectionKey, sectionFor, sectionOpen, type SectionKey import { AdministrationPanel } from './AdministrationPanel'; import { MediaPanel } from './MediaPanel'; import { HomePanel } from './sections/HomePanel'; +import { GalleriesPanel } from './sections/GalleriesPanel'; import { MessagesPanel } from './MessagesPanel'; import { PlannedSection } from './PlannedSection'; import { useReady } from './useReady'; @@ -40,6 +41,7 @@ import { useReady } from './useReady'; */ const PANELS: Partial React.JSX.Element>> = { home: HomePanel, + galleries: GalleriesPanel, messages: MessagesPanel, administration: AdministrationPanel, media: MediaPanel, diff --git a/web/file-actions.ts b/web/file-actions.ts new file mode 100644 index 0000000..b49e3cc --- /dev/null +++ b/web/file-actions.ts @@ -0,0 +1,64 @@ +import { Images } from 'lucide-react'; +import type { FileAction } from 'officerdev'; + +/** + * What this plugin adds to the file browser's context menu. + * + * ── Why an action and not a task ── + * + * A task runs a script as the owner and reports through the job system, which is right for "convert + * these" and wrong for this. Creating a gallery is a name to type and a screen to land on — expressing + * it as a job would produce something to watch whose only output is somewhere to go next. + * + * ── It only offers itself inside `originals/` ── + * + * A gallery item is a path relative to that folder, so the action is meaningless anywhere else — and a + * menu entry that appears everywhere and works in one place is worse than one that appears where it + * works. The check is on the path rather than on which panel is open, because the same browser is + * mounted in several places and only the path says where it is pointed. + */ + +const ORIGINALS = '/media/originals'; + +/** Everything under `originals/`, relative to it — which is what a gallery item stores. */ +const relativeTo = (path: string): string | null => { + const at = path.indexOf(ORIGINALS); + return at === -1 ? null : path.slice(at + ORIGINALS.length).replace(/^\/+/, ''); +}; + +export const fileActions: FileAction[] = [ + { + key: 'fofos-admin:create-gallery', + label: 'Create Gallery', + icon: Images, + /* + A folder, or any number of files — both are ways of saying "these, in this order". + + A mixed selection is refused: a folder inside a selection of files is either a mistake or a + request to recurse, and neither is something to guess at from a right-click. + */ + appliesTo: (entries) => { + if (!entries.length) return false; + if (!entries.every((entry) => relativeTo(entry.path))) return false; + const directories = entries.filter((entry) => entry.type === 'directory').length; + return directories === 0 || (directories === 1 && entries.length === 1); + }, + run: (entries) => { + const paths = entries.map((entry) => relativeTo(entry.path)).filter((path): path is string => Boolean(path)); + /* + Handed over in the URL, and the Galleries panel does the asking. + + The action has no dialog of its own on purpose: naming a gallery, seeing the slug it will get + and watching the items land belongs on the screen that owns galleries, not in a modal over a + file browser that would then have to explain where the result went. + */ + const params = new URLSearchParams(window.location.search); + params.set('section', 'galleries'); + params.set('create', entries.length === 1 && entries[0]!.type === 'directory' ? entries[0]!.name : ''); + params.set('from', paths.join('\n')); + window.history.pushState({}, '', `${window.location.pathname}?${params.toString()}`); + // The router listens for this; pushState alone does not notify it. + window.dispatchEvent(new PopStateEvent('popstate')); + }, + }, +]; diff --git a/web/sections.ts b/web/sections.ts index bea7f94..bdd6fc8 100644 --- a/web/sections.ts +++ b/web/sections.ts @@ -164,18 +164,20 @@ export const SECTIONS: Section[] = [ has chosen and put in an order, which is a decision worth keeping once rather than remaking on every page that wants it. - `requires: 'root'` and not `'connection'`. A gallery is made of originals on this disk, so it is - reachable as soon as there is a root location — the same reasoning that unlocked Media, and for - the same reason: gating it behind a site connection it does not use was the bug that made the - distinction necessary. + `requires: 'connection'`, unlike Media beside it, and the difference is real rather than a + copy-paste. Media is a file browser over a folder on this disk and needs nothing else. A gallery + is a ROW in the site's database — reading or writing one goes through the site's admin API, so + this section is useless without the development server answering, and opening it there would be + a screen where every action fails. */ key: 'galleries', label: 'Galleries', icon: Images, hint: 'Named sets of media, reusable across the site', - path: null, - built: false, - requires: 'root', + // `/fofos-admin/galleries` — it reads the site's own admin API, unlike Media beside it. + path: '/fofos-admin/galleries', + built: true, + requires: 'connection', pinned: true, }, { diff --git a/web/sections/GalleriesPanel.tsx b/web/sections/GalleriesPanel.tsx new file mode 100644 index 0000000..aa724bd --- /dev/null +++ b/web/sections/GalleriesPanel.tsx @@ -0,0 +1,254 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router'; +import { ArrowDown, ArrowUp, FolderOpen, 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 { readableError } from '../errors'; +import { MediaPicker } from '../MediaPicker'; + +/** + * Galleries — named, ordered sets of media, reusable anywhere on the site. + * + * ── Why a gallery is not a list on a page ── + * + * The home page had one, as rows hanging off its own content. That works for exactly one. The moment a + * second page wants the same photographs in the same order, a list-per-page means choosing them again + * and keeping two copies in step by hand. + * + * ── The server owns the shape ── + * + * Every write answers with the whole gallery and this never patches its own state from what it sent. + * That matters most here: reordering renumbers everything, adding appends after whatever was there, + * and deleting closes a gap. A client keeping its own copy drifts on the first of those with no way to + * notice. + */ + +type Item = { id: number; url: string; alt: string | null; sort_order: number }; +type Gallery = { id: number; name: string; slug: string; items: Item[] }; + +export const GalleriesPanel = () => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [galleries, setGalleries] = useState(null); + const [openId, setOpenId] = useState(null); + const [name, setName] = useState(''); + const [picking, setPicking] = useState(null); + /** Paths handed over by the context menu, waiting for a name. */ + const [pending, setPending] = useState([]); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const [params, setParams] = useSearchParams(); + + const load = useCallback(async () => { + try { + const response = await clientRef.current.get<{ data: Gallery[] }>('/fofos-admin/galleries'); + setGalleries(response.data); + } catch (failure) { + setError(readableError(failure)); + setGalleries([]); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + /* + Arriving from "Create Gallery" in the file browser. + + The action puts the chosen paths in the URL and sends you here rather than opening its own dialog: + naming a gallery, seeing the slug it gets and watching the items land belongs on the screen that + owns galleries. Consumed once and removed from the URL — a `?from=` left behind would recreate the + gallery on every refresh, which somebody discovers as four copies. + */ + const arriving = params.get('from'); + useEffect(() => { + if (arriving === null) return; + const urls = arriving.split('\n').filter(Boolean); + const suggested = params.get('create') ?? ''; + setParams( + (current) => { + const next = new URLSearchParams(current); + next.delete('from'); + next.delete('create'); + return next; + }, + { replace: true }, + ); + // Pre-filled rather than created: the name is somebody's to choose and a folder name is only a + // suggestion. Nothing is written until Create. + setName(suggested); + setPending(urls); + }, [arriving, params, setParams]); + + const send = async (label: string, call: () => Promise) => { + setBusy(label); + setError(null); + try { + await call(); + await load(); + } catch (failure) { + setError(readableError(failure)); + } finally { + setBusy(null); + } + }; + + const create = () => + send('create', async () => { + // `urls` in the same request, so creating from a selection is one action — splitting it would + // leave an empty gallery behind whenever the second call failed. + await clientRef.current.post('/fofos-admin/galleries', { name: name.trim(), urls: pending }); + setName(''); + setPending([]); + }); + + const addTo = (galleryId: number, url: string) => + send(`add:${galleryId}`, () => clientRef.current.post(`/fofos-admin/galleries/${galleryId}/items`, { urls: [url] })); + + const removeItem = (galleryId: number, itemId: number) => + send(`del:${itemId}`, () => clientRef.current.delete(`/fofos-admin/galleries/${galleryId}/items/${itemId}`)); + + /** Every id, in order — which is what makes two items claiming a position impossible. */ + const move = (gallery: Gallery, index: number, by: -1 | 1) => { + const next = [...gallery.items]; + const target = index + by; + if (target < 0 || target >= next.length) return; + [next[index], next[target]] = [next[target]!, next[index]!]; + return send('order', () => + clientRef.current.put(`/fofos-admin/galleries/${gallery.id}/items/order`, { ids: next.map((item) => item.id) }), + ); + }; + + if (!galleries) return
{error ?? 'Loading…'}
; + + return ( +
+
+
+

Galleries

+

+ A named set of media, in an order. Any page can use one, and they all change together. +

+
+ + {error &&

{error}

} + + {/* What the context menu sent, so what is about to be created is not a mystery. */} + {pending.length > 0 && ( +

+ {pending.length} item{pending.length === 1 ? '' : 's'} ready — name it and press Create. +

+ )} + +
+ 0} + value={name} + onChange={(event) => setName(event.target.value)} + placeholder="Name a new gallery" + className="min-w-0 flex-1 text-xs" + onKeyDown={(event) => { + if (event.key === 'Enter' && name.trim()) void create(); + }} + /> + +
+
+ + {galleries.map((gallery) => { + const open = openId === gallery.id; + return ( +
+ + + {open && ( + <> +
    + {gallery.items.map((item, index) => ( +
  • + + {index + 1} + + + {item.url} + + + + + + +
  • + ))} + {!gallery.items.length &&
  • Nothing in it yet.
  • } +
+ +
+ + +
+ + )} +
+ ); + })} + + setPicking(null)} + onPick={(path) => { + if (picking !== null) void addTo(picking, path); + }} + title="Add to gallery" + /> +
+ ); +};