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,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>;
|
||||
Reference in New Issue
Block a user