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

app/page.tsx PromptInput GenerationResult ModelSelector app/gallery/page.tsx GalleryGrid GenerationCard useGeneration hook

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

  • useGeneration hook encapsulates all async logic — components stay simple
  • Poll /api/predictions/:id every 2 seconds for live status updates
  • ModelSelector lets users pick quality vs. speed
  • GenerationResult handles all four states: idle, loading, success, error

Next: ship the app to production.

On this page