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.
This commit is contained in:
2026-08-20 04:01:10 +00:00
parent f23ec1283b
commit 859fb5767c
14 changed files with 1211 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
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>
);
};
+248
View File
@@ -0,0 +1,248 @@
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>
);
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useEffect } from 'react';
import { useState } from 'react';
import { useAtelierRefresh } from 'officerdev';
import { useAtelierClient } from '../lib/api';
import type { GenerationRow } from '../lib/api';
export const useGalleryManager = () => {
const client = useAtelierClient();
const [rows, setRows] = useState<GenerationRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshSignal] = useAtelierRefresh();
const refresh = useCallback(async () => {
try {
setRows(await client.listGenerations());
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
// A finished generation elsewhere on the dashboard (the Generate panel) bumps this — re-read.
useEffect(() => {
if (refreshSignal > 0) void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshSignal]);
const succeeded = rows.filter((row) => row.status === 'succeeded' && row.image_path);
const totalCost = rows.reduce((sum, row) => sum + Number(row.cost_usd ?? 0), 0);
return { rows, succeeded, totalCost, loading, error, refresh };
};
export type GalleryManager = ReturnType<typeof useGalleryManager>;
+174
View File
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import type { GenerationRow } from '../lib/api';
export const MIN_SCALE = 1;
export const MAX_SCALE = 8;
/** What the image sits on. Not decoration: it is how you tell transparent from white. */
export const BACKDROPS = ['checker', 'dark', 'light'] as const;
export type Backdrop = (typeof BACKDROPS)[number];
export type Viewport = { scale: number; x: number; y: number };
export const RESET: Viewport = { scale: 1, x: 0, y: 0 };
export const clampScale = (scale: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
/**
* Zoom about a point rather than about the centre.
*
* `point` is the cursor's offset from the centre of the frame. Keeping whatever is under the cursor
* under the cursor is the difference between inspecting a detail and chasing it around the screen —
* which matters here, because the reason to zoom an icon is almost always to look at one edge of it.
*/
export function zoomAt(viewport: Viewport, factor: number, point: { x: number; y: number }): Viewport {
const scale = clampScale(viewport.scale * factor);
if (scale === viewport.scale) return viewport;
const ratio = scale / viewport.scale;
return {
scale,
x: point.x - (point.x - viewport.x) * ratio,
y: point.y - (point.y - viewport.y) * ratio,
};
}
/**
* Keep the image overlapping its frame.
*
* Without this, one flick of a drag sends the picture off-screen and the only way back is a control
* you can no longer see. The bound is half the overhang, so the image can always be pulled back.
*/
export function clampOffset(viewport: Viewport, frame: { width: number; height: number }): Viewport {
const overhangX = Math.max(0, (frame.width * viewport.scale - frame.width) / 2);
const overhangY = Math.max(0, (frame.height * viewport.scale - frame.height) / 2);
return {
scale: viewport.scale,
x: Math.min(overhangX, Math.max(-overhangX, viewport.x)),
y: Math.min(overhangY, Math.max(-overhangY, viewport.y)),
};
}
/** Only rows with a file can be opened; a failed generation has nothing to show. */
export const viewable = (rows: GenerationRow[]) => rows.filter((row) => row.status === 'succeeded' && row.image_path);
/**
* Which image is open lives in `?selected=<id>`, not local state — a lightbox is a thing worth linking
* to and worth surviving a refresh, and the URL is what `docs/navigation-audit.md` calls the source of
* truth for selection.
*/
export const useLightboxManager = (rows: GenerationRow[]) => {
const [searchParams, setSearchParams] = useSearchParams();
const openId = searchParams.get('selected');
const [viewport, setViewport] = useState<Viewport>(RESET);
const [backdrop, setBackdrop] = useState<Backdrop>('checker');
const images = viewable(rows);
const index = images.findIndex((row) => row.id === openId);
const current = index >= 0 ? images[index] : undefined;
const select = useCallback(
(id: string | null) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (id) next.set('selected', id);
else next.delete('selected');
return next;
},
{ replace: true },
);
},
[setSearchParams],
);
const close = useCallback(() => select(null), [select]);
const open = useCallback(
(id: string) => {
select(id);
setViewport(RESET);
},
[select],
);
// Wraps, because reaching the end of a gallery and being unable to press the same key again is a
// small, repeated annoyance.
const step = useCallback(
(delta: number) => {
if (images.length === 0 || index < 0) return;
const next = images[(index + delta + images.length) % images.length];
if (next) {
select(next.id);
setViewport(RESET);
}
},
[images, index, select],
);
const zoom = useCallback((factor: number, point = { x: 0, y: 0 }) => {
setViewport((current) => zoomAt(current, factor, point));
}, []);
const reset = useCallback(() => setViewport(RESET), []);
const pan = useCallback((dx: number, dy: number, frame: { width: number; height: number }) => {
setViewport((current) =>
current.scale <= 1 ? current : clampOffset({ ...current, x: current.x + dx, y: current.y + dy }, frame),
);
}, []);
// Bound to the document rather than the dialog, so the keys work before anything is focused and
// keep working after a click lands on the backdrop.
useEffect(() => {
if (!current) return;
const onKey = (event: KeyboardEvent) => {
const handlers: Record<string, () => void> = {
Escape: close,
ArrowRight: () => step(1),
ArrowLeft: () => step(-1),
'+': () => zoom(1.4),
'=': () => zoom(1.4),
'-': () => zoom(1 / 1.4),
'0': reset,
};
const handler = handlers[event.key];
if (handler) {
event.preventDefault();
handler();
}
};
document.addEventListener('keydown', onKey);
// The page behind must not scroll while a full-screen overlay is up.
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = previousOverflow;
};
}, [current, close, step, zoom, reset]);
return {
images,
current,
index,
isOpen: Boolean(current),
viewport,
backdrop,
setBackdrop,
open,
close,
step,
zoom,
reset,
pan,
};
};
export type LightboxManager = ReturnType<typeof useLightboxManager>;