Add study community discussion board and Ask Nate button

This commit is contained in:
nmemmert
2026-05-21 14:04:12 -04:00
parent ffa860d5bc
commit eee200cf9c
3 changed files with 460 additions and 15 deletions
+226
View File
@@ -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">