{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "linkedin-post",
  "type": "registry:block",
  "title": "Linkedin Post",
  "description": "Render LinkedIn post previews.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "button",
    "tooltip"
  ],
  "files": [
    {
      "path": "components/tool-ui/linkedin-post/_adapter.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/linkedin-post/_adapter.tsx",
      "content": "/**\n * Adapter: UI and utility re-exports for copy-standalone portability.\n *\n * When copying this component to another project, update these imports\n * to match your project's paths:\n *\n *   cn      → Your Tailwind merge utility (e.g., \"@/lib/utils\", \"~/lib/cn\")\n *   Button  → shadcn/ui Button\n *   Tooltip → shadcn/ui Tooltip\n */\n\nexport { cn } from \"@/lib/utils\";\nexport { Button } from \"@/components/ui/button\";\nexport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\n"
    },
    {
      "path": "components/tool-ui/linkedin-post/index.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/linkedin-post/index.ts",
      "content": "export { LinkedInPost } from \"./linkedin-post\";\nexport type { LinkedInPostProps } from \"./linkedin-post\";\nexport type {\n  LinkedInPostData,\n  LinkedInPostAuthor,\n  LinkedInPostMedia,\n  LinkedInPostLinkPreview,\n  LinkedInPostStats,\n} from \"./schema\";\n"
    },
    {
      "path": "components/tool-ui/linkedin-post/linkedin-post.tsx",
      "type": "registry:component",
      "target": "components/tool-ui/linkedin-post/linkedin-post.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ThumbsUp, Share } from \"lucide-react\";\nimport {\n  cn,\n  Button,\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"./_adapter\";\nimport { formatCount, formatRelativeTime, getDomain } from \"../shared/utils\";\n\nimport { resolveSafeNavigationHref } from \"../shared/media\";\nimport type {\n  LinkedInPostData,\n  LinkedInPostMedia,\n  LinkedInPostLinkPreview,\n} from \"./schema\";\n\nconst TEXT_PREVIEW_LENGTH = 280;\n\nexport interface LinkedInPostProps {\n  post: LinkedInPostData;\n  className?: string;\n  onAction?: (action: string, post: LinkedInPostData) => void;\n}\n\nfunction LinkedInLogo({ className }: { className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 72 72\"\n      className={className}\n      role=\"img\"\n      aria-label=\"LinkedIn logo\"\n    >\n      <g fill=\"none\" fillRule=\"evenodd\">\n        <path\n          d=\"M8 72h56c4.42 0 8-3.58 8-8V8c0-4.42-3.58-8-8-8H8C3.58 0 0 3.58 0 8v56c0 4.42 3.58 8 8 8z\"\n          fill=\"currentColor\"\n        />\n        <path\n          d=\"M62 62H51.3V43.8c0-4.98-1.9-7.78-5.83-7.78-4.3 0-6.54 2.9-6.54 7.78V62H28.63V27.33h10.3v4.67c0 0 3.1-5.73 10.45-5.73 7.36 0 12.62 4.5 12.62 13.8V62zM16.35 22.8c-3.5 0-6.35-2.86-6.35-6.4 0-3.52 2.85-6.4 6.35-6.4 3.5 0 6.35 2.88 6.35 6.4 0 3.54-2.85 6.4-6.35 6.4zM11.03 62h10.74V27.33H11.03V62z\"\n          fill=\"#FFF\"\n        />\n      </g>\n    </svg>\n  );\n}\n\nfunction Header({\n  author,\n  createdAt,\n}: {\n  author: LinkedInPostData[\"author\"];\n  createdAt?: string;\n}) {\n  return (\n    <header className=\"flex items-start gap-3\">\n      <img\n        src={author.avatarUrl}\n        alt={`${author.name} avatar`}\n        width={48}\n        height={48}\n        className=\"size-12 rounded-full object-cover\"\n      />\n      <div className=\"flex min-w-0 flex-1 flex-col leading-tight\">\n        <span className=\"text-sm font-semibold\">{author.name}</span>\n        {author.headline && (\n          <span className=\"text-muted-foreground line-clamp-1 text-xs\">\n            {author.headline}\n          </span>\n        )}\n        {createdAt && (\n          <div className=\"text-muted-foreground mt-0.5 flex items-center gap-1 text-xs\">\n            <span>{formatRelativeTime(createdAt)}</span>\n            <span>·</span>\n            <span>Edited</span>\n          </div>\n        )}\n      </div>\n      <LinkedInLogo className=\"size-5 text-[#0077b5]\" />\n    </header>\n  );\n}\n\nfunction PostBody({ text }: { text?: string }) {\n  const [isExpanded, setIsExpanded] = React.useState(false);\n  const shouldTruncate = text && text.length > TEXT_PREVIEW_LENGTH;\n\n  if (!text) return null;\n\n  return (\n    <div className=\"text-sm leading-relaxed text-pretty wrap-break-word whitespace-pre-wrap\">\n      {shouldTruncate && !isExpanded ? (\n        <>\n          {text.slice(0, TEXT_PREVIEW_LENGTH)}\n          ...\n          <button\n            onClick={() => setIsExpanded(true)}\n            className=\"text-muted-foreground hover:text-foreground ml-1 font-medium hover:underline\"\n          >\n            see more\n          </button>\n        </>\n      ) : (\n        text\n      )}\n    </div>\n  );\n}\n\nfunction PostMedia({ media }: { media: LinkedInPostMedia }) {\n  return (\n    <div className=\"overflow-hidden rounded-lg\">\n      {media.type === \"image\" ? (\n        <img\n          src={media.url}\n          alt={media.alt}\n          className=\"w-full object-cover\"\n          style={{ aspectRatio: \"16/9\" }}\n          loading=\"lazy\"\n        />\n      ) : (\n        <video\n          src={media.url}\n          controls\n          playsInline\n          className=\"w-full object-contain\"\n          style={{ aspectRatio: \"16/9\" }}\n        />\n      )}\n    </div>\n  );\n}\n\nfunction PostLinkPreview({ preview }: { preview: LinkedInPostLinkPreview }) {\n  const href = resolveSafeNavigationHref(preview.url);\n  const domain = preview.domain ?? getDomain(preview.url);\n  const content = (\n    <>\n      {preview.imageUrl && (\n        <img\n          src={preview.imageUrl}\n          alt=\"\"\n          className=\"h-40 w-full object-cover\"\n          loading=\"lazy\"\n        />\n      )}\n      <div className=\"p-3\">\n        {preview.title && (\n          <div className=\"line-clamp-2 font-medium text-pretty\">\n            {preview.title}\n          </div>\n        )}\n        {domain && (\n          <div className=\"text-muted-foreground mt-1 text-xs\">{domain}</div>\n        )}\n      </div>\n    </>\n  );\n\n  if (!href) {\n    return (\n      <div className=\"block overflow-hidden rounded-lg border\">{content}</div>\n    );\n  }\n\n  return (\n    <a\n      href={href}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className=\"hover:bg-muted/50 block overflow-hidden rounded-lg border transition-colors\"\n    >\n      {content}\n    </a>\n  );\n}\n\nfunction ActionButton({\n  icon: Icon,\n  label,\n  count,\n  active,\n  hoverColor,\n  activeColor,\n  onClick,\n}: {\n  icon: React.ComponentType<{ className?: string }>;\n  label: string;\n  count?: number;\n  active?: boolean;\n  hoverColor: string;\n  activeColor?: string;\n  onClick: () => void;\n}) {\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={(e) => {\n            e.stopPropagation();\n            onClick();\n          }}\n          className={cn(\n            \"h-auto gap-1.5 px-3 py-2\",\n            hoverColor,\n            active && activeColor,\n          )}\n          aria-label={label}\n        >\n          <Icon className=\"size-4\" />\n          <span className=\"text-xs font-medium\">{label}</span>\n          {count !== undefined && (\n            <span className=\"text-muted-foreground text-xs\">\n              ({formatCount(count)})\n            </span>\n          )}\n        </Button>\n      </TooltipTrigger>\n      <TooltipContent>{label}</TooltipContent>\n    </Tooltip>\n  );\n}\n\nfunction PostActions({\n  stats,\n  onAction,\n}: {\n  stats?: LinkedInPostData[\"stats\"];\n  onAction: (action: string) => void;\n}) {\n  return (\n    <TooltipProvider delayDuration={300}>\n      <div className=\"mt-1 flex items-center gap-1 border-t pt-1.5\">\n        <ActionButton\n          icon={ThumbsUp}\n          label=\"Like\"\n          active={stats?.isLiked}\n          hoverColor=\"hover:bg-muted\"\n          activeColor=\"text-blue-600 fill-blue-600\"\n          onClick={() => onAction(\"like\")}\n        />\n        <ActionButton\n          icon={Share}\n          label=\"Share\"\n          hoverColor=\"hover:bg-muted\"\n          onClick={() => onAction(\"share\")}\n        />\n      </div>\n    </TooltipProvider>\n  );\n}\n\nexport function LinkedInPost({ post, className, onAction }: LinkedInPostProps) {\n  return (\n    <div\n      className={cn(\"flex max-w-xl flex-col gap-3\", className)}\n      data-tool-ui-id={post.id}\n      data-slot=\"linkedin-post\"\n    >\n      <article className=\"bg-card flex flex-col gap-3 rounded-lg border p-3 shadow-sm\">\n        <Header author={post.author} createdAt={post.createdAt} />\n        <PostBody text={post.text} />\n\n        {post.media && <PostMedia media={post.media} />}\n\n        {post.linkPreview && !post.media && (\n          <PostLinkPreview preview={post.linkPreview} />\n        )}\n\n        <PostActions\n          stats={post.stats}\n          onAction={(action) => onAction?.(action, post)}\n        />\n      </article>\n    </div>\n  );\n}\n"
    },
    {
      "path": "components/tool-ui/linkedin-post/README.md",
      "type": "registry:file",
      "target": "components/tool-ui/linkedin-post/README.md",
      "content": "# Linkedin Post\n\nImplementation for the \"linkedin-post\" Tool UI surface.\n\n## Files\n\n- public exports: components/tool-ui/linkedin-post/index.ts\n- serializable schema + parse helpers: components/tool-ui/linkedin-post/schema.ts\n\n## Companion assets\n\n- Docs page: app/docs/social-post/content.mdx\n- Preset payload: lib/presets/linkedin-post.ts\n\n## Quick check\n\nRun this after edits:\n\npnpm test\n"
    },
    {
      "path": "components/tool-ui/linkedin-post/schema.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/linkedin-post/schema.ts",
      "content": "import { z } from \"zod\";\nimport { defineToolUiContract } from \"../shared/contract\";\n\nexport const LinkedInPostAuthorSchema = z.object({\n  name: z.string(),\n  avatarUrl: z.string(),\n  headline: z.string().optional(),\n});\n\nexport const LinkedInPostMediaSchema = z.object({\n  type: z.enum([\"image\", \"video\"]),\n  url: z.string(),\n  alt: z.string(),\n});\n\nexport const LinkedInPostLinkPreviewSchema = z.object({\n  url: z.string(),\n  title: z.string().optional(),\n  description: z.string().optional(),\n  imageUrl: z.string().optional(),\n  domain: z.string().optional(),\n});\n\nexport const LinkedInPostStatsSchema = z.object({\n  likes: z.number().optional(),\n  isLiked: z.boolean().optional(),\n});\n\nexport const SerializableLinkedInPostSchema = z.object({\n  id: z.string(),\n  author: LinkedInPostAuthorSchema,\n  text: z.string().optional(),\n  media: LinkedInPostMediaSchema.optional(),\n  linkPreview: LinkedInPostLinkPreviewSchema.optional(),\n  stats: LinkedInPostStatsSchema.optional(),\n  createdAt: z.string().optional(),\n});\n\nexport type LinkedInPostData = z.infer<typeof SerializableLinkedInPostSchema>;\n\nexport type LinkedInPostAuthor = z.infer<typeof LinkedInPostAuthorSchema>;\nexport type LinkedInPostMedia = z.infer<typeof LinkedInPostMediaSchema>;\nexport type LinkedInPostLinkPreview = z.infer<\n  typeof LinkedInPostLinkPreviewSchema\n>;\nexport type LinkedInPostStats = z.infer<typeof LinkedInPostStatsSchema>;\n\nconst SerializableLinkedInPostSchemaContract = defineToolUiContract(\n  \"LinkedInPost\",\n  SerializableLinkedInPostSchema,\n);\n\nexport const parseSerializableLinkedInPost: (\n  input: unknown,\n) => LinkedInPostData = SerializableLinkedInPostSchemaContract.parse;\n\nexport const safeParseSerializableLinkedInPost: (\n  input: unknown,\n) => LinkedInPostData | null = SerializableLinkedInPostSchemaContract.safeParse;\n"
    },
    {
      "path": "components/tool-ui/shared/contract.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/contract.ts",
      "content": "import { z } from \"zod\";\nimport { parseWithSchema, safeParseWithSchema } from \"./parse\";\n\nexport interface ToolUiContract<T> {\n  schema: z.ZodType<T>;\n  parse: (input: unknown) => T;\n  safeParse: (input: unknown) => T | null;\n}\n\nexport function defineToolUiContract<T>(\n  componentName: string,\n  schema: z.ZodType<T>,\n): ToolUiContract<T> {\n  return {\n    schema,\n    parse: (input: unknown) => parseWithSchema(schema, input, componentName),\n    safeParse: (input: unknown) => safeParseWithSchema(schema, input),\n  };\n}\n"
    },
    {
      "path": "components/tool-ui/shared/media/aspect-ratio.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/aspect-ratio.ts",
      "content": "import { z } from \"zod\";\n\nexport const AspectRatioSchema = z\n  .enum([\"auto\", \"1:1\", \"4:3\", \"16:9\", \"9:16\"])\n  .default(\"auto\");\n\nexport type AspectRatio = z.infer<typeof AspectRatioSchema>;\n\nexport const MediaFitSchema = z.enum([\"cover\", \"contain\"]).default(\"cover\");\n\nexport type MediaFit = z.infer<typeof MediaFitSchema>;\n\nexport const RATIO_CLASS_MAP: Record<AspectRatio, string> = {\n  auto: \"\",\n  \"1:1\": \"aspect-square\",\n  \"4:3\": \"aspect-[4/3]\",\n  \"16:9\": \"aspect-video\",\n  \"9:16\": \"aspect-[9/16]\",\n};\n\nexport function getRatioClass(ratio: AspectRatio): string {\n  return RATIO_CLASS_MAP[ratio];\n}\n\nexport function getFitClass(fit: MediaFit): string {\n  return fit === \"cover\" ? \"object-cover\" : \"object-contain\";\n}\n"
    },
    {
      "path": "components/tool-ui/shared/media/format-utils.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/format-utils.ts",
      "content": "/**\n * Format duration in milliseconds to human-readable string.\n * @example formatDuration(128000) => \"2:08\"\n * @example formatDuration(3661000) => \"1:01:01\"\n */\nexport function formatDuration(durationMs: number): string {\n  const totalSeconds = Math.round(durationMs / 1000);\n  const hours = Math.floor(totalSeconds / 3600);\n  const minutes = Math.floor((totalSeconds % 3600) / 60);\n  const seconds = totalSeconds % 60;\n\n  if (hours > 0) {\n    return `${hours}:${minutes.toString().padStart(2, \"0\")}:${seconds\n      .toString()\n      .padStart(2, \"0\")}`;\n  }\n  return `${minutes}:${seconds.toString().padStart(2, \"0\")}`;\n}\n\n/**\n * Format file size in bytes to human-readable string.\n * @example formatFileSize(1024) => \"1 KB\"\n * @example formatFileSize(1536000) => \"1.5 MB\"\n */\nexport function formatFileSize(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  const units = [\"KB\", \"MB\", \"GB\"];\n  let size = bytes / 1024;\n  let unit = 0;\n  while (size >= 1024 && unit < units.length - 1) {\n    size /= 1024;\n    unit += 1;\n  }\n  return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/media/index.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/index.ts",
      "content": "export {\n  AspectRatioSchema,\n  MediaFitSchema,\n  RATIO_CLASS_MAP,\n  getRatioClass,\n  getFitClass,\n  type AspectRatio,\n  type MediaFit,\n} from \"./aspect-ratio\";\n\nexport { OVERLAY_GRADIENT } from \"./overlay-gradient\";\n\nexport { formatDuration, formatFileSize } from \"./format-utils\";\n\nexport { sanitizeHref } from \"./sanitize-href\";\nexport {\n  resolveSafeNavigationHref,\n  openSafeNavigationHref,\n} from \"./safe-navigation\";\n"
    },
    {
      "path": "components/tool-ui/shared/media/overlay-gradient.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/overlay-gradient.ts",
      "content": "/**\n * Eased gradient for hover overlays on media elements.\n * Creates a smooth fade from opaque black at top to transparent.\n *\n * @see https://larsenwork.com/easing-gradients/\n */\nexport const OVERLAY_GRADIENT = `linear-gradient(\n  to bottom,\n  hsl(0, 0%, 0%) 0%,\n  hsla(0, 0%, 0%, 0.987) 8.3%,\n  hsla(0, 0%, 0%, 0.951) 16.6%,\n  hsla(0, 0%, 0%, 0.896) 24.6%,\n  hsla(0, 0%, 0%, 0.825) 32.5%,\n  hsla(0, 0%, 0%, 0.741) 40.1%,\n  hsla(0, 0%, 0%, 0.648) 47.6%,\n  hsla(0, 0%, 0%, 0.55) 54.8%,\n  hsla(0, 0%, 0%, 0.45) 61.7%,\n  hsla(0, 0%, 0%, 0.352) 68.3%,\n  hsla(0, 0%, 0%, 0.259) 74.5%,\n  hsla(0, 0%, 0%, 0.175) 80.4%,\n  hsla(0, 0%, 0%, 0.104) 86%,\n  hsla(0, 0%, 0%, 0.049) 91.1%,\n  hsla(0, 0%, 0%, 0.013) 95.8%,\n  hsla(0, 0%, 0%, 0) 100%\n)` as const;\n"
    },
    {
      "path": "components/tool-ui/shared/media/safe-navigation.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/safe-navigation.ts",
      "content": "import { sanitizeHref } from \"./sanitize-href\";\n\nexport function resolveSafeNavigationHref(\n  ...candidates: Array<string | null | undefined>\n): string | undefined {\n  for (const candidate of candidates) {\n    const safeHref = sanitizeHref(candidate ?? undefined);\n    if (safeHref) {\n      return safeHref;\n    }\n  }\n\n  return undefined;\n}\n\nexport function openSafeNavigationHref(href: string | undefined): boolean {\n  if (!href || typeof window === \"undefined\") {\n    return false;\n  }\n\n  window.open(href, \"_blank\", \"noopener,noreferrer\");\n  return true;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/media/sanitize-href.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/media/sanitize-href.ts",
      "content": "/**\n * Sanitize a URL to ensure it's safe for use in href attributes.\n * Allows:\n * - Absolute http(s) URLs\n * - Relative URLs (/path, ./path, ../path, ?query, #hash)\n *\n * @returns The sanitized URL string, or undefined if invalid/unsafe\n */\nexport function sanitizeHref(href?: string): string | undefined {\n  if (!href) return undefined;\n  const candidate = href.trim();\n  if (!candidate) return undefined;\n\n  if (\n    candidate.startsWith(\"/\") ||\n    candidate.startsWith(\"./\") ||\n    candidate.startsWith(\"../\") ||\n    candidate.startsWith(\"?\") ||\n    candidate.startsWith(\"#\")\n  ) {\n    if (candidate.startsWith(\"//\")) return undefined;\n    // eslint-disable-next-line no-control-regex -- intentionally matching control characters\n    if (/[\\u0000-\\u001F\\u007F]/.test(candidate)) return undefined;\n    return candidate;\n  }\n\n  try {\n    const url = new URL(candidate);\n    if (url.protocol === \"http:\" || url.protocol === \"https:\") {\n      return url.toString();\n    }\n  } catch {\n    return undefined;\n  }\n  return undefined;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/parse.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/parse.ts",
      "content": "import { z } from \"zod\";\n\nfunction formatZodPath(path: Array<string | number | symbol>): string {\n  if (path.length === 0) return \"root\";\n  return path\n    .map((segment) =>\n      typeof segment === \"number\" ? `[${segment}]` : String(segment),\n    )\n    .join(\".\");\n}\n\n/**\n * Format Zod errors into a compact `path: message` string.\n */\nexport function formatZodError(error: z.ZodError): string {\n  const parts = error.issues.map((issue) => {\n    const path = formatZodPath(issue.path);\n    return `${path}: ${issue.message}`;\n  });\n\n  return Array.from(new Set(parts)).join(\"; \");\n}\n\n/**\n * Parse unknown input and throw a readable error.\n */\nexport function parseWithSchema<T>(\n  schema: z.ZodType<T>,\n  input: unknown,\n  name: string,\n): T {\n  const res = schema.safeParse(input);\n  if (!res.success) {\n    throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);\n  }\n  return res.data;\n}\n\n/**\n * Parse unknown input, returning `null` instead of throwing on failure.\n *\n * Use this in assistant-ui `render` functions where `args` stream in\n * incrementally and may be incomplete until the tool call finishes.\n */\nexport function safeParseWithSchema<T>(\n  schema: z.ZodType<T>,\n  input: unknown,\n): T | null {\n  const res = schema.safeParse(input);\n  return res.success ? res.data : null;\n}\n"
    },
    {
      "path": "components/tool-ui/shared/utils.ts",
      "type": "registry:lib",
      "target": "components/tool-ui/shared/utils.ts",
      "content": "export function formatRelativeTime(iso: string): string {\n  const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);\n  if (seconds < 60) return `${seconds}s`;\n  if (seconds < 3600) return `${Math.round(seconds / 60)}m`;\n  if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;\n  if (seconds < 604800) return `${Math.round(seconds / 86400)}d`;\n  return `${Math.round(seconds / 604800)}w`;\n}\n\nexport function formatCount(count: number): string {\n  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;\n  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;\n  return String(count);\n}\n\nexport function getDomain(url: string): string {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return \"\";\n  }\n}\n\nexport function prefersReducedMotion(): boolean {\n  return (\n    typeof window !== \"undefined\" &&\n    window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches\n  );\n}\n"
    }
  ]
}
