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 `` 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(null);
const [openId, setOpenId] = useState(null);
const [name, setName] = useState('');
const [picking, setPicking] = useState(null);
/** Paths handed over by the context menu, waiting for a name. */
const [pending, setPending] = useState([]);
const [busy, setBusy] = useState(null);
const [error, setError] = useState(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) => {
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
{error ?? 'Loading…'}
;
return (
Galleries
{error &&
{error}
}
{/* What the context menu sent, so what is about to be created is not a mystery. */}
{pending.length > 0 && (
{pending.length} item{pending.length === 1 ? '' : 's'} ready — name it and press Create.
)}
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();
}}
/>
{index + 1}
{/*
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.
*/}
{
event.currentTarget.style.visibility = 'hidden';
}}
/>
{item.url}
{/*
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.
*/}
{
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"
/>
))}
{!gallery.items.length &&
Nothing in it yet.
}
>
)}
);
})}
setPicking(null)}
onPick={(path) => {
if (picking !== null) void addTo(picking, path);
}}
title="Add to gallery"
/>