Home picks a gallery instead of managing one
Managing items on the page would be a second editor for something that already has one, and the two would drift — a caption fixed in one place and not the other. The page chooses; the Galleries section arranges; every page using that gallery changes with it. The list of galleries is read from the section that owns them rather than embedded in the home payload, because a copy there would be stale the moment somebody edited one. While no gallery is chosen the old per-page rows still render, and the panel says so — a carousel that appears to come from nowhere is worse than a sentence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+51
-104
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { ArrowDown, ArrowUp, Check, FolderOpen, ImagePlus, Loader2, Trash2 } from 'lucide-react';
|
import { Check, FolderOpen, Loader2 } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -44,9 +44,14 @@ type GalleryItem = {
|
|||||||
type Home = {
|
type Home = {
|
||||||
hero: { url: string | null; crop: string | null; fit: string | null };
|
hero: { url: string | null; crop: string | null; fit: string | null };
|
||||||
intro_text: string | null;
|
intro_text: string | null;
|
||||||
|
/** The OLD per-page list. Still returned by the API, and only rendered while no gallery is chosen. */
|
||||||
gallery: GalleryItem[];
|
gallery: GalleryItem[];
|
||||||
|
/** Which gallery the carousel shows. Null means the old list above still applies. */
|
||||||
|
gallery_id: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Gallery = { id: number; name: string; slug: string; items: unknown[] };
|
||||||
|
|
||||||
export const HomePanel = () => {
|
export const HomePanel = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const clientRef = useRef(client);
|
const clientRef = useRef(client);
|
||||||
@@ -55,11 +60,11 @@ export const HomePanel = () => {
|
|||||||
const [home, setHome] = useState<Home | null>(null);
|
const [home, setHome] = useState<Home | null>(null);
|
||||||
const [heroUrl, setHeroUrl] = useState('');
|
const [heroUrl, setHeroUrl] = useState('');
|
||||||
const [intro, setIntro] = useState('');
|
const [intro, setIntro] = useState('');
|
||||||
const [addUrl, setAddUrl] = useState('');
|
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [saved, flashSaved] = useFlash();
|
const [saved, flashSaved] = useFlash();
|
||||||
const [picking, setPicking] = useState(false);
|
const [picking, setPicking] = useState(false);
|
||||||
|
const [galleries, setGalleries] = useState<Gallery[]>([]);
|
||||||
|
|
||||||
/** Every write answers with the whole payload, so this is the only place state is set. */
|
/** Every write answers with the whole payload, so this is the only place state is set. */
|
||||||
const apply = useCallback((data: Home) => {
|
const apply = useCallback((data: Home) => {
|
||||||
@@ -81,6 +86,15 @@ export const HomePanel = () => {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
// The galleries to choose between, read from the section that owns them rather than embedded in the
|
||||||
|
// home payload — a copy there would go stale the moment somebody edited one.
|
||||||
|
useEffect(() => {
|
||||||
|
void clientRef.current
|
||||||
|
.get<{ data: Gallery[] }>('/fofos-admin/galleries')
|
||||||
|
.then((response) => setGalleries(response.data))
|
||||||
|
.catch(() => setGalleries([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const send = async (label: string, call: () => Promise<{ data: Home }>) => {
|
const send = async (label: string, call: () => Promise<{ data: Home }>) => {
|
||||||
setBusy(label);
|
setBusy(label);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -107,34 +121,6 @@ export const HomePanel = () => {
|
|||||||
const saveHero = () =>
|
const saveHero = () =>
|
||||||
send('hero', () => clientRef.current.patch<{ data: Home }>('/fofos-admin/home', { hero_url: heroUrl.trim() || null }));
|
send('hero', () => clientRef.current.patch<{ data: Home }>('/fofos-admin/home', { hero_url: heroUrl.trim() || null }));
|
||||||
|
|
||||||
const add = () =>
|
|
||||||
send('add', async () => {
|
|
||||||
const response = await clientRef.current.post<{ data: Home }>('/fofos-admin/home/gallery', {
|
|
||||||
url: addUrl.trim(),
|
|
||||||
});
|
|
||||||
setAddUrl('');
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
|
|
||||||
const remove = (item: GalleryItem) =>
|
|
||||||
send(`del:${item.id}`, () => clientRef.current.delete<{ data: Home }>(`/fofos-admin/home/gallery/${item.id}`));
|
|
||||||
|
|
||||||
const setAlt = (item: GalleryItem, alt: string) =>
|
|
||||||
send(`alt:${item.id}`, () =>
|
|
||||||
clientRef.current.patch<{ data: Home }>(`/fofos-admin/home/gallery/${item.id}`, { alt: alt.trim() || null }),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Moving one sends them ALL, which is what makes two items claiming a position impossible. */
|
|
||||||
const move = (index: number, by: -1 | 1) => {
|
|
||||||
if (!home) return;
|
|
||||||
const next = [...home.gallery];
|
|
||||||
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<{ data: Home }>('/fofos-admin/home/gallery/order', { ids: next.map((item) => item.id) }),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!home) {
|
if (!home) {
|
||||||
return (
|
return (
|
||||||
@@ -233,87 +219,48 @@ export const HomePanel = () => {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-sm font-medium">Gallery</h3>
|
<h3 className="text-sm font-medium">Gallery</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
The carousel below the intro. {home.gallery.length} item{home.gallery.length === 1 ? '' : 's'}, in order.
|
Which gallery the carousel shows. Edit its contents in the Galleries section — every page
|
||||||
|
using it changes with it.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-2">
|
{/*
|
||||||
{home.gallery.map((item, index) => (
|
A picker, not a list.
|
||||||
<li key={item.id} className="flex flex-wrap items-center gap-2 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>
|
|
||||||
|
|
||||||
{/* Through the plugin's own proxy, so it loads with the session rather than needing the
|
Managing items here would be a second editor for something that already has one, and the two
|
||||||
dev server to be reachable from the browser. */}
|
would drift: a caption fixed in one place and not the other. The page chooses; the Galleries
|
||||||
{item.kind === 'photo' ? (
|
section arranges.
|
||||||
<img
|
*/}
|
||||||
src={`/api/fofos-admin${item.url}`}
|
<select
|
||||||
alt=""
|
value={home.gallery_id ?? ''}
|
||||||
className="size-10 shrink-0 rounded object-cover"
|
onChange={(event) =>
|
||||||
loading="lazy"
|
void send('gallery', () =>
|
||||||
/>
|
clientRef.current.patch<{ data: Home }>('/fofos-admin/home', {
|
||||||
) : (
|
gallery_id: event.target.value ? Number(event.target.value) : null,
|
||||||
<span className="flex size-10 shrink-0 items-center justify-center rounded bg-muted text-[10px]">
|
}),
|
||||||
{item.kind}
|
)
|
||||||
</span>
|
}
|
||||||
)}
|
disabled={busy !== null}
|
||||||
|
className="w-full rounded-md border border-border bg-background px-3 py-2 text-xs"
|
||||||
<span className="min-w-0 flex-1 space-y-1">
|
>
|
||||||
<span className="block truncate font-mono text-xs text-muted-foreground">{item.url}</span>
|
<option value="">None</option>
|
||||||
<Input
|
{galleries.map((gallery) => (
|
||||||
defaultValue={item.alt ?? ''}
|
<option key={gallery.id} value={gallery.id}>
|
||||||
onBlur={(event) => {
|
{gallery.name} · {gallery.items.length} item{gallery.items.length === 1 ? '' : 's'}
|
||||||
if (event.target.value.trim() !== (item.alt ?? '')) void setAlt(item, event.target.value);
|
</option>
|
||||||
}}
|
|
||||||
placeholder="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(index, -1)} disabled={busy !== null || index === 0}>
|
|
||||||
<ArrowUp className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void move(index, 1)}
|
|
||||||
disabled={busy !== null || index === home.gallery.length - 1}
|
|
||||||
>
|
|
||||||
<ArrowDown className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => void remove(item)} disabled={busy !== null}>
|
|
||||||
{busy === `del:${item.id}` ? (
|
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="size-3.5 text-destructive" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</select>
|
||||||
|
|
||||||
<div className="flex gap-2 border-t border-border pt-3">
|
{/* Only while it is true: the old rows are what renders until a gallery is chosen, and saying so
|
||||||
<Input
|
beats a carousel that appears to come from nowhere. */}
|
||||||
value={addUrl}
|
{home.gallery_id === null && home.gallery.length > 0 && (
|
||||||
onChange={(event) => setAddUrl(event.target.value)}
|
<p className="text-xs text-muted-foreground/70">
|
||||||
placeholder="/media/…"
|
Showing {home.gallery.length} item{home.gallery.length === 1 ? '' : 's'} from the old
|
||||||
className="min-w-0 flex-1 font-mono text-xs"
|
per-page list. Choosing a gallery replaces it.
|
||||||
autoComplete="off"
|
</p>
|
||||||
spellCheck={false}
|
)}
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-28 shrink-0 justify-center"
|
|
||||||
onClick={() => void add()}
|
|
||||||
disabled={busy !== null || !addUrl.trim()}
|
|
||||||
>
|
|
||||||
{busy === 'add' ? <Loader2 className="size-3.5 animate-spin" /> : <ImagePlus className="size-3.5" />}
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user