/ai/imagesImage 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.
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
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>
)
}Install
npx shadcn@latest add @vibra/ai-imagesNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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 }))
}"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
}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>
)
}"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>
)
}"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>
)
}/**
* 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" },
]"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>
)
}