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
+ }
+ const initial = (name || 'S').slice(0, 1).toUpperCase()
+ return (
+
Checking your account status...
} + {!auth.checked &&Checking your account status...
} {auth.checked && !auth.authenticated && (Sign in to join the study community.
- Posting to: {releasedSections.find(s => s.id === activeSection)?.title ?? 'this lesson'} -
- )} -