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
+9
View File
@@ -0,0 +1,9 @@
# [placeholder] artwork
`icon.png` (256×256) and `splash.png` (1024×1024) are flat `#f59e0b` swatches — the manifest's accent
colour, nothing more. No real Atelier artwork existed to carry over from the standalone service, and
drawing one is outside an unattended run's scope.
Replace both with real artwork before this plugin is published to the Marketplace; until then the dock
tile and any mobile launch screen will show a plain amber square. Grep for `[placeholder]` across this
plugin to find every other invented value.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+161
View File
@@ -0,0 +1,161 @@
import { Link, useSearchParams } from 'react-router';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { fileUrl } from '../lib/api';
import type { CollectionSummary, GenerationRow } from '../lib/api';
import { useCollectionsManager } from './useCollectionsManager';
import type { CollectionsManager } from './useCollectionsManager';
export const Collections = () => {
const [searchParams] = useSearchParams();
const openId = searchParams.get('collection') ?? undefined;
const manager = useCollectionsManager(openId);
if (manager.loading) return <p className="p-4 text-sm text-muted-foreground">Loading…</p>;
if (openId) return <Detail manager={manager} searchParams={searchParams} />;
return (
<div className="space-y-6 p-4">
<div className="flex gap-2">
<Input
placeholder="New collection — an afternoon, a suite, a client"
value={manager.draftName}
onChange={(ev) => manager.setDraftName(ev.target.value)}
onKeyDown={(ev) => ev.key === 'Enter' && manager.create()}
/>
<Button onClick={manager.create} disabled={!manager.draftName.trim()}>
Create
</Button>
</div>
{manager.error && <p className="text-sm text-destructive">{manager.error}</p>}
{manager.collections.length === 0 ? (
<p className="text-sm text-muted-foreground">
No collections yet. Create one here, or pass <code className="text-foreground">?collection=name</code>{' '}
when generating and it will be made for you.
</p>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{manager.collections.map((collection) => (
<Card
key={collection.id}
collection={collection}
searchParams={searchParams}
onDelete={() => manager.remove(collection.id)}
/>
))}
</div>
)}
</div>
);
};
type CardProps = { collection: CollectionSummary; searchParams: URLSearchParams; onDelete: () => void };
const Card = ({ collection, searchParams, onDelete }: CardProps) => {
const src = fileUrl(collection.cover_url);
const to = (() => {
const next = new URLSearchParams(searchParams);
next.set('collection', collection.id);
return { search: `?${next.toString()}` };
})();
return (
<div className="group relative overflow-hidden rounded-lg border border-border">
<Link to={to} className="block">
<div className="flex aspect-video items-center justify-center bg-muted/30">
{src ? (
<img src={src} alt="" loading="lazy" className="size-full object-cover" />
) : (
<span className="text-xs text-muted-foreground">empty</span>
)}
</div>
<div className="space-y-0.5 p-3">
<p className="truncate text-sm font-medium">{collection.name}</p>
<p className="text-xs text-muted-foreground">
{collection.item_count} image{collection.item_count === 1 ? '' : 's'}
</p>
</div>
</Link>
<button
type="button"
onClick={onDelete}
// Deleting a collection does not delete images, which is why this needs no confirmation step.
title="Delete collection (images are kept)"
className="absolute right-2 top-2 rounded bg-black/60 px-2 py-1 text-xs text-white/70 opacity-0 transition group-hover:opacity-100 hover:text-white"
>
Delete
</button>
</div>
);
};
type DetailProps = { manager: CollectionsManager; searchParams: URLSearchParams };
const Detail = ({ manager, searchParams }: DetailProps) => {
const collection = manager.detail;
const backTo = (() => {
const next = new URLSearchParams(searchParams);
next.delete('collection');
const search = next.toString();
return { search: search ? `?${search}` : '' };
})();
if (!collection) return <p className="p-4 text-sm text-muted-foreground">Loading…</p>;
return (
<div className="space-y-6 p-4">
<div className="flex items-baseline gap-3">
<Link to={backTo} className="text-sm text-muted-foreground hover:text-foreground">
‹ Collections
</Link>
<h2 className="text-lg font-semibold">{collection.name}</h2>
<span className="text-sm text-muted-foreground">
{collection.images.length} image{collection.images.length === 1 ? '' : 's'}
</span>
</div>
{collection.images.length === 0 ? (
<p className="text-sm text-muted-foreground">
Nothing here yet. Add images from the gallery, or generate with{' '}
<code className="text-foreground">?collection={collection.name}</code>.
</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{collection.images.map((image) => (
<Item key={image.id} image={image} onRemove={() => manager.removeImage(collection.id, image.id)} />
))}
</div>
)}
</div>
);
};
const Item = ({ image, onRemove }: { image: GenerationRow; onRemove: () => void }) => {
const src = fileUrl(image.url);
return (
<figure className="group relative space-y-2">
<div className="flex aspect-square items-center justify-center overflow-hidden rounded-lg border border-border bg-muted/30">
{src ? (
<img src={src} alt={image.prompt} loading="lazy" className="size-full object-cover" />
) : (
<span className="px-3 text-center text-xs text-muted-foreground">{image.error ?? image.status}</span>
)}
</div>
<button
type="button"
onClick={onRemove}
title="Remove from this collection"
className="absolute right-2 top-2 rounded bg-black/60 px-2 py-1 text-xs text-white/70 opacity-0 transition group-hover:opacity-100 hover:text-white"
>
Remove
</button>
<figcaption className="line-clamp-2 text-xs text-muted-foreground">{image.prompt}</figcaption>
</figure>
);
};
+76
View File
@@ -0,0 +1,76 @@
import { useCallback, useEffect, useState } from 'react';
import { useAtelierClient } from '../lib/api';
import type { CollectionDetail, CollectionSummary } from '../lib/api';
export const useCollectionsManager = (openId?: string) => {
const client = useAtelierClient();
const [collections, setCollections] = useState<CollectionSummary[]>([]);
const [detail, setDetail] = useState<CollectionDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [draftName, setDraftName] = useState('');
const refresh = useCallback(async () => {
try {
setCollections(await client.listCollections());
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]);
// The detail view is driven by the URL, so opening one is a navigation and this only follows it.
useEffect(() => {
if (!openId) {
setDetail(null);
return;
}
let cancelled = false;
client
.getCollection(openId)
.then((data) => !cancelled && setDetail(data))
.catch((err: Error) => !cancelled && setError(err.message));
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openId]);
const create = async () => {
const name = draftName.trim();
if (!name) return;
try {
await client.createCollection(name);
setDraftName('');
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const remove = async (id: string) => {
try {
await client.deleteCollection(id);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const removeImage = async (collectionId: string, generationId: string) => {
const { images } = await client.removeFromCollection(collectionId, generationId);
setDetail((current) => (current ? { ...current, images } : current));
void refresh();
};
return { collections, detail, loading, error, draftName, setDraftName, create, remove, removeImage, refresh };
};
export type CollectionsManager = ReturnType<typeof useCollectionsManager>;
+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>;
+146
View File
@@ -0,0 +1,146 @@
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { fileUrl } from '../lib/api';
import { BACKGROUNDS, QUALITIES, SIZES, useGenerateManager } from './useGenerateManager';
export const Generate = () => {
const manager = useGenerateManager();
if (manager.providers.length > 0 && manager.usableProviders.length === 0) {
return (
<Card>
<CardContent className="pt-6 text-sm text-muted-foreground">
No provider is ready. Add an API key in Settings → Integrations → Atelier first.
</CardContent>
</Card>
);
}
return (
<div className="space-y-6 p-4">
<div className="space-y-2">
<Label htmlFor="atelier-prompt">Prompt</Label>
<Textarea
id="atelier-prompt"
rows={4}
placeholder="a voxel duck in a beret, painting at an easel"
value={manager.prompt}
onChange={(ev) => manager.setPrompt(ev.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Model" id="atelier-model">
<Select value={manager.model} onValueChange={manager.setModel}>
<SelectTrigger id="atelier-model" className="w-full">
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>
{manager.models.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Count" id="atelier-n">
<Input
id="atelier-n"
type="number"
min={1}
max={10}
value={manager.n}
onChange={(ev) => manager.setN(Math.min(10, Math.max(1, Number(ev.target.value) || 1)))}
/>
</Field>
<Choice label="Size" id="atelier-size" value={manager.size} onChange={manager.setSize} options={SIZES} />
<Choice
label="Quality"
id="atelier-quality"
value={manager.quality}
onChange={manager.setQuality}
options={QUALITIES}
/>
<Choice
label="Background"
id="atelier-background"
value={manager.background}
onChange={manager.setBackground}
options={BACKGROUNDS}
/>
</div>
<div className="flex items-center gap-4">
<Button disabled={!manager.ready} onClick={manager.generate}>
{manager.running ? 'Generating…' : 'Generate'}
</Button>
{manager.job?.atelier && manager.running && (
<span className="text-sm text-muted-foreground">{manager.job.atelier.status}…</span>
)}
</div>
{manager.error && <p className="text-sm text-destructive">{manager.error}</p>}
{manager.job?.data && manager.job.data.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2">
{manager.job.data.map((image) => (
<figure key={image.url} className="space-y-2">
<img src={fileUrl(image.url) ?? undefined} alt={manager.prompt} className="w-full rounded-lg border border-border" />
<figcaption className="truncate text-xs text-muted-foreground">{image.revised_prompt ?? ''}</figcaption>
</figure>
))}
</div>
)}
{manager.job?.atelier?.images.some((image) => image.error) && (
<ul className="space-y-1 text-sm text-destructive">
{manager.job.atelier.images
.filter((image) => image.error)
.map((image) => (
<li key={image.id}>{image.error}</li>
))}
</ul>
)}
</div>
);
};
const Field = ({ label, id, children }: { label: string; id: string; children: ReactNode }) => (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
type ChoiceProps = {
label: string;
id: string;
value: string;
onChange: (value: string) => void;
options: readonly string[];
};
const Choice = ({ label, id, value, onChange, options }: ChoiceProps) => (
<Field label={label} id={id}>
<Select value={value} onValueChange={onChange}>
<SelectTrigger id={id} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import { useAtelierRefresh } from 'officerdev';
import { useAtelierClient } from '../lib/api';
import type { JobResponse, ProviderStatus } from '../lib/api';
export const SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const;
export const QUALITIES = ['auto', 'low', 'medium', 'high'] as const;
export const BACKGROUNDS = ['auto', 'transparent', 'opaque'] as const;
export const useGenerateManager = () => {
const client = useAtelierClient();
const [, bumpRefresh] = useAtelierRefresh();
const [providers, setProviders] = useState<ProviderStatus[]>([]);
const [model, setModel] = useState('');
const [prompt, setPrompt] = useState('');
const [n, setN] = useState(1);
const [size, setSize] = useState<string>('auto');
const [quality, setQuality] = useState<string>('auto');
const [background, setBackground] = useState<string>('auto');
const [job, setJob] = useState<JobResponse | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
client
.listProviders()
.then((rows) => {
setProviders(rows);
const usable =
rows.find((row) => row.is_default && row.configured && row.enabled) ??
rows.find((row) => row.configured && row.enabled);
if (usable) setModel(usable.model);
})
.catch((err: Error) => setError(err.message));
// Loaded once on mount — the providers screen (Settings → Integrations → Atelier) is where they
// are managed, and this panel does not need to react to edits made there in real time.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Derived in the hook, not in the component.
const usableProviders = providers.filter((provider) => provider.configured && provider.enabled);
// One model per configured provider, deduped: two providers on the same model is legitimate (two
// keys, two accounts) and the request picks between them by model alone.
const models = [...new Set(usableProviders.map((provider) => provider.model))];
const ready = Boolean(prompt.trim()) && Boolean(model) && !running;
async function generate() {
if (!ready) return;
setRunning(true);
setError(null);
setJob(null);
try {
// Only what the user actually chose. `auto` is the provider's own default, so sending it would
// be inventing a parameter they did not set.
const body: Record<string, unknown> = { prompt: prompt.trim(), model, n };
if (size !== 'auto') body.size = size;
if (quality !== 'auto') body.quality = quality;
if (background !== 'auto') body.background = background;
const { id } = await client.startGeneration(body);
// Long-poll in a loop: each call waits up to 30s server-side, so this is one request per half
// minute rather than a busy poll, and it survives a generation that takes longer.
for (;;) {
const result = await client.awaitGeneration(id, 30);
if (result.atelier && ['succeeded', 'failed', 'partial'].includes(result.atelier.status)) {
setJob(result);
bumpRefresh();
return;
}
setJob(result);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setRunning(false);
}
}
return {
providers,
usableProviders,
models,
model,
setModel,
prompt,
setPrompt,
n,
setN,
size,
setSize,
quality,
setQuality,
background,
setBackground,
job,
running,
error,
ready,
generate,
};
};
export type GenerateManager = ReturnType<typeof useGenerateManager>;
+15
View File
@@ -0,0 +1,15 @@
import type { LayoutNode } from 'officerdev';
// Generate stays narrow and fixed — it is a form, not a working area. Gallery gets the most room: it
// is the main "what did I make" view. Collections is the organising view, smallest of the three. Users
// can resize or rearrange freely; this is only the starting point.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'atelier-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'atelier-generate', appType: 'atelier-generate' }, size: 26 },
{ node: { type: 'panel', id: 'atelier-gallery', appType: 'atelier-gallery' }, size: 48 },
{ node: { type: 'panel', id: 'atelier-collections', appType: 'atelier-collections' }, size: 26 },
],
};
+130
View File
@@ -0,0 +1,130 @@
import { getHeaders, useClient } from 'hooks/useClient';
// Every call the panels make to this plugin's own sidecar, through `/api/atelier`.
//
// `useClient('/api/atelier')` gives typed verbs for JSON; `fileUrl` below is the one thing a client
// call cannot do — put a bearer token on an `<img src>`.
const ATELIER_API = '/api/atelier';
/** A configured connection: adapter + model + key. Several may share one adapter. */
export type ProviderStatus = {
id: string;
name: string;
adapter: string;
model: string;
enabled: boolean;
is_default: boolean;
configured: boolean;
key_source: 'database' | 'environment' | null;
env_var: string;
created_at: string;
updated_at: string;
};
/** The code side — what a new provider can be built on. */
export type Adapter = {
name: string;
label: string;
models: string[];
default_model: string;
env_var: string;
supports_edit: boolean;
};
/** Snake case, matching the server — one spelling per field across every endpoint. */
export type GenerationRow = {
id: string;
request_id: string;
index: number;
operation: 'generate' | 'edit';
status: 'queued' | 'running' | 'succeeded' | 'failed';
provider: string;
model: string;
prompt: string;
options: Record<string, unknown> | null;
input_images: string[] | null;
mask_image: string | null;
image_path: string | null;
url: string | null;
width: number | null;
height: number | null;
has_alpha: boolean | null;
opaque: boolean | null;
cost_usd: string | null;
duration_ms: number | null;
error: string | null;
created_at: string;
};
export type JobResponse = {
id?: string;
status?: string;
created?: number;
data?: { url: string; revised_prompt?: string; b64_json?: string }[];
atelier?: {
id: string;
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'partial';
cost_usd: string | null;
duration_ms: number;
images: { id: string; status: string; url: string | null; error: string | null }[];
};
error?: { message: string; param?: string | null };
};
export type Collection = {
id: string;
name: string;
description: string | null;
created_at: string;
updated_at: string;
};
export type CollectionSummary = Collection & { item_count: number; cover_image_path: string | null; cover_url: string | null };
export type CollectionDetail = Collection & { images: GenerationRow[] };
/**
* `row.url` (e.g. `/files/<name>`) with this session's bearer token appended, for an `<img src>`.
*
* Browsers cannot set an `Authorization` header on an `<img>` request, so the token travels in the
* query string instead — the same escape hatch `FileViewer/file-types.ts` and the music plugin's
* `MusicPlayerHost` already use for exactly this reason (`permission-gate.ts` and `user-middleware.ts`
* both read `?token=` as well as the header).
*/
export function fileUrl(path: string | null): string | null {
if (!path) return null;
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
return `${ATELIER_API}${path}?token=${encodeURIComponent(token)}`;
}
export function useAtelierClient() {
const client = useClient(ATELIER_API);
return {
...client,
listProviders: () => client.get<{ data: ProviderStatus[] }>('providers').then((r) => r.data),
listAdapters: () => client.get<{ data: Adapter[] }>('providers/adapters').then((r) => r.data),
listGenerations: (limit = 60) => client.get<{ data: GenerationRow[] }>(`images?limit=${limit}`).then((r) => r.data),
startGeneration: (body: Record<string, unknown>) => client.post<{ id: string }>('images/generations', body),
/** Long-polls up to `waitSeconds` server-side, so the panel does not run its own retry loop. */
awaitGeneration: (id: string, waitSeconds = 30) => client.get<JobResponse>(`images/generations/${id}?wait=${waitSeconds}`),
listCollections: () => client.get<{ data: CollectionSummary[] }>('collections').then((r) => r.data),
getCollection: (id: string) => client.get<{ data: CollectionDetail }>(`collections/${id}`).then((r) => r.data),
createCollection: (name: string) =>
client.post<{ data: Collection }>('collections', { name }).then((r) => r.data),
deleteCollection: (id: string) => client.delete<{ data: unknown }>(`collections/${id}`),
addToCollection: (id: string, generationIds: string[]) =>
client
.post<{ data: { added: number; images: GenerationRow[] } }>(`collections/${id}/items`, { generationIds })
.then((r) => r.data),
removeFromCollection: (id: string, generationId: string) =>
client.delete<{ data: { images: GenerationRow[] } }>(`collections/${id}/items/${generationId}`).then((r) => r.data),
};
}
export type UseAtelierClientType = ReturnType<typeof useAtelierClient>;
+19
View File
@@ -0,0 +1,19 @@
import type { AppRegistryMeta } from 'officerdev';
import { Sparkles, GalleryHorizontalEnd, FolderOpen } from 'lucide-react';
import { Generate } from './Generate/Generate';
import { Gallery } from './Gallery/Gallery';
import { Collections } from './Collections/Collections';
// No component re-exports. `panels.ts` declares PANELS — the shell mounts them by key and there is no
// way to export a component from a plugin.
//
// The three coordinate through the URL and one refresh channel, never through props: Gallery reads
// `?selected=<id>` for its lightbox, Collections reads `?collection=<id>` for its detail view — two
// different param names on purpose, since both panels render on the same route at once and would
// otherwise fight over one param. Generate bumps `useAtelierRefresh` (officerdev/src/channels.ts) when
// a job finishes; Gallery subscribes to it. See plugins/atelier/PLUGIN.md.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'atelier-generate', name: 'Generate', icon: Sparkles, component: Generate, availableOnPanel: false },
{ key: 'atelier-gallery', name: 'Gallery', icon: GalleryHorizontalEnd, component: Gallery, availableOnPanel: false },
{ key: 'atelier-collections', name: 'Collections', icon: FolderOpen, component: Collections, availableOnPanel: false },
];