swadhinbiswas Initial release v0.1.0 · 1/16/2026
ed7a4e4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import { motion } from 'framer-motion';
import { MessageCircle, Image as ImageIcon, Pin, Lock } from 'lucide-react';
import { CatalogThread } from '../../types/api';
import { getThumbnailUrl } from '../../utils/apiClient';
import { extractPlainText } from '../../utils/parser';
import { formatNumber } from '../../utils/formatters';
import { getBoardTheme } from '../../lib/design-tokens';
interface ThreadCardProps {
thread: CatalogThread;
board: string;
onClick: () => void;
}
export default function ThreadCard({ thread, board, onClick }: ThreadCardProps) {
const themeColor = getBoardTheme(board);
const excerpt = extractPlainText(thread.com, 150);
const hasThumbnail = thread.tim && thread.ext;
return (
<motion.div
whileHover={{
scale: 1.02,
y: -4,
boxShadow: '0 10px 40px -10px rgba(0,0,0,0.5)'
}}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
onClick={onClick}
className="thread-card cursor-pointer bg-dark-surface rounded-lg overflow-hidden border border-dark-border hover:border-primary-500/50 transition-colors"
style={{
borderTopColor: themeColor,
borderTopWidth: '3px',
}}
>
{/* Thumbnail */}
{hasThumbnail ? (
<div className="aspect-video bg-dark-elevated relative overflow-hidden">
<img
src={getThumbnailUrl(board, thread.tim!)}
alt={thread.filename || 'Thread image'}
className="w-full h-full object-cover"
loading="lazy"
/>
{/* Badges */}
<div className="absolute top-2 right-2 flex gap-1">
{thread.sticky === 1 && (
<div className="bg-green-600 p-1 rounded">
<Pin className="w-3 h-3 text-white" />
</div>
)}
{thread.closed === 1 && (
<div className="bg-red-600 p-1 rounded">
<Lock className="w-3 h-3 text-white" />
</div>
)}
</div>
</div>
) : (
<div className="aspect-video bg-dark-elevated flex items-center justify-center">
<MessageCircle className="w-12 h-12 text-gray-600" />
</div>
)}
{/* Content */}
<div className="p-3">
{/* Subject */}
{thread.sub && (
<h3 className="font-semibold text-sm mb-1 truncate" title={thread.sub}>
{thread.sub}
</h3>
)}
{/* Excerpt */}
{excerpt && (
<p className="text-xs text-gray-400 truncate-2 mb-2 leading-relaxed">
{excerpt}
</p>
)}
{/* Stats */}
<div className="flex items-center gap-3 text-xs text-gray-400">
<div className="flex items-center gap-1">
<MessageCircle className="w-3 h-3" />
<span>{formatNumber(thread.replies)}</span>
</div>
<div className="flex items-center gap-1">
<ImageIcon className="w-3 h-3" />
<span>{formatNumber(thread.images)}</span>
</div>
{thread.unique_ips && (
<div className="flex items-center gap-1">
<span className="text-[10px]">👤</span>
<span>{thread.unique_ips}</span>
</div>
)}
</div>
{/* Limit indicators */}
{(thread.bumplimit === 1 || thread.imagelimit === 1) && (
<div className="flex gap-1 mt-2">
{thread.bumplimit === 1 && (
<span className="text-[10px] px-2 py-0.5 bg-orange-600/20 text-orange-400 rounded">
Bump Limit
</span>
)}
{thread.imagelimit === 1 && (
<span className="text-[10px] px-2 py-0.5 bg-blue-600/20 text-blue-400 rounded">
Image Limit
</span>
)}
</div>
)}
</div>
</motion.div>
);
}