Files
fofos-admin/web/sections/GalleriesPanel.tsx
T
pastilhasandClaude Opus 5 9c62777f5b thumbnails need the token in the query string
An <img> cannot send an Authorization header, which is why user-middleware and
the permission gate both already accept `?token=` — it is exactly what the file
browser's own thumbnails do.

Built without it, every picture 401'd while the layout looked perfectly correct:
the space was reserved, the rows were right, and nothing appeared. The route was
fine and had been tested with a bearer token, which is precisely the case the
browser cannot reproduce.

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

307 lines
13 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 { getHeaders } from 'hooks/useClient';
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[] };
/**
* A thumbnail URL, authenticated by `?token=`.
*
* An `<img>` cannot send an Authorization header, which is why `user-middleware` and the permission
* gate both accept the token in the query string — the same thing the file browser's own thumbnails do.
* Built without it first, and every picture 401'd while the layout looked perfectly correct.
*/
const thumbUrl = (path: string): string => {
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
const encoded = path.split('/').map(encodeURIComponent).join('/');
return `/api/fofos-admin/derived/${encoded}/320.webp?token=${encodeURIComponent(token)}`;
};
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={thumbUrl(item.url)}
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>
);
};