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:
+287
-5
@@ -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'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}
|
||||
|
||||
Reference in New Issue
Block a user