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:
@@ -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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>;
|
||||||
@@ -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>;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>;
|
||||||
@@ -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
@@ -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>;
|
||||||
@@ -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 },
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user