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