@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,244 @@
1
+ /**
2
+ * ReplyThread — @mounaji_npm/forum
3
+ *
4
+ * Exports:
5
+ * ReplyCard — single reply with vote + accepted badge
6
+ * ReplyThread — ordered list of ReplyCards + ReplyComposer
7
+ * ReplyComposer — textarea + submit button for writing a reply
8
+ */
9
+
10
+ import { useState } from 'react';
11
+ import { VoteButton, AuthorMeta, Avatar } from './shared.jsx';
12
+
13
+ // ─── ReplyCard ────────────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Props:
17
+ * reply — Reply object { id, body, author, votes, userVote, createdAt, isAccepted }
18
+ * onVote — (replyId, vote) => void
19
+ * onAccept — (replyId) => void (marks as accepted answer)
20
+ * canAccept — boolean (only post author can accept)
21
+ * isDark — boolean
22
+ */
23
+ export function ReplyCard({ reply, onVote, onAccept, canAccept = false, isDark = true }) {
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
+ const acceptedBorder = reply.isAccepted ? 'rgba(16,185,129,0.35)' : border;
30
+ const acceptedBg = reply.isAccepted
31
+ ? (isDark ? 'rgba(16,185,129,0.05)' : 'rgba(16,185,129,0.04)')
32
+ : card;
33
+
34
+ return (
35
+ <div style={{ display: 'flex', borderRadius: 'var(--mn-radius-lg, 0.75rem)', backgroundColor: acceptedBg, border: `1px solid ${acceptedBorder}`, overflow: 'hidden' }}>
36
+ {/* Vote column */}
37
+ <div style={{ padding: '16px 14px', borderRight: `1px solid ${border}`, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, flexShrink: 0 }}>
38
+ <VoteButton
39
+ count={reply.votes}
40
+ userVote={reply.userVote ?? 0}
41
+ onChange={v => onVote?.(reply.id, v)}
42
+ vertical
43
+ isDark={isDark}
44
+ size="sm"
45
+ />
46
+ {/* Accept button */}
47
+ {(reply.isAccepted || canAccept) && (
48
+ <button
49
+ onClick={() => onAccept?.(reply.id)}
50
+ title={reply.isAccepted ? 'Accepted answer' : 'Mark as accepted'}
51
+ style={{
52
+ marginTop: 6, width: 28, height: 28, borderRadius: 6, border: 'none', cursor: canAccept ? 'pointer' : 'default',
53
+ backgroundColor: reply.isAccepted ? 'rgba(16,185,129,0.15)' : 'transparent',
54
+ color: reply.isAccepted ? '#10B981' : textSec,
55
+ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14,
56
+ transition: 'background 100ms',
57
+ }}
58
+ onMouseEnter={e => { if (canAccept && !reply.isAccepted) e.currentTarget.style.backgroundColor = 'rgba(16,185,129,0.1)'; }}
59
+ onMouseLeave={e => { if (!reply.isAccepted) e.currentTarget.style.backgroundColor = 'transparent'; }}
60
+ >
61
+
62
+ </button>
63
+ )}
64
+ </div>
65
+
66
+ {/* Content */}
67
+ <div style={{ flex: 1, padding: '16px 18px', minWidth: 0 }}>
68
+ {reply.isAccepted && (
69
+ <div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: '0.75rem', fontWeight: 600, color: '#10B981', marginBottom: 10 }}>
70
+ <span>✓</span> Accepted Answer
71
+ </div>
72
+ )}
73
+
74
+ <div style={{ fontSize: '0.9375rem', color: textPri, lineHeight: 1.65, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
75
+ {reply.body}
76
+ </div>
77
+
78
+ <div style={{ marginTop: 12 }}>
79
+ <AuthorMeta author={reply.author} date={reply.createdAt} prefix="replied" isDark={isDark} size="sm" />
80
+ </div>
81
+ </div>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ // ─── ReplyThread ──────────────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Props:
90
+ * replies — Reply[]
91
+ * onVote — (replyId, vote) => void
92
+ * onAccept — (replyId) => void
93
+ * canAccept — boolean
94
+ * currentUser — { id, name, avatar? } | null
95
+ * onSubmit — (body: string) => void | Promise<void>
96
+ * isLocked — boolean
97
+ * isDark — boolean
98
+ */
99
+ export function ReplyThread({ replies = [], onVote, onAccept, canAccept = false, currentUser, onSubmit, isLocked = false, isDark = true }) {
100
+ const textPri = isDark ? '#F0F4FF' : '#1A1A2E';
101
+ const textSec = isDark ? '#94A3B8' : '#64748B';
102
+ const border = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.08)';
103
+
104
+ // Pin accepted answer to top, then sort by votes
105
+ const sorted = [...replies].sort((a, b) => {
106
+ if (a.isAccepted && !b.isAccepted) return -1;
107
+ if (!a.isAccepted && b.isAccepted) return 1;
108
+ return b.votes - a.votes;
109
+ });
110
+
111
+ return (
112
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
113
+ {/* Header */}
114
+ {replies.length > 0 && (
115
+ <div style={{ paddingBottom: 12, borderBottom: `1px solid ${border}` }}>
116
+ <h2 style={{ margin: 0, fontSize: '1rem', fontWeight: 600, color: textPri }}>
117
+ {replies.length} {replies.length === 1 ? 'Reply' : 'Replies'}
118
+ </h2>
119
+ </div>
120
+ )}
121
+
122
+ {/* Reply cards */}
123
+ {sorted.length > 0 && (
124
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
125
+ {sorted.map(reply => (
126
+ <ReplyCard
127
+ key={reply.id}
128
+ reply={reply}
129
+ onVote={onVote}
130
+ onAccept={onAccept}
131
+ canAccept={canAccept}
132
+ isDark={isDark}
133
+ />
134
+ ))}
135
+ </div>
136
+ )}
137
+
138
+ {/* Composer or locked notice */}
139
+ {isLocked ? (
140
+ <div style={{ textAlign: 'center', padding: '20px', color: textSec, fontSize: '0.875rem', backgroundColor: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)', borderRadius: 'var(--mn-radius-md, 0.5rem)', border: `1px solid ${border}` }}>
141
+ 🔒 This thread is locked — no new replies allowed.
142
+ </div>
143
+ ) : (
144
+ <ReplyComposer
145
+ currentUser={currentUser}
146
+ onSubmit={onSubmit}
147
+ isDark={isDark}
148
+ placeholder={replies.length === 0 ? 'Be the first to reply…' : 'Write a reply…'}
149
+ />
150
+ )}
151
+ </div>
152
+ );
153
+ }
154
+
155
+ // ─── ReplyComposer ────────────────────────────────────────────────────────────
156
+
157
+ /**
158
+ * Props:
159
+ * currentUser — { name, avatar? } | null
160
+ * onSubmit — (body: string) => void | Promise<void>
161
+ * placeholder — string
162
+ * isDark — boolean
163
+ */
164
+ export function ReplyComposer({ currentUser, onSubmit, placeholder = 'Write a reply…', isDark = true }) {
165
+ const [body, setBody] = useState('');
166
+ const [submitting, setSub] = useState(false);
167
+ const [error, setError] = useState('');
168
+
169
+ const card = isDark ? 'var(--mn-color-card-dark, #0B0F23)' : 'var(--mn-color-card-light, #FAFAF8)';
170
+ const border = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.08)';
171
+ const textPri = isDark ? '#F0F4FF' : '#1A1A2E';
172
+ const textSec = isDark ? '#94A3B8' : '#64748B';
173
+ const inputBg = isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)';
174
+
175
+ async function handleSubmit() {
176
+ const trimmed = body.trim();
177
+ if (!trimmed) { setError('Reply cannot be empty.'); return; }
178
+ setError('');
179
+ setSub(true);
180
+ try {
181
+ await onSubmit?.(trimmed);
182
+ setBody('');
183
+ } finally {
184
+ setSub(false);
185
+ }
186
+ }
187
+
188
+ return (
189
+ <div style={{ borderRadius: 'var(--mn-radius-lg, 0.75rem)', backgroundColor: card, border: `1px solid ${border}`, padding: '16px 18px', display: 'flex', flexDirection: 'column', gap: 12 }}>
190
+ {/* Composer header */}
191
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
192
+ <Avatar name={currentUser?.name ?? '?'} size={26} src={currentUser?.avatar} />
193
+ <span style={{ fontSize: '0.8125rem', fontWeight: 600, color: textPri }}>
194
+ {currentUser ? currentUser.name : 'Reply as Guest'}
195
+ </span>
196
+ </div>
197
+
198
+ {/* Textarea */}
199
+ <textarea
200
+ value={body}
201
+ onChange={e => { setBody(e.target.value); if (error) setError(''); }}
202
+ placeholder={placeholder}
203
+ rows={4}
204
+ style={{
205
+ width: '100%', boxSizing: 'border-box',
206
+ padding: '10px 12px', borderRadius: 'var(--mn-radius-md, 0.5rem)',
207
+ backgroundColor: inputBg, border: `1px solid ${error ? 'rgba(239,68,68,0.4)' : border}`,
208
+ color: textPri, fontSize: '0.9375rem', lineHeight: 1.6, resize: 'vertical',
209
+ fontFamily: 'var(--mn-font-family, system-ui, sans-serif)', outline: 'none',
210
+ transition: 'border-color 120ms',
211
+ }}
212
+ onFocus={e => { e.target.style.borderColor = 'var(--mn-color-primary, #3B82F6)'; }}
213
+ onBlur={e => { e.target.style.borderColor = error ? 'rgba(239,68,68,0.4)' : border; }}
214
+ />
215
+
216
+ {error && <p style={{ margin: 0, fontSize: '0.8125rem', color: '#F87171' }}>{error}</p>}
217
+
218
+ {/* Footer */}
219
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
220
+ <span style={{ fontSize: '0.75rem', color: textSec }}>{body.length} / 8000</span>
221
+ <button
222
+ onClick={handleSubmit}
223
+ disabled={submitting || !body.trim()}
224
+ style={{
225
+ padding: '7px 18px', borderRadius: 'var(--mn-radius-md, 0.5rem)', border: 'none',
226
+ cursor: submitting || !body.trim() ? 'not-allowed' : 'pointer',
227
+ backgroundColor: submitting || !body.trim() ? 'rgba(59,130,246,0.4)' : 'var(--mn-color-primary, #3B82F6)',
228
+ color: '#fff', fontSize: '0.875rem', fontWeight: 600, fontFamily: 'inherit',
229
+ transition: 'background 120ms', display: 'flex', alignItems: 'center', gap: 7,
230
+ }}
231
+ >
232
+ {submitting && <Spinner />}
233
+ {submitting ? 'Posting…' : 'Post Reply'}
234
+ </button>
235
+ </div>
236
+ </div>
237
+ );
238
+ }
239
+
240
+ function Spinner() {
241
+ return <span style={{ display: 'inline-block', width: 12, height: 12, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: '#fff', borderRadius: '50%', animation: 'mn-spin 0.6s linear infinite' }}>
242
+ <style>{`@keyframes mn-spin { to { transform: rotate(360deg); } }`}</style>
243
+ </span>;
244
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * shared.jsx — @mounaji_npm/forum
3
+ * Small reusable primitives: VoteButton, AuthorMeta, TagChip, CategoryBadge
4
+ */
5
+
6
+ import { useState } from 'react';
7
+
8
+ // ─── VoteButton ────────────────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * Props:
12
+ * count — number
13
+ * userVote — 1 | 0 | -1
14
+ * onChange(newVote: 1 | 0 | -1)
15
+ * vertical — boolean (default: false — horizontal layout)
16
+ * isDark — boolean
17
+ */
18
+ export function VoteButton({ count = 0, userVote = 0, onChange, vertical = false, isDark = true, size = 'md' }) {
19
+ const [optimistic, setOptimistic] = useState(null);
20
+
21
+ const vote = optimistic ?? userVote;
22
+ const total = optimistic !== null ? count + (optimistic - userVote) : count;
23
+ const upCol = vote === 1 ? '#10B981' : (isDark ? '#64748B' : '#94A3B8');
24
+ const downCol = vote === -1 ? '#EF4444' : (isDark ? '#64748B' : '#94A3B8');
25
+ const numCol = vote === 1 ? '#10B981' : vote === -1 ? '#EF4444' : (isDark ? '#F0F4FF' : '#1A1A2E');
26
+ const btnSz = size === 'sm' ? 22 : 28;
27
+ const iconSz = size === 'sm' ? 11 : 13;
28
+
29
+ function cast(v) {
30
+ const next = vote === v ? 0 : v;
31
+ setOptimistic(next);
32
+ onChange?.(next);
33
+ }
34
+
35
+ const wrapStyle = vertical
36
+ ? { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }
37
+ : { display: 'flex', alignItems: 'center', gap: 4 };
38
+
39
+ return (
40
+ <div style={wrapStyle}>
41
+ <VBtn size={btnSz} icon={size === 'sm' ? '▲' : '▲'} color={upCol} iconSz={iconSz} onClick={() => cast(1)} title="Upvote" />
42
+ <span style={{ fontSize: size === 'sm' ? '0.75rem' : '0.875rem', fontWeight: 700, color: numCol, minWidth: 20, textAlign: 'center' }}>
43
+ {total}
44
+ </span>
45
+ {vertical && <VBtn size={btnSz} icon="▼" color={downCol} iconSz={iconSz} onClick={() => cast(-1)} title="Downvote" />}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ function VBtn({ size, icon, color, iconSz, onClick, title }) {
51
+ return (
52
+ <button
53
+ onClick={onClick}
54
+ title={title}
55
+ style={{
56
+ width: size, height: size, borderRadius: 4, border: 'none', cursor: 'pointer',
57
+ backgroundColor: 'transparent', color, fontSize: iconSz,
58
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
59
+ transition: 'background 100ms, color 100ms', padding: 0, fontFamily: 'inherit',
60
+ }}
61
+ onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.07)'; }}
62
+ onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
63
+ >
64
+ {icon}
65
+ </button>
66
+ );
67
+ }
68
+
69
+ // ─── AuthorMeta ────────────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Props:
73
+ * author — { name, avatar? }
74
+ * date — string | Date
75
+ * prefix — string (default: '')
76
+ * isDark — boolean
77
+ * size — 'sm' | 'md'
78
+ */
79
+ export function AuthorMeta({ author, date, prefix = '', isDark = true, size = 'md' }) {
80
+ const textSec = isDark ? '#94A3B8' : '#64748B';
81
+ const textPri = isDark ? '#F0F4FF' : '#1A1A2E';
82
+ const avatarSz = size === 'sm' ? 18 : 22;
83
+ const fontSize = size === 'sm' ? '0.75rem' : '0.8125rem';
84
+
85
+ return (
86
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
87
+ <Avatar name={author?.name ?? '?'} size={avatarSz} src={author?.avatar} />
88
+ <span style={{ fontSize, color: textPri, fontWeight: 500 }}>{author?.name ?? 'unknown'}</span>
89
+ {(prefix || date) && (
90
+ <span style={{ fontSize, color: textSec }}>
91
+ {prefix && <>{prefix} </>}{date ? formatRelative(date) : ''}
92
+ </span>
93
+ )}
94
+ </div>
95
+ );
96
+ }
97
+
98
+ // ─── TagChip ─────────────────────────────────────────────────────────────────
99
+
100
+ export function TagChip({ label, onClick, isDark = true }) {
101
+ const bg = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)';
102
+ const border = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
103
+ const color = isDark ? '#94A3B8' : '#64748B';
104
+ return (
105
+ <span
106
+ onClick={onClick}
107
+ style={{
108
+ fontSize: '0.7rem', padding: '2px 8px', borderRadius: 9999,
109
+ backgroundColor: bg, color, border: `1px solid ${border}`,
110
+ cursor: onClick ? 'pointer' : 'default', fontFamily: 'inherit',
111
+ transition: 'background 100ms',
112
+ }}
113
+ >
114
+ #{label}
115
+ </span>
116
+ );
117
+ }
118
+
119
+ // ─── CategoryBadge ────────────────────────────────────────────────────────────
120
+
121
+ const CAT_COLORS = {
122
+ announcements: { bg: 'rgba(239,68,68,0.12)', text: '#F87171', border: 'rgba(239,68,68,0.25)' },
123
+ general: { bg: 'rgba(59,130,246,0.12)', text: '#60A5FA', border: 'rgba(59,130,246,0.25)' },
124
+ questions: { bg: 'rgba(245,158,11,0.12)', text: '#FCD34D', border: 'rgba(245,158,11,0.25)' },
125
+ ideas: { bg: 'rgba(139,92,246,0.12)', text: '#A78BFA', border: 'rgba(139,92,246,0.25)' },
126
+ bugs: { bg: 'rgba(239,68,68,0.12)', text: '#FCA5A5', border: 'rgba(239,68,68,0.2)' },
127
+ default: { bg: 'rgba(255,255,255,0.06)', text: '#94A3B8', border: 'rgba(255,255,255,0.1)' },
128
+ };
129
+
130
+ export function CategoryBadge({ category }) {
131
+ const c = CAT_COLORS[category?.id] ?? CAT_COLORS.default;
132
+ return (
133
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.7rem', fontWeight: 500, padding: '2px 8px', borderRadius: 9999, backgroundColor: c.bg, color: c.text, border: `1px solid ${c.border}` }}>
134
+ {category?.icon && <span>{category.icon}</span>}
135
+ {category?.label}
136
+ </span>
137
+ );
138
+ }
139
+
140
+ // ─── Avatar ───────────────────────────────────────────────────────────────────
141
+
142
+ export function Avatar({ name, size = 24, src }) {
143
+ const initials = (name ?? '?').slice(0, 1).toUpperCase();
144
+ const hue = [...(name ?? '')].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0);
145
+ const bg = `hsl(${hue}, 55%, 35%)`;
146
+
147
+ if (src) {
148
+ return <img src={src} alt={name} style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover' }} />;
149
+ }
150
+ return (
151
+ <div style={{ width: size, height: size, borderRadius: '50%', backgroundColor: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.45, fontWeight: 700, color: '#fff', flexShrink: 0, userSelect: 'none' }}>
152
+ {initials}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
158
+
159
+ export function formatRelative(date) {
160
+ const d = typeof date === 'string' ? new Date(date) : date;
161
+ const diff = (Date.now() - d.getTime()) / 1000;
162
+ if (diff < 60) return 'just now';
163
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
164
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
165
+ if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
166
+ return d.toLocaleDateString();
167
+ }
168
+
169
+ export function plural(n, word) {
170
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
171
+ }
package/src/demo.js ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Demo data for @mounaji_npm/forum
3
+ * Import to populate forum components during prototyping.
4
+ */
5
+
6
+ export const DEMO_CATEGORIES = [
7
+ { id: 'all', label: 'All Posts', icon: '◉', count: 124 },
8
+ { id: 'announcements', label: 'Announcements', icon: '📣', count: 8, pinned: true },
9
+ { id: 'general', label: 'General', icon: '💬', count: 32 },
10
+ { id: 'questions', label: 'Questions', icon: '❓', count: 45 },
11
+ { id: 'ideas', label: 'Ideas', icon: '💡', count: 27 },
12
+ { id: 'bugs', label: 'Bug Reports', icon: '🐛', count: 12 },
13
+ ];
14
+
15
+ export const DEMO_POSTS = [
16
+ {
17
+ id: '1',
18
+ title: 'Introducing the new dark mode token system',
19
+ body: 'We have completely overhauled the design token system to support seamless dark/light mode switching. The new system uses CSS custom properties under a `data-mn-theme` attribute, which means zero-JavaScript theme switching in most cases.',
20
+ author: { id: 'u1', name: 'alex_m', avatar: null },
21
+ category: { id: 'announcements', label: 'Announcements', icon: '📣' },
22
+ tags: ['design-system', 'tokens', 'dark-mode'],
23
+ votes: 87,
24
+ userVote: 0,
25
+ replyCount: 14,
26
+ viewCount: 420,
27
+ createdAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(),
28
+ isPinned: true,
29
+ isSolved: false,
30
+ },
31
+ {
32
+ id: '2',
33
+ title: 'How do I add custom categories to my forum?',
34
+ body: 'I am setting up a community forum and want to add my own categories beyond the defaults. Is there a config option for this, or do I need to pass a custom categories array?',
35
+ author: { id: 'u2', name: 'sara_dev', avatar: null },
36
+ category: { id: 'questions', label: 'Questions', icon: '❓' },
37
+ tags: ['configuration', 'categories'],
38
+ votes: 12,
39
+ userVote: 0,
40
+ replyCount: 5,
41
+ viewCount: 98,
42
+ createdAt: new Date(Date.now() - 1000 * 60 * 60 * 6).toISOString(),
43
+ isSolved: true,
44
+ },
45
+ {
46
+ id: '3',
47
+ title: 'Feature request: markdown support in replies',
48
+ body: 'It would be great to have rich text / markdown support in the reply composer. Being able to add code blocks, links, and bold/italic text would make the forum much more useful for technical discussions.',
49
+ author: { id: 'u3', name: 'techwriter_k', avatar: null },
50
+ category: { id: 'ideas', label: 'Ideas', icon: '💡' },
51
+ tags: ['markdown', 'editor', 'replies'],
52
+ votes: 34,
53
+ userVote: 1,
54
+ replyCount: 9,
55
+ viewCount: 203,
56
+ createdAt: new Date(Date.now() - 1000 * 60 * 60 * 12).toISOString(),
57
+ },
58
+ {
59
+ id: '4',
60
+ title: 'Vote count not updating in real-time',
61
+ body: 'After clicking the upvote button, the vote count updates locally but does not sync back from the server on page refresh. Seems like the onVote callback is firing but the optimistic update is not being persisted.',
62
+ author: { id: 'u4', name: 'james_d', avatar: null },
63
+ category: { id: 'bugs', label: 'Bug Reports', icon: '🐛' },
64
+ tags: ['voting', 'bug', 'real-time'],
65
+ votes: 7,
66
+ userVote: 0,
67
+ replyCount: 3,
68
+ viewCount: 61,
69
+ createdAt: new Date(Date.now() - 1000 * 60 * 60 * 18).toISOString(),
70
+ },
71
+ {
72
+ id: '5',
73
+ title: 'Best practices for organizing forum categories',
74
+ body: 'We are launching a developer community and are unsure how to structure our categories. Should we go broad (3-5 categories) or granular (10+ sub-categories)? Looking for advice from people who have run communities before.',
75
+ author: { id: 'u5', name: 'community_lead', avatar: null },
76
+ category: { id: 'general', label: 'General', icon: '💬' },
77
+ tags: ['community', 'organization', 'best-practices'],
78
+ votes: 22,
79
+ userVote: 0,
80
+ replyCount: 17,
81
+ viewCount: 312,
82
+ createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
83
+ },
84
+ {
85
+ id: '6',
86
+ title: 'Building a searchable FAQ from pinned posts',
87
+ body: 'One pattern I have found useful is using pinned posts as a structured FAQ. You can tag them with `faq` and surface them in a dedicated section. Here is how I set it up with the forum module.',
88
+ author: { id: 'u1', name: 'alex_m', avatar: null },
89
+ category: { id: 'general', label: 'General', icon: '💬' },
90
+ tags: ['faq', 'pinned', 'pattern'],
91
+ votes: 41,
92
+ userVote: 0,
93
+ replyCount: 6,
94
+ viewCount: 188,
95
+ createdAt: new Date(Date.now() - 1000 * 60 * 60 * 36).toISOString(),
96
+ },
97
+ ];
98
+
99
+ export const DEMO_REPLIES = [
100
+ {
101
+ id: 'r1',
102
+ body: 'You can pass a `categories` prop directly to `<ForumPage>`. The shape is `{ id, label, icon, count }`. If you want a global default, set it in your `mn-config.js` under the `forum` key.',
103
+ author: { id: 'u1', name: 'alex_m', avatar: null },
104
+ votes: 15,
105
+ userVote: 1,
106
+ createdAt: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(),
107
+ isAccepted: true,
108
+ },
109
+ {
110
+ id: 'r2',
111
+ body: 'Also worth noting — you can use the `CategoryNav` component standalone if you want to place it somewhere other than the default sidebar.',
112
+ author: { id: 'u3', name: 'techwriter_k', avatar: null },
113
+ votes: 8,
114
+ userVote: 0,
115
+ createdAt: new Date(Date.now() - 1000 * 60 * 60 * 4).toISOString(),
116
+ },
117
+ {
118
+ id: 'r3',
119
+ body: 'Great question — I had the same issue when I first set up the forum. The docs example in the README shows the full config including custom categories.',
120
+ author: { id: 'u5', name: 'community_lead', avatar: null },
121
+ votes: 3,
122
+ userVote: 0,
123
+ createdAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(),
124
+ },
125
+ ];
package/src/index.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @mounaji_npm/forum — Public API
3
+ *
4
+ * Full pages (drop-in routes):
5
+ * ForumPage — post listing with category sidebar + search
6
+ * PostPage — post detail with reply thread
7
+ * CreatePostPage — new post form
8
+ *
9
+ * Standalone components (compose your own layout):
10
+ * PostCard, PostDetail, ReplyCard, ReplyThread,
11
+ * CategoryNav, VoteButton, AuthorMeta, TagChip
12
+ */
13
+
14
+ // ── Full pages ────────────────────────────────────────────────────────────────
15
+ export { ForumPage } from './ForumPage.jsx';
16
+ export { PostPage } from './PostPage.jsx';
17
+ export { CreatePostPage } from './CreatePostPage.jsx';
18
+
19
+ // ── Standalone components ─────────────────────────────────────────────────────
20
+ export {
21
+ PostCard,
22
+ PostList,
23
+ EmptyPostList,
24
+ } from './components/PostCard.jsx';
25
+
26
+ export {
27
+ PostDetail,
28
+ } from './components/PostDetail.jsx';
29
+
30
+ export {
31
+ ReplyCard,
32
+ ReplyThread,
33
+ ReplyComposer,
34
+ } from './components/ReplyThread.jsx';
35
+
36
+ export {
37
+ CategoryNav,
38
+ } from './components/CategoryNav.jsx';
39
+
40
+ export {
41
+ VoteButton,
42
+ AuthorMeta,
43
+ TagChip,
44
+ CategoryBadge,
45
+ } from './components/shared.jsx';
46
+
47
+ // ── Demo data (for prototyping) ───────────────────────────────────────────────
48
+ export { DEMO_POSTS, DEMO_CATEGORIES, DEMO_REPLIES } from './demo.js';