Skip to contentVibraUI
Part of the AI dashboardinstalls at /ai/images

Image studio

A settings card and a two-column gallery of generated pictures — each an abstract drawn from the row's seed and palette, never a photograph.

Open the live page

Installs at /ai/images. The page reads db.generations on the server and shapes each row for the gallery — its three to five blobs come off seeded(id) there, so the island paints geometry it was handed and never needs the store. There is no picture behind a card: GenerationArt is an SVG named by the prompt, a muted ground and soft radial circles in the row's four oklch colours, and it is drawn from the row alone, which is why the same row always looks the same and why Download can hand it to svgToPng like a chart. generate writes count rows through the repository, each taking the next seed on from the one typed — or from the prompt's own hash — with a palette that follows from it, so a set shares a family of colours without repeating a picture; a blank prompt, a count outside one to four, or a seed that is not a whole number is refused in a callout under the field it names. deleteGeneration drops a card; the history sheet lists every prompt and puts one's settings back in the form. Composes AppShell, PageHeader, Card, FormRow, Select, Textarea, Input, AsyncButton, Badge, DropdownMenu, Sheet, DataList, Callout and EmptyState.

Preview

Install

npx shadcn@latest add @vibra/ai-images

Needs the @vibra registry in your components.json — set it up once.

Source

app/ai/images/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"

import { signOut } from "./actions"
import { StudioView } from "./components/studio-view"
import { currentUser, gallery, shellNotifications } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ai/nav"

/**
 * The image studio. The page is a server component: it reads every generation
 * through `db.generations`, shapes each one — its blobs off the seed, its date
 * into words — and hands the rows to one client island that owns the settings
 * card, the gallery and the history. The header's actions belong to that
 * state too, so the island renders the PageHeader.
 */
export default function StudioPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.images}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <StudioView rows={gallery()} />
    </AppShell>
  )
}
app/ai/images/data.ts
/**
 * What this page reads. Generations come from `db.generations`; every row is
 * shaped here, on the server, into what the gallery draws — the abstract's
 * blobs come off `seeded(id)` once, so the island paints geometry it was
 * handed and never needs the store or a random number. "Now" is
 * `REFERENCE_DATE`.
 */
import { getInitials } from "@/lib/format"
import { db, seeded, type Generation } from "@/lib/sample-data"

export type GenerationStyle = Generation["style"]
export type GenerationRatio = Generation["ratio"]
export type GenerationQuality = Generation["quality"]

export const STYLE_IDS: readonly GenerationStyle[] = ["realistic", "digital-art", "watercolor", "line-art"]
export const RATIO_IDS: readonly GenerationRatio[] = ["1:1", "4:3", "16:9", "9:16"]
export const QUALITY_IDS: readonly GenerationQuality[] = ["standard", "high"]

/** One soft circle of the abstract: where it sits and how big it is, as fractions of the frame. */
export type Blob = {
  /** Centre, 0..1 across the width. */
  cx: number
  /** Centre, 0..1 down the height. */
  cy: number
  /** Radius, as a share of the frame's shorter side. */
  r: number
  /** Which of the four palette colours it is painted in. */
  color: number
}

/** A generation as the gallery reads it: the row, its blobs, and its time in words. */
export type GenerationRow = {
  id: string
  prompt: string
  negative?: string
  style: GenerationStyle
  ratio: GenerationRatio
  quality: GenerationQuality
  seed: number
  palette: string[]
  blobs: Blob[]
  createdAt: Date
  /** When it was made, pinned to UTC. */
  when: string
}

const madeAt = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  hour: "numeric",
  minute: "2-digit",
  timeZone: "UTC",
})

/**
 * Three to five soft circles from the row's id: the same id always draws the
 * same picture, and two rows from one prompt draw two pictures because their
 * ids differ. Positions stay inside the middle 70% of the frame so no blob is
 * mostly clipped, and the radii overlap enough that the colours blend.
 */
export function blobsFor(id: string): Blob[] {
  const rand = seeded(`generation-art:${id}`)
  const count = 3 + Math.floor(rand() * 3)
  return Array.from({ length: count }, (_, index) => ({
    cx: Math.round((0.15 + rand() * 0.7) * 1000) / 1000,
    cy: Math.round((0.15 + rand() * 0.7) * 1000) / 1000,
    r: Math.round((0.24 + rand() * 0.26) * 1000) / 1000,
    color: index % 4,
  }))
}

/** A row shaped for the page; the actions use it too, so a picture made at runtime reads like a seeded one. */
export function toRow(row: Generation): GenerationRow {
  return {
    id: row.id,
    prompt: row.prompt,
    negative: row.negative,
    style: row.style,
    ratio: row.ratio,
    quality: row.quality,
    seed: row.seed,
    palette: row.palette,
    blobs: blobsFor(row.id),
    createdAt: row.createdAt,
    when: madeAt.format(row.createdAt),
  }
}

/** Every generation, newest first. */
export function gallery(): GenerationRow[] {
  return db.generations
    .all()
    .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
    .map(toRow)
}

// Read per call, never held at module scope: a rename on the profile page
// shows here on the next render.
const memberRow = () => db.members.all()[0]

/** The person looking at the page: the first member of the workspace, who owns it. */
export function currentUser() {
  const member = memberRow()
  return { name: member.name, email: member.email, initials: getInitials(member.name), avatarUrl: member.avatarUrl }
}

export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/ai/images/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { asString, db, fields, isWhole, paletteFor, REFERENCE_DATE, seeded, type Result } from "@/lib/sample-data"

import {
  QUALITY_IDS,
  RATIO_IDS,
  STYLE_IDS,
  toRow,
  type GenerationQuality,
  type GenerationRatio,
  type GenerationRow,
  type GenerationStyle,
} from "./data"

/**
 * What this page changes. Server actions so the page can stay a server
 * component, and `Result` so every caller reads one success-or-error shape:
 * a refusal names the field it is about and is rendered under it.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

/** The settings card as it was submitted. Strings and numbers, the way a form hands them over. */
export type GenerateInput = {
  prompt: string
  negative: string
  style: string
  ratio: string
  quality: string
  count: number
  /** Blank for "pick one from the prompt"; otherwise a whole number. */
  seed: string
}

const MAX_COUNT = 4
const SEED_SPAN = 1_000_000
/** A prompt says what to draw; a thousand characters is a long one. A negative is a short list of what to leave out. */
const MAX_PROMPT = 1_000
const MAX_NEGATIVE = 500

const oneOf = <T extends string>(list: readonly T[], value: string): value is T =>
  (list as readonly string[]).includes(value)

/** A whole number from the prompt alone, so the same prompt with no seed draws the same set. */
function seedFrom(prompt: string): number {
  return Math.floor(seeded(`generate:${prompt.toLowerCase()}`)() * SEED_SPAN)
}

/**
 * Makes `count` pictures. There is no model behind the page: each row takes
 * the next seed on from the one typed — or from the prompt's own hash when
 * none was — and its palette follows from that seed, so a set from one prompt
 * shares a family of colours without repeating a picture. The rows are written
 * to `db.generations` and come back shaped for the gallery.
 */
export async function generate(sent: GenerateInput): Promise<Result<{ rows: GenerationRow[] }>> {
  const input = fields(sent)
  const prompt = asString(input.prompt).trim()
  if (!prompt) {
    return { ok: false, error: { code: "required", message: "Describe the image before generating.", field: "prompt" } }
  }
  if (prompt.length > MAX_PROMPT) {
    return { ok: false, error: { code: "too_long", message: `Keep the prompt under ${MAX_PROMPT.toLocaleString("en-US")} characters.`, field: "prompt" } }
  }
  const negative = asString(input.negative).trim() || undefined
  if (negative && negative.length > MAX_NEGATIVE) {
    return { ok: false, error: { code: "too_long", message: `Keep what to leave out under ${MAX_NEGATIVE} characters.`, field: "negative" } }
  }
  const [style, ratio, quality] = [asString(input.style), asString(input.ratio), asString(input.quality)]
  if (!oneOf<GenerationStyle>(STYLE_IDS, style)) {
    return { ok: false, error: { code: "invalid", message: "Pick one of the four styles.", field: "style" } }
  }
  if (!oneOf<GenerationRatio>(RATIO_IDS, ratio)) {
    return { ok: false, error: { code: "invalid", message: "Pick one of the four aspect ratios.", field: "ratio" } }
  }
  if (!oneOf<GenerationQuality>(QUALITY_IDS, quality)) {
    return { ok: false, error: { code: "invalid", message: "Pick standard or high quality.", field: "quality" } }
  }
  const count = input.count
  if (!isWhole(count, 1, MAX_COUNT)) {
    return { ok: false, error: { code: "invalid", message: `Make between 1 and ${MAX_COUNT} images at a time.`, field: "count" } }
  }
  // Digits alone are not enough: past 2^53 a number stops being exact, and a
  // seed that cannot be typed back in and reproduce the picture is not a seed.
  // The last of the batch is `seed + (count - 1)`, so that is the one checked
  // — the offset first, because at the ceiling `seed + count` already rounds.
  const typed = asString(input.seed).trim()
  if (typed && (!/^\d+$/.test(typed) || !Number.isSafeInteger(Number(typed) + (count - 1)))) {
    return { ok: false, error: { code: "invalid", message: "A seed is a whole number, or blank to pick one.", field: "seed" } }
  }

  const base = typed ? Number(typed) : seedFrom(prompt)
  const rows: GenerationRow[] = []

  for (let index = 0; index < count; index += 1) {
    const seed = base + index
    const created = await db.generations.create({
      prompt,
      negative,
      style,
      ratio,
      quality,
      seed,
      palette: paletteFor(seed),
      createdAt: REFERENCE_DATE,
    })
    if (!created.ok) return created
    rows.push(toRow(created.data))
  }

  return { ok: true, data: { rows } }
}

/** Removes a picture for good; the gallery drops its card. */
export async function deleteGeneration(id: string): Promise<Result<{ id: string }>> {
  const removed = await db.generations.remove(id)
  if (!removed.ok) {
    return { ok: false, error: { code: "not_found", message: "That image has already been deleted." } }
  }
  return removed
}
app/ai/images/components/generation-art.tsx
import * as React from "react"

import { cn } from "@/lib/utils"

import { type Blob, type GenerationRatio } from "../data"
import { RATIO_BOX } from "./studio-vocabulary"

export type GenerationArtProps = React.ComponentProps<"svg"> & {
  /** The picture's accessible name: what was asked for. */
  prompt: string
  ratio: GenerationRatio
  /** The row's four `oklch()` colours. */
  palette: string[]
  /** The circles, as fractions of the frame — computed once from the row's seed on the server. */
  blobs: Blob[]
}

// The blur's radius as a share of the shorter side: enough for the circles to
// melt into one another, not so much that the frame reads as a flat wash.
const BLUR = 0.09

/**
 * The generated "image". Never a photograph: a muted ground and three to five
 * soft circles painted in the row's own colours, at positions its seed chose.
 * Every part of it comes from the row, so the same row always draws the same
 * picture, and a card can be saved as a PNG through the export lib the way a
 * chart is.
 */
export function GenerationArt({ className, prompt, ratio, palette, blobs, ...props }: GenerationArtProps) {
  // useId's delimiters are not safe inside a url(#…) reference, so only the
  // letters and digits are kept; they are what makes the id unique anyway.
  const id = React.useId().replace(/[^a-zA-Z0-9]/g, "")
  const { width, height } = RATIO_BOX[ratio]
  const short = Math.min(width, height)

  return (
    <svg
      data-slot="generation-art"
      role="img"
      aria-label={prompt}
      viewBox={`0 0 ${width} ${height}`}
      preserveAspectRatio="xMidYMid slice"
      className={cn("block h-full w-full", className)}
      {...props}
    >
      <defs>
        {palette.map((color, index) => (
          <radialGradient key={index} id={`${id}-g${index}`}>
            <stop offset="0%" stopColor={color} stopOpacity={0.95} />
            <stop offset="70%" stopColor={color} stopOpacity={0.55} />
            <stop offset="100%" stopColor={color} stopOpacity={0} />
          </radialGradient>
        ))}
        <filter id={`${id}-blur`} x="-20%" y="-20%" width="140%" height="140%">
          <feGaussianBlur stdDeviation={short * BLUR} />
        </filter>
      </defs>

      <rect width={width} height={height} className="fill-muted" />
      <g filter={`url(#${id}-blur)`}>
        {blobs.map((blob, index) => (
          <circle
            key={index}
            cx={blob.cx * width}
            cy={blob.cy * height}
            r={blob.r * short}
            fill={`url(#${id}-g${blob.color % palette.length})`}
          />
        ))}
      </g>
    </svg>
  )
}
app/ai/images/components/generation-gallery.tsx
"use client"

import * as React from "react"
import { DownloadIcon, EllipsisIcon, Trash2Icon } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { EmptyState } from "@/components/ui/empty-state"

import { type GenerationRow } from "../data"
import { GenerationArt } from "./generation-art"
import { RATIO_BOX, STYLES } from "./studio-vocabulary"

export type GenerationGalleryProps = {
  rows: GenerationRow[]
  onDownload: (row: GenerationRow) => void
  onDelete: (row: GenerationRow) => void
}

/** "digital-art" as the chip reads it: the word, not the key. */
const styleLabel = (style: GenerationRow["style"]): string =>
  STYLES.find((option) => option.value === style)?.label.toLowerCase() ?? style.replace("-", " ")

function GenerationCard({
  row,
  onDownload,
  onDelete,
}: {
  row: GenerationRow
  onDownload: () => void
  onDelete: () => void
}) {
  const { width, height } = RATIO_BOX[row.ratio]

  return (
    <article aria-label={row.prompt} className="min-w-0">
      <Card size="sm">
        <CardContent className="flex flex-col gap-3 p-0">
          {/* The frame keeps the row's own ratio, capped so a portrait does not
              run the column three screens tall; `slice` crops rather than letterboxes. */}
          <div
            data-slot="generation-frame"
            className="max-h-[26rem] w-full overflow-hidden rounded-t-lg"
            style={{ aspectRatio: `${width} / ${height}` }}
          >
            <GenerationArt
              data-generation-id={row.id}
              prompt={row.prompt}
              ratio={row.ratio}
              palette={row.palette}
              blobs={row.blobs}
            />
          </div>
          <div className="flex flex-col gap-2 px-3 pb-3">
            <p className="line-clamp-2 text-sm text-foreground">{row.prompt}</p>
            <div className="flex flex-wrap items-center gap-1.5">
              <Badge variant="outline">{styleLabel(row.style)}</Badge>
              <Badge variant="outline" className="tabular-nums">
                {row.ratio}
              </Badge>
              {row.quality === "high" ? <Badge variant="secondary">High</Badge> : null}
              <span className="ml-auto text-xs tabular-nums text-muted-foreground">{row.when}</span>
              <DropdownMenu>
                <DropdownMenuTrigger
                  render={
                    <Button
                      variant="ghost"
                      size="icon-xs"
                      aria-label={`Actions for ${row.prompt}`}
                      className="text-muted-foreground"
                    />
                  }
                >
                  <EllipsisIcon aria-hidden="true" />
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end">
                  <DropdownMenuItem onClick={onDownload}>
                    <DownloadIcon aria-hidden="true" />
                    Download PNG
                  </DropdownMenuItem>
                  <DropdownMenuSeparator />
                  <DropdownMenuItem variant="destructive" onClick={onDelete}>
                    <Trash2Icon aria-hidden="true" />
                    Delete
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </div>
          </div>
        </CardContent>
      </Card>
    </article>
  )
}

/** The pictures, newest first, two to a row. */
export function GenerationGallery({ rows, onDownload, onDelete }: GenerationGalleryProps) {
  if (rows.length === 0) {
    return (
      <section aria-label="Gallery" className="panel">
        <EmptyState
          title="No images yet"
          description="Describe a picture on the left and generate it; every one you make lands here."
        />
      </section>
    )
  }

  return (
    <section aria-label="Gallery" className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2">
      {rows.map((row) => (
        <GenerationCard
          key={row.id}
          row={row}
          onDownload={() => onDownload(row)}
          onDelete={() => onDelete(row)}
        />
      ))}
    </section>
  )
}
app/ai/images/components/generation-settings.tsx
"use client"

import * as React from "react"
import { SparklesIcon } from "lucide-react"

import { AsyncButton } from "@/components/ui/async-button"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { FormRow } from "@/components/ui/form-section"
import { Input } from "@/components/ui/input"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"

import { type GenerateInput } from "../actions"
import { COUNTS, QUALITIES, QUICK_PROMPTS, RATIOS, STYLES, type Option } from "./studio-vocabulary"

export type SettingsError = { message: string; field?: string }

export type GenerationSettingsProps = {
  className?: string
  value: GenerateInput
  onChange: (next: GenerateInput) => void
  onGenerate: () => void
  generating: boolean
  error?: SettingsError
  /** A quiet line after a set lands: how many, and where. */
  notice?: string
}

/**
 * A refusal, as a danger callout that announces itself, placed under the
 * control it names — never a quiet red line the reader has to find.
 */
function FieldRefusal({ id, message }: { id: string; message?: string }) {
  if (!message) return null
  return (
    <Callout id={id} variant="danger" role="alert">
      {message}
    </Callout>
  )
}

/** One labelled select bound to a field of the form. */
function SettingSelect<T extends string>({
  id,
  label,
  options,
  value,
  onValueChange,
  error,
}: {
  id: string
  label: string
  options: Option<T>[]
  value: string
  onValueChange: (value: string) => void
  error?: string
}) {
  return (
    <FormRow label={label} htmlFor={id}>
      {(field) => (
        <Select value={value} onValueChange={(next) => onValueChange(String(next ?? value))} items={options}>
          <SelectTrigger
            id={field.id}
            aria-label={label}
            aria-describedby={error ? `${id}-error` : undefined}
            aria-invalid={error ? true : undefined}
            className="w-full"
          >
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {options.map((option) => (
              <SelectItem key={option.value} value={option.value}>
                {option.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      )}
    </FormRow>
  )
}

/** The left card: everything a picture is made from, and the button that makes it. */
export function GenerationSettings({
  className,
  value,
  onChange,
  onGenerate,
  generating,
  error,
  notice,
}: GenerationSettingsProps) {
  const base = React.useId()
  const field = (name: string) => `${base}-${name}`
  const errorFor = (name: string) => (error?.field === name ? error.message : undefined)
  // A refusal that names one of the four selects lands under the grid they
  // share; one that names no field at all lands above the button.
  const gridError = ["style", "ratio", "quality", "count"].find((name) => errorFor(name))
  const formError = error && !error.field ? error.message : undefined
  const set = <K extends keyof GenerateInput>(key: K, next: GenerateInput[K]) => onChange({ ...value, [key]: next })

  return (
    <Card className={className}>
      <CardHeader>
        <CardTitle>Generation settings</CardTitle>
      </CardHeader>
      <form
        onSubmit={(event) => {
          event.preventDefault()
          onGenerate()
        }}
      >
        <CardContent className="flex flex-col gap-4">
          <FormRow
            label="Prompt"
            htmlFor={field("prompt")}
            required
            description="Be specific about the subject, the place and the light."
          >
            <Textarea
              id={field("prompt")}
              rows={3}
              placeholder="Describe the image you want to generate…"
              value={value.prompt}
              aria-invalid={errorFor("prompt") ? true : undefined}
              aria-describedby={errorFor("prompt") ? `${field("prompt")}-error` : undefined}
              onChange={(event) => set("prompt", event.target.value)}
            />
          </FormRow>
          <FieldRefusal id={`${field("prompt")}-error`} message={errorFor("prompt")} />

          <FormRow label="Negative prompt" htmlFor={field("negative")} description="What to keep out of the picture.">
            <Textarea
              id={field("negative")}
              rows={2}
              placeholder="text, watermark, blurry…"
              value={value.negative}
              onChange={(event) => set("negative", event.target.value)}
            />
          </FormRow>

          <div className="grid grid-cols-2 gap-3">
            <SettingSelect id={field("style")} label="Style" options={STYLES} value={value.style} onValueChange={(next) => set("style", next)} error={errorFor("style")} />
            <SettingSelect id={field("ratio")} label="Aspect ratio" options={RATIOS} value={value.ratio} onValueChange={(next) => set("ratio", next)} error={errorFor("ratio")} />
            <SettingSelect id={field("quality")} label="Quality" options={QUALITIES} value={value.quality} onValueChange={(next) => set("quality", next)} error={errorFor("quality")} />
            <SettingSelect id={field("count")} label="Count" options={COUNTS} value={String(value.count)} onValueChange={(next) => set("count", Number(next))} error={errorFor("count")} />
          </div>
          {gridError ? <FieldRefusal id={`${field(gridError)}-error`} message={errorFor(gridError)} /> : null}

          <FormRow label="Seed" htmlFor={field("seed")} description="Leave blank to pick one from the prompt.">
            <Input
              id={field("seed")}
              inputMode="numeric"
              placeholder="Any whole number"
              value={value.seed}
              aria-invalid={errorFor("seed") ? true : undefined}
              aria-describedby={errorFor("seed") ? `${field("seed")}-error` : undefined}
              onChange={(event) => set("seed", event.target.value)}
              className="font-mono"
            />
          </FormRow>
          <FieldRefusal id={`${field("seed")}-error`} message={errorFor("seed")} />

          {formError ? (
            <Callout variant="danger" role="alert">
              {formError}
            </Callout>
          ) : null}

          <AsyncButton type="submit" className="w-full" loading={generating} loadingText="Generating…">
            <SparklesIcon data-icon="inline-start" aria-hidden="true" />
            Generate
          </AsyncButton>
          <p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
            {notice}
          </p>

          <div className="flex flex-col gap-2 border-t pt-4">
            <p className="type-eyebrow">Quick prompts</p>
            <div className="flex flex-wrap gap-2">
              {QUICK_PROMPTS.map((chip) => (
                <Button
                  key={chip.label}
                  type="button"
                  variant="outline"
                  size="sm"
                  className="rounded-full"
                  onClick={() => set("prompt", chip.prompt)}
                >
                  {chip.label}
                </Button>
              ))}
            </div>
          </div>
        </CardContent>
      </form>
    </Card>
  )
}
app/ai/images/components/studio-vocabulary.ts
/**
 * The words the settings card offers: the four styles, the four frames, the
 * two qualities and the counts, as a reader picks them — plus the quick
 * prompts. UI vocabulary, so it lives beside the island rather than in
 * data.ts, which reads the store and must never reach the browser.
 */
import { type GenerationQuality, type GenerationRatio, type GenerationStyle } from "../data"

export type Option<T extends string> = { value: T; label: string }

export const STYLES: Option<GenerationStyle>[] = [
  { value: "realistic", label: "Realistic" },
  { value: "digital-art", label: "Digital art" },
  { value: "watercolor", label: "Watercolour" },
  { value: "line-art", label: "Line art" },
]

export const RATIOS: Option<GenerationRatio>[] = [
  { value: "1:1", label: "Square (1:1)" },
  { value: "4:3", label: "Landscape (4:3)" },
  { value: "16:9", label: "Wide (16:9)" },
  { value: "9:16", label: "Portrait (9:16)" },
]

export const QUALITIES: Option<GenerationQuality>[] = [
  { value: "standard", label: "Standard" },
  { value: "high", label: "High" },
]

export const COUNTS: Option<"1" | "2" | "3" | "4">[] = [
  { value: "1", label: "1 image" },
  { value: "2", label: "2 images" },
  { value: "3", label: "3 images" },
  { value: "4", label: "4 images" },
]

/** The frame each ratio is drawn in, as an SVG viewBox. */
export const RATIO_BOX: Record<GenerationRatio, { width: number; height: number }> = {
  "1:1": { width: 400, height: 400 },
  "4:3": { width: 400, height: 300 },
  "16:9": { width: 400, height: 225 },
  "9:16": { width: 225, height: 400 },
}

/** A chip under the form and the prompt it fills in. */
export const QUICK_PROMPTS: { label: string; prompt: string }[] = [
  { label: "Harbour at dawn", prompt: "A quiet harbour at dawn with fishing boats and low mist over the water" },
  { label: "Paper-cut forest", prompt: "A paper-cut style forest with layered hills and a crescent moon" },
  { label: "Abstract hero", prompt: "Abstract flowing shapes in soft pastel colours for a landing page hero" },
  { label: "Northern lights", prompt: "A lone tent under the northern lights on a frozen lake" },
]
app/ai/images/components/studio-view.tsx
"use client"

import * as React from "react"
import { DownloadIcon, HistoryIcon } from "lucide-react"

import { downloadBlob, exportFilename, svgToPng } from "@/lib/export"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { PageHeader } from "@/components/ui/page-header"
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

import { deleteGeneration, generate, type GenerateInput } from "../actions"
import { type GenerationRow } from "../data"
import { GenerationGallery } from "./generation-gallery"
import { GenerationSettings, type SettingsError } from "./generation-settings"
import { COUNTS, QUALITIES, RATIOS, STYLES } from "./studio-vocabulary"

const INITIAL: GenerateInput = {
  prompt: "",
  negative: "",
  style: STYLES[0].value,
  ratio: RATIOS[0].value,
  quality: QUALITIES[0].value,
  count: Number(COUNTS[1].value),
  seed: "",
}

/** The settings a picture was made with, so the history can put them back in the form. */
function settingsOf(row: GenerationRow): GenerateInput {
  return {
    prompt: row.prompt,
    negative: row.negative ?? "",
    style: row.style,
    ratio: row.ratio,
    quality: row.quality,
    count: INITIAL.count,
    seed: String(row.seed),
  }
}

/** A short file name from the prompt: "a-red-kite-over-a-windy-beach-2026-09-04.png". */
const filenameFor = (row: GenerationRow) => exportFilename(row.prompt.slice(0, 48), "png", row.createdAt)

export type StudioViewProps = {
  rows: GenerationRow[]
}

/**
 * The studio. One island, because the form, the gallery it adds to and the
 * history that refills the form are one piece of state. Every change goes
 * through a server action and is applied only once that action says it took.
 */
export function StudioView({ rows: initial }: StudioViewProps) {
  const [rows, setRows] = React.useState(initial)
  const [form, setForm] = React.useState<GenerateInput>(INITIAL)
  const [generating, setGenerating] = React.useState(false)
  const [error, setError] = React.useState<SettingsError | undefined>(undefined)
  const [notice, setNotice] = React.useState<string | undefined>(undefined)
  const [galleryError, setGalleryError] = React.useState<string | undefined>(undefined)
  const [galleryNotice, setGalleryNotice] = React.useState<string | undefined>(undefined)
  const [historyOpen, setHistoryOpen] = React.useState(false)
  const galleryRef = React.useRef<HTMLDivElement>(null)

  async function make() {
    setGenerating(true)
    const result = await generate(form)
    setGenerating(false)

    if (!result.ok) {
      setError({ message: result.error.message, field: result.error.field })
      setNotice(undefined)
      return
    }
    setError(undefined)
    setRows((current) => [...result.data.rows, ...current])
    const count = result.data.rows.length
    setNotice(`${count} ${count === 1 ? "image" : "images"} generated from your prompt — newest first in the gallery.`)
  }

  async function remove(row: GenerationRow) {
    const result = await deleteGeneration(row.id)
    if (!result.ok) {
      setGalleryError(result.error.message)
      return
    }
    setGalleryError(undefined)
    setRows((current) => current.filter((entry) => entry.id !== result.data.id))
    setGalleryNotice("Image deleted.")
  }

  /** The row's SVG, as drawn on the page; the export lib paints it into a PNG. */
  const artOf = (row: GenerationRow) =>
    galleryRef.current?.querySelector<SVGSVGElement>(`svg[data-generation-id="${row.id}"]`) ?? null

  async function download(row: GenerationRow): Promise<boolean> {
    const svg = artOf(row)
    if (!svg) return false
    try {
      const blob = await svgToPng(svg, { scale: 2 })
      return downloadBlob(filenameFor(row), blob)
    } catch (cause) {
      setGalleryError(cause instanceof Error ? cause.message : "The image could not be saved.")
      return false
    }
  }

  async function downloadOne(row: GenerationRow) {
    setGalleryError(undefined)
    if (await download(row)) setGalleryNotice(`Saved ${filenameFor(row)}.`)
  }

  async function downloadAll() {
    setGalleryError(undefined)
    let saved = 0
    for (const row of rows) if (await download(row)) saved += 1
    setGalleryNotice(`Saved ${saved} of ${rows.length} images.`)
  }

  return (
    <div className="flex flex-col gap-4">
      <PageHeader
        title="Image studio"
        description="Describe a picture; every one you make is kept, with the settings it came from."
        meta={`${rows.length} images`}
        actions={
          <>
            <Button type="button" variant="outline" onClick={() => void downloadAll()} disabled={rows.length === 0}>
              <DownloadIcon data-icon="inline-start" aria-hidden="true" />
              Download all
            </Button>
            <Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
              <SheetTrigger render={<Button variant="outline" size="icon" aria-label="History" />}>
                <HistoryIcon aria-hidden="true" />
              </SheetTrigger>
              <SheetContent side="right" className="overflow-y-auto p-4">
                <SheetHeader className="p-0">
                  <SheetTitle>History</SheetTitle>
                  <SheetDescription>Every prompt, newest first. Pick one to put its settings back in the form.</SheetDescription>
                </SheetHeader>
                <DataList divided>
                  {rows.map((row) => (
                    <DataListItem
                      key={row.id}
                      title={row.prompt}
                      description={`${row.style} · ${row.ratio} · seed ${row.seed}`}
                      meta={row.when}
                      wrapTitle
                      onClick={() => {
                        setForm(settingsOf(row))
                        setHistoryOpen(false)
                      }}
                    />
                  ))}
                </DataList>
              </SheetContent>
            </Sheet>
          </>
        }
      />

      <div className="grid items-start gap-4 lg:grid-cols-[22rem_minmax(0,1fr)]">
        <GenerationSettings
          className="lg:sticky lg:top-4"
          value={form}
          onChange={(next) => {
            setForm(next)
            if (error) setError(undefined)
          }}
          onGenerate={() => void make()}
          generating={generating}
          error={error}
          notice={notice}
        />

        <div ref={galleryRef} className="flex min-w-0 flex-col gap-3">
          {galleryError ? (
            <Callout variant="danger" role="alert">
              {galleryError}
            </Callout>
          ) : null}
          <p role="status" aria-live="polite" className="min-h-4 text-xs text-muted-foreground">
            {galleryNotice}
          </p>
          <GenerationGallery rows={rows} onDownload={(row) => void downloadOne(row)} onDelete={(row) => void remove(row)} />
        </div>
      </div>
    </div>
  )
}