Add cross-site improvements across three phases

Phase 1 — Quick wins:
- Image lazy-loading on series/resource cards
- Newsletter signup added to Episodes page (before highlights)
- Per-route meta tags via usePageMeta hook (title, og:title, og:description)
- Breadcrumbs on study index, section, and notes pages
- SVG completion checkmark badges on study section list
- Analytics time-range filter (7d / 30d / 90d) in admin panel

Phase 2 — Medium features:
- Related episodes on archived series detail pages
- Resource library two-tier filter (type + tag chips)
- Global search (Fuse.js) moved below sticky header as full-width bar
- Q&A anonymous upvoting with localStorage dedup + admin pin/unpin
- Study enrollment funnel tracking (firstVisitAt, firstCompletionAt) with funnel chart in analytics

Phase 3 — Larger features:
- Study section comments (auto-approve for enrolled users, admin moderation panel)
- Study completion certificate (canvas render, PNG download, shareable public URL)
- Episode script full-text search (mammoth docx extraction, server-side search, admin upload UI)
- Reflection questions renamed from Discussion Questions; quiz answers can be shared to section discussion
- Public certificate route at /certificate/:token with og meta tags
- Comment moderation panel added to admin under Manage > Study Comments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-06-09 09:10:29 -04:00
parent 9ad24df626
commit c4645e3475
24 changed files with 2991 additions and 76 deletions
+287 -5
View File
@@ -553,6 +553,7 @@ export interface AdminStats {
displayName: string
enrolledStudies: Array<{ slug: string; title: string }>
}>
funnel?: { signups: number; firstVisit: number; firstCompletion: number }
}
}
@@ -587,6 +588,8 @@ interface Question {
answeredAt: string | null
isApproved: boolean
approvedAt: string | null
upvotes?: number
pinned?: boolean
}
interface ContactSubmission {
@@ -670,7 +673,7 @@ type AdminView =
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
| 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'analytics' | 'assets' | 'colossians-study'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study'
| 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
@@ -707,6 +710,7 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
group: 'Manage',
options: [
{ value: 'questions', label: 'Questions' },
{ value: 'study-comments', label: 'Study Comments' },
{ value: 'emails', label: 'Emails' },
{ value: 'contacts', label: 'Contacts' },
{ value: 'subscribers', label: 'Subscribers' },
@@ -774,7 +778,7 @@ const ADMIN_SECTION_LINKS: Partial<Record<AdminView, AdminSectionLink[]>> = {
],
}
type PodcastTab = 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
type PodcastTab = 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' | 'episode-scripts'
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates'
@@ -1019,6 +1023,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [podcastTab, setPodcastTab] = useState<PodcastTab>('current-series')
const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [statsRange, setStatsRange] = useState<'7d' | '30d' | '90d'>('30d')
const [maintenanceMsg, setMaintenanceMsg] = useState('')
const [opsMsg, setOpsMsg] = useState('')
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
@@ -1063,6 +1068,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [subscriberSearch, setSubscriberSearch] = useState('')
const [contactSearch, setContactSearch] = useState('')
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
// Study comments moderation state
interface AdminComment { id: string; studySlug: string; sectionId: string; displayName: string; text: string; createdAt: string; isApproved: boolean; approvedAt: string | null }
const [studyComments, setStudyComments] = useState<AdminComment[]>([])
const [commentsLoading, setCommentsLoading] = useState(false)
const [commentsLoaded, setCommentsLoaded] = useState(false)
const [commentFilter, setCommentFilter] = useState<'all' | 'pending' | 'approved'>('all')
const [commentSearch, setCommentSearch] = useState('')
// Episode scripts state
const [scriptsList, setScriptsList] = useState<Array<{ episodeNumber: string; title: string; filename: string; uploadedAt: string | null; wordCount: number }>>([])
const [scriptsLoading, setScriptsLoading] = useState(false)
const [scriptUploadEpisode, setScriptUploadEpisode] = useState('')
const [scriptUploadTitle, setScriptUploadTitle] = useState('')
const [scriptUploadFile, setScriptUploadFile] = useState<File | null>(null)
const [scriptUploadBusy, setScriptUploadBusy] = useState(false)
const [scriptUploadMsg, setScriptUploadMsg] = useState('')
const [podcastChecklist, setPodcastChecklist] = useState<PodcastChecklistData>({ tasks: [], episodes: [] })
const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('')
@@ -1206,7 +1228,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}, [])
useEffect(() => {
fetch('/api/admin-stats')
fetch(`/api/admin-stats?range=${statsRange}`)
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats'))))
.then(data => {
setStats(data as AdminStats)
@@ -1329,8 +1351,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
})
}, [selectedBackup])
async function reloadStats() {
const r = await fetch('/api/admin-stats')
async function reloadStats(range?: '7d' | '30d' | '90d') {
const r = await fetch(`/api/admin-stats?range=${range ?? statsRange}`)
if (!r.ok) throw new Error('Could not refresh stats')
const data = await r.json()
setStats(data as AdminStats)
@@ -2607,6 +2629,99 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}
}
async function handlePinQuestion(questionId: string, pin: boolean) {
try {
const method = pin ? 'POST' : 'DELETE'
const res = await fetch(`/api/admin-questions/${questionId}/pin`, { method })
if (!res.ok) throw new Error('Failed to update pin')
const data = await res.json().catch(() => ({})) as { question?: Question }
if (data.question) {
setQuestions(items => items.map(q => q.id === questionId ? { ...q, pinned: data.question!.pinned } : q))
}
} catch (err) {
console.error(err)
}
}
async function loadStudyComments() {
setCommentsLoading(true)
try {
const res = await fetch('/api/admin-study-comments')
const data = await res.json()
setStudyComments(Array.isArray(data.comments) ? data.comments : [])
setCommentsLoaded(true)
} catch { /* ignore */ }
finally { setCommentsLoading(false) }
}
async function handleApproveComment(commentId: string) {
try {
const res = await fetch(`/api/admin-study-comments/${commentId}/approve`, { method: 'POST' })
if (!res.ok) return
setStudyComments(prev => prev.map(c => c.id === commentId ? { ...c, isApproved: true, approvedAt: new Date().toISOString() } : c))
} catch { /* ignore */ }
}
async function handleDeleteComment(commentId: string) {
if (!confirm('Delete this comment permanently?')) return
try {
const res = await fetch(`/api/admin-study-comments/${commentId}`, { method: 'DELETE' })
if (!res.ok) return
setStudyComments(prev => prev.filter(c => c.id !== commentId))
} catch { /* ignore */ }
}
async function loadScriptsList() {
setScriptsLoading(true)
try {
const res = await fetch('/api/admin-episode-scripts')
const data = await res.json()
setScriptsList(Array.isArray(data.scripts) ? data.scripts : [])
} catch { /* ignore */ }
finally { setScriptsLoading(false) }
}
async function handleScriptUpload() {
if (!scriptUploadEpisode.trim() || !/^\d+$/.test(scriptUploadEpisode.trim())) {
setScriptUploadMsg('Enter a valid episode number.'); return
}
if (!scriptUploadFile) { setScriptUploadMsg('Select a .docx file.'); return }
setScriptUploadBusy(true)
setScriptUploadMsg('')
try {
const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = e => resolve(e.target?.result as string)
reader.onerror = reject
reader.readAsDataURL(scriptUploadFile)
})
const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(scriptUploadEpisode.trim())}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dataUrl, filename: scriptUploadFile.name, title: scriptUploadTitle.trim() || `Episode ${scriptUploadEpisode.trim()}` }),
})
const data = await res.json()
if (!res.ok) { setScriptUploadMsg(data.message ?? 'Upload failed.'); return }
setScriptUploadMsg(`✓ Episode ${scriptUploadEpisode} uploaded — ${data.wordCount?.toLocaleString()} words indexed.`)
setScriptUploadEpisode('')
setScriptUploadTitle('')
setScriptUploadFile(null)
loadScriptsList()
} catch {
setScriptUploadMsg('Upload failed. Please try again.')
} finally {
setScriptUploadBusy(false)
}
}
async function handleDeleteScript(episodeNumber: string) {
if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return
try {
await fetch(`/api/admin-episode-scripts/${encodeURIComponent(episodeNumber)}`, { method: 'DELETE' })
setScriptsList(prev => prev.filter(s => s.episodeNumber !== episodeNumber))
} catch { alert('Failed to delete script.') }
}
async function handleDeleteQuestion(questionId: string) {
if (!confirm('Delete this question permanently?')) return
try {
@@ -3640,6 +3755,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<button type="button" className={`admin-tab${podcastTab === 'episode-highlights' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-highlights')}>Ep. Highlights</button>
<button type="button" className={`admin-tab${podcastTab === 'podcast-checklist' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('podcast-checklist')}>Production Checklist</button>
<button type="button" className={`admin-tab${podcastTab === 'archived-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('archived-series')}>Archived Series</button>
<button type="button" className={`admin-tab${podcastTab === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button>
</div>
</section>
)}
@@ -4646,6 +4762,163 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section>
)}
{/* EPISODE SCRIPTS */}
{(adminView === 'podcast' && podcastTab === 'episode-scripts') && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Episode Scripts</h2>
<p>Upload a Word (.docx) script for each episode to make the full content searchable from the site&apos;s global search bar.</p>
</div>
{/* Upload form */}
<div className="admin-script-upload-form">
<div className="admin-field">
<label htmlFor="script-episode-num">Episode Number</label>
<input
id="script-episode-num"
type="number"
min="1"
placeholder="e.g. 42"
value={scriptUploadEpisode}
onChange={e => setScriptUploadEpisode(e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor="script-episode-title">Episode Title <span style={{ color: '#5a5440', fontWeight: 400 }}>(optional will be pulled from search results)</span></label>
<input
id="script-episode-title"
type="text"
placeholder="e.g. Colossians 1:15 — The Image of God"
value={scriptUploadTitle}
onChange={e => setScriptUploadTitle(e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor="script-file">Word Document (.docx)</label>
<input
id="script-file"
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
onChange={e => setScriptUploadFile(e.target.files?.[0] ?? null)}
/>
</div>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button type="button" className="btn-admin-save" onClick={handleScriptUpload} disabled={scriptUploadBusy}>
{scriptUploadBusy ? 'Uploading…' : 'Upload Script'}
</button>
{scriptUploadMsg && <span style={{ fontSize: '0.85rem', color: scriptUploadMsg.startsWith('✓') ? '#6fcf97' : '#e07a7a' }}>{scriptUploadMsg}</span>}
</div>
</div>
{/* Indexed scripts list */}
<div className="admin-panel-head" style={{ marginTop: '2rem' }}>
<h3 style={{ margin: 0 }}>Indexed Scripts</h3>
<button type="button" className="btn-admin-reset" onClick={loadScriptsList} disabled={scriptsLoading} style={{ marginLeft: 'auto' }}>
{scriptsLoading ? 'Loading…' : 'Refresh'}
</button>
</div>
{scriptsList.length === 0 ? (
<p style={{ color: '#5a5440', fontSize: '0.9rem', marginTop: '0.75rem' }}>
No scripts uploaded yet.{' '}
<button type="button" className="btn-admin-reset" style={{ display: 'inline', padding: '0.1rem 0.5rem' }} onClick={loadScriptsList}>Load list</button>
</p>
) : (
<div className="admin-script-list">
{scriptsList.map(script => (
<div key={script.episodeNumber} className="admin-script-row">
<div className="admin-script-row-info">
<span className="admin-script-ep">Ep. {script.episodeNumber}</span>
<span className="admin-script-title">{script.title}</span>
<span className="admin-script-meta">{script.wordCount.toLocaleString()} words · {script.filename}</span>
</div>
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteScript(script.episodeNumber)}>Remove</button>
</div>
))}
</div>
)}
</section>
)}
{/* STUDY COMMENTS */}
{adminView === 'study-comments' && (() => {
const filtered = studyComments.filter(c => {
if (commentFilter === 'pending') return !c.isApproved
if (commentFilter === 'approved') return c.isApproved
return true
}).filter(c => {
if (!commentSearch.trim()) return true
const q = commentSearch.toLowerCase()
return c.displayName.toLowerCase().includes(q) || c.text.toLowerCase().includes(q) || c.studySlug.includes(q) || c.sectionId.includes(q)
})
return (
<section className="admin-panel-section" aria-label="Study Comment Moderation">
<div className="admin-panel-head">
<h2>Study Comments</h2>
<p>Review, approve, and delete comments left on study section pages.</p>
</div>
<div className="admin-actions admin-actions--maintenance" style={{ alignItems: 'center' }}>
<input
type="text"
value={commentSearch}
onChange={e => setCommentSearch(e.target.value)}
placeholder="Search by name, text, or section…"
style={{ minWidth: '280px', maxWidth: '480px', width: '100%' }}
/>
<button type="button" className={`btn-admin-reset${commentFilter === 'all' ? ' btn-admin-reset--active' : ''}`} onClick={() => setCommentFilter('all')}>All</button>
<button type="button" className={`btn-admin-reset${commentFilter === 'pending' ? ' btn-admin-reset--active' : ''}`} onClick={() => setCommentFilter('pending')}>Pending</button>
<button type="button" className={`btn-admin-reset${commentFilter === 'approved' ? ' btn-admin-reset--active' : ''}`} onClick={() => setCommentFilter('approved')}>Approved</button>
<button type="button" className="btn-admin-reset" onClick={loadStudyComments} disabled={commentsLoading} style={{ marginLeft: 'auto' }}>
{commentsLoading ? 'Loading…' : 'Refresh'}
</button>
</div>
{!commentsLoaded ? (
<div style={{ marginTop: '1.5rem' }}>
<button type="button" className="btn-admin-reset" onClick={loadStudyComments} disabled={commentsLoading}>
{commentsLoading ? 'Loading…' : 'Load Comments'}
</button>
</div>
) : filtered.length === 0 ? (
<p className="admin-stats-note" style={{ marginTop: '1rem' }}>
{commentFilter === 'pending' ? 'No pending comments.' : commentFilter === 'approved' ? 'No approved comments.' : 'No comments yet.'}
</p>
) : (
<>
<p className="admin-stats-note">Showing {filtered.length} of {studyComments.length} comments.</p>
<div className="admin-comment-list">
{filtered.map(comment => (
<div key={comment.id} className={`admin-comment-card${comment.isApproved ? '' : ' admin-comment-card--pending'}`}>
<div className="admin-comment-card-header">
<div className="admin-comment-card-meta">
<span className="admin-comment-author">{comment.displayName}</span>
<span className="admin-comment-location">{comment.studySlug} {comment.sectionId}</span>
<span className="admin-comment-date">{new Date(comment.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</span>
</div>
{!comment.isApproved && (
<span className="admin-comment-badge admin-comment-badge--pending">Pending</span>
)}
{comment.isApproved && (
<span className="admin-comment-badge admin-comment-badge--approved">Approved</span>
)}
</div>
<p className="admin-comment-text">{comment.text}</p>
<div className="admin-comment-actions">
{!comment.isApproved && (
<button type="button" className="btn-admin-save" onClick={() => handleApproveComment(comment.id)}>Approve</button>
)}
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteComment(comment.id)}>Delete</button>
</div>
</div>
))}
</div>
</>
)}
</section>
)
})()}
{/* EMAIL TEMPLATES */}
{adminView === 'email-templates' && (
<EmailTemplatesPanel form={form} handleChange={handleChange} renderSaveStatus={renderSaveStatus} />
@@ -4815,6 +5088,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<button type="button" className={`btn-admin-${question.isApproved ? 'remove' : 'reset'}`} onClick={() => handleApproveQuestion(question.id, !question.isApproved)}>
{question.isApproved ? 'Unapprove' : 'Approve'}
</button>
<button type="button" className={`btn-admin-${question.pinned ? 'remove' : 'reset'}`} onClick={() => handlePinQuestion(question.id, !question.pinned)}>
{question.pinned ? '📌 Unpin' : '📌 Pin'}
</button>
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteQuestion(question.id)}>Delete</button>
</div>
)}
@@ -4855,6 +5131,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<AnalyticsPanel
stats={stats}
statsStatus={statsStatus}
statsRange={statsRange}
onRangeChange={async (range) => {
setStatsRange(range)
setStatsStatus('loading')
try { await reloadStats(range) } catch { setStatsStatus('error') }
}}
opsStatus={opsStatus}
formatDate={formatDate}
maskIp={maskIp}