Replace study lesson expanding cards with master-detail drill-down; v1.1.51
Publish Container / docker (push) Successful in 13m15s

Studies section now uses a three-level navigation: study list → study detail
with compact sortable lesson rows → individual lesson editor. Eliminates the
40+ expanding AdminCollapsibleCard items for lessons, replacing them with a
compact table (drag handle, reference, title, live/draft status, edit/preview/
delete per row) and a focused lesson editor opened by clicking Edit. Prev/Next
navigation between lessons added. BsbFetchButton extracted as a standalone
component for the inline lesson editor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-03 09:23:56 -04:00
1 parent e593031d0d
commit 14665d06b5
3 files changed
+409 -195

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "1.1.50",
"version": "1.1.51",
"type": "module",
"scripts": {
"dev": "vite",
+248 -194
View File
@@ -11,150 +11,57 @@ import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
import { useAutosave } from './hooks/useAutosave'
import QRCode from 'qrcode'
interface SortableLessonSectionProps {
interface SortableLessonRowProps {
section: ColossiansStudySection
study: StudyProgram
updateStudySection: (studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | string[] | number) => void
onEdit: () => void
removeStudySection: (studyId: string, sectionId: string) => void
rssEpisodes: Array<{ title: string; audioUrl: string; duration: string; episode: string }>
}
function SortableLessonSection({ section, study, updateStudySection, removeStudySection, rssEpisodes }: SortableLessonSectionProps) {
function SortableLessonRow({ section, study, onEdit, removeStudySection }: SortableLessonRowProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id })
const [bsbFetching, setBsbFetching] = useState(false)
const [bsbMsg, setBsbMsg] = useState('')
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 }
const isLive = section.releasedAt ? new Date(section.releasedAt) <= new Date() : false
return (
<div ref={setNodeRef} style={style} className="study-lesson-row">
<span className="study-lesson-drag" {...attributes} {...listeners} title="Drag to reorder">⠿</span>
<span className="study-lesson-ref">{section.reference || '—'}</span>
<span className="study-lesson-title">{section.title || <em style={{ opacity: 0.5 }}>Untitled</em>}</span>
<span className={`study-lesson-pill ${isLive ? 'live' : 'draft'}`}>{isLive ? 'Live' : 'Draft'}</span>
<div className="study-lesson-actions">
<button type="button" className="btn-admin-ghost" onClick={onEdit}>Edit</button>
<a href={`/study/${study.slug}/${section.id}`} target="_blank" rel="noopener noreferrer" className="btn-admin-ghost" onClick={e => e.stopPropagation()}>Preview</a>
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.8rem', padding: '0.2rem 0.5rem' }} onClick={() => removeStudySection(study.id, section.id)}>✕</button>
</div>
</div>
)
}
async function fetchBsbPassage() {
function BsbFetchButton({ study, section, updateStudySection }: { study: StudyProgram; section: ColossiansStudySection; updateStudySection: (studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | string[] | number) => void }) {
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
async function fetch_() {
const bookKey = (study.bibleBook?.trim() || study.slug).toLowerCase()
const reference = section.reference?.trim()
if (!bookKey || !reference) { setBsbMsg('Set the passage reference first.'); return }
setBsbFetching(true)
setBsbMsg('')
if (!bookKey || !reference) { setMsg('Set the passage reference first.'); return }
setBusy(true); setMsg('')
try {
const params = new URLSearchParams({ book: bookKey, reference })
const res = await fetch(`/api/bible-passage?${params}`)
const res = await fetch(`/api/bible-passage?${new URLSearchParams({ book: bookKey, reference })}`)
const data = await res.json() as { text?: string; message?: string }
if (!res.ok || !data.text) { setBsbMsg(data.message ?? 'Passage not found.'); return }
if (!res.ok || !data.text) { setMsg(data.message ?? 'Passage not found.'); return }
updateStudySection(study.id, section.id, 'passageText', data.text)
setBsbMsg('Passage filled from BSB.')
} catch {
setBsbMsg('Could not reach the BSB API.')
} finally {
setBsbFetching(false)
setMsg('Passage filled from BSB.')
} catch { setMsg('Could not reach the BSB API.') }
finally { setBusy(false) }
}
}
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
background: isDragging ? '#f8f7f2' : undefined,
border: isDragging ? '2px solid #e0c070' : undefined,
marginBottom: '0.5rem',
}
return (
<details ref={setNodeRef} style={style} className="admin-collapsible-card" open={false}>
<summary className="admin-collapsible-summary" {...attributes} {...listeners} style={{ cursor: 'grab', userSelect: 'none' }}>
<div>
<strong>Lesson {section.reference || section.id}</strong>
<p>{section.title || 'Untitled section'}</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<a href={`/study/${study.slug}/${section.id}`} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary" onClick={e => e.stopPropagation()}>Preview</a>
<span className="admin-collapsible-hint">Expand to edit lesson</span>
</div>
</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}`}>Episode Audio (optional)</label>
<select id={`study-section-audio-${study.id}-${section.id}`} value={section.audioEmbedUrl ?? ''} onChange={e => updateStudySection(study.id, section.id, 'audioEmbedUrl', e.target.value)}>
<option value="">— No audio —</option>
{rssEpisodes.map(ep => (
<option key={ep.audioUrl} value={ep.audioUrl}>{ep.title}</option>
))}
{section.audioEmbedUrl && !rssEpisodes.some(ep => ep.audioUrl === section.audioEmbedUrl) && (
<option value={section.audioEmbedUrl}>{section.audioEmbedUrl}</option>
)}
</select>
</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-announcement-${study.id}-${section.id}`}>Lesson Announcement</label>
<textarea id={`study-section-announcement-${study.id}-${section.id}`} rows={3} value={section.announcement ?? ''} placeholder="A short instructor announcement for this lesson." onChange={e => updateStudySection(study.id, section.id, 'announcement', e.target.value)} />
</div>
<div className="admin-field">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '0.35rem' }}>
<label htmlFor={`study-section-passage-${study.id}-${section.id}`} style={{ margin: 0 }}>Passage Text</label>
<button type="button" className="btn-admin-secondary" disabled={bsbFetching} onClick={fetchBsbPassage} style={{ fontSize: '0.8rem', padding: '0.2rem 0.75rem' }}>
{bsbFetching ? 'Fetching…' : 'Fetch from BSB'}
<>
<button type="button" className="btn-admin-secondary" disabled={busy} onClick={fetch_} style={{ fontSize: '0.8rem', padding: '0.2rem 0.75rem' }}>
{busy ? 'Fetching…' : 'Fetch from BSB'}
</button>
{bsbMsg && <span style={{ fontSize: '0.8rem', color: bsbMsg.includes('filled') ? '#7abf7a' : '#c97070' }}>{bsbMsg}</span>}
</div>
<textarea id={`study-section-passage-${study.id}-${section.id}`} rows={4} value={section.passageText} placeholder="Paste the passage text here, or use Fetch from BSB." 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={10} value={section.commentary} placeholder="Add your teaching notes and explanation here. Press Enter twice to start a new paragraph." 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-focus-${study.id}-${section.id}`}>Focus Question</label>
<input id={`study-section-focus-${study.id}-${section.id}`} type="text" value={section.focusQuestion ?? ''} placeholder="Shown on the lesson card (defaults to first study question)" onChange={e => updateStudySection(study.id, section.id, 'focusQuestion', e.target.value)} />
</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 className="admin-field">
<label htmlFor={`study-section-checkpoint-prompt-${study.id}-${section.id}`}>Checkpoint Prompt</label>
<input id={`study-section-checkpoint-prompt-${study.id}-${section.id}`} type="text" value={section.checkpointPrompt ?? ''} placeholder="A short prompt for the checkpoint" onChange={e => updateStudySection(study.id, section.id, 'checkpointPrompt', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-checkpoint-questions-${study.id}-${section.id}`}>Checkpoint Questions</label>
<textarea id={`study-section-checkpoint-questions-${study.id}-${section.id}`} rows={5} value={(section.checkpointQuestions ?? []).join('\n')} placeholder="One checkpoint question per line" onChange={e => updateStudySection(study.id, section.id, 'checkpointQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
<p className="admin-field-help">Optional reflection questions that learners answer before completing the lesson.</p>
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeStudySection(study.id, section.id)}>Remove Lesson</button>
</div>
</div>
</details>
{msg && <span style={{ fontSize: '0.8rem', color: msg.includes('filled') ? '#7abf7a' : '#c97070' }}>{msg}</span>}
</>
)
}
@@ -1202,6 +1109,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [qrEditDest, setQrEditDest] = useState('')
const [rssEpisodes, setRssEpisodes] = useState<Array<{ title: string; audioUrl: string; duration: string; episode: string }>>([])
const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null)
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null)
const [dashboardNow, setDashboardNow] = useState(() => new Date())
const [manualQuestion, setManualQuestion] = useState({
@@ -4012,75 +3921,122 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)}
{/* STUDIES */}
{adminView === 'colossians-study' && (
{adminView === 'colossians-study' && (() => {
const activeStudy = selectedStudyId ? (form.studies ?? []).find(s => s.id === selectedStudyId) ?? null : null
const activeLesson = activeStudy && selectedLessonId ? (activeStudy.sections ?? []).find(s => s.id === selectedLessonId) ?? null : null
const activeLessonIndex = activeStudy && activeLesson ? activeStudy.sections.findIndex(s => s.id === selectedLessonId) : -1
return (
<section className="admin-panel-section">
{/* ── LEVEL 0: Study list ── */}
{!activeStudy && (
<>
<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>
<p>Select a study to manage its lessons and settings.</p>
</div>
{(form.studies ?? []).length === 0 && <p className="admin-stats-note">No studies yet.</p>}
<div className="study-program-list">
{(form.studies ?? []).map(study => (
<button key={study.id} type="button" className="study-program-row" onClick={() => setSelectedStudyId(study.id)}>
<div className="study-program-info">
<strong>{study.title || 'Untitled Study'}</strong>
<span>/study/{study.slug}</span>
</div>
<div className="study-program-meta">
<span>{(study.sections ?? []).length} lessons</span>
<span className={`study-status-pill ${study.status}`}>{study.status}</span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</div>
</button>
))}
</div>
<button type="button" className="btn-admin-add" onClick={addStudyProgram}>+ Add Study Program</button>
{renderSaveStatus()}
</>
)}
{/* ── LEVEL 1: Study detail + compact lesson list ── */}
{activeStudy && !activeLesson && (
<>
<div className="study-drill-header">
<button type="button" className="study-back-btn" onClick={() => { setSelectedStudyId(null); setSelectedLessonId(null) }}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M10 12L6 8l4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
Studies
</button>
<h2>{activeStudy.title || 'Untitled Study'}</h2>
</div>
{(form.studies ?? []).length === 0 && <p className="admin-stats-note">No studies yet.</p>}
{(form.studies ?? []).map(study => (
<AdminCollapsibleCard
key={study.id}
title={`${study.title || 'Untitled study'} · /study/${study.slug || 'slug'}`}
subtitle={study.description || 'Study description'}
open={false}
>
<div className="admin-array-row" style={{ marginBottom: '1rem' }}>
<details className="admin-collapsible-card" style={{ marginBottom: '1.5rem' }}>
<summary className="admin-collapsible-summary">
<div><strong>Study Settings</strong><p>Title, slug, description, and homepage display</p></div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<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)} />
<label htmlFor={`study-title-${activeStudy.id}`}>Study Title</label>
<input id={`study-title-${activeStudy.id}`} type="text" value={activeStudy.title} placeholder="Colossians: Rooted in Christ" onChange={e => updateStudyProgram(activeStudy.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, '-'))} />
<label htmlFor={`study-slug-${activeStudy.id}`}>Slug</label>
<input id={`study-slug-${activeStudy.id}`} type="text" value={activeStudy.slug} placeholder="colossians" onChange={e => updateStudyProgram(activeStudy.id, 'slug', e.target.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'))} />
</div>
<div className="admin-field">
<label htmlFor={`study-bible-book-${study.id}`}>Bible Book (for passage lookup)</label>
<input id={`study-bible-book-${study.id}`} type="text" value={study.bibleBook ?? ''} placeholder="colossians (leave blank to use slug)" onChange={e => updateStudyProgram(study.id, 'bibleBook', e.target.value.trim().toLowerCase().replace(/\s+/g, ''))} />
<p className="admin-stats-note" style={{ marginTop: '0.25rem' }}>Lowercase book name used to fetch BSB passage text. E.g. colossians, ephesians, 1corinthians. Leave blank if the slug matches the book name.</p>
<label htmlFor={`study-bible-book-${activeStudy.id}`}>Bible Book (for passage lookup)</label>
<input id={`study-bible-book-${activeStudy.id}`} type="text" value={activeStudy.bibleBook ?? ''} placeholder="colossians" onChange={e => updateStudyProgram(activeStudy.id, 'bibleBook', e.target.value.trim().toLowerCase().replace(/\s+/g, ''))} />
<p className="admin-field-help">Lowercase book name for BSB fetch. E.g. colossians, ephesians, 1corinthians. Leave blank if slug matches.</p>
</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)}>
<label htmlFor={`study-status-${activeStudy.id}`}>Status</label>
<select id={`study-status-${activeStudy.id}`} value={activeStudy.status} onChange={e => updateStudyProgram(activeStudy.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)} />
<label htmlFor={`study-description-${activeStudy.id}`}>Description</label>
<textarea id={`study-description-${activeStudy.id}`} rows={3} value={activeStudy.description} placeholder="Brief description for the study hub card" onChange={e => updateStudyProgram(activeStudy.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)} />
<label htmlFor={`study-home-eyebrow-${activeStudy.id}`}>Homepage Card Eyebrow</label>
<input id={`study-home-eyebrow-${activeStudy.id}`} type="text" value={activeStudy.homepageEyebrow ?? ''} placeholder="New Study" onChange={e => updateStudyProgram(activeStudy.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)} />
<label htmlFor={`study-new-tag-label-${activeStudy.id}`}>NEW Tag Label</label>
<input id={`study-new-tag-label-${activeStudy.id}`} type="text" value={activeStudy.newTagLabel ?? 'NEW'} placeholder="NEW" onChange={e => updateStudyProgram(activeStudy.id, 'newTagLabel', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-chapters-${activeStudy.id}`}>Number of Chapters</label>
<input id={`study-chapters-${activeStudy.id}`} type="number" min="1" max="999" value={activeStudy.numberOfChapters} placeholder="4" onChange={e => updateStudyProgram(activeStudy.id, 'numberOfChapters', Number(e.target.value) || 1)} />
</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>
<input id={`study-home-feature-${activeStudy.id}`} type="checkbox" checked={activeStudy.showOnHomepage === true} onChange={e => updateStudyProgram(activeStudy.id, 'showOnHomepage', e.target.checked)} />
<label htmlFor={`study-home-feature-${activeStudy.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>
<input id={`study-show-new-tag-${activeStudy.id}`} type="checkbox" checked={activeStudy.showNewTag === true} onChange={e => updateStudyProgram(activeStudy.id, 'showNewTag', e.target.checked)} />
<label htmlFor={`study-show-new-tag-${activeStudy.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)} />
<button type="button" className="btn-admin-remove" style={{ marginTop: '1rem' }} onClick={() => { removeStudyProgram(activeStudy.id); setSelectedStudyId(null) }}>Remove Study</button>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeStudyProgram(study.id)}>Remove Study</button>
</details>
<div className="admin-panel-subhead" style={{ marginBottom: '0.75rem' }}>
<h3>Lessons ({(activeStudy.sections ?? []).length})</h3>
</div>
{(study.sections ?? []).length === 0 && (
<p className="admin-stats-note">No sections yet for this study.</p>
)}
{(activeStudy.sections ?? []).length === 0 && <p className="admin-stats-note">No lessons yet. Add one below.</p>}
<div className="study-lesson-list-header">
<span style={{ width: 28 }} />
<span style={{ width: 80 }}>Reference</span>
<span style={{ flex: 1 }}>Title</span>
<span style={{ width: 60 }}>Status</span>
<span style={{ width: 160 }} />
</div>
<DndContext
sensors={dragSensors}
@@ -4088,46 +4044,144 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
onDragEnd={({ active, over }) => {
const overId = over?.id
if (!overId || active.id === overId) return
setForm(f => {
const studies = f.studies.map(s => {
if (s.id !== study.id) return s
const oldIndex = s.sections.findIndex(sec => sec.id === active.id)
const newIndex = s.sections.findIndex(sec => sec.id === overId)
if (oldIndex === -1 || newIndex === -1) return s
const newSections = arrayMove(s.sections, oldIndex, newIndex)
return { ...s, sections: newSections }
})
return { ...f, studies }
})
setForm(f => ({
...f,
studies: (f.studies ?? []).map(s => {
if (s.id !== activeStudy.id) return s
const oldIdx = s.sections.findIndex(sec => sec.id === active.id)
const newIdx = s.sections.findIndex(sec => sec.id === overId)
if (oldIdx === -1 || newIdx === -1) return s
return { ...s, sections: arrayMove(s.sections, oldIdx, newIdx) }
}),
}))
}}
>
<SortableContext
items={(study.sections ?? []).map(section => section.id)}
strategy={verticalListSortingStrategy}
>
{(study.sections ?? []).map(section => (
<SortableLessonSection
<SortableContext items={(activeStudy.sections ?? []).map(s => s.id)} strategy={verticalListSortingStrategy}>
{(activeStudy.sections ?? []).map(section => (
<SortableLessonRow
key={section.id}
section={section}
study={study}
updateStudySection={updateStudySection}
study={activeStudy}
onEdit={() => setSelectedLessonId(section.id)}
removeStudySection={removeStudySection}
rssEpisodes={rssEpisodes}
/>
))}
</SortableContext>
</DndContext>
<button type="button" className="btn-admin-add" onClick={() => addStudySection(study.id)}>+ Add Lesson Section</button>
</AdminCollapsibleCard>
))}
<button type="button" className="btn-admin-add" onClick={addStudyProgram}>+ Add Study Program</button>
<button type="button" className="btn-admin-add" style={{ marginTop: '0.75rem' }} onClick={() => addStudySection(activeStudy.id)}>+ Add Lesson</button>
{renderSaveStatus()}
</section>
</>
)}
{/* ── LEVEL 2: Lesson editor ── */}
{activeStudy && activeLesson && (
<>
<div className="study-drill-header">
<button type="button" className="study-back-btn" onClick={() => setSelectedLessonId(null)}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M10 12L6 8l4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
{activeStudy.title || 'Study'}
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{activeLessonIndex > 0 && (
<button type="button" className="btn-admin-ghost" onClick={() => setSelectedLessonId(activeStudy.sections[activeLessonIndex - 1].id)}>‹ Prev</button>
)}
{activeLessonIndex < activeStudy.sections.length - 1 && (
<button type="button" className="btn-admin-ghost" onClick={() => setSelectedLessonId(activeStudy.sections[activeLessonIndex + 1].id)}>Next ›</button>
)}
<a href={`/study/${activeStudy.slug}/${activeLesson.id}`} target="_blank" rel="noopener noreferrer" className="btn-admin-ghost">Preview ↗</a>
</div>
</div>
<div className="admin-panel-subhead" style={{ marginBottom: '1rem' }}>
<h3>{activeLesson.title || 'Untitled Lesson'}{activeLesson.reference ? ` · ${activeLesson.reference}` : ''}</h3>
</div>
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`sl-chapter-${activeLesson.id}`}>Chapter</label>
<select id={`sl-chapter-${activeLesson.id}`} value={activeLesson.chapter} onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'chapter', Number(e.target.value) || 1)}>
{Array.from({ length: Math.max(activeStudy.numberOfChapters, activeLesson.chapter, 1) }, (_, i) => i + 1).map(n => (
<option key={n} value={n}>Chapter {n}</option>
))}
</select>
</div>
<div className="admin-field">
<label htmlFor={`sl-reference-${activeLesson.id}`}>Passage Reference</label>
<input id={`sl-reference-${activeLesson.id}`} type="text" value={activeLesson.reference} placeholder="1:1-2" onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'reference', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`sl-title-${activeLesson.id}`}>Section Title</label>
<input id={`sl-title-${activeLesson.id}`} type="text" value={activeLesson.title} placeholder="Paul's Greeting" onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`sl-audio-${activeLesson.id}`}>Episode Audio (optional)</label>
<select id={`sl-audio-${activeLesson.id}`} value={activeLesson.audioEmbedUrl ?? ''} onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'audioEmbedUrl', e.target.value)}>
<option value="">— No audio —</option>
{rssEpisodes.map(ep => <option key={ep.audioUrl} value={ep.audioUrl}>{ep.title}</option>)}
{activeLesson.audioEmbedUrl && !rssEpisodes.some(ep => ep.audioUrl === activeLesson.audioEmbedUrl) && (
<option value={activeLesson.audioEmbedUrl}>{activeLesson.audioEmbedUrl}</option>
)}
</select>
</div>
<div className="admin-field">
<label htmlFor={`sl-released-${activeLesson.id}`}>Release Date (optional)</label>
<input id={`sl-released-${activeLesson.id}`} type="datetime-local" value={activeLesson.releasedAt ? new Date(activeLesson.releasedAt).toISOString().slice(0, 16) : ''} onChange={e => {
const val = e.target.value
updateStudySection(activeStudy.id, activeLesson.id, 'releasedAt', val ? new Date(val + ':00Z').toISOString() : '')
}} />
</div>
<div className="admin-field">
<label htmlFor={`sl-summary-${activeLesson.id}`}>Short Summary</label>
<textarea id={`sl-summary-${activeLesson.id}`} rows={3} value={activeLesson.summary} placeholder="One to two sentences that summarize the section." onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'summary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`sl-announcement-${activeLesson.id}`}>Lesson Announcement</label>
<textarea id={`sl-announcement-${activeLesson.id}`} rows={3} value={activeLesson.announcement ?? ''} placeholder="A short instructor announcement for this lesson." onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'announcement', e.target.value)} />
</div>
<div className="admin-field">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '0.35rem' }}>
<label htmlFor={`sl-passage-${activeLesson.id}`} style={{ margin: 0 }}>Passage Text</label>
<BsbFetchButton study={activeStudy} section={activeLesson} updateStudySection={updateStudySection} />
</div>
<textarea id={`sl-passage-${activeLesson.id}`} rows={4} value={activeLesson.passageText} placeholder="Paste the passage text here, or use Fetch from BSB." onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'passageText', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`sl-commentary-${activeLesson.id}`}>Commentary</label>
<textarea id={`sl-commentary-${activeLesson.id}`} rows={10} value={activeLesson.commentary} placeholder="Add your teaching notes and explanation here." onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'commentary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`sl-greek-${activeLesson.id}`}>Greek Words</label>
<textarea id={`sl-greek-${activeLesson.id}`} rows={4} value={(activeLesson.greekNotes ?? []).join('\n')} placeholder="One note per line" onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'greekNotes', e.target.value.split('\n').map(l => l.trim()).filter(Boolean))} />
</div>
<div className="admin-field">
<label htmlFor={`sl-focus-${activeLesson.id}`}>Focus Question</label>
<input id={`sl-focus-${activeLesson.id}`} type="text" value={activeLesson.focusQuestion ?? ''} placeholder="Shown on the lesson card" onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'focusQuestion', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`sl-questions-${activeLesson.id}`}>Study Questions</label>
<textarea id={`sl-questions-${activeLesson.id}`} rows={5} value={(activeLesson.studyQuestions ?? []).join('\n')} placeholder="One question per line" onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'studyQuestions', e.target.value.split('\n').map(l => l.trim()).filter(Boolean))} />
</div>
<div className="admin-field">
<label htmlFor={`sl-checkpoint-prompt-${activeLesson.id}`}>Checkpoint Prompt</label>
<input id={`sl-checkpoint-prompt-${activeLesson.id}`} type="text" value={activeLesson.checkpointPrompt ?? ''} placeholder="A short prompt for the checkpoint" onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'checkpointPrompt', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`sl-checkpoint-questions-${activeLesson.id}`}>Checkpoint Questions</label>
<textarea id={`sl-checkpoint-questions-${activeLesson.id}`} rows={5} value={(activeLesson.checkpointQuestions ?? []).join('\n')} placeholder="One checkpoint question per line" onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'checkpointQuestions', e.target.value.split('\n').map(l => l.trim()).filter(Boolean))} />
<p className="admin-field-help">Optional reflection questions that learners answer before completing the lesson.</p>
</div>
</div>
<button type="button" className="btn-admin-remove" style={{ marginTop: '1rem' }} onClick={() => { removeStudySection(activeStudy.id, activeLesson.id); setSelectedLessonId(null) }}>Remove Lesson</button>
{renderSaveStatus()}
</>
)}
</section>
)
})()}
{/* GLOBAL / FOOTER & PLATFORM */}
{adminView === 'global' && (
<section className="admin-panel-section">
+160
View File
@@ -6122,6 +6122,166 @@
border-color: rgba(224, 92, 92, 0.4);
}
/* ── Studies master-detail ── */
.study-program-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-bottom: 1rem;
}
.study-program-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.875rem 1rem;
background: var(--admin-card-bg);
border: 1px solid var(--admin-card-border);
border-radius: 8px;
cursor: pointer;
text-align: left;
width: 100%;
color: var(--admin-text);
font-family: var(--brand-font-body);
transition: border-color 0.15s, background 0.15s;
}
.study-program-row:hover {
border-color: rgba(201, 168, 76, 0.5);
background: rgba(201, 168, 76, 0.04);
}
.study-program-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.study-program-info strong { font-size: 0.95rem; color: var(--admin-heading); }
.study-program-info span { font-size: 0.78rem; color: var(--admin-muted); }
.study-program-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.8rem;
color: var(--admin-muted);
flex-shrink: 0;
}
.study-status-pill {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.55rem;
border-radius: 100px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.study-status-pill.active { background: rgba(122, 191, 122, 0.15); color: #7abf7a; }
.study-status-pill.planned { background: rgba(201, 168, 76, 0.15); color: var(--brand-gold); }
.study-drill-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.study-drill-header h2 { margin: 0; font-size: 1.15rem; }
.study-back-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: transparent;
border: none;
color: var(--brand-gold);
font-family: var(--brand-font-body);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
padding: 0.3rem 0;
letter-spacing: 0.03em;
}
.study-back-btn:hover { opacity: 0.75; }
.study-lesson-list-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.75rem 0.4rem;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--admin-muted);
border-bottom: 1px solid var(--admin-card-border);
margin-bottom: 0.25rem;
}
.study-lesson-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--admin-card-border);
transition: background 0.12s;
}
.study-lesson-row:hover { background: rgba(201, 168, 76, 0.04); }
.study-lesson-drag {
cursor: grab;
color: var(--admin-muted);
font-size: 1rem;
padding: 0 0.1rem;
width: 20px;
text-align: center;
flex-shrink: 0;
user-select: none;
}
.study-lesson-drag:active { cursor: grabbing; }
.study-lesson-ref {
width: 80px;
flex-shrink: 0;
font-size: 0.82rem;
color: var(--admin-muted);
font-variant-numeric: tabular-nums;
}
.study-lesson-title {
flex: 1;
font-size: 0.88rem;
color: var(--admin-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.study-lesson-pill {
display: inline-flex;
align-items: center;
padding: 0.12rem 0.5rem;
border-radius: 100px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
flex-shrink: 0;
width: 48px;
justify-content: center;
}
.study-lesson-pill.live { background: rgba(122, 191, 122, 0.15); color: #7abf7a; }
.study-lesson-pill.draft { background: rgba(201, 168, 76, 0.1); color: var(--brand-gold); }
.study-lesson-actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.btn-admin-apply {
background: transparent;
color: var(--brand-gold);