Files
atelier/web/Gallery/Lightbox.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

249 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { fileUrl, useAtelierClient } from '../lib/api';
import type { CollectionSummary } from '../lib/api';
import { BACKDROPS, MAX_SCALE, MIN_SCALE } from './useLightboxManager';
import type { LightboxManager } from './useLightboxManager';
/**
* A checkerboard, drawn in CSS rather than shipped as an image.
*
* The default backdrop, and deliberately: every asset here is destined to be an app icon, so the
* question asked of a generated image is usually "is the background actually gone". Against a flat
* colour, a transparent PNG and an opaque one of that colour look identical.
*/
const CHECKER = 'repeating-conic-gradient(rgb(255 255 255 / 0.08) 0% 25%, transparent 0% 50%) 50% / 20px 20px';
export const Lightbox = ({ manager }: { manager: LightboxManager }) => {
const client = useAtelierClient();
const frameRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const [dragging, setDragging] = useState(false);
const [collections, setCollections] = useState<CollectionSummary[]>([]);
const [filedIn, setFiledIn] = useState<string | null>(null);
// Loaded once the lightbox is first opened rather than with the gallery: most sessions never file
// anything, and this is a request nobody asked for until they do.
useEffect(() => {
if (collections.length === 0 && manager.isOpen) {
client
.listCollections()
.then(setCollections)
.catch(() => {});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manager.isOpen, collections.length]);
const { current, viewport } = manager;
const fileInto = async (collectionId: string) => {
if (!current) return;
const collection = collections.find((row) => row.id === collectionId);
await client.addToCollection(collectionId, [current.id]);
setFiledIn(collection?.name ?? 'collection');
// Cleared on a timer rather than left up: it confirms an action, it is not state.
setTimeout(() => setFiledIn(null), 2000);
};
// Move focus in on open so the dialog's controls are reachable, and put it back on close rather
// than leaving it on <body> where the next Tab starts from the top of the page.
useEffect(() => {
if (!current) return;
const previous = document.activeElement as HTMLElement | null;
closeRef.current?.focus();
return () => previous?.focus?.();
}, [current]);
if (!current) return null;
const frame = () => {
const rect = frameRef.current?.getBoundingClientRect();
return { width: rect?.width ?? 0, height: rect?.height ?? 0 };
};
const onWheel = (event: ReactWheelEvent) => {
const rect = frameRef.current?.getBoundingClientRect();
if (!rect) return;
// Cursor position relative to the frame's centre — what zoomAt anchors on.
const point = {
x: event.clientX - rect.left - rect.width / 2,
y: event.clientY - rect.top - rect.height / 2,
};
manager.zoom(event.deltaY < 0 ? 1.15 : 1 / 1.15, point);
};
const onPointerDown = (event: ReactPointerEvent) => {
if (viewport.scale <= 1) return;
(event.target as Element).setPointerCapture?.(event.pointerId);
setDragging(true);
};
const onPointerMove = (event: ReactPointerEvent) => {
if (!dragging) return;
manager.pan(event.movementX, event.movementY, frame());
};
const stopDragging = () => setDragging(false);
const facts = [
current.width && current.height ? `${current.width}×${current.height}` : null,
current.model,
current.cost_usd ? `$${Number(current.cost_usd).toFixed(4)}` : null,
current.duration_ms != null ? `${(current.duration_ms / 1000).toFixed(1)}s` : null,
current.has_alpha === false ? 'no alpha' : current.opaque ? 'alpha unused — opaque' : 'transparent',
].filter(Boolean);
const imageSrc = fileUrl(current.url);
return (
<div
role="dialog"
aria-modal="true"
aria-label={current.prompt}
className="fixed inset-0 z-50 flex flex-col bg-black/90 backdrop-blur-sm"
// Only a click that both starts and ends on the backdrop closes — otherwise a drag that ends
// outside the image dismisses the thing you were dragging.
onClick={(event) => event.target === event.currentTarget && manager.close()}
>
<header className="flex items-center gap-3 px-4 py-3 text-sm text-neutral-300">
<span className="tabular-nums text-neutral-500">
{manager.index + 1} / {manager.images.length}
</span>
<span className="flex-1 truncate" title={current.prompt}>
{current.prompt}
</span>
<div className="flex items-center gap-1">
{BACKDROPS.map((name) => (
<button
key={name}
type="button"
onClick={() => manager.setBackdrop(name)}
aria-pressed={manager.backdrop === name}
className={cn(
'size-6 rounded border border-white/20',
manager.backdrop === name && 'ring-2 ring-amber-400 ring-offset-1 ring-offset-black',
)}
style={{
background: name === 'checker' ? CHECKER : name === 'dark' ? '#0a0a0a' : '#ffffff',
backgroundColor: name === 'checker' ? '#171717' : undefined,
}}
title={`${name} backdrop`}
/>
))}
</div>
{collections.length > 0 && (
<select
aria-label="Add to collection"
value=""
onChange={(event) => event.target.value && void fileInto(event.target.value)}
className="rounded-md border border-white/20 bg-transparent px-2 py-1 text-xs text-neutral-300"
>
<option value="">Add to…</option>
{collections.map((collection) => (
<option key={collection.id} value={collection.id} className="bg-neutral-900">
{collection.name}
</option>
))}
</select>
)}
{filedIn && <span className="text-xs text-amber-400">Added to {filedIn}</span>}
<Button ref={closeRef} variant="ghost" size="sm" onClick={manager.close} aria-label="Close">
Esc ✕
</Button>
</header>
<div
ref={frameRef}
className={cn(
'relative flex-1 overflow-hidden',
viewport.scale > 1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in',
)}
style={{
background: manager.backdrop === 'checker' ? CHECKER : undefined,
backgroundColor: manager.backdrop === 'light' ? '#ffffff' : manager.backdrop === 'dark' ? '#0a0a0a' : '#171717',
}}
onWheel={onWheel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={stopDragging}
onPointerLeave={stopDragging}
onDoubleClick={() => (viewport.scale > 1 ? manager.reset() : manager.zoom(2.5))}
>
{imageSrc && (
<img
// Keyed so React swaps the element between images rather than reusing one whose decoded
// bitmap is still the previous picture — otherwise stepping shows the old image scaled.
key={current.id}
src={imageSrc}
alt={current.prompt}
draggable={false}
className="absolute inset-0 m-auto max-h-full max-w-full select-none object-contain"
style={{
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
// Transitioning while dragging fights the pointer; only animate discrete jumps.
transition: dragging ? 'none' : 'transform 120ms ease-out',
imageRendering: viewport.scale >= 3 ? 'pixelated' : undefined,
}}
/>
)}
{manager.images.length > 1 && (
<>
<Step side="left" onClick={() => manager.step(-1)} />
<Step side="right" onClick={() => manager.step(1)} />
</>
)}
</div>
<footer className="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-3 text-xs text-neutral-400">
{facts.map((fact) => (
<span key={fact}>{fact}</span>
))}
<span className="tabular-nums">{Math.round(viewport.scale * 100)}%</span>
<div className="ml-auto flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => manager.zoom(1 / 1.4)} disabled={viewport.scale <= MIN_SCALE}>
−
</Button>
<Button variant="ghost" size="sm" onClick={manager.reset}>
Reset
</Button>
<Button variant="ghost" size="sm" onClick={() => manager.zoom(1.4)} disabled={viewport.scale >= MAX_SCALE}>
+
</Button>
{imageSrc && (
<a
href={imageSrc}
target="_blank"
rel="noreferrer"
className="rounded-md px-3 py-1.5 text-sm text-neutral-300 hover:text-white"
>
Open file
</a>
)}
</div>
</footer>
</div>
);
};
const Step = ({ side, onClick }: { side: 'left' | 'right'; onClick: () => void }) => (
<button
type="button"
onClick={onClick}
aria-label={side === 'left' ? 'Previous image' : 'Next image'}
className={cn(
'absolute top-1/2 -translate-y-1/2 rounded-full bg-black/50 px-3 py-4 text-lg text-white/70 transition hover:bg-black/70 hover:text-white',
side === 'left' ? 'left-3' : 'right-3',
)}
>
{side === 'left' ? '‹' : '›'}
</button>
);