'use client' /** * BlogCard (pure presentation). * * Two densities: * - `default`: full vertical card with cover, title, summary, author/date * footer. * - `sm`: compact horizontal card (~80px tall) for chat-inline rendering. * * The card writes NO click logic — callers wrap with their own anchor * (e.g. hub's `` or lib's ``) * and pass the resolved detail URL via `href`. * * Image-fallback chain: * `post.featured_image` → `placeholderUrl` (caller passes * `useOgPlaceholderUrl(...)` from the hub OR any pre-resolved URL) → * `bg-ods-bg` (via the slot's background). */ import React from 'react' import { Eye } from 'lucide-react' import Image from '../../../embed-shims/next-image' import { StatusBadge } from '../../ui/status-badge' import { cn } from '../../../utils/cn' import type { BlogPostSummary } from '../../../types/blog' import { EntityPortraitCard } from './entity-portrait-card' import { useEntityCardLink } from './use-entity-card-link' import { useEntityCardPlaceholder } from './use-entity-card-placeholder' import { useCoverImageFallback } from './use-cover-image-fallback' import { COMPACT_CARD_IMAGE_SLOT, COMPACT_CARD_META_ROW_BOX, COMPACT_CARD_OUTER, COMPACT_CARD_ROW_FILLER, COMPACT_CARD_SKELETON_IMAGE_SLOT, COMPACT_CARD_SKELETON_OUTER, COMPACT_CARD_SUBTITLE, COMPACT_CARD_SUMMARY, COMPACT_CARD_TEXT_COL, COMPACT_CARD_TITLE, COMPACT_CARD_TITLE_ROW, } from '../utils/compact-card-classes' export interface BlogCardProps { post: BlogPostSummary /** Detail URL resolved by the caller (e.g. `buildContentURL`). */ href: string /** When `_blank`, opens in a new tab. Set by chat dispatch via * `computeIsNewTab`. Defaults to same-tab. */ target?: '_blank' rel?: 'noopener noreferrer' /** Platform that owns `href`. Used by parent wrappers; the card * itself doesn't read it but exposes the prop for the standard * pure-presentation contract. */ targetPlatform?: string | null /** Placeholder URL when `post.featured_image` is missing. Caller * resolves via `useOgPlaceholderUrl` (hub) or a static asset. */ placeholderUrl?: string | null size?: 'default' | 'sm' | 'portrait' /** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */ showTypeBadge?: boolean className?: string /** Surfaces a "Video" badge in compact mode. */ hasEmbeddedVideo?: boolean /** Optional render-prop for the title-area anchor in `default` mode. * When omitted the title renders as plain text (caller wraps the * whole card if it wants a link). */ priority?: boolean } /** `portrait` shares the default skeleton shape (same zone boxes). */ export function BlogCardSkeleton({ size = 'default' }: { size?: 'default' | 'sm' | 'portrait' }) { if (size === 'sm') { return ( ) } return (
) } export function BlogCard({ post, href, target: targetProp, rel: relProp, targetPlatform, placeholderUrl: placeholderUrlProp, size = 'default', showTypeBadge = true, className, hasEmbeddedVideo = false, priority = false, }: BlogCardProps) { const { target, rel } = useEntityCardLink({ href, targetPlatform, target: targetProp, rel: relProp, }) const placeholderUrl = useEntityCardPlaceholder({ title: post.title, placeholderUrl: placeholderUrlProp, aspect: size === 'sm' ? 'square' : 'wide', }) // Shared cover → placeholder → hide chain (ONE fallback logic for all cards). const { src: displayImage, onError: onImageError } = useCoverImageFallback(post.featured_image, placeholderUrl) if (size === 'sm') { const dateStr = post.published_at ? new Date(post.published_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }) : '' const firstCategory = post.categories?.find((c) => c && c.name)?.name return ( {displayImage ? ( {post.title} ) : null} {post.title} {hasEmbeddedVideo ? ( ) : null} {dateStr}{dateStr && firstCategory ? ' · ' : ''}{firstCategory ?? 'Blog Post'} {post.summary || COMPACT_CARD_ROW_FILLER} ) } if (size === 'portrait') { // Rail/strip density — shared shell. Raw cover + // placeholder go in separately; the shell runs the SAME shared // useCoverImageFallback chain internally (so its error recovery works). const dateStr = post.published_at ? new Date(post.published_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }) : '' return ( ) } // Default: full vertical card. const dateStr = post.published_at ? new Date(post.published_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }) : '' return ( ) }