Intermediate45 minModule 3 of 4
Frontend
Build the React components — prompt input, real-time generation state with optimistic UI, model selector, and gallery grid.
Component map
The generation hook
All async logic lives in a custom hook, keeping components clean:
// hooks/useGeneration.ts
'use client';
import { useState, useCallback, useRef } from 'react';
type Status = 'idle' | 'queued' | 'processing' | 'succeeded' | 'failed';
interface Generation {
id: string;
status: Status;
outputUrl?: string;
error?: string;
}
export function useGeneration(userId: string) {
const [generation, setGeneration] = useState<Generation | null>(null);
const [isLoading, setIsLoading] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopPolling = () => {
if (pollRef.current) clearInterval(pollRef.current);
};
const pollStatus = useCallback(async (id: string) => {
pollRef.current = setInterval(async () => {
const res = await fetch(`/api/predictions/${id}`);
const data: Generation = await res.json();
setGeneration(data);
if (data.status === 'succeeded' || data.status === 'failed') {
stopPolling();
setIsLoading(false);
}
}, 2000); // poll every 2 seconds
}, []);
const generate = useCallback(
async (prompt: string, model: string) => {
stopPolling();
setIsLoading(true);
setGeneration(null);
try {
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, model, userId }),
});
if (!res.ok) {
const err = await res.json();
setGeneration({ id: '', status: 'failed', error: err.error });
setIsLoading(false);
return;
}
const { id, status } = await res.json();
setGeneration({ id, status });
if (status !== 'succeeded') {
pollStatus(id);
} else {
setIsLoading(false);
}
} catch {
setGeneration({ id: '', status: 'failed', error: 'Network error' });
setIsLoading(false);
}
},
[userId, pollStatus],
);
return { generation, isLoading, generate };
}Model selector component
// components/ModelSelector.tsx
'use client';
const MODELS = [
{ id: 'truefusion-pro', label: 'Standard', description: 'Best quality · ~15s' },
{ id: 'truefusion-edge', label: 'Fast Preview', description: 'Quick draft · ~2s' },
{ id: 'truefusion-2.0', label: 'High Quality', description: 'Maximum detail · ~30s' },
];
interface Props {
value: string;
onChange: (model: string) => void;
}
export function ModelSelector({ value, onChange }: Props) {
return (
<div className="flex gap-2 flex-wrap">
{MODELS.map((m) => (
<button
key={m.id}
onClick={() => onChange(m.id)}
className={`rounded-lg border px-3 py-2 text-sm text-left transition-all ${
value === m.id
? 'border-indigo-500 bg-indigo-500/10 text-indigo-600 dark:text-indigo-400'
: 'border-fd-border bg-fd-card text-fd-muted-foreground hover:border-fd-border/80'
}`}
>
<div className="font-medium">{m.label}</div>
<div className="text-xs opacity-70">{m.description}</div>
</button>
))}
</div>
);
}Prompt input component
// components/PromptInput.tsx
'use client';
import { useState } from 'react';
import { SparklesIcon, XCircleIcon } from 'lucide-react';
interface Props {
onGenerate: (prompt: string) => void;
onCancel?: () => void;
isLoading: boolean;
}
export function PromptInput({ onGenerate, onCancel, isLoading }: Props) {
const [prompt, setPrompt] = useState('');
return (
<div className="space-y-3">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="A photorealistic mountain lake at golden hour, reflections in the water..."
rows={3}
className="w-full rounded-xl border border-fd-border bg-fd-card px-4 py-3 text-fd-foreground placeholder:text-fd-muted-foreground focus:outline-none focus:ring-2 focus:ring-indigo-500/30 resize-none"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !isLoading && prompt.trim()) {
onGenerate(prompt.trim());
}
}}
/>
<div className="flex items-center justify-between">
<span className="text-xs text-fd-muted-foreground">
{prompt.length}/500 · ⌘↵ to generate
</span>
{isLoading ? (
<button
onClick={onCancel}
className="flex items-center gap-2 rounded-lg border border-fd-border px-4 py-2 text-sm text-fd-muted-foreground hover:border-red-500/30 hover:text-red-500 transition-colors"
>
<XCircleIcon className="size-4" />
Cancel
</button>
) : (
<button
onClick={() => onGenerate(prompt.trim())}
disabled={!prompt.trim() || prompt.length > 500}
className="flex items-center gap-2 rounded-xl bg-indigo-500 px-5 py-2 text-sm font-medium text-white hover:bg-indigo-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<SparklesIcon className="size-4" />
Generate
</button>
)}
</div>
</div>
);
}Generation result component
// components/GenerationResult.tsx
'use client';
import Image from 'next/image';
import { DownloadIcon, Share2Icon } from 'lucide-react';
type Status = 'idle' | 'queued' | 'processing' | 'succeeded' | 'failed';
interface Props {
status: Status;
outputUrl?: string;
error?: string;
prompt?: string;
}
export function GenerationResult({ status, outputUrl, error, prompt }: Props) {
if (status === 'idle') return null;
if (status === 'queued' || status === 'processing') {
return (
<div className="aspect-square w-full max-w-lg mx-auto rounded-2xl border border-fd-border bg-fd-card flex flex-col items-center justify-center gap-4">
<div className="h-8 w-8 rounded-full border-2 border-indigo-500 border-t-transparent animate-spin" />
<p className="text-sm text-fd-muted-foreground capitalize">{status}…</p>
</div>
);
}
if (status === 'failed') {
return (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4 text-sm text-red-500">
Generation failed: {error ?? 'Unknown error'}
</div>
);
}
if (status === 'succeeded' && outputUrl) {
return (
<div className="space-y-3">
<div className="relative aspect-square w-full max-w-lg mx-auto overflow-hidden rounded-2xl border border-fd-border">
<Image src={outputUrl} alt={prompt ?? 'Generated image'} fill className="object-cover" />
</div>
<div className="flex justify-end gap-2">
<a
href={outputUrl}
download
className="flex items-center gap-1.5 rounded-lg border border-fd-border px-3 py-1.5 text-sm text-fd-muted-foreground hover:text-fd-foreground transition-colors"
>
<DownloadIcon className="size-3.5" />
Download
</a>
<button
onClick={() => navigator.share?.({ url: outputUrl, title: prompt })}
className="flex items-center gap-1.5 rounded-lg border border-fd-border px-3 py-1.5 text-sm text-fd-muted-foreground hover:text-fd-foreground transition-colors"
>
<Share2Icon className="size-3.5" />
Share
</button>
</div>
</div>
);
}
return null;
}Studio page
// app/page.tsx
'use client';
import { useState } from 'react';
import { PromptInput } from '@/components/PromptInput';
import { GenerationResult } from '@/components/GenerationResult';
import { ModelSelector } from '@/components/ModelSelector';
import { useGeneration } from '@/hooks/useGeneration';
const USER_ID = 'demo-user'; // replace with real auth
export default function StudioPage() {
const [model, setModel] = useState('truefusion-pro');
const [lastPrompt, setLastPrompt] = useState('');
const { generation, isLoading, generate } = useGeneration(USER_ID);
function handleGenerate(prompt: string) {
setLastPrompt(prompt);
generate(prompt, model);
}
return (
<main className="container mx-auto max-w-2xl px-6 py-12 space-y-8">
<div>
<h1 className="text-3xl font-bold text-fd-foreground">AI Image Studio</h1>
<p className="mt-1 text-fd-muted-foreground">Generate images from text in seconds.</p>
</div>
<ModelSelector value={model} onChange={setModel} />
<PromptInput
onGenerate={handleGenerate}
isLoading={isLoading}
/>
<GenerationResult
status={generation?.status ?? 'idle'}
outputUrl={generation?.outputUrl}
error={generation?.error}
prompt={lastPrompt}
/>
</main>
);
}Summary
useGenerationhook encapsulates all async logic — components stay simple- Poll
/api/predictions/:idevery 2 seconds for live status updates ModelSelectorlets users pick quality vs. speedGenerationResulthandles all four states: idle, loading, success, error
Next: ship the app to production.