notes upgrade

This commit is contained in:
nmemmert
2026-06-18 13:43:17 -04:00
parent e0ed798741
commit dd43d0c3ea
9 changed files with 1591 additions and 155 deletions
+345 -135
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, useCallback } from 'react'
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import type { SiteContent, StudyProgram, StudySection } from './content'
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './colossiansStudyData'
@@ -6,6 +6,7 @@ import { usePageMeta } from './hooks/usePageMeta'
import { Breadcrumbs } from './components/Breadcrumbs'
import { StudyCertificate } from './components/StudyCertificate'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import { NoteCard } from './components/NoteCard'
type Props = { content: SiteContent }
@@ -83,6 +84,7 @@ type StudyCommunityPost = {
message: string
createdAt: string
replies: StudyCommunityReply[]
pending?: boolean
}
function getLegacyColossiansStudy(content: SiteContent): StudyProgram {
@@ -177,6 +179,11 @@ function isSectionReleased(section: StudySection): boolean {
function getReleaseDateDisplay(section: StudySection): string {
const date = getSectionReleaseDate(section)
if (!date) return ''
const now = new Date()
const diffMs = date.getTime() - now.getTime()
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 1) return 'tomorrow'
if (diffDays > 1 && diffDays <= 14) return `in ${diffDays} days`
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
@@ -184,6 +191,32 @@ function getNoteId(studySlug: string, sectionId: string) {
return `${studySlug}--${sectionId}`
}
const NOTE_ANCHORS = [
{ id: 'scripture', label: 'Scripture Text' },
{ id: 'commentary', label: 'Commentary' },
{ id: 'questions', label: 'Reflection Questions' },
] as const
type NoteAnchorId = typeof NOTE_ANCHORS[number]['id'] | 'general'
const ANCHOR_LABELS: Record<string, string> = {
scripture: 'Scripture Text',
commentary: 'Commentary',
questions: 'Reflection Questions',
general: 'General Notes',
}
function parseNoteMap(raw: string): Record<string, string> {
if (!raw?.trim()) return {}
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, string>
}
} catch {}
return { general: raw }
}
function getDisplayName(auth: StudyAuthState) {
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
}
@@ -259,16 +292,34 @@ function CommunityBoard({
setSubmitting(true)
setMessage('')
setError('')
const optimisticId = `pending-${Date.now()}`
const optimisticPost: StudyCommunityPost = {
id: optimisticId,
studySlug: study.slug,
sectionId: activeSection,
authorName: communityDisplayName,
authorAvatarUrl: auth.avatarUrl || undefined,
message: newPost,
createdAt: new Date().toISOString(),
replies: [],
pending: true,
}
setPosts(prev => [optimisticPost, ...prev])
const postedMessage = newPost
setNewPost('')
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: activeSection, message: newPost }),
body: JSON.stringify({ studySlug: study.slug, sectionId: activeSection, message: postedMessage }),
})
if (data.post) setPosts(prev => [data.post!, ...prev])
setNewPost('')
setMessage('Posted to the community.')
setPosts(prev => data.post
? prev.map(p => p.id === optimisticId ? { ...data.post!, pending: false } : p)
: prev.filter(p => p.id !== optimisticId))
setMessage('')
} catch (err) {
setPosts(prev => prev.filter(p => p.id !== optimisticId))
setNewPost(postedMessage)
setError(err instanceof Error ? err.message : 'Unable to post right now.')
} finally {
setSubmitting(false)
@@ -350,8 +401,13 @@ function CommunityBoard({
rows={4}
value={newPost}
onChange={e => setNewPost(e.target.value)}
onInput={e => {
const el = e.currentTarget
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}}
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' }}
style={{ width: '100%', padding: '0.85rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem', resize: 'none', overflow: 'hidden', boxSizing: 'border-box' }}
/>
<div className="study-auth-actions">
<button type="button" className="btn-primary" disabled={submitting || !newPost.trim()} onClick={submitPost}>
@@ -373,7 +429,7 @@ function CommunityBoard({
) : 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 key={post.id} style={{ border: '1px solid #2a2518', borderRadius: '14px', padding: '1.15rem', background: '#11100d', opacity: post.pending ? 0.65 : 1, transition: 'opacity 300ms' }}>
<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' }}>
{post.authorAvatarUrl ? (
@@ -407,8 +463,13 @@ function CommunityBoard({
rows={3}
value={replyDrafts[post.id] ?? ''}
onChange={e => setReplyDrafts(prev => ({ ...prev, [post.id]: e.target.value }))}
onInput={e => {
const el = e.currentTarget
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}}
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' }}
style={{ width: '100%', padding: '0.8rem 1rem', borderRadius: '12px', border: '1px solid #2a2518', background: '#14130f', color: '#f0ead8', marginBottom: '0.75rem', resize: 'none', overflow: 'hidden' }}
/>
<button type="button" className="btn-primary" style={{ fontSize: '0.9rem' }} disabled={submitting || !(replyDrafts[post.id] ?? '').trim()} onClick={() => submitReply(post.id)}>Post Reply</button>
</div>
@@ -474,6 +535,7 @@ export function StudyLandingPage({ content }: Props) {
const [enrollBusySlug, setEnrollBusySlug] = useState('')
const [enrollMessage, setEnrollMessage] = useState('')
const [showNewsletterNudge, setShowNewsletterNudge] = useState(false)
const [confirmLeaveSlug, setConfirmLeaveSlug] = useState<string | null>(null)
const [newsletterNudgeDone, setNewsletterNudgeDone] = useState(false)
const studies = getStudies(content)
@@ -647,14 +709,27 @@ export function StudyLandingPage({ content }: Props) {
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
</div>
<button
type="button"
className="btn-admin-remove"
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => handleEnrollmentToggle(studyInfo.slug, true)}
>
{enrollBusySlug === studyInfo.slug ? 'Working…' : 'Leave'}
</button>
{confirmLeaveSlug === studyInfo.slug ? (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.85rem', color: '#b9b09b' }}>Are you sure?</span>
<button
type="button"
className="btn-admin-remove"
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => { setConfirmLeaveSlug(null); handleEnrollmentToggle(studyInfo.slug, true) }}
>{enrollBusySlug === studyInfo.slug ? 'Working…' : 'Yes, leave'}</button>
<button type="button" className="btn-secondary" style={{ fontSize: '0.85rem', padding: '0.3rem 0.75rem' }} onClick={() => setConfirmLeaveSlug(null)}>Cancel</button>
</div>
) : (
<button
type="button"
className="btn-admin-remove"
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => setConfirmLeaveSlug(studyInfo.slug)}
>
Leave
</button>
)}
</div>
</div>
))}
@@ -675,14 +750,27 @@ export function StudyLandingPage({ content }: Props) {
<h3 style={{ margin: '0.35rem 0 0' }}>{studyInfo.title}</h3>
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>{studyInfo.completedLessons}/{studyInfo.totalLessons} lessons completed · {studyInfo.noteCount} notes</p>
</div>
<button
type="button"
className={studyInfo.enrolled ? 'btn-admin-remove' : 'btn-primary'}
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => handleEnrollmentToggle(studyInfo.slug, studyInfo.enrolled)}
>
{enrollBusySlug === studyInfo.slug ? 'Working…' : studyInfo.enrolled ? 'Leave' : 'Enroll'}
</button>
{studyInfo.enrolled && confirmLeaveSlug === studyInfo.slug ? (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.85rem', color: '#b9b09b' }}>Are you sure?</span>
<button
type="button"
className="btn-admin-remove"
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => { setConfirmLeaveSlug(null); handleEnrollmentToggle(studyInfo.slug, true) }}
>{enrollBusySlug === studyInfo.slug ? 'Working…' : 'Yes, leave'}</button>
<button type="button" className="btn-secondary" style={{ fontSize: '0.85rem', padding: '0.3rem 0.75rem' }} onClick={() => setConfirmLeaveSlug(null)}>Cancel</button>
</div>
) : (
<button
type="button"
className={studyInfo.enrolled ? 'btn-admin-remove' : 'btn-primary'}
disabled={enrollBusySlug === studyInfo.slug}
onClick={() => studyInfo.enrolled ? setConfirmLeaveSlug(studyInfo.slug) : handleEnrollmentToggle(studyInfo.slug, false)}
>
{enrollBusySlug === studyInfo.slug ? 'Working…' : studyInfo.enrolled ? 'Leave' : 'Enroll'}
</button>
)}
</div>
</div>
))}
@@ -907,6 +995,8 @@ export function StudyLandingPage({ content }: Props) {
)
}
const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/
export function StudySignupPage() {
const navigate = useNavigate()
const [mode, setMode] = useState<'signup' | 'login'>('login')
@@ -917,6 +1007,7 @@ export function StudySignupPage() {
const [message, setMessage] = useState('')
const [done, setDone] = useState(false)
const [loggedInAs, setLoggedInAs] = useState('')
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({})
// 2FA state
const [totpPendingToken, setTotpPendingToken] = useState('')
const [totpCode, setTotpCode] = useState('')
@@ -939,7 +1030,7 @@ export function StudySignupPage() {
setMessage('Please enter your email and password.')
return
}
if (!/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) {
if (!EMAIL_REGEX.test(email.trim())) {
setMessage('Please enter a valid email address.')
return
}
@@ -994,11 +1085,11 @@ export function StudySignupPage() {
{done ? (
<>
<p className="study-signup-success">You're signed in as <strong>{loggedInAs}</strong>. Your notes are ready on every lesson.</p>
<div className="study-signup-actions">
<button type="button" className="btn-primary" onClick={() => navigate('/study')}>Go to Study Hub</button>
<Link to="/study/account" className="btn-secondary">My Account</Link>
<Link to="/study" className="btn-secondary">Browse Tracks</Link>
<p className="study-signup-success">You're signed in as <strong>{loggedInAs}</strong>.</p>
<p style={{ color: '#b09e79', marginBottom: '1.5rem', fontSize: '0.95rem' }}>Head to the study hub to enroll in a track and begin your first lesson.</p>
<div className="study-signup-actions" style={{ flexDirection: 'column', alignItems: 'stretch', gap: '0.75rem' }}>
<button type="button" className="btn-primary" style={{ justifyContent: 'center' }} onClick={() => navigate('/study')}>Browse & Enroll in a Study →</button>
<Link to="/study/account" className="btn-secondary" style={{ justifyContent: 'center' }}>My Account</Link>
</div>
</>
) : totpPendingToken ? (
@@ -1059,21 +1150,37 @@ export function StudySignupPage() {
id="signup-email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
onChange={e => { setEmail(e.target.value); if (fieldErrors.email) setFieldErrors(prev => ({ ...prev, email: undefined })) }}
onBlur={() => {
if (email.trim() && !EMAIL_REGEX.test(email.trim())) {
setFieldErrors(prev => ({ ...prev, email: 'Please enter a valid email address.' }))
} else {
setFieldErrors(prev => ({ ...prev, email: undefined }))
}
}}
placeholder="your@email.com"
autoComplete="email"
autoFocus
/>
{fieldErrors.email && <p className="study-signup-field-error">{fieldErrors.email}</p>}
<label htmlFor="signup-password">Password</label>
<input
id="signup-password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
onChange={e => { setPassword(e.target.value); if (fieldErrors.password) setFieldErrors(prev => ({ ...prev, password: undefined })) }}
onBlur={() => {
if (mode === 'signup' && password && password.length < 8) {
setFieldErrors(prev => ({ ...prev, password: 'Password must be at least 8 characters.' }))
} else {
setFieldErrors(prev => ({ ...prev, password: undefined }))
}
}}
placeholder="At least 8 characters"
autoComplete={mode === 'signup' ? 'new-password' : 'current-password'}
onKeyDown={e => e.key === 'Enter' && submit()}
/>
{fieldErrors.password && <p className="study-signup-field-error">{fieldErrors.password}</p>}
{mode === 'signup' && (
<label className="contact-consent" style={{ marginTop: '0.75rem' }}>
<input
@@ -1381,19 +1488,24 @@ export function ColossiansStudySectionPage({ content }: Props) {
const [enrollBusy, setEnrollBusy] = useState(false)
const [enrollMessage, setEnrollMessage] = useState('')
const [noteText, setNoteText] = useState('')
const [noteMap, setNoteMap] = useState<Record<string, string>>({})
const [openAnchor, setOpenAnchor] = useState<NoteAnchorId | null>(null)
const [noteLoading, setNoteLoading] = useState(false)
const [noteSaving, setNoteSaving] = useState(false)
const [noteMessage, setNoteMessage] = useState('')
const [notesModalOpen, setNotesModalOpen] = useState(false)
const [contextNoteMenu, setContextNoteMenu] = useState<{ x: number; y: number; text: string } | null>(null)
const noteMapDirtyRef = useRef(false)
const [contextNoteMenu, setContextNoteMenu] = useState<{ x: number; y: number; text: string; anchor: NoteAnchorId } | null>(null)
const [completedSectionIds, setCompletedSectionIds] = useState<string[]>([])
const [progressSaving, setProgressSaving] = useState(false)
const [progressMessage, setProgressMessage] = useState('')
const [checkpointAnswers, setCheckpointAnswers] = useState<Record<number, string>>({})
const [showCelebration, setShowCelebration] = useState(false)
const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1')
const [autoSaveMsg, setAutoSaveMsg] = useState('')
const [_checkpointReflection, _setCheckpointReflection] = useState('')
const canSaveNote = auth.authenticated && !noteSaving && !noteLoading
const savedNoteCount = Object.values(noteMap).filter(v => v?.trim()).length
const lessonAudioEmbedUrl = useMemo(() => {
const trimmed = section?.audioEmbedUrl?.trim() ?? ''
if (!trimmed) return ''
@@ -1461,7 +1573,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
useEffect(() => {
if (!auth.authenticated || !noteId || !isEnrolled) {
setNoteText('')
setNoteMap({})
noteMapDirtyRef.current = false
return
}
@@ -1472,7 +1585,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
readJson<{ note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`)
.then(data => {
if (cancelled) return
setNoteText(data.note ?? '')
setNoteMap(parseNoteMap(data.note ?? ''))
noteMapDirtyRef.current = false
})
.catch(err => {
if (cancelled) return
@@ -1515,17 +1629,22 @@ export function ColossiansStudySectionPage({ content }: Props) {
}
}
function updateNote(anchor: NoteAnchorId, html: string) {
noteMapDirtyRef.current = true
setNoteMap(prev => ({ ...prev, [anchor]: html }))
}
async function saveNote() {
if (!noteId || !canSaveNote) return
setNoteSaving(true)
setNoteMessage('')
try {
const data = await readJson<{ ok: boolean; note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
await readJson<{ ok: boolean; note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: noteText }),
body: JSON.stringify({ note: JSON.stringify(noteMap) }),
})
setNoteText(data.note ?? '')
noteMapDirtyRef.current = false
setNoteMessage('Notes saved.')
} catch (err) {
setNoteMessage(err instanceof Error ? err.message : 'Unable to save note.')
@@ -1534,23 +1653,54 @@ export function ColossiansStudySectionPage({ content }: Props) {
}
}
function closeNotesModal() {
setNotesModalOpen(false)
}
useEffect(() => {
if (!notesModalOpen) return
if (!openAnchor) return
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') closeNotesModal()
if (e.key === 'Escape') setOpenAnchor(null)
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [notesModalOpen])
}, [openAnchor])
function appendSelectedTextToNotes(selectedText: string) {
useEffect(() => {
if (!auth.authenticated || !noteId || noteLoading) return
if (!noteMapDirtyRef.current) return
const timer = setTimeout(async () => {
if (!noteMapDirtyRef.current) return
noteMapDirtyRef.current = false
try {
await readJson<{ ok: boolean }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: JSON.stringify(noteMap) }),
})
setAutoSaveMsg('Auto-saved.')
setTimeout(() => setAutoSaveMsg(''), 3000)
} catch {
noteMapDirtyRef.current = true
}
}, 2000)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [noteMap, noteId, auth.authenticated, noteLoading])
useEffect(() => {
if (!showCelebration) return
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setShowCelebration(false)
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [showCelebration])
function appendSelectedTextToNotes(selectedText: string, anchor: NoteAnchorId = 'general') {
if (!selectedText.trim()) return
setNoteText(prev => prev ? `${prev.trim()}\n\n${selectedText.trim()}` : selectedText.trim())
setNotesModalOpen(true)
const existing = noteMap[anchor] ?? ''
const appended = existing
? `${existing}<p>${selectedText.trim()}</p>`
: `<p>${selectedText.trim()}</p>`
updateNote(anchor, appended)
setOpenAnchor(anchor)
}
function handleLessonContentContextMenu(e: React.MouseEvent<HTMLDivElement>) {
@@ -1562,7 +1712,9 @@ export function ColossiansStudySectionPage({ content }: Props) {
return
}
e.preventDefault()
setContextNoteMenu({ x: e.clientX + 4, y: e.clientY + 4, text: selectedText })
const anchorEl = (e.target as Element).closest('[data-note-anchor]')
const anchor = (anchorEl?.getAttribute('data-note-anchor') ?? 'general') as NoteAnchorId
setContextNoteMenu({ x: e.clientX + 4, y: e.clientY + 4, text: selectedText, anchor })
}
useEffect(() => {
@@ -1572,6 +1724,15 @@ export function ColossiansStudySectionPage({ content }: Props) {
return () => window.removeEventListener('mousedown', handleClick)
}, [contextNoteMenu])
const releasedSectionCount = sections.filter(s => isSectionReleased(s)).length
function checkAndShowCelebration(newIds: string[], wasAlreadyComplete: boolean) {
if (wasAlreadyComplete) return
if (releasedSectionCount > 0 && newIds.length >= releasedSectionCount) {
setShowCelebration(true)
}
}
async function submitCheckpoint() {
if (!study || !section?.id || progressSaving) return
const checkpointQuestions = section.checkpointQuestions ?? []
@@ -1588,8 +1749,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`, {
method: 'POST',
})
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
const newIds = Array.isArray(data.completedSectionIds) ? data.completedSectionIds : []
setCompletedSectionIds(newIds)
setProgressMessage('')
checkAndShowCelebration(newIds, lessonCompleted)
} catch (err) {
setProgressMessage(err instanceof Error ? err.message : 'Unable to save. Please try again.')
} finally {
@@ -1689,13 +1852,36 @@ export function ColossiansStudySectionPage({ content }: Props) {
return (
<main className="study-section-page" aria-label={section.title}>
{section.announcement && (
{section.announcement && !bannerDismissed && (
<div className="study-lesson-announcement-banner" role="alert">
<span className="study-lesson-announcement-icon" aria-hidden="true">📢</span>
<div className="study-lesson-announcement-body">
<strong className="study-lesson-announcement-label">Announcement</strong>
<p className="study-lesson-announcement-text">{section.announcement}</p>
</div>
<button
type="button"
className="study-lesson-announcement-close"
aria-label="Dismiss announcement"
onClick={() => {
setBannerDismissed(true)
localStorage.setItem('study-banner-dismissed-v1', '1')
}}
>×</button>
</div>
)}
{showCelebration && study && (
<div className="study-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="celebrate-heading">
<div className="study-modal-box" style={{ textAlign: 'center', maxWidth: '480px' }}>
<p style={{ fontSize: '2.5rem', margin: '0 0 0.5rem' }}>🎉</p>
<h2 id="celebrate-heading" style={{ margin: '0 0 0.75rem' }}>You finished {study.title}!</h2>
<p style={{ color: '#b09e79', marginBottom: '1.5rem' }}>Your certificate of completion is ready on the study dashboard.</p>
<div className="study-auth-actions" style={{ justifyContent: 'center' }}>
<Link to="/study/account" className="btn-primary" onClick={() => setShowCelebration(false)}>View Certificate</Link>
<button type="button" className="btn-secondary" onClick={() => setShowCelebration(false)}>Continue</button>
</div>
</div>
</div>
)}
<section className="section-study-classroom" onContextMenu={handleLessonContentContextMenu}>
@@ -1729,12 +1915,39 @@ export function ColossiansStudySectionPage({ content }: Props) {
</article>
)}
<article className="study-class-block">
<article className="study-class-block study-class-block--anchored" data-note-anchor="scripture">
{isEnrolled && (
<button
type="button"
className={`note-anchor-btn${noteMap['scripture']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'scripture' ? ' note-anchor-btn--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === 'scripture' ? null : 'scripture')}
title={noteMap['scripture']?.trim() ? 'Edit scripture note' : 'Add note to Scripture'}
aria-pressed={openAnchor === 'scripture'}
>✎</button>
)}
<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>
{isEnrolled && openAnchor === 'scripture' && (
<NoteCard
anchorLabel="Scripture Text"
html={noteMap['scripture'] ?? ''}
autoSaveMsg={autoSaveMsg}
onChange={html => updateNote('scripture', html)}
onClose={() => setOpenAnchor(null)}
/>
)}
</article>
<article className="study-class-block">
<article className="study-class-block study-class-block--anchored" data-note-anchor="commentary">
{isEnrolled && (
<button
type="button"
className={`note-anchor-btn${noteMap['commentary']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'commentary' ? ' note-anchor-btn--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === 'commentary' ? null : 'commentary')}
title={noteMap['commentary']?.trim() ? 'Edit commentary note' : 'Add note to Commentary'}
aria-pressed={openAnchor === 'commentary'}
>✎</button>
)}
<h2>Instructor Commentary</h2>
{section.commentary.split(/\n{2,}/).map((para, i) => (
<p key={i} className="study-detail-copy">
@@ -1743,9 +1956,27 @@ export function ColossiansStudySectionPage({ content }: Props) {
))}
</p>
))}
{isEnrolled && openAnchor === 'commentary' && (
<NoteCard
anchorLabel="Commentary"
html={noteMap['commentary'] ?? ''}
autoSaveMsg={autoSaveMsg}
onChange={html => updateNote('commentary', html)}
onClose={() => setOpenAnchor(null)}
/>
)}
</article>
<article className="study-class-block">
<article className="study-class-block study-class-block--anchored" data-note-anchor="questions">
{isEnrolled && (
<button
type="button"
className={`note-anchor-btn${noteMap['questions']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'questions' ? ' note-anchor-btn--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === 'questions' ? null : 'questions')}
title={noteMap['questions']?.trim() ? 'Edit reflection note' : 'Add note to Reflection Questions'}
aria-pressed={openAnchor === 'questions'}
>✎</button>
)}
<h2>Reflection Questions</h2>
<p className="study-detail-copy" style={{ marginBottom: '0.75rem', color: '#8a7f5a', fontSize: '0.9rem' }}>
Work through these on your own, then share your thoughts in the Discussion below.
@@ -1760,6 +1991,15 @@ export function ColossiansStudySectionPage({ content }: Props) {
<Link to={`/study/${study.slug}/community`} className="btn-primary">Go to Community</Link>
<Link to={`/contact?source=community&study=${encodeURIComponent(study.title)}`} className="btn-secondary">Ask Nate</Link>
</div>
{isEnrolled && openAnchor === 'questions' && (
<NoteCard
anchorLabel="Reflection Questions"
html={noteMap['questions'] ?? ''}
autoSaveMsg={autoSaveMsg}
onChange={html => updateNote('questions', html)}
onClose={() => setOpenAnchor(null)}
/>
)}
</article>
{isEnrolled && !(section.checkpointQuestions && section.checkpointQuestions.length > 0) && (
@@ -1776,13 +2016,16 @@ export function ColossiansStudySectionPage({ content }: Props) {
onClick={async () => {
if (!study || !section?.id) return
setProgressSaving(true)
const wasComplete = lessonCompleted
try {
const method = lessonCompleted ? 'DELETE' : 'POST'
const method = wasComplete ? 'DELETE' : 'POST'
const data = await readJson<{ ok: boolean; completedSectionIds: string[] }>(
`/api/study-progress/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`,
{ method }
)
setCompletedSectionIds(Array.isArray(data.completedSectionIds) ? data.completedSectionIds : [])
const newIds = Array.isArray(data.completedSectionIds) ? data.completedSectionIds : []
setCompletedSectionIds(newIds)
if (!wasComplete) checkAndShowCelebration(newIds, false)
} catch { /* ignore */ }
finally { setProgressSaving(false) }
}}
@@ -1886,12 +2129,35 @@ export function ColossiansStudySectionPage({ content }: Props) {
{auth.checked && auth.authenticated && (
<div className="study-notes-box">
<p className="study-notes-user">Signed in as {auth.username}</p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.5rem' }}>
<button type="button" className="btn-primary" onClick={() => setNotesModalOpen(true)}>Open Notes</button>
<Link to="/study/account" className="btn-secondary">My Account</Link>
</div>
<p style={{ margin: '0.5rem 0 0', color: '#b9b09b', fontSize: '0.9rem' }}>Highlight scripture, commentary, or Greek word text, right click, and add it to your notes.</p>
{noteLoading ? <p style={{ marginTop: '0.75rem' }}>Loading notes…</p> : null}
<Link to="/study/account" className="btn-secondary" style={{ marginBottom: '0.5rem', display: 'inline-block' }}>My Account</Link>
{noteLoading ? (
<p style={{ color: '#7a7060', fontSize: '0.85rem', marginTop: '0.5rem' }}>Loading notes…</p>
) : (
<>
{savedNoteCount > 0 && (
<p style={{ fontSize: '0.82rem', color: '#7a9c6a', marginBottom: '0.5rem' }}>
{savedNoteCount} note{savedNoteCount !== 1 ? 's' : ''} saved ✓
</p>
)}
<div className="note-anchor-list">
{NOTE_ANCHORS.map(({ id, label }) => {
const hasNote = !!(noteMap[id]?.trim())
return (
<button
key={id}
type="button"
className={`note-anchor-list-item${hasNote ? ' note-anchor-list-item--has' : ''}${openAnchor === id ? ' note-anchor-list-item--open' : ''}`}
onClick={() => setOpenAnchor(curr => curr === id ? null : id as NoteAnchorId)}
>
<span>{label}</span>
{hasNote && <span className="note-anchor-dot" aria-hidden="true">●</span>}
</button>
)
})}
</div>
<p style={{ margin: '0.5rem 0 0', color: '#7a7060', fontSize: '0.82rem' }}>Click a section above to add notes. Right-click lesson text to append a selection.</p>
</>
)}
</div>
)}
{(authMessage || noteMessage || progressMessage) && <p className="study-note-status">{authMessage || noteMessage || progressMessage}</p>}
@@ -1930,7 +2196,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
}}
onMouseDown={e => e.stopPropagation()}
>
<p style={{ margin: '0 0 0.75rem', color: '#ece3c6', fontSize: '0.95rem' }}>Add selected text to notes:</p>
<p style={{ margin: '0 0 0.35rem', color: '#ece3c6', fontSize: '0.95rem' }}>Add to <strong style={{ color: '#c9a84c' }}>{ANCHOR_LABELS[contextNoteMenu.anchor] ?? 'Notes'}</strong>:</p>
<div style={{ marginBottom: '0.75rem', maxHeight: '100px', overflow: 'auto', color: '#f0ead8', fontSize: '0.9rem' }}>
{contextNoteMenu.text}
</div>
@@ -1938,7 +2204,7 @@ export function ColossiansStudySectionPage({ content }: Props) {
type="button"
className="btn-primary"
onClick={() => {
appendSelectedTextToNotes(contextNoteMenu.text)
appendSelectedTextToNotes(contextNoteMenu.text, contextNoteMenu.anchor)
setContextNoteMenu(null)
}}
>
@@ -1947,73 +2213,6 @@ export function ColossiansStudySectionPage({ content }: Props) {
</div>
)}
{notesModalOpen && (
<div
className="study-modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Lesson notes editor"
onClick={closeNotesModal}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(12, 11, 10, 0.75)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 40,
padding: '1rem',
}}
>
<div
className="study-modal"
onClick={e => e.stopPropagation()}
style={{
width: 'min(760px, 100%)',
maxHeight: 'min(90vh, 900px)',
overflowY: 'auto',
backgroundColor: '#1b1a16',
border: '1px solid #3c3a31',
borderRadius: '18px',
padding: '1.5rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<div>
<h2 style={{ margin: 0 }}>Lesson Notes</h2>
<p style={{ margin: '0.5rem 0 0', color: '#b9b09b' }}>Save notes for this lesson and paste selections from the page.</p>
</div>
<button type="button" className="btn-secondary" onClick={closeNotesModal}>Close</button>
</div>
<textarea
rows={12}
value={noteText}
onChange={e => setNoteText(e.target.value)}
placeholder="Write your notes here..."
style={{
width: '100%',
minHeight: '280px',
padding: '1rem',
borderRadius: '16px',
border: '1px solid #3f3b2f',
background: '#14130f',
color: '#f0ead8',
fontSize: '1rem',
lineHeight: '1.6',
}}
/>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button type="button" className="btn-primary" onClick={saveNote} disabled={!canSaveNote}>
{noteSaving ? 'Saving...' : 'Save Notes'}
</button>
<button type="button" className="btn-secondary" onClick={closeNotesModal}>Cancel</button>
</div>
{(noteMessage || progressMessage) && <p className="study-note-status" style={{ marginTop: '1rem' }}>{noteMessage || progressMessage}</p>}
</div>
</div>
)}
</div>
</section>
</main>
@@ -2189,8 +2388,19 @@ export function ColossiansStudyNotesPage({ content }: Props) {
<p className="study-module-lesson">{active.section ? `Lesson ${getLessonNumber(study.sections, active.section.id)}` : 'Saved Note'}</p>
<h3>{active.section?.title ?? active.sectionId}</h3>
<p className="study-detail-reference">{active.section ? active.section.reference : 'Lesson reference unavailable'}</p>
<p className="study-module-focus">Your Note</p>
<p className="study-detail-copy study-detail-copy--scripture">{active.note}</p>
{(() => {
const anchorMap = parseNoteMap(active.note)
const hasContent = Object.values(anchorMap).some(v => v?.trim())
if (!hasContent) return <p className="study-detail-copy">No notes saved for this lesson yet.</p>
return Object.entries(anchorMap).map(([key, html]) => (
html?.trim() ? (
<div key={key} style={{ marginBottom: '1.25rem' }}>
<p className="study-module-focus" style={{ marginBottom: '0.5rem' }}>{ANCHOR_LABELS[key] ?? key}</p>
<div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: html }} />
</div>
) : null
))
})()}
{active.section && <Link to={`/study/${study.slug}/${active.section.id}`} className="btn-secondary">Open Lesson</Link>}
</article>
</div>