# Post List

> Post list with list, grid, carousel, and fullwidth variants. Fullwidth mode shows paginated posts.

A shadcn/ui block from the [@manifest](https://ui.manifest.build) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/manifest/post-list).

- Page, with a live preview: https://shadcn.exchange/r/manifest/post-list
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/manifest/post-list-light-59ae0228147b7fc8.png
- Registry item: https://ui.manifest.build/r/post-list.json
- Categories: blogging
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.manifest.build/r/post-list.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `button`, `tooltip`, `https://ui.manifest.build/r/manifest-types.json`

## Files

### `registry/blogging/post-list.tsx`

````tsx
'use client'

import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useState } from 'react'
import type { Post } from './types'
import { PostCard } from './post-card'
import { demoPosts } from './demo/blogging'

/**
 * ═══════════════════════════════════════════════════════════════════════════
 * PostListProps
 * ═══════════════════════════════════════════════════════════════════════════
 *
 * Props for the PostList component, a blog post list with multiple layout variants.
 */
export interface PostListProps {
  data?: {
    /** Array of blog posts to display. */
    posts?: Post[]
  }
  actions?: {
    /** Called when the read more button is clicked on a post. */
    onReadMore?: (post: Post) => void
  }
  appearance?: {
    /**
     * Layout variant for the post list.
     * @default "list"
     */
    variant?: 'list' | 'grid' | 'carousel' | 'fullwidth'
    /**
     * Number of columns for grid and fullwidth variants.
     * @default 2
     */
    columns?: 2 | 3 | 4
    /**
     * Whether to show author information on post cards.
     * @default true
     */
    showAuthor?: boolean
    /**
     * Whether to show category labels on post cards.
     * @default true
     */
    showCategory?: boolean
  }
}

/**
 * A blog post list component with multiple layout variants.
 * Supports list, grid, carousel, and fullwidth paginated modes.
 *
 * Features:
 * - Four layout variants (list, grid, carousel, fullwidth)
 * - Responsive grid columns
 * - Carousel with touch-friendly navigation
 * - Fullwidth mode with pagination
 * - Configurable author and category display
 *
 * @component
 * @example
 * ```tsx
 * <PostList
 *   data={{
 *     posts: [
 *       {
 *         title: "Getting Started Guide",
 *         excerpt: "Learn the basics...",
 *         coverImage: "https://example.com/image.jpg",
 *         author: { name: "Sarah Chen" },
 *         publishedAt: "2024-01-15"
 *       }
 *     ]
 *   }}
 *   appearance={{
 *     variant: "grid",
 *     columns: 3,
 *     showAuthor: true
 *   }}
 *   actions={{
 *     onReadMore: (post) => console.log("Read:", post.title)
 *   }}
 * />
 * ```
 */
export function PostList({ data, actions, appearance }: PostListProps) {
  const resolved: NonNullable<PostListProps['data']> = data ?? { posts: demoPosts }
  const posts = resolved.posts ?? []
  const onReadMore = actions?.onReadMore
  const variant = appearance?.variant ?? 'list'
  const columns = appearance?.columns ?? 2
  const showAuthor = appearance?.showAuthor ?? true
  const showCategory = appearance?.showCategory ?? true
  const [currentIndex, setCurrentIndex] = useState(0)

  // List variant
  if (variant === 'list') {
    return (
      <div className="space-y-3 m-3 bg-card rounded-lg p-3">
        {posts.slice(0, 3).map((post) => (
          <PostCard
            key={post.title || post.url}
            data={{ post }}
            appearance={{ variant: "horizontal", showAuthor, showCategory }}
            actions={{ onReadMore }}
          />
        ))}
      </div>
    )
  }

  // Grid variant (inline mode - show only 4 posts)
  if (variant === 'grid') {
    return (
      <div
        className={cn(
          'grid gap-4 grid-cols-1',
          columns === 2 ? 'sm:grid-cols-2' : 'sm:grid-cols-3'
        )}
      >
        {posts.slice(0, 4).map((post) => (
          <PostCard
            key={post.title || post.url}
            data={{ post }}
            appearance={{ variant: "compact", showImage: false, showAuthor, showCategory }}
            actions={{ onReadMore }}
          />
        ))}
      </div>
    )
  }

  // Fullwidth variant
  if (variant === 'fullwidth') {
    const getGridColsClass = () => {
      switch (columns) {
        case 2:
          return 'sm:grid-cols-2'
        case 3:
          return 'sm:grid-cols-2 lg:grid-cols-3'
        case 4:
          return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'
        default:
          return 'sm:grid-cols-2'
      }
    }

    return (
      <div className="space-y-6 p-6">
        <div className={cn('grid gap-6 grid-cols-1', getGridColsClass())}>
          {posts.map((post) => (
            <PostCard
              key={post.title || post.url}
              data={{ post }}
              appearance={{ variant: "default", showAuthor, showCategory }}
              actions={{ onReadMore }}
            />
          ))}
        </div>
      </div>
    )
  }

  // Carousel variant
  const maxIndexMobile = posts.length - 1
  const maxIndexTablet = Math.max(0, posts.length - 2)
  const maxIndexDesktop = Math.max(0, posts.length - 3)

  const prev = () => {
    setCurrentIndex((i) => Math.max(0, i - 1))
  }

  const next = () => {
    setCurrentIndex((i) => i + 1)
  }

  const isAtStart = currentIndex === 0
  const isAtEndMobile = currentIndex >= maxIndexMobile
  const isAtEndTablet = currentIndex >= maxIndexTablet
  const isAtEndDesktop = currentIndex >= maxIndexDesktop

  return (
    <div className="relative">
      <div className="overflow-hidden rounded-lg">
        {/* Mobile: 1 card, slides by 100% */}
        <div
          className="flex transition-transform duration-300 ease-out md:hidden"
          style={{ transform: `translateX(-${currentIndex * 100}%)` }}
        >
          {posts.map((post) => (
            <div key={post.title || post.url} className="w-full shrink-0 px-0.5">
              <PostCard
                data={{ post }}
                appearance={{ variant: "compact", showAuthor, showCategory }}
                actions={{ onReadMore }}
              />
            </div>
          ))}
        </div>

        {/* Tablet: 2 cards visible, slides by 50% */}
        <div
          className="hidden md:flex lg:hidden transition-transform duration-300 ease-out"
          style={{ transform: `translateX(-${currentIndex * 50}%)` }}
        >
          {posts.map((post) => (
            <div key={post.title || post.url} className="w-1/2 shrink-0 px-1.5">
              <PostCard
                data={{ post }}
                appearance={{ variant: "compact", showAuthor, showCategory }}
                actions={{ onReadMore }}
              />
            </div>
          ))}
        </div>

        {/* Desktop: 3 cards visible, slides by 33.333% */}
        <div
          className="hidden lg:flex transition-transform duration-300 ease-out"
          style={{ transform: `translateX(-${currentIndex * (100 / 3)}%)` }}
        >
          {posts.map((post) => (
            <div key={post.title || post.url} className="w-1/3 shrink-0 px-1.5">
              <PostCard
                data={{ post }}
                appearance={{ variant: "compact", showAuthor, showCategory }}
                actions={{ onReadMore }}
              />
            </div>
          ))}
        </div>
      </div>
      <div className="mt-3 flex items-center justify-between px-2">
        <div className="flex gap-1">
          {posts.map((_, i) => (
            <button
              key={i}
              onClick={() => setCurrentIndex(i)}
              aria-label={`Go to slide ${i + 1}`}
              className={cn(
                'h-1.5 rounded-full transition-all cursor-pointer',
                i === currentIndex
                  ? 'w-4 bg-foreground'
                  : 'w-1.5 bg-muted-foreground/30'
              )}
            />
          ))}
        </div>
        {/* Mobile navigation */}
        <div className="flex gap-1 md:hidden">
          <Button
            variant="outline"
            size="icon"
            className="h-8 w-8"
            onClick={prev}
            disabled={isAtStart}
            aria-label="Previous post"
          >
            <ChevronLeft className="h-4 w-4" />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="h-8 w-8"
            onClick={next}
            disabled={isAtEndMobile}
            aria-label="Next post"
          >
            <ChevronRight className="h-4 w-4" />
          </Button>
        </div>
        {/* Tablet navigation */}
        <div className="hidden md:flex lg:hidden gap-1">
          <Button
            variant="outline"
            size="icon"
            className="h-8 w-8"
            onClick={prev}
            disabled={isAtStart}
            aria-label="Previous post"
          >
            <ChevronLeft className="h-4 w-4" />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="h-8 w-8"
            onClick={next}
            disabled={isAtEndTablet}
            aria-label="Next post"
          >
            <ChevronRight className="h-4 w-4" />
          </Button>
        </div>
        {/* Desktop navigation */}
        <div className="hidden lg:flex gap-1">
          <Button
            variant="outline"
            size="icon"
            className="h-8 w-8"
            onClick={prev}
            disabled={isAtStart}
            aria-label="Previous post"
          >
            <ChevronLeft className="h-4 w-4" />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="h-8 w-8"
            onClick={next}
            disabled={isAtEndDesktop}
            aria-label="Next post"
          >
            <ChevronRight className="h-4 w-4" />
          </Button>
        </div>
      </div>
    </div>
  )
}
````

### `registry/blogging/post-card.tsx`

````tsx
'use client'

import { Button } from '@/components/ui/button'

// Import types from shared types file to avoid circular dependencies
import type { Post } from './types'
// Re-export for backward compatibility
export type { Post } from './types'

import { demoPost } from './demo/blogging'


/**
 * ═══════════════════════════════════════════════════════════════════════════
 * PostCardProps
 * ═══════════════════════════════════════════════════════════════════════════
 *
 * Props for the PostCard component, a blog post card with multiple layout variants.
 */
export interface PostCardProps {
  data?: {
    /** The blog post to display. */
    post?: Post
  }
  actions?: {
    /** Called when the read more button is clicked. */
    onReadMore?: (post: Post) => void
  }
  appearance?: {
    /**
     * Card layout variant.
     * @default "default"
     */
    variant?: 'default' | 'compact' | 'horizontal' | 'covered'
    /**
     * Whether to show the cover image.
     * @default true
     */
    showImage?: boolean
    /**
     * Whether to show author information.
     * @default true
     */
    showAuthor?: boolean
    /**
     * Whether to show the category label.
     * @default true
     */
    showCategory?: boolean
  }
}

/**
 * A blog post card component with multiple layout variants.
 * Supports default, compact, horizontal, and covered (overlay) styles.
 *
 * Features:
 * - Four layout variants (default, compact, horizontal, covered)
 * - Cover image with hover zoom effect
 * - Author avatar and info display
 * - Category and tags display
 * - Read more action button
 * - Responsive design
 *
 * @component
 * @example
 * ```tsx
 * <PostCard
 *   data={{
 *     post: {
 *       title: "Getting Started Guide",
 *       excerpt: "Learn the basics of our component library.",
 *       coverImage: "https://example.com/image.jpg",
 *       author: { name: "Sarah Chen", avatar: "https://example.com/avatar.jpg" },
 *       publishedAt: "2024-01-15",
 *       readTime: "5 min read",
 *       tags: ["Tutorial", "Components"],
 *       category: "Tutorial"
 *     }
 *   }}
 *   actions={{
 *     onReadMore: (post) => console.log("Read more:", post.title)
 *   }}
 *   appearance={{
 *     variant: "default",
 *     showImage: true,
 *     showAuthor: true,
 *     showCategory: true
 *   }}
 * />
 * ```
 */
export function PostCard({ data, actions, appearance }: PostCardProps) {
  const resolved: NonNullable<PostCardProps['data']> = data ?? { post: demoPost }
  const post = resolved.post
  if (!post) {
    return null
  }
  const onReadMore = actions?.onReadMore
  const variant = appearance?.variant ?? 'default'
  const showImage = appearance?.showImage ?? true
  const showAuthor = appearance?.showAuthor ?? true
  const showCategory = appearance?.showCategory ?? true

  // Handle "Read more" click - only call callback if provided, otherwise do nothing
  // This lets users decide to use an external link or open fullscreen mode
  const handleReadMore = () => {
    if (onReadMore) {
      onReadMore(post)
    }
  }

  const formatDate = (dateStr: string) => {
    return new Date(dateStr).toLocaleDateString('en-US', {
      month: 'short',
      day: 'numeric',
      year: 'numeric'
    })
  }

  if (variant === 'covered') {
    return (
      <div className="relative overflow-hidden rounded-lg border">
        <div className="min-h-[280px] sm:aspect-[16/9] sm:min-h-0 w-full">
          {post.coverImage ? (
            <img
              src={post.coverImage}
              alt={post.title || ''}
              className="absolute inset-0 h-full w-full object-cover"
            />
          ) : (
            <div className="absolute inset-0 h-full w-full bg-muted" />
          )}
        </div>
        {/* Minimal overlay - solid color instead of gradient per ChatGPT guidelines */}
        <div className="absolute inset-0 bg-black/60" />
        <div className="absolute inset-0 flex flex-col justify-end p-4 text-white">
          <div>
            {showCategory && post.category && (
              <p className="text-[10px] font-medium uppercase tracking-wide text-white/70">
                {post.category}
              </p>
            )}
            {post.title && (
              <h2 className="mt-1 text-lg font-semibold leading-tight">
                {post.title}
              </h2>
            )}
            {post.excerpt && (
              <p className="mt-1 line-clamp-2 text-sm text-white/80">
                {post.excerpt}
              </p>
            )}
            {post.tags && post.tags.length > 0 && (
              <div className="mt-2 flex flex-wrap gap-1">
                {post.tags.slice(0, 2).map((tag) => (
                  <span
                    key={tag}
                    className="rounded-md bg-white/20 px-2 py-0.5 text-xs"
                  >
                    {tag}
                  </span>
                ))}
              </div>
            )}
            <div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
              {showAuthor && (
                <div className="flex items-center gap-2">
                  {post.author?.avatar && (
                    <img
                      src={post.author.avatar}
                      alt={post.author?.name || ''}
                      className="h-6 w-6 rounded-full ring-1 ring-white/30"
                    />
                  )}
                  <div className="text-xs">
                    {post.author?.name && (
                      <p className="font-medium">{post.author.name}</p>
                    )}
                    {post.publishedAt && (
                      <p className="text-white/60">
                        {formatDate(post.publishedAt)}
                      </p>
                    )}
                  </div>
                </div>
              )}
              <Button
                size="sm"
                variant="secondary"
                className="w-full sm:w-auto"
                onClick={handleReadMore}
              >
                Read article
              </Button>
            </div>
          </div>
        </div>
      </div>
    )
  }

  if (variant === 'horizontal') {
    return (
      <div className="flex flex-col sm:flex-row gap-4 rounded-lg border bg-card p-3">
        {showImage && post.coverImage && (
          <div className="aspect-video sm:aspect-square sm:h-24 sm:w-24 shrink-0 overflow-hidden rounded-md">
            <img
              src={post.coverImage}
              alt={post.title || ''}
              className="h-full w-full object-cover"
            />
          </div>
        )}
        <div className="flex flex-1 flex-col justify-between">
          <div>
            {showCategory && post.category && (
              <p className="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
                {post.category}
              </p>
            )}
            {post.title && (
              <h3 className="line-clamp-2 text-sm font-medium leading-tight">
                {post.title}
              </h3>
            )}
            {post.excerpt && (
              <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
                {post.excerpt}
              </p>
            )}
            {post.tags && post.tags.length > 0 && (
              <div className="mt-1.5 flex flex-wrap gap-1">
                {post.tags.slice(0, 2).map((tag) => (
                  <span
                    key={tag}
                    className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
                  >
                    {tag}
                  </span>
                ))}
              </div>
            )}
          </div>
          <div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
            <div className="flex items-center gap-2 text-xs text-muted-foreground">
              {showAuthor && post.author?.avatar && (
                <img
                  src={post.author.avatar}
                  alt={post.author?.name || ''}
                  className="h-4 w-4 rounded-full"
                />
              )}
              {post.publishedAt && (
                <span>{formatDate(post.publishedAt)}</span>
              )}
              {post.readTime && (
                <>
                  <span>·</span>
                  <span>{post.readTime}</span>
                </>
              )}
            </div>
            <Button
              size="sm"
              className="w-full sm:w-auto"
              onClick={handleReadMore}
            >
              Read
            </Button>
          </div>
        </div>
      </div>
    )
  }

  if (variant === 'compact') {
    return (
      <div className="flex h-full flex-col justify-between rounded-lg border bg-card p-3">
        <div>
          {showCategory && post.category && (
            <p className="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
              {post.category}
            </p>
          )}
          {post.title && (
            <h3 className="line-clamp-2 text-sm font-medium">{post.title}</h3>
          )}
          {post.excerpt && (
            <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
              {post.excerpt}
            </p>
          )}
          {post.tags && post.tags.length > 0 && (
            <div className="mt-1.5 flex flex-wrap gap-1">
              {post.tags.slice(0, 2).map((tag) => (
                <span
                  key={tag}
                  className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
                >
                  {tag}
                </span>
              ))}
            </div>
          )}
        </div>
        <div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
          <div className="flex items-center gap-2">
            {showAuthor && post.author?.avatar && (
              <img
                src={post.author.avatar}
                alt={post.author?.name || ''}
                className="h-5 w-5 rounded-full"
              />
            )}
            {post.publishedAt && (
              <span className="text-xs text-muted-foreground">
                {formatDate(post.publishedAt)}
              </span>
            )}
          </div>
          <Button size="sm" onClick={handleReadMore}>
            Read more
          </Button>
        </div>
      </div>
    )
  }

  // Default variant
  return (
    <div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
      {showImage && post.coverImage && (
        <div className="aspect-video overflow-hidden">
          <img
            src={post.coverImage}
            alt={post.title || ''}
            className="h-full w-full object-cover transition-transform hover:scale-105"
          />
        </div>
      )}
      <div className="flex flex-1 flex-col justify-between p-4">
        <div>
          {showCategory && post.category && (
            <p className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
              {post.category}
            </p>
          )}
          {post.title && (
            <h3 className="line-clamp-2 font-medium">{post.title}</h3>
          )}
          {post.excerpt && (
            <p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
              {post.excerpt}
            </p>
          )}
          {post.tags && post.tags.length > 0 && (
            <div className="mt-2 flex flex-wrap gap-1">
              {post.tags.slice(0, 2).map((tag) => (
                <span
                  key={tag}
                  className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
                >
                  {tag}
                </span>
              ))}
            </div>
          )}
        </div>
        <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
          {showAuthor && (
            <div className="flex items-center gap-2">
              {post.author?.avatar && (
                <img
                  src={post.author.avatar}
                  alt={post.author?.name || ''}
                  className="h-6 w-6 rounded-full"
                />
              )}
              <div className="text-xs">
                {post.author?.name && (
                  <p className="font-medium">{post.author.name}</p>
                )}
                {post.publishedAt && (
                  <p className="text-muted-foreground">
                    {formatDate(post.publishedAt)}
                  </p>
                )}
              </div>
            </div>
          )}
          <Button size="sm" onClick={handleReadMore}>
            Read
          </Button>
        </div>
      </div>
    </div>
  )
}
````

### `registry/blogging/post-detail.tsx`

````tsx
'use client';

import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Calendar, Clock, ExternalLink, Maximize2 } from 'lucide-react';
import { useMemo } from 'react';
import type { Post } from './types';
import { demoPostDetailData } from './demo/blogging';

// DOM-based allowlist HTML sanitizer for post content
const ALLOWED_TAGS = new Set([
  'p', 'br', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li',
  'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre',
  'span', 'div', 'img', 'figure', 'figcaption', 'hr',
]);
const ALLOWED_ATTRS: Record<string, Set<string>> = {
  a: new Set(['href', 'target', 'rel', 'title']),
  img: new Set(['src', 'alt', 'width', 'height']),
  '*': new Set(['class', 'id']),
};
const DANGEROUS_URL = /^\s*(javascript|data):/i;

function sanitizeNode(node: Node, doc: Document): void {
  const children = Array.from(node.childNodes);
  for (const child of children) {
    if (child.nodeType === 3 /* TEXT */) continue;
    if (child.nodeType !== 1 /* ELEMENT */) {
      child.remove();
      continue;
    }
    const el = child as Element;
    const tag = el.tagName.toLowerCase();
    if (!ALLOWED_TAGS.has(tag)) {
      // Unwrap: keep text content, discard the tag
      while (el.firstChild) node.insertBefore(el.firstChild, el);
      el.remove();
      continue;
    }
    // Strip disallowed attributes
    const tagAllowed = ALLOWED_ATTRS[tag];
    const globalAllowed = ALLOWED_ATTRS['*'];
    for (const attr of Array.from(el.attributes)) {
      const name = attr.name.toLowerCase();
      if (!tagAllowed?.has(name) && !globalAllowed?.has(name)) {
        el.removeAttribute(attr.name);
      }
    }
    // Block dangerous URL schemes on href/src
    for (const urlAttr of ['href', 'src']) {
      const val = el.getAttribute(urlAttr);
      if (val && DANGEROUS_URL.test(val)) el.removeAttribute(urlAttr);
    }
    sanitizeNode(el, doc);
  }
}

function sanitizeHtml(html: string): string {
  if (typeof document === 'undefined') return html;
  const doc = new DOMParser().parseFromString(html, 'text/html');
  sanitizeNode(doc.body, doc);
  return doc.body.innerHTML;
}

function TagList({
  tags,
  maxVisible = 2,
  size = 'default',
}: {
  tags: string[];
  maxVisible?: number;
  size?: 'small' | 'default';
}) {
  const visibleTags = tags.slice(0, maxVisible);
  const remainingTags = tags.slice(maxVisible);
  const hasMore = remainingTags.length > 0;

  const tagClass =
    size === 'small'
      ? 'rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium'
      : 'rounded-full bg-muted px-3 py-1 text-xs font-medium';

  return (
    <>
      {visibleTags.map((tag) => (
        <span key={tag} className={tagClass}>
          {tag}
        </span>
      ))}
      {hasMore && (
        <TooltipProvider delayDuration={0}>
          <Tooltip>
            <TooltipTrigger asChild>
              <span className={`${tagClass} cursor-default`}>+{remainingTags.length}</span>
            </TooltipTrigger>
            <TooltipContent>
              <p>{remainingTags.join(', ')}</p>
            </TooltipContent>
          </Tooltip>
        </TooltipProvider>
      )}
    </>
  );
}

/**
 * ═══════════════════════════════════════════════════════════════════════════
 * PostDetailProps
 * ═══════════════════════════════════════════════════════════════════════════
 *
 * Props for the PostDetail component, a full post detail view with Medium-style typography.
 */
export interface PostDetailProps {
  data?: {
    /** The main blog post to display. */
    post?: Post;
    /** HTML content of the post body. */
    content?: string;
    /** Related posts to show at the bottom of the article. */
    relatedPosts?: Post[];
  };
  actions?: {
    /** Called when the back button is clicked. */
    onBack?: () => void;
    /** Called when the read more button is clicked (inline mode). */
    onReadMore?: () => void;
    /** Called when a related post is clicked. */
    onReadRelated?: (post: Post) => void;
  };
  appearance?: {
    /**
     * Whether to show the cover image.
     * @default true
     */
    showCover?: boolean;
    /**
     * Whether to show author information.
     * @default true
     */
    showAuthor?: boolean;
    /**
     * Display mode for the component.
     * - inline: Compact card view with truncated content
     * - pip: Picture-in-picture view with truncated content
     * - fullscreen: Full article view with complete content
     * @default "fullscreen"
     */
    displayMode?: 'inline' | 'pip' | 'fullscreen';
  };
}

/**
 * A full post detail component with Medium-style typography.
 * Supports inline preview and fullscreen reading modes.
 *
 * Features:
 * - Medium-style typography and spacing
 * - Cover image display
 * - Author info with avatar
 * - Tag list with overflow tooltip
 * - Related posts section
 * - Inline (truncated) and fullscreen modes
 * - MCP Apps display mode integration
 *
 * @component
 * @example
 * ```tsx
 * <PostDetail
 *   data={{
 *     post: {
 *       id: "1",
 *       title: "Getting Started",
 *       excerpt: "Learn the basics...",
 *       coverImage: "https://example.com/cover.jpg",
 *       author: { name: "Sarah Chen", avatar: "https://example.com/avatar.jpg" },
 *       publishedAt: "2024-01-15",
 *       readTime: "5 min read",
 *       tags: ["Tutorial", "Components"],
 *       category: "Tutorial"
 *     },
 *     content: "<p>Full post content here...</p>",
 *     relatedPosts: [...]
 *   }}
 *   actions={{
 *     onReadMore: () => console.log("Expand to fullscreen"),
 *     onReadRelated: (post) => console.log("Read related:", post.title)
 *   }}
 *   appearance={{
 *     showCover: true,
 *     showAuthor: true,
 *     displayMode: "fullscreen"
 *   }}
 * />
 * ```
 */
export function PostDetail({ data, actions, appearance }: PostDetailProps) {
  const resolved: NonNullable<PostDetailProps['data']> = data ?? demoPostDetailData;
  const post = resolved.post;
  const rawContent = resolved.content;
  const content = useMemo(() => rawContent ? sanitizeHtml(rawContent) : undefined, [rawContent]);
  const relatedPosts = resolved.relatedPosts ?? [];
  const onReadMore = actions?.onReadMore;
  const showCover = appearance?.showCover ?? true;
  const showAuthor = appearance?.showAuthor ?? true;

  const displayMode = appearance?.displayMode ?? 'inline';

  const handleReadMore = () => {
    onReadMore?.();
  };

  const formatDate = (dateStr: string) => {
    return new Date(dateStr).toLocaleDateString('en-US', {
      month: 'long',
      day: 'numeric',
      year: 'numeric',
    });
  };

  // Inline mode - card view with truncated content
  if (displayMode === 'inline') {
    return (
      <div className="flex flex-col sm:flex-row gap-4 rounded-lg border bg-card p-3">
        {showCover && post?.coverImage && (
          <div className="aspect-video sm:aspect-square sm:h-24 sm:w-24 shrink-0 overflow-hidden rounded-md">
            <img
              src={post.coverImage}
              alt={post?.title || ''}
              className="h-full w-full object-cover"
            />
          </div>
        )}

        <div className="flex flex-1 flex-col justify-between min-w-0">
          <div>
            <div className="flex items-start justify-between gap-2">
              <div className="flex-1 min-w-0">
                {post?.category && (
                  <p className="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
                    {post.category}
                  </p>
                )}

                {post?.title && (
                  <h1 className="line-clamp-2 text-sm font-bold leading-tight">{post.title}</h1>
                )}
              </div>
              <button
                onClick={handleReadMore}
                className="shrink-0 p-1 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground cursor-pointer"
                aria-label="Expand to fullscreen"
              >
                <Maximize2 className="h-4 w-4" />
              </button>
            </div>

            {post?.excerpt && (
              <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{post.excerpt}</p>
            )}

            {post?.tags && post.tags.length > 0 && (
              <div className="mt-1.5 flex flex-wrap gap-1">
                <TagList tags={post.tags} maxVisible={2} size="small" />
              </div>
            )}
          </div>

          <div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
            <div className="flex items-center gap-2 text-xs text-muted-foreground">
              {showAuthor && post?.author?.avatar && (
                <img
                  src={post.author.avatar}
                  alt={post?.author?.name || ''}
                  className="h-4 w-4 rounded-full"
                />
              )}
              {showAuthor && post?.author?.name && <span>{post.author.name}</span>}
              {post?.publishedAt && (
                <span className="flex items-center gap-1">
                  <Calendar className="h-3 w-3" />
                  {formatDate(post.publishedAt)}
                </span>
              )}
              {post?.readTime && (
                <span className="flex items-center gap-1">
                  <Clock className="h-3 w-3" />
                  {post.readTime}
                </span>
              )}
            </div>

            <Button size="sm" onClick={handleReadMore}>
              Read
            </Button>
          </div>
        </div>
      </div>
    );
  }

  // PiP mode - horizontal layout with image on left, similar to post-card horizontal
  if (displayMode === 'pip') {
    return (
      <div className="flex flex-col sm:flex-row gap-4 rounded-lg border bg-card p-3">
        {showCover && post?.coverImage && (
          <div className="aspect-video sm:aspect-square sm:h-24 sm:w-24 shrink-0 overflow-hidden rounded-md">
            <img
              src={post.coverImage}
              alt={post?.title || ''}
              className="h-full w-full object-cover"
            />
          </div>
        )}

        <div className="flex flex-1 flex-col justify-between min-w-0">
          <div>
            {post?.category && (
              <p className="mb-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
                {post.category}
              </p>
            )}

            {post?.title && (
              <h1 className="line-clamp-2 text-sm font-bold leading-tight">{post.title}</h1>
            )}

            {post?.excerpt && (
              <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{post.excerpt}</p>
            )}

            {post?.tags && post.tags.length > 0 && (
              <div className="mt-1.5 flex flex-wrap gap-1">
                <TagList tags={post.tags} maxVisible={2} size="small" />
              </div>
            )}
          </div>

          <div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
            <div className="flex items-center gap-2 text-xs text-muted-foreground">
              {showAuthor && post?.author?.avatar && (
                <img
                  src={post.author.avatar}
                  alt={post?.author?.name || ''}
                  className="h-4 w-4 rounded-full"
                />
              )}
              {showAuthor && post?.author?.name && <span>{post.author.name}</span>}
              {post?.publishedAt && (
                <span className="flex items-center gap-1">
                  <Calendar className="h-3 w-3" />
                  {formatDate(post.publishedAt)}
                </span>
              )}
              {post?.readTime && (
                <span className="flex items-center gap-1">
                  <Clock className="h-3 w-3" />
                  {post.readTime}
                </span>
              )}
            </div>
            <Button size="sm" onClick={handleReadMore}>
              Read
            </Button>
          </div>
        </div>
      </div>
    );
  }

  // Fullscreen mode
  return (
    <div className="min-h-screen fs-mode bg-background">
      <article className="mx-auto w-full max-w-[680px] px-6 py-10">
        {showCover && post?.coverImage && (
          <div className="aspect-video w-full overflow-hidden rounded-lg mb-8">
            <img
              src={post.coverImage}
              alt={post?.title || ''}
              className="h-full w-full object-cover"
            />
          </div>
        )}
        {post?.category && (
          <p className="mb-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
            {post.category}
          </p>
        )}

        {post?.title && (
          <h1 className="text-[32px] font-bold leading-[1.25] tracking-tight md:text-[42px]">
            {post.title}
          </h1>
        )}

        {post?.tags && post.tags.length > 0 && (
          <div className="mt-4 flex flex-wrap items-center gap-2">
            <TagList tags={post.tags} maxVisible={2} size="default" />
          </div>
        )}

        {showAuthor && post?.author && (
          <div className="mt-8 flex items-center gap-4 border-b pb-8">
            {post.author.avatar && (
              <img
                src={post.author.avatar}
                alt={post.author.name || ''}
                className="h-12 w-12 rounded-full"
              />
            )}
            <div>
              {post.author.name && <p className="font-medium">{post.author.name}</p>}
              {(post?.publishedAt || post?.readTime) && (
                <div className="flex items-center gap-3 text-sm text-muted-foreground">
                  {post?.publishedAt && (
                    <span className="flex items-center gap-1">
                      <Calendar className="h-3.5 w-3.5" />
                      {formatDate(post.publishedAt)}
                    </span>
                  )}
                  {post?.readTime && (
                    <span className="flex items-center gap-1">
                      <Clock className="h-3.5 w-3.5" />
                      {post.readTime}
                    </span>
                  )}
                </div>
              )}
            </div>
          </div>
        )}

        {/* Medium-style content */}
        <div className="mt-10">
          {post?.excerpt && (
            <p className="text-[21px] leading-[1.8] text-muted-foreground mb-8">{post.excerpt}</p>
          )}
          {content && (
            <div
              className="
                text-[21px] leading-[1.8] tracking-[-0.003em]
                [&>p]:mb-8
                [&>h2]:text-[26px] [&>h2]:font-bold [&>h2]:mt-12 [&>h2]:mb-4 [&>h2]:leading-[1.3]
                [&>h3]:text-[22px] [&>h3]:font-bold [&>h3]:mt-10 [&>h3]:mb-3 [&>h3]:leading-[1.3]
                [&>ul]:mb-8 [&>ul]:pl-6 [&>ul>li]:mb-2
                [&>ol]:mb-8 [&>ol]:pl-6 [&>ol>li]:mb-2
                [&>blockquote]:border-l-4 [&>blockquote]:border-foreground [&>blockquote]:pl-6 [&>blockquote]:my-8 [&>blockquote]:italic
              "
              dangerouslySetInnerHTML={{ __html: content }}
            />
          )}
        </div>

        {relatedPosts && relatedPosts.length > 0 && (
          <div className="mt-16 border-t pt-10">
            <h3 className="mb-6 text-lg font-semibold">Related Posts</h3>
            <div className="space-y-4">
              {relatedPosts.map((related) => (
                <a
                  key={related.title || related.url}
                  href={related.url || '#'}
                  target="_blank"
                  rel="noopener noreferrer"
                  className="flex w-full items-center gap-4 rounded-lg p-3 text-left transition-colors hover:bg-muted cursor-pointer"
                >
                  {related.coverImage && (
                    <div className="h-16 w-16 shrink-0 overflow-hidden rounded-lg">
                      <img
                        src={related.coverImage}
                        alt={related.title || ''}
                        className="h-full w-full object-cover"
                      />
                    </div>
                  )}
                  <div className="min-w-0 flex-1">
                    {related.title && <p className="font-medium">{related.title}</p>}
                    {related.excerpt && (
                      <p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
                        {related.excerpt}
                      </p>
                    )}
                    {related.readTime && (
                      <p className="mt-1 text-xs text-muted-foreground">{related.readTime}</p>
                    )}
                  </div>
                  <ExternalLink className="h-4 w-4 shrink-0 text-muted-foreground" />
                </a>
              ))}
            </div>
          </div>
        )}
      </article>
    </div>
  );
}
````

### `registry/blogging/demo/blogging.ts`

```ts
// Demo data for Blogging category components
// This file contains sample data used for component previews and documentation

import type { Post } from '../types';

// Single post for PostCard default
export const demoPost: Post = {
  title: 'Getting Started with Agentic UI Components',
  excerpt:
    'Learn how to build conversational interfaces with our comprehensive component library designed for AI-powered applications.',
  coverImage: 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=800',
  author: {
    name: 'Sarah Chen',
    avatar: 'https://i.pravatar.cc/150?u=sarah',
  },
  publishedAt: '2024-01-15',
  readTime: '5 min read',
  tags: ['Tutorial', 'Components'],
  category: 'Tutorial',
};

// Demo content for PostDetail (HTML content for full article view)
export const demoPostContent = `
  <p>Building modern AI-powered applications requires a new approach to UI design. Traditional web components don't always translate well to conversational interfaces, where context and flow are paramount.</p>

  <p>Our Agentic UI component library provides a collection of purpose-built components that work seamlessly within chat interfaces. From payment flows to product displays, each component is designed with the unique constraints of conversational UIs in mind.</p>

  <h2>Key Features</h2>
  <p>Each component supports three display modes: inline (within the chat flow), fullscreen (for complex interactions), and picture-in-picture (persistent visibility). This flexibility allows you to create rich, interactive experiences without breaking the conversational flow.</p>

  <p>Components are designed mobile-first and touch-friendly, ensuring a great experience across all devices. They automatically adapt to light and dark themes, and integrate seamlessly with MCP tools for backend communication.</p>
`;

// Related posts for PostDetail
export const demoRelatedPosts: Post[] = [
  {
    title: 'Designing for Conversational Interfaces',
    excerpt:
      'Best practices for creating intuitive UI components that work within chat environments.',
    coverImage: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=800',
    author: { name: 'Alex Rivera', avatar: 'https://i.pravatar.cc/150?u=alex' },
    publishedAt: '2024-01-12',
    readTime: '8 min read',
    tags: ['Design', 'UX'],
    category: 'Design',
    url: 'https://example.com/posts/designing-conversational-interfaces',
  },
  {
    title: 'MCP Integration Patterns',
    excerpt: 'How to leverage Model Context Protocol for seamless backend communication.',
    coverImage: 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=800',
    author: { name: 'Jordan Kim', avatar: 'https://i.pravatar.cc/150?u=jordan' },
    publishedAt: '2024-01-10',
    readTime: '12 min read',
    tags: ['MCP', 'Backend'],
    category: 'Development',
    url: 'https://example.com/posts/mcp-integration-patterns',
  },
];

// Full PostDetail demo data (combines post, content, and relatedPosts)
export const demoPostDetailData = {
  post: {
    ...demoPost,
    tags: ['Tutorial', 'Components', 'AI', 'React', 'TypeScript'],
  },
  content: demoPostContent,
  relatedPosts: demoRelatedPosts,
};

// 15 posts for PostList default
export const demoPosts: Post[] = [
  {
    title: 'Getting Started with Agentic UI Components',
    excerpt:
      'Learn how to build conversational interfaces with our comprehensive component library designed for AI-powered applications.',
    coverImage: 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=800',
    author: {
      name: 'Sarah Chen',
      avatar: 'https://i.pravatar.cc/150?u=sarah',
    },
    publishedAt: '2024-01-15',
    readTime: '5 min read',
    tags: ['Tutorial', 'Components', 'AI'],
    category: 'Tutorial',
  },
  {
    title: 'Designing for Conversational Interfaces with Manifest UI',
    excerpt:
      'Best practices for creating intuitive UI components that work within chat environments.',
    coverImage: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=800',
    author: {
      name: 'Alex Rivera',
      avatar: 'https://i.pravatar.cc/150?u=alex',
    },
    publishedAt: '2024-01-12',
    readTime: '8 min read',
    tags: ['Design', 'UX'],
    category: 'Design',
  },
  {
    title: 'MCP Integration Patterns',
    excerpt:
      'How to leverage Model Context Protocol for seamless backend communication in your agentic applications.',
    coverImage: 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=800',
    author: {
      name: 'Jordan Kim',
      avatar: 'https://i.pravatar.cc/150?u=jordan',
    },
    publishedAt: '2024-01-10',
    readTime: '12 min read',
    tags: ['MCP', 'Backend', 'Integration'],
    category: 'Development',
  },
  {
    title: 'Building Payment Flows in Chat',
    excerpt:
      'A complete guide to implementing secure, user-friendly payment experiences within conversational interfaces.',
    coverImage: 'https://images.unsplash.com/photo-1472214103451-9374bd1c798e?w=800',
    author: {
      name: 'Morgan Lee',
      avatar: 'https://i.pravatar.cc/150?u=morgan',
    },
    publishedAt: '2024-01-08',
    readTime: '10 min read',
    tags: ['Payments', 'Security'],
    category: 'Tutorial',
  },
  {
    title: 'Real-time Collaboration in AI Apps',
    excerpt:
      'Implementing WebSocket connections and real-time updates for collaborative agentic experiences.',
    coverImage: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800',
    author: {
      name: 'Casey Taylor',
      avatar: 'https://i.pravatar.cc/150?u=casey',
    },
    publishedAt: '2024-01-06',
    readTime: '15 min read',
    tags: ['WebSocket', 'Real-time', 'Collaboration'],
    category: 'Development',
  },
  {
    title: 'Accessibility in Chat Interfaces',
    excerpt:
      'Making your conversational UI accessible to all users with screen readers and keyboard navigation.',
    coverImage: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=800',
    author: {
      name: 'Jamie Park',
      avatar: 'https://i.pravatar.cc/150?u=jamie',
    },
    publishedAt: '2024-01-04',
    readTime: '9 min read',
    tags: ['Accessibility', 'A11y', 'UX'],
    category: 'Design',
  },
  {
    title: 'State Management for Complex Workflows',
    excerpt:
      'Managing complex multi-step workflows in agentic applications using modern state patterns.',
    coverImage: 'https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=800',
    author: {
      name: 'Drew Martinez',
      avatar: 'https://i.pravatar.cc/150?u=drew',
    },
    publishedAt: '2024-01-02',
    readTime: '11 min read',
    tags: ['State', 'Workflow', 'Architecture'],
    category: 'Development',
  },
  {
    title: 'Testing Conversational Components',
    excerpt: 'Strategies for unit testing and integration testing of chat-based UI components.',
    coverImage: 'https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=800',
    author: {
      name: 'Riley Johnson',
      avatar: 'https://i.pravatar.cc/150?u=riley',
    },
    publishedAt: '2023-12-30',
    readTime: '8 min read',
    tags: ['Testing', 'Quality', 'CI/CD'],
    category: 'Development',
  },
  {
    title: 'Theming and Dark Mode Support',
    excerpt: 'Implementing flexible theming systems with dark mode for agentic UI components.',
    coverImage: 'https://images.unsplash.com/photo-1475924156734-496f6cac6ec1?w=800',
    author: {
      name: 'Avery Williams',
      avatar: 'https://i.pravatar.cc/150?u=avery',
    },
    publishedAt: '2023-12-28',
    readTime: '7 min read',
    tags: ['Theming', 'Dark Mode', 'CSS'],
    category: 'Design',
  },
  {
    title: 'Performance Optimization Techniques',
    excerpt: 'Optimizing render performance and reducing bundle size in chat applications.',
    coverImage: 'https://images.unsplash.com/photo-1518173946687-a2e8a36af77a?w=800',
    author: {
      name: 'Quinn Anderson',
      avatar: 'https://i.pravatar.cc/150?u=quinn',
    },
    publishedAt: '2023-12-25',
    readTime: '13 min read',
    tags: ['Performance', 'Optimization', 'React'],
    category: 'Development',
  },
  {
    title: 'Error Handling and Recovery',
    excerpt:
      'Graceful error handling patterns and user-friendly recovery flows in conversational UIs.',
    coverImage: 'https://images.unsplash.com/photo-1509316975850-ff9c5deb0cd9?w=800',
    author: {
      name: 'Sage Thompson',
      avatar: 'https://i.pravatar.cc/150?u=sage',
    },
    publishedAt: '2023-12-22',
    readTime: '10 min read',
    tags: ['Error Handling', 'UX', 'Resilience'],
    category: 'Development',
  },
  {
    title: 'Internationalization Best Practices',
    excerpt: 'Making your agentic UI components work across languages and locales.',
    coverImage: 'https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=800',
    author: {
      name: 'Blake Garcia',
      avatar: 'https://i.pravatar.cc/150?u=blake',
    },
    publishedAt: '2023-12-20',
    readTime: '9 min read',
    tags: ['i18n', 'Localization', 'Global'],
    category: 'Design',
  },
  {
    title: 'Mobile-First Chat Design',
    excerpt: 'Designing conversational interfaces that work beautifully on mobile devices.',
    coverImage: 'https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=800',
    author: {
      name: 'Charlie Brown',
      avatar: 'https://i.pravatar.cc/150?u=charlie',
    },
    publishedAt: '2023-12-18',
    readTime: '8 min read',
    tags: ['Mobile', 'Responsive', 'Design'],
    category: 'Design',
  },
  {
    title: 'Analytics and User Insights',
    excerpt: 'Tracking user interactions and deriving insights from conversational UI usage.',
    coverImage: 'https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?w=800',
    author: {
      name: 'Sydney Chen',
      avatar: 'https://i.pravatar.cc/150?u=sydney',
    },
    publishedAt: '2023-12-15',
    readTime: '11 min read',
    tags: ['Analytics', 'Insights', 'Data'],
    category: 'Tutorial',
  },
  {
    title: 'Building Reusable Component Libraries',
    excerpt: 'Creating a scalable component library for agentic UIs that teams can share.',
    coverImage: 'https://images.unsplash.com/photo-1465056836900-8f1e940f2114?w=800',
    author: {
      name: 'Taylor Swift',
      avatar: 'https://i.pravatar.cc/150?u=taylor',
    },
    publishedAt: '2023-12-12',
    readTime: '14 min read',
    tags: ['Components', 'Library', 'Scalability'],
    category: 'Development',
  },
];
```
