Make homepage study card admin-editable with NEW tag controls
This commit is contained in:
@@ -0,0 +1,697 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import type { SiteContent, StudyProgram, StudySection } from './content'
|
||||
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './colossiansStudyData'
|
||||
|
||||
type Props = { content: SiteContent }
|
||||
|
||||
type StudyAuthState = {
|
||||
checked: boolean
|
||||
authenticated: boolean
|
||||
username: string
|
||||
}
|
||||
|
||||
type StudyNotesMap = Record<string, string>
|
||||
|
||||
function getLegacyColossiansStudy(content: SiteContent): StudyProgram {
|
||||
const legacySections = content.colossiansStudySections?.length ? content.colossiansStudySections : DEFAULT_COLOSSIANS_STUDY_SECTIONS
|
||||
return {
|
||||
id: 'study-colossians',
|
||||
slug: 'colossians',
|
||||
title: 'Colossians: Rooted in Christ',
|
||||
description: 'Walk through Colossians in guided lessons with commentary, Greek notes, and discussion prompts.',
|
||||
status: 'active',
|
||||
difficulty: 'intermediate',
|
||||
estimatedHours: 12,
|
||||
completionBadge: 'Colossians Completion',
|
||||
numberOfChapters: 4,
|
||||
sections: legacySections,
|
||||
}
|
||||
}
|
||||
|
||||
function getStudies(content: SiteContent): StudyProgram[] {
|
||||
if (Array.isArray(content.studies) && content.studies.length > 0) return content.studies
|
||||
return [getLegacyColossiansStudy(content)]
|
||||
}
|
||||
|
||||
function getStudyBySlug(studies: StudyProgram[], slug: string | undefined) {
|
||||
const safeSlug = (slug ?? 'colossians').trim().toLowerCase()
|
||||
return studies.find(study => study.slug === safeSlug)
|
||||
}
|
||||
|
||||
function getSectionById(sections: StudySection[], sectionId: string | undefined) {
|
||||
if (!sectionId) return undefined
|
||||
return sections.find(section => section.id === sectionId)
|
||||
}
|
||||
|
||||
function getLessonNumber(sections: StudySection[], sectionId: string | undefined) {
|
||||
const index = sections.findIndex(item => item.id === sectionId)
|
||||
return index >= 0 ? index + 1 : 0
|
||||
}
|
||||
|
||||
function getPrimaryQuestion(questions: string[]) {
|
||||
return questions.length > 0 ? questions[0] : 'How does this passage shape the way we follow Christ this week?'
|
||||
}
|
||||
|
||||
function getChapterSummaries(study: StudyProgram) {
|
||||
const highestSectionChapter = study.sections.reduce((max, section) => Math.max(max, section.chapter), 0)
|
||||
const totalChapters = Math.max(study.numberOfChapters, highestSectionChapter)
|
||||
return Array.from({ length: totalChapters }, (_, index) => {
|
||||
const chapter = index + 1
|
||||
const lessonCount = study.sections.filter(section => section.chapter === chapter).length
|
||||
return { chapter, lessonCount }
|
||||
})
|
||||
}
|
||||
|
||||
function isNewLesson(section: StudySection): boolean {
|
||||
if (!section.releasedAt) return false
|
||||
const releaseDate = new Date(section.releasedAt)
|
||||
const now = new Date()
|
||||
const daysSinceRelease = (now.getTime() - releaseDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
return daysSinceRelease >= 0 && daysSinceRelease <= 14
|
||||
}
|
||||
|
||||
function isComingSoon(section: StudySection): boolean {
|
||||
if (!section.releasedAt) return false
|
||||
const releaseDate = new Date(section.releasedAt)
|
||||
const now = new Date()
|
||||
return releaseDate > now
|
||||
}
|
||||
|
||||
function getReleaseDateDisplay(section: StudySection): string {
|
||||
if (!section.releasedAt) return ''
|
||||
const date = new Date(section.releasedAt)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function getNoteId(studySlug: string, sectionId: string) {
|
||||
return `${studySlug}--${sectionId}`
|
||||
}
|
||||
|
||||
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 }
|
||||
if (!response.ok) {
|
||||
throw new Error((data as { message?: string }).message ?? 'Request failed')
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
export function StudyLandingPage({ content }: Props) {
|
||||
const studies = getStudies(content)
|
||||
const activeStudies = studies.filter(study => study.status !== 'planned')
|
||||
const plannedStudies = studies.filter(study => study.status === 'planned')
|
||||
const firstActiveStudy = activeStudies[0]
|
||||
|
||||
return (
|
||||
<main className="study-index-page" aria-label="Study hub">
|
||||
<section className="section-study-course-hero">
|
||||
<div className="section-inner study-course-hero-inner">
|
||||
<p className="eyebrow">Self-Paced Bible Academy</p>
|
||||
<h1>Study at Your Own Pace</h1>
|
||||
<p className="study-course-hero-copy">Pick a study track, move lesson by lesson on your own schedule, and keep personal notes as you grow through each passage.</p>
|
||||
<div className="study-course-meta">
|
||||
<span>{studies.length} study tracks</span>
|
||||
<span>Self-paced flow</span>
|
||||
<span>Personal notes</span>
|
||||
<span>Audio + commentary</span>
|
||||
</div>
|
||||
<div className="study-course-hero-actions">
|
||||
{firstActiveStudy && <Link to={`/study/${firstActiveStudy.slug}`} className="btn-primary">Start Learning</Link>}
|
||||
<Link to="#study-tracks" className="btn-secondary">Browse Tracks</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-study-hub-flow" aria-label="How self-paced study works">
|
||||
<div className="section-inner">
|
||||
<div className="study-module-header">
|
||||
<p>How It Works</p>
|
||||
<h2>A Simple Self-Paced Rhythm</h2>
|
||||
</div>
|
||||
<div className="study-hub-flow-grid">
|
||||
<article className="study-hub-flow-card">
|
||||
<p className="study-module-lesson">Step 1</p>
|
||||
<h3>Choose Your Track</h3>
|
||||
<p>Start with any active study and begin at lesson one, or jump back in where you left off.</p>
|
||||
</article>
|
||||
<article className="study-hub-flow-card">
|
||||
<p className="study-module-lesson">Step 2</p>
|
||||
<h3>Work Each Lesson</h3>
|
||||
<p>Read the text, listen to audio, review commentary, and process key Greek word notes.</p>
|
||||
</article>
|
||||
<article className="study-hub-flow-card">
|
||||
<p className="study-module-lesson">Step 3</p>
|
||||
<h3>Save Notes and Continue</h3>
|
||||
<p>Keep personal notes per lesson and build your own study archive over time.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="study-tracks" className="section-study-module" aria-label="Available studies">
|
||||
<div className="section-inner">
|
||||
<div className="study-module-header">
|
||||
<p>Available Now</p>
|
||||
<h2>Current Study Tracks</h2>
|
||||
</div>
|
||||
<div className="study-module-list">
|
||||
{activeStudies.map(study => (
|
||||
<Link key={study.id} to={`/study/${study.slug}`} className="study-module-row">
|
||||
<div className="study-module-row-left">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<p className="study-module-lesson">Start Anytime</p>
|
||||
{study.difficulty && <span style={{ backgroundColor: study.difficulty === 'advanced' ? '#d32f2f' : (study.difficulty === 'intermediate' ? '#f57c00' : '#388e3c'), color: '#fff', padding: '0.25rem 0.75rem', borderRadius: '16px', fontSize: '0.75rem', fontWeight: '600', textTransform: 'capitalize' }}>{study.difficulty}</span>}
|
||||
</div>
|
||||
<h3>{study.title}</h3>
|
||||
<p>{study.description}</p>
|
||||
{study.estimatedHours && <p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>⏱ {study.estimatedHours} hours</p>}
|
||||
</div>
|
||||
<div className="study-module-row-right">
|
||||
<p className="study-module-focus">Track Snapshot</p>
|
||||
<p>{study.sections.length > 0 ? `${study.sections.length} lessons available` : 'Lessons will be published soon.'}</p>
|
||||
<p>{study.sections.length > 0 ? 'Estimated pace: 1-2 lessons per week' : 'Pacing details coming with first lesson release.'}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{plannedStudies.length > 0 && (
|
||||
<section className="section-study-module" aria-label="Upcoming studies">
|
||||
<div className="section-inner">
|
||||
<div className="study-module-header">
|
||||
<p>Coming Soon</p>
|
||||
<h2>Next Study Tracks</h2>
|
||||
</div>
|
||||
<div className="study-module-list">
|
||||
{plannedStudies.map(study => (
|
||||
<article key={study.id} className="study-module-row study-module-row--disabled" aria-disabled="true">
|
||||
<div className="study-module-row-left">
|
||||
<p className="study-module-lesson">Planned</p>
|
||||
<h3>{study.title}</h3>
|
||||
<p>{study.description}</p>
|
||||
</div>
|
||||
<div className="study-module-row-right">
|
||||
<p className="study-module-focus">Status</p>
|
||||
<p>Preparing lesson structure and media.</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export function ColossiansStudyIndexPage({ content }: Props) {
|
||||
const { studySlug } = useParams<{ studySlug?: string }>()
|
||||
const studies = getStudies(content)
|
||||
const study = getStudyBySlug(studies, studySlug)
|
||||
|
||||
if (!study) {
|
||||
return (
|
||||
<main className="thanks-page" aria-label="Study not found">
|
||||
<div className="thanks-card">
|
||||
<p className="eyebrow">Study Hub</p>
|
||||
<h1>Study not found</h1>
|
||||
<p>The study you requested is not available yet.</p>
|
||||
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = study.sections
|
||||
const chapterSummaries = getChapterSummaries(study)
|
||||
const populatedChapterCount = chapterSummaries.filter(chapter => chapter.lessonCount > 0).length
|
||||
|
||||
return (
|
||||
<main className="study-index-page" aria-label={`${study.title} study`}>
|
||||
<section className="section-study-course-hero">
|
||||
<div className="section-inner study-course-hero-inner">
|
||||
<p className="eyebrow">Online Bible Class</p>
|
||||
<h1>{study.title}</h1>
|
||||
<p className="study-course-hero-copy">{study.description}</p>
|
||||
<div className="study-course-meta">
|
||||
<span>{sections.length} lessons</span>
|
||||
<span>{chapterSummaries.length} chapters</span>
|
||||
<span>{populatedChapterCount} chapters with lessons</span>
|
||||
<span>Text + commentary + discussion</span>
|
||||
<span>Student notes enabled</span>
|
||||
</div>
|
||||
<div className="study-course-hero-actions">
|
||||
{sections.length > 0 && <Link to={`/study/${study.slug}/${sections[0].id}`} className="btn-primary">Start Class</Link>}
|
||||
<Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>
|
||||
<Link to="/study" className="btn-secondary">Back to Studies</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{sections.length === 0 && (
|
||||
<section className="section-study-module">
|
||||
<div className="section-inner">
|
||||
<article className="study-module-row study-module-row--disabled" aria-disabled="true">
|
||||
<div className="study-module-row-left">
|
||||
<p className="study-module-lesson">Planned</p>
|
||||
<h3>Lessons are being prepared</h3>
|
||||
<p>Use the admin Studies editor to add section lessons and publish when ready.</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{sections.length > 0 && (
|
||||
<section className="section-study-module" aria-label="Course lessons">
|
||||
<div className="section-inner">
|
||||
<div className="study-module-header">
|
||||
<p>Course Lessons</p>
|
||||
<h2>{study.title}</h2>
|
||||
</div>
|
||||
<div className="study-module-list">
|
||||
{sections.map(section => {
|
||||
const lessonNumber = getLessonNumber(sections, section.id)
|
||||
const isNew = isNewLesson(section)
|
||||
const coming = isComingSoon(section)
|
||||
const isDisabled = coming
|
||||
|
||||
return (
|
||||
<Link key={section.id} to={isDisabled ? '#' : `/study/${study.slug}/${section.id}`} className={`study-module-row${isDisabled ? ' study-module-row--disabled' : ''}`} onClick={e => isDisabled && e.preventDefault()}>
|
||||
<div className="study-module-row-left">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<p className="study-module-lesson">Lesson {lessonNumber}</p>
|
||||
{isNew && <span style={{ backgroundColor: '#4caf50', color: '#fff', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>New</span>}
|
||||
{coming && <span style={{ backgroundColor: '#ffb74d', color: '#333', padding: '0.2rem 0.5rem', borderRadius: '3px', fontSize: '0.75rem', fontWeight: '600' }}>Coming {getReleaseDateDisplay(section)}</span>}
|
||||
</div>
|
||||
<h3>{section.title}</h3>
|
||||
<p>{section.summary}</p>
|
||||
</div>
|
||||
<div className="study-module-row-right">
|
||||
<p className="study-section-reference">{section.reference}</p>
|
||||
<p className="study-module-focus">Focus question</p>
|
||||
<p>{getPrimaryQuestion(section.studyQuestions)}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export function ColossiansStudySectionPage({ content }: Props) {
|
||||
const { studySlug, sectionId } = useParams<{ studySlug?: string; sectionId: string }>()
|
||||
const studies = getStudies(content)
|
||||
const study = getStudyBySlug(studies, studySlug)
|
||||
const sections = study?.sections ?? []
|
||||
const section = getSectionById(sections, sectionId)
|
||||
const sectionIndex = sections.findIndex(item => item.id === sectionId)
|
||||
const lessonNumber = getLessonNumber(sections, sectionId)
|
||||
const previousSection = sectionIndex > 0 ? sections[sectionIndex - 1] : null
|
||||
const nextSection = sectionIndex >= 0 && sectionIndex < sections.length - 1 ? sections[sectionIndex + 1] : null
|
||||
|
||||
const [auth, setAuth] = useState<StudyAuthState>({ checked: false, authenticated: false, username: '' })
|
||||
const [usernameInput, setUsernameInput] = useState('')
|
||||
const [passwordInput, setPasswordInput] = useState('')
|
||||
const [authBusy, setAuthBusy] = useState(false)
|
||||
const [authMessage, setAuthMessage] = useState('')
|
||||
|
||||
const [noteText, setNoteText] = useState('')
|
||||
const [noteLoading, setNoteLoading] = useState(false)
|
||||
const [noteSaving, setNoteSaving] = useState(false)
|
||||
const [noteMessage, setNoteMessage] = useState('')
|
||||
|
||||
const canSaveNote = auth.authenticated && !noteSaving && !noteLoading
|
||||
const lessonAudioEmbedUrl = useMemo(() => {
|
||||
const trimmed = section?.audioEmbedUrl?.trim() ?? ''
|
||||
if (!trimmed) return ''
|
||||
return /^https?:\/\//i.test(trimmed) ? trimmed : ''
|
||||
}, [section?.audioEmbedUrl])
|
||||
|
||||
const currentStudySlug = study?.slug ?? 'colossians'
|
||||
const noteId = section?.id ? getNoteId(currentStudySlug, section.id) : ''
|
||||
|
||||
useEffect(() => {
|
||||
document.title = section && study ? `${section.title} | ${study.title}` : 'Study'
|
||||
}, [section, study])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
readJson<{ authenticated: boolean; username: string }>('/api/study-auth/status')
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setAuth({ checked: true, authenticated: Boolean(data.authenticated), username: data.username ?? '' })
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setAuth({ checked: true, authenticated: false, username: '' })
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.authenticated || !noteId) {
|
||||
setNoteText('')
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setNoteLoading(true)
|
||||
setNoteMessage('')
|
||||
|
||||
readJson<{ note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`)
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setNoteText(data.note ?? '')
|
||||
})
|
||||
.catch(err => {
|
||||
if (cancelled) return
|
||||
setNoteMessage(err instanceof Error ? err.message : 'Unable to load your note.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return
|
||||
setNoteLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [auth.authenticated, noteId])
|
||||
|
||||
async function submitAuth(mode: 'login' | 'signup') {
|
||||
setAuthBusy(true)
|
||||
setAuthMessage('')
|
||||
|
||||
try {
|
||||
const payload = { username: usernameInput, password: passwordInput }
|
||||
const data = await readJson<{ username: string }>(`/api/study-auth/${mode}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
setAuth({ checked: true, authenticated: true, username: data.username ?? usernameInput.trim().toLowerCase() })
|
||||
setUsernameInput('')
|
||||
setPasswordInput('')
|
||||
setAuthMessage(mode === 'signup' ? 'Account created. You can now save notes for each lesson.' : 'Signed in successfully.')
|
||||
} catch (err) {
|
||||
setAuthMessage(err instanceof Error ? err.message : 'Sign-in failed.')
|
||||
} finally {
|
||||
setAuthBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutStudyUser() {
|
||||
setAuthBusy(true)
|
||||
setAuthMessage('')
|
||||
try {
|
||||
await readJson<{ ok: boolean }>('/api/study-auth/logout', { method: 'POST' })
|
||||
setAuth({ checked: true, authenticated: false, username: '' })
|
||||
setNoteText('')
|
||||
setAuthMessage('Signed out.')
|
||||
} catch (err) {
|
||||
setAuthMessage(err instanceof Error ? err.message : 'Unable to sign out.')
|
||||
} finally {
|
||||
setAuthBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
if (!noteId || !canSaveNote) return
|
||||
setNoteSaving(true)
|
||||
setNoteMessage('')
|
||||
try {
|
||||
const data = await readJson<{ ok: boolean; note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ note: noteText }),
|
||||
})
|
||||
setNoteText(data.note ?? '')
|
||||
setNoteMessage('Notes saved.')
|
||||
} catch (err) {
|
||||
setNoteMessage(err instanceof Error ? err.message : 'Unable to save note.')
|
||||
} finally {
|
||||
setNoteSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!study || !section) {
|
||||
return (
|
||||
<main className="thanks-page" aria-label="Section not found">
|
||||
<div className="thanks-card">
|
||||
<p className="eyebrow">Study Hub</p>
|
||||
<h1>Section not found</h1>
|
||||
<p>The section you requested is not available yet.</p>
|
||||
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="study-section-page" aria-label={section.title}>
|
||||
<section className="section-study-classroom">
|
||||
<div className="section-inner study-classroom-shell">
|
||||
<div className="study-classroom-main">
|
||||
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
|
||||
<p className="study-lesson-label">Lesson {lessonNumber} of {sections.length}</p>
|
||||
<h1>{section.title}</h1>
|
||||
<p className="study-detail-reference">{section.reference}</p>
|
||||
<p className="study-detail-summary">{section.summary}</p>
|
||||
|
||||
{lessonAudioEmbedUrl && (
|
||||
<article className="study-class-block">
|
||||
<h2>Lesson Audio</h2>
|
||||
<div className="study-audio-embed-wrap">
|
||||
<iframe
|
||||
src={lessonAudioEmbedUrl}
|
||||
title={`${section.title} audio`}
|
||||
width="100%"
|
||||
height="152"
|
||||
frameBorder="0"
|
||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
|
||||
<article className="study-class-block">
|
||||
<h2>Scripture Text</h2>
|
||||
<p className="study-detail-copy study-detail-copy--scripture">{section.passageText || `Add the passage text for ${section.reference} here when you move the guide online.`}</p>
|
||||
</article>
|
||||
|
||||
<article className="study-class-block">
|
||||
<h2>Instructor Commentary</h2>
|
||||
<p className="study-detail-copy">{section.commentary}</p>
|
||||
</article>
|
||||
|
||||
<article className="study-class-block">
|
||||
<h2>Discussion Questions</h2>
|
||||
<ol className="study-detail-list">
|
||||
{section.studyQuestions.map((question, index) => (
|
||||
<li key={index}>{question}</li>
|
||||
))}
|
||||
</ol>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
|
||||
<article className="study-class-side-block">
|
||||
<h3>Study Track</h3>
|
||||
<p>{study.title}</p>
|
||||
<p>{study.description}</p>
|
||||
</article>
|
||||
|
||||
<article className="study-class-side-block">
|
||||
<h3>Greek Word Study</h3>
|
||||
{section.greekNotes.length > 0 ? (
|
||||
<ul className="study-detail-list">
|
||||
{section.greekNotes.map((note, index) => (
|
||||
<li key={index}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="study-detail-copy">Greek notes will be added for this lesson.</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="study-class-side-block">
|
||||
<h3>Student Notes</h3>
|
||||
{!auth.checked && <p className="study-detail-copy">Checking sign-in status...</p>}
|
||||
{auth.checked && !auth.authenticated && (
|
||||
<div className="study-auth-box">
|
||||
<label htmlFor="study-username">Username</label>
|
||||
<input id="study-username" type="text" value={usernameInput} onChange={e => setUsernameInput(e.target.value)} placeholder="yourname" autoComplete="username" />
|
||||
<label htmlFor="study-password">Password</label>
|
||||
<input id="study-password" type="password" value={passwordInput} onChange={e => setPasswordInput(e.target.value)} placeholder="At least 8 characters" autoComplete="current-password" />
|
||||
<div className="study-auth-actions">
|
||||
<button type="button" className="btn-secondary" disabled={authBusy} onClick={() => submitAuth('login')}>Sign In</button>
|
||||
<button type="button" className="btn-primary" disabled={authBusy} onClick={() => submitAuth('signup')}>Create Account</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{auth.checked && auth.authenticated && (
|
||||
<div className="study-notes-box">
|
||||
<p className="study-notes-user">Signed in as {auth.username}</p>
|
||||
<Link to={`/study/${study.slug}/notes`} className="btn-secondary">Open My Notes</Link>
|
||||
<textarea
|
||||
rows={8}
|
||||
value={noteText}
|
||||
onChange={e => setNoteText(e.target.value)}
|
||||
placeholder="Write your lesson notes here..."
|
||||
disabled={noteLoading || noteSaving}
|
||||
/>
|
||||
<div className="study-auth-actions">
|
||||
<button type="button" className="btn-primary" disabled={!canSaveNote} onClick={saveNote}>{noteSaving ? 'Saving...' : 'Save Notes'}</button>
|
||||
<button type="button" className="btn-secondary" disabled={authBusy} onClick={logoutStudyUser}>Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(authMessage || noteMessage) && <p className="study-note-status">{authMessage || noteMessage}</p>}
|
||||
</article>
|
||||
|
||||
<article className="study-class-side-block">
|
||||
<h3>Lesson Navigation</h3>
|
||||
<div className="study-detail-nav">
|
||||
{previousSection ? (
|
||||
<Link to={`/study/${study.slug}/${previousSection.id}`} className="btn-secondary">Previous Lesson</Link>
|
||||
) : <span className="study-nav-placeholder" />}
|
||||
{nextSection ? (
|
||||
<Link to={`/study/${study.slug}/${nextSection.id}`} className="btn-secondary">Next Lesson</Link>
|
||||
) : <span className="study-nav-placeholder" />}
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export function ColossiansStudyNotesPage({ 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: '' })
|
||||
const [notes, setNotes] = useState<StudyNotesMap>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
document.title = study ? `My Notes | ${study.title}` : 'My Study Notes'
|
||||
}, [study])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const authData = await readJson<{ authenticated: boolean; username: string }>('/api/study-auth/status')
|
||||
if (cancelled) return
|
||||
|
||||
if (!authData.authenticated) {
|
||||
setAuth({ checked: true, authenticated: false, username: '' })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setAuth({ checked: true, authenticated: true, username: authData.username ?? '' })
|
||||
const notesData = await readJson<{ notes: StudyNotesMap }>('/api/study-notes')
|
||||
if (cancelled) return
|
||||
setNotes(notesData.notes ?? {})
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setMessage(err instanceof Error ? err.message : 'Unable to load notes.')
|
||||
} finally {
|
||||
if (cancelled) return
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!study) {
|
||||
return (
|
||||
<main className="thanks-page" aria-label="Study not found">
|
||||
<div className="thanks-card">
|
||||
<p className="eyebrow">Study Hub</p>
|
||||
<h1>Study not found</h1>
|
||||
<p>The study you requested is not available yet.</p>
|
||||
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const prefix = `${study.slug}--`
|
||||
const entries = Object.entries(notes)
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.map(([key, note]) => {
|
||||
const sectionId = key.slice(prefix.length)
|
||||
const section = study.sections.find(item => item.id === sectionId)
|
||||
return { sectionId, section, note }
|
||||
})
|
||||
.filter(item => item.note && item.note.trim())
|
||||
|
||||
return (
|
||||
<main className="study-section-page" aria-label="My study notes">
|
||||
<section className="section-study-classroom">
|
||||
<div className="section-inner">
|
||||
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
|
||||
<p className="study-lesson-label">Student Workspace</p>
|
||||
<h1>My Lesson Notes</h1>
|
||||
{!loading && auth.authenticated && <p className="study-detail-summary">Signed in as {auth.username}</p>}
|
||||
|
||||
{loading && <p className="study-detail-copy">Loading your notes...</p>}
|
||||
{!loading && !auth.authenticated && (
|
||||
<article className="study-class-block">
|
||||
<h2>Sign In Required</h2>
|
||||
<p className="study-detail-copy">Open any lesson and sign in from the Student Notes panel to see your saved notes here.</p>
|
||||
{study.sections[0] && <Link to={`/study/${study.slug}/${study.sections[0].id}`} className="btn-primary">Open First Lesson</Link>}
|
||||
</article>
|
||||
)}
|
||||
{!loading && auth.authenticated && entries.length === 0 && (
|
||||
<article className="study-class-block">
|
||||
<h2>No Notes Yet</h2>
|
||||
<p className="study-detail-copy">You have not saved notes yet. Open a lesson and use the Student Notes area to start.</p>
|
||||
{study.sections[0] && <Link to={`/study/${study.slug}/${study.sections[0].id}`} className="btn-primary">Open First Lesson</Link>}
|
||||
</article>
|
||||
)}
|
||||
{!loading && auth.authenticated && entries.length > 0 && (
|
||||
<div className="study-module-list">
|
||||
{entries.map(({ sectionId, section, note }) => (
|
||||
<article key={sectionId} className="study-module-row">
|
||||
<div className="study-module-row-left">
|
||||
<p className="study-module-lesson">{section ? `Lesson ${getLessonNumber(study.sections, section.id)}` : 'Saved Note'}</p>
|
||||
<h3>{section?.title ?? sectionId}</h3>
|
||||
<p>{section ? section.reference : 'Lesson reference unavailable'}</p>
|
||||
</div>
|
||||
<div className="study-module-row-right">
|
||||
<p className="study-module-focus">Your Note</p>
|
||||
<p className="study-detail-copy study-detail-copy--scripture">{note}</p>
|
||||
{section && <Link to={`/study/${study.slug}/${section.id}`} className="btn-secondary">Open Lesson</Link>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message && <p className="study-note-status">{message}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user