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:
2026-08-20 21:34:34 +01:00
parent 4533ef03ec
commit 82ca696ccf
15 changed files with 834 additions and 516 deletions
+37 -2
View File
@@ -1,8 +1,11 @@
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
import {
addToCollection,
collectionImages,
createCollection,
deleteCollection,
deleteGenerations,
getCollection,
getCollectionByName,
listCollections,
@@ -10,6 +13,7 @@ import {
renameCollection,
} from '../db';
import { createRouter } from '../create-router';
import { IMAGES_DIR } from '../env';
import { openaiError, UUID_PATTERN } from '../errors';
import { serializeCollection, serializeCollectionSummary, serializeGeneration } from '../serialize';
@@ -89,13 +93,44 @@ collectionsRouter.patch('/:id', async (ctx) => {
return ctx.json({ data: updated ? serializeCollection(updated) : null });
});
/** Removes the collection and its memberships. The images are untouched and stay in the gallery. */
/**
* Delete a collection. `?images=delete` deletes what was in it as well; the default keeps them.
*
* The two are genuinely different intentions and only the caller knows which one this is. "I filed
* these wrongly" wants the folder gone and the images kept; "this whole afternoon was a dead end"
* wants the images gone too, and doing that by hand is one delete per image. Which is why the
* destructive one has to be asked for by name rather than inferred.
*
* Order matters: read the members BEFORE the collection goes, because deleting it takes the
* memberships with it and there is then nothing left that says which images were in it.
*
* A file that will not unlink is not an error the caller can act on — the row is already gone and
* retrying deletes nothing. It is left on disk and logged rather than failing a delete that, from
* every side the user can see, succeeded.
*/
collectionsRouter.delete('/:id', async (ctx) => {
const id = ctx.req.param('id');
if (!UUID_PATTERN.test(id) || !(await getCollection(id))) return ctx.json(notFound(id), 404);
const alsoImages = ctx.req.query('images') === 'delete';
const members = alsoImages ? await collectionImages(id) : [];
await deleteCollection(id);
return ctx.json({ data: { id, deleted: true } });
let deletedImages = 0;
if (alsoImages && members.length > 0) {
const removed = await deleteGenerations(members.map((row) => row.id));
deletedImages = removed.length;
for (const row of removed) {
if (!row.imagePath) continue;
await rm(join(IMAGES_DIR, row.imagePath), { force: true }).catch((err: unknown) => {
console.error(`[atelier] could not unlink ${row.imagePath}:`, err);
});
}
}
return ctx.json({ data: { id, deleted: true, deletedImages } });
});
/** Add images. Ones already in the collection are counted as added and change nothing. */
+12
View File
@@ -20,6 +20,18 @@ export async function listGenerations(limit = 50): Promise<Generation[]> {
return db.select().from(generations).orderBy(desc(generations.createdAt)).limit(limit);
}
/**
* Delete generations outright, returning the rows that went — the caller needs `image_path` to unlink
* the files, and after the delete there is nowhere left to read it from.
*
* `collection_items` rows go with them by foreign key, so an image deleted while filed in three
* collections leaves none of the three holding a dangling membership.
*/
export async function deleteGenerations(ids: string[]): Promise<Generation[]> {
if (ids.length === 0) return [];
return db.delete(generations).where(inArray(generations.id, ids)).returning();
}
/** Every image of one request, in the order it was asked for. This is what polling a job reads. */
export async function getRequest(requestId: string): Promise<Generation[]> {
return db.select().from(generations).where(eq(generations.requestId, requestId)).orderBy(asc(generations.index));
+248 -135
View File
@@ -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>
);
};
+34 -41
View File
@@ -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>;
+68 -20
View File
@@ -1,5 +1,5 @@
import { Link } from 'react-router';
import { useSearchParams } from 'react-router';
import { Link, useSearchParams } from 'react-router';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { fileUrl } from '../lib/api';
import type { GenerationRow } from '../lib/api';
@@ -7,43 +7,78 @@ import { Lightbox } from './Lightbox';
import { useLightboxManager } from './useLightboxManager';
import { useGalleryManager } from './useGalleryManager';
/**
* The main surface: every image, or one collection's images when `?collection=` is set.
*
* The collection view is HERE rather than in the Collections panel because that panel is a narrow
* selector — showing a grid of images inside it meant two thumbnails per row, and it replaced the
* list you were selecting from with the thing you selected.
*/
export const Gallery = () => {
const manager = useGalleryManager();
const [searchParams] = useSearchParams();
const collectionId = searchParams.get('collection') ?? undefined;
const manager = useGalleryManager(collectionId);
// Hooks run before any early return, or the order changes between renders.
const lightbox = useLightboxManager(manager.rows);
const [searchParams] = useSearchParams();
const allImagesTo = (() => {
const next = new URLSearchParams(searchParams);
next.delete('collection');
next.delete('selected');
const search = next.toString();
return { search: search ? `?${search}` : '' };
})();
if (manager.loading) return <p className="p-4 text-sm text-muted-foreground">Loading…</p>;
if (manager.error) return <p className="p-4 text-sm text-destructive">{manager.error}</p>;
if (manager.rows.length === 0) {
return <p className="p-4 text-sm text-muted-foreground">Nothing generated yet.</p>;
}
return (
<div className="h-full space-y-6 overflow-y-auto p-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{manager.succeeded.length} image{manager.succeeded.length === 1 ? '' : 's'}
{manager.totalCost > 0 && ` · $${manager.totalCost.toFixed(4)} total`}
</p>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-baseline gap-3">
{manager.collection && (
<>
<h2 className="truncate text-lg font-semibold">{manager.collection.name}</h2>
<Link to={allImagesTo} className="shrink-0 text-sm text-muted-foreground hover:text-foreground">
All images
</Link>
</>
)}
<p className="shrink-0 text-sm text-muted-foreground">
{manager.succeeded.length} image{manager.succeeded.length === 1 ? '' : 's'}
{manager.totalCost > 0 && ` · $${manager.totalCost.toFixed(4)} total`}
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => void manager.refresh()}>
Refresh
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{manager.rows.map((row) => (
<Tile key={row.id} row={row} searchParams={searchParams} />
))}
</div>
{manager.rows.length === 0 ? (
<p className="text-sm text-muted-foreground">
{manager.collection ? 'Nothing filed here yet.' : 'Nothing generated yet.'}
</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{manager.rows.map((row) => (
<Tile
key={row.id}
row={row}
searchParams={searchParams}
onRemove={manager.collection ? () => void manager.removeFromCollection(row.id) : undefined}
/>
))}
</div>
)}
<Lightbox manager={lightbox} />
</div>
);
};
const Tile = ({ row, searchParams }: { row: GenerationRow; searchParams: URLSearchParams }) => {
type TileProps = { row: GenerationRow; searchParams: URLSearchParams; onRemove?: () => void };
const Tile = ({ row, searchParams, onRemove }: TileProps) => {
const src = fileUrl(row.url);
const to = (() => {
const next = new URLSearchParams(searchParams);
@@ -52,7 +87,7 @@ const Tile = ({ row, searchParams }: { row: GenerationRow; searchParams: URLSear
})();
return (
<figure className="space-y-2">
<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 ? (
// A real link, not a button with an onClick — the selection lives in the URL
@@ -70,6 +105,19 @@ const Tile = ({ row, searchParams }: { row: GenerationRow; searchParams: URLSear
)}
</div>
{/* Only in collection mode: "remove" means remove from THIS collection, and on the all-images
view there is nothing to remove it from. */}
{onRemove && (
<button
type="button"
onClick={onRemove}
title="Remove from this collection"
className="absolute right-2 top-2 rounded bg-black/60 p-1.5 text-white/70 opacity-0 transition group-hover:opacity-100 hover:text-white"
>
<X className="size-3.5" />
</button>
)}
<figcaption className="space-y-1">
<p className="line-clamp-2 text-xs text-foreground/80">{row.prompt}</p>
<p className="text-[11px] text-muted-foreground">
+30 -9
View File
@@ -1,19 +1,33 @@
import { useCallback, useEffect } from 'react';
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useAtelierRefresh } from 'officerdev';
import { useAtelierClient } from '../lib/api';
import type { GenerationRow } from '../lib/api';
import type { CollectionDetail, GenerationRow } from '../lib/api';
export const useGalleryManager = () => {
/**
* What the main panel is showing: everything, or one collection.
*
* One hook for both, because the panel below it is the same grid either way and the only difference is
* where the rows came from. The collection case used to live in the Collections panel, which meant two
* panels each holding their own copy of a collection's images and disagreeing after any edit.
*/
export const useGalleryManager = (collectionId?: string) => {
const client = useAtelierClient();
const [rows, setRows] = useState<GenerationRow[]>([]);
const [collection, setCollection] = useState<CollectionDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshSignal] = useAtelierRefresh();
const refresh = useCallback(async () => {
try {
setRows(await client.listGenerations());
if (collectionId) {
const detail = await client.getCollection(collectionId);
setCollection(detail);
setRows(detail.images);
} else {
setCollection(null);
setRows(await client.listGenerations());
}
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -21,9 +35,10 @@ export const useGalleryManager = () => {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [collectionId]);
useEffect(() => {
setLoading(true);
void refresh();
}, [refresh]);
@@ -33,10 +48,16 @@ export const useGalleryManager = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshSignal]);
/** Only meaningful in collection mode: takes the image out of this collection, not out of Atelier. */
const removeFromCollection = async (generationId: string) => {
if (!collectionId) return;
const { images } = await client.removeFromCollection(collectionId, generationId);
setRows(images);
setCollection((current) => (current ? { ...current, images } : current));
};
const succeeded = rows.filter((row) => row.status === 'succeeded' && row.image_path);
const totalCost = rows.reduce((sum, row) => sum + Number(row.cost_usd ?? 0), 0);
return { rows, succeeded, totalCost, loading, error, refresh };
return { rows, succeeded, totalCost, collection, loading, error, refresh, removeFromCollection };
};
export type GalleryManager = ReturnType<typeof useGalleryManager>;
+13 -12
View File
@@ -1,28 +1,29 @@
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { fileUrl } from '../lib/api';
import { ProvidersBar } from './ProvidersBar';
import { useProviders } from './useProviders';
import { BACKGROUNDS, QUALITIES, SIZES, useGenerateManager } from './useGenerateManager';
export const Generate = () => {
const manager = useGenerateManager();
if (manager.providers.length > 0 && manager.usableProviders.length === 0) {
return (
<Card>
<CardContent className="pt-6 text-sm text-muted-foreground">
No provider is ready. Add an API key in the Providers panel first.
</CardContent>
</Card>
);
}
const providers = useProviders();
const manager = useGenerateManager(providers.providers);
return (
<div className="h-full space-y-6 overflow-y-auto p-4">
{/* Which provider, then what to make with it — one panel, in that order. */}
<ProvidersBar manager={providers} />
{providers.providers.length > 0 && manager.usableProviders.length === 0 && (
<p className="text-sm text-muted-foreground">
No provider is ready. Set an API key from the dropdown above.
</p>
)}
<div className="space-y-2">
<Label htmlFor="atelier-prompt">Prompt</Label>
<Textarea
+252
View File
@@ -0,0 +1,252 @@
import { useEffect, useState } from 'react';
import { Check, KeyRound, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { ProviderStatus } from '../lib/api';
import type { ProvidersManager } from './useProviders';
/**
* Providers, as one line at the top of the Generate panel.
*
* It was a whole panel — a card per provider, each with its own key field and three buttons, filling
* a quarter of the screen for something set once and then ignored. What it actually has to answer is
* "which one is this generation going to use", and that is a dropdown.
*
* Adding one opens a dialog rather than expanding the menu: it is three fields, one of them a secret,
* and a menu that turns into a form cannot be dismissed by clicking away without losing what was typed.
*/
export const ProvidersBar = ({ manager }: { manager: ProvidersManager }) => {
const [dialog, setDialog] = useState<null | { mode: 'add' } | { mode: 'key'; provider: ProviderStatus }>(null);
const active =
manager.providers.find((row) => row.is_default && row.configured && row.enabled) ??
manager.providers.find((row) => row.configured && row.enabled) ??
manager.providers[0];
return (
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="min-w-0 flex-1 justify-start">
<span className="truncate">{active ? label(active) : 'No provider'}</span>
{active && !active.configured && <span className="ml-auto shrink-0 text-xs text-destructive">no key</span>}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{manager.providers.length === 0 && (
<DropdownMenuItem disabled>No providers configured</DropdownMenuItem>
)}
{manager.providers.map((provider) => (
<DropdownMenuItem
key={provider.id}
// Choosing one makes it the default, which is what "use this" means to the server: a
// request names a model, and the default provider is the row that answers for it.
onSelect={() => void manager.makeDefault(provider.id)}
className="gap-2"
>
{provider.id === active?.id ? <Check className="size-3.5 shrink-0" /> : <span className="w-3.5" />}
<span className="min-w-0 flex-1 truncate">{label(provider)}</span>
{!provider.configured && (
<button
type="button"
title="Set the API key"
className="shrink-0 rounded p-1 text-destructive hover:bg-destructive/10"
onClick={(ev) => {
// The row's own onSelect would close the menu and make this the default instead.
ev.preventDefault();
ev.stopPropagation();
setDialog({ mode: 'key', provider });
}}
>
<KeyRound className="size-3.5" />
</button>
)}
<button
type="button"
title="Delete this provider"
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
void manager.remove(provider.id);
}}
>
<Trash2 className="size-3.5" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setDialog({ mode: 'add' })} className="gap-2">
<Plus className="size-3.5" />
Add provider
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{manager.error && <span className="shrink-0 text-xs text-destructive">{manager.error}</span>}
<ProviderDialog state={dialog} manager={manager} onClose={() => setDialog(null)} />
</div>
);
};
const label = (provider: ProviderStatus) => `${provider.adapter} · ${provider.model}`;
type DialogState = null | { mode: 'add' } | { mode: 'key'; provider: ProviderStatus };
/** Add a provider, or set the key on one that has none. Same dialog, since the key field is shared. */
const ProviderDialog = ({
state,
manager,
onClose,
}: {
state: DialogState;
manager: ProvidersManager;
onClose: () => void;
}) => {
const [adapter, setAdapter] = useState('');
const [model, setModel] = useState('');
const [apiKey, setApiKey] = useState('');
// Seeded when the dialog opens, not on every render: the adapter list arrives asynchronously, and
// resetting on each render would fight the user's own choice as soon as it did.
useEffect(() => {
if (state?.mode !== 'add') return;
const first = manager.adapters[0];
setAdapter(first?.name ?? '');
setModel(first?.default_model ?? '');
setApiKey('');
}, [state, manager.adapters]);
useEffect(() => {
if (state?.mode === 'key') setApiKey('');
}, [state]);
const submit = async () => {
if (!state) return;
const ok =
state.mode === 'add'
? await manager.add(adapter, model, apiKey)
: apiKey.trim()
? await manager.setKey(state.provider.id, apiKey)
: false;
if (ok) onClose();
};
return (
<Dialog open={!!state} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{state?.mode === 'key' ? 'Set API key' : 'Add a provider'}</DialogTitle>
<DialogDescription>
{state?.mode === 'key'
? `${label(state.provider)} — the key is stored encrypted and never sent back.`
: 'An adapter and a model. The same adapter can back several providers — two keys, two accounts.'}
</DialogDescription>
</DialogHeader>
<form
className="grid gap-4"
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
>
{state?.mode === 'add' && (
<>
<Label className="grid gap-2">
<span className="text-xs text-muted-foreground">Adapter</span>
<Select
value={adapter}
onValueChange={(value) => {
setAdapter(value);
setModel(manager.adapterFor(value)?.default_model ?? '');
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>
{manager.adapters.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Label>
<Label className="grid gap-2">
<span className="text-xs text-muted-foreground">Model</span>
<Select value={model} onValueChange={setModel}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>
{manager.modelsFor(adapter).map((entry) => (
<SelectItem key={entry} value={entry}>
{entry}
</SelectItem>
))}
</SelectContent>
</Select>
</Label>
</>
)}
<Label className="grid gap-2">
<span className="text-xs text-muted-foreground">
API key {state?.mode === 'add' && <span className="text-muted-foreground/60">— optional</span>}
</span>
<Input
type="password"
autoComplete="off"
placeholder="sk-…"
value={apiKey}
onChange={(ev) => setApiKey(ev.target.value)}
/>
<span className="text-xs text-muted-foreground">
{state?.mode === 'add'
? 'Leave empty if the key is already in the environment for this adapter.'
: 'Stored encrypted, so this field always starts empty.'}
</span>
</Label>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
disabled={manager.busy || (state?.mode === 'add' ? !adapter || !model : !apiKey.trim())}
>
{state?.mode === 'key' ? 'Save key' : 'Add'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
+13 -15
View File
@@ -7,11 +7,15 @@ export const SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const;
export const QUALITIES = ['auto', 'low', 'medium', 'high'] as const;
export const BACKGROUNDS = ['auto', 'transparent', 'opaque'] as const;
export const useGenerateManager = () => {
/**
* The form. Providers are passed IN rather than fetched here — `useProviders` owns them, because the
* dropdown above this form and the model list inside it have to be the same list. They were two
* fetches, and adding a provider left the form showing the old models until the page was reloaded.
*/
export const useGenerateManager = (providers: ProviderStatus[]) => {
const client = useAtelierClient();
const [, bumpRefresh] = useAtelierRefresh();
const [providers, setProviders] = useState<ProviderStatus[]>([]);
const [model, setModel] = useState('');
const [prompt, setPrompt] = useState('');
const [n, setN] = useState(1);
@@ -23,21 +27,15 @@ export const useGenerateManager = () => {
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
// Follow the provider list: pick the default's model, and only overwrite a choice the user made if
// the provider behind it has gone away.
useEffect(() => {
client
.listProviders()
.then((rows) => {
setProviders(rows);
const usable =
rows.find((row) => row.is_default && row.configured && row.enabled) ??
rows.find((row) => row.configured && row.enabled);
if (usable) setModel(usable.model);
})
.catch((err: Error) => setError(err.message));
// Loaded once on mount — the Providers panel is where they
// are managed, and this panel does not need to react to edits made there in real time.
const usable = providers.filter((row) => row.configured && row.enabled);
if (usable.length === 0) return;
if (model && usable.some((row) => row.model === model)) return;
setModel((usable.find((row) => row.is_default) ?? usable[0]!).model);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [providers]);
// Derived in the hook, not in the component.
const usableProviders = providers.filter((provider) => provider.configured && provider.enabled);
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useEffect, useState } from 'react';
import { useAtelierClient } from '../lib/api';
import type { Adapter, ProviderStatus } from '../lib/api';
/**
* Every provider, and the verbs that change them.
*
* Owned here rather than inside useGenerateManager because two things need the same list and it must
* not exist twice: the dropdown at the top of the panel, and the form below it whose model choices are
* derived from whichever providers actually have a key. Adding one used to mean reloading the page
* before the form noticed.
*/
export const useProviders = () => {
const client = useAtelierClient();
const [providers, setProviders] = useState<ProviderStatus[]>([]);
const [adapters, setAdapters] = useState<Adapter[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
try {
const [rows, catalogue] = await Promise.all([client.listProviders(), client.listAdapters()]);
setProviders(rows);
setAdapters(catalogue);
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]);
const guard = async (action: () => Promise<ProviderStatus[]>) => {
setBusy(true);
try {
setProviders(await action());
setError(null);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return false;
} finally {
setBusy(false);
}
};
/**
* Create, then set the key if one was typed.
*
* Two calls because they are two things on the server: a provider row is public configuration, a key
* is an encrypted secret that is never sent back. A provider added without a key is legitimate — the
* key may live in the environment — so the second call is skipped rather than required.
*/
const add = async (adapter: string, model: string, apiKey?: string) =>
guard(async () => {
const rows = await client.createProvider(adapter, model);
const created = rows.find((row) => row.adapter === adapter && row.model === model);
if (!apiKey?.trim() || !created) return rows;
return client.updateProvider(created.id, { apiKey: apiKey.trim() });
});
return {
providers,
adapters,
loading,
busy,
error,
refresh,
add,
setKey: (id: string, apiKey: string) => guard(() => client.updateProvider(id, { apiKey: apiKey.trim() })),
/** The one a generation will use when the request names only a model. */
makeDefault: (id: string) => guard(() => client.updateProvider(id, { isDefault: true, enabled: true })),
remove: (id: string) => guard(() => client.deleteProvider(id)),
modelsFor: (adapter: string) => adapters.find((entry) => entry.name === adapter)?.models ?? [],
adapterFor: (adapter: string) => adapters.find((entry) => entry.name === adapter),
};
};
export type ProvidersManager = ReturnType<typeof useProviders>;
-258
View File
@@ -1,258 +0,0 @@
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
// Atelier's providers: which adapter, which model, whose key.
//
// A PANEL, in the plugin, not a section in Officer's settings. It was the latter for one day, because the
// extraction brief said so — and `IntegrationsSettings/index.tsx` already carried the opposite rule,
// written when email was extracted: "A plugin contributes panels, not settings sections, so it now lives
// inside the email workspace itself."
//
// The brief was wrong and the precedent was right. Settings in the platform mean the plugin is not
// self-contained: it cannot be configured on an older platform, and uninstalling leaves a dead section
// behind that someone has to delete by hand. Neither is true of a panel.
// (which adapter, which model, whose key), the same shape as the Apify token above rather than
// something worth a dedicated working panel. Talks to /api/atelier/providers over HTTP through
// useClient(), same as every other authenticated screen — this does not import anything from
// plugins/atelier/, so it stays on the right side of "a plugin importing platform code is fine; the
// reverse is not".
type ProviderStatus = {
id: string;
name: string;
adapter: string;
model: string;
enabled: boolean;
is_default: boolean;
configured: boolean;
key_source: 'database' | 'environment' | null;
env_var: string;
};
type Adapter = {
name: string;
label: string;
models: string[];
default_model: string;
env_var: string;
};
export const Providers = () => {
const client = useClient();
const [providers, setProviders] = useState<ProviderStatus[]>([]);
const [adapters, setAdapters] = useState<Adapter[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [keyDrafts, setKeyDrafts] = useState<Record<string, string>>({});
const [newAdapter, setNewAdapter] = useState('');
const [newModel, setNewModel] = useState('');
useEffect(() => {
Promise.all([
client.get<{ data: ProviderStatus[] }>('/atelier/providers').then((r) => r.data),
client.get<{ data: Adapter[] }>('/atelier/providers/adapters').then((r) => r.data),
])
.then(([rows, catalogue]) => {
setProviders(rows);
setAdapters(catalogue);
if (catalogue[0]) {
setNewAdapter(catalogue[0].name);
setNewModel(catalogue[0].default_model);
}
})
.catch(() => toast.error('Failed to load Atelier providers'))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const run = async (key: string, action: () => Promise<{ data: ProviderStatus[] }>) => {
setBusy(key);
try {
setProviders((await action()).data);
} catch {
toast.error('That did not work — check the server logs');
} finally {
setBusy(null);
}
};
const modelsFor = (adapterName: string) => adapters.find((a) => a.name === adapterName)?.models ?? [];
const patch = (id: string, values: Record<string, unknown>) =>
run(id, () => client.patch<{ data: ProviderStatus[] }>(`/atelier/providers/${id}`, values));
const saveKey = async (id: string) => {
const apiKey = keyDrafts[id]?.trim();
if (!apiKey) return;
await patch(id, { apiKey });
setKeyDrafts((current) => ({ ...current, [id]: '' }));
toast.success('Key saved');
};
const add = () =>
run('new', () =>
client.post<{ data: ProviderStatus[] }>('/atelier/providers', { adapter: newAdapter, model: newModel }),
);
const remove = (id: string) => run(id, () => client.delete<{ data: ProviderStatus[] }>(`/atelier/providers/${id}`));
if (loading) return null;
// `content-start`: a grid told to fill its height stretches its rows, which would pull the two cards
// tall and leave their text floating in the middle of them.
return (
<div className="grid h-full content-start gap-5 overflow-y-auto p-4">
<div className="grid gap-3 rounded-lg border border-duck-dark/10 p-4 dark:border-foreground/10">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add a provider</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
An adapter and a model. The same adapter can back several providers.
</p>
<div className="flex flex-wrap items-end gap-3">
<Label className="grid gap-2">
<span className="text-xs text-duck-dark/70 dark:text-foreground/70">Adapter</span>
<Select
value={newAdapter}
onValueChange={(value) => {
setNewAdapter(value);
setNewModel(adapters.find((a) => a.name === value)?.default_model ?? '');
}}
>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{adapters.map((adapter) => (
<SelectItem key={adapter.name} value={adapter.name}>
{adapter.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Label>
<Label className="grid gap-2">
<span className="text-xs text-duck-dark/70 dark:text-foreground/70">Model</span>
<Select value={newModel} onValueChange={setNewModel}>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{modelsFor(newAdapter).map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
</Label>
<Button onClick={add} disabled={busy === 'new' || !newAdapter}>
Add
</Button>
</div>
</div>
{providers.length === 0 ? (
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">No providers yet. Add one above.</p>
) : (
providers.map((provider) => {
const busyHere = busy === provider.id;
return (
<div
key={provider.id}
className="grid gap-3 rounded-lg border border-duck-dark/10 p-4 dark:border-foreground/10"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="flex items-center gap-2 text-sm font-medium text-duck-dark dark:text-foreground">
{provider.name}
{provider.is_default && (
<span className="rounded bg-duck-yellow/20 px-2 py-0.5 text-[10px] uppercase tracking-wider text-duck-teal">
default
</span>
)}
</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">{describe(provider)}</p>
</div>
<div className="flex gap-2">
{!provider.is_default && (
<Button
variant="ghost"
size="sm"
disabled={busyHere}
onClick={() => patch(provider.id, { isDefault: true })}
>
Make default
</Button>
)}
<Button
variant={provider.enabled ? 'secondary' : 'default'}
size="sm"
disabled={busyHere}
onClick={() => patch(provider.id, { enabled: !provider.enabled })}
>
{provider.enabled ? 'Enabled' : 'Disabled'}
</Button>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor={`${provider.id}-key`} className="text-xs text-duck-dark/70 dark:text-foreground/70">
API key
</Label>
{/* A form, so Enter saves. Pasting a key and pressing Enter is the strongest habit there
is in a password field, and outside a form it silently does nothing — which reads as
"the key did not save". TODO.md tracks nineteen panels with this same defect; this is
not the twentieth. */}
<form
className="flex gap-2"
onSubmit={(ev) => {
ev.preventDefault();
void saveKey(provider.id);
}}
>
<Input
id={`${provider.id}-key`}
type="password"
autoComplete="off"
placeholder={provider.configured ? 'A key is set — type a new one to replace it' : 'sk-…'}
value={keyDrafts[provider.id] ?? ''}
onChange={(ev) => setKeyDrafts((current) => ({ ...current, [provider.id]: ev.target.value }))}
/>
<Button type="submit" disabled={busyHere || !keyDrafts[provider.id]?.trim()}>
Save
</Button>
</form>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Stored encrypted. The server never sends a key back, so this field always starts empty.
</p>
</div>
<Button
variant="ghost"
size="sm"
disabled={busyHere}
onClick={() => remove(provider.id)}
className="w-fit"
>
Delete provider
</Button>
</div>
);
})
)}
</div>
);
};
function describe(provider: ProviderStatus): string {
const base = `${provider.adapter} · ${provider.model}`;
if (!provider.configured) return `${base} — no key. Set one here, or as ${provider.env_var}.`;
if (provider.key_source === 'environment') return `${base} — key from ${provider.env_var}.`;
return `${base} — key stored and encrypted.`;
}
-1
View File
@@ -1 +0,0 @@
export { Providers } from './Providers';
+13 -12
View File
@@ -1,13 +1,15 @@
import type { LayoutNode } from 'officerdev';
// Generate stays narrow and fixed — it is a form, not a working area. Gallery gets the most room: it
// is the main "what did I make" view. The right column stacks the two organising views: Collections
// above, Providers below.
// Two panels on the left, the work on the right.
//
// Providers is here rather than in Officer's settings because a plugin contributes panels, not settings
// sections — the rule `IntegrationsSettings/index.tsx` states for email. It is placed smallest and last
// because it is configured once and then ignored, but it has to be REACHABLE, and a plugin whose only
// configuration screen lives in the platform is not self-contained.
// The arrangement is the point rather than the panel count: everything that ORGANISES (collections)
// or CONFIGURES (providers, the generate form) is stacked in one narrow column, and the whole of the
// rest is one wide surface for looking at images — the gallery, or one collection's images when a
// collection is selected. Selecting a collection is a navigation on that surface, not a view that
// opens inside the narrow list that triggered it.
//
// Providers is no longer its own panel. It is a dropdown at the top of Generate, because a provider is
// picked once and then lives as a fact about the next generation — the same panel, a few pixels up.
//
// Users can resize or rearrange freely; this is only the starting point.
export const defaultLayout: LayoutNode = {
@@ -15,19 +17,18 @@ export const defaultLayout: LayoutNode = {
id: 'atelier-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'atelier-generate', appType: 'atelier-generate' }, size: 26 },
{ node: { type: 'panel', id: 'atelier-gallery', appType: 'atelier-gallery' }, size: 48 },
{
node: {
type: 'group',
id: 'atelier-side',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'atelier-collections', appType: 'atelier-collections' }, size: 60 },
{ node: { type: 'panel', id: 'atelier-providers', appType: 'atelier-providers' }, size: 40 },
{ node: { type: 'panel', id: 'atelier-collections', appType: 'atelier-collections' }, size: 40 },
{ node: { type: 'panel', id: 'atelier-generate', appType: 'atelier-generate' }, size: 60 },
],
},
size: 26,
size: 30,
},
{ node: { type: 'panel', id: 'atelier-gallery', appType: 'atelier-gallery' }, size: 70 },
],
};
+19 -1
View File
@@ -113,11 +113,29 @@ export function useAtelierClient() {
/** Long-polls up to `waitSeconds` server-side, so the panel does not run its own retry loop. */
awaitGeneration: (id: string, waitSeconds = 30) => client.get<JobResponse>(`images/generations/${id}?wait=${waitSeconds}`),
/** A provider row is adapter + model; the key is set separately, and never comes back. */
createProvider: (adapter: string, model: string) =>
client.post<{ data: ProviderStatus[] }>('providers', { adapter, model }).then((r) => r.data),
updateProvider: (id: string, values: Record<string, unknown>) =>
client.patch<{ data: ProviderStatus[] }>(`providers/${id}`, values).then((r) => r.data),
deleteProvider: (id: string) => client.delete<{ data: ProviderStatus[] }>(`providers/${id}`).then((r) => r.data),
listCollections: () => client.get<{ data: CollectionSummary[] }>('collections').then((r) => r.data),
getCollection: (id: string) => client.get<{ data: CollectionDetail }>(`collections/${id}`).then((r) => r.data),
createCollection: (name: string) =>
client.post<{ data: Collection }>('collections', { name }).then((r) => r.data),
deleteCollection: (id: string) => client.delete<{ data: unknown }>(`collections/${id}`),
renameCollection: (id: string, name: string) =>
client.patch<{ data: Collection }>(`collections/${id}`, { name }).then((r) => r.data),
/**
* `deleteImages` is the difference between filing something wrongly and regretting the whole
* afternoon — the server keeps the images unless it is asked, by name, not to.
*/
deleteCollection: (id: string, deleteImages = false) =>
client
.delete<{ data: { id: string; deleted: boolean; deletedImages: number } }>(
`collections/${id}${deleteImages ? '?images=delete' : ''}`,
)
.then((r) => r.data),
addToCollection: (id: string, generationIds: string[]) =>
client
.post<{ data: { added: number; images: GenerationRow[] } }>(`collections/${id}/items`, { generationIds })
+10 -10
View File
@@ -1,23 +1,23 @@
import type { AppRegistryMeta } from 'officerdev';
import { Sparkles, GalleryHorizontalEnd, FolderOpen, KeyRound } from 'lucide-react';
import { Sparkles, GalleryHorizontalEnd, FolderOpen } from 'lucide-react';
import { Generate } from './Generate/Generate';
import { Gallery } from './Gallery/Gallery';
import { Collections } from './Collections/Collections';
import { Providers } from './Providers';
// No component re-exports. `panels.ts` declares PANELS — the shell mounts them by key and there is no
// way to export a component from a plugin.
//
// The three coordinate through the URL and one refresh channel, never through props: Gallery reads
// `?selected=<id>` for its lightbox, Collections reads `?collection=<id>` for its detail view — two
// different param names on purpose, since both panels render on the same route at once and would
// otherwise fight over one param. Generate bumps `useAtelierRefresh` (officerdev/src/channels.ts) when
// a job finishes; Gallery subscribes to it. See plugins/atelier/PLUGIN.md.
// `?selected=<id>` for its lightbox AND `?collection=<id>` for which images it is showing — the second
// used to belong to the Collections panel, which rendered the detail inside the narrow list it was
// selected from. Generate bumps `useAtelierRefresh` (officerdev/src/channels.ts) when a job finishes;
// Gallery subscribes. See plugins/atelier/PLUGIN.md.
//
// `atelier-providers` was a fourth panel and is gone: providers are a dropdown at the top of Generate.
// Removing a panel key rewrites saved layouts that name it — see dashboards/queries.ts — which the
// install flow already handles by clearing this plugin's screen rows on install.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'atelier-collections', name: 'Collections', icon: FolderOpen, component: Collections, availableOnPanel: false },
{ key: 'atelier-generate', name: 'Generate', icon: Sparkles, component: Generate, availableOnPanel: false },
{ key: 'atelier-gallery', name: 'Gallery', icon: GalleryHorizontalEnd, component: Gallery, availableOnPanel: false },
{ key: 'atelier-collections', name: 'Collections', icon: FolderOpen, component: Collections, availableOnPanel: false },
// Providers is a panel and not a section in Officer's settings — see Providers.tsx. A plugin that
// cannot be configured without the platform shipping its settings screen is not self-contained.
{ key: 'atelier-providers', name: 'Providers', icon: KeyRound, component: Providers, availableOnPanel: false },
];