Each panel's root was a plain div with padding, so anything past the panel's height was simply not reachable — the gallery showed its first two rows of sixty images, and the Providers panel cut off mid-card. The shell hands a panel a fixed-height box and does not scroll it for you; plugins/music has had `h-full overflow-y-auto` on every panel root since it was written. Collections gets it in both places, the list and the detail view, which are two separate returns in one file and would otherwise disagree.
162 lines
6.0 KiB
TypeScript
162 lines
6.0 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="h-full space-y-6 overflow-y-auto 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="h-full space-y-6 overflow-y-auto 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>
|
||
);
|
||
};
|