"use client"; import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon, Loader2 } from "lucide-react"; import Image from "next/image"; import { Component, type ReactNode } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; /** * Aspect ratio options for media cards */ type AspectRatio = "1:1" | "4:3" | "16:9" | "21:9" | "auto"; /** * MediaCard kind - determines the display style */ type MediaCardKind = "link" | "image" | "video" | "audio"; /** * Response action configuration */ interface ResponseAction { id: string; label: string; variant?: "default" | "secondary" | "outline" | "destructive" | "ghost"; confirmLabel?: string; } /** * Props for the MediaCard component */ export interface MediaCardProps { id: string; assetId: string; kind: MediaCardKind; href?: string; src?: string; title: string; description?: string; thumb?: string; ratio?: AspectRatio; domain?: string; maxWidth?: string; alt?: string; className?: string; responseActions?: ResponseAction[]; onResponseAction?: (id: string) => void; } /** * Serializable schema for MediaCard props (for tool results) */ export interface SerializableMediaCard { id: string; assetId: string; kind: MediaCardKind; href?: string; src?: string; title: string; description?: string; thumb?: string; ratio?: AspectRatio; domain?: string; } /** * Parse and validate serializable media card from tool result */ export function parseSerializableMediaCard(result: unknown): SerializableMediaCard { if (typeof result !== "object" || result === null) { throw new Error("Invalid media card result: expected object"); } const obj = result as Record; // Validate required fields if (typeof obj.id !== "string") { throw new Error("Invalid media card: missing id"); } if (typeof obj.assetId !== "string") { throw new Error("Invalid media card: missing assetId"); } if (typeof obj.kind !== "string") { throw new Error("Invalid media card: missing kind"); } if (typeof obj.title !== "string") { throw new Error("Invalid media card: missing title"); } return { id: obj.id, assetId: obj.assetId, kind: obj.kind as MediaCardKind, href: typeof obj.href === "string" ? obj.href : undefined, src: typeof obj.src === "string" ? obj.src : undefined, title: obj.title, description: typeof obj.description === "string" ? obj.description : undefined, thumb: typeof obj.thumb === "string" ? obj.thumb : undefined, ratio: typeof obj.ratio === "string" ? (obj.ratio as AspectRatio) : undefined, domain: typeof obj.domain === "string" ? obj.domain : undefined, }; } /** * Get aspect ratio class based on ratio prop */ 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 "21:9": return "aspect-[21/9]"; case "auto": default: return "aspect-[2/1]"; } } /** * Get icon based on media card kind */ function getKindIcon(kind: MediaCardKind) { switch (kind) { case "link": return ; case "image": return ; case "video": case "audio": return ; default: return ; } } /** * Error boundary for MediaCard */ interface MediaCardErrorBoundaryState { hasError: boolean; error?: Error; } export class MediaCardErrorBoundary extends Component< { children: ReactNode }, MediaCardErrorBoundaryState > { constructor(props: { children: ReactNode }) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(error: Error): MediaCardErrorBoundaryState { return { hasError: true, error }; } render() { if (this.state.hasError) { return (

Failed to load preview

{this.state.error?.message || "An error occurred"}

); } return this.props.children; } } /** * Loading skeleton for MediaCard */ export function MediaCardSkeleton({ maxWidth = "420px" }: { maxWidth?: string }) { return (
); } /** * MediaCard Component * * A rich media card for displaying link previews, images, and other media * in AI chat applications. Supports thumbnails, descriptions, and actions. */ export function MediaCard({ id, kind, href, title, description, thumb, ratio = "auto", domain, maxWidth = "420px", alt, className, responseActions, onResponseAction, }: MediaCardProps) { const aspectRatioClass = getAspectRatioClass(ratio); const displayDomain = domain || (href ? new URL(href).hostname.replace("www.", "") : undefined); const handleCardClick = () => { if (href) { window.open(href, "_blank", "noopener,noreferrer"); } }; return ( { if (href && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); handleCardClick(); } }} > {/* Thumbnail */} {thumb && (
{alt { // Hide broken images e.currentTarget.style.display = "none"; }} /> {/* Gradient overlay */}
)} {/* Fallback when no thumbnail */} {!thumb && (
{getKindIcon(kind)} {kind === "link" ? "Link Preview" : kind}
)} {/* Content */}
{/* Domain favicon placeholder */}
{/* Domain badge */} {displayDomain && (
{displayDomain} {href && ( )}
)} {/* Title */}

{title}

{/* Description */} {description && (

{description}

)}
{/* Response Actions */} {responseActions && responseActions.length > 0 && (
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > {responseActions.map((action) => ( {action.confirmLabel && (

{action.confirmLabel}

)}
))}
)}
); } /** * MediaCard Loading State */ export function MediaCardLoading({ title = "Loading preview..." }: { title?: string }) { return (

{title}

); }