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.
249 lines
9.6 KiB
TypeScript
249 lines
9.6 KiB
TypeScript
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>
|
||
);
|