Add dedicated study community page at /study/:studySlug/community
This commit is contained in:
+2
-1
@@ -4,7 +4,7 @@ import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } fro
|
||||
import AdminPage from './AdminPage'
|
||||
import QASection from './components/QASection'
|
||||
import ContactForm from './components/ContactForm'
|
||||
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage } from './colossiansStudy'
|
||||
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage } from './colossiansStudy'
|
||||
import { SpotifyIcon } from './icons'
|
||||
import type { SiteContent, ArchivedSeries, StudyProgram } from './content'
|
||||
import { DEFAULTS } from './content'
|
||||
@@ -2021,6 +2021,7 @@ export default function App() {
|
||||
<Route path="/study/signup" element={<StudyRouteFrame content={content} child={<StudySignupPage />} />} />
|
||||
<Route path="/study/account" element={<StudyRouteFrame content={content} child={<StudyAccountPage />} />} />
|
||||
<Route path="/study/:studySlug" element={<StudyRouteFrame content={content} child={<ColossiansStudyIndexPage content={content} />} />} />
|
||||
<Route path="/study/:studySlug/community" element={<StudyRouteFrame content={content} child={<StudyCommunityPage content={content} />} />} />
|
||||
<Route path="/study/:studySlug/notes" element={<StudyRouteFrame content={content} child={<ColossiansStudyNotesPage content={content} />} />} />
|
||||
<Route path="/study/:studySlug/:sectionId" element={<StudyRouteFrame content={content} child={<ColossiansStudySectionPage content={content} />} />} />
|
||||
<Route path="/episodes" element={<EpisodesPage content={content} />} />
|
||||
|
||||
+152
-60
@@ -172,16 +172,16 @@ function formatCommunityDate(value: string) {
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function StudyCommunityWidget({
|
||||
function CommunityBoard({
|
||||
study,
|
||||
section,
|
||||
auth,
|
||||
isEnrolled,
|
||||
sectionFilter,
|
||||
}: {
|
||||
study: StudyProgram
|
||||
section: StudySection
|
||||
auth: StudyAuthState
|
||||
isEnrolled: boolean
|
||||
sectionFilter?: string
|
||||
}) {
|
||||
const [posts, setPosts] = useState<StudyCommunityPost[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -191,10 +191,16 @@ function StudyCommunityWidget({
|
||||
const [replyingTo, setReplyingTo] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [newPost, setNewPost] = useState('')
|
||||
const [activeSection, setActiveSection] = useState<string>(sectionFilter ?? '')
|
||||
|
||||
const canParticipate = auth.authenticated && isEnrolled
|
||||
const communityDisplayName = getDisplayName(auth)
|
||||
|
||||
const releasedSections = study.sections.filter(isSectionReleased)
|
||||
const visiblePosts = activeSection
|
||||
? posts.filter(p => p.sectionId === activeSection)
|
||||
: posts
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
@@ -235,7 +241,7 @@ function StudyCommunityWidget({
|
||||
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 }),
|
||||
body: JSON.stringify({ studySlug: study.slug, sectionId: activeSection, message: newPost }),
|
||||
})
|
||||
if (data.post) setPosts(prev => [data.post!, ...prev])
|
||||
setNewPost('')
|
||||
@@ -272,18 +278,29 @@ function StudyCommunityWidget({
|
||||
}
|
||||
|
||||
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 className="study-community-board">
|
||||
{/* 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>
|
||||
{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>
|
||||
))}
|
||||
</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 && <p className="study-note-status">Checking your account status...</p>}
|
||||
{auth.checked && !auth.authenticated && (
|
||||
<div className="study-auth-box" style={{ marginTop: '1rem' }}>
|
||||
<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>
|
||||
@@ -292,8 +309,8 @@ function StudyCommunityWidget({
|
||||
</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-box">
|
||||
<p className="study-auth-why">Enroll in {study.title} to participate in the community.</p>
|
||||
<div className="study-auth-actions">
|
||||
<Link to="/study/account" className="btn-primary">Go to My Account</Link>
|
||||
</div>
|
||||
@@ -301,12 +318,17 @@ function StudyCommunityWidget({
|
||||
)}
|
||||
|
||||
{canParticipate && (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
{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)}
|
||||
placeholder={`Share a thought about ${section.title}...`}
|
||||
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' }}
|
||||
/>
|
||||
<div className="study-auth-actions">
|
||||
@@ -317,55 +339,70 @@ function StudyCommunityWidget({
|
||||
</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>}
|
||||
{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={{ marginTop: '1rem', display: 'grid', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'grid', gap: '0.85rem' }}>
|
||||
{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>
|
||||
) : 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' }}>
|
||||
<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' }}>
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
<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={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" style={{ fontSize: '0.9rem' }} 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>
|
||||
<p className="study-detail-copy" style={{ margin: '0.25rem 0 0', whiteSpace: 'pre-wrap' }}>{reply.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -743,6 +780,7 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
||||
)}
|
||||
{auth.authenticated && <Link to="/study/account" className="btn-secondary">My Account</Link>}
|
||||
{enrolled && <Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>}
|
||||
{enrolled && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}
|
||||
<Link to="/study" className="btn-secondary">Back to Studies</Link>
|
||||
</div>
|
||||
{enrollMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{enrollMessage}</p>}
|
||||
@@ -1111,9 +1149,11 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
||||
<li key={index}>{question}</li>
|
||||
))}
|
||||
</ol>
|
||||
<div style={{ marginTop: '1.25rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<Link to={`/study/${study.slug}/community`} className="btn-primary">Go to Community</Link>
|
||||
<Link to="/contact" className="btn-secondary">Ask Nate</Link>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<StudyCommunityWidget study={study} section={section} auth={auth} isEnrolled={isEnrolled} />
|
||||
</div>
|
||||
|
||||
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
|
||||
@@ -1798,3 +1838,55 @@ export function StudyAccountPage() {
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export function StudyCommunityPage({ content }: Props) {
|
||||
const { studySlug } = useParams<{ studySlug: string }>()
|
||||
const studies = getStudies(content)
|
||||
const study = getStudyBySlug(studies, studySlug)
|
||||
|
||||
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '', enrolledStudySlugs: [], displayName: '' })
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/study-auth-status', { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then((data: StudyAuthStatusResponse) => {
|
||||
setAuth({ checked: true, authenticated: data.authenticated, username: data.username ?? '', enrolledStudySlugs: normalizeEnrolledStudySlugs(data.enrolledStudySlugs), displayName: data.displayName ?? '' })
|
||||
})
|
||||
.catch(() => setAuth(prev => ({ ...prev, checked: true })))
|
||||
}, [])
|
||||
|
||||
if (!study) {
|
||||
return (
|
||||
<main className="study-index-page" aria-label="Community">
|
||||
<section className="section-study-course-hero">
|
||||
<div className="section-inner">
|
||||
<h1>Study not found</h1>
|
||||
<Link to="/study" className="btn-secondary">Back to Studies</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const isEnrolled = isEnrolledInStudy(auth, study.slug)
|
||||
|
||||
return (
|
||||
<main className="study-index-page" aria-label="Study community">
|
||||
<section className="section-study-course-hero" style={{ paddingBottom: '2rem' }}>
|
||||
<div className="section-inner study-course-hero-inner">
|
||||
<p className="eyebrow">{study.title}</p>
|
||||
<h1>Study Community</h1>
|
||||
<p className="study-course-hero-copy">Talk with other enrolled students. Keep it gracious and on topic.</p>
|
||||
<div className="study-course-hero-actions">
|
||||
<Link to={`/study/${study.slug}`} className="btn-secondary">Back to Study</Link>
|
||||
<Link to="/contact" className="btn-primary">Ask Nate</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-inner" style={{ paddingTop: '2rem', paddingBottom: '4rem', maxWidth: '860px' }}>
|
||||
<CommunityBoard study={study} auth={auth} isEnrolled={isEnrolled} />
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user