From f61653f7af8551f48cef4c45f7f8b4ca4f4d089d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 21:08:25 +0100 Subject: [PATCH] Galleries: a panel, and Create Gallery in the file browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section reads the site's own admin API, so it requires a CONNECTION where Media beside it requires only a root. The difference is real rather than copy-paste: Media is a file browser over a folder on this disk, while a gallery is a row in the site's database — opening it without the site answering would be a screen where every action fails. The context action hands its selection over in the URL and sends you to the panel rather than opening a dialog of its own. 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. It offers itself only inside originals/, because a gallery item is a path relative to that folder and an entry that appears everywhere while working in one place is worse than one that appears where it works. The test is on the PATH, not on which panel is open — the same browser is mounted in several places and only the path says where it is pointed. A mixed selection is refused. A folder among files is either a mistake or a request to recurse, and neither is something to guess from a right-click. The handoff is consumed once and stripped from the URL. Left there, a refresh would create the gallery again, which somebody discovers as four copies. Nothing is written until Create: the folder's name is a suggestion in the field, not a decision already taken. Co-Authored-By: Claude Opus 5 --- web/DetailPanel.tsx | 2 + web/file-actions.ts | 64 ++++++++ web/sections.ts | 16 +- web/sections/GalleriesPanel.tsx | 254 ++++++++++++++++++++++++++++++++ 4 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 web/file-actions.ts create mode 100644 web/sections/GalleriesPanel.tsx 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" + /> +
+ ); +};