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.
84 lines
3.2 KiB
TypeScript
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>
|
|
);
|
|
};
|