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:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user