Files
atelier/web/Collections/Collections.tsx
T
pastilhas 859fb5767c 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.
2026-08-20 04:01:10 +00:00

162 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};