@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.
- package/README.md +445 -0
- package/dist/mounajiforum.es.js +1090 -0
- package/dist/mounajiforum.es.js.map +1 -0
- package/dist/mounajiforum.umd.cjs +2 -0
- package/dist/mounajiforum.umd.cjs.map +1 -0
- package/package.json +47 -0
- package/src/CreatePostPage.jsx +237 -0
- package/src/ForumPage.jsx +193 -0
- package/src/PostPage.jsx +122 -0
- package/src/components/CategoryNav.jsx +83 -0
- package/src/components/PostCard.jsx +186 -0
- package/src/components/PostDetail.jsx +89 -0
- package/src/components/ReplyThread.jsx +244 -0
- package/src/components/shared.jsx +171 -0
- package/src/demo.js +125 -0
- package/src/index.js +48 -0
|
@@ -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
|
+
}
|
package/src/PostPage.jsx
ADDED
|
@@ -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
|
+
}
|