Replace study lesson expanding cards with master-detail drill-down; v1.1.51
Publish Container / docker (push) Successful in 13m15s
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:
1 parent
e593031d0d
commit
14665d06b5
3 files changed
+409
-195
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "1.1.50",
|
||||
"version": "1.1.51",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+248
-194
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user