Redesign community section with social media feed UX; v1.2.6
- Twitter/X-style post cards: avatar thread line, author + relative timestamp - Heart like button with optimistic toggle and like count per post - Comment button with reply count; inline threaded replies always visible - Compose card with user avatar, borderless textarea, pill Post button - Horizontal scrollable lesson filter chips (All + per-lesson) - New POST /api/study-community/posts/:postId/like endpoint (toggle) - GET community posts now returns likedByMe + likeCount per post - sanitizeStudyCommunityPosts preserves likedBy array on disk - Relative timestamps (just now, Xm ago, Xh ago, Xd ago, then date) - UserAvatar helper renders img or initials fallback consistently Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
874dd0c89b
commit
49e3a80c2c
5 files changed
+259
-133
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1523,6 +1523,7 @@ export function sanitizeStudyCommunityPosts(value) {
|
||||
? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50)
|
||||
: []
|
||||
if (!studySlug || !message) return null
|
||||
const likedBy = Array.isArray(item.likedBy) ? item.likedBy.filter(id => typeof id === 'string' && id.trim()).slice(0, 1000) : []
|
||||
return {
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
studySlug,
|
||||
@@ -1532,6 +1533,7 @@ export function sanitizeStudyCommunityPosts(value) {
|
||||
message,
|
||||
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
|
||||
replies,
|
||||
likedBy,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -363,9 +363,12 @@ export function register(app) {
|
||||
.slice(0, 50)
|
||||
.map(post => {
|
||||
const author = findStudyUserById(post.authorUserId)
|
||||
const likedBy = Array.isArray(post.likedBy) ? post.likedBy : []
|
||||
return {
|
||||
...post,
|
||||
authorAvatarUrl: getStudyAvatarUrl(author || post.authorName || ''),
|
||||
likedByMe: likedBy.includes(user.id),
|
||||
likeCount: likedBy.length,
|
||||
replies: Array.isArray(post.replies)
|
||||
? post.replies.map(reply => {
|
||||
const replyAuthor = findStudyUserById(reply.authorUserId)
|
||||
@@ -452,4 +455,23 @@ export function register(app) {
|
||||
|
||||
res.json({ ok: true, reply })
|
||||
})
|
||||
|
||||
app.post('/api/study-community/posts/:postId/like', requireStudyAuth, (req, res) => {
|
||||
const user = req.studyUser
|
||||
const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
|
||||
const post = state.studyCommunityPosts.find(item => item.id === postId)
|
||||
if (!post) { res.status(404).json({ message: 'Post not found.' }); return }
|
||||
if (!isStudyUserEnrolled(user, post.studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to like posts.' }); return
|
||||
}
|
||||
if (!Array.isArray(post.likedBy)) post.likedBy = []
|
||||
const idx = post.likedBy.indexOf(user.id)
|
||||
if (idx === -1) {
|
||||
post.likedBy.push(user.id)
|
||||
} else {
|
||||
post.likedBy.splice(idx, 1)
|
||||
}
|
||||
queueStudyCommunityWrite()
|
||||
res.json({ ok: true, likeCount: post.likedBy.length, likedByMe: post.likedBy.includes(user.id) })
|
||||
})
|
||||
}
|
||||
+70
@@ -12550,3 +12550,73 @@
|
||||
.admin-save-ok { color: #6ebd7c; font-size: 0.85rem; margin: 0; }
|
||||
.admin-save-error { color: #e05c5c; font-size: 0.85rem; margin: 0; }
|
||||
.admin-field-hint { font-size: 0.78rem; color: var(--admin-muted, #a0957e); margin: 0; }
|
||||
|
||||
/* ── Community feed ──────────────────────────────────────────────────────── */
|
||||
.community-feed { display: flex; flex-direction: column; gap: 0; }
|
||||
|
||||
.community-filter-bar { display: flex; gap: 0.4rem; flex-wrap: nowrap; overflow-x: auto; padding-bottom: 0.25rem; margin-bottom: 1.25rem; scrollbar-width: none; }
|
||||
.community-filter-bar::-webkit-scrollbar { display: none; }
|
||||
.community-filter-chip { flex-shrink: 0; padding: 0.3rem 0.85rem; border-radius: 999px; border: 1px solid var(--brand-border, #2a2518); background: transparent; color: var(--brand-muted, #b0a48c); font-size: 0.82rem; font-weight: 500; cursor: pointer; white-space: nowrap; transition: background 0.15s, color 0.15s, border-color 0.15s; }
|
||||
.community-filter-chip:hover { background: rgba(201,168,76,0.08); color: var(--brand-gold, #c9a84c); border-color: var(--brand-gold, #c9a84c); }
|
||||
.community-filter-chip--active { background: rgba(201,168,76,0.12); color: var(--brand-gold, #c9a84c); border-color: var(--brand-gold, #c9a84c); font-weight: 600; }
|
||||
|
||||
/* Avatar */
|
||||
.community-avatar { border-radius: 50%; object-fit: cover; flex-shrink: 0; display: block; }
|
||||
.community-avatar--initials { background: #2a2518; display: grid; place-items: center; color: var(--brand-warm-white, #f0ead8); font-weight: 700; font-family: var(--brand-font-body, sans-serif); line-height: 1; }
|
||||
|
||||
/* Compose card */
|
||||
.community-compose-card { display: flex; gap: 0.85rem; padding: 1.1rem 1.25rem; background: var(--brand-card-bg, #11100d); border: 1px solid var(--brand-border, #2a2518); border-radius: 14px; margin-bottom: 1.25rem; }
|
||||
.community-compose-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.community-compose-tag { display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; color: var(--brand-muted, #b0a48c); }
|
||||
.community-compose-tag strong { color: var(--brand-gold, #c9a84c); }
|
||||
.community-compose-tag-clear { background: none; border: none; color: var(--brand-muted, #b0a48c); cursor: pointer; font-size: 1rem; line-height: 1; padding: 0 0.1rem; }
|
||||
.community-compose-textarea { width: 100%; padding: 0.7rem 0; background: transparent; border: none; border-bottom: 1px solid var(--brand-border, #2a2518); color: var(--brand-warm-white, #f0ead8); font-family: var(--brand-font-body, sans-serif); font-size: 0.975rem; resize: none; overflow: hidden; outline: none; box-sizing: border-box; transition: border-color 0.15s; }
|
||||
.community-compose-textarea:focus { border-bottom-color: var(--brand-gold, #c9a84c); }
|
||||
.community-compose-textarea::placeholder { color: var(--brand-muted, #b0a48c); }
|
||||
.community-compose-footer { display: flex; justify-content: flex-end; }
|
||||
.community-post-btn { font-size: 0.875rem; padding: 0.4rem 1.1rem; border-radius: 999px; }
|
||||
|
||||
/* Status */
|
||||
.community-status-msg { color: var(--brand-muted, #b0a48c); font-size: 0.9rem; padding: 0.75rem 0; margin: 0; }
|
||||
.community-status-msg--error { color: #e07060; }
|
||||
|
||||
/* Post list */
|
||||
.community-posts-list { display: flex; flex-direction: column; }
|
||||
|
||||
/* Post card */
|
||||
.community-post-card { display: flex; gap: 0.75rem; padding: 1rem 0; border-bottom: 1px solid var(--brand-border, #2a2518); opacity: 1; transition: opacity 300ms; }
|
||||
.community-post-card--pending { opacity: 0.55; }
|
||||
.community-post-card:last-child { border-bottom: none; }
|
||||
.community-post-left { display: flex; flex-direction: column; align-items: center; gap: 0; padding-top: 0.15rem; }
|
||||
.community-thread-line { flex: 1; width: 2px; background: var(--brand-border, #2a2518); margin-top: 0.35rem; border-radius: 1px; min-height: 1rem; }
|
||||
.community-post-body { flex: 1; min-width: 0; }
|
||||
.community-post-header { display: flex; align-items: baseline; gap: 0.55rem; flex-wrap: wrap; margin-bottom: 0.25rem; }
|
||||
.community-post-author { font-weight: 700; font-size: 0.925rem; color: var(--brand-warm-white, #f0ead8); }
|
||||
.community-post-time { font-size: 0.8rem; color: var(--brand-muted, #b0a48c); }
|
||||
.community-lesson-chip { font-size: 0.73rem; font-weight: 500; padding: 0.15rem 0.55rem; border-radius: 999px; border: 1px solid var(--brand-border, #2a2518); background: transparent; color: var(--brand-muted, #b0a48c); cursor: pointer; transition: color 0.15s, border-color 0.15s; white-space: nowrap; }
|
||||
.community-lesson-chip:hover { color: var(--brand-gold, #c9a84c); border-color: var(--brand-gold, #c9a84c); }
|
||||
.community-post-text { font-size: 0.95rem; line-height: 1.6; color: var(--brand-warm-white, #f0ead8); white-space: pre-wrap; margin: 0 0 0.6rem; }
|
||||
|
||||
/* Engagement bar */
|
||||
.community-engagement-bar { display: flex; align-items: center; gap: 0.15rem; margin-bottom: 0.25rem; }
|
||||
.community-action-btn { display: flex; align-items: center; gap: 0.3rem; background: none; border: none; color: var(--brand-muted, #b0a48c); cursor: pointer; padding: 0.3rem 0.6rem; border-radius: 999px; font-size: 0.82rem; font-weight: 500; transition: color 0.15s, background 0.15s; line-height: 1; }
|
||||
.community-action-btn:hover { color: var(--brand-gold, #c9a84c); background: rgba(201,168,76,0.08); }
|
||||
.community-action-btn--liked .community-action-icon { fill: #e05c5c; color: #e05c5c; }
|
||||
.community-action-btn--liked { color: #e05c5c; }
|
||||
.community-action-btn--liked:hover { background: rgba(224,92,92,0.08); color: #e05c5c; }
|
||||
.community-action-btn--active { color: var(--brand-gold, #c9a84c); }
|
||||
.community-action-icon { width: 17px; height: 17px; fill: currentColor; flex-shrink: 0; }
|
||||
|
||||
/* Reply compose */
|
||||
.community-reply-compose { display: flex; gap: 0.65rem; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--brand-border, #2a2518); align-items: flex-start; }
|
||||
|
||||
/* Replies thread */
|
||||
.community-replies { display: flex; flex-direction: column; gap: 0; margin-top: 0.5rem; padding-top: 0.5rem; }
|
||||
.community-reply { display: flex; gap: 0.6rem; padding: 0.65rem 0; border-top: 1px solid color-mix(in srgb, var(--brand-border, #2a2518) 60%, transparent); }
|
||||
.community-reply:first-child { border-top: none; }
|
||||
.community-reply-body { flex: 1; min-width: 0; }
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.community-compose-card { padding: 0.9rem 1rem; }
|
||||
.community-post-card { gap: 0.55rem; padding: 0.85rem 0; }
|
||||
}
|
||||
+164
-132
@@ -91,6 +91,8 @@ type StudyCommunityPost = {
|
||||
message: string
|
||||
createdAt: string
|
||||
replies: StudyCommunityReply[]
|
||||
likeCount?: number
|
||||
likedByMe?: boolean
|
||||
pending?: boolean
|
||||
}
|
||||
|
||||
@@ -228,10 +230,31 @@ function getDisplayName(auth: StudyAuthState) {
|
||||
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
|
||||
}
|
||||
|
||||
function formatCommunityDate(value: string) {
|
||||
function formatRelativeTime(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
const diffMs = Date.now() - date.getTime()
|
||||
const diffSec = Math.floor(diffMs / 1000)
|
||||
if (diffSec < 60) return 'just now'
|
||||
const diffMin = Math.floor(diffSec / 60)
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
const diffHr = Math.floor(diffMin / 60)
|
||||
if (diffHr < 24) return `${diffHr}h ago`
|
||||
const diffDay = Math.floor(diffHr / 24)
|
||||
if (diffDay < 7) return `${diffDay}d ago`
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function UserAvatar({ name, url, size = 36 }: { name?: string; url?: string; size?: number }) {
|
||||
if (url) {
|
||||
return <img src={url} alt={name || 'avatar'} width={size} height={size} className="community-avatar" />
|
||||
}
|
||||
const initial = (name || 'S').slice(0, 1).toUpperCase()
|
||||
return (
|
||||
<div className="community-avatar community-avatar--initials" style={{ width: size, height: size, fontSize: size * 0.4 }}>
|
||||
{initial}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommunityBoard({
|
||||
@@ -248,7 +271,6 @@ function CommunityBoard({
|
||||
const [posts, setPosts] = useState<StudyCommunityPost[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
|
||||
const [replyingTo, setReplyingTo] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
@@ -265,40 +287,25 @@ function CommunityBoard({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
if (!canParticipate) {
|
||||
setPosts([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
if (!canParticipate) { setPosts([]); setLoading(false); return }
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const data = await readJson<{ posts?: StudyCommunityPost[] }>('/api/study-community?' + new URLSearchParams({ studySlug: study.slug }).toString())
|
||||
if (cancelled) return
|
||||
setPosts(Array.isArray(data.posts) ? data.posts : [])
|
||||
if (!cancelled) setPosts(Array.isArray(data.posts) ? data.posts : [])
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setError(err instanceof Error ? err.message : 'Unable to load the community right now.')
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Unable to load the community right now.')
|
||||
} finally {
|
||||
if (cancelled) return
|
||||
setLoading(false)
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
return () => { cancelled = true }
|
||||
}, [canParticipate, study.slug])
|
||||
|
||||
async function submitPost() {
|
||||
if (!newPost.trim() || !canParticipate) return
|
||||
setSubmitting(true)
|
||||
setMessage('')
|
||||
setError('')
|
||||
setSubmitting(true); setError('')
|
||||
const optimisticId = `pending-${Date.now()}`
|
||||
const optimisticPost: StudyCommunityPost = {
|
||||
id: optimisticId,
|
||||
@@ -309,6 +316,8 @@ function CommunityBoard({
|
||||
message: newPost,
|
||||
createdAt: new Date().toISOString(),
|
||||
replies: [],
|
||||
likeCount: 0,
|
||||
likedByMe: false,
|
||||
pending: true,
|
||||
}
|
||||
setPosts(prev => [optimisticPost, ...prev])
|
||||
@@ -321,9 +330,8 @@ function CommunityBoard({
|
||||
body: JSON.stringify({ studySlug: study.slug, sectionId: activeSection, message: postedMessage }),
|
||||
})
|
||||
setPosts(prev => data.post
|
||||
? prev.map(p => p.id === optimisticId ? { ...data.post!, pending: false } : p)
|
||||
? prev.map(p => p.id === optimisticId ? { ...data.post!, likeCount: 0, likedByMe: false, pending: false } : p)
|
||||
: prev.filter(p => p.id !== optimisticId))
|
||||
setMessage('')
|
||||
} catch (err) {
|
||||
setPosts(prev => prev.filter(p => p.id !== optimisticId))
|
||||
setNewPost(postedMessage)
|
||||
@@ -336,8 +344,7 @@ function CommunityBoard({
|
||||
async function submitReply(postId: string) {
|
||||
const reply = replyDrafts[postId]?.trim()
|
||||
if (!reply || !canParticipate) return
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
setSubmitting(true); setError('')
|
||||
try {
|
||||
const data = await readJson<{ reply?: StudyCommunityReply }>(`/api/study-community/posts/${encodeURIComponent(postId)}/replies`, {
|
||||
method: 'POST',
|
||||
@@ -349,7 +356,6 @@ function CommunityBoard({
|
||||
}
|
||||
setReplyDrafts(prev => ({ ...prev, [postId]: '' }))
|
||||
setReplyingTo(null)
|
||||
setMessage('Reply posted.')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to post your reply.')
|
||||
} finally {
|
||||
@@ -357,34 +363,41 @@ function CommunityBoard({
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLike(postId: string) {
|
||||
if (!canParticipate) return
|
||||
setPosts(prev => prev.map(p => p.id === postId
|
||||
? { ...p, likedByMe: !p.likedByMe, likeCount: (p.likeCount ?? 0) + (p.likedByMe ? -1 : 1) }
|
||||
: p))
|
||||
try {
|
||||
await readJson(`/api/study-community/posts/${encodeURIComponent(postId)}/like`, {
|
||||
method: 'POST',
|
||||
})
|
||||
} catch {
|
||||
setPosts(prev => prev.map(p => p.id === postId
|
||||
? { ...p, likedByMe: !p.likedByMe, likeCount: (p.likeCount ?? 0) + (p.likedByMe ? -1 : 1) }
|
||||
: p))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="study-community-board">
|
||||
<div className="community-feed">
|
||||
{/* Section filter tabs */}
|
||||
{releasedSections.length > 1 && canParticipate && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`qa-topic-btn${activeSection === '' ? ' qa-topic-btn--active' : ''}`}
|
||||
onClick={() => setActiveSection('')}
|
||||
>All lessons</button>
|
||||
<div className="community-filter-bar">
|
||||
<button type="button" className={`community-filter-chip${activeSection === '' ? ' community-filter-chip--active' : ''}`} onClick={() => setActiveSection('')}>All</button>
|
||||
{releasedSections.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={`qa-topic-btn${activeSection === s.id ? ' qa-topic-btn--active' : ''}`}
|
||||
onClick={() => setActiveSection(s.id)}
|
||||
>{s.title}</button>
|
||||
<button key={s.id} type="button" className={`community-filter-chip${activeSection === s.id ? ' community-filter-chip--active' : ''}`} onClick={() => setActiveSection(s.id)}>{s.title}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!auth.checked && <p className="study-note-status">Checking your account status...</p>}
|
||||
{!auth.checked && <p className="community-status-msg">Checking your account status...</p>}
|
||||
{auth.checked && !auth.authenticated && (
|
||||
<div className="study-auth-box">
|
||||
<p className="study-auth-why">Sign in to join the study community.</p>
|
||||
<div className="study-auth-actions">
|
||||
<Link to="/study/signup" className="btn-primary">Create Account</Link>
|
||||
<Link to="/study/account" className="btn-secondary">My Account</Link>
|
||||
<Link to="/study/account" className="btn-secondary">Sign In</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -398,112 +411,131 @@ function CommunityBoard({
|
||||
)}
|
||||
|
||||
{canParticipate && (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div className="community-compose-card">
|
||||
<UserAvatar name={communityDisplayName} url={auth.avatarUrl || undefined} size={40} />
|
||||
<div className="community-compose-body">
|
||||
{activeSection && (
|
||||
<p style={{ fontSize: '0.875rem', color: '#7a7060', marginBottom: '0.5rem' }}>
|
||||
Posting to: <strong style={{ color: '#e0c070' }}>{releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}</strong>
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
rows={4}
|
||||
value={newPost}
|
||||
onChange={e => setNewPost(e.target.value)}
|
||||
onInput={e => {
|
||||
const el = e.currentTarget
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}}
|
||||
placeholder={activeSection ? `Share a thought about ${releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}...` : 'Share a thought with the community...'}
|
||||
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem', resize: 'none', overflow: 'hidden', boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div className="study-auth-actions">
|
||||
<button type="button" className="btn-primary" disabled={submitting || !newPost.trim()} onClick={submitPost}>
|
||||
{submitting ? 'Posting...' : 'Post to Community'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="community-compose-tag">
|
||||
<span>Posting to:</span>
|
||||
<strong>{releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}</strong>
|
||||
<button type="button" className="community-compose-tag-clear" onClick={() => setActiveSection('')} aria-label="Clear lesson filter">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && <p className="study-note-status" style={{ marginBottom: '0.75rem' }}>{message}</p>}
|
||||
{error && <p className="study-note-status" style={{ marginBottom: '0.75rem' }}>{error}</p>}
|
||||
|
||||
{canParticipate && (
|
||||
<div style={{ display: 'grid', gap: '0.85rem' }}>
|
||||
{loading ? (
|
||||
<p className="study-detail-copy">Loading community posts...</p>
|
||||
) : visiblePosts.length === 0 ? (
|
||||
<p className="study-detail-copy">No posts yet{activeSection ? ' for this lesson' : ''}. Be the first to start the conversation.</p>
|
||||
) : visiblePosts.map(post => {
|
||||
const postSection = study.sections.find(s => s.id === post.sectionId)
|
||||
return (
|
||||
<div key={post.id} style={{ border: '1px solid #2a2518', borderRadius: '14px', padding: '1.15rem', background: '#11100d', opacity: post.pending ? 0.65 : 1, transition: 'opacity 300ms' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.4rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.65rem', flexWrap: 'wrap' }}>
|
||||
{post.authorAvatarUrl ? (
|
||||
<img src={post.authorAvatarUrl} alt={`${post.authorName || 'Student'} avatar`} width={28} height={28} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
|
||||
) : (
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: '#2a2518', display: 'grid', placeItems: 'center', color: '#f0ead8', fontSize: '0.85rem', fontWeight: 700 }}>
|
||||
{post.authorName?.slice(0, 1).toUpperCase() || 'S'}
|
||||
</div>
|
||||
)}
|
||||
<strong style={{ color: '#e0c070' }}>{post.authorName || 'Student'}</strong>
|
||||
{postSection && !activeSection && (
|
||||
<button
|
||||
type="button"
|
||||
className="qa-topic-btn"
|
||||
style={{ padding: '0.2rem 0.6rem', fontSize: '0.78rem' }}
|
||||
onClick={() => setActiveSection(postSection.id)}
|
||||
>{postSection.title}</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="study-note-status">{formatCommunityDate(post.createdAt)}</span>
|
||||
</div>
|
||||
<p className="study-detail-copy" style={{ marginTop: 0, whiteSpace: 'pre-wrap' }}>{post.message}</p>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginTop: '0.75rem' }}>
|
||||
<button type="button" className="btn-secondary" style={{ fontSize: '0.85rem', padding: '0.35rem 0.85rem' }} onClick={() => setReplyingTo(curr => curr === post.id ? null : post.id)}>
|
||||
{replyingTo === post.id ? 'Cancel' : `Reply${post.replies?.length ? ` (${post.replies.length})` : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
{replyingTo === post.id && canParticipate && (
|
||||
<div style={{ marginTop: '0.85rem' }}>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={newPost}
|
||||
onChange={e => setNewPost(e.target.value)}
|
||||
onInput={e => { const el = e.currentTarget; el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px` }}
|
||||
placeholder={activeSection ? `Share a thought about ${releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'}…` : 'Share a thought with the community…'}
|
||||
className="community-compose-textarea"
|
||||
/>
|
||||
<div className="community-compose-footer">
|
||||
<button type="button" className="btn-primary community-post-btn" disabled={submitting || !newPost.trim()} onClick={submitPost}>
|
||||
{submitting ? 'Posting…' : 'Post'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="community-status-msg community-status-msg--error">{error}</p>}
|
||||
|
||||
{canParticipate && (
|
||||
<div className="community-posts-list">
|
||||
{loading ? (
|
||||
<p className="community-status-msg">Loading posts…</p>
|
||||
) : visiblePosts.length === 0 ? (
|
||||
<p className="community-status-msg">No posts yet{activeSection ? ' for this lesson' : ''}. Be the first to start the conversation.</p>
|
||||
) : visiblePosts.map(post => {
|
||||
const postSection = study.sections.find(s => s.id === post.sectionId)
|
||||
const isReplying = replyingTo === post.id
|
||||
return (
|
||||
<article key={post.id} className={`community-post-card${post.pending ? ' community-post-card--pending' : ''}`}>
|
||||
<div className="community-post-left">
|
||||
<UserAvatar name={post.authorName} url={post.authorAvatarUrl} size={40} />
|
||||
{(post.replies?.length ?? 0) > 0 && <div className="community-thread-line" />}
|
||||
</div>
|
||||
<div className="community-post-body">
|
||||
{/* Header */}
|
||||
<div className="community-post-header">
|
||||
<span className="community-post-author">{post.authorName || 'Student'}</span>
|
||||
<span className="community-post-time" title={post.createdAt}>{formatRelativeTime(post.createdAt)}</span>
|
||||
{postSection && !activeSection && (
|
||||
<button type="button" className="community-lesson-chip" onClick={() => setActiveSection(postSection.id)}>{postSection.title}</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Content */}
|
||||
<p className="community-post-text">{post.message}</p>
|
||||
{/* Engagement bar */}
|
||||
<div className="community-engagement-bar">
|
||||
<button
|
||||
type="button"
|
||||
className={`community-action-btn${post.likedByMe ? ' community-action-btn--liked' : ''}`}
|
||||
onClick={() => toggleLike(post.id)}
|
||||
aria-label={post.likedByMe ? 'Unlike' : 'Like'}
|
||||
disabled={post.pending}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="community-action-icon" aria-hidden="true">
|
||||
<path d={post.likedByMe
|
||||
? 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'
|
||||
: 'M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z'}
|
||||
/>
|
||||
</svg>
|
||||
{(post.likeCount ?? 0) > 0 && <span>{post.likeCount}</span>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`community-action-btn${isReplying ? ' community-action-btn--active' : ''}`}
|
||||
onClick={() => setReplyingTo(curr => curr === post.id ? null : post.id)}
|
||||
aria-label="Reply"
|
||||
disabled={post.pending}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="community-action-icon" aria-hidden="true">
|
||||
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" />
|
||||
</svg>
|
||||
{(post.replies?.length ?? 0) > 0 && <span>{post.replies!.length}</span>}
|
||||
</button>
|
||||
</div>
|
||||
{/* Reply compose */}
|
||||
{isReplying && (
|
||||
<div className="community-reply-compose">
|
||||
<UserAvatar name={communityDisplayName} url={auth.avatarUrl || undefined} size={30} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<textarea
|
||||
rows={2}
|
||||
autoFocus
|
||||
value={replyDrafts[post.id] ?? ''}
|
||||
onChange={e => setReplyDrafts(prev => ({ ...prev, [post.id]: e.target.value }))}
|
||||
onInput={e => {
|
||||
const el = e.currentTarget
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}}
|
||||
placeholder={`Reply to ${post.authorName || communityDisplayName}...`}
|
||||
style={{ width: '100%', padding: '0.8rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem', resize: 'none', overflow: 'hidden' }}
|
||||
onInput={e => { const el = e.currentTarget; el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px` }}
|
||||
placeholder={`Reply to ${post.authorName || 'this post'}…`}
|
||||
className="community-compose-textarea"
|
||||
/>
|
||||
<button type="button" className="btn-primary" style={{ fontSize: '0.9rem' }} disabled={submitting || !(replyDrafts[post.id] ?? '').trim()} onClick={() => submitReply(post.id)}>Post Reply</button>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||
<button type="button" className="btn-primary" style={{ fontSize: '0.875rem', padding: '0.35rem 0.9rem' }} disabled={submitting || !(replyDrafts[post.id] ?? '').trim()} onClick={() => submitReply(post.id)}>Reply</button>
|
||||
<button type="button" className="btn-secondary" style={{ fontSize: '0.875rem', padding: '0.35rem 0.9rem' }} onClick={() => setReplyingTo(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Replies */}
|
||||
{Array.isArray(post.replies) && post.replies.length > 0 && (
|
||||
<div style={{ marginTop: '1rem', display: 'grid', gap: '0.65rem' }}>
|
||||
<div className="community-replies">
|
||||
{post.replies.map(reply => (
|
||||
<div key={reply.id} style={{ borderLeft: '2px solid #2a2518', paddingLeft: '0.85rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.55rem', flexWrap: 'wrap' }}>
|
||||
{reply.authorAvatarUrl ? (
|
||||
<img src={reply.authorAvatarUrl} alt={`${reply.authorName || 'Student'} avatar`} width={22} height={22} style={{ borderRadius: '50%', border: '1px solid #2a2518' }} />
|
||||
) : (
|
||||
<div style={{ width: 22, height: 22, borderRadius: '50%', background: '#2a2518', display: 'grid', placeItems: 'center', color: '#f0ead8', fontSize: '0.75rem', fontWeight: 700 }}>
|
||||
{reply.authorName?.slice(0, 1).toUpperCase() || 'S'}
|
||||
<div key={reply.id} className="community-reply">
|
||||
<UserAvatar name={reply.authorName} url={reply.authorAvatarUrl} size={28} />
|
||||
<div className="community-reply-body">
|
||||
<div className="community-post-header">
|
||||
<span className="community-post-author">{reply.authorName || 'Student'}</span>
|
||||
<span className="community-post-time">{formatRelativeTime(reply.createdAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
<strong style={{ color: '#c9a84c', fontSize: '0.95rem' }}>{reply.authorName || 'Student'}</strong>
|
||||
<p className="community-post-text">{reply.message}</p>
|
||||
</div>
|
||||
<span className="study-note-status">{formatCommunityDate(reply.createdAt)}</span>
|
||||
</div>
|
||||
<p className="study-detail-copy" style={{ margin: '0.25rem 0 0', whiteSpace: 'pre-wrap' }}>{reply.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user