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:
+13
-12
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user