Files
fofos-admin/web/sections/GalleriesPanel.tsx
T
pastilhasandClaude Opus 5 9fd5f1b3ab gallery items get a thumbnail and a caption
The thumbnail comes from the DERIVED ladder, which is the point of the whole
pipeline: most of these are HEIC and a browser cannot display the original at
all. 320 is the smallest rung and still sharp at this size.

Served by this plugin from disk rather than by the site. The panel talks to the
plugin and the catch-all forwards everything to the site's /api/admin, which is a
different route — and more importantly, looking at a gallery should not require
the development server to be running. Editing the site is not the same as
reading it.

Not under /_config, because that prefix is owner-only and a thumbnail is not a
credential: a member who can open the section can see the pictures in it, which
is what the section is. Registered before the catch-all, or the catch-all would
forward it to fofos.

Path traversal is refused by resolving first and comparing against the directory
plus a separator — a bare startsWith accepts `derived-old` for a root of
`derived`.

An item whose conversion has not run has no rung, and the thumbnail hides rather
than showing a broken-image glyph, which reads as corrupt rather than pending.

Captions save on blur. A caption is a sentence somebody is in the middle of, and
a request per keystroke would be wasteful and a race with itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 21:23:40 +01:00

293 lines
12 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-3 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>
{/*
From the DERIVED ladder, which is the whole point of the pipeline: most of these
are HEIC and a browser cannot show the original at all. 320 is the smallest rung
and still sharp at this size on a retina screen.
*/}
<img
src={`/api/fofos-admin/derived/${item.url.split('/').map(encodeURIComponent).join('/')}/320.webp`}
alt=""
loading="lazy"
className="size-20 shrink-0 rounded bg-muted object-cover"
// A file whose conversion has not run yet has no rung. Hidden rather than left
// as a broken-image glyph, which reads as a corrupt picture rather than a
// pending one.
onError={(event) => {
event.currentTarget.style.visibility = 'hidden';
}}
/>
<span className="min-w-0 flex-1 space-y-1">
<span className="block truncate font-mono text-xs text-muted-foreground">{item.url}</span>
{/*
Saved on blur rather than on every keystroke: a caption is a sentence somebody
is in the middle of, and a request per character would be both wasteful and a
race with itself.
*/}
<Input
defaultValue={item.alt ?? ''}
onBlur={(event) => {
if (event.target.value.trim() !== (item.alt ?? '')) {
void send(`alt:${item.id}`, () =>
clientRef.current.patch(`/fofos-admin/galleries/${gallery.id}/items/${item.id}`, {
alt: event.target.value.trim() || null,
}),
);
}
}}
placeholder="Caption — describe it, for screen readers"
className="h-7 text-xs"
/>
</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>
);
};