'use client'; /** * LinkPreviewCard — a presentational URL-unfurl card (favicon + og:image + * title / description / site name), Notion / Telegram style. * * It NEVER fetches the page itself — the browser can't read cross-origin * meta tags (CORS). It either renders pre-resolved `data`, or calls the * host-provided `resolver(url)` (a Wails/Go method, a Next.js API route) * once on mount, showing a skeleton until it resolves. A `null` result * (or a thrown resolver) renders nothing. */ import { useEffect, useRef, useState } from 'react'; import { cn } from '@djangocfg/ui-core/lib'; import type { LinkPreviewData, ResolveLinkPreview } from './types'; export interface LinkPreviewCardProps { url: string; /** Pre-resolved metadata. When present, no resolver call is made. */ data?: LinkPreviewData; /** Host resolver used when `data` is absent. */ resolver?: ResolveLinkPreview; /** Layout — `compact` (default) is a tight horizontal card; `full` is a * roomier card with a larger cover image. */ appearance?: 'compact' | 'full'; className?: string; } /** Hostname for the fallback site label / favicon. */ function hostnameOf(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return url; } } type LoadState = | { status: 'ready'; data: LinkPreviewData } | { status: 'loading' } | { status: 'empty' }; export function LinkPreviewCard({ url, data, resolver, appearance = 'compact', className, }: LinkPreviewCardProps) { // If data is supplied we're immediately ready; else loading (resolver) or // empty (nothing to do). const [state, setState] = useState(() => data ? { status: 'ready', data } : resolver ? { status: 'loading' } : { status: 'empty' }, ); // Keep the latest resolver without re-running the effect on identity churn. const resolverRef = useRef(resolver); resolverRef.current = resolver; useEffect(() => { if (data) { setState({ status: 'ready', data }); return; } const resolve = resolverRef.current; if (!resolve) { setState({ status: 'empty' }); return; } let alive = true; const controller = new AbortController(); setState({ status: 'loading' }); resolve(url, controller.signal) .then((result) => { if (!alive) return; setState(result ? { status: 'ready', data: result } : { status: 'empty' }); }) .catch(() => { if (alive) setState({ status: 'empty' }); }); return () => { alive = false; controller.abort(); }; }, [url, data]); if (state.status === 'empty') return null; const host = hostnameOf(url); // Shared frame — matches the calm media-card surface used elsewhere. const frame = cn( 'block w-full overflow-hidden rounded-2xl border border-border bg-card shadow-sm', 'transition-colors hover:bg-accent/40', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', className, ); if (state.status === 'loading') { return (
); } const meta = state.data; const title = meta.title || host; const siteName = meta.siteName || host; const isFull = appearance === 'full'; return ( {/* Cover image (full appearance, when present) — top banner. */} {isFull && meta.image ? ( // eslint-disable-next-line @next/next/no-img-element ) : null}
{/* Site row — favicon + site name. */}
{meta.favicon ? ( // eslint-disable-next-line @next/next/no-img-element ) : null} {siteName}
{title}
{meta.description ? (
{meta.description}
) : null}
{/* Compact thumbnail (compact appearance, when present) — side. */} {!isFull && meta.image ? ( // eslint-disable-next-line @next/next/no-img-element ) : null}
); }