Skip to content
ilinxa/pro-ui

Post Card

alphav0.5.0

Social post composite in four layouts — text expansion, media carousel, engagement bar, and comment thread wired together.

Category: Data DisplayUpdated: 2026-08-17Created: 2026-05-02Author: ilinxa

Context

Sixth ship in the 8-component social-posts-system arc. Third cross-folder import precedent in pro-ui (after media-carousel → video-player and comment-thread → expandable-text + engagement-bar) — declares all five Tier-1 social-posts siblings as registryDependencies; consumer install auto-pulls the family. Migration origin: kasder kas-social-front-v0 AdvancedPostCard.tsx (167 LOC) + PostEngagementPanel.tsx inline-panel UX. Component owns a stateful local mirror initialized from `post` on mount (R-Plan-1) — engagement bar runs always-controlled with mirror values, optimistic onLike/onBookmark dispatches mirror first then fires host handler, realtime engagementSubscribe deltas patch mirror + fire onSubscribeEngagementDelta callback. Inline panels (engagementMode default 'inline'): tap heart toggles like, tap count opens likers strip; tap comment toggles inline CommentThread (with composer); tap share opens searchable user list. `engagementMode='navigate'` deactivates panels for hosts that prefer page navigation. Heart-burst heuristic auto-wires when `post.media?.length > 0` AND `onLike` AND `variant ∈ {feed, detail}` (disableHeartBurst opts out). `getHref` double-duty: overlay-link in non-detail variants + Copy-link kebab item. Detail variant embeds <CommentThread> always (ignores engagementMode) with auto-default + `renderCommentSection` slot for full takeover.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/post-card

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/post-card-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

IP

Ines Park

@ines · 12h

Studio process this week — three iterations on the same composition. Trying to figure out which one carries the message clearest.

Iteration A
Iteration B
Iteration C

Demo source

demo.tsxtsx
"use client"; import { useMemo, useRef, useState } from "react";import { Lock, Star, Trash2, Wand2 } from "lucide-react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { PostCard } from "./post-card";import { defaultPostEngagementActions } from "./lib/defaults";import {  DUMMY_DETAIL_THREAD,  DUMMY_FEATURED_POST,  DUMMY_LIKERS,  DUMMY_LINK_PREVIEW_POST,  DUMMY_LONG_TEXT_POST,  DUMMY_MIXED_MEDIA_POST,  DUMMY_MULTI_IMAGE_POST,  DUMMY_PINNED_POST,  DUMMY_POLL_CLOSED_POST,  DUMMY_POLL_POST,  DUMMY_POLL_VOTED_POST,  DUMMY_REPLY_POST,  DUMMY_REPOST_POST,  DUMMY_RICH_POST,  DUMMY_SENSITIVE_POST,  DUMMY_SINGLE_IMAGE_POST,  DUMMY_TEXT_ONLY_POST,  DUMMY_VIDEO_POST,  DUMMY_VIEWER,  createDummyCommentSubscribe,  createDummyEngagementSubscribe,  generateMoreLikers,} from "./dummy-data";import type { CommentMenuItem } from "@/registry/components/data/comment-thread";import type { EngagementAction } from "@/registry/components/data/engagement-bar"; function log(tag: string, payload: unknown) {  if (typeof console !== "undefined") {    console.log(`[demo:post-card:${tag}]`, payload);  }} function FeedTab() {  const likersPageRef = useRef(1);  return (    <div className="mx-auto max-w-xl">      <PostCard        variant="feed"        post={DUMMY_MULTI_IMAGE_POST}        currentUser={DUMMY_VIEWER}        likers={DUMMY_LIKERS}        commentThread={DUMMY_DETAIL_THREAD}        shareSuggestions={DUMMY_LIKERS}        onShareTo={(id, user) => log("share-to", { id, user })}        onLike={(id, liked) => log("like", { id, liked })}        onBookmark={(id, bookmarked) => log("bookmark", { id, bookmarked })}        onReport={(id) => log("report", id)}        onAddComment={async (content, parentId) => {          log("add-comment", { content, parentId });        }}        onLikeComment={(id, liked) => log("like-comment", { id, liked })}        onDeleteComment={(id) => log("delete-comment", id)}        onLoadMoreLikers={async () => {          await new Promise((r) => setTimeout(r, 300));          likersPageRef.current += 1;          return generateMoreLikers(likersPageRef.current);        }}        getHref={(p) => `/posts/${p.id}`}      />    </div>  );} function VideoTab() {  return (    <div className="mx-auto max-w-xl">      <PostCard        variant="feed"        post={DUMMY_VIDEO_POST}        currentUser={DUMMY_VIEWER}        likers={DUMMY_LIKERS}        commentThread={DUMMY_DETAIL_THREAD}        shareSuggestions={DUMMY_LIKERS}        onShareTo={(id, user) => log("share-to", { id, user })}        onLike={(id, liked) => log("like", { id, liked })}        onBookmark={(id, bookmarked) => log("bookmark", { id, bookmarked })}      />    </div>  );} function CompactTab() {  return (    <div className="mx-auto max-w-sm">      <PostCard        variant="compact"        post={DUMMY_SINGLE_IMAGE_POST}        onLike={(id, liked) => log("like", { id, liked })}        onComment={(id) => log("comment", id)}        getHref={(p) => `/posts/${p.id}`}      />    </div>  );} function ListTab() {  return (    <div className="flex flex-col gap-3">      <PostCard        variant="list"        post={DUMMY_FEATURED_POST}        kebabActions={(p) => [          { label: "Pin to top", onClick: () => log("pin", p.id) },          {            label: "Take down",            destructive: true,            onClick: () => log("takedown", p.id),          },        ]}        getHref={(p) => `/posts/${p.id}`}      />      <PostCard        variant="list"        post={DUMMY_TEXT_ONLY_POST}        kebabActions={(p) => [          { label: "Pin to top", onClick: () => log("pin", p.id) },          { label: "Block author", onClick: () => log("block", p.author.id) },        ]}      />    </div>  );} function DetailTab() {  // v0.2.0 — Detail tab demonstrates the full owner-side surface via  // viewerMode="owner" + all 8 mutation handlers wired. The kebab will  // render Edit / Pin / Change visibility / Mark sensitive / See analytics /  // Bookmark / Share / Copy link / [sep] / Delete.  return (    <div className="mx-auto max-w-2xl">      <PostCard        variant="detail"        post={DUMMY_PINNED_POST}        currentUser={DUMMY_VIEWER}        commentThread={DUMMY_DETAIL_THREAD}        viewerMode="owner"        onLike={(id, liked) => log("like", { id, liked })}        onShare={(id) => log("share", id)}        onBookmark={(id, bookmarked) => log("bookmark", { id, bookmarked })}        onEdit={(id) => log("edit", id)}        onDelete={(id) => log("delete", id)}        onPin={(id, next) => log("pin", { id, next })}        onChangeVisibility={(id, current) =>          log("change-visibility", { id, current })        }        onMarkSensitive={(id, next) => log("mark-sensitive", { id, next })}        onSeeAnalytics={(id) => log("see-analytics", id)}        onAddComment={async (content, parentId) => {          log("add-comment", { content, parentId });        }}        onLikeComment={(id, liked) =>          log("like-comment", { id, liked })        }        onDeleteComment={(id) => log("delete-comment", id)}        onReportComment={(id) => log("report-comment", id)}        getHref={(p) => `/posts/${p.id}`}      />    </div>  );} // ─── v0.2.0 tabs ────────────────────────────────────────────────────────── function RepostTab() {  // Showcases the nested repost mini-card (Q-D3 / Q-P30). Outer post is a  // public viewer-mode repost; nested mini-card is the original FEATURED post  // with engagementActions={()=>[]} suppressing the inner bar.  return (    <div className="mx-auto max-w-xl">      <PostCard        variant="feed"        post={DUMMY_REPOST_POST}        currentUser={DUMMY_VIEWER}        viewerMode="viewer"        onLike={(id, liked) => log("like", { id, liked })}        onShare={(id) => log("share", id)}        onBookmark={(id, bookmarked) => log("bookmark", { id, bookmarked })}        onRepostOfClick={(orig) => log("repost-of-click", orig.id)}        getHref={(p) => `/posts/${p.id}`}      />    </div>  );} function PollTab() {  // Three cards stacked: active poll (vote view) → voted poll (results view  // with viewer choice highlighted) → closed poll (results + closed label).  return (    <div className="mx-auto flex max-w-xl flex-col gap-4">      <PostCard        variant="feed"        post={DUMMY_POLL_POST}        currentUser={DUMMY_VIEWER}        viewerMode="viewer"        onVotePoll={(id, optionId) => log("vote-poll", { id, optionId })}        onLike={(id, liked) => log("like", { id, liked })}      />      <PostCard        variant="feed"        post={DUMMY_POLL_VOTED_POST}        currentUser={DUMMY_VIEWER}        viewerMode="viewer"        onLike={(id, liked) => log("like", { id, liked })}      />      <PostCard        variant="feed"        post={DUMMY_POLL_CLOSED_POST}        currentUser={DUMMY_VIEWER}        viewerMode="viewer"        onLike={(id, liked) => log("like", { id, liked })}      />    </div>  );} function SensitiveTab() {  // Two cards: sensitive gate (viewer taps Show) + link-preview card + rich  // header (visibility / edited / location / mentions / tags).  return (    <div className="mx-auto flex max-w-xl flex-col gap-4">      <PostCard        variant="feed"        post={DUMMY_SENSITIVE_POST}        currentUser={DUMMY_VIEWER}        viewerMode="viewer"        onLike={(id, liked) => log("like", { id, liked })}        onRevealSensitive={(id) => log("reveal-sensitive", id)}      />      <PostCard        variant="feed"        post={DUMMY_LINK_PREVIEW_POST}        currentUser={DUMMY_VIEWER}        viewerMode="viewer"        onLike={(id, liked) => log("like", { id, liked })}        onLinkPreviewClick={(url) => log("link-preview-click", url)}      />      <PostCard        variant="feed"        post={DUMMY_RICH_POST}        currentUser={DUMMY_VIEWER}        viewerMode="viewer"        onLike={(id, liked) => log("like", { id, liked })}        onLocationClick={(loc) => log("location-click", loc)}        onMentionClick={(mid) => log("mention-click", mid)}        onTagClick={(tag) => log("tag-click", tag)}      />      <PostCard        variant="feed"        post={DUMMY_REPLY_POST}        currentUser={DUMMY_VIEWER}        viewerMode="viewer"        onLike={(id, liked) => log("like", { id, liked })}        onMentionClick={(mid) => log("reply-mention-click", mid)}      />    </div>  );} function TextOnlyTab() {  return (    <div className="mx-auto max-w-xl">      <PostCard        variant="feed"        post={DUMMY_LONG_TEXT_POST}        currentUser={DUMMY_VIEWER}        onLike={(id, liked) => log("like", { id, liked })}        onComment={(id) => log("comment", id)}        onShare={(id) => log("share", id)}        onBookmark={(id, bookmarked) => log("bookmark", { id, bookmarked })}      />    </div>  );} function RealtimeTab() {  const engagementSubscribe = useMemo(    () => createDummyEngagementSubscribe(),    [],  );  const commentSubscribe = useMemo(() => createDummyCommentSubscribe(), []);  return (    <div className="mx-auto max-w-xl">      <PostCard        variant="detail"        post={DUMMY_VIDEO_POST}        currentUser={DUMMY_VIEWER}        commentThread={DUMMY_DETAIL_THREAD}        engagementSubscribe={engagementSubscribe}        commentSubscribe={commentSubscribe}        onSubscribeEngagementDelta={(d) => log("engagement-delta", d)}        onSubscribeCommentDelta={(d) => log("comment-delta", d)}        onLike={(id, liked) => log("like", { id, liked })}        onAddComment={async (content) => {          log("add-comment", content);        }}      />    </div>  );} function InlineEngagementTab() {  // TR-localized variant — same panels, kasder labels.  const likersPageRef = useRef(1);  return (    <div className="mx-auto max-w-xl">      <PostCard        variant="feed"        post={DUMMY_MULTI_IMAGE_POST}        currentUser={DUMMY_VIEWER}        likers={DUMMY_LIKERS}        commentThread={DUMMY_DETAIL_THREAD}        shareSuggestions={DUMMY_LIKERS}        onShareTo={(id, user) => log("share-to", { id, user })}        onLoadMoreLikers={async () => {          await new Promise((r) => setTimeout(r, 300));          likersPageRef.current += 1;          return generateMoreLikers(likersPageRef.current);        }}        onLike={(id, liked) => log("like", { id, liked })}        onBookmark={(id, b) => log("bookmark", { id, b })}        onAddComment={async (content, parentId) => {          log("add-comment", { content, parentId });        }}        onLikeComment={(id, liked) => log("like-comment", { id, liked })}        onDeleteComment={(id) => log("delete-comment", id)}        onReportComment={(id) => log("report-comment", id)}        onLoadMoreComments={async () => {          await new Promise((r) => setTimeout(r, 300));          return [];        }}        labels={{          likersHeading: "Beğenenler",          shareHeading: "Şununla paylaş…",          shareSearchPlaceholder: "Kişi ara…",          shareEmptyLabel: "Eşleşme yok.",          hidePanelLabel: "Gizle",          commentLabels: { reply: "Yanıtla", like: "Beğen" },        }}      />    </div>  );} function ModeratorTab() {  // v0.3.0 ILX-3 — viewer-mode card with moderator capability opted in via  // `permissions={{ canModerate: true }}`. `moderatorActions(post)` supplies  // the items; the library wraps them in a section between common items and  // viewer-destructive items, with a divider above. `kebabActions` is left  // unset so the role-aware default kebab assembly runs.  return (    <div className="mx-auto max-w-xl">      <PostCard        variant="feed"        post={DUMMY_FEATURED_POST}        currentUser={DUMMY_VIEWER}        viewerMode="viewer"        permissions={{ canModerate: true }}        moderatorActions={(p): CommentMenuItem[] => [          {            label: "Feature post",            icon: <Star className="h-4 w-4" />,            onClick: () => log("mod:feature", p.id),          },          {            label: "Lock comments",            icon: <Lock className="h-4 w-4" />,            onClick: () => log("mod:lock", p.id),          },          {            label: "Remove post",            icon: <Trash2 className="h-4 w-4" />,            destructive: true,            onClick: () => log("mod:remove", p.id),          },        ]}        onLike={(id, liked) => log("like", { id, liked })}        onShare={(id) => log("share", id)}        onBookmark={(id, bookmarked) => log("bookmark", { id, bookmarked })}        onReport={(id) => log("report", id)}        onBlockAuthor={(authorId) => log("block-author", authorId)}        onMuteAuthor={(authorId) => log("mute-author", authorId)}        getHref={(p) => `/posts/${p.id}`}      />    </div>  );} function CustomActionsTab() {  const [extras] = useState({ remixActive: false });  return (    <div className="mx-auto max-w-xl">      <PostCard        variant="feed"        post={DUMMY_MIXED_MEDIA_POST}        currentUser={DUMMY_VIEWER}        onLike={(id, liked) => log("like", { id, liked })}        onComment={(id) => log("comment", id)}        onShare={(id) => log("share", id)}        onBookmark={(id, bookmarked) => log("bookmark", { id, bookmarked })}        engagementActions={(p, h, v): EngagementAction[] => [          ...defaultPostEngagementActions(p, h, v),          {            kind: "custom",            id: "remix",            label: "Remix",            icon: <Wand2 className="h-4 w-4" />,            active: extras.remixActive,            onClick: () => log("remix", p.id),          },        ]}        kebabActions={(p): CommentMenuItem[] => [          { label: "Pin", onClick: () => log("pin", p.id) },          { label: "Translate", onClick: () => log("translate", p.id) },          {            label: "Block author",            onClick: () => log("block", p.author.id),          },          {            label: "Report",            destructive: true,            onClick: () => log("report", p.id),          },        ]}      />    </div>  );} export default function PostCardDemo() {  return (    <Tabs defaultValue="feed" className="w-full">      <SwipeTabsList>        <TabsTrigger value="feed">Feed</TabsTrigger>        <TabsTrigger value="compact">Compact</TabsTrigger>        <TabsTrigger value="list">List</TabsTrigger>        <TabsTrigger value="detail">Detail</TabsTrigger>        <TabsTrigger value="text">Text-only</TabsTrigger>        <TabsTrigger value="video">Video</TabsTrigger>        <TabsTrigger value="realtime">Realtime</TabsTrigger>        <TabsTrigger value="inline">Inline TR</TabsTrigger>        <TabsTrigger value="custom">Custom</TabsTrigger>        <TabsTrigger value="moderator">Moderator</TabsTrigger>        <TabsTrigger value="repost">Repost</TabsTrigger>        <TabsTrigger value="poll">Poll</TabsTrigger>        <TabsTrigger value="sensitive">Sensitive</TabsTrigger>      </SwipeTabsList>      <TabsContent value="feed" className="mt-4">        <FeedTab />      </TabsContent>      <TabsContent value="compact" className="mt-4">        <CompactTab />      </TabsContent>      <TabsContent value="list" className="mt-4">        <ListTab />      </TabsContent>      <TabsContent value="detail" className="mt-4">        <DetailTab />      </TabsContent>      <TabsContent value="text" className="mt-4">        <TextOnlyTab />      </TabsContent>      <TabsContent value="video" className="mt-4">        <VideoTab />      </TabsContent>      <TabsContent value="realtime" className="mt-4">        <RealtimeTab />      </TabsContent>      <TabsContent value="inline" className="mt-4">        <InlineEngagementTab />      </TabsContent>      <TabsContent value="custom" className="mt-4">        <CustomActionsTab />      </TabsContent>      <TabsContent value="moderator" className="mt-4">        <ModeratorTab />      </TabsContent>      <TabsContent value="repost" className="mt-4">        <RepostTab />      </TabsContent>      <TabsContent value="poll" className="mt-4">        <PollTab />      </TabsContent>      <TabsContent value="sensitive" className="mt-4">        <SensitiveTab />      </TabsContent>    </Tabs>  );} 

Usage

When to use

Reach for PostCard when you need a social-post surface in any of four shapes: feed (Instagram-post), compact (sidebar widget), list (admin / search row), or detail (full page with embedded comment thread). Composes all five Tier-1 social primitives — expandable-text, media-carousel, engagement-bar, comment-thread, plus video-player transitively — and ships with kasder-style inline engagement panels (likes / comments / share) on by default.

Inline engagement panels (default)

Out of the box, every variant except detail wires kasder-style inline panels:

  • Tap heart → toggle like (and auto-open likers panel on a fresh like, controlled by openLikersOnLike).
  • Tap like count → open inline likers strip (horizontal swipable avatar list with paginating +N pill).
  • Tap comment icon → open inline <CommentThread> with composer; scrollable, height configurable via inlineCommentsMaxHeight (default 24rem).
  • Tap share icon → open inline searchable user list (when shareSuggestions provided); local filter or async onShareSearch.

Pass engagementMode="navigate" to deactivate the panels and revert to single-button like / onComment(id) navigation. The detail variant ignores engagementMode — its thread is always embedded.

Footgun: the `post` prop is mount-only

Component captures post as initial state on mount; subsequent prop reference changes are ignored. RealtimeengagementSubscribe + optimistic onLike/onBookmarkmutate the internal mirror. To push external updates, use the imperative handle's reset(next):

const ref = useRef<PostCardHandle>(null);
useEffect(() => {
  ref.current?.reset(externalPost);
}, [externalPost]);

<PostCard ref={ref} variant="feed" post={externalPost} ... />

Basic feed

<PostCard
  variant="feed"
  post={post}
  currentUser={viewer}
  likers={preloadedLikers}              // enables inline likers panel
  commentThread={preloadedComments}     // enables inline comments panel
  shareSuggestions={recentContacts}     // enables inline share menu
  onLike={(id, liked) => api.likePost(id, liked)}
  onBookmark={(id, b) => api.bookmark(id, b)}
  onShareTo={(id, user) => api.shareTo(id, user)}
  onAddComment={(content, parentId) => api.addComment(post.id, { content, parentId })}
  onLikeComment={api.likeComment}
  onLoadMoreLikers={() => api.fetchMoreLikers(post.id)}
  getHref={(p) => `/posts/${p.id}`}    // overlay-link + Copy-link kebab item
/>

Detail with embedded thread + realtime

const engagementSubscribe = useCallback<Subscribe<EngagementDelta>>(
  (h) => channel.on(`post-${post.id}-engagement`, h),
  [post.id, channel],
);
const commentSubscribe = useCallback<Subscribe<CommentDelta>>(
  (h) => channel.on(`post-${post.id}-comments`, h),
  [post.id, channel],
);

<PostCard
  variant="detail"
  post={post}
  currentUser={viewer}
  commentThread={preloadedComments}
  engagementSubscribe={engagementSubscribe}
  commentSubscribe={commentSubscribe}
  onLike={api.likePost}
  onAddComment={(content, parentId) => api.addComment(post.id, { content, parentId })}
  onLikeComment={api.likeComment}
  onDeleteComment={api.deleteComment}
  onLoadMoreComments={(page) => api.fetchComments(post.id, page)}
/>

Navigate mode (no inline panels)

<PostCard
  variant="feed"
  post={post}
  engagementMode="navigate"
  onLike={api.likePost}
  onComment={(id) => router.push(`/posts/${id}#comments`)}
  onShare={(id) => navigator.share?.({ url: `/posts/${id}` })}
/>

Custom engagement actions (extending defaults)

import { defaultPostEngagementActions } from "@/components/post-card";

<PostCard
  variant="feed"
  post={post}
  engagementActions={(p, h, v) => [
    ...defaultPostEngagementActions(p, h, v),
    { kind: "custom", id: "remix", label: "Remix", icon: <Wand2 />, onClick: () => openRemix(p.id) },
  ]}
/>

Custom kebab actions (full takeover)

<PostCard
  variant="list"
  post={post}
  kebabActions={(p) => [
    { label: "Pin", onClick: () => api.pin(p.id) },
    { label: "Take down", destructive: true, onClick: () => api.takeDown(p.id) },
    { label: "Block author", onClick: () => api.block(p.author.id) },
  ]}
/>

kebabActions is full-takeover — it bypasses the role-aware default assembly entirely. For an additive moderator section on top of the default kebab, use moderatorActions below.

v0.3.0 — Moderator section (ILX-3)

Moderation is orthogonal to viewerMode — a moderator is usually a viewer (or sometimes an owner) with extra capability. Opt the viewer in via permissions.canModerate: true (or the universal canPerformAction("moderate", post) predicate) AND supply the menu items via moderatorActions(post). The library renders them as a section between common items (Bookmark / Share / Copy link / Translate) and viewer-destructive items (Mute / Block / Report), with a divider above. Setting kebabActions still wins (full takeover).

<PostCard
  variant="feed"
  post={post}
  currentUser={viewer}
  viewerMode="viewer"
  permissions={{ canModerate: viewer.role === "mod" || viewer.role === "admin" }}
  moderatorActions={(p) => [
    { label: "Feature post", icon: <Star className="h-4 w-4" />, onClick: () => api.feature(p.id) },
    { label: "Lock comments", icon: <Lock className="h-4 w-4" />, onClick: () => api.lockThread(p.id) },
    { label: "Remove post", icon: <Trash2 className="h-4 w-4" />, destructive: true, onClick: () => api.removePost(p.id) },
  ]}
  // viewer-side defaults still resolve:
  onReport={api.report}
  onBlockAuthor={api.block}
  onMuteAuthor={api.mute}
/>

Resolution: moderatorActions runs only when canPerformAction("moderate", post) returns true (wins) OR permissions.canModerate === true. Default for both viewer modes is false — moderators must be opted in explicitly; never auto-derived from viewerMode.

i18n

labels={{
  // header / kebab
  bookmark: "Kaydet", unbookmark: "Kaldır", share: "Paylaş",
  copyLink: "Bağlantıyı kopyala", report: "Şikayet et",
  // inline panels
  likersHeading: "Beğenenler",
  shareHeading: "Şununla paylaş…",
  shareSearchPlaceholder: "Kişi ara…",
  shareEmptyLabel: "Eşleşme yok.",
  hidePanelLabel: "Gizle",
  // forwarded
  engagementLabels: { /* engagement-bar labels */ },
  commentLabels: { /* comment-thread labels */ },
}}

Notes

  • engagementMode defaults to "inline" — tap-to-open panels everywhere. Pass "navigate" to deactivate.
  • Heart-burst auto-wires when post.media?.length > 0 AND onLike is provided AND variant ∈ {feed, detail}. Opt out via disableHeartBurst.
  • getHrefdouble-duty: makes the card clickable via overlay-link in feed / compact / list AND adds a "Copy link" kebab item. Detail variant ignores overlay-link.
  • Default kebab is "Bookmark / Share / Copy link / Report" — each item only appears when its handler is wired (or for Copy link, when getHref is provided).
  • v0.3.0 — moderatorActions(post) + permissions.canModerate + "moderate" action discriminator. Orthogonal to viewerMode (never auto-derived). The section sits between common items and viewer-destructive items with a divider above. kebabActions full-takeover still wins.
  • The split heart-vs-count behavior comes from engagement-bar's like action (onCountClickon the action) — it's the bar feature, not card-specific. Hosts using the bar directly can wire the same split.
  • renderHeader, renderContent, renderMedia, renderEngagementBar, renderCommentSection are full-takeover slots at every connective seam.
  • List variant's thumbnail stretches to fill the card height edge-to-edge; content area gets its own padding so the seams stay flush.
  • Realtime: two separate subscribe props. engagementSubscribe is owned by the card (delta routes to mirror); commentSubscribe forwards directly to the embedded CommentThread in detail variant or to the inline thread when opened.

Features

  • 4 variants: feed (Instagram-post) / compact (sidebar widget) / list (admin row, full-height thumb) / detail (full page + embedded thread)
  • Composes 5 Tier-1 social siblings via cross-folder imports (declared registryDependencies)
  • Inline engagement panels DEFAULT — tap heart to like, tap count to open likers strip; tap comment for inline thread+composer; tap share for searchable user list
  • engagementMode='navigate' opt-out for hosts that prefer page-navigation UX (single-button like, comment fires onComment(id))
  • v0.2.0 — Permissions matrix + viewerMode (owner|viewer) + canPerformAction predicate; dual-mode defaultPostKebabActions preserves zero v0.1 drift
  • v0.2.0 — 12 new optional Post fields: isPinned, isSensitive, sensitiveReason, visibility, editedAt, mentions, tags, location, language, replyTo, repostOf, linkPreview, poll
  • v0.2.0 — Inline poll widget with optimistic vote (vote buttons / live results bar chart with width transition; motion-reduce safe)
  • v0.2.0 — Sensitive-media gate with reveal button (per-post; analytics hook via onRevealSensitive)
  • v0.2.0 — OG link-preview card (host pre-fetches; library is fetch-free)
  • v0.2.0 — Nested repost mini-card (compact + navigate + empty engagementActions; recursion-strip)
  • v0.2.0 — Header badges: pinned, visibility (6 base values + branded extension), edited suffix, replyTo sub-line, location chip
  • v0.2.0 — MentionText + TagChips sub-exports for inline mention highlighting via renderContent slot
  • v0.2.0 — RepostOfCard + PollWidget sub-exports for standalone use outside the auto-rendered slot
  • v0.2.0 — Responsive sweep: padding / avatar / body-text / list-thumb step at sm / md / lg per description §2.1-B; touch targets ≥44×44 (kebab fixed)
  • v0.2.0 — engagement-bar v0.2.x dependency (LikersStrip + ShareMenu sub-exports re-exported here for soft-compat)
  • v0.2.0 — Imperative handle gains 5 trigger methods: triggerEdit / triggerDelete / triggerPin / revealSensitive / votePoll
  • v0.3.0 — Moderator section in kebab (ILX-3): `moderatorActions(post)` slot + `canModerate` permission + `"moderate"` action discriminator. Orthogonal to viewerMode (never auto-derived). Renders between common items and viewer-destructive items with a divider above.
  • v0.3.1 — F-S1 cross-procomp cleanup: 32 absolute `@/registry/components/...` imports across 9 files converted to relative + specific-file paths (the F-S1 lock pattern). Latent v0.2.0 bug where shadcn 4.6.0's path-rewriter mangled cross-procomp barrel imports — surfaced post-push by the v0.3.0 smoke; consumers couldn't tsc-pass after install. Zero public-API change.
  • v0.3.2 — Cross-category MediaItem inline-copy: shadcn rewriter mangles cross-category `/types` imports in unpredictable ways (sometimes to wrong-slug, sometimes preserving an invalid `<cat>/` prefix). Local `PostMediaItem` type defined in `types.ts` (structurally identical to media-carousel's MediaItem); `MediaItem` retained as soft-compat alias for v0.2.x consumers. Drops the broken cross-category re-export from `index.ts`.
  • LikersStrip part — horizontal swipable avatar strip + paginating +N pill (touch swipe + desktop drag-to-scroll)
  • ShareMenu part — searchable user picker; local filter when no async wired, optional onShareSearch for backend search
  • Auto-wired canonical heart-burst on double-tap (heuristic; disableHeartBurst opt-out)
  • Local engagement mirror — bar runs always-controlled, realtime + optimistic flow into single source of truth
  • Wrapped engagement handlers — defaultPostEngagementActions(post, handlers, variant, onLikeCountClick?) gets pre-wrapped handlers
  • Default kebab — Bookmark / Share / Copy link / Report (legacy); role-aware kebab with Edit / Delete / Pin / Change visibility / Mark sensitive / See analytics / Block / Mute / Translate when viewerMode set
  • kebabActions slot for full takeover (moderator semantics)
  • engagementActions slot — defaultPostEngagementActions exported for extend-not-replace
  • Overlay-link via getHref + linkComponent (polymorphic root) — non-detail variants only
  • Two separate subscribe props (engagementSubscribe + commentSubscribe) — distinct delta types stay distinct
  • Embedded CommentThread in detail variant + renderCommentSection slot
  • renderHeader / renderContent / renderMedia / renderEngagementBar slots — full takeover at every seam
  • v0.2.0 — Additional slots: renderPoll / renderLinkPreview / renderRepostOf / renderSensitiveGate + disableXxxRender opt-outs
  • VerifiedBadge sub-export — RSC-compatible (no `"use client"`)
  • Always-uncontrolled — `post` prop is mount-only; ref.current.reset(next) for external state push (clears optimistic pollVote + sensitiveRevealed)
  • Tailwind v4-clean (no legacy class names)
  • No new shadcn primitives needed (avatar / button / card / dropdown-menu / input / popover present)
  • No framer-motion — inherits engagement-heart-burst CSS from engagement-bar
  • Uses the `engagement-bar` like-action `onCountClick` split (heart vs count) when in inline mode
  • v0.5 barrel completeness: the eleven `Post` / `PostCardProps` sub-shapes (PostMention, PostPoll, PostPollOption, PostLocation, PostReplyTo, LinkPreview, PostVisibility, PostViewerMode, PostPermissions, PostPermissionAction, PostMutationHandlers) are now importable from the package root — previously a consumer could hold a `Post` and still not name the type of a field they were building. Type-only, additive.

Tags

post-cardsocialposttier-2compositefeeddetailcompactlistrealtimeheart-burstinline-panelssharelikers

Dependencies

shadcn primitives: avatar, button, dropdown-menu
npm peer deps: lucide-react@^1.11.0
internal: expandable-text, media-carousel, engagement-bar, comment-thread