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.
162 lines
5.9 KiB
TypeScript
162 lines
5.9 KiB
TypeScript
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>
|
||
);
|
||
};
|