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.
111 lines
3.6 KiB
TypeScript
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`
|
|
}
|