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(null); const closeRef = useRef(null); const [dragging, setDragging] = useState(false); const [collections, setCollections] = useState([]); const [filedIn, setFiledIn] = useState(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 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 (
event.target === event.currentTarget && manager.close()} >
{manager.index + 1} / {manager.images.length} {current.prompt}
{BACKDROPS.map((name) => (
{collections.length > 0 && ( )} {filedIn && Added to {filedIn}}
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 && ( {current.prompt}= 3 ? 'pixelated' : undefined, }} /> )} {manager.images.length > 1 && ( <> manager.step(-1)} /> manager.step(1)} /> )}
{facts.map((fact) => ( {fact} ))} {Math.round(viewport.scale * 100)}%
{imageSrc && ( Open file )}
); }; const Step = ({ side, onClick }: { side: 'left' | 'right'; onClick: () => void }) => ( );