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>
307 lines
13 KiB
TypeScript
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>
|
|
);
|
|
};
|