{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "social-post-shadcn",
  "title": "Social Post (shadcn-native)",
  "author": "Lloyd Humphreys",
  "description": "EXPERIMENTAL — the API is still settling and will change in breaking ways; pin what you install. The platform-neutral social post embed card composed shadcn-natively: your app's actual <Avatar>/<AvatarFallback> (installed as a registry dependency) for the avatar-with-fallback, Tailwind theme tokens for every color, lucide icons (BadgeCheckIcon for the neutral verified badge, ArrowUpRightIcon on the 'Source' link, UserRoundIcon as the avatar fallback), cn. Same data model, outline/filled variants, and entity tinting (inert @mention/#hashtag/URL spans, never anchors) as social-post — a compound SocialPost / SocialPostHeader / SocialPostContent / SocialPostMedia / SocialPostFooter composition, with SocialPost itself doubling as the data-driven entry point. Self-contained in one file (the entity detection is re-typed inline, not imported).",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "avatar"
  ],
  "files": [
    {
      "path": "registry/social-post/social-post-shadcn.tsx",
      "content": "// social-post-shadcn — the platform-neutral social post embed card, composed\n// shadcn-natively.\n//\n// EXPERIMENTAL — the API is still settling and will change in breaking ways; pin what you\n// install.\n//\n// Same model as the vanilla `social-post` (an embedded-tweet-shaped quotation card with\n// no platform branding: every field passed in as data, nothing fetched), but built from\n// your app's actual pieces: your `<Avatar>` (installed as a registry dependency) for the\n// avatar-with-fallback, Tailwind theme tokens for every color, lucide icons for the\n// verified badge / footer arrow / avatar silhouette. Inside a shadcn app it matches your\n// theme untouched.\n//\n//   <SocialPost name=\"Ada\" handle=\"ada\" content=\"…\" link=\"https://…\" />\n//\n// `SocialPost` is both the root part and the data-driven entry point — it composes\n// SocialPostHeader / SocialPostContent / SocialPostMedia / SocialPostFooter internally.\n// Those parts are exported too: to rearrange the anatomy, compose them yourself inside\n// your own <article>.\n//\n// The footer link is deliberately the only interactive element — the card is a\n// quotation, not a button, so its text stays selectable. @mentions, #hashtags, and URLs\n// in the content are tinted as inert spans, never anchors. The verified badge keeps\n// --foreground color on purpose: the shape says \"verified\", the neutral color keeps it\n// from reading as any platform's brand check.\n//\n// Self-contained on purpose: the entity detection is inlined rather than imported from\n// the vanilla core, so this file installs alone. See social-post.ts for the annotated\n// reference implementation — the segmentation semantics here are identical.\n\n'use client'\n\nimport { useMemo } from 'react'\nimport type { ComponentProps } from 'react'\nimport { ArrowUpRightIcon, BadgeCheckIcon, UserRoundIcon } from 'lucide-react'\nimport { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'\nimport { cn } from '@/lib/utils'\n\n// ── Entities ───────────────────────────────────────────────────────────────────────────\n\ntype SocialPostEntityKind = 'mention' | 'hashtag' | 'url'\n\ninterface SocialPostSegment {\n  /** null for plain text between entities. */\n  kind: SocialPostEntityKind | null\n  text: string\n}\n\n// Alternation order matters: at any position the URL branch wins, so a URL's own\n// #fragment or ?q=@x is consumed whole and never re-matched as a hashtag/mention. The\n// lookbehinds require a non-word boundary, which is what keeps bob@x.com from tagging @x.\n// Only explicit http(s):// URLs are detected (no bare domains); mentions accept fediverse\n// form (@user@instance.tld); unicode hashtags work. Single bounded scan per alternative —\n// no backtracking blow-up.\nconst ENTITY_RE =\n  /(https?:\\/\\/[^\\s<>\"']+)|(?<![\\w@])(@[A-Za-z0-9_]{1,30}(?:@[A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*)?)|(?<![\\w#])(#[\\p{L}\\p{N}_]+)/gu\n\n/** Strip sentence punctuation a URL match swept in, plus a closing paren with no opening\n *  partner inside the match. */\nfunction trimTrailingPunctuation(url: string): string {\n  for (;;) {\n    if (/[.,!?;:'\"…]$/.test(url)) {\n      url = url.slice(0, -1)\n      continue\n    }\n    if (url.endsWith(')')) {\n      const opens = (url.match(/\\(/g) ?? []).length\n      const closes = (url.match(/\\)/g) ?? []).length\n      if (closes > opens) {\n        url = url.slice(0, -1)\n        continue\n      }\n    }\n    return url\n  }\n}\n\n/** Split post text into plain runs and mention/hashtag/URL entities, in order. */\nfunction splitContentEntities(content: string): SocialPostSegment[] {\n  const segments: SocialPostSegment[] = []\n  let last = 0\n  for (const m of content.matchAll(ENTITY_RE)) {\n    const start = m.index\n    let text = m[0]\n    const kind: SocialPostEntityKind = m[1] ? 'url' : m[2] ? 'mention' : 'hashtag'\n    if (kind === 'url') text = trimTrailingPunctuation(text)\n    if (start > last) segments.push({ kind: null, text: content.slice(last, start) })\n    segments.push({ kind, text })\n    // The scan resumes after the *untrimmed* match, so a trimmed URL tail lands in the\n    // next plain segment naturally.\n    last = start + text.length\n  }\n  if (last < content.length) segments.push({ kind: null, text: content.slice(last) })\n  return segments\n}\n\n// ── Card ───────────────────────────────────────────────────────────────────────────────\n\ninterface SocialPostLabels {\n  /** Accessible name of the card. Default `Post by ${name} (@${handle})`. */\n  root?: string\n  /** Footer link text. Default 'Source'. */\n  source?: string\n  /** Accessible label of the verified badge. Default 'Verified'. */\n  verified?: string\n}\n\nconst stripAt = (handle: string) => handle.replace(/^@/, '')\n\nfunction normalizeImages(images: string[] | undefined): string[] {\n  if (!images) return []\n  if (images.length <= 4) return images\n  console.warn('social-post: more than 4 images — rendering the first 4.')\n  return images.slice(0, 4)\n}\n\ninterface SocialPostProps extends Omit<ComponentProps<'article'>, 'content' | 'children'> {\n  /** Display name, e.g. 'Ada Lovelace'. */\n  name: string\n  /** Bare handle without the leading '@' — the card prepends it. */\n  handle: string\n  /** The post text. Line breaks are preserved; entities are tinted as inert spans. */\n  content: string\n  /** Avatar image URL. Omitted or dead, the silhouette fallback shows instead. */\n  avatarUrl?: string\n  /** 0–4 image URLs for the media grid; more than 4 are truncated with a console.warn. */\n  images?: string[]\n  /** Href of the original post — the footer's \"View original ↗\", the card's only link. */\n  link: string\n  /** Preformatted display string — rendered verbatim, never parsed. */\n  date?: string\n  /** Show the neutral-colored check badge after the name. */\n  verified?: boolean\n  /** 'outline' (default): a bordered card on bg-card. 'filled': the same geometry on a\n   *  borderless bg-muted fill — the border stays transparent rather than removed, so\n   *  nothing shifts by a pixel when variants mix. */\n  variant?: 'outline' | 'filled'\n  labels?: SocialPostLabels\n}\n\n/** The card: header, tinted content, media grid, footer — all from the data props. Also\n *  the root part for manual composition (or roll your own <article> from the parts). */\nfunction SocialPost({\n  name,\n  handle,\n  content,\n  avatarUrl,\n  images,\n  link,\n  date,\n  verified = false,\n  variant = 'outline',\n  labels,\n  className,\n  ...props\n}: SocialPostProps) {\n  return (\n    <article\n      data-slot=\"social-post\"\n      data-variant={variant}\n      aria-label={labels?.root ?? `Post by ${name} (@${stripAt(handle)})`}\n      className={cn(\n        'group/social-post flex w-full flex-col gap-3 rounded-xl border p-4 text-card-foreground',\n        variant === 'filled' ? 'border-transparent bg-muted' : 'bg-card',\n        className,\n      )}\n      {...props}\n    >\n      <SocialPostHeader\n        name={name}\n        handle={handle}\n        avatarUrl={avatarUrl}\n        verified={verified}\n        verifiedLabel={labels?.verified}\n      />\n      <SocialPostContent content={content} />\n      <SocialPostMedia images={images} />\n      <SocialPostFooter date={date} link={link} sourceLabel={labels?.source} />\n    </article>\n  )\n}\n\ninterface SocialPostHeaderProps extends Omit<ComponentProps<'header'>, 'children'> {\n  name: string\n  handle: string\n  avatarUrl?: string\n  verified?: boolean\n  verifiedLabel?: string\n}\n\nfunction SocialPostHeader({\n  name,\n  handle,\n  avatarUrl,\n  verified = false,\n  verifiedLabel,\n  className,\n  ...props\n}: SocialPostHeaderProps) {\n  return (\n    <header data-slot=\"social-post-header\" className={cn('flex min-w-0 items-center gap-2.5', className)} {...props}>\n      {/* Decorative — the name and handle beside it carry the identity as text. Radix's\n          load-status state machine is exactly the \"missing or dead URL → fallback\"\n          behavior the vanilla core hand-rolls. */}\n      <Avatar aria-hidden=\"true\" className=\"size-10\">\n        {avatarUrl ? <AvatarImage src={avatarUrl} alt=\"\" /> : null}\n        <AvatarFallback className=\"bg-muted text-muted-foreground\">\n          <UserRoundIcon className=\"size-5\" />\n        </AvatarFallback>\n      </Avatar>\n      <span data-slot=\"social-post-identity\" className=\"flex min-w-0 flex-col\">\n        <span data-slot=\"social-post-name-row\" className=\"flex min-w-0 items-center gap-1\">\n          <span data-slot=\"social-post-name\" title={name} className=\"truncate text-sm font-semibold text-foreground\">\n            {name}\n          </span>\n          {verified ? (\n            <span data-slot=\"social-post-verified\" className=\"inline-flex flex-none items-center text-foreground\">\n              <BadgeCheckIcon aria-hidden=\"true\" className=\"size-4\" />\n              <span className=\"sr-only\">{verifiedLabel ?? 'Verified'}</span>\n            </span>\n          ) : null}\n        </span>\n        <span data-slot=\"social-post-handle\" className=\"truncate text-[13px] leading-snug text-muted-foreground\">\n          @{stripAt(handle)}\n        </span>\n      </span>\n    </header>\n  )\n}\n\ninterface SocialPostContentProps extends Omit<ComponentProps<'p'>, 'content' | 'children'> {\n  content: string\n}\n\nfunction SocialPostContent({ content, className, ...props }: SocialPostContentProps) {\n  const segments = useMemo(() => splitContentEntities(content), [content])\n  return (\n    <p\n      data-slot=\"social-post-content\"\n      className={cn('whitespace-pre-wrap break-words text-[15px] leading-normal', className)}\n      {...props}\n    >\n      {segments.map((seg, i) =>\n        seg.kind ? (\n          // Tint plus a slight weight bump: in themes where --primary sits near\n          // --foreground (default zinc), color alone wouldn't read. Inert on purpose —\n          // no underline, no cursor, never an anchor.\n          <span key={i} data-slot=\"social-post-entity\" data-entity={seg.kind} className=\"font-medium text-primary\">\n            {seg.text}\n          </span>\n        ) : (\n          seg.text\n        ),\n      )}\n    </p>\n  )\n}\n\ninterface SocialPostMediaProps extends Omit<ComponentProps<'div'>, 'children'> {\n  /** 0–4 image URLs; more than 4 are truncated with a console.warn. Renders nothing\n   *  when empty. */\n  images?: string[]\n}\n\n/** One fixed 16:9 frame regardless of image count — only the internal grid template\n *  varies (1 full, 2 columns, 3 tall + stacked, 4 in a 2×2), so every card with media\n *  keeps identical proportions. The container's bg-border paints the seams. */\nfunction SocialPostMedia({ images, className, ...props }: SocialPostMediaProps) {\n  const shown = normalizeImages(images)\n  if (shown.length === 0) return null\n  return (\n    <div\n      data-slot=\"social-post-media\"\n      data-count={shown.length}\n      className={cn(\n        'grid aspect-video gap-0.5 overflow-hidden rounded-lg bg-border',\n        shown.length >= 2 && 'grid-cols-2',\n        shown.length >= 3 && 'grid-rows-2',\n        className,\n      )}\n      {...props}\n    >\n      {shown.map((src, i) => (\n        <img\n          key={i}\n          data-slot=\"social-post-media-item\"\n          src={src}\n          alt=\"\"\n          loading=\"lazy\"\n          referrerPolicy=\"no-referrer\"\n          className={cn('size-full min-h-0 object-cover', shown.length === 3 && i === 0 && 'row-span-2')}\n        />\n      ))}\n    </div>\n  )\n}\n\ninterface SocialPostFooterProps extends Omit<ComponentProps<'footer'>, 'children'> {\n  /** Preformatted display string — rendered verbatim, never parsed. */\n  date?: string\n  link: string\n  sourceLabel?: string\n}\n\nfunction SocialPostFooter({ date, link, sourceLabel, className, ...props }: SocialPostFooterProps) {\n  return (\n    <footer\n      data-slot=\"social-post-footer\"\n      // -mx-4/-mb-4 mirror the card's p-4: the hairline runs full-bleed, and the footer\n      // swallows the card's bottom padding so its text sits vertically centered (py-2.5\n      // off the hairline and off the bottom edge alike). On the filled card there's no\n      // visible border to echo, so the hairline takes the token at full strength.\n      className={cn(\n        '-mx-4 -mb-4 flex items-center gap-2.5 border-t border-border/60 px-4 py-2.5 text-[13px] text-muted-foreground',\n        'group-data-[variant=filled]/social-post:border-border',\n        className,\n      )}\n      {...props}\n    >\n      {date ? (\n        <span data-slot=\"social-post-date\" className=\"truncate\">\n          {date}\n        </span>\n      ) : null}\n      <a\n        data-slot=\"social-post-link\"\n        href={link}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className={cn(\n          'ms-auto inline-flex flex-none items-center gap-1 rounded-sm font-medium outline-none',\n          'transition-colors hover:text-foreground motion-reduce:transition-none',\n          'focus-visible:ring-[3px] focus-visible:ring-ring/50',\n        )}\n      >\n        {sourceLabel ?? 'Source'}\n        <ArrowUpRightIcon aria-hidden=\"true\" className=\"size-3.5\" />\n      </a>\n    </footer>\n  )\n}\n\nexport {\n  type SocialPostEntityKind,\n  type SocialPostSegment,\n  splitContentEntities,\n  type SocialPostLabels,\n  type SocialPostProps,\n  SocialPost,\n  type SocialPostHeaderProps,\n  SocialPostHeader,\n  type SocialPostContentProps,\n  SocialPostContent,\n  type SocialPostMediaProps,\n  SocialPostMedia,\n  type SocialPostFooterProps,\n  SocialPostFooter,\n}\n",
      "type": "registry:component",
      "target": "components/social-post/social-post-shadcn.tsx"
    }
  ],
  "type": "registry:component"
}