Make homepage study card admin-editable with NEW tag controls
This commit is contained in:
+311
-12
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
|
||||
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
|
||||
import { DEFAULTS } from './content'
|
||||
import { AnalyticsPanel } from './components/AnalyticsPanel'
|
||||
|
||||
@@ -156,13 +156,13 @@ interface ContactReplyConfig {
|
||||
note: string
|
||||
}
|
||||
|
||||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards'>
|
||||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
|
||||
|
||||
type AdminView =
|
||||
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
|
||||
| 'current-series' | 'episode-highlights' | 'archived-series'
|
||||
| 'downloads' | 'custom-links' | 'content-blocks'
|
||||
| 'questions' | 'analytics' | 'assets'
|
||||
| 'questions' | 'analytics' | 'assets' | 'colossians-study'
|
||||
| 'emails' | 'subscribers' | 'contacts'
|
||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
|
||||
|
||||
@@ -287,9 +287,68 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
|
||||
{ key: 'welcomeEmailSignoff', label: 'Welcome Email — Signoff HTML', multiline: true, section: 'global' },
|
||||
]
|
||||
|
||||
function normalizeStudies(siteContent: SiteContent): StudyProgram[] {
|
||||
if (Array.isArray(siteContent.studies) && siteContent.studies.length > 0) {
|
||||
return siteContent.studies.map(study => ({
|
||||
...study,
|
||||
homepageEyebrow: typeof study.homepageEyebrow === 'string' ? study.homepageEyebrow : '',
|
||||
showOnHomepage: study.showOnHomepage === true,
|
||||
showNewTag: study.showNewTag === true,
|
||||
newTagLabel: typeof study.newTagLabel === 'string' ? study.newTagLabel : 'NEW',
|
||||
numberOfChapters: Number.isInteger(study.numberOfChapters) && study.numberOfChapters >= 1 && study.numberOfChapters <= 999 ? study.numberOfChapters : 1,
|
||||
}))
|
||||
}
|
||||
|
||||
const fallbackSections = siteContent.colossiansStudySections?.length
|
||||
? siteContent.colossiansStudySections
|
||||
: DEFAULTS.colossiansStudySections
|
||||
|
||||
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.',
|
||||
homepageEyebrow: 'New Study',
|
||||
showOnHomepage: true,
|
||||
showNewTag: true,
|
||||
newTagLabel: 'NEW',
|
||||
status: 'active',
|
||||
difficulty: 'intermediate',
|
||||
estimatedHours: 12,
|
||||
completionBadge: 'Colossians Completion',
|
||||
numberOfChapters: 4,
|
||||
sections: fallbackSections,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeSiteContentForAdmin(siteContent: SiteContent): SiteContent {
|
||||
const studies = normalizeStudies(siteContent)
|
||||
const colossians = studies.find(study => study.slug === 'colossians')
|
||||
|
||||
return {
|
||||
...siteContent,
|
||||
studies,
|
||||
colossiansStudySections: colossians?.sections?.length
|
||||
? colossians.sections
|
||||
: (siteContent.colossiansStudySections?.length ? siteContent.colossiansStudySections : DEFAULTS.colossiansStudySections),
|
||||
}
|
||||
}
|
||||
|
||||
function buildSiteContentForSave(siteContent: SiteContent): SiteContent {
|
||||
const normalized = normalizeSiteContentForAdmin(siteContent)
|
||||
const colossians = normalized.studies.find(study => study.slug === 'colossians')
|
||||
return {
|
||||
...normalized,
|
||||
colossiansStudySections: colossians?.sections ?? normalized.colossiansStudySections,
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [form, setForm] = useState<SiteContent>(content)
|
||||
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content))
|
||||
const normalizedContent = normalizeSiteContentForAdmin(content)
|
||||
const [form, setForm] = useState<SiteContent>(normalizedContent)
|
||||
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(normalizedContent))
|
||||
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [adminView, setAdminView] = useState<AdminView>('dashboard')
|
||||
@@ -368,8 +427,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
}, [form, adminView, previewOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const nextSnapshot = JSON.stringify(content)
|
||||
setForm(content)
|
||||
const normalized = normalizeSiteContentForAdmin(content)
|
||||
const nextSnapshot = JSON.stringify(normalized)
|
||||
setForm(normalized)
|
||||
setLastSavedSnapshot(nextSnapshot)
|
||||
}, [content])
|
||||
|
||||
@@ -575,7 +635,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
if (!r.ok) return
|
||||
const data = await r.json() as { siteContent?: Partial<SiteContent> }
|
||||
if (data?.siteContent && typeof data.siteContent === 'object') {
|
||||
const next = { ...DEFAULTS, ...data.siteContent }
|
||||
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...data.siteContent })
|
||||
setForm(next)
|
||||
onSave(next)
|
||||
}
|
||||
@@ -601,13 +661,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
}
|
||||
|
||||
async function handleSaveDraft() {
|
||||
const payload = buildSiteContentForSave(form)
|
||||
setStatus('saving')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const res = await fetch('/api/admin-content-draft', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ siteContent: form }),
|
||||
body: JSON.stringify({ siteContent: payload }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
@@ -615,7 +676,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
}
|
||||
const data = await res.json() as { updatedAt?: string }
|
||||
setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() }))
|
||||
setLastSavedSnapshot(JSON.stringify(form))
|
||||
setLastSavedSnapshot(JSON.stringify(payload))
|
||||
setStatus('saved')
|
||||
setTimeout(() => setStatus('idle'), 3500)
|
||||
} catch (err) {
|
||||
@@ -626,6 +687,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
|
||||
async function handlePublishDraft() {
|
||||
if (!confirm('Publish current changes to the live site now?')) return
|
||||
const payload = buildSiteContentForSave(form)
|
||||
setStatus('saving')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
@@ -633,7 +695,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const draftRes = await fetch('/api/admin-content-draft', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ siteContent: form }),
|
||||
body: JSON.stringify({ siteContent: payload }),
|
||||
})
|
||||
if (!draftRes.ok) {
|
||||
const data = await draftRes.json().catch(() => ({}))
|
||||
@@ -653,7 +715,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
if (!latestRes.ok) throw new Error('Failed to refresh published content')
|
||||
const latest = await latestRes.json() as { siteContent?: Partial<SiteContent> }
|
||||
if (latest?.siteContent) {
|
||||
const next = { ...DEFAULTS, ...latest.siteContent }
|
||||
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...latest.siteContent })
|
||||
setForm(next)
|
||||
onSave(next)
|
||||
setLastSavedSnapshot(JSON.stringify(next))
|
||||
@@ -1326,6 +1388,92 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
}))
|
||||
}
|
||||
|
||||
function addStudyProgram() {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
studies: [
|
||||
...(f.studies ?? []),
|
||||
{
|
||||
id: `study-${Date.now().toString(36)}`,
|
||||
slug: `new-study-${Date.now().toString(36)}`,
|
||||
title: 'New Study',
|
||||
description: 'Add study description',
|
||||
homepageEyebrow: 'New Study',
|
||||
showOnHomepage: false,
|
||||
showNewTag: false,
|
||||
newTagLabel: 'NEW',
|
||||
status: 'planned',
|
||||
difficulty: 'beginner',
|
||||
estimatedHours: 8,
|
||||
completionBadge: 'Study Completion',
|
||||
numberOfChapters: 4,
|
||||
sections: [],
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
function updateStudyProgram(studyId: string, field: keyof Omit<StudyProgram, 'sections'>, value: string | number | boolean) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
studies: (f.studies ?? []).map(study => study.id === studyId ? { ...study, [field]: value } : study),
|
||||
}))
|
||||
}
|
||||
|
||||
function removeStudyProgram(studyId: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
studies: (f.studies ?? []).filter(study => study.id !== studyId),
|
||||
}))
|
||||
}
|
||||
|
||||
function addStudySection(studyId: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
studies: (f.studies ?? []).map(study => study.id === studyId
|
||||
? {
|
||||
...study,
|
||||
sections: [
|
||||
...(study.sections ?? []),
|
||||
{
|
||||
id: `${study.slug || 'section'}-${Date.now().toString(36)}`,
|
||||
chapter: 1,
|
||||
reference: '',
|
||||
title: '',
|
||||
audioEmbedUrl: '',
|
||||
passageText: '',
|
||||
summary: '',
|
||||
commentary: '',
|
||||
greekNotes: [],
|
||||
studyQuestions: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
: study),
|
||||
}))
|
||||
}
|
||||
|
||||
function updateStudySection(studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | number | string[]) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
studies: (f.studies ?? []).map(study => study.id === studyId
|
||||
? {
|
||||
...study,
|
||||
sections: (study.sections ?? []).map(section => section.id === sectionId ? { ...section, [field]: value } : section),
|
||||
}
|
||||
: study),
|
||||
}))
|
||||
}
|
||||
|
||||
function removeStudySection(studyId: string, sectionId: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
studies: (f.studies ?? []).map(study => study.id === studyId
|
||||
? { ...study, sections: (study.sections ?? []).filter(section => section.id !== sectionId) }
|
||||
: study),
|
||||
}))
|
||||
}
|
||||
|
||||
function archiveCurrentSeriesSnapshot() {
|
||||
const currentTitle = form.seriesTitle.trim()
|
||||
if (!currentTitle) {
|
||||
@@ -1738,6 +1886,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
|
||||
<div className="admin-nav-group">
|
||||
<span className="admin-nav-label">Content</span>
|
||||
<button type="button" className={`admin-nav-item${adminView === 'colossians-study' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('colossians-study')}>Studies</button>
|
||||
<button type="button" className={`admin-nav-item${adminView === 'downloads' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('downloads')}>Downloads</button>
|
||||
<button type="button" className={`admin-nav-item${adminView === 'custom-links' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('custom-links')}>Custom Links</button>
|
||||
<button type="button" className={`admin-nav-item${adminView === 'content-blocks' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('content-blocks')}>Content Blocks</button>
|
||||
@@ -2724,6 +2873,156 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* STUDIES */}
|
||||
{adminView === 'colossians-study' && (
|
||||
<section className="admin-panel-section">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Studies</h2>
|
||||
<p>Manage multiple study tracks and their lesson sections (text, commentary, Greek notes, audio, and questions).</p>
|
||||
</div>
|
||||
|
||||
{(form.studies ?? []).length === 0 && <p className="admin-stats-note">No studies yet.</p>}
|
||||
|
||||
{(form.studies ?? []).map(study => (
|
||||
<details key={study.id} className="admin-collapsible-card" open={false}>
|
||||
<summary className="admin-collapsible-summary">
|
||||
<div>
|
||||
<strong>{study.title || 'Untitled study'} · /study/{study.slug || 'slug'}</strong>
|
||||
<p>{study.description || 'Study description'}</p>
|
||||
</div>
|
||||
<span className="admin-collapsible-hint">Expand to edit</span>
|
||||
</summary>
|
||||
<div className="admin-collapsible-body">
|
||||
<div className="admin-array-row" style={{ marginBottom: '1rem' }}>
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-title-${study.id}`}>Study Title</label>
|
||||
<input id={`study-title-${study.id}`} type="text" value={study.title} placeholder="Colossians: Rooted in Christ" onChange={e => updateStudyProgram(study.id, 'title', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-slug-${study.id}`}>Slug</label>
|
||||
<input id={`study-slug-${study.id}`} type="text" value={study.slug} placeholder="colossians" onChange={e => updateStudyProgram(study.id, 'slug', e.target.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'))} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-status-${study.id}`}>Status</label>
|
||||
<select id={`study-status-${study.id}`} value={study.status} onChange={e => updateStudyProgram(study.id, 'status', e.target.value)}>
|
||||
<option value="active">Active</option>
|
||||
<option value="planned">Planned</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-description-${study.id}`}>Description</label>
|
||||
<textarea id={`study-description-${study.id}`} rows={3} value={study.description} placeholder="Brief description for the study hub card" onChange={e => updateStudyProgram(study.id, 'description', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-home-eyebrow-${study.id}`}>Homepage Card Eyebrow</label>
|
||||
<input id={`study-home-eyebrow-${study.id}`} type="text" value={study.homepageEyebrow ?? ''} placeholder="New Study" onChange={e => updateStudyProgram(study.id, 'homepageEyebrow', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-new-tag-label-${study.id}`}>NEW Tag Label</label>
|
||||
<input id={`study-new-tag-label-${study.id}`} type="text" value={study.newTagLabel ?? 'NEW'} placeholder="NEW" onChange={e => updateStudyProgram(study.id, 'newTagLabel', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<input id={`study-home-feature-${study.id}`} type="checkbox" checked={study.showOnHomepage === true} onChange={e => updateStudyProgram(study.id, 'showOnHomepage', e.target.checked)} />
|
||||
<label htmlFor={`study-home-feature-${study.id}`}>Feature this study card on homepage</label>
|
||||
</div>
|
||||
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<input id={`study-show-new-tag-${study.id}`} type="checkbox" checked={study.showNewTag === true} onChange={e => updateStudyProgram(study.id, 'showNewTag', e.target.checked)} />
|
||||
<label htmlFor={`study-show-new-tag-${study.id}`}>Show NEW tag on homepage card</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-chapters-${study.id}`}>Number of Chapters</label>
|
||||
<input id={`study-chapters-${study.id}`} type="number" min="1" max="999" value={study.numberOfChapters} placeholder="4" onChange={e => updateStudyProgram(study.id, "numberOfChapters", Number(e.target.value) || 1)} />
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeStudyProgram(study.id)}>Remove Study</button>
|
||||
</div>
|
||||
|
||||
{(study.sections ?? []).length === 0 && (
|
||||
<p className="admin-stats-note">No sections yet for this study.</p>
|
||||
)}
|
||||
|
||||
{(study.sections ?? []).map(section => (
|
||||
<details key={section.id} className="admin-collapsible-card" open={false}>
|
||||
<summary className="admin-collapsible-summary">
|
||||
<div>
|
||||
<strong>Lesson {section.reference || section.id}</strong>
|
||||
<p>{section.title || 'Untitled section'}</p>
|
||||
</div>
|
||||
<span className="admin-collapsible-hint">Expand to edit lesson</span>
|
||||
</summary>
|
||||
<div className="admin-collapsible-body">
|
||||
<div className="admin-array-row">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-chapter-${study.id}-${section.id}`}>Chapter</label>
|
||||
<select id={`study-section-chapter-${study.id}-${section.id}`} value={section.chapter} onChange={e => updateStudySection(study.id, section.id, 'chapter', Number(e.target.value) || 1)}>
|
||||
{Array.from({ length: Math.max(study.numberOfChapters, section.chapter, 1) }, (_, index) => index + 1).map(chapterNumber => (
|
||||
<option key={chapterNumber} value={chapterNumber}>Chapter {chapterNumber}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-reference-${study.id}-${section.id}`}>Passage Reference</label>
|
||||
<input id={`study-section-reference-${study.id}-${section.id}`} type="text" value={section.reference} placeholder="1:1-2" onChange={e => updateStudySection(study.id, section.id, 'reference', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-title-${study.id}-${section.id}`}>Section Title</label>
|
||||
<input id={`study-section-title-${study.id}-${section.id}`} type="text" value={section.title} placeholder="Paul's Greeting" onChange={e => updateStudySection(study.id, section.id, 'title', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-audio-${study.id}-${section.id}`}>Spotify Embed URL (optional)</label>
|
||||
<input id={`study-section-audio-${study.id}-${section.id}`} type="url" value={section.audioEmbedUrl ?? ''} placeholder="https://open.spotify.com/embed/episode/..." onChange={e => updateStudySection(study.id, section.id, 'audioEmbedUrl', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-released-${study.id}-${section.id}`}>Release Date (optional)</label>
|
||||
<input id={`study-section-released-${study.id}-${section.id}`} type="datetime-local" value={section.releasedAt ? new Date(section.releasedAt).toISOString().slice(0, 16) : ''} onChange={e => {
|
||||
const val = e.target.value;
|
||||
if (val) {
|
||||
const date = new Date(val + ':00Z');
|
||||
updateStudySection(study.id, section.id, 'releasedAt', date.toISOString());
|
||||
} else {
|
||||
updateStudySection(study.id, section.id, 'releasedAt', '');
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-summary-${study.id}-${section.id}`}>Short Summary</label>
|
||||
<textarea id={`study-section-summary-${study.id}-${section.id}`} rows={3} value={section.summary} placeholder="One to two sentences that summarize the section." onChange={e => updateStudySection(study.id, section.id, 'summary', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-passage-${study.id}-${section.id}`}>Passage Text</label>
|
||||
<textarea id={`study-section-passage-${study.id}-${section.id}`} rows={4} value={section.passageText} placeholder="Paste the passage text here." onChange={e => updateStudySection(study.id, section.id, 'passageText', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-commentary-${study.id}-${section.id}`}>Commentary</label>
|
||||
<textarea id={`study-section-commentary-${study.id}-${section.id}`} rows={5} value={section.commentary} placeholder="Add your teaching notes and explanation here." onChange={e => updateStudySection(study.id, section.id, 'commentary', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-greek-${study.id}-${section.id}`}>Greek Words</label>
|
||||
<textarea id={`study-section-greek-${study.id}-${section.id}`} rows={4} value={(section.greekNotes ?? []).join('\n')} placeholder="One note per line" onChange={e => updateStudySection(study.id, section.id, 'greekNotes', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-questions-${study.id}-${section.id}`}>Study Questions</label>
|
||||
<textarea id={`study-section-questions-${study.id}-${section.id}`} rows={5} value={(section.studyQuestions ?? []).join('\n')} placeholder="One question per line" onChange={e => updateStudySection(study.id, section.id, 'studyQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeStudySection(study.id, section.id)}>Remove Lesson</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
|
||||
<button type="button" className="btn-admin-add" onClick={() => addStudySection(study.id)}>+ Add Lesson Section</button>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
|
||||
<button type="button" className="btn-admin-add" onClick={addStudyProgram}>+ Add Study Program</button>
|
||||
{renderSaveStatus()}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* GLOBAL / FOOTER & PLATFORM */}
|
||||
{adminView === 'global' && (
|
||||
<section className="admin-panel-section">
|
||||
|
||||
+485
@@ -267,6 +267,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--brand-warm-white);
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 500;
|
||||
@@ -965,6 +966,490 @@
|
||||
font-size: 0.97rem;
|
||||
}
|
||||
|
||||
.home-jump-card--featured {
|
||||
border-color: rgba(201, 168, 76, 0.38);
|
||||
background: linear-gradient(180deg, rgba(201, 168, 76, 0.1), rgba(12, 12, 11, 0.96));
|
||||
}
|
||||
|
||||
.home-jump-card--featured:hover {
|
||||
border-color: rgba(201, 168, 76, 0.55);
|
||||
background: linear-gradient(180deg, rgba(201, 168, 76, 0.16), rgba(14, 14, 12, 0.98));
|
||||
}
|
||||
|
||||
.home-jump-featured-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.home-jump-new-tag {
|
||||
display: inline-block;
|
||||
padding: 0.12rem 0.48rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(201, 168, 76, 0.7);
|
||||
background: rgba(201, 168, 76, 0.22);
|
||||
color: #f4e3b0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ── Colossians Study ── */
|
||||
.study-index-page,
|
||||
.study-section-page {
|
||||
background:
|
||||
radial-gradient(1200px 500px at 90% -10%, rgba(201, 168, 76, 0.12), transparent 55%),
|
||||
radial-gradient(900px 450px at 0% 0%, rgba(255, 255, 255, 0.05), transparent 55%),
|
||||
#090909;
|
||||
}
|
||||
|
||||
.section-study-course-hero {
|
||||
padding: 4.5rem 0 1.5rem;
|
||||
}
|
||||
|
||||
.study-course-hero-inner {
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.study-course-hero-copy {
|
||||
max-width: 66ch;
|
||||
margin: 0.85rem 0 1.5rem;
|
||||
color: var(--brand-muted);
|
||||
}
|
||||
|
||||
.study-course-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.study-course-meta span {
|
||||
padding: 0.4rem 0.65rem;
|
||||
border: 1px solid rgba(201, 168, 76, 0.28);
|
||||
border-radius: 999px;
|
||||
color: var(--brand-warm-white);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.study-course-hero-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-study-hub-flow {
|
||||
padding: 0.5rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.study-hub-flow-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.study-hub-flow-card {
|
||||
padding: 1rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(201, 168, 76, 0.16);
|
||||
background: rgba(16, 16, 14, 0.86);
|
||||
}
|
||||
|
||||
.study-hub-flow-card h3 {
|
||||
margin: 0.25rem 0 0.4rem;
|
||||
font-family: var(--brand-font-heading);
|
||||
color: var(--brand-warm-white);
|
||||
}
|
||||
|
||||
.study-hub-flow-card p:last-child {
|
||||
margin: 0;
|
||||
color: var(--brand-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.section-study-syllabus {
|
||||
padding: 0.5rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.study-syllabus-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.5rem;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.study-syllabus-header h2 {
|
||||
margin: 0;
|
||||
font-family: var(--brand-font-heading);
|
||||
color: var(--brand-warm-white);
|
||||
}
|
||||
|
||||
.study-syllabus-header p {
|
||||
margin: 0;
|
||||
color: var(--brand-muted);
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.section-study-module {
|
||||
padding: 1rem 0 0;
|
||||
}
|
||||
|
||||
.study-module-header {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.study-module-header p {
|
||||
margin: 0;
|
||||
color: var(--brand-gold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.study-module-header h2 {
|
||||
margin: 0.35rem 0 0;
|
||||
font-family: var(--brand-font-heading);
|
||||
font-size: clamp(1.32rem, 2.1vw, 1.85rem);
|
||||
color: var(--brand-warm-white);
|
||||
}
|
||||
|
||||
.study-module-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.study-module-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 0.85fr;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.05rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(201, 168, 76, 0.14);
|
||||
background: rgba(17, 17, 15, 0.84);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.study-module-row:hover {
|
||||
border-color: rgba(201, 168, 76, 0.35);
|
||||
background: rgba(28, 27, 23, 0.92);
|
||||
}
|
||||
|
||||
.study-module-row--disabled,
|
||||
.study-module-row--disabled:hover {
|
||||
border-style: dashed;
|
||||
border-color: rgba(201, 168, 76, 0.22);
|
||||
background: rgba(13, 13, 11, 0.78);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.study-module-row-left h3 {
|
||||
margin: 0.25rem 0 0.4rem;
|
||||
font-family: var(--brand-font-heading);
|
||||
color: var(--brand-warm-white);
|
||||
}
|
||||
|
||||
.study-module-lesson {
|
||||
margin: 0;
|
||||
color: var(--brand-gold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.11em;
|
||||
font-size: 0.73rem;
|
||||
}
|
||||
|
||||
.study-module-row-left p:last-child,
|
||||
.study-module-row-right p:last-child {
|
||||
margin: 0;
|
||||
color: var(--brand-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.study-module-row-right {
|
||||
border-left: 1px solid rgba(201, 168, 76, 0.18);
|
||||
padding-left: 0.9rem;
|
||||
}
|
||||
|
||||
.study-section-reference {
|
||||
margin: 0 0 0.2rem;
|
||||
color: var(--brand-gold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.study-module-focus {
|
||||
margin: 0 0 0.2rem;
|
||||
color: var(--brand-warm-white);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.section-study-classroom {
|
||||
padding: 3.75rem 0 1.75rem;
|
||||
}
|
||||
|
||||
.study-classroom-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(260px, 0.85fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.study-detail-back {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.9rem;
|
||||
color: var(--brand-gold);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.study-lesson-label {
|
||||
margin: 0;
|
||||
color: var(--brand-gold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.73rem;
|
||||
}
|
||||
|
||||
.study-classroom-main h1 {
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
|
||||
.study-detail-reference {
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--brand-gold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.study-detail-summary {
|
||||
max-width: 70ch;
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--brand-muted);
|
||||
}
|
||||
|
||||
.study-class-block {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(201, 168, 76, 0.14);
|
||||
background: rgba(18, 18, 16, 0.86);
|
||||
}
|
||||
|
||||
.study-class-block h2 {
|
||||
margin: 0 0 0.7rem;
|
||||
font-family: var(--brand-font-heading);
|
||||
color: var(--brand-warm-white);
|
||||
}
|
||||
|
||||
.study-audio-embed-wrap {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||
}
|
||||
|
||||
.study-detail-copy--scripture {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.study-detail-copy,
|
||||
.study-detail-list {
|
||||
margin: 0;
|
||||
color: var(--brand-muted);
|
||||
}
|
||||
|
||||
.study-detail-list {
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.study-detail-list li + li {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.study-classroom-sidebar {
|
||||
position: sticky;
|
||||
top: 5.75rem;
|
||||
}
|
||||
|
||||
.study-class-side-block {
|
||||
margin-bottom: 0.85rem;
|
||||
padding: 1rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(201, 168, 76, 0.14);
|
||||
background: rgba(18, 18, 16, 0.86);
|
||||
}
|
||||
|
||||
.study-class-side-block h3 {
|
||||
margin: 0 0 0.7rem;
|
||||
font-family: var(--brand-font-heading);
|
||||
color: var(--brand-warm-white);
|
||||
}
|
||||
|
||||
.study-class-side-block p {
|
||||
margin: 0;
|
||||
color: var(--brand-muted);
|
||||
}
|
||||
|
||||
.study-class-side-block p + p {
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.study-auth-box,
|
||||
.study-notes-box {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.study-auth-box label {
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-gold);
|
||||
}
|
||||
|
||||
.study-auth-box input,
|
||||
.study-notes-box textarea {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(201, 168, 76, 0.26);
|
||||
background: rgba(10, 10, 9, 0.9);
|
||||
color: var(--brand-warm-white);
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.5;
|
||||
padding: 0.6rem 0.65rem;
|
||||
}
|
||||
|
||||
.study-auth-box input:focus,
|
||||
.study-notes-box textarea:focus {
|
||||
outline: 1px solid rgba(201, 168, 76, 0.65);
|
||||
border-color: rgba(201, 168, 76, 0.5);
|
||||
}
|
||||
|
||||
.study-auth-actions {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.study-auth-actions .btn-primary,
|
||||
.study-auth-actions .btn-secondary {
|
||||
min-width: 126px;
|
||||
}
|
||||
|
||||
.study-auth-actions .btn-secondary {
|
||||
background: rgba(15, 15, 13, 0.86);
|
||||
}
|
||||
|
||||
.study-notes-user {
|
||||
color: var(--brand-gold) !important;
|
||||
font-size: 0.86rem;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.study-note-status {
|
||||
margin-top: 0.65rem;
|
||||
color: var(--brand-muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.study-detail-nav {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.study-detail-nav .btn-secondary {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.study-nav-placeholder {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.section-study-homework {
|
||||
padding: 0.2rem 0 4rem;
|
||||
}
|
||||
|
||||
.study-homework-card {
|
||||
padding: 1rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||
background: linear-gradient(180deg, rgba(201, 168, 76, 0.12), rgba(20, 20, 18, 0.92));
|
||||
}
|
||||
|
||||
.study-homework-card h2 {
|
||||
margin: 0 0 0.65rem;
|
||||
font-family: var(--brand-font-heading);
|
||||
color: var(--brand-warm-white);
|
||||
}
|
||||
|
||||
.study-homework-card p {
|
||||
margin: 0;
|
||||
color: var(--brand-muted);
|
||||
}
|
||||
|
||||
.study-homework-note {
|
||||
margin-top: 0.6rem !important;
|
||||
font-size: 0.92rem;
|
||||
color: var(--brand-gold) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.study-classroom-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.study-classroom-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.study-hub-flow-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.study-module-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.study-module-row-right {
|
||||
border-left: none;
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.18);
|
||||
padding-left: 0;
|
||||
padding-top: 0.7rem;
|
||||
}
|
||||
|
||||
.study-detail-nav {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.study-auth-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.study-auth-actions .btn-primary,
|
||||
.study-auth-actions .btn-secondary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Episode Detail Page ── */
|
||||
.section-episode-detail {
|
||||
background: var(--brand-black);
|
||||
|
||||
+27
-1
@@ -3,8 +3,9 @@ import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } fro
|
||||
import AdminPage from './AdminPage'
|
||||
import QASection from './components/QASection'
|
||||
import ContactForm from './components/ContactForm'
|
||||
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage } from './colossiansStudy'
|
||||
import { SpotifyIcon } from './icons'
|
||||
import type { SiteContent, ArchivedSeries } from './content'
|
||||
import type { SiteContent, ArchivedSeries, StudyProgram } from './content'
|
||||
import { DEFAULTS } from './content'
|
||||
import './App.css'
|
||||
|
||||
@@ -55,6 +56,14 @@ function isLikelySpotifyEpisodeUrl(url: string | undefined): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function getHomepageFeaturedStudy(content: SiteContent): StudyProgram | null {
|
||||
const studies = Array.isArray(content.studies) ? content.studies : []
|
||||
if (studies.length === 0) return null
|
||||
return studies.find(study => study.showOnHomepage === true)
|
||||
?? studies.find(study => study.status === 'active')
|
||||
?? studies[0]
|
||||
}
|
||||
|
||||
function HeadlinerWidget() {
|
||||
const [iframeSrc, setIframeSrc] = useState('')
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'empty'>('loading')
|
||||
@@ -457,6 +466,7 @@ function SiteHeader({ content }: { content: SiteContent }) {
|
||||
<nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}>
|
||||
<NavLink to="/episodes" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Episodes</NavLink>
|
||||
<NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&A</NavLink>
|
||||
<NavLink to="/study" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Studies</NavLink>
|
||||
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Downloads</NavLink>
|
||||
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
|
||||
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact</NavLink>
|
||||
@@ -939,6 +949,8 @@ function CustomBlocksSection({ content, page }: { content: SiteContent; page: st
|
||||
}
|
||||
|
||||
function LandingPage({ content }: { content: SiteContent }) {
|
||||
const featuredStudy = getHomepageFeaturedStudy(content)
|
||||
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
@@ -1024,6 +1036,16 @@ function LandingPage({ content }: { content: SiteContent }) {
|
||||
<p>{card.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
{featuredStudy && (
|
||||
<Link to={`/study/${featuredStudy.slug || ''}`} className="home-jump-card home-jump-card--featured">
|
||||
<div className="home-jump-featured-head">
|
||||
<p className="eyebrow">{featuredStudy.homepageEyebrow || 'Study'}</p>
|
||||
{featuredStudy.showNewTag && <span className="home-jump-new-tag">{featuredStudy.newTagLabel || 'NEW'}</span>}
|
||||
</div>
|
||||
<h3>{featuredStudy.title}</h3>
|
||||
<p>{featuredStudy.description || 'Explore this study track with lesson notes, commentary, and guided questions.'}</p>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1908,6 +1930,10 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage content={content} />} />
|
||||
<Route path="/start-here" element={<StartHerePage content={content} />} />
|
||||
<Route path="/study" element={<StudyLandingPage content={content} />} />
|
||||
<Route path="/study/:studySlug" element={<ColossiansStudyIndexPage content={content} />} />
|
||||
<Route path="/study/:studySlug/notes" element={<ColossiansStudyNotesPage content={content} />} />
|
||||
<Route path="/study/:studySlug/:sectionId" element={<ColossiansStudySectionPage content={content} />} />
|
||||
<Route path="/episodes" element={<EpisodesPage content={content} />} />
|
||||
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
|
||||
<Route path="/resources" element={<ResourcesPage content={content} />} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export type ColossiansStudySection = {
|
||||
id: string
|
||||
chapter: number
|
||||
reference: string
|
||||
title: string
|
||||
audioEmbedUrl?: string
|
||||
passageText: string
|
||||
summary: string
|
||||
commentary: string
|
||||
greekNotes: string[]
|
||||
studyQuestions: string[]
|
||||
releasedAt?: string
|
||||
}
|
||||
|
||||
function section(
|
||||
id: string,
|
||||
chapter: number,
|
||||
reference: string,
|
||||
title: string,
|
||||
audioEmbedUrl: string,
|
||||
passageText: string,
|
||||
summary: string,
|
||||
commentary: string,
|
||||
studyQuestions: string[],
|
||||
greekNotes: string[] = [],
|
||||
releasedAt: string | undefined = undefined,
|
||||
): ColossiansStudySection {
|
||||
return { id, chapter, reference, title, audioEmbedUrl, passageText, summary, commentary, studyQuestions, greekNotes, ...(releasedAt ? { releasedAt } : {}) }
|
||||
}
|
||||
|
||||
export const DEFAULT_COLOSSIANS_STUDY_SECTIONS: ColossiansStudySection[] = [
|
||||
section(
|
||||
'1-1-2',
|
||||
1,
|
||||
'1:1-2',
|
||||
'Paul\'s Greeting',
|
||||
'',
|
||||
'v. 1 Paul, an apostle of Christ Jesus by the will of God, and Timothy our brother,\n\n'
|
||||
+ 'v. 2 To the saints in Colossae, the faithful brothers in Christ: Grace and peace to you from God our Father.',
|
||||
"Paul opens with apostolic authority and warm fellowship, greeting the saints at Colossae with the twin gifts that frame every believer's life: grace and peace.",
|
||||
"Paul opens with his credentials: an apostle of Christ Jesus. But the manner of the claim matters - he immediately attributes this not to his own merit but to the will of God. This is the same humility that runs beneath the letter's entire argument: everything comes from God, not from human achievement. Timothy is included not as a co-author but as a fellow laborer, identified simply as 'our brother' - the relational warmth is immediate. The recipients are addressed as 'saints' - the Greek hagios means holy ones, set apart by God - and as 'faithful brothers in Christ.' These are not terms of flattery but of theological definition: these are people who belong to God. Paul's greeting, 'grace and peace,' is more than convention. Grace (charis) is God's unmerited favor; peace (eirene) is the wholeness and well-being that flows from it. Every believer's life is framed by these two realities.",
|
||||
['Why does Paul begin with grace and peace?', 'What tone does this create for the rest of the study?'],
|
||||
[
|
||||
'Apostle (apostolos): one commissioned directly by Jesus Christ, invested with authority to speak on His behalf.',
|
||||
'Will (thelema): desire or purpose; Paul was not self-appointed but called by God\'s initiative.',
|
||||
'Brother (adelphos): a fellow believer; the same spiritual-family language Paul uses throughout his letters.',
|
||||
'Saints (hagios): God\'s holy people set apart as belonging to Him, not holy by their own merit.',
|
||||
"Grace (charis): God's unmerited favor that initiates and sustains the believer's life.",
|
||||
'Peace (eirene): wholeness and well-being that flows from reconciliation with God.',
|
||||
],
|
||||
'2026-05-04T00:00:00Z',
|
||||
),
|
||||
section('1-3-8', 1, '1:3-8', 'Thanksgiving and Report', '', '', 'Paul thanks God for faith, hope, and love, and reports that the gospel is bearing fruit.', 'The gospel is shown to be active and alive. Paul points to evidence of real spiritual growth, not merely religious activity.', ['What signs of gospel growth does Paul mention?', 'How do faith, hope, and love work together here?'], [], '2026-05-04T00:00:00Z'),
|
||||
section('1-9-14', 1, '1:9-14', 'Paul\'s Prayer', '', '', 'Paul prays for spiritual wisdom, endurance, and a life worthy of the Lord.', 'This prayer shows that knowledge and fruitfulness belong together. Paul wants the believers to know God\'s will and walk in a way that reflects it.', ['What does Paul ask God to produce?', 'How does this prayer shape the goals of the study?'], [], '2026-05-04T00:00:00Z'),
|
||||
section('1-15-20', 1, '1:15-20', 'The Supremacy of Christ', '', '', 'Christ is supreme over creation, the church, and reconciliation.', 'This is the center of Colossians. Everything else in the letter depends on who Christ is and what He has done.', ['What stands out most about Christ\'s supremacy?', 'Why is this passage central to the study?'], [], '2026-05-04T00:00:00Z'),
|
||||
section('1-21-23', 1, '1:21-23', 'Reconciliation Applied', '', '', 'Paul explains how Christ\'s work changes alienation into steadfast faith.', 'The gospel moves from doctrine to real life. Paul shows what reconciliation looks like for people who have been brought near to God.', ['How does Paul describe the believer\'s former condition?', 'What does continuing in the faith look like?'], [], '2026-05-18T00:00:00Z'),
|
||||
section('1-24-29', 1, '1:24-29', 'Paul\'s Ministry and the Revealed Mystery', '', '', 'Paul describes his labor to proclaim Christ and present believers mature in Him.', 'Paul\'s ministry is costly and purposeful. He labors so the church will grow into maturity, not just receive information.', ['What is the goal of Paul\'s ministry?', 'Why is maturity such an important theme here?'], [], '2026-05-18T00:00:00Z'),
|
||||
|
||||
section('2-1-5', 2, '2:1-5', 'Paul\'s Pastoral Concern', '', '', 'Paul reveals his struggle for the believers and his desire for encouragement and unity.', 'This passage shows the heart behind the letter. Paul is deeply invested in the spiritual stability of the church.', ['What does Paul want the believers to experience?', 'Why does he emphasize encouragement and unity?'], [], '2026-06-01T00:00:00Z'),
|
||||
section('2-6-8', 2, '2:6-8', 'Walk in Christ', '', '', 'Paul calls believers to continue in Christ and resist deceptive teaching.', 'The Christian life begins in Christ and must continue in Christ. Paul warns that subtle deception can slowly pull believers off course.', ['What does it mean to continue in Christ?', 'What kinds of deception should believers watch for?'], [], '2026-06-01T00:00:00Z'),
|
||||
section('2-9-10', 2, '2:9-10', 'Supremacy and Sufficiency', '', '', 'The fullness of deity dwells in Christ, and believers are made complete in Him.', 'This section corrects the idea that anything must be added to Christ. He is fully sufficient for the believer\'s life and growth.', ['Why is fullness in Christ such an important correction?', 'How does this guard against spiritual insecurity?'], [], '2026-06-15T00:00:00Z'),
|
||||
section('2-11-15', 2, '2:11-15', 'What Christ Has Done', '', '', 'Paul explains the believer\'s union with Christ in His death, resurrection, and victory.', 'The work of Christ is presented as decisive and complete. This becomes the foundation for all the commands that follow.', ['What changes because of Christ\'s work here?', 'How does victory over the powers encourage believers today?'], [], '2026-06-15T00:00:00Z'),
|
||||
section('2-16-17', 2, '2:16-17', 'Freedom from Legalistic Judgment', '', '', 'Paul warns against letting others judge believers over shadows when Christ is the substance.', 'Outward practices can never replace Christ. Paul reminds the church that the real thing has come in Jesus.', ['What do shadows and substance mean here?', 'Where do believers still feel pressure from external judgment?'], [], '2026-06-29T00:00:00Z'),
|
||||
section('2-18-19', 2, '2:18-19', 'Warning Against False Mysticism', '', '', 'Paul warns against prideful spirituality that disconnects believers from Christ the head.', 'Anything that pulls attention away from Christ and His body is spiritually dangerous. True growth stays connected to the head.', ['How does Paul describe false spirituality?', 'Why is connection to Christ essential?'], [], '2026-06-29T00:00:00Z'),
|
||||
section('2-20-23', 2, '2:20-23', 'The Emptiness of Human Regulations', '', '', 'Human rules cannot produce true transformation.', 'External restrictions may look wise, but they cannot change the heart. Paul points the church back to union with Christ.', ['Why do human regulations fail to transform the heart?', 'What does real spiritual change require?'], [], '2026-07-13T00:00:00Z'),
|
||||
|
||||
section('3-1-4', 3, '3:1-4', 'Seek the Things Above', '', '', 'Paul calls believers to set their hearts and minds on Christ.', 'Identity in Christ leads to a new direction for the mind and heart. The believer\'s life is hidden with Christ, so priorities shift upward.', ['What does it mean to seek the things above?', 'How does hidden life in Christ shape daily priorities?'], [], '2026-07-13T00:00:00Z'),
|
||||
section('3-5-9', 3, '3:5-9', 'Put to Death the Old Life', '', '', 'Paul calls believers to put away the practices of the old life.', 'Christian growth includes real moral change. Paul\'s language is strong because the old life cannot be carried into the new one.', ['Which old patterns does Paul name?', 'Why is decisive change necessary?'], [], '2026-07-27T00:00:00Z'),
|
||||
section('3-10-11', 3, '3:10-11', 'Put On the New Self', '', '', 'Believers are renewed in Christ and formed into one new people.', 'Renewal in Christ changes both personal identity and community life. The new self is not merely private; it is shared by the body of Christ.', ['What does renewal in Christ produce?', 'How does this reshape how believers see one another?'], [], '2026-07-27T00:00:00Z'),
|
||||
section('3-12-14', 3, '3:12-14', 'Clothe Yourselves with Christlike Virtues', '', '', 'Paul lists the virtues believers should wear as Christ\'s character becomes visible in them.', 'The image of clothing makes virtue practical and visible. Paul wants Christ\'s character to define the way believers live together.', ['Which virtues are most needed in your context?', 'How does love bind these virtues together?'], [], '2026-08-10T00:00:00Z'),
|
||||
section('3-15-17', 3, '3:15-17', 'The Rule of Christ\'s Peace and Word', '', '', 'Paul calls the church to let Christ\'s peace rule and His word dwell richly among them.', 'Peace, gratitude, and the word of Christ shape a healthy Christian community. These verses connect worship, teaching, and daily life.', ['What does it look like for Christ\'s peace to rule?', 'How can the word of Christ dwell richly in a church family?'], [], '2026-08-10T00:00:00Z'),
|
||||
section('3-18-21', 3, '3:18-21', 'Household Code — Relationships', '', '', 'Paul applies the new life in Christ to family relationships.', 'The gospel reaches into ordinary relationships. Christ changes how homes are ordered and how people treat one another.', ['How do these instructions reflect Christ\'s lordship?', 'Which relationship is most challenging to apply today?'], [], '2026-08-24T00:00:00Z'),
|
||||
section('3-22-25', 3, '3:22-25', 'Household Code — Work as unto the Lord', '', '', 'Paul instructs believers to serve faithfully and wholeheartedly.', 'Work becomes worship when it is done for Christ. Paul teaches integrity, diligence, and accountability before a heavenly Master.', ['How does serving the Lord reshape everyday work?', 'What does Paul say about fairness and accountability?'], [], '2026-08-24T00:00:00Z'),
|
||||
|
||||
section('4-1', 4, '4:1', 'Masters — Fairness Before a Heavenly Master', '', '', 'Paul reminds masters to act justly because they too answer to the Lord.', 'The gospel confronts authority as well as submission. All authority is accountable to Christ.', ['Why does Paul remind masters of their heavenly Master?', 'What does fairness look like in this context?'], [], '2026-09-07T00:00:00Z'),
|
||||
section('4-2-6', 4, '4:2-6', 'Devoted to Prayer and Wise Witness', '', '', 'Paul closes with a call to steadfast prayer and wise witness toward outsiders.', 'Prayer and witness belong together. Paul shows that spiritual alertness and everyday speech are both part of faithful ministry.', ['How are prayer and witness connected here?', 'What does gracious speech look like in real life?'], [], '2026-09-07T00:00:00Z'),
|
||||
section('4-7-9', 4, '4:7-9', 'Tychicus and Onesimus', '', '', 'Paul sends trusted messengers to encourage the church and bring news from the ministry field.', 'These greetings reveal the relational side of ministry. The letter is carried by real people who serve the church with loyalty and care.', ['Why do these messengers matter to the letter?', 'What does this tell us about trusted coworkers?'], [], '2026-09-21T00:00:00Z'),
|
||||
section('4-10-14', 4, '4:10-14', 'Greetings from Paul\'s Co-workers', '', '', 'Paul names the coworkers who are with him and commends their ministry support.', 'The closing greetings show the breadth of the gospel network. Ministry is shared, supported, and strengthened by faithful co-workers.', ['What stands out about Paul\'s coworkers?', 'How does shared ministry strengthen the church?'], [], '2026-09-21T00:00:00Z'),
|
||||
section('4-15-18', 4, '4:15-18', 'Final Instructions and Closing', '', '', 'Paul offers final greetings, instructions, and a closing reminder to complete the ministry entrusted to them.', 'The letter ends with community, responsibility, and grace. Paul\'s final words keep the church focused on the mission it has received.', ['What final responsibility does Paul place on the church?', 'How does the closing reinforce the letter\'s themes?'], [], '2026-10-05T00:00:00Z'),
|
||||
]
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './colossiansStudyData'
|
||||
import type { ColossiansStudySection as ColossiansStudySectionData } from './colossiansStudyData'
|
||||
|
||||
export type ColossiansStudySection = ColossiansStudySectionData
|
||||
export type StudySection = ColossiansStudySectionData
|
||||
|
||||
export interface StudyProgram {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
homepageEyebrow?: string
|
||||
showOnHomepage?: boolean
|
||||
showNewTag?: boolean
|
||||
newTagLabel?: string
|
||||
status: 'active' | 'planned'
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||
estimatedHours: number
|
||||
completionBadge: string
|
||||
numberOfChapters: number
|
||||
sections: StudySection[]
|
||||
}
|
||||
|
||||
export interface CustomLink {
|
||||
id: string
|
||||
label: string
|
||||
@@ -145,6 +168,9 @@ export interface SiteContent {
|
||||
studyGuideDownloadUrl: string
|
||||
studyGuideUrl: string
|
||||
studyGuideAmazonButtonLabel: string
|
||||
// ── Colossians Study ──
|
||||
studies: StudyProgram[]
|
||||
colossiansStudySections: ColossiansStudySection[]
|
||||
// ── Share ──
|
||||
shareHeading: string
|
||||
shareP: string
|
||||
@@ -256,6 +282,41 @@ export const DEFAULTS: SiteContent = {
|
||||
studyGuideDownloadUrl: '',
|
||||
studyGuideUrl: 'https://a.co/d/01sG2tOJ',
|
||||
studyGuideAmazonButtonLabel: 'Get it on Amazon',
|
||||
studies: [
|
||||
{
|
||||
id: 'study-colossians',
|
||||
slug: 'colossians',
|
||||
title: 'Colossians: Rooted in Christ',
|
||||
description: 'Walk through Colossians in guided lessons with commentary, Greek notes, and discussion prompts.',
|
||||
homepageEyebrow: 'New Study',
|
||||
showOnHomepage: true,
|
||||
showNewTag: true,
|
||||
newTagLabel: 'NEW',
|
||||
status: 'active',
|
||||
difficulty: 'intermediate',
|
||||
estimatedHours: 12,
|
||||
completionBadge: 'Colossians Completion',
|
||||
numberOfChapters: 4,
|
||||
sections: DEFAULT_COLOSSIANS_STUDY_SECTIONS,
|
||||
},
|
||||
{
|
||||
id: 'study-titus',
|
||||
slug: 'titus',
|
||||
title: 'Titus: Sound Doctrine',
|
||||
description: 'A full online class version of Titus with lesson-by-lesson notes and discussion.',
|
||||
homepageEyebrow: 'Planned Study',
|
||||
showOnHomepage: false,
|
||||
showNewTag: false,
|
||||
newTagLabel: 'NEW',
|
||||
status: 'planned',
|
||||
difficulty: 'beginner',
|
||||
estimatedHours: 8,
|
||||
completionBadge: 'Titus Completion',
|
||||
numberOfChapters: 3,
|
||||
sections: [],
|
||||
},
|
||||
],
|
||||
colossiansStudySections: DEFAULT_COLOSSIANS_STUDY_SECTIONS,
|
||||
shareHeading: 'Help one more person hear the Word this week.',
|
||||
shareP: 'Scan the QR code or text the show link to a friend who needs encouragement today.',
|
||||
whereToNextEyebrow: 'Where to Next',
|
||||
|
||||
Reference in New Issue
Block a user