ported from the standalone app's hash-tab UI. The panels do not coordinate through props (the panel contract only ever hands a component its panelId) or usePanelChannel-as-selection: - Gallery's lightbox is ?selected=<id>, Collections' detail is ?collection=<id> — different names since both panels render on the same route at once - Generate bumps useAtelierRefresh (added to officerdev/src/channels.ts) when a job finishes; Gallery subscribes to it — a genuine signal, not a selection, so it stays a channel images render through GET /files/:filename with a ?token= query param appended client-side (lib/api.ts's fileUrl), the same escape hatch FileViewer and the music plugin use for exactly this: a browser cannot set an Authorization header on an <img src>. Providers is not here — see PLUGIN.md for why it became a platform settings screen instead. assets/icon.png and assets/splash.png are [placeholder] flat swatches, not real artwork — see assets/PLACEHOLDER.md.
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 Settings → Integrations → Atelier first.
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 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>
|
|
);
|