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.
This commit is contained in:
110
packages/web/src/components/PostCard.tsx
Normal file
110
packages/web/src/components/PostCard.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
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`
|
||||
}
|
||||
Reference in New Issue
Block a user