notes upgrade
This commit is contained in:
+345
-135
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user