Files
atelier/web/Gallery/Gallery.tsx
T
pastilhas 859fb5767c web: three panels — Generate, Gallery, Collections — and a layout
ported from the standalone app's hash-tab UI. The panels do not coordinate through props (the panel
contract only ever hands a component its panelId) or usePanelChannel-as-selection:

- Gallery's lightbox is ?selected=<id>, Collections' detail is ?collection=<id> — different names
  since both panels render on the same route at once
- Generate bumps useAtelierRefresh (added to officerdev/src/channels.ts) when a job finishes;
  Gallery subscribes to it — a genuine signal, not a selection, so it stays a channel

images render through GET /files/:filename with a ?token= query param appended client-side
(lib/api.ts's fileUrl), the same escape hatch FileViewer and the music plugin use for exactly this:
a browser cannot set an Authorization header on an <img src>.

Providers is not here — see PLUGIN.md for why it became a platform settings screen instead.

assets/icon.png and assets/splash.png are [placeholder] flat swatches, not real artwork — see
assets/PLACEHOLDER.md.
2026-08-20 04:01:10 +00:00

84 lines
3.2 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="space-y-6 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>
);
};