"use client"; import { AlertCircleIcon, BookOpenIcon, CalendarIcon, ExternalLinkIcon, FileTextIcon, UserIcon, } from "lucide-react"; import Image from "next/image"; import { Component, type ReactNode, useCallback, useState } from "react"; import { z } from "zod"; import { Card, CardContent } from "@/components/ui/card"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; /** * Zod schema for serializable article data (from backend) */ const SerializableArticleSchema = z.object({ id: z.string().default("article-unknown"), assetId: z.string().nullish(), kind: z.literal("article").nullish(), title: z.string().default("Untitled Article"), description: z.string().nullish(), content: z.string().nullish(), href: z.string().url().nullish(), domain: z.string().nullish(), author: z.string().nullish(), date: z.string().nullish(), word_count: z.number().nullish(), wordCount: z.number().nullish(), was_truncated: z.boolean().nullish(), wasTruncated: z.boolean().nullish(), error: z.string().nullish(), }); /** * Serializable article data type (from backend) */ export type SerializableArticle = z.infer; /** * Article component props */ export interface ArticleProps { /** Unique identifier for the article */ id: string; /** Asset identifier (usually the URL) */ assetId?: string; /** Article title */ title: string; /** Brief description or excerpt */ description?: string; /** Full content of the article (markdown) */ content?: string; /** URL to the original article */ href?: string; /** Domain of the article source */ domain?: string; /** Author name */ author?: string; /** Publication date */ date?: string; /** Word count */ wordCount?: number; /** Whether content was truncated */ wasTruncated?: boolean; /** Optional max width */ maxWidth?: string; /** Optional error message */ error?: string; /** Optional className */ className?: string; /** Response actions */ responseActions?: Array<{ id: string; label: string; variant?: "default" | "outline"; }>; /** Response action handler */ onResponseAction?: (actionId: string) => void; } /** * Parse and validate serializable article data to ArticleProps */ export function parseSerializableArticle(data: unknown): ArticleProps { const result = SerializableArticleSchema.safeParse(data); if (!result.success) { console.warn("Invalid article data:", result.error.issues); // Return fallback with basic info const obj = (data && typeof data === "object" ? data : {}) as Record; return { id: String(obj.id || "article-unknown"), title: String(obj.title || "Untitled Article"), error: "Failed to parse article data", }; } const parsed = result.data; return { id: parsed.id, assetId: parsed.assetId, title: parsed.title, description: parsed.description, content: parsed.content, href: parsed.href, domain: parsed.domain, author: parsed.author, date: parsed.date, wordCount: parsed.word_count ?? parsed.wordCount, wasTruncated: parsed.was_truncated ?? parsed.wasTruncated, error: parsed.error, }; } /** * Format word count for display */ function formatWordCount(count: number): string { if (count >= 1000) { return `${(count / 1000).toFixed(1)}k words`; } return `${count} words`; } /** * Favicon component that fetches the site icon via Google's favicon service, * falling back to BookOpenIcon on error. */ function SiteFavicon({ domain }: { domain: string }) { const [failed, setFailed] = useState(false); if (failed) { return ; } return ( {`${domain} setFailed(true)} unoptimized /> ); } /** * Article card component for displaying scraped webpage content */ export function Article({ id, title, description, content, href, domain, author, date, wordCount, wasTruncated, maxWidth = "100%", error, className, responseActions, onResponseAction, }: ArticleProps) { const handleCardClick = useCallback(() => { if (href) { window.open(href, "_blank", "noopener,noreferrer"); } }, [href]); // Error state if (error) { return (

Failed to scrape webpage

{href &&

{href}

}

{error}

); } return ( { if (href && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); handleCardClick(); } }} > {/* Header */}
{/* Favicon / Icon */} {domain ? (
) : (
)} {/* Content */}
{/* Title */}

{title}

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

{description}

)} {/* Metadata row */}
{domain && ( {domain}

Source: {domain}

)} {author && ( {author}

Author: {author}

)} {date && ( {date} )} {wordCount && ( {formatWordCount(wordCount)} {wasTruncated && (truncated)}

{wasTruncated ? "Content was truncated due to length" : "Full article content available"}

)}
{/* Response actions */} {responseActions && responseActions.length > 0 && (
{responseActions.map((action) => ( ))}
)}
); } /** * Loading state for article component */ export function ArticleLoading({ title = "Loading article..." }: { title?: string }) { return (

{title}

); } /** * Skeleton for article component */ export function ArticleSkeleton() { return (
); } /** * Error boundary props */ interface ErrorBoundaryProps { children: ReactNode; fallback?: ReactNode; } interface ErrorBoundaryState { hasError: boolean; } /** * Error boundary for article component */ export class ArticleErrorBoundary extends Component { constructor(props: ErrorBoundaryProps) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(): ErrorBoundaryState { return { hasError: true }; } render() { if (this.state.hasError) { return ( this.props.fallback || (

Failed to render article

) ); } return this.props.children; } }