refactor(web): two panels on the left, the work on the right
Four panels became three, and the arrangement carries the change: everything that organises or configures is stacked in one narrow column, and the whole of the rest is one surface for looking at images. Collections is a LIST, not a grid of covers — it sits in a narrow column beside the gallery, where two cards per row meant four collections visible. A row is a name, a count, and rename/delete on hover. Create is a dialog rather than an always-present input, and a filter field replaces it in that space, since finding one is the frequent act and making one is not. Selecting a collection now shows it in the GALLERY. It used to replace the list inside its own panel, which meant the selector disappeared the moment it was used, and both panels kept their own copy of one collection's images and disagreed after any edit. `?collection=` is read by the gallery now; the collections hook stopped fetching detail entirely. Deleting asks the question that cannot be undone: keep the images, or delete them too. That needed a server route — DELETE /collections/:id?images=delete — which reads the members BEFORE the collection goes, since deleting it takes the memberships with it. Unchecked by default: the recoverable answer is the one you get by pressing Enter without reading. Providers stopped being a panel. It was a card per provider, each with a key field and three buttons, filling a quarter of the screen for something set once — it is a dropdown at the top of Generate now, listing "adapter · model" with a delete on each, and adding one opens a dialog. The form and the dropdown share one provider list (useProviders); they were two fetches, so adding a provider left the form showing the old models until a reload.
This commit is contained in:
+248
-135
@@ -1,161 +1,274 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Folder, FolderOpen, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { fileUrl } from '../lib/api';
|
||||
import type { CollectionSummary, GenerationRow } from '../lib/api';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CollectionSummary } from '../lib/api';
|
||||
import { useCollectionsManager } from './useCollectionsManager';
|
||||
import type { CollectionsManager } from './useCollectionsManager';
|
||||
|
||||
/**
|
||||
* The collection list: a filter, a create button, and one row per collection.
|
||||
*
|
||||
* Rows rather than a grid of covers. A grid of thumbnails is a browsing surface and this panel is a
|
||||
* selector — it lives in a narrow column beside the gallery, where two cards per row means four
|
||||
* collections visible and a lot of scrolling to find the one you meant. A row is a name, a count and
|
||||
* its two actions.
|
||||
*
|
||||
* Selecting one is a LINK, writing `?collection=<id>`. The Gallery panel reads that param and shows
|
||||
* the images; nothing here fetches them. That is the whole reason the detail view left this file.
|
||||
*/
|
||||
export const Collections = () => {
|
||||
const manager = useCollectionsManager();
|
||||
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} />;
|
||||
const selectedId = searchParams.get('collection');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [renaming, setRenaming] = useState<CollectionSummary | null>(null);
|
||||
const [deleting, setDeleting] = useState<CollectionSummary | null>(null);
|
||||
|
||||
return (
|
||||
<div className="h-full space-y-6 overflow-y-auto p-4">
|
||||
<div className="flex h-full flex-col gap-3 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()}
|
||||
placeholder="Filter collections"
|
||||
value={manager.query}
|
||||
onChange={(ev) => manager.setQuery(ev.target.value)}
|
||||
/>
|
||||
<Button onClick={manager.create} disabled={!manager.draftName.trim()}>
|
||||
Create
|
||||
<Button size="icon" variant="secondary" title="New collection" onClick={() => setCreating(true)}>
|
||||
<Plus className="size-4" />
|
||||
</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'}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{manager.loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : manager.collections.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{manager.total === 0 ? (
|
||||
<>
|
||||
No collections yet. Create one, or pass <code className="text-foreground">?collection=name</code> when
|
||||
generating and it will be made for you.
|
||||
</>
|
||||
) : (
|
||||
'Nothing matches that filter.'
|
||||
)}
|
||||
</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>
|
||||
<ul className="space-y-0.5">
|
||||
{manager.collections.map((collection) => (
|
||||
<Row
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
selected={collection.id === selectedId}
|
||||
searchParams={searchParams}
|
||||
onRename={() => setRenaming(collection)}
|
||||
onDelete={() => setDeleting(collection)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</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>
|
||||
<NameDialog
|
||||
open={creating}
|
||||
title="New collection"
|
||||
description="An afternoon, a suite, a client — whatever you will look for it by."
|
||||
confirmLabel="Create"
|
||||
onClose={() => setCreating(false)}
|
||||
onSubmit={(name) => manager.create(name)}
|
||||
/>
|
||||
|
||||
<figcaption className="line-clamp-2 text-xs text-muted-foreground">{image.prompt}</figcaption>
|
||||
</figure>
|
||||
<NameDialog
|
||||
open={!!renaming}
|
||||
title="Rename collection"
|
||||
initial={renaming?.name ?? ''}
|
||||
confirmLabel="Rename"
|
||||
onClose={() => setRenaming(null)}
|
||||
onSubmit={(name) => manager.rename(renaming!.id, name)}
|
||||
/>
|
||||
|
||||
<DeleteDialog collection={deleting} manager={manager} onClose={() => setDeleting(null)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type RowProps = {
|
||||
collection: CollectionSummary;
|
||||
selected: boolean;
|
||||
searchParams: URLSearchParams;
|
||||
onRename: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const Row = ({ collection, selected, searchParams, onRename, onDelete }: RowProps) => {
|
||||
const to = (() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set('collection', collection.id);
|
||||
// The lightbox selection belongs to whatever was being looked at before; carrying it into another
|
||||
// collection opens an image that is not in the list you just switched to.
|
||||
next.delete('selected');
|
||||
return { search: `?${next.toString()}` };
|
||||
})();
|
||||
|
||||
const Icon = selected ? FolderOpen : Folder;
|
||||
|
||||
return (
|
||||
<li className={cn('group flex items-center rounded-md pr-1', selected ? 'bg-accent' : 'hover:bg-accent/50')}>
|
||||
<Link to={to} className="flex min-w-0 flex-1 items-center gap-2 px-2 py-2">
|
||||
<Icon className={cn('size-4 shrink-0', selected ? 'text-foreground' : 'text-muted-foreground')} />
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{collection.name}</span>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{collection.item_count}</span>
|
||||
</Link>
|
||||
|
||||
{/* Visible on hover, and on keyboard focus — `opacity-0` alone would hide a button you can still
|
||||
tab to, which is a control nobody can see but everybody can press. */}
|
||||
<span className="flex shrink-0 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100">
|
||||
<Button size="icon" variant="ghost" className="size-7" title="Rename" onClick={onRename}>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-7" title="Delete" onClick={onDelete}>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
type NameDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
initial?: string;
|
||||
confirmLabel: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (name: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
/** Create and rename are the same dialog: one text field, one verb. */
|
||||
const NameDialog = ({ open, title, description, initial = '', confirmLabel, onClose, onSubmit }: NameDialogProps) => {
|
||||
const [name, setName] = useState(initial);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (!name.trim() || busy) return;
|
||||
setBusy(true);
|
||||
const ok = await onSubmit(name);
|
||||
setBusy(false);
|
||||
if (ok) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next) setName(initial);
|
||||
else onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
|
||||
{/* A form, so Enter submits. A dialog whose only field ignores Enter reads as broken. */}
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<Input autoFocus value={name} onChange={(ev) => setName(ev.target.value)} placeholder="Name" />
|
||||
<DialogFooter className="mt-4">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || busy}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
type DeleteDialogProps = { collection: CollectionSummary | null; manager: CollectionsManager; onClose: () => void };
|
||||
|
||||
/**
|
||||
* Deleting a collection asks one further question, because there are two different intentions behind
|
||||
* it and the wrong guess is unrecoverable: the folder was a mistake (keep the images), or the work in
|
||||
* it was (take them too). Unchecked by default — the recoverable answer is the one you get by
|
||||
* pressing Enter without reading.
|
||||
*/
|
||||
const DeleteDialog = ({ collection, manager, onClose }: DeleteDialogProps) => {
|
||||
const [alsoImages, setAlsoImages] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const count = collection?.item_count ?? 0;
|
||||
|
||||
const confirm = async () => {
|
||||
if (!collection || busy) return;
|
||||
setBusy(true);
|
||||
const ok = await manager.remove(collection.id, alsoImages);
|
||||
setBusy(false);
|
||||
if (ok) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={!!collection}
|
||||
onOpenChange={(next) => {
|
||||
if (next) setAlsoImages(false);
|
||||
else onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete “{collection?.name}”?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{count === 0
|
||||
? 'This collection is empty.'
|
||||
: `It holds ${count} image${count === 1 ? '' : 's'}. They stay in the gallery unless you say otherwise.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{count > 0 && (
|
||||
<Label className="flex items-start gap-3 rounded-md border border-destructive/30 p-3">
|
||||
<Checkbox checked={alsoImages} onCheckedChange={(value) => setAlsoImages(value === true)} />
|
||||
<span className="grid gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
Delete the {count} image{count === 1 ? '' : 's'} as well
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Removes them from the gallery and from disk. There is no undo, and an image filed in
|
||||
another collection goes from that one too.
|
||||
</span>
|
||||
</span>
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={busy} onClick={() => void confirm()}>
|
||||
{alsoImages ? `Delete collection and ${count} image${count === 1 ? '' : 's'}` : 'Delete collection'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAtelierClient } from '../lib/api';
|
||||
import type { CollectionDetail, CollectionSummary } from '../lib/api';
|
||||
import type { CollectionSummary } from '../lib/api';
|
||||
|
||||
export const useCollectionsManager = (openId?: string) => {
|
||||
/**
|
||||
* The collection LIST — no detail view any more.
|
||||
*
|
||||
* A selected collection is rendered by the Gallery panel, which reads the same `?collection=` param.
|
||||
* This hook stopped fetching the detail when that moved: two panels both holding a copy of one
|
||||
* collection's images is two things to keep in step, and the narrow list has no use for them.
|
||||
*/
|
||||
export const useCollectionsManager = () => {
|
||||
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 [query, setQuery] = useState('');
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -26,51 +32,38 @@ export const useCollectionsManager = (openId?: string) => {
|
||||
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]);
|
||||
// Filtering happens here rather than server-side: the list is small enough that a round trip per
|
||||
// keystroke would be slower than the filter, and a search that cannot go stale needs no refresh.
|
||||
const visible = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return collections;
|
||||
return collections.filter((collection) => collection.name.toLowerCase().includes(needle));
|
||||
}, [collections, query]);
|
||||
|
||||
const create = async () => {
|
||||
const name = draftName.trim();
|
||||
if (!name) return;
|
||||
const guard = async (action: () => Promise<unknown>) => {
|
||||
try {
|
||||
await client.createCollection(name);
|
||||
setDraftName('');
|
||||
await action();
|
||||
setError(null);
|
||||
await refresh();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
try {
|
||||
await client.deleteCollection(id);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
return {
|
||||
collections: visible,
|
||||
total: collections.length,
|
||||
loading,
|
||||
error,
|
||||
query,
|
||||
setQuery,
|
||||
refresh,
|
||||
create: (name: string) => guard(() => client.createCollection(name.trim())),
|
||||
rename: (id: string, name: string) => guard(() => client.renameCollection(id, name.trim())),
|
||||
remove: (id: string, deleteImages: boolean) => guard(() => client.deleteCollection(id, deleteImages)),
|
||||
};
|
||||
|
||||
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