From 49e3a80c2c8be30b7a83cd818a428aa4a0dfe6f5 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 3 Sep 2026 16:34:28 -0400 Subject: [PATCH] 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 --- package.json | 2 +- server/data.js | 2 + server/routes/study-data.js | 22 +++ src/App.css | 70 +++++++++ src/colossiansStudy.tsx | 300 ++++++++++++++++++++---------------- 5 files changed, 261 insertions(+), 135 deletions(-) diff --git a/package.json b/package.json index 22148ec..9e6d173 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.2.5", + "version": "1.2.6", "type": "module", "scripts": { "dev": "vite", diff --git a/server/data.js b/server/data.js index 542f391..e68eef8 100644 --- a/server/data.js +++ b/server/data.js @@ -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) diff --git a/server/routes/study-data.js b/server/routes/study-data.js index b76ad75..47e290e 100644 --- a/server/routes/study-data.js +++ b/server/routes/study-data.js @@ -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) }) + }) } diff --git a/src/App.css b/src/App.css index 82e6061..4ba82cc 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } +} diff --git a/src/colossiansStudy.tsx b/src/colossiansStudy.tsx index 169ee6b..d8e5149 100644 --- a/src/colossiansStudy.tsx +++ b/src/colossiansStudy.tsx @@ -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 {name + } + const initial = (name || 'S').slice(0, 1).toUpperCase() + return ( +
+ {initial} +
+ ) } function CommunityBoard({ @@ -248,7 +271,6 @@ function CommunityBoard({ const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') - const [message, setMessage] = useState('') const [replyDrafts, setReplyDrafts] = useState>({}) const [replyingTo, setReplyingTo] = useState(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 ( -
+
{/* Section filter tabs */} {releasedSections.length > 1 && canParticipate && ( -
- +
+ {releasedSections.map(s => ( - + ))}
)} - {!auth.checked &&

Checking your account status...

} + {!auth.checked &&

Checking your account status...

} {auth.checked && !auth.authenticated && (

Sign in to join the study community.

Create Account - My Account + Sign In
)} @@ -398,112 +411,131 @@ function CommunityBoard({ )} {canParticipate && ( -
- {activeSection && ( -

- Posting to: {releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'} -

- )} -