AI Image Generation

Complete AI image generation template.

Installation

npx shadcn@latest add @boardcn/ai-image-generation
npx shadcn@latest add @boardcn/ai-image-generation

npm packages

  • @remixicon/react
  • motion
  • react-aria-components

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

What it composes

7 BoardCN components, installed automatically.

Source

The 5 files the CLI copies into your project.

components/templates/ai-image-generation/ai-image-generation.tsx
"use client";

import { useCallback, useRef, useState, type HTMLAttributes, type KeyboardEvent, type PointerEvent, type ReactNode, type Ref } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Dialog, Modal, ModalOverlay } from "react-aria-components";
import {
  RiAddLine,
  RiArrowDownSLine,
  RiCloseLine,
  RiGalleryLine,
  RiMenuLine,
  RiMore2Line,
  RiShareForwardLine,
  RiSparkling2Line,
} from "@remixicon/react";
import { Button } from "@/components/base/buttons/button";
import { IconButton } from "@/components/base/buttons/icon-button";
import { AiChatComposerPreview } from "@/components/blocks/composer/composer";
import { AgentSidebar } from "@/components/templates/ai-chat/agent-sidebar";
import { cx } from "@/utils/cx";
import { type ArtworkPalette } from "./artwork";
import { GenerationFrame } from "./generation-frame";
import { ImageGallery, type GalleryItem, type ImageGalleryProps } from "./image-gallery";

export interface AiImageGenerationProps extends HTMLAttributes<HTMLDivElement> {
  defaultPrompt?: string;
  userName?: string;
  onGenerate?: (prompt: string) => void;
  galleryItems: readonly GalleryItem[];
  palettes: readonly ArtworkPalette[];
  styleLabels?: readonly string[];
  /** Licensed generated-image URL supplied by the registry consumer. */
  generatedArtworkSrc?: string;
  /** Render slot for exact licensed media or a framework image component. */
  renderGeneratedArtwork?: () => ReactNode;
  /** Per-gallery-item licensed artwork URLs keyed by gallery item id. */
  galleryArtworkSrcById?: ImageGalleryProps["artworkSrcById"];
  /** Render slot for exact licensed gallery media. */
  renderGalleryArtwork?: ImageGalleryProps["renderArtwork"];
  ref?: Ref<HTMLDivElement>;
}

const DEFAULT_PANEL_WIDTH = 410;
const MIN_PANEL_WIDTH = 330;
const MAX_PANEL_WIDTH = 620;

export function AiImageGeneration({
  defaultPrompt = "",
  userName = "Mertcan Esmergul",
  onGenerate,
  galleryItems,
  palettes,
  styleLabels,
  generatedArtworkSrc,
  renderGeneratedArtwork,
  galleryArtworkSrcById,
  renderGalleryArtwork,
  className,
  ref,
  ...props
}: AiImageGenerationProps) {
  const [collapsed, setCollapsed] = useState(false);
  const [mobileNavigation, setMobileNavigation] = useState(false);
  const [mobileGallery, setMobileGallery] = useState(false);
  const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
  const [generationKey, setGenerationKey] = useState(0);
  const [prompt, setPrompt] = useState(defaultPrompt);
  const [toast, setToast] = useState<string | null>(null);
  const navigationTriggerRef = useRef<HTMLButtonElement>(null);
  const galleryTriggerRef = useRef<HTMLButtonElement>(null);
  const dragStart = useRef<{ x: number; width: number } | null>(null);

  const showToast = useCallback((message: string) => {
    setToast(message);
    window.setTimeout(() => setToast(null), 2100);
  }, []);

  const generate = useCallback((value?: string) => {
    const nextPrompt = value?.trim() || defaultPrompt;
    setPrompt(nextPrompt);
    setGenerationKey((key) => key + 1);
    onGenerate?.(nextPrompt);
  }, [defaultPrompt, onGenerate]);

  const closeNavigation = useCallback(() => {
    setMobileNavigation(false);
    window.setTimeout(() => navigationTriggerRef.current?.focus());
  }, []);

  const closeGallery = useCallback(() => {
    setMobileGallery(false);
    window.setTimeout(() => galleryTriggerRef.current?.focus());
  }, []);

  const beginResize = useCallback((event: PointerEvent<HTMLDivElement>) => {
    dragStart.current = { x: event.clientX, width: panelWidth };
    event.currentTarget.setPointerCapture(event.pointerId);
  }, [panelWidth]);

  const resize = useCallback((event: PointerEvent<HTMLDivElement>) => {
    if (!dragStart.current) return;
    setPanelWidth(Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, dragStart.current.width + dragStart.current.x - event.clientX)));
  }, []);

  const resizeWithKeyboard = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
    const increments: Record<string, number> = { ArrowLeft: 10, ArrowRight: -10 };
    if (event.key === "Home") {
      event.preventDefault();
      setPanelWidth(MIN_PANEL_WIDTH);
      return;
    }
    if (event.key === "End") {
      event.preventDefault();
      setPanelWidth(MAX_PANEL_WIDTH);
      return;
    }
    const increment = increments[event.key];
    if (!increment) return;
    event.preventDefault();
    setPanelWidth((width) => Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, width + increment)));
  }, []);

  return (
    <div ref={ref} data-user-name={userName} className={cx("relative flex h-[720px] min-h-[620px] w-full gap-3 overflow-hidden bg-background-full p-3 font-sans text-text-primary", className)} {...props}>
      <AgentSidebar userName={userName} userInitials={userName.trim().charAt(0).toUpperCase() || "M"} collapsed={collapsed} onCollapsedChange={setCollapsed} className="hidden lg:flex" />

      <main className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
        <header className="flex h-12 shrink-0 items-center justify-between border-b border-border-button-default px-3">
          <div className="flex min-w-0 items-center gap-2">
            <Button ref={navigationTriggerRef} variant="secondary" iconOnly leadingIcon={RiMenuLine} aria-label="Open navigation" onClick={() => setMobileNavigation(true)} className="rounded-full lg:hidden" />
            <nav aria-label="Chat location" className="flex min-w-0 items-center gap-2 text-body-medium"><button type="button" className="flex items-center gap-1 rounded-lg p-1.5 hover:bg-background-primary-hover">vibl coding project<RiArrowDownSLine className="size-4 text-foreground-icon-secondary" /></button><span className="text-text-tertiary">/</span><span className="truncate text-text-primary">image generation</span></nav>
          </div>
          <div className="flex gap-1"><IconButton icon={RiShareForwardLine} size="small" aria-label="Share chat" className="border-0 bg-transparent shadow-none" /><IconButton icon={RiMore2Line} size="small" aria-label="More options" className="border-0 bg-transparent shadow-none" /></div>
        </header>

        <section className="min-h-0 flex-1 overflow-y-auto px-5 py-8 [scrollbar-width:none]">
          <div className="mx-auto flex w-full max-w-[560px] flex-col gap-5">
            <p className="ml-auto max-w-[460px] rounded-2xl rounded-br-md bg-background-tertiary-default px-4 py-3 text-body text-text-primary">{prompt}</p>
            <GenerationFrame generationKey={generationKey} onToast={showToast} artworkSrc={generatedArtworkSrc} renderArtwork={renderGeneratedArtwork} generatedImageLabel={prompt} />
          </div>
        </section>

        <div className="shrink-0 px-4 pb-3">
          <AiChatComposerPreview className="mx-auto" composerProps={{ defaultModel: "Fable 5", onSubmit: (value) => generate(value) }} statusBarProps={{ branch: "Main", project: "project-sea", mode: "Agent", contextUsage: 57 }} />
        </div>
      </main>

      <div
        role="separator"
        aria-label="Resize panels"
        aria-orientation="vertical"
        aria-valuemin={MIN_PANEL_WIDTH}
        aria-valuemax={MAX_PANEL_WIDTH}
        aria-valuenow={panelWidth}
        tabIndex={0}
        onKeyDown={resizeWithKeyboard}
        onPointerDown={beginResize}
        onPointerMove={resize}
        onPointerUp={(event) => {
          dragStart.current = null;
          if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
        }}
        className="group/drag relative hidden w-px shrink-0 cursor-col-resize touch-none items-center justify-center outline-none before:absolute before:inset-y-0 before:-left-2 before:w-5 before:content-[''] focus-visible:ring-2 focus-visible:ring-border-focus-ring xl:flex"
      >
        <span aria-hidden className="h-6 w-px bg-border-button-default transition-colors group-hover/drag:bg-foreground-icon-secondary" />
      </div>
      <ImageGallery items={galleryItems} palettes={palettes} styleLabels={styleLabels} panelWidth={panelWidth} artworkSrcById={galleryArtworkSrcById} renderArtwork={renderGalleryArtwork} onNewGeneration={() => generate()} onToast={showToast} />

      <AnimatePresence>
        {toast ? (
          <motion.div role="status" initial={{ opacity: 0, y: 12, scale: .96 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 8 }} className="absolute bottom-5 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-xl border border-border-button-default bg-background-primary-default px-3 py-2 text-body-2-medium text-text-primary shadow-dropdown"><RiSparkling2Line className="size-4 text-accent-600" />{toast}<button type="button" aria-label="Dismiss notification" onClick={() => setToast(null)}><RiCloseLine className="size-4 text-foreground-icon-secondary" /></button></motion.div>
        ) : null}
      </AnimatePresence>

      <div className="absolute right-3 bottom-3 z-20 flex gap-2 xl:hidden">
        <Button ref={galleryTriggerRef} variant="secondary" iconOnly leadingIcon={RiGalleryLine} aria-label="Open image gallery" onClick={() => setMobileGallery(true)} className="rounded-full shadow-lg" />
        <button type="button" className="rounded-full bg-accent-600 p-3 text-white shadow-lg" aria-label="New generation" onClick={() => generate()}><RiAddLine className="size-5" /></button>
      </div>

      <ModalOverlay isOpen={mobileNavigation} isDismissable onOpenChange={(open) => { if (!open) closeNavigation(); }} className="fixed inset-0 z-50 bg-black/40 transition-opacity duration-200 data-[entering]:opacity-0 data-[exiting]:opacity-0 motion-reduce:transition-none lg:hidden">
        <Modal className="h-full w-[min(272px,calc(100%-24px))] p-3 pr-0 outline-none transition-transform duration-300 ease-out data-[entering]:-translate-x-full data-[exiting]:-translate-x-full motion-reduce:transition-none">
          <Dialog aria-label="Navigation" className="relative h-full outline-none">
            <AgentSidebar userName={userName} userInitials={userName.trim().charAt(0).toUpperCase() || "M"} className="w-[260px] max-w-full" />
            <Button variant="secondary" iconOnly leadingIcon={RiCloseLine} aria-label="Close navigation" onClick={closeNavigation} className="absolute top-2 right-2 rounded-full" />
          </Dialog>
        </Modal>
      </ModalOverlay>

      <ModalOverlay isOpen={mobileGallery} isDismissable onOpenChange={(open) => { if (!open) closeGallery(); }} className="fixed inset-0 z-50 flex items-end bg-black/40 transition-opacity duration-200 data-[entering]:opacity-0 data-[exiting]:opacity-0 motion-reduce:transition-none xl:hidden">
        <Modal className="h-[min(86dvh,720px)] w-full rounded-t-3xl bg-background-full p-3 pb-[calc(12px+env(safe-area-inset-bottom))] shadow-sidebar outline-none transition-transform duration-300 ease-out data-[entering]:translate-y-full data-[exiting]:translate-y-full motion-reduce:transition-none">
          <Dialog aria-label="Image gallery" className="flex h-full min-h-0 flex-col outline-none">
            <div className="flex h-10 shrink-0 items-center justify-between px-1"><span className="text-headline-medium text-text-primary">Image generations</span><Button variant="secondary" iconOnly leadingIcon={RiCloseLine} aria-label="Close image gallery" onClick={closeGallery} className="rounded-full" /></div>
            <ImageGallery items={galleryItems} palettes={palettes} styleLabels={styleLabels} mobile artworkSrcById={galleryArtworkSrcById} renderArtwork={renderGalleryArtwork} onNewGeneration={() => generate()} onToast={showToast} className="min-h-0 flex-1" />
          </Dialog>
        </Modal>
      </ModalOverlay>
    </div>
  );
}

export default AiImageGeneration;
"use client";

import { useCallback, useRef, useState, type HTMLAttributes, type KeyboardEvent, type PointerEvent, type ReactNode, type Ref } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Dialog, Modal, ModalOverlay } from "react-aria-components";
import {
  RiAddLine,
  RiArrowDownSLine,
  RiCloseLine,
  RiGalleryLine,
  RiMenuLine,
  RiMore2Line,
  RiShareForwardLine,
  RiSparkling2Line,
} from "@remixicon/react";
import { Button } from "@/components/base/buttons/button";
import { IconButton } from "@/components/base/buttons/icon-button";
import { AiChatComposerPreview } from "@/components/blocks/composer/composer";
import { AgentSidebar } from "@/components/templates/ai-chat/agent-sidebar";
import { cx } from "@/utils/cx";
import { type ArtworkPalette } from "./artwork";
import { GenerationFrame } from "./generation-frame";
import { ImageGallery, type GalleryItem, type ImageGalleryProps } from "./image-gallery";

export interface AiImageGenerationProps extends HTMLAttributes<HTMLDivElement> {
  defaultPrompt?: string;
  userName?: string;
  onGenerate?: (prompt: string) => void;
  galleryItems: readonly GalleryItem[];
  palettes: readonly ArtworkPalette[];
  styleLabels?: readonly string[];
  /** Licensed generated-image URL supplied by the registry consumer. */
  generatedArtworkSrc?: string;
  /** Render slot for exact licensed media or a framework image component. */
  renderGeneratedArtwork?: () => ReactNode;
  /** Per-gallery-item licensed artwork URLs keyed by gallery item id. */
  galleryArtworkSrcById?: ImageGalleryProps["artworkSrcById"];
  /** Render slot for exact licensed gallery media. */
  renderGalleryArtwork?: ImageGalleryProps["renderArtwork"];
  ref?: Ref<HTMLDivElement>;
}

const DEFAULT_PANEL_WIDTH = 410;
const MIN_PANEL_WIDTH = 330;
const MAX_PANEL_WIDTH = 620;

export function AiImageGeneration({
  defaultPrompt = "",
  userName = "Mertcan Esmergul",
  onGenerate,
  galleryItems,
  palettes,
  styleLabels,
  generatedArtworkSrc,
  renderGeneratedArtwork,
  galleryArtworkSrcById,
  renderGalleryArtwork,
  className,
  ref,
  ...props
}: AiImageGenerationProps) {
  const [collapsed, setCollapsed] = useState(false);
  const [mobileNavigation, setMobileNavigation] = useState(false);
  const [mobileGallery, setMobileGallery] = useState(false);
  const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
  const [generationKey, setGenerationKey] = useState(0);
  const [prompt, setPrompt] = useState(defaultPrompt);
  const [toast, setToast] = useState<string | null>(null);
  const navigationTriggerRef = useRef<HTMLButtonElement>(null);
  const galleryTriggerRef = useRef<HTMLButtonElement>(null);
  const dragStart = useRef<{ x: number; width: number } | null>(null);

  const showToast = useCallback((message: string) => {
    setToast(message);
    window.setTimeout(() => setToast(null), 2100);
  }, []);

  const generate = useCallback((value?: string) => {
    const nextPrompt = value?.trim() || defaultPrompt;
    setPrompt(nextPrompt);
    setGenerationKey((key) => key + 1);
    onGenerate?.(nextPrompt);
  }, [defaultPrompt, onGenerate]);

  const closeNavigation = useCallback(() => {
    setMobileNavigation(false);
    window.setTimeout(() => navigationTriggerRef.current?.focus());
  }, []);

  const closeGallery = useCallback(() => {
    setMobileGallery(false);
    window.setTimeout(() => galleryTriggerRef.current?.focus());
  }, []);

  const beginResize = useCallback((event: PointerEvent<HTMLDivElement>) => {
    dragStart.current = { x: event.clientX, width: panelWidth };
    event.currentTarget.setPointerCapture(event.pointerId);
  }, [panelWidth]);

  const resize = useCallback((event: PointerEvent<HTMLDivElement>) => {
    if (!dragStart.current) return;
    setPanelWidth(Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, dragStart.current.width + dragStart.current.x - event.clientX)));
  }, []);

  const resizeWithKeyboard = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
    const increments: Record<string, number> = { ArrowLeft: 10, ArrowRight: -10 };
    if (event.key === "Home") {
      event.preventDefault();
      setPanelWidth(MIN_PANEL_WIDTH);
      return;
    }
    if (event.key === "End") {
      event.preventDefault();
      setPanelWidth(MAX_PANEL_WIDTH);
      return;
    }
    const increment = increments[event.key];
    if (!increment) return;
    event.preventDefault();
    setPanelWidth((width) => Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, width + increment)));
  }, []);

  return (
    <div ref={ref} data-user-name={userName} className={cx("relative flex h-[720px] min-h-[620px] w-full gap-3 overflow-hidden bg-background-full p-3 font-sans text-text-primary", className)} {...props}>
      <AgentSidebar userName={userName} userInitials={userName.trim().charAt(0).toUpperCase() || "M"} collapsed={collapsed} onCollapsedChange={setCollapsed} className="hidden lg:flex" />

      <main className="relative flex min-w-0 flex-1 flex-col overflow-hidden">
        <header className="flex h-12 shrink-0 items-center justify-between border-b border-border-button-default px-3">
          <div className="flex min-w-0 items-center gap-2">
            <Button ref={navigationTriggerRef} variant="secondary" iconOnly leadingIcon={RiMenuLine} aria-label="Open navigation" onClick={() => setMobileNavigation(true)} className="rounded-full lg:hidden" />
            <nav aria-label="Chat location" className="flex min-w-0 items-center gap-2 text-body-medium"><button type="button" className="flex items-center gap-1 rounded-lg p-1.5 hover:bg-background-primary-hover">vibl coding project<RiArrowDownSLine className="size-4 text-foreground-icon-secondary" /></button><span className="text-text-tertiary">/</span><span className="truncate text-text-primary">image generation</span></nav>
          </div>
          <div className="flex gap-1"><IconButton icon={RiShareForwardLine} size="small" aria-label="Share chat" className="border-0 bg-transparent shadow-none" /><IconButton icon={RiMore2Line} size="small" aria-label="More options" className="border-0 bg-transparent shadow-none" /></div>
        </header>

        <section className="min-h-0 flex-1 overflow-y-auto px-5 py-8 [scrollbar-width:none]">
          <div className="mx-auto flex w-full max-w-[560px] flex-col gap-5">
            <p className="ml-auto max-w-[460px] rounded-2xl rounded-br-md bg-background-tertiary-default px-4 py-3 text-body text-text-primary">{prompt}</p>
            <GenerationFrame generationKey={generationKey} onToast={showToast} artworkSrc={generatedArtworkSrc} renderArtwork={renderGeneratedArtwork} generatedImageLabel={prompt} />
          </div>
        </section>

        <div className="shrink-0 px-4 pb-3">
          <AiChatComposerPreview className="mx-auto" composerProps={{ defaultModel: "Fable 5", onSubmit: (value) => generate(value) }} statusBarProps={{ branch: "Main", project: "project-sea", mode: "Agent", contextUsage: 57 }} />
        </div>
      </main>

      <div
        role="separator"
        aria-label="Resize panels"
        aria-orientation="vertical"
        aria-valuemin={MIN_PANEL_WIDTH}
        aria-valuemax={MAX_PANEL_WIDTH}
        aria-valuenow={panelWidth}
        tabIndex={0}
        onKeyDown={resizeWithKeyboard}
        onPointerDown={beginResize}
        onPointerMove={resize}
        onPointerUp={(event) => {
          dragStart.current = null;
          if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
        }}
        className="group/drag relative hidden w-px shrink-0 cursor-col-resize touch-none items-center justify-center outline-none before:absolute before:inset-y-0 before:-left-2 before:w-5 before:content-[''] focus-visible:ring-2 focus-visible:ring-border-focus-ring xl:flex"
      >
        <span aria-hidden className="h-6 w-px bg-border-button-default transition-colors group-hover/drag:bg-foreground-icon-secondary" />
      </div>
      <ImageGallery items={galleryItems} palettes={palettes} styleLabels={styleLabels} panelWidth={panelWidth} artworkSrcById={galleryArtworkSrcById} renderArtwork={renderGalleryArtwork} onNewGeneration={() => generate()} onToast={showToast} />

      <AnimatePresence>
        {toast ? (
          <motion.div role="status" initial={{ opacity: 0, y: 12, scale: .96 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 8 }} className="absolute bottom-5 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-xl border border-border-button-default bg-background-primary-default px-3 py-2 text-body-2-medium text-text-primary shadow-dropdown"><RiSparkling2Line className="size-4 text-accent-600" />{toast}<button type="button" aria-label="Dismiss notification" onClick={() => setToast(null)}><RiCloseLine className="size-4 text-foreground-icon-secondary" /></button></motion.div>
        ) : null}
      </AnimatePresence>

      <div className="absolute right-3 bottom-3 z-20 flex gap-2 xl:hidden">
        <Button ref={galleryTriggerRef} variant="secondary" iconOnly leadingIcon={RiGalleryLine} aria-label="Open image gallery" onClick={() => setMobileGallery(true)} className="rounded-full shadow-lg" />
        <button type="button" className="rounded-full bg-accent-600 p-3 text-white shadow-lg" aria-label="New generation" onClick={() => generate()}><RiAddLine className="size-5" /></button>
      </div>

      <ModalOverlay isOpen={mobileNavigation} isDismissable onOpenChange={(open) => { if (!open) closeNavigation(); }} className="fixed inset-0 z-50 bg-black/40 transition-opacity duration-200 data-[entering]:opacity-0 data-[exiting]:opacity-0 motion-reduce:transition-none lg:hidden">
        <Modal className="h-full w-[min(272px,calc(100%-24px))] p-3 pr-0 outline-none transition-transform duration-300 ease-out data-[entering]:-translate-x-full data-[exiting]:-translate-x-full motion-reduce:transition-none">
          <Dialog aria-label="Navigation" className="relative h-full outline-none">
            <AgentSidebar userName={userName} userInitials={userName.trim().charAt(0).toUpperCase() || "M"} className="w-[260px] max-w-full" />
            <Button variant="secondary" iconOnly leadingIcon={RiCloseLine} aria-label="Close navigation" onClick={closeNavigation} className="absolute top-2 right-2 rounded-full" />
          </Dialog>
        </Modal>
      </ModalOverlay>

      <ModalOverlay isOpen={mobileGallery} isDismissable onOpenChange={(open) => { if (!open) closeGallery(); }} className="fixed inset-0 z-50 flex items-end bg-black/40 transition-opacity duration-200 data-[entering]:opacity-0 data-[exiting]:opacity-0 motion-reduce:transition-none xl:hidden">
        <Modal className="h-[min(86dvh,720px)] w-full rounded-t-3xl bg-background-full p-3 pb-[calc(12px+env(safe-area-inset-bottom))] shadow-sidebar outline-none transition-transform duration-300 ease-out data-[entering]:translate-y-full data-[exiting]:translate-y-full motion-reduce:transition-none">
          <Dialog aria-label="Image gallery" className="flex h-full min-h-0 flex-col outline-none">
            <div className="flex h-10 shrink-0 items-center justify-between px-1"><span className="text-headline-medium text-text-primary">Image generations</span><Button variant="secondary" iconOnly leadingIcon={RiCloseLine} aria-label="Close image gallery" onClick={closeGallery} className="rounded-full" /></div>
            <ImageGallery items={galleryItems} palettes={palettes} styleLabels={styleLabels} mobile artworkSrcById={galleryArtworkSrcById} renderArtwork={renderGalleryArtwork} onNewGeneration={() => generate()} onToast={showToast} className="min-h-0 flex-1" />
          </Dialog>
        </Modal>
      </ModalOverlay>
    </div>
  );
}

export default AiImageGeneration;
View this template on GitHub