diff --git a/assets/PLACEHOLDER.md b/assets/PLACEHOLDER.md new file mode 100644 index 0000000..694008e --- /dev/null +++ b/assets/PLACEHOLDER.md @@ -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. diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..ce04612 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/splash.png b/assets/splash.png new file mode 100644 index 0000000..c8788df Binary files /dev/null and b/assets/splash.png differ diff --git a/web/Collections/Collections.tsx b/web/Collections/Collections.tsx new file mode 100644 index 0000000..e2c79a2 --- /dev/null +++ b/web/Collections/Collections.tsx @@ -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

Loading…

; + if (openId) return ; + + return ( +
+
+ manager.setDraftName(ev.target.value)} + onKeyDown={(ev) => ev.key === 'Enter' && manager.create()} + /> + +
+ + {manager.error &&

{manager.error}

} + + {manager.collections.length === 0 ? ( +

+ No collections yet. Create one here, or pass ?collection=name{' '} + when generating and it will be made for you. +

+ ) : ( +
+ {manager.collections.map((collection) => ( + manager.remove(collection.id)} + /> + ))} +
+ )} +
+ ); +}; + +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 ( +
+ +
+ {src ? ( + + ) : ( + empty + )} +
+
+

{collection.name}

+

+ {collection.item_count} image{collection.item_count === 1 ? '' : 's'} +

+
+ + + +
+ ); +}; + +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

Loading…

; + + return ( +
+
+ + ‹ Collections + +

{collection.name}

+ + {collection.images.length} image{collection.images.length === 1 ? '' : 's'} + +
+ + {collection.images.length === 0 ? ( +

+ Nothing here yet. Add images from the gallery, or generate with{' '} + ?collection={collection.name}. +

+ ) : ( +
+ {collection.images.map((image) => ( + manager.removeImage(collection.id, image.id)} /> + ))} +
+ )} +
+ ); +}; + +const Item = ({ image, onRemove }: { image: GenerationRow; onRemove: () => void }) => { + const src = fileUrl(image.url); + return ( +
+
+ {src ? ( + {image.prompt} + ) : ( + {image.error ?? image.status} + )} +
+ + + +
{image.prompt}
+
+ ); +}; diff --git a/web/Collections/useCollectionsManager.ts b/web/Collections/useCollectionsManager.ts new file mode 100644 index 0000000..9beeb47 --- /dev/null +++ b/web/Collections/useCollectionsManager.ts @@ -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([]); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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; diff --git a/web/Gallery/Gallery.tsx b/web/Gallery/Gallery.tsx new file mode 100644 index 0000000..1e7388d --- /dev/null +++ b/web/Gallery/Gallery.tsx @@ -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

Loading…

; + if (manager.error) return

{manager.error}

; + + if (manager.rows.length === 0) { + return

Nothing generated yet.

; + } + + return ( +
+
+

+ {manager.succeeded.length} image{manager.succeeded.length === 1 ? '' : 's'} + {manager.totalCost > 0 && ` · $${manager.totalCost.toFixed(4)} total`} +

+ +
+ +
+ {manager.rows.map((row) => ( + + ))} +
+ + +
+ ); +}; + +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 ( +
+
+ {src ? ( + // A real link, not a button with an onClick — the selection lives in the URL + // (`?selected=`), so this is a navigation and it earns cmd-click, middle-click and + // focus-visible for free. + + {row.prompt} + + ) : ( + {row.error ?? row.status} + )} +
+ +
+

{row.prompt}

+

+ {row.model} + {row.cost_usd && ` · $${Number(row.cost_usd).toFixed(4)}`} + {row.duration_ms != null && ` · ${(row.duration_ms / 1000).toFixed(1)}s`} +

+
+
+ ); +}; diff --git a/web/Gallery/Lightbox.tsx b/web/Gallery/Lightbox.tsx new file mode 100644 index 0000000..2a8c7c0 --- /dev/null +++ b/web/Gallery/Lightbox.tsx @@ -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(null); + const closeRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [collections, setCollections] = useState([]); + const [filedIn, setFiledIn] = useState(null); + + // Loaded once the lightbox is first opened rather than with the gallery: most sessions never file + // anything, and this is a request nobody asked for until they do. + useEffect(() => { + if (collections.length === 0 && manager.isOpen) { + client + .listCollections() + .then(setCollections) + .catch(() => {}); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manager.isOpen, collections.length]); + + const { current, viewport } = manager; + + const fileInto = async (collectionId: string) => { + if (!current) return; + const collection = collections.find((row) => row.id === collectionId); + await client.addToCollection(collectionId, [current.id]); + setFiledIn(collection?.name ?? 'collection'); + // Cleared on a timer rather than left up: it confirms an action, it is not state. + setTimeout(() => setFiledIn(null), 2000); + }; + + // Move focus in on open so the dialog's controls are reachable, and put it back on close rather + // than leaving it on where the next Tab starts from the top of the page. + useEffect(() => { + if (!current) return; + const previous = document.activeElement as HTMLElement | null; + closeRef.current?.focus(); + return () => previous?.focus?.(); + }, [current]); + + if (!current) return null; + + const frame = () => { + const rect = frameRef.current?.getBoundingClientRect(); + return { width: rect?.width ?? 0, height: rect?.height ?? 0 }; + }; + + const onWheel = (event: ReactWheelEvent) => { + const rect = frameRef.current?.getBoundingClientRect(); + if (!rect) return; + // Cursor position relative to the frame's centre — what zoomAt anchors on. + const point = { + x: event.clientX - rect.left - rect.width / 2, + y: event.clientY - rect.top - rect.height / 2, + }; + manager.zoom(event.deltaY < 0 ? 1.15 : 1 / 1.15, point); + }; + + const onPointerDown = (event: ReactPointerEvent) => { + if (viewport.scale <= 1) return; + (event.target as Element).setPointerCapture?.(event.pointerId); + setDragging(true); + }; + + const onPointerMove = (event: ReactPointerEvent) => { + if (!dragging) return; + manager.pan(event.movementX, event.movementY, frame()); + }; + + const stopDragging = () => setDragging(false); + + const facts = [ + current.width && current.height ? `${current.width}×${current.height}` : null, + current.model, + current.cost_usd ? `$${Number(current.cost_usd).toFixed(4)}` : null, + current.duration_ms != null ? `${(current.duration_ms / 1000).toFixed(1)}s` : null, + current.has_alpha === false ? 'no alpha' : current.opaque ? 'alpha unused — opaque' : 'transparent', + ].filter(Boolean); + + const imageSrc = fileUrl(current.url); + + return ( +
event.target === event.currentTarget && manager.close()} + > +
+ + {manager.index + 1} / {manager.images.length} + + + {current.prompt} + + +
+ {BACKDROPS.map((name) => ( +
+ + {collections.length > 0 && ( + + )} + + {filedIn && Added to {filedIn}} + + +
+ +
1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in', + )} + style={{ + background: manager.backdrop === 'checker' ? CHECKER : undefined, + backgroundColor: manager.backdrop === 'light' ? '#ffffff' : manager.backdrop === 'dark' ? '#0a0a0a' : '#171717', + }} + onWheel={onWheel} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={stopDragging} + onPointerLeave={stopDragging} + onDoubleClick={() => (viewport.scale > 1 ? manager.reset() : manager.zoom(2.5))} + > + {imageSrc && ( + {current.prompt}= 3 ? 'pixelated' : undefined, + }} + /> + )} + + {manager.images.length > 1 && ( + <> + manager.step(-1)} /> + manager.step(1)} /> + + )} +
+ +
+ {facts.map((fact) => ( + {fact} + ))} + {Math.round(viewport.scale * 100)}% + +
+ + + + {imageSrc && ( + + Open file + + )} +
+
+
+ ); +}; + +const Step = ({ side, onClick }: { side: 'left' | 'right'; onClick: () => void }) => ( + +); diff --git a/web/Gallery/useGalleryManager.ts b/web/Gallery/useGalleryManager.ts new file mode 100644 index 0000000..043b8c8 --- /dev/null +++ b/web/Gallery/useGalleryManager.ts @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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; diff --git a/web/Gallery/useLightboxManager.ts b/web/Gallery/useLightboxManager.ts new file mode 100644 index 0000000..b184058 --- /dev/null +++ b/web/Gallery/useLightboxManager.ts @@ -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=`, 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(RESET); + const [backdrop, setBackdrop] = useState('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 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; diff --git a/web/Generate/Generate.tsx b/web/Generate/Generate.tsx new file mode 100644 index 0000000..a6a51ab --- /dev/null +++ b/web/Generate/Generate.tsx @@ -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 ( + + + No provider is ready. Add an API key in Settings → Integrations → Atelier first. + + + ); + } + + return ( +
+
+ +