Files
atelier/web/Gallery/Gallery.tsx
T
pastilhas 4533ef03ec fix(web): make every panel scroll
Each panel's root was a plain div with padding, so anything past the panel's
height was simply not reachable — the gallery showed its first two rows of
sixty images, and the Providers panel cut off mid-card. The shell hands a
panel a fixed-height box and does not scroll it for you; plugins/music has
had `h-full overflow-y-auto` on every panel root since it was written.

Collections gets it in both places, the list and the detail view, which are
two separate returns in one file and would otherwise disagree.
2026-08-20 21:15:39 +01:00

84 lines
3.3 KiB
TypeScript

import { Link } from 'react-router';
import { useSearchParams } from 'react-router';
import { Button } from '@/components/ui/button';
import { fileUrl } from '../lib/api';
import type { GenerationRow } from '../lib/api';
import { Lightbox } from './Lightbox';
import { useLightboxManager } from './useLightboxManager';
import { useGalleryManager } from './useGalleryManager';
export const Gallery = () => {
const manager = useGalleryManager();
// Hooks run before any early return, or the order changes between renders.
const lightbox = useLightboxManager(manager.rows);
const [searchParams] = useSearchParams();
if (manager.loading) return <p className="p-4 text-sm text-muted-foreground">Loading…</p>;
if (manager.error) return <p className="p-4 text-sm text-destructive">{manager.error}</p>;
if (manager.rows.length === 0) {
return <p className="p-4 text-sm text-muted-foreground">Nothing generated yet.</p>;
}
return (
<div className="h-full space-y-6 overflow-y-auto p-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{manager.succeeded.length} image{manager.succeeded.length === 1 ? '' : 's'}
{manager.totalCost > 0 && ` · $${manager.totalCost.toFixed(4)} total`}
</p>
<Button variant="ghost" size="sm" onClick={() => void manager.refresh()}>
Refresh
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{manager.rows.map((row) => (
<Tile key={row.id} row={row} searchParams={searchParams} />
))}
</div>
<Lightbox manager={lightbox} />
</div>
);
};
const Tile = ({ row, searchParams }: { row: GenerationRow; searchParams: URLSearchParams }) => {
const src = fileUrl(row.url);
const to = (() => {
const next = new URLSearchParams(searchParams);
next.set('selected', row.id);
return { search: `?${next.toString()}` };
})();
return (
<figure className="space-y-2">
<div className="flex aspect-square items-center justify-center overflow-hidden rounded-lg border border-border bg-muted/30">
{src ? (
// A real link, not a button with an onClick — the selection lives in the URL
// (`?selected=<id>`), so this is a navigation and it earns cmd-click, middle-click and
// focus-visible for free.
<Link
to={to}
aria-label={`Open: ${row.prompt.slice(0, 80)}`}
className="size-full cursor-zoom-in transition hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-400"
>
<img src={src} alt={row.prompt} loading="lazy" className="size-full object-cover" />
</Link>
) : (
<span className="px-3 text-center text-xs text-muted-foreground">{row.error ?? row.status}</span>
)}
</div>
<figcaption className="space-y-1">
<p className="line-clamp-2 text-xs text-foreground/80">{row.prompt}</p>
<p className="text-[11px] text-muted-foreground">
{row.model}
{row.cost_usd && ` · $${Number(row.cost_usd).toFixed(4)}`}
{row.duration_ms != null && ` · ${(row.duration_ms / 1000).toFixed(1)}s`}
</p>
</figcaption>
</figure>
);
};