"use client"; import { ExternalLinkIcon, ImageIcon, SparklesIcon } from "lucide-react"; import NextImage from "next/image"; import { Component, type ReactNode, useState } from "react"; import { z } from "zod"; import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; /** * Zod schemas for runtime validation */ const AspectRatioSchema = z.enum(["1:1", "4:3", "16:9", "9:16", "21:9", "auto"]); const ImageFitSchema = z.enum(["cover", "contain"]); const ImageSourceSchema = z.object({ label: z.string(), iconUrl: z.string().nullish(), url: z.string().nullish(), }); const SerializableImageSchema = z.object({ id: z.string(), assetId: z.string(), src: z.string(), alt: z.string().nullish(), title: z.string().nullish(), description: z.string().nullish(), href: z.string().nullish(), domain: z.string().nullish(), ratio: AspectRatioSchema.nullish(), source: ImageSourceSchema.nullish(), }); /** * Types derived from Zod schemas */ type AspectRatio = z.infer; type ImageFit = z.infer; type ImageSource = z.infer; export type SerializableImage = z.infer; /** * Props for the Image component */ export interface ImageProps { id: string; assetId: string; src: string; alt?: string; title?: string; description?: string; href?: string; domain?: string; ratio?: AspectRatio; fit?: ImageFit; source?: ImageSource; maxWidth?: string; className?: string; } /** * Parse and validate serializable image from tool result * Returns a valid SerializableImage with fallback values for missing optional fields */ export function parseSerializableImage(result: unknown): SerializableImage & { alt: string } { const parsed = SerializableImageSchema.safeParse(result); if (!parsed.success) { console.warn("Invalid image data:", parsed.error.issues); const obj = (result && typeof result === "object" ? result : {}) as Record; if ( typeof obj.id === "string" && typeof obj.assetId === "string" && typeof obj.src === "string" ) { return { id: obj.id, assetId: obj.assetId, src: obj.src, alt: typeof obj.alt === "string" ? obj.alt : "Image", title: typeof obj.title === "string" ? obj.title : undefined, description: typeof obj.description === "string" ? obj.description : undefined, href: typeof obj.href === "string" ? obj.href : undefined, domain: typeof obj.domain === "string" ? obj.domain : undefined, ratio: undefined, source: undefined, }; } throw new Error(`Invalid image: ${parsed.error.issues.map((i) => i.message).join(", ")}`); } return { ...parsed.data, alt: parsed.data.alt ?? "Image", }; } /** * Get aspect ratio class based on ratio prop (used for fixed-ratio images only) */ function getAspectRatioClass(ratio?: AspectRatio): string { switch (ratio) { case "1:1": return "aspect-square"; case "4:3": return "aspect-[4/3]"; case "16:9": return "aspect-video"; case "9:16": return "aspect-[9/16]"; case "21:9": return "aspect-[21/9]"; default: return "aspect-[4/3]"; } } /** * Error boundary for Image component */ interface ImageErrorBoundaryState { hasError: boolean; error?: Error; } export class ImageErrorBoundary extends Component< { children: ReactNode }, ImageErrorBoundaryState > { constructor(props: { children: ReactNode }) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(error: Error): ImageErrorBoundaryState { return { hasError: true, error }; } render() { if (this.state.hasError) { return (

Failed to load image

); } return this.props.children; } } /** * Loading skeleton for Image */ export function ImageSkeleton({ maxWidth = "512px" }: { maxWidth?: string }) { return (
); } /** * Image Loading State */ export function ImageLoading({ title = "Loading image..." }: { title?: string }) { return (

{title}

); } /** * Image Component * * Display images with metadata and attribution. * - For "auto" ratio: renders the image at natural dimensions (no cropping) * - For fixed ratios: uses a fixed aspect container with object-cover * - Features hover overlay with title, description, and source attribution. */ export function Image({ id, src, alt = "Image", title, description, href, domain, ratio = "auto", fit = "cover", source, maxWidth = "512px", className, }: ImageProps) { const [isHovered, setIsHovered] = useState(false); const [imageError, setImageError] = useState(false); const [imageLoaded, setImageLoaded] = useState(false); const displayDomain = domain || source?.label; const isGenerated = domain === "ai-generated"; const isAutoRatio = !ratio || ratio === "auto"; const handleClick = () => { const targetUrl = href || source?.url || src; if (targetUrl) { window.open(targetUrl, "_blank", "noopener,noreferrer"); } }; if (imageError) { return (

Image not available

); } return ( setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleClick(); } }} role="button" tabIndex={0} >
{isAutoRatio ? ( /* Auto ratio: image renders at natural dimensions, no cropping */ <> {!imageLoaded && (
)} {/* eslint-disable-next-line @next/next/no-img-element */} {alt} setImageLoaded(true)} onError={() => setImageError(true)} /> ) : ( /* Fixed ratio: constrained aspect container with fill */
setImageError(true)} />
)} {/* Hover overlay */}
{title && (

{title}

)} {description && (

{description}

)} {displayDomain && (
{isGenerated ? ( ) : source?.iconUrl ? ( ) : ( )} {displayDomain}
)}
{/* Badge when not hovered */} {displayDomain && !isHovered && (
{isGenerated && } {displayDomain}
)}
); }