@mounaji_npm/forum 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * ForumPage — @mounaji_npm/forum
3
+ *
4
+ * Full forum listing page: category sidebar + search/sort header + post list.
5
+ * Self-contained — no router assumptions, all navigation via callbacks.
6
+ *
7
+ * Props:
8
+ * categories — Category[] sidebar list (default: DEMO_CATEGORIES)
9
+ * posts — Post[] (default: DEMO_POSTS)
10
+ * activeCategory — string category id (default: 'all')
11
+ * searchQuery — string
12
+ * sortBy — 'newest'|'top'|'unanswered'
13
+ * onCategoryChange — (id) => void
14
+ * onSearch — (q) => void
15
+ * onSortChange — (sort) => void
16
+ * onPostClick — (postId) => void
17
+ * onNewPost — () => void
18
+ * onVote — (postId, vote) => void
19
+ * isLoading — boolean
20
+ * isDark — boolean (default: true)
21
+ * header — React node
22
+ * title — string (default: 'Forum')
23
+ * subtitle — string
24
+ * style — CSSProperties
25
+ */
26
+
27
+ import { useState, useMemo } from 'react';
28
+ import { CategoryNav } from './components/CategoryNav.jsx';
29
+ import { PostList } from './components/PostCard.jsx';
30
+ import { DEMO_POSTS, DEMO_CATEGORIES } from './demo.js';
31
+
32
+ const SORTS = [
33
+ { id: 'newest', label: 'Newest' },
34
+ { id: 'top', label: 'Top Voted' },
35
+ { id: 'unanswered', label: 'Unanswered' },
36
+ ];
37
+
38
+ export function ForumPage({
39
+ categories = DEMO_CATEGORIES,
40
+ posts = DEMO_POSTS,
41
+ activeCategory = 'all',
42
+ searchQuery = '',
43
+ sortBy = 'newest',
44
+ onCategoryChange,
45
+ onSearch,
46
+ onSortChange,
47
+ onPostClick,
48
+ onNewPost,
49
+ onVote,
50
+ isLoading = false,
51
+ isDark = true,
52
+ header,
53
+ title = 'Forum',
54
+ subtitle = 'Discussions, questions, and ideas from the community.',
55
+ style,
56
+ }) {
57
+ const [localSearch, setLocalSearch] = useState(searchQuery);
58
+ const [localSort, setLocalSort] = useState(sortBy);
59
+ const [localCategory, setLocalCategory] = useState(activeCategory);
60
+
61
+ const textPri = isDark ? 'var(--mn-text-primary-dark, #F0F4FF)' : 'var(--mn-text-primary-light, #1A1A2E)';
62
+ const textSec = isDark ? 'var(--mn-text-secondary-dark, #94A3B8)' : 'var(--mn-text-secondary-light, #64748B)';
63
+ const border = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.08)';
64
+ const inputBg = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)';
65
+
66
+ function handleSearch(q) {
67
+ setLocalSearch(q);
68
+ onSearch?.(q);
69
+ }
70
+
71
+ function handleSort(s) {
72
+ setLocalSort(s);
73
+ onSortChange?.(s);
74
+ }
75
+
76
+ function handleCategory(id) {
77
+ setLocalCategory(id);
78
+ onCategoryChange?.(id);
79
+ }
80
+
81
+ // Client-side filter + sort (overridden by server data when posts prop changes)
82
+ const displayed = useMemo(() => {
83
+ let list = posts;
84
+ if (localCategory !== 'all') {
85
+ list = list.filter(p => p.category?.id === localCategory);
86
+ }
87
+ if (localSearch.trim()) {
88
+ const q = localSearch.toLowerCase();
89
+ list = list.filter(p => p.title.toLowerCase().includes(q) || p.body?.toLowerCase().includes(q));
90
+ }
91
+ if (localSort === 'top') list = [...list].sort((a, b) => b.votes - a.votes);
92
+ if (localSort === 'newest') list = [...list].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
93
+ if (localSort === 'unanswered') list = list.filter(p => !p.isSolved && (p.replyCount ?? 0) === 0);
94
+ // Pinned always first
95
+ return [...list].sort((a, b) => (b.isPinned ? 1 : 0) - (a.isPinned ? 1 : 0));
96
+ }, [posts, localCategory, localSearch, localSort]);
97
+
98
+ return (
99
+ <div style={{
100
+ padding: 'var(--mn-spacing-xl, 32px)',
101
+ display: 'flex', flexDirection: 'column', gap: 24,
102
+ fontFamily: 'var(--mn-font-family, system-ui, sans-serif)',
103
+ ...style,
104
+ }}>
105
+ {/* Header */}
106
+ {header ?? (
107
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 12 }}>
108
+ <div>
109
+ <h1 style={{ margin: 0, fontSize: 'var(--mn-font-size-2xl, 1.5rem)', fontWeight: 700, color: textPri }}>{title}</h1>
110
+ {subtitle && <p style={{ margin: '4px 0 0', fontSize: '0.875rem', color: textSec }}>{subtitle}</p>}
111
+ </div>
112
+ <button
113
+ onClick={onNewPost}
114
+ style={{ padding: '8px 18px', borderRadius: 'var(--mn-radius-md, 0.5rem)', border: 'none', cursor: 'pointer', backgroundColor: 'var(--mn-color-primary, #3B82F6)', color: '#fff', fontSize: '0.875rem', fontWeight: 600, fontFamily: 'inherit', display: 'flex', alignItems: 'center', gap: 6 }}
115
+ >
116
+ + New Post
117
+ </button>
118
+ </div>
119
+ )}
120
+
121
+ {/* Body: sidebar + main */}
122
+ <div style={{ display: 'flex', gap: 20, alignItems: 'flex-start' }}>
123
+ {/* Sidebar */}
124
+ <div style={{ width: 200, flexShrink: 0, position: 'sticky', top: 80 }}>
125
+ <CategoryNav
126
+ categories={categories}
127
+ active={localCategory}
128
+ onChange={handleCategory}
129
+ isDark={isDark}
130
+ />
131
+ </div>
132
+
133
+ {/* Main column */}
134
+ <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 14 }}>
135
+ {/* Search + sort bar */}
136
+ <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
137
+ {/* Search */}
138
+ <div style={{ position: 'relative', flex: '1 1 200px' }}>
139
+ <span style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: textSec, pointerEvents: 'none', fontSize: 14 }}>🔍</span>
140
+ <input
141
+ value={localSearch}
142
+ onChange={e => handleSearch(e.target.value)}
143
+ placeholder="Search posts…"
144
+ style={{ width: '100%', boxSizing: 'border-box', padding: '8px 10px 8px 32px', borderRadius: 'var(--mn-radius-md, 0.5rem)', backgroundColor: inputBg, border: `1px solid ${border}`, color: textPri, fontSize: '0.875rem', fontFamily: 'inherit', outline: 'none' }}
145
+ onFocus={e => { e.target.style.borderColor = 'var(--mn-color-primary, #3B82F6)'; }}
146
+ onBlur={e => { e.target.style.borderColor = border; }}
147
+ />
148
+ </div>
149
+
150
+ {/* Sort tabs */}
151
+ <div style={{ display: 'flex', gap: 2, backgroundColor: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)', padding: 3, borderRadius: 'var(--mn-radius-md, 0.5rem)' }}>
152
+ {SORTS.map(s => {
153
+ const isActive = localSort === s.id;
154
+ return (
155
+ <button
156
+ key={s.id}
157
+ onClick={() => handleSort(s.id)}
158
+ style={{
159
+ padding: '4px 12px', borderRadius: 6, border: 'none', cursor: 'pointer',
160
+ backgroundColor: isActive ? (isDark ? 'rgba(255,255,255,0.09)' : '#fff') : 'transparent',
161
+ color: isActive ? textPri : textSec,
162
+ fontSize: '0.8125rem', fontWeight: isActive ? 600 : 400, fontFamily: 'inherit',
163
+ transition: 'all 100ms',
164
+ }}
165
+ >
166
+ {s.label}
167
+ </button>
168
+ );
169
+ })}
170
+ </div>
171
+ </div>
172
+
173
+ {/* Post count */}
174
+ {!isLoading && (
175
+ <p style={{ margin: 0, fontSize: '0.8125rem', color: textSec }}>
176
+ {displayed.length} {displayed.length === 1 ? 'post' : 'posts'}
177
+ {localSearch ? ` matching "${localSearch}"` : ''}
178
+ </p>
179
+ )}
180
+
181
+ {/* Post list */}
182
+ <PostList
183
+ posts={displayed}
184
+ onPostClick={onPostClick}
185
+ onVote={onVote}
186
+ isLoading={isLoading}
187
+ isDark={isDark}
188
+ />
189
+ </div>
190
+ </div>
191
+ </div>
192
+ );
193
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * PostPage — @mounaji_npm/forum
3
+ *
4
+ * Full post detail page: back button, post content, reply thread.
5
+ * No router assumptions — navigation via callbacks.
6
+ *
7
+ * Props:
8
+ * post — Post object (required)
9
+ * replies — Reply[]
10
+ * currentUser — { id, name, avatar? } | null
11
+ * onBack — () => void
12
+ * onVotePost — (postId, vote) => void
13
+ * onVoteReply — (replyId, vote) => void
14
+ * onAccept — (replyId) => void
15
+ * onSubmitReply — (body: string) => void | Promise<void>
16
+ * isLoading — boolean
17
+ * isDark — boolean (default: true)
18
+ * style — CSSProperties
19
+ */
20
+
21
+ import { PostDetail } from './components/PostDetail.jsx';
22
+ import { ReplyThread } from './components/ReplyThread.jsx';
23
+ import { DEMO_POSTS, DEMO_REPLIES } from './demo.js';
24
+
25
+ export function PostPage({
26
+ post = DEMO_POSTS[0],
27
+ replies = DEMO_REPLIES,
28
+ currentUser = null,
29
+ onBack,
30
+ onVotePost,
31
+ onVoteReply,
32
+ onAccept,
33
+ onSubmitReply,
34
+ isLoading = false,
35
+ isDark = true,
36
+ style,
37
+ }) {
38
+ const textPri = isDark ? 'var(--mn-text-primary-dark, #F0F4FF)' : 'var(--mn-text-primary-light, #1A1A2E)';
39
+ const textSec = isDark ? 'var(--mn-text-secondary-dark, #94A3B8)' : 'var(--mn-text-secondary-light, #64748B)';
40
+
41
+ // Only the post author can accept replies
42
+ const canAccept = currentUser && post?.author?.id === currentUser?.id;
43
+
44
+ if (isLoading) {
45
+ return (
46
+ <div style={{ padding: 'var(--mn-spacing-xl, 32px)', fontFamily: 'var(--mn-font-family, system-ui, sans-serif)', ...style }}>
47
+ <PostSkeleton isDark={isDark} />
48
+ </div>
49
+ );
50
+ }
51
+
52
+ if (!post) {
53
+ return (
54
+ <div style={{ padding: 'var(--mn-spacing-xl, 32px)', textAlign: 'center', color: textSec, fontFamily: 'var(--mn-font-family, system-ui, sans-serif)', ...style }}>
55
+ <p style={{ fontSize: 32, margin: '0 0 12px' }}>🔍</p>
56
+ <p style={{ margin: 0, fontWeight: 600, color: textPri }}>Post not found</p>
57
+ </div>
58
+ );
59
+ }
60
+
61
+ return (
62
+ <div style={{
63
+ padding: 'var(--mn-spacing-xl, 32px)',
64
+ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 860,
65
+ fontFamily: 'var(--mn-font-family, system-ui, sans-serif)',
66
+ ...style,
67
+ }}>
68
+ {/* Back nav */}
69
+ <button
70
+ onClick={onBack}
71
+ style={{
72
+ alignSelf: 'flex-start', display: 'flex', alignItems: 'center', gap: 6,
73
+ padding: '6px 12px', borderRadius: 'var(--mn-radius-md, 0.5rem)', border: 'none',
74
+ cursor: 'pointer', backgroundColor: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)',
75
+ color: textSec, fontSize: '0.875rem', fontFamily: 'inherit', transition: 'background 100ms',
76
+ }}
77
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'; }}
78
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'; }}
79
+ >
80
+ ← Back to Forum
81
+ </button>
82
+
83
+ {/* Post content */}
84
+ <PostDetail
85
+ post={post}
86
+ onVote={onVotePost}
87
+ isDark={isDark}
88
+ />
89
+
90
+ {/* Reply thread */}
91
+ <ReplyThread
92
+ replies={replies}
93
+ onVote={onVoteReply}
94
+ onAccept={onAccept}
95
+ canAccept={canAccept}
96
+ currentUser={currentUser}
97
+ onSubmit={onSubmitReply}
98
+ isLocked={post.isLocked}
99
+ isDark={isDark}
100
+ />
101
+ </div>
102
+ );
103
+ }
104
+
105
+ function PostSkeleton({ isDark }) {
106
+ const bg = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)';
107
+ const border = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.08)';
108
+ const card = isDark ? 'var(--mn-color-card-dark, #0B0F23)' : 'var(--mn-color-card-light, #FAFAF8)';
109
+ const S = (w, h = 12) => ({ width: w, height: h, borderRadius: 6, backgroundColor: bg, animation: 'mn-pulse 1.4s ease infinite' });
110
+ return (
111
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
112
+ <div style={S(80)} />
113
+ <div style={{ borderRadius: 'var(--mn-radius-lg, 0.75rem)', backgroundColor: card, border: `1px solid ${border}`, padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 14 }}>
114
+ <div style={S('30%', 8)} />
115
+ <div style={S('70%', 20)} />
116
+ <div style={S('45%', 10)} />
117
+ <div style={{ height: 80, borderRadius: 6, backgroundColor: bg }} />
118
+ </div>
119
+ <style>{`@keyframes mn-pulse { 0%,100%{opacity:.5} 50%{opacity:1} }`}</style>
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * CategoryNav — @mounaji_npm/forum
3
+ *
4
+ * Sidebar category list with counts and active state.
5
+ *
6
+ * Props:
7
+ * categories — { id, label, icon?, count?, pinned? }[]
8
+ * active — string (category id)
9
+ * onChange(id) — callback
10
+ * isDark — boolean
11
+ * title — string (default: 'Categories')
12
+ * style — CSSProperties
13
+ */
14
+
15
+ export function CategoryNav({ categories = [], active = 'all', onChange, isDark = true, title = 'Categories', style }) {
16
+ const card = isDark ? 'var(--mn-color-card-dark, #0B0F23)' : 'var(--mn-color-card-light, #FAFAF8)';
17
+ const border = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.08)';
18
+ const textPri = isDark ? 'var(--mn-text-primary-dark, #F0F4FF)' : 'var(--mn-text-primary-light, #1A1A2E)';
19
+ const textSec = isDark ? 'var(--mn-text-secondary-dark, #94A3B8)' : 'var(--mn-text-secondary-light, #64748B)';
20
+
21
+ return (
22
+ <div style={{
23
+ borderRadius: 'var(--mn-radius-lg, 0.75rem)',
24
+ backgroundColor: card,
25
+ border: `1px solid ${border}`,
26
+ overflow: 'hidden',
27
+ ...style,
28
+ }}>
29
+ {title && (
30
+ <div style={{ padding: '12px 16px', borderBottom: `1px solid ${border}` }}>
31
+ <p style={{ margin: 0, fontSize: '0.75rem', fontWeight: 600, color: textSec, textTransform: 'uppercase', letterSpacing: '0.07em' }}>
32
+ {title}
33
+ </p>
34
+ </div>
35
+ )}
36
+ <div style={{ padding: '8px 0' }}>
37
+ {categories.map(cat => {
38
+ const isActive = active === cat.id;
39
+ return (
40
+ <button
41
+ key={cat.id}
42
+ onClick={() => onChange?.(cat.id)}
43
+ style={{
44
+ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
45
+ padding: '7px 16px', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
46
+ backgroundColor: isActive
47
+ ? (isDark ? 'rgba(59,130,246,0.1)' : 'rgba(59,130,246,0.07)')
48
+ : 'transparent',
49
+ borderLeft: `3px solid ${isActive ? 'var(--mn-color-primary, #3B82F6)' : 'transparent'}`,
50
+ transition: 'all 100ms',
51
+ gap: 8,
52
+ }}
53
+ onMouseEnter={e => { if (!isActive) e.currentTarget.style.backgroundColor = isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'; }}
54
+ onMouseLeave={e => { if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'; }}
55
+ >
56
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
57
+ {cat.icon && <span style={{ fontSize: 14, flexShrink: 0 }}>{cat.icon}</span>}
58
+ <span style={{
59
+ fontSize: '0.875rem',
60
+ fontWeight: isActive ? 600 : 400,
61
+ color: isActive ? 'var(--mn-color-primary, #3B82F6)' : textPri,
62
+ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
63
+ }}>
64
+ {cat.label}
65
+ </span>
66
+ </div>
67
+ {cat.count != null && (
68
+ <span style={{
69
+ fontSize: '0.7rem', fontWeight: 500, padding: '1px 7px', borderRadius: 9999,
70
+ backgroundColor: isActive ? 'rgba(59,130,246,0.15)' : (isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'),
71
+ color: isActive ? 'var(--mn-color-primary, #3B82F6)' : textSec,
72
+ flexShrink: 0,
73
+ }}>
74
+ {cat.count}
75
+ </span>
76
+ )}
77
+ </button>
78
+ );
79
+ })}
80
+ </div>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * PostCard — @mounaji_npm/forum
3
+ *
4
+ * Compact card shown in the forum listing. Displays vote count, category badge,
5
+ * title, preview text, author meta, reply/view counts, and status badges.
6
+ *
7
+ * Props (single post):
8
+ * post — Post object (see data shape in demo.js)
9
+ * onClick — () => void
10
+ * onVote — (postId, vote: 1|0|-1) => void
11
+ * isDark — boolean
12
+ * compact — boolean (smaller variant, no body preview)
13
+ * style — CSSProperties
14
+ *
15
+ * PostList:
16
+ * posts, onPostClick, onVote, isLoading, isDark
17
+ */
18
+
19
+ import { VoteButton, AuthorMeta, TagChip, CategoryBadge } from './shared.jsx';
20
+
21
+ // ─── PostCard ────────────────────────────────────────────────────────────────
22
+
23
+ export function PostCard({ post, onClick, onVote, isDark = true, compact = false, style }) {
24
+ const card = isDark ? 'var(--mn-color-card-dark, #0B0F23)' : 'var(--mn-color-card-light, #FAFAF8)';
25
+ const border = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.08)';
26
+ const textPri = isDark ? 'var(--mn-text-primary-dark, #F0F4FF)' : 'var(--mn-text-primary-light, #1A1A2E)';
27
+ const textSec = isDark ? 'var(--mn-text-secondary-dark, #94A3B8)' : 'var(--mn-text-secondary-light, #64748B)';
28
+
29
+ return (
30
+ <div
31
+ onClick={onClick}
32
+ style={{
33
+ display: 'flex', gap: 14, padding: compact ? '12px 16px' : '16px 18px',
34
+ borderRadius: 'var(--mn-radius-lg, 0.75rem)',
35
+ backgroundColor: card, border: `1px solid ${border}`,
36
+ cursor: onClick ? 'pointer' : 'default',
37
+ transition: 'border-color 120ms, background 120ms',
38
+ ...style,
39
+ }}
40
+ onMouseEnter={e => { if (onClick) { e.currentTarget.style.borderColor = isDark ? 'rgba(255,255,255,0.14)' : 'rgba(0,0,0,0.16)'; } }}
41
+ onMouseLeave={e => { e.currentTarget.style.borderColor = border; }}
42
+ >
43
+ {/* Vote column */}
44
+ <div
45
+ onClick={e => e.stopPropagation()}
46
+ style={{ flexShrink: 0, paddingTop: 2 }}
47
+ >
48
+ <VoteButton
49
+ count={post.votes}
50
+ userVote={post.userVote ?? 0}
51
+ onChange={v => onVote?.(post.id, v)}
52
+ vertical
53
+ isDark={isDark}
54
+ size="sm"
55
+ />
56
+ </div>
57
+
58
+ {/* Content */}
59
+ <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
60
+ {/* Badges row */}
61
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
62
+ {post.isPinned && (
63
+ <span style={{ fontSize: '0.7rem', fontWeight: 600, padding: '2px 7px', borderRadius: 9999, backgroundColor: 'rgba(245,158,11,0.12)', color: '#FCD34D', border: '1px solid rgba(245,158,11,0.25)' }}>
64
+ 📌 Pinned
65
+ </span>
66
+ )}
67
+ {post.isSolved && (
68
+ <span style={{ fontSize: '0.7rem', fontWeight: 600, padding: '2px 7px', borderRadius: 9999, backgroundColor: 'rgba(16,185,129,0.12)', color: '#34D399', border: '1px solid rgba(16,185,129,0.25)' }}>
69
+ ✓ Solved
70
+ </span>
71
+ )}
72
+ <CategoryBadge category={post.category} />
73
+ </div>
74
+
75
+ {/* Title */}
76
+ <h3 style={{ margin: 0, fontSize: compact ? '0.9375rem' : '1rem', fontWeight: 600, color: textPri, lineHeight: 1.4 }}>
77
+ {post.title}
78
+ </h3>
79
+
80
+ {/* Body preview */}
81
+ {!compact && post.body && (
82
+ <p style={{ margin: 0, fontSize: '0.8125rem', color: textSec, lineHeight: 1.55, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
83
+ {post.body}
84
+ </p>
85
+ )}
86
+
87
+ {/* Tags */}
88
+ {post.tags?.length > 0 && (
89
+ <div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
90
+ {post.tags.map(t => <TagChip key={t} label={t} isDark={isDark} />)}
91
+ </div>
92
+ )}
93
+
94
+ {/* Footer meta */}
95
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginTop: 2 }}>
96
+ <AuthorMeta author={post.author} date={post.createdAt} isDark={isDark} size="sm" />
97
+ <MetaCount icon="💬" value={post.replyCount} label="replies" isDark={isDark} />
98
+ <MetaCount icon="👁" value={post.viewCount} label="views" isDark={isDark} />
99
+ </div>
100
+ </div>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ function MetaCount({ icon, value, label, isDark }) {
106
+ const color = isDark ? '#64748B' : '#94A3B8';
107
+ return (
108
+ <span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.75rem', color }}>
109
+ <span style={{ fontSize: 12 }}>{icon}</span>
110
+ {value} {label}
111
+ </span>
112
+ );
113
+ }
114
+
115
+ // ─── PostList ─────────────────────────────────────────────────────────────────
116
+
117
+ export function PostList({ posts = [], onPostClick, onVote, isLoading = false, isDark = true }) {
118
+ if (isLoading) {
119
+ return (
120
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
121
+ {Array.from({ length: 4 }).map((_, i) => (
122
+ <SkeletonCard key={i} isDark={isDark} />
123
+ ))}
124
+ </div>
125
+ );
126
+ }
127
+
128
+ if (posts.length === 0) {
129
+ return <EmptyPostList isDark={isDark} />;
130
+ }
131
+
132
+ return (
133
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
134
+ {posts.map(post => (
135
+ <PostCard
136
+ key={post.id}
137
+ post={post}
138
+ onClick={() => onPostClick?.(post.id)}
139
+ onVote={onVote}
140
+ isDark={isDark}
141
+ />
142
+ ))}
143
+ </div>
144
+ );
145
+ }
146
+
147
+ export function EmptyPostList({ isDark = true, message = 'No posts yet', cta, onCta }) {
148
+ const textPri = isDark ? '#F0F4FF' : '#1A1A2E';
149
+ const textSec = isDark ? '#94A3B8' : '#64748B';
150
+ return (
151
+ <div style={{ textAlign: 'center', padding: '64px 24px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
152
+ <p style={{ fontSize: 40, margin: 0 }}>💬</p>
153
+ <p style={{ margin: 0, fontSize: '1rem', fontWeight: 600, color: textPri }}>{message}</p>
154
+ <p style={{ margin: 0, fontSize: '0.875rem', color: textSec }}>Be the first to start a discussion.</p>
155
+ {cta && (
156
+ <button
157
+ onClick={onCta}
158
+ style={{ marginTop: 8, padding: '8px 20px', borderRadius: 'var(--mn-radius-md, 0.5rem)', border: 'none', cursor: 'pointer', backgroundColor: 'var(--mn-color-primary, #3B82F6)', color: '#fff', fontSize: '0.875rem', fontWeight: 600, fontFamily: 'inherit' }}
159
+ >
160
+ {cta}
161
+ </button>
162
+ )}
163
+ </div>
164
+ );
165
+ }
166
+
167
+ function SkeletonCard({ isDark }) {
168
+ const bg = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)';
169
+ const border = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.08)';
170
+ const S = (w, h = 12) => ({ width: w, height: h, borderRadius: 6, backgroundColor: bg, animation: 'mn-pulse 1.4s ease infinite' });
171
+ return (
172
+ <div style={{ display: 'flex', gap: 14, padding: '16px 18px', borderRadius: 'var(--mn-radius-lg, 0.75rem)', backgroundColor: isDark ? 'var(--mn-color-card-dark, #0B0F23)' : 'var(--mn-color-card-light, #FAFAF8)', border: `1px solid ${border}` }}>
173
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, paddingTop: 2, flexShrink: 0 }}>
174
+ <div style={S(22, 10)} />
175
+ <div style={S(22, 10)} />
176
+ </div>
177
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 9 }}>
178
+ <div style={S('60%', 8)} />
179
+ <div style={S('85%', 14)} />
180
+ <div style={S('50%', 8)} />
181
+ <div style={S('40%', 8)} />
182
+ </div>
183
+ <style>{`@keyframes mn-pulse { 0%,100%{opacity:.5} 50%{opacity:1} }`}</style>
184
+ </div>
185
+ );
186
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * PostDetail — @mounaji_npm/forum
3
+ *
4
+ * Full post content view: title, body, metadata, tags.
5
+ * Used inside PostPage.
6
+ *
7
+ * Props:
8
+ * post — Post object
9
+ * onVote — (postId, vote) => void
10
+ * isDark — boolean
11
+ */
12
+
13
+ import { VoteButton, AuthorMeta, TagChip, CategoryBadge } from './shared.jsx';
14
+
15
+ export function PostDetail({ post, onVote, isDark = true }) {
16
+ const card = isDark ? 'var(--mn-color-card-dark, #0B0F23)' : 'var(--mn-color-card-light, #FAFAF8)';
17
+ const border = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.08)';
18
+ const textPri = isDark ? 'var(--mn-text-primary-dark, #F0F4FF)' : 'var(--mn-text-primary-light, #1A1A2E)';
19
+ const textSec = isDark ? 'var(--mn-text-secondary-dark, #94A3B8)' : 'var(--mn-text-secondary-light, #64748B)';
20
+
21
+ return (
22
+ <div style={{ borderRadius: 'var(--mn-radius-lg, 0.75rem)', backgroundColor: card, border: `1px solid ${border}`, overflow: 'hidden' }}>
23
+ {/* Post header */}
24
+ <div style={{ padding: '20px 24px', borderBottom: `1px solid ${border}` }}>
25
+ {/* Badges */}
26
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
27
+ {post.isPinned && <StatusBadge color="warning">📌 Pinned</StatusBadge>}
28
+ {post.isSolved && <StatusBadge color="success">✓ Solved</StatusBadge>}
29
+ {post.isLocked && <StatusBadge color="muted">🔒 Locked</StatusBadge>}
30
+ <CategoryBadge category={post.category} />
31
+ </div>
32
+
33
+ {/* Title */}
34
+ <h1 style={{ margin: '0 0 14px', fontSize: 'var(--mn-font-size-xl, 1.25rem)', fontWeight: 700, color: textPri, lineHeight: 1.4 }}>
35
+ {post.title}
36
+ </h1>
37
+
38
+ {/* Author + date + stats */}
39
+ <div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
40
+ <AuthorMeta author={post.author} date={post.createdAt} prefix="posted" isDark={isDark} />
41
+ <span style={{ fontSize: '0.8125rem', color: textSec }}>👁 {post.viewCount ?? 0} views</span>
42
+ <span style={{ fontSize: '0.8125rem', color: textSec }}>💬 {post.replyCount ?? 0} replies</span>
43
+ </div>
44
+ </div>
45
+
46
+ {/* Post body + vote */}
47
+ <div style={{ display: 'flex', gap: 0 }}>
48
+ {/* Vote column */}
49
+ <div style={{ padding: '20px 16px', borderRight: `1px solid ${border}`, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, flexShrink: 0 }}>
50
+ <VoteButton
51
+ count={post.votes}
52
+ userVote={post.userVote ?? 0}
53
+ onChange={v => onVote?.(post.id, v)}
54
+ vertical
55
+ isDark={isDark}
56
+ />
57
+ </div>
58
+
59
+ {/* Body */}
60
+ <div style={{ flex: 1, padding: '20px 24px' }}>
61
+ <div style={{ fontSize: '0.9375rem', color: textPri, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
62
+ {post.body}
63
+ </div>
64
+
65
+ {/* Tags */}
66
+ {post.tags?.length > 0 && (
67
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 18 }}>
68
+ {post.tags.map(t => <TagChip key={t} label={t} isDark={isDark} />)}
69
+ </div>
70
+ )}
71
+ </div>
72
+ </div>
73
+ </div>
74
+ );
75
+ }
76
+
77
+ function StatusBadge({ color, children }) {
78
+ const COLORS = {
79
+ warning: { bg: 'rgba(245,158,11,0.12)', text: '#FCD34D', border: 'rgba(245,158,11,0.25)' },
80
+ success: { bg: 'rgba(16,185,129,0.12)', text: '#34D399', border: 'rgba(16,185,129,0.25)' },
81
+ muted: { bg: 'rgba(107,114,128,0.12)', text: '#9CA3AF', border: 'rgba(107,114,128,0.2)' },
82
+ };
83
+ const c = COLORS[color] ?? COLORS.muted;
84
+ return (
85
+ <span style={{ fontSize: '0.7rem', fontWeight: 600, padding: '2px 8px', borderRadius: 9999, backgroundColor: c.bg, color: c.text, border: `1px solid ${c.border}` }}>
86
+ {children}
87
+ </span>
88
+ );
89
+ }