"The About page, in the order it is shown: a header, the text about you, the product, and the manifesto." above four bands headed Header, About us, Product and Manifesto. "The photograph at the top, and the line over it" above a photograph at the top and a line over it. "Who you are" above a box headed About us. Every one was a caption for something already visible, and together they were most of the vertical space before any content — so the actual work started below the fold on a laptop. The person using this is not a developer and is not a child. A heading names a thing; a sentence underneath saying what the thing is says it twice and trusts them less each time. Removed from About, Home, Galleries and the record sections. What stays is anything reporting STATE rather than describing furniture — a count of photographs, a loading line, an error, a save confirmation — because those say something the screen does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
302 lines
13 KiB
TypeScript
302 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">
|
|
<h3 className="text-sm font-medium">Galleries</h3>
|
|
|
|
{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>
|
|
);
|
|
};
|