'use client';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useLocale } from 'next-intl';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { MessageSquare, Flag, Edit, Trash2, Lock, Pin, MoreVertical } from 'lucide-react';
import { VoteButtons } from './VoteButtons';
import { InlineReplyForm } from './InlineReplyForm';
import { EngagementBadges } from './EngagementBadges';
import { SectionContextBadge } from './SectionContextBadge';
import { SharedConversationEmbed } from './SharedConversationEmbed';
import { NewsArticleEmbed, type NewsArticleData } from './NewsArticleEmbed';
import type { ForumPost } from '@/types/forum';
import { useAuth } from '@/contexts/AuthContext';
import { ShareButton } from '../ShareButton';
import { PrintableCard } from '../PrintableCard';
import { BookmarkButton } from '../bookmarks/BookmarkButton';
import { MentionRenderer } from '@/components/mentions';
import { extractLeadingBillMention, getSectionFromBillMention } from '@/lib/mentions/mentionParser';
interface PostCardProps {
post: ForumPost;
showReplyButton?: boolean;
showCategory?: boolean;
onReply?: (post: ForumPost) => void;
onEdit?: (post: ForumPost) => void;
onDelete?: (postId: string) => void;
onReport?: (postId: string) => void;
onReplySuccess?: () => void;
variant?: 'full' | 'compact';
/** Whether to show the post title (default: true) */
showTitle?: boolean;
}
export function PostCard({
post,
showReplyButton = true,
showCategory = false,
onReply,
onEdit,
onDelete,
onReport,
onReplySuccess,
variant = 'full',
showTitle = true,
}: PostCardProps) {
const { user } = useAuth();
const locale = useLocale();
const [isExpanded, setIsExpanded] = useState(variant === 'full');
const [localUpvotes, setLocalUpvotes] = useState(post.upvotes_count);
const [localDownvotes, setLocalDownvotes] = useState(post.downvotes_count);
const [showInlineReply, setShowInlineReply] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowMenu(false);
}
};
if (showMenu) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showMenu]);
// Sync local vote counts with prop changes (when parent refetches data)
useEffect(() => {
setLocalUpvotes(post.upvotes_count);
setLocalDownvotes(post.downvotes_count);
}, [post.upvotes_count, post.downvotes_count]);
const isAuthor = user?.id === post.author_id;
const canEdit = isAuthor && !post.is_deleted && !post.is_locked;
const canDelete = isAuthor && !post.is_deleted;
// Extract leading bill mention for bill_comment posts (to show as badge and strip from content)
const leadingBillMention = useMemo(() => {
if (post.post_type !== 'bill_comment') return null;
return extractLeadingBillMention(post.content);
}, [post.content, post.post_type]);
// Content with leading mention stripped for display
const contentWithoutLeadingMention = useMemo(() => {
if (!leadingBillMention) return post.content;
return post.content.slice(leadingBillMention.endIndex).trimStart();
}, [post.content, leadingBillMention]);
// Get section from mention or fallback to entity_metadata
const sectionRef = useMemo(() => {
if (leadingBillMention) {
return getSectionFromBillMention(leadingBillMention);
}
return post.entity_metadata?.section_ref as string | undefined;
}, [leadingBillMention, post.entity_metadata]);
// Format timestamp
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) return 'just now';
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
});
};
const handleVoteChange = (upvotes: number, downvotes: number) => {
setLocalUpvotes(upvotes);
setLocalDownvotes(downvotes);
};
// Use content without leading mention for display
const baseContent = contentWithoutLeadingMention;
// Truncate content for compact view
const displayContent = variant === 'compact' && !isExpanded && baseContent.length > 300
? baseContent.slice(0, 300) + '...'
: baseContent;
const needsTruncation = variant === 'compact' && baseContent.length > 300;
// Share data
const shareUrl = post.bill_number
? `/${locale}/bills/${post.bill_number}#post-${post.id}`
: `/${locale}/forum#post-${post.id}`;
const shareTitle = post.title || `${post.author_name || 'Anonymous'}'s post`;
const shareDescription = post.content
.replace(/[#*_~`>\[\]]/g, '') // Remove basic markdown
.substring(0, 150) + (post.content.length > 150 ? '...' : '');
return (
<PrintableCard>
<div
className={`
bg-slate-800 dark:bg-slate-800 rounded-lg border border-slate-700 dark:border-slate-700
${post.is_pinned ? 'border-accent-red dark:border-accent-red' : ''}
${post.is_deleted ? 'opacity-60' : ''}
transition-all hover:border-slate-600 dark:hover:border-slate-600 hover:shadow-sm
`}
>
<div className="flex gap-2 sm:gap-4 p-3 sm:p-4">
{/* Voting column */}
<div className="flex-shrink-0">
<VoteButtons
postId={post.id}
upvotes={localUpvotes}
downvotes={localDownvotes}
userVote={post.user_vote}
size="md"
onVoteChange={handleVoteChange}
/>
</div>
{/* Content column */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-start justify-between gap-2 mb-2 relative">
{/* 3-dot Menu - Top Right */}
{!post.is_deleted && ((canDelete && onDelete) || (!isAuthor && onReport)) && (
<div ref={menuRef} className="absolute top-0 right-0">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 text-gray-400 hover:text-gray-200 transition-colors rounded hover:bg-slate-700"
>
<MoreVertical size={16} />
</button>
{/* Dropdown Menu */}
{showMenu && (
<div className="absolute right-0 top-8 bg-slate-700 border border-slate-600 rounded-lg shadow-lg py-1 min-w-[120px] z-10">
{/* Delete option - only for author */}
{canDelete && onDelete && (
<button
onClick={async () => {
setShowMenu(false);
if (confirm('Are you sure you want to delete this post?')) {
try {
await onDelete(post.id);
} catch (error) {
console.error('Delete error:', error);
alert('Failed to delete post');
}
}
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-400 hover:bg-slate-600 transition-colors"
>
<Trash2 size={14} />
<span>Delete</span>
</button>
)}
{/* Report option - only for non-authors */}
{!isAuthor && onReport && (
<button
onClick={() => {
setShowMenu(false);
onReport(post.id);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-yellow-400 hover:bg-slate-600 transition-colors"
>
<Flag size={14} />
<span>Report</span>
</button>
)}
</div>
)}
</div>
)}
<div className="flex-1 min-w-0 pr-8">
{/* Title */}
{showTitle && post.title && (
<h3 className="text-lg font-semibold text-text-primary mb-1 flex items-center gap-2">
{post.is_pinned && (
<Pin size={16} className="text-accent-red flex-shrink-0" />
)}
{post.is_locked && (
<Lock size={16} className="text-yellow-500 flex-shrink-0" />
)}
<span className="truncate">{post.title}</span>
</h3>
)}
{/* Pinned/Locked indicators when title is hidden */}
{!showTitle && (post.is_pinned || post.is_locked) && (
<div className="flex items-center gap-2 mb-1">
{post.is_pinned && (
<Pin size={16} className="text-accent-red flex-shrink-0" />
)}
{post.is_locked && (
<Lock size={16} className="text-yellow-500 flex-shrink-0" />
)}
</div>
)}
{/* Metadata */}
<div className="flex items-center gap-2 text-sm flex-wrap">
<span className="font-medium text-white">
{post.author_name || 'Anonymous'}
</span>
<span className="text-gray-300">•</span>
<time dateTime={post.created_at} className="text-gray-200">
{formatDate(post.created_at)}
</time>
{post.edited_at && (
<>
<span className="text-gray-300">•</span>
<span className="italic text-gray-200">edited</span>
</>
)}
{showCategory && post.category && (
<>
<span className="text-gray-300">•</span>
<span className="text-accent-red">
{post.category.name}
</span>
</>
)}
{/* Engagement badges - inline with metadata */}
<EngagementBadges post={post} showLabels={true} />
{/* Section context badge for bill comments */}
{post.post_type === 'bill_comment' && (
<SectionContextBadge sectionRef={sectionRef || null} locale={locale} />
)}
</div>
</div>
</div>
{/* Content */}
{post.is_deleted ? (
<div className="text-gray-300 italic py-2">
[This post has been deleted]
</div>
) : (
<div className="prose prose-invert max-w-none prose-sm mb-3 text-gray-100">
{/* Shared Conversation Embed (from chatbot) */}
{!!post.entity_metadata?.shared_from_chat && !!post.entity_metadata?.conversation_snapshot && (
<SharedConversationEmbed
conversationSnapshot={post.entity_metadata.conversation_snapshot as {
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
billContext?: { number: string; session: string; title?: string };
}}
conversationId={post.entity_metadata.conversation_id as string | undefined}
locale={locale}
defaultExpanded={false}
/>
)}
{/* News Article Embed (from activity feed) */}
{!!post.entity_metadata?.newsArticle && (
<NewsArticleEmbed
article={post.entity_metadata.newsArticle as NewsArticleData}
locale={locale}
defaultExpanded={false}
/>
)}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Paragraph with mention support
p: ({ children, ...props }) => (
<p {...props}>
{React.Children.map(children, (child, index) => {
if (typeof child === 'string') {
return (
<MentionRenderer
key={`p-mention-${index}`}
text={child}
newTab={true}
/>
);
}
return child;
})}
</p>
),
// List items with mention support
li: ({ children, ...props }) => (
<li {...props}>
{React.Children.map(children, (child, index) => {
if (typeof child === 'string') {
return (
<MentionRenderer
key={`li-mention-${index}`}
text={child}
newTab={true}
/>
);
}
return child;
})}
</li>
),
// Headers with mention support
h1: ({ children, ...props }) => (
<h1 {...props}>
{React.Children.map(children, (child, index) => {
if (typeof child === 'string') {
return <MentionRenderer key={`h1-mention-${index}`} text={child} newTab={true} />;
}
return child;
})}
</h1>
),
h2: ({ children, ...props }) => (
<h2 {...props}>
{React.Children.map(children, (child, index) => {
if (typeof child === 'string') {
return <MentionRenderer key={`h2-mention-${index}`} text={child} newTab={true} />;
}
return child;
})}
</h2>
),
h3: ({ children, ...props }) => (
<h3 {...props}>
{React.Children.map(children, (child, index) => {
if (typeof child === 'string') {
return <MentionRenderer key={`h3-mention-${index}`} text={child} newTab={true} />;
}
return child;
})}
</h3>
),
}}
>
{displayContent}
</ReactMarkdown>
</div>
)}
{/* Expand/Collapse for truncated content */}
{needsTruncation && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-accent-red text-sm hover:underline mb-3"
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
)}
{/* Actions bar */}
{!post.is_deleted && (
<div className="flex items-center gap-4 text-sm">
{/* Reply button */}
{showReplyButton && !post.is_locked && (
<button
onClick={() => {
// Top-level posts (depth=0) use modal if onReply provided, otherwise inline
if (post.depth === 0 && onReply) {
onReply(post);
} else {
setShowInlineReply(!showInlineReply);
}
}}
className="flex items-center gap-1.5 text-gray-200 hover:text-accent-red transition-colors"
>
<MessageSquare size={16} />
<span>Reply</span>
{post.reply_count > 0 && (
<span className="text-gray-300">({post.reply_count})</span>
)}
</button>
)}
{/* Edit button */}
{canEdit && onEdit && (
<button
onClick={() => onEdit(post)}
className="flex items-center gap-1.5 text-gray-200 hover:text-blue-400 transition-colors"
>
<Edit size={16} />
<span>Edit</span>
</button>
)}
</div>
)}
</div>
</div>
{/* Inline reply form - full width on mobile, inside content area on desktop */}
{showInlineReply && (
<div className="px-3 pb-3 sm:px-4 sm:pb-4 sm:pl-[52px]">
<InlineReplyForm
parentPost={post}
onSuccess={() => {
setShowInlineReply(false);
// Use callback if provided, otherwise reload
if (onReplySuccess) {
onReplySuccess();
} else {
window.location.reload();
}
}}
onCancel={() => setShowInlineReply(false)}
/>
</div>
)}
</div>
</PrintableCard>
);
}