one gallery editor, in both places
GalleriesPanel drew its own copy of the item list — same operations, same routes, arrows instead of a grip. It is a master list now: which galleries there are, making one, deleting one. What is inside the selected one is announced to the editor column and drawn by GalleryItems, which is what a gallery BLOCK already used. The device toggle only shows where there is a page to look at. It sets the width the preview renders at, and Galleries, Media and Administration have none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
-4
@@ -59,10 +59,21 @@ export const EditorPanel = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outside the scroll area, so a long editor cannot carry it off the screen. */}
|
||||
<div className="shrink-0 border-b border-border px-3 py-2">
|
||||
<DeviceToggle />
|
||||
</div>
|
||||
{/*
|
||||
Only where there is a page to look at.
|
||||
|
||||
The toggle sets the width the PREVIEW renders at, and Galleries, Media and Administration have
|
||||
no preview — the control would be three buttons that do nothing visible. Named here rather
|
||||
than read from `usePreviewable`, which is a context inside the detail panel and this is its
|
||||
sibling.
|
||||
|
||||
Outside the scroll area, so a long editor cannot carry it off the screen.
|
||||
*/}
|
||||
{(section === 'layout' || section === 'pages') && (
|
||||
<div className="shrink-0 border-b border-border px-3 py-2">
|
||||
<DeviceToggle />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editor ? (
|
||||
/*
|
||||
|
||||
+58
-136
@@ -1,12 +1,12 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { ArrowDown, ArrowUp, FolderOpen, Images, Loader2, Plus, Trash2 } from 'lucide-react';
|
||||
import { 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';
|
||||
import { useEditorSlot } from '../useEditorSlot';
|
||||
import { GalleryItems } from './GalleryItems';
|
||||
|
||||
/**
|
||||
* Galleries — named, ordered sets of media, reusable anywhere on the site.
|
||||
@@ -28,19 +28,6 @@ import { MediaPicker } from '../MediaPicker';
|
||||
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);
|
||||
@@ -49,7 +36,6 @@ export const GalleriesPanel = () => {
|
||||
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);
|
||||
@@ -70,6 +56,28 @@ export const GalleriesPanel = () => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
/*
|
||||
The selected gallery's contents, handed to the editor column.
|
||||
|
||||
Announced rather than rendered, because that panel is a SIBLING — see useEditorSlot. `onChanged`
|
||||
reloads the list here, so the item count beside the name is never one behind what is in it.
|
||||
|
||||
The dependency list is everything the editor DISPLAYS: an element captures the props it was built
|
||||
with, so one left out reads as a control that has stopped responding rather than as an error.
|
||||
*/
|
||||
const [, setEditor] = useEditorSlot();
|
||||
const setEditorRef = useRef(setEditor);
|
||||
setEditorRef.current = setEditor;
|
||||
|
||||
useEffect(() => {
|
||||
setEditorRef.current(
|
||||
openId === null ? null : <GalleryItems galleryId={openId} onChanged={() => void load()} />,
|
||||
);
|
||||
}, [openId, load]);
|
||||
|
||||
// Cleared on unmount, or the panel goes on offering a gallery over somebody else's screen.
|
||||
useEffect(() => () => setEditorRef.current(null), []);
|
||||
|
||||
/*
|
||||
Arriving from "Create Gallery" in the file browser.
|
||||
|
||||
@@ -120,23 +128,6 @@ export const GalleriesPanel = () => {
|
||||
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 (
|
||||
@@ -177,125 +168,56 @@ export const GalleriesPanel = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/*
|
||||
The list, and only the list.
|
||||
|
||||
The contents of a gallery — its pictures, their order, their captions — are announced to the
|
||||
editor column and drawn by `GalleryItems`, which is the same component a gallery BLOCK on a
|
||||
page uses. Two copies of that list existed for a day and this is the one that went.
|
||||
|
||||
So this panel is what a master list should be: which galleries there are, making one, deleting
|
||||
one. What is inside the selected one is a different question, answered beside it.
|
||||
*/}
|
||||
{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">
|
||||
<section
|
||||
key={gallery.id}
|
||||
className={`flex items-center gap-3 rounded-lg border p-4 ${
|
||||
open ? 'border-primary/60 bg-muted/40' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between gap-3 text-left"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
onClick={() => setOpenId(open ? null : gallery.id)}
|
||||
>
|
||||
<Images className="size-4 shrink-0" />
|
||||
<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">
|
||||
<span className="block truncate text-sm font-medium">{gallery.name}</span>
|
||||
<span className="block truncate 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>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() =>
|
||||
void send(`gallery:${gallery.id}`, () =>
|
||||
clientRef.current.delete(`/fofos-admin/galleries/${gallery.id}`),
|
||||
)
|
||||
}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-destructive" />
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<MediaPicker
|
||||
open={picking !== null}
|
||||
onClose={() => setPicking(null)}
|
||||
onPick={(path) => {
|
||||
if (picking !== null) void addTo(picking, path);
|
||||
}}
|
||||
title="Add to gallery"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user