Files
echoboard/packages/web/src/components/PostCard.tsx
lashman f07eddf29e initial project setup
Fastify + Prisma backend, React + Vite frontend, Docker deployment.
Multi-board feedback platform with anonymous cookie auth, passkey
upgrade path, ALTCHA spam protection, plugin system, and full
privacy-first architecture.
2026-03-19 18:05:16 +02:00

111 lines
3.6 KiB
TypeScript

import { Link } from 'react-router-dom'
import StatusBadge from './StatusBadge'
interface Post {
id: string
title: string
excerpt?: string
type: 'feature' | 'bug' | 'general'
status: string
voteCount: number
commentCount: number
authorName: string
createdAt: string
boardSlug: string
hasVoted?: boolean
}
export default function PostCard({
post,
onVote,
}: {
post: Post
onVote?: (id: string) => void
}) {
const timeAgo = formatTimeAgo(post.createdAt)
return (
<div
className="card flex gap-0 overflow-hidden"
style={{ transition: 'border-color 200ms ease-out' }}
>
{/* Vote column */}
<button
onClick={(e) => { e.preventDefault(); onVote?.(post.id) }}
className="flex flex-col items-center justify-center px-3 py-4 shrink-0 gap-1"
style={{
width: 48,
background: post.hasVoted ? 'var(--accent-subtle)' : 'transparent',
color: post.hasVoted ? 'var(--accent)' : 'var(--text-tertiary)',
transition: 'all 200ms ease-out',
borderRight: '1px solid var(--border)',
}}
>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
<span className="text-xs font-semibold">{post.voteCount}</span>
</button>
{/* Content zone */}
<Link
to={`/b/${post.boardSlug}/post/${post.id}`}
className="flex-1 py-3 px-4 min-w-0"
>
<div className="flex items-center gap-2 mb-1">
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
background: post.type === 'bug' ? 'rgba(239, 68, 68, 0.15)' : 'var(--accent-subtle)',
color: post.type === 'bug' ? 'var(--error)' : 'var(--accent)',
}}
>
{post.type}
</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{post.authorName} - {timeAgo}
</span>
</div>
<h3
className="text-sm font-medium mb-1 truncate"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
>
{post.title}
</h3>
{post.excerpt && (
<p
className="text-xs line-clamp-2"
style={{ color: 'var(--text-secondary)' }}
>
{post.excerpt}
</p>
)}
</Link>
{/* Status + comments */}
<div className="flex flex-col items-end justify-center px-4 py-3 shrink-0 gap-2">
<StatusBadge status={post.status} />
<div className="flex items-center gap-1" style={{ color: 'var(--text-tertiary)' }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<span className="text-xs">{post.commentCount}</span>
</div>
</div>
</div>
)
}
function formatTimeAgo(date: string): string {
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}d ago`
const months = Math.floor(days / 30)
return `${months}mo ago`
}