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>
This commit is contained in:
2026-08-29 21:23:40 +01:00
co-authored by Claude Opus 5
parent 5cfe571db0
commit 9fd5f1b3ab
2 changed files with 86 additions and 4 deletions
+41 -3
View File
@@ -192,12 +192,50 @@ export const GalleriesPanel = () => {
<>
<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">
<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>
<span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">
{item.url}
{/*
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}>