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.
148 lines
4.9 KiB
TypeScript
148 lines
4.9 KiB
TypeScript
import type { ReactNode } from 'react';
|
|
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';
|
|
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 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
|
|
id="atelier-prompt"
|
|
rows={4}
|
|
placeholder="a voxel duck in a beret, painting at an easel"
|
|
value={manager.prompt}
|
|
onChange={(ev) => manager.setPrompt(ev.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<Field label="Model" id="atelier-model">
|
|
<Select value={manager.model} onValueChange={manager.setModel}>
|
|
<SelectTrigger id="atelier-model" className="w-full">
|
|
<SelectValue placeholder="Choose" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{manager.models.map((model) => (
|
|
<SelectItem key={model} value={model}>
|
|
{model}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
|
|
<Field label="Count" id="atelier-n">
|
|
<Input
|
|
id="atelier-n"
|
|
type="number"
|
|
min={1}
|
|
max={10}
|
|
value={manager.n}
|
|
onChange={(ev) => manager.setN(Math.min(10, Math.max(1, Number(ev.target.value) || 1)))}
|
|
/>
|
|
</Field>
|
|
|
|
<Choice label="Size" id="atelier-size" value={manager.size} onChange={manager.setSize} options={SIZES} />
|
|
<Choice
|
|
label="Quality"
|
|
id="atelier-quality"
|
|
value={manager.quality}
|
|
onChange={manager.setQuality}
|
|
options={QUALITIES}
|
|
/>
|
|
<Choice
|
|
label="Background"
|
|
id="atelier-background"
|
|
value={manager.background}
|
|
onChange={manager.setBackground}
|
|
options={BACKGROUNDS}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<Button disabled={!manager.ready} onClick={manager.generate}>
|
|
{manager.running ? 'Generating…' : 'Generate'}
|
|
</Button>
|
|
{manager.job?.atelier && manager.running && (
|
|
<span className="text-sm text-muted-foreground">{manager.job.atelier.status}…</span>
|
|
)}
|
|
</div>
|
|
|
|
{manager.error && <p className="text-sm text-destructive">{manager.error}</p>}
|
|
|
|
{manager.job?.data && manager.job.data.length > 0 && (
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
{manager.job.data.map((image) => (
|
|
<figure key={image.url} className="space-y-2">
|
|
<img src={fileUrl(image.url) ?? undefined} alt={manager.prompt} className="w-full rounded-lg border border-border" />
|
|
<figcaption className="truncate text-xs text-muted-foreground">{image.revised_prompt ?? ''}</figcaption>
|
|
</figure>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{manager.job?.atelier?.images.some((image) => image.error) && (
|
|
<ul className="space-y-1 text-sm text-destructive">
|
|
{manager.job.atelier.images
|
|
.filter((image) => image.error)
|
|
.map((image) => (
|
|
<li key={image.id}>{image.error}</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Field = ({ label, id, children }: { label: string; id: string; children: ReactNode }) => (
|
|
<div className="space-y-2">
|
|
<Label htmlFor={id}>{label}</Label>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
type ChoiceProps = {
|
|
label: string;
|
|
id: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
options: readonly string[];
|
|
};
|
|
|
|
const Choice = ({ label, id, value, onChange, options }: ChoiceProps) => (
|
|
<Field label={label} id={id}>
|
|
<Select value={value} onValueChange={onChange}>
|
|
<SelectTrigger id={id} className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((option) => (
|
|
<SelectItem key={option} value={option}>
|
|
{option}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
);
|