Installation
npx shadcn@latest add https://boardcn.dev/r/ai-image-generation.jsonnpx shadcn@latest add https://boardcn.dev/r/ai-image-generation.jsonnpm 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.
"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;import { useId, type CSSProperties } from "react";
export type ArtworkPalette = readonly [string, string, string, string];
export interface GeneratedArtworkProps {
title: string;
palettes: readonly ArtworkPalette[];
variant?: number;
className?: string;
style?: CSSProperties;
}
/**
* Redistributable, authored artwork used in place of BoardCN's photographic
* demo assets. The layered vector treatment preserves the template's dense,
* editorial gallery rhythm without copying the reference files.
*/
export function GeneratedArtwork({ title, palettes, variant = 0, className, style }: GeneratedArtworkProps) {
const id = useId().replace(/:/g, "");
const colors = palettes[Math.abs(variant) % palettes.length] ?? palettes[0];
const rotation = (variant * 19) % 90;
return (
<svg
role="img"
aria-label={title}
viewBox="0 0 320 400"
preserveAspectRatio="xMidYMid slice"
className={className}
style={style}
>
<defs>
<linearGradient id={`${id}-bg`} x1="0" y1="0" x2="1" y2="1">
<stop stopColor={colors[0]} />
<stop offset="1" stopColor={colors[1]} />
</linearGradient>
<pattern id={`${id}-dots`} width="12" height="12" patternUnits="userSpaceOnUse">
<circle cx="3" cy="3" r="1.7" fill={colors[3]} opacity=".28" />
</pattern>
<filter id={`${id}-grain`} x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence baseFrequency=".76" numOctaves="2" seed={variant + 3} type="fractalNoise" />
<feColorMatrix values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 .11 0" />
</filter>
</defs>
<rect width="320" height="400" fill={`url(#${id}-bg)`} />
<g transform={`rotate(${rotation} 160 200)`}>
<ellipse cx="88" cy="98" rx="118" ry="72" fill={colors[2]} opacity=".82" />
<rect x="148" y="84" width="176" height="168" rx="52" fill={colors[3]} opacity=".86" />
<path d="M-28 335C54 230 118 247 171 179c46-59 88-57 184-16v262H-28Z" fill={colors[2]} />
<path d="M-18 360c78-58 135-42 197-93 48-39 91-36 156-6" fill="none" stroke={colors[0]} strokeWidth="22" strokeLinecap="round" />
</g>
<rect width="320" height="400" fill={`url(#${id}-dots)`} />
<g fill="none" stroke={colors[3]} strokeWidth="3" opacity=".72">
<circle cx="160" cy="195" r={42 + (variant % 4) * 11} />
<circle cx="160" cy="195" r={58 + (variant % 3) * 14} />
</g>
<path d="M102 251c9-45 31-71 59-71s50 26 58 71l20 95H82Z" fill={colors[3]} />
<circle cx="160" cy="154" r="38" fill={colors[2]} />
<path d="M124 151c9-45 68-51 78-1-20-12-48-15-78 1Z" fill={colors[3]} />
<path d="M145 169c10 8 22 8 32 0" fill="none" stroke={colors[3]} strokeWidth="4" strokeLinecap="round" />
<rect width="320" height="400" filter={`url(#${id}-grain)`} opacity=".5" />
</svg>
);
}
export function FootballArtwork({ className }: { className?: string }) {
const id = useId().replace(/:/g, "");
return (
<svg role="img" aria-label="Vintage-style illustration of a football player in Argentina's striped kit" viewBox="0 0 400 500" className={className} preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id={`${id}-sky`} cx="50%" cy="38%" r="70%"><stop stopColor="#5a9af2" /><stop offset="1" stopColor="#0c4eae" /></radialGradient>
<pattern id={`${id}-stripe`} width="52" height="20" patternUnits="userSpaceOnUse"><rect width="26" height="20" fill="#f6f2df" /><rect x="26" width="26" height="20" fill="#70b6ec" /></pattern>
<filter id={`${id}-paper`}><feTurbulence baseFrequency=".5" numOctaves="3" seed="11" /><feColorMatrix values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 .13 0" /></filter>
</defs>
<rect width="400" height="500" fill={`url(#${id}-sky)`} />
<circle cx="328" cy="88" r="96" fill="#8dc3ff" opacity=".25" />
<path d="M0 420c108-70 259-73 400-11v91H0Z" fill="#14367b" />
<path d="M176 137c-27 35-25 73 4 91 32 20 70-3 75-43 5-38-7-72-39-76-17-2-30 8-40 28Z" fill="#d9a170" stroke="#212846" strokeWidth="8" />
<path d="M168 145c7-49 64-70 91-19 9 17 9 32 4 47-20-30-48-44-95-28Z" fill="#302519" />
<path d="M137 226c39-28 95-20 129 12l29 150-169 11-22-126c-5-22 6-37 33-47Z" fill={`url(#${id}-stripe)`} stroke="#202c50" strokeWidth="9" />
<path d="M194 231v167M249 240l22 148M145 240l8 151" stroke="#26385f" strokeWidth="5" opacity=".45" />
<path d="M125 260 53 337l43 29 73-70M267 257l79 54-23 45-90-59" fill="#d9a170" stroke="#212846" strokeWidth="9" strokeLinecap="round" strokeLinejoin="round" />
<path d="m156 395-23 92h58l21-91m40-4 36 95h60l-49-111" fill="#e8e1c8" stroke="#212846" strokeWidth="10" strokeLinejoin="round" />
<circle cx="74" cy="376" r="40" fill="#ece8db" stroke="#1e3159" strokeWidth="8" />
<path d="m74 338 23 17-8 27-30 1-9-28Z" fill="#1e3159" />
<rect width="400" height="500" filter={`url(#${id}-paper)`} />
</svg>
);
}import { useId, type CSSProperties } from "react";
export type ArtworkPalette = readonly [string, string, string, string];
export interface GeneratedArtworkProps {
title: string;
palettes: readonly ArtworkPalette[];
variant?: number;
className?: string;
style?: CSSProperties;
}
/**
* Redistributable, authored artwork used in place of BoardCN's photographic
* demo assets. The layered vector treatment preserves the template's dense,
* editorial gallery rhythm without copying the reference files.
*/
export function GeneratedArtwork({ title, palettes, variant = 0, className, style }: GeneratedArtworkProps) {
const id = useId().replace(/:/g, "");
const colors = palettes[Math.abs(variant) % palettes.length] ?? palettes[0];
const rotation = (variant * 19) % 90;
return (
<svg
role="img"
aria-label={title}
viewBox="0 0 320 400"
preserveAspectRatio="xMidYMid slice"
className={className}
style={style}
>
<defs>
<linearGradient id={`${id}-bg`} x1="0" y1="0" x2="1" y2="1">
<stop stopColor={colors[0]} />
<stop offset="1" stopColor={colors[1]} />
</linearGradient>
<pattern id={`${id}-dots`} width="12" height="12" patternUnits="userSpaceOnUse">
<circle cx="3" cy="3" r="1.7" fill={colors[3]} opacity=".28" />
</pattern>
<filter id={`${id}-grain`} x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence baseFrequency=".76" numOctaves="2" seed={variant + 3} type="fractalNoise" />
<feColorMatrix values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 .11 0" />
</filter>
</defs>
<rect width="320" height="400" fill={`url(#${id}-bg)`} />
<g transform={`rotate(${rotation} 160 200)`}>
<ellipse cx="88" cy="98" rx="118" ry="72" fill={colors[2]} opacity=".82" />
<rect x="148" y="84" width="176" height="168" rx="52" fill={colors[3]} opacity=".86" />
<path d="M-28 335C54 230 118 247 171 179c46-59 88-57 184-16v262H-28Z" fill={colors[2]} />
<path d="M-18 360c78-58 135-42 197-93 48-39 91-36 156-6" fill="none" stroke={colors[0]} strokeWidth="22" strokeLinecap="round" />
</g>
<rect width="320" height="400" fill={`url(#${id}-dots)`} />
<g fill="none" stroke={colors[3]} strokeWidth="3" opacity=".72">
<circle cx="160" cy="195" r={42 + (variant % 4) * 11} />
<circle cx="160" cy="195" r={58 + (variant % 3) * 14} />
</g>
<path d="M102 251c9-45 31-71 59-71s50 26 58 71l20 95H82Z" fill={colors[3]} />
<circle cx="160" cy="154" r="38" fill={colors[2]} />
<path d="M124 151c9-45 68-51 78-1-20-12-48-15-78 1Z" fill={colors[3]} />
<path d="M145 169c10 8 22 8 32 0" fill="none" stroke={colors[3]} strokeWidth="4" strokeLinecap="round" />
<rect width="320" height="400" filter={`url(#${id}-grain)`} opacity=".5" />
</svg>
);
}
export function FootballArtwork({ className }: { className?: string }) {
const id = useId().replace(/:/g, "");
return (
<svg role="img" aria-label="Vintage-style illustration of a football player in Argentina's striped kit" viewBox="0 0 400 500" className={className} preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id={`${id}-sky`} cx="50%" cy="38%" r="70%"><stop stopColor="#5a9af2" /><stop offset="1" stopColor="#0c4eae" /></radialGradient>
<pattern id={`${id}-stripe`} width="52" height="20" patternUnits="userSpaceOnUse"><rect width="26" height="20" fill="#f6f2df" /><rect x="26" width="26" height="20" fill="#70b6ec" /></pattern>
<filter id={`${id}-paper`}><feTurbulence baseFrequency=".5" numOctaves="3" seed="11" /><feColorMatrix values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 .13 0" /></filter>
</defs>
<rect width="400" height="500" fill={`url(#${id}-sky)`} />
<circle cx="328" cy="88" r="96" fill="#8dc3ff" opacity=".25" />
<path d="M0 420c108-70 259-73 400-11v91H0Z" fill="#14367b" />
<path d="M176 137c-27 35-25 73 4 91 32 20 70-3 75-43 5-38-7-72-39-76-17-2-30 8-40 28Z" fill="#d9a170" stroke="#212846" strokeWidth="8" />
<path d="M168 145c7-49 64-70 91-19 9 17 9 32 4 47-20-30-48-44-95-28Z" fill="#302519" />
<path d="M137 226c39-28 95-20 129 12l29 150-169 11-22-126c-5-22 6-37 33-47Z" fill={`url(#${id}-stripe)`} stroke="#202c50" strokeWidth="9" />
<path d="M194 231v167M249 240l22 148M145 240l8 151" stroke="#26385f" strokeWidth="5" opacity=".45" />
<path d="M125 260 53 337l43 29 73-70M267 257l79 54-23 45-90-59" fill="#d9a170" stroke="#212846" strokeWidth="9" strokeLinecap="round" strokeLinejoin="round" />
<path d="m156 395-23 92h58l21-91m40-4 36 95h60l-49-111" fill="#e8e1c8" stroke="#212846" strokeWidth="10" strokeLinejoin="round" />
<circle cx="74" cy="376" r="40" fill="#ece8db" stroke="#1e3159" strokeWidth="8" />
<path d="m74 338 23 17-8 27-30 1-9-28Z" fill="#1e3159" />
<rect width="400" height="500" filter={`url(#${id}-paper)`} />
</svg>
);
}"use client";
import { useEffect, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { RiCheckLine, RiFileCopyLine, RiThumbDownLine, RiThumbUpLine } from "@remixicon/react";
import { IconButton } from "@/components/base/buttons/icon-button";
export interface GenerationFrameProps {
generationKey: number;
duration?: number;
onComplete?: () => void;
onToast?: (message: string) => void;
/** Licensed artwork URL supplied by the registry consumer. */
artworkSrc?: string;
/** Render slot for licensed artwork or a framework-specific image component. */
renderArtwork?: () => ReactNode;
generatedImageLabel?: string;
}
const PULSE_DOTS = [
{ count: 14, radius: 27, size: 3 },
{ count: 22, radius: 41, size: 3.5 },
{ count: 28, radius: 54, size: 2.5 },
].flatMap(({ count, radius, size }, ring) => Array.from({ length: count }, (_, index) => {
const angle = (index / count) * Math.PI * 2 - Math.PI / 2;
return {
delay: -(index / count) * 1.8 - ring * 0.14,
size,
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
};
}));
function PulsatingLoader({ remaining }: { remaining: number }) {
return (
<div className="absolute inset-0 flex flex-col justify-end p-4">
<div aria-hidden className="absolute top-[46%] left-1/2 size-32 -translate-x-1/2 -translate-y-1/2">
{PULSE_DOTS.map((dot, index) => (
<span
key={index}
className="image-generation-pulse-dot absolute rounded-full bg-text-primary"
style={{
width: `${dot.size}px`,
height: `${dot.size}px`,
left: `${(64 + dot.x - dot.size / 2).toFixed(4)}px`,
top: `${(64 + dot.y - dot.size / 2).toFixed(4)}px`,
animationDelay: `${dot.delay.toFixed(5)}s`,
}}
/>
))}
</div>
<div className="flex items-center justify-between gap-3 text-body-medium text-text-secondary">
<span>Generating image</span>
<span aria-label={`${remaining} second${remaining === 1 ? "" : "s"} remaining`} className="tabular-nums">{remaining} s</span>
</div>
</div>
);
}
export function GenerationFrame({
generationKey,
duration = 10,
onComplete,
onToast,
artworkSrc,
renderArtwork,
generatedImageLabel = "Vintage editorial illustration of Lionel Messi dribbling in Argentina's home kit against a blue background",
}: GenerationFrameProps) {
const reducedMotion = useReducedMotion();
const [remaining, setRemaining] = useState(duration);
const [complete, setComplete] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
setRemaining(duration);
setComplete(false);
setCopied(false);
if (reducedMotion || duration <= 0) {
setComplete(true);
onComplete?.();
return;
}
const startedAt = Date.now();
const timer = window.setInterval(() => {
const next = Math.max(0, Math.ceil(duration - (Date.now() - startedAt) / 1000));
setRemaining(next);
if (next === 0) {
window.clearInterval(timer);
setComplete(true);
onComplete?.();
}
}, 100);
return () => window.clearInterval(timer);
}, [duration, generationKey, onComplete, reducedMotion]);
const copyPrompt = async () => {
const text = "Create a vintage editorial illustration of Lionel Messi dribbling in Argentina's home kit against a blue background.";
try { await navigator.clipboard.writeText(text); } catch { /* Clipboard may be unavailable in embedded previews. */ }
setCopied(true);
onToast?.("Prompt copied");
window.setTimeout(() => setCopied(false), 1800);
};
return (
<motion.div
key={generationKey}
initial={reducedMotion ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
transition={{ height: { duration: 0.52, ease: [0.22, 1, 0.36, 1] }, opacity: { duration: 0.22 } }}
className="w-[200px] overflow-hidden"
>
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl bg-background-tertiary-default shadow-sm" aria-label={complete ? "Generated image" : "Generating image"}>
{!complete ? <PulsatingLoader remaining={remaining} /> : null}
<AnimatePresence>
{complete ? (
<motion.div
initial={reducedMotion ? false : { clipPath: "circle(0% at 50% 50%)", filter: "blur(8px)" }}
animate={{ clipPath: "circle(76% at 50% 50%)", filter: "blur(0px)" }}
transition={{ duration: reducedMotion ? 0 : 0.9, ease: [0.22, 1, 0.36, 1] }}
className="absolute inset-0"
>
<div role="img" aria-label={generatedImageLabel} className="size-full">
{renderArtwork ? renderArtwork() : <img src={artworkSrc ?? "/ai-image-generation/messi.jpg"} alt="" className="size-full object-cover outline -outline-offset-1 outline-black/10 dark:outline-white/10" />}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
{complete ? (
<motion.div initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} className="mt-2 flex items-center gap-1">
<IconButton size="small" icon={RiThumbUpLine} aria-label="Like image" className="size-7 border-0 bg-transparent shadow-none" onClick={() => onToast?.("Thanks for the feedback")} />
<IconButton size="small" icon={RiThumbDownLine} aria-label="Dislike image" className="size-7 border-0 bg-transparent shadow-none" onClick={() => onToast?.("Feedback recorded")} />
<IconButton size="small" icon={copied ? RiCheckLine : RiFileCopyLine} aria-label={copied ? "Prompt copied" : "Copy prompt"} className="size-7 border-0 bg-transparent shadow-none" onClick={copyPrompt} />
</motion.div>
) : null}
</motion.div>
);
}"use client";
import { useEffect, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { RiCheckLine, RiFileCopyLine, RiThumbDownLine, RiThumbUpLine } from "@remixicon/react";
import { IconButton } from "@/components/base/buttons/icon-button";
export interface GenerationFrameProps {
generationKey: number;
duration?: number;
onComplete?: () => void;
onToast?: (message: string) => void;
/** Licensed artwork URL supplied by the registry consumer. */
artworkSrc?: string;
/** Render slot for licensed artwork or a framework-specific image component. */
renderArtwork?: () => ReactNode;
generatedImageLabel?: string;
}
const PULSE_DOTS = [
{ count: 14, radius: 27, size: 3 },
{ count: 22, radius: 41, size: 3.5 },
{ count: 28, radius: 54, size: 2.5 },
].flatMap(({ count, radius, size }, ring) => Array.from({ length: count }, (_, index) => {
const angle = (index / count) * Math.PI * 2 - Math.PI / 2;
return {
delay: -(index / count) * 1.8 - ring * 0.14,
size,
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
};
}));
function PulsatingLoader({ remaining }: { remaining: number }) {
return (
<div className="absolute inset-0 flex flex-col justify-end p-4">
<div aria-hidden className="absolute top-[46%] left-1/2 size-32 -translate-x-1/2 -translate-y-1/2">
{PULSE_DOTS.map((dot, index) => (
<span
key={index}
className="image-generation-pulse-dot absolute rounded-full bg-text-primary"
style={{
width: `${dot.size}px`,
height: `${dot.size}px`,
left: `${(64 + dot.x - dot.size / 2).toFixed(4)}px`,
top: `${(64 + dot.y - dot.size / 2).toFixed(4)}px`,
animationDelay: `${dot.delay.toFixed(5)}s`,
}}
/>
))}
</div>
<div className="flex items-center justify-between gap-3 text-body-medium text-text-secondary">
<span>Generating image</span>
<span aria-label={`${remaining} second${remaining === 1 ? "" : "s"} remaining`} className="tabular-nums">{remaining} s</span>
</div>
</div>
);
}
export function GenerationFrame({
generationKey,
duration = 10,
onComplete,
onToast,
artworkSrc,
renderArtwork,
generatedImageLabel = "Vintage editorial illustration of Lionel Messi dribbling in Argentina's home kit against a blue background",
}: GenerationFrameProps) {
const reducedMotion = useReducedMotion();
const [remaining, setRemaining] = useState(duration);
const [complete, setComplete] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
setRemaining(duration);
setComplete(false);
setCopied(false);
if (reducedMotion || duration <= 0) {
setComplete(true);
onComplete?.();
return;
}
const startedAt = Date.now();
const timer = window.setInterval(() => {
const next = Math.max(0, Math.ceil(duration - (Date.now() - startedAt) / 1000));
setRemaining(next);
if (next === 0) {
window.clearInterval(timer);
setComplete(true);
onComplete?.();
}
}, 100);
return () => window.clearInterval(timer);
}, [duration, generationKey, onComplete, reducedMotion]);
const copyPrompt = async () => {
const text = "Create a vintage editorial illustration of Lionel Messi dribbling in Argentina's home kit against a blue background.";
try { await navigator.clipboard.writeText(text); } catch { /* Clipboard may be unavailable in embedded previews. */ }
setCopied(true);
onToast?.("Prompt copied");
window.setTimeout(() => setCopied(false), 1800);
};
return (
<motion.div
key={generationKey}
initial={reducedMotion ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
transition={{ height: { duration: 0.52, ease: [0.22, 1, 0.36, 1] }, opacity: { duration: 0.22 } }}
className="w-[200px] overflow-hidden"
>
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl bg-background-tertiary-default shadow-sm" aria-label={complete ? "Generated image" : "Generating image"}>
{!complete ? <PulsatingLoader remaining={remaining} /> : null}
<AnimatePresence>
{complete ? (
<motion.div
initial={reducedMotion ? false : { clipPath: "circle(0% at 50% 50%)", filter: "blur(8px)" }}
animate={{ clipPath: "circle(76% at 50% 50%)", filter: "blur(0px)" }}
transition={{ duration: reducedMotion ? 0 : 0.9, ease: [0.22, 1, 0.36, 1] }}
className="absolute inset-0"
>
<div role="img" aria-label={generatedImageLabel} className="size-full">
{renderArtwork ? renderArtwork() : <img src={artworkSrc ?? "/ai-image-generation/messi.jpg"} alt="" className="size-full object-cover outline -outline-offset-1 outline-black/10 dark:outline-white/10" />}
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
{complete ? (
<motion.div initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} className="mt-2 flex items-center gap-1">
<IconButton size="small" icon={RiThumbUpLine} aria-label="Like image" className="size-7 border-0 bg-transparent shadow-none" onClick={() => onToast?.("Thanks for the feedback")} />
<IconButton size="small" icon={RiThumbDownLine} aria-label="Dislike image" className="size-7 border-0 bg-transparent shadow-none" onClick={() => onToast?.("Feedback recorded")} />
<IconButton size="small" icon={copied ? RiCheckLine : RiFileCopyLine} aria-label={copied ? "Prompt copied" : "Copy prompt"} className="size-7 border-0 bg-transparent shadow-none" onClick={copyPrompt} />
</motion.div>
) : null}
</motion.div>
);
}"use client";
import { useState, type ReactNode } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Dialog, Modal, ModalOverlay } from "react-aria-components";
import {
RiAddLine,
RiCloseLine,
RiDownloadLine,
RiExpandDiagonalLine,
RiGalleryLine,
RiMore2Line,
RiPaletteLine,
RiRefreshLine,
RiSideBarFill,
} from "@remixicon/react";
import { IconButton } from "@/components/base/buttons/icon-button";
import { Dropdown, DropdownItem, DropdownPopover, DropdownTrigger } from "@/components/base/dropdown/dropdown";
import { cx } from "@/utils/cx";
import { type ArtworkPalette } from "./artwork";
export interface GalleryItem {
id: string;
title: string;
aspect: "square" | "portrait" | "tall" | "landscape";
variant: number;
generated?: boolean;
}
export interface ImageGalleryProps {
items: readonly GalleryItem[];
palettes: readonly ArtworkPalette[];
styleLabels?: readonly string[];
onNewGeneration?: () => void;
onToast?: (message: string) => void;
className?: string;
mobile?: boolean;
panelWidth?: number;
artworkSrcById?: Partial<Record<string, string>>;
renderArtwork?: (item: GalleryItem) => ReactNode;
}
const aspectClasses: Record<GalleryItem["aspect"], string> = {
square: "aspect-square",
portrait: "aspect-[3/4]",
tall: "aspect-[14/21]",
landscape: "aspect-[4/3]",
};
function GalleryArtwork({ item, className, artworkSrc, renderArtwork }: { item: GalleryItem; className?: string; artworkSrc?: string; renderArtwork?: (item: GalleryItem) => ReactNode }) {
if (renderArtwork) return <div role="img" aria-label={item.title} className={className}>{renderArtwork(item)}</div>;
return <img src={artworkSrc ?? `/ai-image-generation/${item.id}.jpg`} alt={item.title} className={cx("object-cover outline -outline-offset-1 outline-black/10 dark:outline-white/10", className)} />;
}
function GalleryTile({ item, onEnlarge, onToast, artworkSrc, renderArtwork }: { item: GalleryItem; onEnlarge: () => void; onToast?: (message: string) => void; artworkSrc?: string; renderArtwork?: (item: GalleryItem) => ReactNode }) {
return (
<figure className={cx("group relative mb-2 break-inside-avoid overflow-hidden rounded-xl bg-background-tertiary-default", aspectClasses[item.aspect])}>
<GalleryArtwork item={item} artworkSrc={artworkSrc} renderArtwork={renderArtwork} className="size-full transition-transform duration-500 ease-out group-hover:scale-[1.035]" />
<button type="button" aria-label={`Enlarge ${item.title}`} onClick={onEnlarge} className="absolute inset-0 cursor-zoom-in rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-border-focus-ring" />
<div className="absolute right-1.5 bottom-1.5 flex translate-y-1 gap-1 opacity-0 transition-[opacity,transform] duration-200 group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100">
<button type="button" aria-label={`Download ${item.title}`} onClick={() => onToast?.("Download prepared")} className="rounded-md bg-neutral-950/45 p-1 text-white backdrop-blur-sm transition-colors hover:bg-neutral-950/65"><RiDownloadLine className="size-4" aria-hidden /></button>
<Dropdown>
<DropdownTrigger aria-label={`More actions for ${item.title}`} className="rounded-md bg-neutral-950/45 p-1 text-white backdrop-blur-sm transition-colors hover:bg-neutral-950/65"><RiMore2Line className="size-4" aria-hidden /></DropdownTrigger>
<DropdownPopover aria-label={`Actions for ${item.title}`} placement="top end" offset={4} className="w-32 p-1">
<DropdownItem className="gap-1.5 px-2 py-1.5" onSelect={() => onToast?.("Generation duplicated")}><RiRefreshLine className="size-4" aria-hidden />Remix</DropdownItem>
<DropdownItem className="gap-1.5 px-2 py-1.5" onSelect={() => onToast?.("Prompt copied")}>Copy prompt</DropdownItem>
</DropdownPopover>
</Dropdown>
</div>
<figcaption className="sr-only">{item.title}</figcaption>
</figure>
);
}
export function ImageGallery({ items, palettes, styleLabels = [], onNewGeneration, onToast, className, mobile = false, panelWidth = 410, artworkSrcById, renderArtwork }: ImageGalleryProps) {
const [view, setView] = useState<"gallery" | "styles">("gallery");
const [expanded, setExpanded] = useState(false);
const [hidden, setHidden] = useState(false);
const [selected, setSelected] = useState<GalleryItem | null>(null);
void palettes;
if (hidden) {
return mobile ? null : <IconButton icon={RiSideBarFill} aria-label="Open image panel" onClick={() => setHidden(false)} className="absolute top-3 right-3 z-20" />;
}
return (
<motion.aside layout style={expanded || mobile ? undefined : { width: panelWidth }} className={cx("relative h-full min-h-0 shrink-0 flex-col gap-2.5 overflow-hidden pt-2", mobile ? "flex w-full" : "hidden xl:flex", expanded && !mobile && "w-[calc(100%-292px)]", className)} aria-label="Image generations">
<header className="flex h-10 shrink-0 items-center justify-between border-b border-border-button-default pb-2">
<div role="group" aria-label="Panel view" className="relative flex rounded-full bg-background-tertiary-default p-0.5">
<motion.span layoutId="gallery-tab" className={cx("absolute inset-y-0.5 rounded-full bg-background-primary-default shadow-xs", view === "gallery" ? "left-0.5 w-[82px]" : "left-[84px] w-[72px]")} />
<button type="button" aria-pressed={view === "gallery"} onClick={() => setView("gallery")} className="relative z-10 flex items-center gap-1 rounded-full px-2 py-[5px] text-body-2-medium text-text-primary"><RiGalleryLine className="size-[18px]" aria-hidden />Gallery</button>
<button type="button" aria-pressed={view === "styles"} onClick={() => setView("styles")} className="relative z-10 flex items-center gap-1 rounded-full px-2 py-[5px] text-body-2-medium text-text-primary"><RiPaletteLine className="size-[18px]" aria-hidden />Styles</button>
</div>
<div className="flex items-center gap-1 text-foreground-icon-secondary">
<button type="button" aria-label="New generation" onClick={onNewGeneration} className="rounded-md p-1 hover:bg-background-primary-hover"><RiAddLine className="size-4" /></button>
{!mobile ? <button type="button" aria-label={expanded ? "Restore panel" : "Expand panel"} onClick={() => setExpanded((value) => !value)} className="rounded-md p-1 hover:bg-background-primary-hover"><RiExpandDiagonalLine className="size-4" /></button> : null}
{!mobile ? <button type="button" aria-label="Toggle panel" onClick={() => setHidden(true)} className="rounded-md p-1 hover:bg-background-primary-hover"><RiSideBarFill className="size-4" /></button> : null}
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-width:none]">
{view === "gallery" ? (
<div className={cx("columns-3 gap-2", expanded && "columns-4")}>
{items.map((item) => <GalleryTile key={item.id} item={item} onEnlarge={() => setSelected(item)} onToast={onToast} artworkSrc={artworkSrcById?.[item.id]} renderArtwork={renderArtwork} />)}
</div>
) : (
<div className="grid grid-cols-2 gap-2 p-1">
{styleLabels.map((style, index) => (
<button key={style} type="button" onClick={() => onToast?.(`${style} selected`)} className="group overflow-hidden rounded-xl border border-border-button-default bg-background-primary-default text-left shadow-xs">
<img src={`/ai-image-generation/${items[(index + 1) % items.length]?.id ?? "messi"}.jpg`} alt="" className="aspect-[4/3] w-full object-cover outline -outline-offset-1 outline-black/10 dark:outline-white/10" />
<span className="block px-2 py-2 text-body-2-medium text-text-primary">{style}</span>
</button>
))}
</div>
)}
</div>
<AnimatePresence>
{selected ? (
<ModalOverlay isOpen isDismissable onOpenChange={(open) => { if (!open) setSelected(null); }} className="fixed inset-0 z-50 grid place-items-center bg-neutral-950/70 p-6 backdrop-blur-sm data-[entering]:opacity-0 data-[exiting]:opacity-0">
<Modal className="max-h-[88vh] max-w-3xl overflow-hidden rounded-2xl bg-background-primary-default shadow-xl data-[entering]:scale-95 data-[entering]:opacity-0 data-[exiting]:scale-95 data-[exiting]:opacity-0">
<Dialog aria-label={selected.title} className="relative outline-none">
<GalleryArtwork item={selected} artworkSrc={artworkSrcById?.[selected.id]} renderArtwork={renderArtwork} className="max-h-[80vh] w-auto max-w-[85vw]" />
<IconButton icon={RiCloseLine} aria-label="Close preview" onClick={() => setSelected(null)} className="absolute top-3 right-3" />
</Dialog>
</Modal>
</ModalOverlay>
) : null}
</AnimatePresence>
</motion.aside>
);
}"use client";
import { useState, type ReactNode } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Dialog, Modal, ModalOverlay } from "react-aria-components";
import {
RiAddLine,
RiCloseLine,
RiDownloadLine,
RiExpandDiagonalLine,
RiGalleryLine,
RiMore2Line,
RiPaletteLine,
RiRefreshLine,
RiSideBarFill,
} from "@remixicon/react";
import { IconButton } from "@/components/base/buttons/icon-button";
import { Dropdown, DropdownItem, DropdownPopover, DropdownTrigger } from "@/components/base/dropdown/dropdown";
import { cx } from "@/utils/cx";
import { type ArtworkPalette } from "./artwork";
export interface GalleryItem {
id: string;
title: string;
aspect: "square" | "portrait" | "tall" | "landscape";
variant: number;
generated?: boolean;
}
export interface ImageGalleryProps {
items: readonly GalleryItem[];
palettes: readonly ArtworkPalette[];
styleLabels?: readonly string[];
onNewGeneration?: () => void;
onToast?: (message: string) => void;
className?: string;
mobile?: boolean;
panelWidth?: number;
artworkSrcById?: Partial<Record<string, string>>;
renderArtwork?: (item: GalleryItem) => ReactNode;
}
const aspectClasses: Record<GalleryItem["aspect"], string> = {
square: "aspect-square",
portrait: "aspect-[3/4]",
tall: "aspect-[14/21]",
landscape: "aspect-[4/3]",
};
function GalleryArtwork({ item, className, artworkSrc, renderArtwork }: { item: GalleryItem; className?: string; artworkSrc?: string; renderArtwork?: (item: GalleryItem) => ReactNode }) {
if (renderArtwork) return <div role="img" aria-label={item.title} className={className}>{renderArtwork(item)}</div>;
return <img src={artworkSrc ?? `/ai-image-generation/${item.id}.jpg`} alt={item.title} className={cx("object-cover outline -outline-offset-1 outline-black/10 dark:outline-white/10", className)} />;
}
function GalleryTile({ item, onEnlarge, onToast, artworkSrc, renderArtwork }: { item: GalleryItem; onEnlarge: () => void; onToast?: (message: string) => void; artworkSrc?: string; renderArtwork?: (item: GalleryItem) => ReactNode }) {
return (
<figure className={cx("group relative mb-2 break-inside-avoid overflow-hidden rounded-xl bg-background-tertiary-default", aspectClasses[item.aspect])}>
<GalleryArtwork item={item} artworkSrc={artworkSrc} renderArtwork={renderArtwork} className="size-full transition-transform duration-500 ease-out group-hover:scale-[1.035]" />
<button type="button" aria-label={`Enlarge ${item.title}`} onClick={onEnlarge} className="absolute inset-0 cursor-zoom-in rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-border-focus-ring" />
<div className="absolute right-1.5 bottom-1.5 flex translate-y-1 gap-1 opacity-0 transition-[opacity,transform] duration-200 group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100">
<button type="button" aria-label={`Download ${item.title}`} onClick={() => onToast?.("Download prepared")} className="rounded-md bg-neutral-950/45 p-1 text-white backdrop-blur-sm transition-colors hover:bg-neutral-950/65"><RiDownloadLine className="size-4" aria-hidden /></button>
<Dropdown>
<DropdownTrigger aria-label={`More actions for ${item.title}`} className="rounded-md bg-neutral-950/45 p-1 text-white backdrop-blur-sm transition-colors hover:bg-neutral-950/65"><RiMore2Line className="size-4" aria-hidden /></DropdownTrigger>
<DropdownPopover aria-label={`Actions for ${item.title}`} placement="top end" offset={4} className="w-32 p-1">
<DropdownItem className="gap-1.5 px-2 py-1.5" onSelect={() => onToast?.("Generation duplicated")}><RiRefreshLine className="size-4" aria-hidden />Remix</DropdownItem>
<DropdownItem className="gap-1.5 px-2 py-1.5" onSelect={() => onToast?.("Prompt copied")}>Copy prompt</DropdownItem>
</DropdownPopover>
</Dropdown>
</div>
<figcaption className="sr-only">{item.title}</figcaption>
</figure>
);
}
export function ImageGallery({ items, palettes, styleLabels = [], onNewGeneration, onToast, className, mobile = false, panelWidth = 410, artworkSrcById, renderArtwork }: ImageGalleryProps) {
const [view, setView] = useState<"gallery" | "styles">("gallery");
const [expanded, setExpanded] = useState(false);
const [hidden, setHidden] = useState(false);
const [selected, setSelected] = useState<GalleryItem | null>(null);
void palettes;
if (hidden) {
return mobile ? null : <IconButton icon={RiSideBarFill} aria-label="Open image panel" onClick={() => setHidden(false)} className="absolute top-3 right-3 z-20" />;
}
return (
<motion.aside layout style={expanded || mobile ? undefined : { width: panelWidth }} className={cx("relative h-full min-h-0 shrink-0 flex-col gap-2.5 overflow-hidden pt-2", mobile ? "flex w-full" : "hidden xl:flex", expanded && !mobile && "w-[calc(100%-292px)]", className)} aria-label="Image generations">
<header className="flex h-10 shrink-0 items-center justify-between border-b border-border-button-default pb-2">
<div role="group" aria-label="Panel view" className="relative flex rounded-full bg-background-tertiary-default p-0.5">
<motion.span layoutId="gallery-tab" className={cx("absolute inset-y-0.5 rounded-full bg-background-primary-default shadow-xs", view === "gallery" ? "left-0.5 w-[82px]" : "left-[84px] w-[72px]")} />
<button type="button" aria-pressed={view === "gallery"} onClick={() => setView("gallery")} className="relative z-10 flex items-center gap-1 rounded-full px-2 py-[5px] text-body-2-medium text-text-primary"><RiGalleryLine className="size-[18px]" aria-hidden />Gallery</button>
<button type="button" aria-pressed={view === "styles"} onClick={() => setView("styles")} className="relative z-10 flex items-center gap-1 rounded-full px-2 py-[5px] text-body-2-medium text-text-primary"><RiPaletteLine className="size-[18px]" aria-hidden />Styles</button>
</div>
<div className="flex items-center gap-1 text-foreground-icon-secondary">
<button type="button" aria-label="New generation" onClick={onNewGeneration} className="rounded-md p-1 hover:bg-background-primary-hover"><RiAddLine className="size-4" /></button>
{!mobile ? <button type="button" aria-label={expanded ? "Restore panel" : "Expand panel"} onClick={() => setExpanded((value) => !value)} className="rounded-md p-1 hover:bg-background-primary-hover"><RiExpandDiagonalLine className="size-4" /></button> : null}
{!mobile ? <button type="button" aria-label="Toggle panel" onClick={() => setHidden(true)} className="rounded-md p-1 hover:bg-background-primary-hover"><RiSideBarFill className="size-4" /></button> : null}
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-width:none]">
{view === "gallery" ? (
<div className={cx("columns-3 gap-2", expanded && "columns-4")}>
{items.map((item) => <GalleryTile key={item.id} item={item} onEnlarge={() => setSelected(item)} onToast={onToast} artworkSrc={artworkSrcById?.[item.id]} renderArtwork={renderArtwork} />)}
</div>
) : (
<div className="grid grid-cols-2 gap-2 p-1">
{styleLabels.map((style, index) => (
<button key={style} type="button" onClick={() => onToast?.(`${style} selected`)} className="group overflow-hidden rounded-xl border border-border-button-default bg-background-primary-default text-left shadow-xs">
<img src={`/ai-image-generation/${items[(index + 1) % items.length]?.id ?? "messi"}.jpg`} alt="" className="aspect-[4/3] w-full object-cover outline -outline-offset-1 outline-black/10 dark:outline-white/10" />
<span className="block px-2 py-2 text-body-2-medium text-text-primary">{style}</span>
</button>
))}
</div>
)}
</div>
<AnimatePresence>
{selected ? (
<ModalOverlay isOpen isDismissable onOpenChange={(open) => { if (!open) setSelected(null); }} className="fixed inset-0 z-50 grid place-items-center bg-neutral-950/70 p-6 backdrop-blur-sm data-[entering]:opacity-0 data-[exiting]:opacity-0">
<Modal className="max-h-[88vh] max-w-3xl overflow-hidden rounded-2xl bg-background-primary-default shadow-xl data-[entering]:scale-95 data-[entering]:opacity-0 data-[exiting]:scale-95 data-[exiting]:opacity-0">
<Dialog aria-label={selected.title} className="relative outline-none">
<GalleryArtwork item={selected} artworkSrc={artworkSrcById?.[selected.id]} renderArtwork={renderArtwork} className="max-h-[80vh] w-auto max-w-[85vw]" />
<IconButton icon={RiCloseLine} aria-label="Close preview" onClick={() => setSelected(null)} className="absolute top-3 right-3" />
</Dialog>
</Modal>
</ModalOverlay>
) : null}
</AnimatePresence>
</motion.aside>
);
}export { AiImageGeneration, default } from "./ai-image-generation";
export type { AiImageGenerationProps } from "./ai-image-generation";
export { GenerationFrame } from "./generation-frame";
export type { GenerationFrameProps } from "./generation-frame";
export { ImageGallery } from "./image-gallery";
export type { GalleryItem, ImageGalleryProps } from "./image-gallery";
export { FootballArtwork, GeneratedArtwork } from "./artwork";
export type { ArtworkPalette, GeneratedArtworkProps } from "./artwork";export { AiImageGeneration, default } from "./ai-image-generation";
export type { AiImageGenerationProps } from "./ai-image-generation";
export { GenerationFrame } from "./generation-frame";
export type { GenerationFrameProps } from "./generation-frame";
export { ImageGallery } from "./image-gallery";
export type { GalleryItem, ImageGalleryProps } from "./image-gallery";
export { FootballArtwork, GeneratedArtwork } from "./artwork";
export type { ArtworkPalette, GeneratedArtworkProps } from "./artwork";