Each panel's root was a plain div with padding, so anything past the panel's height was simply not reachable — the gallery showed its first two rows of sixty images, and the Providers panel cut off mid-card. The shell hands a panel a fixed-height box and does not scroll it for you; plugins/music has had `h-full overflow-y-auto` on every panel root since it was written. Collections gets it in both places, the list and the detail view, which are two separate returns in one file and would otherwise disagree.
147 lines
4.8 KiB
TypeScript
147 lines
4.8 KiB
TypeScript
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 { 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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full space-y-6 overflow-y-auto p-4">
|
|
<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>
|
|
);
|