Skip to content
ilinxa/pro-ui

Post Card

alphav0.4.0

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

Category: Data DisplayUpdated: 2026-08-11Created: 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
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
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

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

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

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