Files
atelier/web/Collections/Collections.tsx
T
pastilhas 4533ef03ec fix(web): make every panel scroll
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.
2026-08-20 21:15:39 +01:00

162 lines
6.0 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="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>
);
};