Redesign study lesson UI: progress bar, sidebar nav, inline answers; v1.2.5

- Progress bar: thin gold bar under lesson label showing completed/total lessons
- Sidebar lesson navigator: all lessons listed with ✓/current/○ states, replaces static Study Track + Lesson Navigation blocks
- Notes panel moves to sidebar: clicking a block's Note button opens NoteCard in sidebar instead of inline, preserving reading position
- Inline reflection answers: textareas under each question auto-save to quiz API; replaces separate quiz page link
- Note buttons: "Note" / "✎ Note" text label instead of bare pencil icon for better discoverability
- Greek Word Study block hidden when lesson has no greek notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-03 16:01:15 -04:00
1 parent 038ef2b039
commit 874dd0c89b
3 files changed
+328 -108

No files matched your search

+186 -5
View File
@@ -2668,17 +2668,20 @@
.note-anchor-btn {
position: absolute;
top: 1rem;
right: 1rem;
top: 0.85rem;
right: 0.85rem;
background: transparent;
border: 1px solid #3a3828;
border-radius: 6px;
color: #5a5440;
padding: 0.2rem 0.55rem;
font-size: 0.9rem;
padding: 0.22rem 0.65rem;
font-size: 0.78rem;
font-family: var(--brand-font-body);
font-weight: 500;
letter-spacing: 0.02em;
cursor: pointer;
transition: color 150ms, border-color 150ms, background 150ms;
line-height: 1;
line-height: 1.4;
z-index: 1;
}
@@ -2699,6 +2702,184 @@
border-color: rgba(224, 192, 112, 0.5);
}
/* ── Progress bar ────────────────────────────────────────────────── */
.study-lesson-progress-bar {
height: 3px;
background: rgba(201, 168, 76, 0.12);
border-radius: 2px;
margin: 0.35rem 0 0.1rem;
overflow: hidden;
}
.study-lesson-progress-fill {
height: 100%;
background: #c9a84c;
border-radius: 2px;
transition: width 0.4s ease;
min-width: 3px;
}
/* ── Sidebar lesson navigator ────────────────────────────────────── */
.study-lesson-nav-progress {
font-size: 0.72rem;
font-weight: 400;
color: #5a5440;
margin-left: 0.4rem;
letter-spacing: 0.02em;
}
.study-lesson-nav {
display: flex;
flex-direction: column;
gap: 1px;
margin-top: 0.25rem;
max-height: 320px;
overflow-y: auto;
}
.study-lesson-nav-item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.42rem 0.55rem;
border-radius: 7px;
border: 1px solid transparent;
text-decoration: none;
transition: background 120ms, border-color 120ms;
cursor: pointer;
}
.study-lesson-nav-item:hover {
background: rgba(201, 168, 76, 0.06);
}
.study-lesson-nav-item--current {
background: rgba(201, 168, 76, 0.1);
border-color: rgba(201, 168, 76, 0.2);
}
.study-lesson-nav-item--locked {
opacity: 0.45;
cursor: default;
pointer-events: none;
}
.study-lesson-nav-icon {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
font-weight: 700;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.05);
color: #5a5440;
}
.study-lesson-nav-item--done .study-lesson-nav-icon {
background: rgba(201, 168, 76, 0.18);
color: #c9a84c;
}
.study-lesson-nav-item--current .study-lesson-nav-icon {
background: #c9a84c;
color: #0e0d0b;
}
.study-lesson-nav-label {
font-size: 0.8rem;
font-weight: 500;
color: #7a7060;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.study-lesson-nav-item--current .study-lesson-nav-label {
color: #e2d4a8;
}
.study-lesson-nav-item--done .study-lesson-nav-label {
color: #5a5040;
}
.study-lesson-nav-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
/* ── Notes count badge ────────────────────────────────────────────── */
.study-notes-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
border-radius: 9px;
background: rgba(201, 168, 76, 0.18);
color: #c9a84c;
font-size: 0.7rem;
font-weight: 600;
padding: 0 5px;
margin-left: 0.4rem;
font-family: var(--brand-font-body);
vertical-align: middle;
}
/* ── Inline reflection question answers ─────────────────────────── */
.study-detail-list--questions {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
margin-top: 0.25rem;
}
.study-question-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.study-question-text {
color: var(--brand-muted);
font-size: 0.93rem;
line-height: 1.6;
margin: 0;
}
.study-question-answer {
width: 100%;
box-sizing: border-box;
background: rgba(201, 168, 76, 0.04);
border: 1px solid rgba(201, 168, 76, 0.15);
border-radius: 8px;
padding: 0.65rem 0.85rem;
font-size: 0.88rem;
font-family: var(--brand-font-body);
color: #f0ead8;
resize: vertical;
min-height: 72px;
line-height: 1.6;
transition: border-color 150ms;
}
.study-question-answer:focus {
outline: none;
border-color: rgba(201, 168, 76, 0.4);
}
.study-question-answer::placeholder {
color: #3a3020;
}
/* ── Sidebar anchor list ─────────────────────────────────────────── */
.note-anchor-list {
display: flex;
+141 -102
View File
@@ -1507,6 +1507,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
const [progressSaving, setProgressSaving] = useState(false)
const [progressMessage, setProgressMessage] = useState('')
const [checkpointAnswers, setCheckpointAnswers] = useState<Record<number, string>>({})
const [quizAnswers, setQuizAnswers] = useState<string[]>([])
const quizAnswersDirtyRef = useRef(false)
const [showCelebration, setShowCelebration] = useState(false)
const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1')
const [autoSaveMsg, setAutoSaveMsg] = useState('')
@@ -1692,6 +1694,44 @@ export function ColossiansStudySectionPage({ content }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [noteMap, noteId, auth.authenticated, noteLoading])
useEffect(() => {
if (!auth.authenticated || !isEnrolled || !study || !section?.id) {
setQuizAnswers([])
quizAnswersDirtyRef.current = false
return
}
let cancelled = false
readJson<{ answers: string[] }>(`/api/study-quiz/${encodeURIComponent(currentStudySlug)}/${encodeURIComponent(section.id)}`)
.then(data => {
if (cancelled) return
setQuizAnswers(Array.isArray(data.answers) ? data.answers : [])
quizAnswersDirtyRef.current = false
})
.catch(() => { if (!cancelled) setQuizAnswers([]) })
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [auth.authenticated, isEnrolled, currentStudySlug, section?.id])
useEffect(() => {
if (!auth.authenticated || !isEnrolled || !study || !section?.id) return
if (!quizAnswersDirtyRef.current) return
const timer = setTimeout(async () => {
if (!quizAnswersDirtyRef.current) return
quizAnswersDirtyRef.current = false
try {
await readJson<{ ok: boolean; answers: string[] }>(`/api/study-quiz/${encodeURIComponent(currentStudySlug)}/${encodeURIComponent(section.id)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers: quizAnswers }),
})
} catch {
quizAnswersDirtyRef.current = true
}
}, 1500)
return () => clearTimeout(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [quizAnswers, auth.authenticated, isEnrolled, currentStudySlug, section?.id])
useEffect(() => {
if (!showCelebration) return
function onKeyDown(e: KeyboardEvent) {
@@ -1896,6 +1936,11 @@ export function ColossiansStudySectionPage({ content }: Props) {
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: `Lesson ${lessonNumber}` }]} />
<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>
{sections.length > 0 && (
<div className="study-lesson-progress-bar" role="progressbar" aria-valuenow={completedSectionIds.length} aria-valuemax={sections.length} aria-label={`${completedSectionIds.length} of ${sections.length} lessons completed`}>
<div className="study-lesson-progress-fill" style={{ width: `${Math.round((completedSectionIds.length / sections.length) * 100)}%` }} />
</div>
)}
<h1>{section.title}</h1>
<p className="study-detail-reference">{section.reference}</p>
<p className="study-detail-summary">{section.summary}</p>
@@ -1927,9 +1972,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
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>
>{noteMap['scripture']?.trim() ? '✎ Note' : '+ Note'}</button>
)}
<h2>Scripture Text</h2>
{bsbPassageLoading
@@ -1943,15 +1987,6 @@ export function ColossiansStudySectionPage({ content }: Props) {
: <p className="study-detail-copy" style={{ opacity: 0.5 }}>Passage text not available.</p>
}
{bsbPassage && <p style={{ fontSize: '0.78rem', color: '#5a5440', marginTop: '0.5rem' }}>Berean Standard Bible (BSB)</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 study-class-block--anchored" data-note-anchor="commentary">
@@ -1960,9 +1995,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
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>
>{noteMap['commentary']?.trim() ? '✎ Note' : '+ Note'}</button>
)}
<h2>Instructor Commentary</h2>
{section.commentary.split(/\n{2,}/).map((para, i) => (
@@ -1972,15 +2006,6 @@ 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 study-class-block--anchored" data-note-anchor="questions">
@@ -1989,33 +2014,35 @@ export function ColossiansStudySectionPage({ content }: Props) {
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>
>{noteMap['questions']?.trim() ? '✎ Note' : '+ Note'}</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.
</p>
<ol className="study-detail-list">
<ol className="study-detail-list study-detail-list--questions">
{section.studyQuestions.map((question, index) => (
<li key={index}>{question}</li>
<li key={index} className="study-question-item">
<p className="study-question-text">{question}</p>
{isEnrolled && (
<textarea
className="study-question-answer"
value={quizAnswers[index] ?? ''}
onChange={e => {
const next = [...quizAnswers]
next[index] = e.target.value
setQuizAnswers(next)
quizAnswersDirtyRef.current = true
}}
placeholder="Write your answer…"
rows={2}
/>
)}
</li>
))}
</ol>
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<Link to={`/study/${study.slug}/${section.id}/quiz`} className="btn-secondary">Write My Answers</Link>
<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) && (
@@ -2133,28 +2160,40 @@ export function ColossiansStudySectionPage({ content }: Props) {
</div>
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
{/* Lesson navigator */}
<article className="study-class-side-block">
<h3>Study Track</h3>
<p>{study.title}</p>
<p>{study.description}</p>
<h3>Lessons <span className="study-lesson-nav-progress">{completedSectionIds.length}/{sections.length}</span></h3>
<nav className="study-lesson-nav" aria-label="All lessons">
{sections.map(s => {
const sNum = getLessonNumber(sections, s.id)
const isDone = completedSectionIds.includes(s.id)
const isCurr = s.id === section.id
const released = isSectionReleased(s)
return (
<Link
key={s.id}
to={released ? `/study/${study.slug}/${s.id}` : '#'}
aria-current={isCurr ? 'page' : undefined}
className={`study-lesson-nav-item${isCurr ? ' study-lesson-nav-item--current' : ''}${isDone ? ' study-lesson-nav-item--done' : ''}${!released ? ' study-lesson-nav-item--locked' : ''}`}
onClick={e => { if (!released) e.preventDefault() }}
>
<span className="study-lesson-nav-icon" aria-hidden="true">
{isDone ? '✓' : isCurr ? String(sNum) : released ? String(sNum) : '○'}
</span>
<span className="study-lesson-nav-label">{s.title}</span>
</Link>
)
})}
</nav>
</article>
{/* Notes panel */}
<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>}
<h3>
Your Notes
{savedNoteCount > 0 && <span className="study-notes-count-badge">{savedNoteCount}</span>}
</h3>
{!auth.checked && <p className="study-detail-copy" style={{ fontSize: '0.85rem' }}>Checking sign-in…</p>}
{auth.checked && !auth.authenticated && (
<div className="study-auth-box">
<p className="study-auth-why">Sign in to save notes for this lesson.</p>
@@ -2166,60 +2205,60 @@ export function ColossiansStudySectionPage({ content }: Props) {
<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>
{authMessage && <p className="study-note-status" style={{ marginTop: '0.5rem' }}>{authMessage}</p>}
</div>
)}
{auth.checked && auth.authenticated && (
<div className="study-notes-box">
<p className="study-notes-user">Signed in as {auth.username}</p>
<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>
<p style={{ color: '#7a7060', fontSize: '0.85rem' }}>Loading notes…</p>
) : openAnchor ? (
<NoteCard
anchorLabel={ANCHOR_LABELS[openAnchor] ?? 'Notes'}
html={noteMap[openAnchor] ?? ''}
autoSaveMsg={autoSaveMsg}
onChange={html => updateNote(openAnchor as NoteAnchorId, html)}
onClose={() => setOpenAnchor(null)}
/>
) : (
<>
{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>
</>
<p style={{ fontSize: '0.85rem', color: '#5a5040', lineHeight: 1.55 }}>
{savedNoteCount > 0
? `${savedNoteCount} note${savedNoteCount !== 1 ? 's' : ''} saved. Click a section's Note button to edit.`
: 'Click the Note button on Scripture, Commentary, or Reflection Questions to write notes here.'}
</p>
)}
</div>
{autoSaveMsg && <p style={{ fontSize: '0.82rem', color: '#7a9c6a', marginTop: '0.35rem' }}>{autoSaveMsg}</p>}
{noteMessage && <p className="study-note-status" style={{ marginTop: '0.35rem' }}>{noteMessage}</p>}
<Link to="/study/account" style={{ display: 'block', fontSize: '0.82rem', color: '#7a7060', marginTop: '0.75rem' }}>My Account →</Link>
</>
)}
{(authMessage || noteMessage || progressMessage) && <p className="study-note-status">{authMessage || noteMessage || progressMessage}</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 && !isSectionReleased(nextSection) ? (
<span className="study-nav-placeholder">Available {getReleaseDateDisplay(nextSection)}</span>
) : nextSection && !lessonCompleted ? (
<span className="study-nav-placeholder" title="Complete this lesson's checkpoint to unlock the next one">🔒 Complete checkpoint first</span>
) : nextSection ? (
<Link to={`/study/${study.slug}/${nextSection.id}`} className="btn-secondary">Next Lesson</Link>
) : <span className="study-nav-placeholder" />}
</div>
</article>
{/* Greek Word Study */}
{section.greekNotes.length > 0 && (
<article className="study-class-side-block">
<h3>Greek Word Study</h3>
<ul className="study-detail-list">
{section.greekNotes.map((note, index) => (
<li key={index}>{note}</li>
))}
</ul>
</article>
)}
{/* Prev / Next navigation */}
<div className="study-lesson-nav-actions">
{previousSection ? (
<Link to={`/study/${study.slug}/${previousSection.id}`} className="btn-secondary">← Previous</Link>
) : <span />}
{nextSection && !isSectionReleased(nextSection) ? (
<span className="study-nav-placeholder" style={{ fontSize: '0.82rem', color: '#5a5440' }}>Next available {getReleaseDateDisplay(nextSection)}</span>
) : nextSection && !lessonCompleted ? (
<span className="study-nav-placeholder" style={{ fontSize: '0.82rem', color: '#5a5440' }}>Complete lesson to continue</span>
) : nextSection ? (
<Link to={`/study/${study.slug}/${nextSection.id}`} className="btn-secondary">Next →</Link>
) : null}
</div>
</aside>
{contextNoteMenu && (