Files
fofos-admin/web/sections/GalleriesPanel.tsx
T
pastilhasandClaude Opus 5 f61653f7af Galleries: a panel, and Create Gallery in the file browser
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 <noreply@anthropic.com>
2026-08-29 21:08:25 +01:00

255 lines
10 KiB
TypeScript

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<Gallery[] | null>(null);
const [openId, setOpenId] = useState<number | null>(null);
const [name, setName] = useState('');
const [picking, setPicking] = useState<number | null>(null);
/** Paths handed over by the context menu, waiting for a name. */
const [pending, setPending] = useState<string[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<unknown>) => {
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 <div className="p-5 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
return (
<div className="flex size-full flex-col gap-4 overflow-auto p-5">
<section className="flex flex-col gap-3 rounded-lg border border-border p-4">
<div className="space-y-1">
<h3 className="text-sm font-medium">Galleries</h3>
<p className="text-xs text-muted-foreground">
A named set of media, in an order. Any page can use one, and they all change together.
</p>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
{/* What the context menu sent, so what is about to be created is not a mystery. */}
{pending.length > 0 && (
<p className="text-xs text-muted-foreground/80">
{pending.length} item{pending.length === 1 ? '' : 's'} ready — name it and press Create.
</p>
)}
<div className="flex gap-2">
<Input
autoFocus={pending.length > 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();
}}
/>
<Button
variant="outline"
size="sm"
className="w-24 shrink-0 justify-center"
onClick={() => void create()}
disabled={busy !== null || !name.trim()}
>
{busy === 'create' ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
Create
</Button>
</div>
</section>
{galleries.map((gallery) => {
const open = openId === gallery.id;
return (
<section key={gallery.id} className="flex flex-col gap-3 rounded-lg border border-border p-4">
<button
type="button"
className="flex items-center justify-between gap-3 text-left"
onClick={() => setOpenId(open ? null : gallery.id)}
>
<span className="min-w-0 space-y-1">
<span className="flex items-center gap-2 text-sm font-medium">
<Images className="size-4" />
{gallery.name}
</span>
<span className="block font-mono text-xs text-muted-foreground/70">
{gallery.slug} · {gallery.items.length} item{gallery.items.length === 1 ? '' : 's'}
</span>
</span>
</button>
{open && (
<>
<ul className="space-y-1">
{gallery.items.map((item, index) => (
<li key={item.id} className="flex items-center gap-2 rounded border border-border/60 p-2">
<span className="w-6 shrink-0 text-center font-mono text-xs text-muted-foreground/60">
{index + 1}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">
{item.url}
</span>
<span className="flex shrink-0 gap-1">
<Button variant="ghost" size="sm" onClick={() => void move(gallery, index, -1)} disabled={busy !== null || index === 0}>
<ArrowUp className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void move(gallery, index, 1)}
disabled={busy !== null || index === gallery.items.length - 1}
>
<ArrowDown className="size-3.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => void removeItem(gallery.id, item.id)} disabled={busy !== null}>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</span>
</li>
))}
{!gallery.items.length && <li className="text-xs text-muted-foreground/60">Nothing in it yet.</li>}
</ul>
<div className="flex justify-between gap-2 border-t border-border pt-3">
<Button variant="outline" size="sm" onClick={() => setPicking(gallery.id)} disabled={busy !== null}>
<FolderOpen className="size-3.5" />
Add media
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void send(`gallery:${gallery.id}`, () => clientRef.current.delete(`/fofos-admin/galleries/${gallery.id}`))}
disabled={busy !== null}
>
<Trash2 className="size-3.5 text-destructive" />
Delete gallery
</Button>
</div>
</>
)}
</section>
);
})}
<MediaPicker
open={picking !== null}
onClose={() => setPicking(null)}
onPick={(path) => {
if (picking !== null) void addTo(picking, path);
}}
title="Add to gallery"
/>
</div>
);
};