Add study community discussion board and Ask Nate button
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"1-1-2": "Test"
|
||||
}
|
||||
@@ -61,6 +61,7 @@ const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
|
||||
const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json')
|
||||
const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json') // legacy — kept only for one-time migration
|
||||
const STUDY_NOTES_DIR = path.join(DATA_DIR, 'study-notes')
|
||||
const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json')
|
||||
const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
|
||||
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
|
||||
const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
|
||||
@@ -630,6 +631,8 @@ let studyUsers = []
|
||||
let studyUsersWritePromise = Promise.resolve()
|
||||
const studyNotesCache = new Map() // userId -> { [sectionId]: string }
|
||||
const studyNotesWriteQueues = new Map() // userId -> Promise
|
||||
let studyCommunityPosts = []
|
||||
let studyCommunityWritePromise = Promise.resolve()
|
||||
let downloadCounts = {}
|
||||
let downloadCountsWritePromise = Promise.resolve()
|
||||
let lastVisitorStatsWrite = { ok: true, at: null, error: null }
|
||||
@@ -1245,6 +1248,7 @@ async function createBackupSnapshot(reason = 'scheduled') {
|
||||
hitStats,
|
||||
visitorStats,
|
||||
contactSubmissions,
|
||||
studyCommunityPosts,
|
||||
replyTemplates,
|
||||
replyHistory,
|
||||
}
|
||||
@@ -1931,6 +1935,38 @@ function getCanonicalBaseUrl() {
|
||||
return configured.trim()
|
||||
}
|
||||
|
||||
function buildBrandedEmailHtml({
|
||||
title,
|
||||
eyebrow,
|
||||
bodyHtml,
|
||||
ctaLabel,
|
||||
ctaUrl,
|
||||
footerHtml,
|
||||
}) {
|
||||
const ctaBlock = ctaLabel && ctaUrl
|
||||
? `<p style="margin:24px 0 0;"><a href="${escapeHtml(ctaUrl)}" target="_blank" style="display:inline-block;padding:12px 22px;background:#c9a84c;border:1px solid #e0c070;border-radius:999px;color:#111111;font-family:Georgia,serif;font-size:13px;font-weight:700;letter-spacing:0.14em;text-decoration:none;text-transform:uppercase;">${escapeHtml(ctaLabel)}</a></p>`
|
||||
: ''
|
||||
|
||||
return (
|
||||
`<div style="margin:0;padding:0;background-color:#0a0a08;font-family:Georgia,serif;">` +
|
||||
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background-color:#0a0a08;">` +
|
||||
`<tr><td align="center" style="padding:40px 20px;">` +
|
||||
`<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:580px;margin:0 auto;background-color:#0f0f0c;border:1px solid #2a2518;">` +
|
||||
`<tr><td align="center" style="background-color:#0d0d0a;padding:36px 40px 28px;border-bottom:1px solid #2a2518;">` +
|
||||
`<p style="margin:0 0 6px;font-family:Georgia,serif;font-size:13px;font-weight:400;color:#7a7060;letter-spacing:0.12em;text-transform:uppercase;">${escapeHtml(eyebrow ?? 'Verse by Verse with Nate')}</p>` +
|
||||
`<p style="margin:0;font-family:Georgia,serif;font-size:28px;font-weight:600;color:#e0c070;line-height:1.2;">${escapeHtml(title)}</p>` +
|
||||
`</td></tr>` +
|
||||
`<tr><td style="padding:40px 40px 0;">` +
|
||||
`<div style="font-family:Georgia,serif;font-size:15px;line-height:1.8;color:#c8c0ac;">${bodyHtml}</div>` +
|
||||
`${ctaBlock}` +
|
||||
`</td></tr>` +
|
||||
`<tr><td style="padding:28px 40px 40px;text-align:center;">${footerHtml ?? ''}</td></tr>` +
|
||||
`</table>` +
|
||||
`</td></tr></table>` +
|
||||
`</div>`
|
||||
)
|
||||
}
|
||||
|
||||
async function sendStudyWelcomeEmail(email, displayName) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
try {
|
||||
@@ -1939,6 +1975,12 @@ async function sendStudyWelcomeEmail(email, displayName) {
|
||||
const baseUrl = getCanonicalBaseUrl()
|
||||
const studiesUrl = buildAbsoluteUrl(baseUrl, '/study')
|
||||
const accountUrl = buildAbsoluteUrl(baseUrl, '/study/account')
|
||||
const bodyHtml = (
|
||||
`<p style="margin:0 0 16px;">Welcome, <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>.</p>` +
|
||||
`<p style="margin:0 0 16px;">Your student account is ready.</p>` +
|
||||
`<p style="margin:0 0 16px;">Open studies and continue learning, or manage your account details anytime.</p>`
|
||||
)
|
||||
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Grace and peace,<br/>Verse by Verse with Nate</p>`
|
||||
const { error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
||||
to: [email],
|
||||
@@ -1949,13 +1991,14 @@ async function sendStudyWelcomeEmail(email, displayName) {
|
||||
`Open studies: ${studiesUrl}\n` +
|
||||
`Manage account: ${accountUrl}\n\n` +
|
||||
`Grace and peace,\nVerse by Verse with Nate`,
|
||||
html:
|
||||
`<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f1a12;">` +
|
||||
`<p>Welcome, <strong>${escapeHtml(namePart)}</strong>.</p>` +
|
||||
`<p>Your student account is ready.</p>` +
|
||||
`<p><a href="${escapeHtml(studiesUrl)}">Open studies</a><br/><a href="${escapeHtml(accountUrl)}">Manage account</a></p>` +
|
||||
`<p>Grace and peace,<br/>Verse by Verse with Nate</p>` +
|
||||
`</div>`,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: 'Welcome to the Study Community',
|
||||
eyebrow: 'Study Account',
|
||||
bodyHtml,
|
||||
ctaLabel: 'Open Studies',
|
||||
ctaUrl: studiesUrl,
|
||||
footerHtml: footerHtml + `<p style="margin:14px 0 0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;"><a href="${escapeHtml(accountUrl)}" target="_blank" style="color:#c9a84c;text-decoration:none;">Manage your account</a></p>`,
|
||||
}),
|
||||
})
|
||||
if (error) console.error('[study-signup] welcome email send error:', error)
|
||||
} catch (err) {
|
||||
@@ -1970,6 +2013,12 @@ async function sendStudyAccountDeletedEmail(email, displayName) {
|
||||
const namePart = typeof displayName === 'string' && displayName.trim() ? displayName.trim() : 'friend'
|
||||
const baseUrl = getCanonicalBaseUrl()
|
||||
const signupUrl = buildAbsoluteUrl(baseUrl, '/study/signup')
|
||||
const bodyHtml = (
|
||||
`<p style="margin:0 0 16px;">Hi <strong style="color:#f0ead8;">${escapeHtml(namePart)}</strong>,</p>` +
|
||||
`<p style="margin:0 0 16px;">This confirms your study account and saved notes were deleted.</p>` +
|
||||
`<p style="margin:0 0 16px;">If this was not you, please contact us immediately.</p>`
|
||||
)
|
||||
const footerHtml = `<p style="margin:0;font-family:Georgia,serif;font-size:12px;font-weight:300;color:#7a7060;line-height:1.6;">Verse by Verse with Nate</p>`
|
||||
const { error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
||||
to: [email],
|
||||
@@ -1980,14 +2029,14 @@ async function sendStudyAccountDeletedEmail(email, displayName) {
|
||||
`If this was not you, please contact us immediately.\n\n` +
|
||||
`Create a new account anytime: ${signupUrl}\n\n` +
|
||||
`Verse by Verse with Nate`,
|
||||
html:
|
||||
`<div style="font-family:Arial,sans-serif;line-height:1.6;color:#1f1a12;">` +
|
||||
`<p>Hi ${escapeHtml(namePart)},</p>` +
|
||||
`<p>This confirms your study account and saved notes were deleted.</p>` +
|
||||
`<p>If this was not you, please contact us immediately.</p>` +
|
||||
`<p><a href="${escapeHtml(signupUrl)}">Create a new account</a></p>` +
|
||||
`<p>Verse by Verse with Nate</p>` +
|
||||
`</div>`,
|
||||
html: buildBrandedEmailHtml({
|
||||
title: 'Study Account Deleted',
|
||||
eyebrow: 'Account Update',
|
||||
bodyHtml,
|
||||
ctaLabel: 'Create a New Account',
|
||||
ctaUrl: signupUrl,
|
||||
footerHtml,
|
||||
}),
|
||||
})
|
||||
if (error) console.error('[study-account] delete email send error:', error)
|
||||
} catch (err) {
|
||||
@@ -2056,6 +2105,74 @@ function sanitizeUserNotes(value) {
|
||||
return out
|
||||
}
|
||||
|
||||
function sanitizeStudyCommunityReply(reply) {
|
||||
if (!reply || typeof reply !== 'object' || Array.isArray(reply)) return null
|
||||
const message = typeof reply.message === 'string' ? reply.message.trim().slice(0, 3000) : ''
|
||||
if (!message) return null
|
||||
return {
|
||||
id: typeof reply.id === 'string' && reply.id.trim() ? reply.id.trim() : randomUUID(),
|
||||
authorUserId: typeof reply.authorUserId === 'string' && reply.authorUserId.trim() ? reply.authorUserId.trim() : '',
|
||||
authorName: typeof reply.authorName === 'string' ? reply.authorName.trim().slice(0, 120) : '',
|
||||
message,
|
||||
createdAt: typeof reply.createdAt === 'string' ? reply.createdAt : new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeStudyCommunityPosts(value) {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
return value
|
||||
.filter(item => item && typeof item === 'object' && !Array.isArray(item))
|
||||
.map(item => {
|
||||
const studySlug = normalizeStudySlug(item.studySlug)
|
||||
const sectionId = typeof item.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(item.sectionId) ? item.sectionId.trim() : ''
|
||||
const message = typeof item.message === 'string' ? item.message.trim().slice(0, 3000) : ''
|
||||
const replies = Array.isArray(item.replies)
|
||||
? item.replies.map(sanitizeStudyCommunityReply).filter(Boolean).slice(0, 50)
|
||||
: []
|
||||
|
||||
if (!studySlug || !message) return null
|
||||
|
||||
return {
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
studySlug,
|
||||
sectionId,
|
||||
authorUserId: typeof item.authorUserId === 'string' && item.authorUserId.trim() ? item.authorUserId.trim() : '',
|
||||
authorName: typeof item.authorName === 'string' ? item.authorName.trim().slice(0, 120) : '',
|
||||
message,
|
||||
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
|
||||
replies,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function loadStudyCommunityFromDisk() {
|
||||
return readFile(STUDY_COMMUNITY_FILE, 'utf8')
|
||||
.then(raw => {
|
||||
const parsed = JSON.parse(raw)
|
||||
studyCommunityPosts = sanitizeStudyCommunityPosts(parsed?.posts ?? parsed)
|
||||
})
|
||||
.catch(() => {
|
||||
studyCommunityPosts = []
|
||||
})
|
||||
}
|
||||
|
||||
function queueStudyCommunityWrite() {
|
||||
studyCommunityWritePromise = studyCommunityWritePromise
|
||||
.then(async () => {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(
|
||||
STUDY_COMMUNITY_FILE,
|
||||
JSON.stringify({ posts: studyCommunityPosts, updatedAt: new Date().toISOString() }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[study-community] failed to write discussion posts:', err)
|
||||
})
|
||||
}
|
||||
|
||||
function getUserNotesFilePath(userId) {
|
||||
// userId is a UUID — safe as a filename
|
||||
return path.join(STUDY_NOTES_DIR, `${userId}.json`)
|
||||
@@ -2449,6 +2566,104 @@ app.put('/api/study-notes/:sectionId', requireStudyAuth, async (req, res) => {
|
||||
res.json({ ok: true, note })
|
||||
})
|
||||
|
||||
app.get('/api/study-community', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(typeof req.query?.studySlug === 'string' ? req.query.studySlug : '')
|
||||
|
||||
if (!studySlug) {
|
||||
res.status(400).json({ message: 'Study slug is required.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to view the community.' })
|
||||
return
|
||||
}
|
||||
|
||||
const posts = studyCommunityPosts
|
||||
.filter(post => post.studySlug === studySlug)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 50)
|
||||
|
||||
res.json({
|
||||
studySlug,
|
||||
posts,
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/study-community/posts', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const studySlug = normalizeStudySlug(req.body?.studySlug)
|
||||
const sectionId = typeof req.body?.sectionId === 'string' && /^[a-z0-9-]{1,80}$/i.test(req.body.sectionId) ? req.body.sectionId.trim() : ''
|
||||
const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : ''
|
||||
|
||||
if (!studySlug || !message) {
|
||||
res.status(400).json({ message: 'Study slug and message are required.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isStudyUserEnrolled(user, studySlug)) {
|
||||
res.status(403).json({ message: 'Please enroll in this study to post in the community.' })
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const authorName = user.displayName?.trim() || user.username
|
||||
const post = {
|
||||
id: randomUUID(),
|
||||
studySlug,
|
||||
sectionId,
|
||||
authorUserId: user.id,
|
||||
authorName,
|
||||
message,
|
||||
createdAt: now,
|
||||
replies: [],
|
||||
}
|
||||
|
||||
studyCommunityPosts.unshift(post)
|
||||
studyCommunityPosts = sanitizeStudyCommunityPosts(studyCommunityPosts).slice(0, 500)
|
||||
queueStudyCommunityWrite()
|
||||
|
||||
res.json({ ok: true, post })
|
||||
})
|
||||
|
||||
app.post('/api/study-community/posts/:postId/replies', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
|
||||
const message = typeof req.body?.message === 'string' ? req.body.message.trim().slice(0, 3000) : ''
|
||||
|
||||
if (!postId || !message) {
|
||||
res.status(400).json({ message: 'Post id and message are required.' })
|
||||
return
|
||||
}
|
||||
|
||||
const post = 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 reply in the community.' })
|
||||
return
|
||||
}
|
||||
|
||||
const reply = {
|
||||
id: randomUUID(),
|
||||
authorUserId: user.id,
|
||||
authorName: user.displayName?.trim() || user.username,
|
||||
message,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
post.replies = Array.isArray(post.replies) ? post.replies : []
|
||||
post.replies.push(reply)
|
||||
post.replies = sanitizeStudyCommunityPosts([post])[0]?.replies ?? []
|
||||
queueStudyCommunityWrite()
|
||||
|
||||
res.json({ ok: true, reply })
|
||||
})
|
||||
|
||||
app.get('/api/study-account/export-notes', requireStudyAuth, async (req, res) => {
|
||||
const user = req.studyUser
|
||||
const notes = await loadUserNotes(user.id)
|
||||
@@ -4337,6 +4552,7 @@ Promise.all([
|
||||
loadQuestionsFromDisk(),
|
||||
loadDraftQuestionsFromDisk(),
|
||||
loadStudyUsersFromDisk(),
|
||||
loadStudyCommunityFromDisk(),
|
||||
migrateStudyNotesIfNeeded(),
|
||||
loadDownloadCountsFromDisk(),
|
||||
loadPodcastChecklistFromDisk(),
|
||||
|
||||
@@ -46,6 +46,23 @@ type StudyAccountOverview = {
|
||||
|
||||
type StudyNotesMap = Record<string, string>
|
||||
|
||||
type StudyCommunityReply = {
|
||||
id: string
|
||||
authorName: string
|
||||
message: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type StudyCommunityPost = {
|
||||
id: string
|
||||
studySlug: string
|
||||
sectionId: string
|
||||
authorName: string
|
||||
message: string
|
||||
createdAt: string
|
||||
replies: StudyCommunityReply[]
|
||||
}
|
||||
|
||||
function getLegacyColossiansStudy(content: SiteContent): StudyProgram {
|
||||
const legacySections = content.colossiansStudySections?.length ? content.colossiansStudySections : DEFAULT_COLOSSIANS_STUDY_SECTIONS
|
||||
return {
|
||||
@@ -145,6 +162,213 @@ function getNoteId(studySlug: string, sectionId: string) {
|
||||
return `${studySlug}--${sectionId}`
|
||||
}
|
||||
|
||||
function getDisplayName(auth: StudyAuthState) {
|
||||
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
|
||||
}
|
||||
|
||||
function formatCommunityDate(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function StudyCommunityWidget({
|
||||
study,
|
||||
section,
|
||||
auth,
|
||||
isEnrolled,
|
||||
}: {
|
||||
study: StudyProgram
|
||||
section: StudySection
|
||||
auth: StudyAuthState
|
||||
isEnrolled: boolean
|
||||
}) {
|
||||
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)
|
||||
const [newPost, setNewPost] = useState('')
|
||||
|
||||
const canParticipate = auth.authenticated && isEnrolled
|
||||
const communityDisplayName = getDisplayName(auth)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
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 : [])
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setError(err instanceof Error ? err.message : 'Unable to load the community right now.')
|
||||
} finally {
|
||||
if (cancelled) return
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [canParticipate, study.slug])
|
||||
|
||||
async function submitPost() {
|
||||
if (!newPost.trim() || !canParticipate) return
|
||||
setSubmitting(true)
|
||||
setMessage('')
|
||||
setError('')
|
||||
try {
|
||||
const data = await readJson<{ post?: StudyCommunityPost }>('/api/study-community/posts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ studySlug: study.slug, sectionId: section.id, message: newPost }),
|
||||
})
|
||||
if (data.post) setPosts(prev => [data.post!, ...prev])
|
||||
setNewPost('')
|
||||
setMessage('Posted to the community.')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to post right now.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReply(postId: string) {
|
||||
const reply = replyDrafts[postId]?.trim()
|
||||
if (!reply || !canParticipate) return
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await readJson<{ reply?: StudyCommunityReply }>(`/api/study-community/posts/${encodeURIComponent(postId)}/replies`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: reply }),
|
||||
})
|
||||
if (data.reply) {
|
||||
setPosts(prev => prev.map(post => post.id === postId ? { ...post, replies: [...(post.replies ?? []), data.reply!] } : post))
|
||||
}
|
||||
setReplyDrafts(prev => ({ ...prev, [postId]: '' }))
|
||||
setReplyingTo(null)
|
||||
setMessage('Reply posted.')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to post your reply.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="study-class-block" aria-label="Study community">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h2>Study Community</h2>
|
||||
<p className="study-detail-copy">Talk with other enrolled students about {study.title}. Keep it gracious and on topic.</p>
|
||||
</div>
|
||||
<Link to="/contact" className="btn-secondary">Ask Nate</Link>
|
||||
</div>
|
||||
|
||||
{!auth.checked && <p className="study-note-status" style={{ marginTop: '1rem' }}>Checking your account status...</p>}
|
||||
{auth.checked && !auth.authenticated && (
|
||||
<div className="study-auth-box" style={{ marginTop: '1rem' }}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{auth.checked && auth.authenticated && !isEnrolled && (
|
||||
<div className="study-auth-box" style={{ marginTop: '1rem' }}>
|
||||
<p className="study-auth-why">Enroll in this study to participate in the community.</p>
|
||||
<div className="study-auth-actions">
|
||||
<Link to="/study/account" className="btn-primary">Go to My Account</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canParticipate && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={newPost}
|
||||
onChange={e => setNewPost(e.target.value)}
|
||||
placeholder={`Share a thought about ${section.title}...`}
|
||||
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem' }}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{message && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{message}</p>}
|
||||
{error && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{error}</p>}
|
||||
|
||||
{canParticipate && (
|
||||
<div style={{ marginTop: '1rem', display: 'grid', gap: '0.75rem' }}>
|
||||
{loading ? (
|
||||
<p className="study-detail-copy">Loading community posts...</p>
|
||||
) : posts.length === 0 ? (
|
||||
<p className="study-detail-copy">No one has posted here yet. Be the first to start the conversation.</p>
|
||||
) : posts.map(post => (
|
||||
<div key={post.id} style={{ border: '1px solid #2a2518', borderRadius: '14px', padding: '1rem', background: '#11100d' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.5rem' }}>
|
||||
<strong style={{ color: '#e0c070' }}>{post.authorName || 'Student'}</strong>
|
||||
<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" onClick={() => setReplyingTo(curr => curr === post.id ? null : post.id)}>{replyingTo === post.id ? 'Cancel Reply' : 'Reply'}</button>
|
||||
</div>
|
||||
{replyingTo === post.id && canParticipate && (
|
||||
<div style={{ marginTop: '0.85rem' }}>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={replyDrafts[post.id] ?? ''}
|
||||
onChange={e => setReplyDrafts(prev => ({ ...prev, [post.id]: e.target.value }))}
|
||||
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' }}
|
||||
/>
|
||||
<button type="button" className="btn-primary" disabled={submitting || !(replyDrafts[post.id] ?? '').trim()} onClick={() => submitReply(post.id)}>Post Reply</button>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(post.replies) && post.replies.length > 0 && (
|
||||
<div style={{ marginTop: '1rem', display: 'grid', gap: '0.65rem' }}>
|
||||
{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' }}>
|
||||
<strong style={{ color: '#c9a84c', fontSize: '0.95rem' }}>{reply.authorName || 'Student'}</strong>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
async function readJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, init)
|
||||
const data = await response.json().catch(() => ({})) as T & { message?: string }
|
||||
@@ -888,6 +1112,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
))}
|
||||
</ol>
|
||||
</article>
|
||||
|
||||
<StudyCommunityWidget study={study} section={section} auth={auth} isEnrolled={isEnrolled} />
|
||||
</div>
|
||||
|
||||
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
|
||||
|
||||
Reference in New Issue
Block a user