- Pause reminders: remindersPausedUntil field + scheduler skip + account UI with 7/14/30-day presets - Email digest mode: reminderFrequency field (immediate/weekly) + weekly digest email + scheduler + account UI toggle - Admin user list improvements: streak, reengagement, frequency, pause, per-study lesson progress - Admin manual re-engagement: POST endpoint + tier selector button in admin panel - Study bookmarks: bookmarkedSectionIds in progress file + POST/DELETE endpoints + toggle button in lesson UI - Study completion certificate email: auto-sent on first certificate issuance - Content fields for certificate and weekly digest email templates in admin Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5422 lines
292 KiB
TypeScript
5422 lines
292 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react'
|
||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
|
||
import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
|
||
import { CSS } from '@dnd-kit/utilities'
|
||
import type { ChangeEvent } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
|
||
import { DEFAULTS } from './content'
|
||
import { AnalyticsPanel } from './components/AnalyticsPanel'
|
||
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
|
||
import { useAutosave } from './hooks/useAutosave'
|
||
import QRCode from 'qrcode'
|
||
|
||
|
||
interface SortableLessonRowProps {
|
||
section: ColossiansStudySection
|
||
study: StudyProgram
|
||
onEdit: () => void
|
||
removeStudySection: (studyId: string, sectionId: string) => void
|
||
}
|
||
|
||
function SortableLessonRow({ section, study, onEdit, removeStudySection }: SortableLessonRowProps) {
|
||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id })
|
||
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>
|
||
)
|
||
}
|
||
|
||
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) { setMsg('Set the passage reference first.'); return }
|
||
setBusy(true); setMsg('')
|
||
try {
|
||
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) { setMsg(data.message ?? 'Passage not found.'); return }
|
||
updateStudySection(study.id, section.id, 'passageText', data.text)
|
||
setMsg('Passage filled from BSB.')
|
||
} catch { setMsg('Could not reach the BSB API.') }
|
||
finally { setBusy(false) }
|
||
}
|
||
return (
|
||
<>
|
||
<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>
|
||
{msg && <span style={{ fontSize: '0.8rem', color: msg.includes('filled') ? '#7abf7a' : '#c97070' }}>{msg}</span>}
|
||
</>
|
||
)
|
||
}
|
||
|
||
interface StudyUserRecord {
|
||
id: string
|
||
username: string
|
||
displayName: string
|
||
createdAt: string | null
|
||
lastLoginAt: string | null
|
||
lastStudiedDate: string | null
|
||
currentStreak: number
|
||
longestStreak: number
|
||
enrolledStudies: Array<{ slug: string; title: string; completedLessons?: number; totalLessons?: number }>
|
||
noteCount: number
|
||
subscribeNewsletter: boolean
|
||
studyRemindersEnabled: boolean
|
||
reminderFrequency: 'immediate' | 'weekly'
|
||
remindersPausedUntil: string | null
|
||
reengagementSentAt: Record<string, string>
|
||
}
|
||
|
||
function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
||
const [users, setUsers] = useState<StudyUserRecord[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [search, setSearch] = useState('')
|
||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||
const [editDisplayName, setEditDisplayName] = useState<Record<string, string>>({})
|
||
const [newPassword, setNewPassword] = useState<Record<string, string>>({})
|
||
const [msg, setMsg] = useState<Record<string, string>>({})
|
||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
||
const [reengagementTier, setReengagementTier] = useState<Record<string, string>>({})
|
||
|
||
function flashMsg(id: string, text: string) {
|
||
setMsg(prev => ({ ...prev, [id]: text }))
|
||
setTimeout(() => setMsg(prev => { const n = { ...prev }; delete n[id]; return n }), 4000)
|
||
}
|
||
|
||
useEffect(() => {
|
||
fetch('/api/admin/study-users')
|
||
.then(r => r.json())
|
||
.then((d: { users: StudyUserRecord[] }) => { setUsers(d.users ?? []); setLoading(false) })
|
||
.catch(() => setLoading(false))
|
||
}, [])
|
||
|
||
async function patchUser(id: string, body: Record<string, string>) {
|
||
const res = await fetch(`/api/admin/study-users/${id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok) throw new Error(data.message ?? 'Error')
|
||
return data
|
||
}
|
||
|
||
async function handleSaveDisplayName(user: StudyUserRecord) {
|
||
try {
|
||
const name = editDisplayName[user.id] ?? user.displayName
|
||
await patchUser(user.id, { displayName: name })
|
||
setUsers(prev => prev.map(u => u.id === user.id ? { ...u, displayName: name } : u))
|
||
flashMsg(user.id, 'Display name updated.')
|
||
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
|
||
}
|
||
|
||
async function handleResetPassword(user: StudyUserRecord) {
|
||
const pw = newPassword[user.id] ?? ''
|
||
if (pw.length < 8) { flashMsg(user.id, 'Password must be at least 8 characters.'); return }
|
||
try {
|
||
await patchUser(user.id, { newPassword: pw })
|
||
setNewPassword(prev => { const n = { ...prev }; delete n[user.id]; return n })
|
||
flashMsg(user.id, 'Password reset. Their active sessions have been signed out.')
|
||
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
|
||
}
|
||
|
||
async function handleEnroll(user: StudyUserRecord, slug: string) {
|
||
try {
|
||
const data = await patchUser(user.id, { addEnrollment: slug })
|
||
setUsers(prev => prev.map(u => {
|
||
if (u.id !== user.id) return u
|
||
const study = studies.find(s => s.slug === slug)
|
||
const already = u.enrolledStudies.find(e => e.slug === slug)
|
||
return { ...u, enrolledStudies: already ? u.enrolledStudies : [...u.enrolledStudies, { slug, title: study?.title ?? slug }] }
|
||
}))
|
||
flashMsg(user.id, `Enrolled in ${slug}.`)
|
||
return data
|
||
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
|
||
}
|
||
|
||
async function handleUnenroll(user: StudyUserRecord, slug: string) {
|
||
try {
|
||
await patchUser(user.id, { removeEnrollment: slug })
|
||
setUsers(prev => prev.map(u => u.id === user.id ? { ...u, enrolledStudies: u.enrolledStudies.filter(e => e.slug !== slug) } : u))
|
||
flashMsg(user.id, `Removed from ${slug}.`)
|
||
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
|
||
}
|
||
|
||
async function handleSendReengagement(user: StudyUserRecord) {
|
||
const tier = reengagementTier[user.id] ?? 't1'
|
||
try {
|
||
const res = await fetch(`/api/admin/study-users/${user.id}/send-reengagement`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tier }),
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok) throw new Error(data.message ?? 'Error')
|
||
flashMsg(user.id, `Tier ${tier.slice(1)} re-engagement email sent.`)
|
||
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
|
||
}
|
||
|
||
async function handleDelete(id: string) {
|
||
try {
|
||
await fetch(`/api/admin/study-users/${id}`, { method: 'DELETE' })
|
||
setUsers(prev => prev.filter(u => u.id !== id))
|
||
setConfirmDeleteId(null)
|
||
setExpandedId(null)
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
const filtered = users.filter(u =>
|
||
u.username.toLowerCase().includes(search.toLowerCase()) ||
|
||
u.displayName.toLowerCase().includes(search.toLowerCase())
|
||
)
|
||
|
||
if (loading) return <p className="admin-stats-note">Loading study users…</p>
|
||
|
||
return (
|
||
<section className="admin-panel-section" aria-label="Study Users">
|
||
<div className="admin-panel-head">
|
||
<h2>Study Users</h2>
|
||
<p>Manage student accounts — reset passwords, update display names, adjust enrollments, or delete accounts.</p>
|
||
</div>
|
||
|
||
<div className="admin-field" style={{ maxWidth: '360px', marginBottom: '1.25rem' }}>
|
||
<input
|
||
type="search"
|
||
placeholder="Search by email or display name…"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{filtered.length === 0 && <p className="admin-stats-note">{users.length === 0 ? 'No study users yet.' : 'No users match your search.'}</p>}
|
||
|
||
<div className="admin-study-users-list">
|
||
{filtered.map(user => {
|
||
const isExpanded = expandedId === user.id
|
||
const unenrolledStudies = studies.filter(s => !user.enrolledStudies.find(e => e.slug === s.slug))
|
||
return (
|
||
<div key={user.id} className={`admin-study-user-card${isExpanded ? ' admin-study-user-card--open' : ''}`}>
|
||
<button
|
||
type="button"
|
||
className="admin-study-user-header"
|
||
onClick={() => {
|
||
setExpandedId(isExpanded ? null : user.id)
|
||
setEditDisplayName(prev => ({ ...prev, [user.id]: user.displayName }))
|
||
}}
|
||
>
|
||
<div className="admin-study-user-header-left">
|
||
<strong>{user.displayName || user.username}</strong>
|
||
{user.displayName && <span className="admin-study-user-email">{user.username}</span>}
|
||
</div>
|
||
<div className="admin-study-user-header-right">
|
||
<span className="admin-study-user-pill">{user.enrolledStudies.length} enrolled</span>
|
||
<span className="admin-study-user-pill">{user.noteCount} notes</span>
|
||
{(user.currentStreak ?? 0) > 0 && <span className="admin-study-user-pill">{user.currentStreak} 🔥</span>}
|
||
<span style={{ color: '#5a5440', fontSize: '0.8rem' }}>{isExpanded ? '▲' : '▼'}</span>
|
||
</div>
|
||
</button>
|
||
|
||
{isExpanded && (
|
||
<div className="admin-study-user-body">
|
||
<div className="admin-study-user-meta">
|
||
<span>Joined: {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'}</span>
|
||
<span>Last login: {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : '—'}</span>
|
||
<span>Last studied: {user.lastStudiedDate ?? '—'}</span>
|
||
<span>Streak: {user.currentStreak ?? 0} 🔥 (best: {user.longestStreak ?? 0})</span>
|
||
<span>Newsletter: {user.subscribeNewsletter ? 'Yes' : 'No'}</span>
|
||
<span>Reminders: {user.studyRemindersEnabled ? 'On' : 'Off'} · {user.reminderFrequency === 'weekly' ? 'Weekly digest' : 'Per-lesson'}</span>
|
||
{user.remindersPausedUntil && new Date(user.remindersPausedUntil) > new Date() && (
|
||
<span style={{ color: '#c9a84c' }}>Paused until {new Date(user.remindersPausedUntil).toLocaleDateString()}</span>
|
||
)}
|
||
{user.reengagementSentAt && Object.keys(user.reengagementSentAt).length > 0 && (
|
||
<span>Re-engagement sent: {Object.keys(user.reengagementSentAt).sort().join(', ')}</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Display Name */}
|
||
<div className="admin-study-user-section">
|
||
<h4>Display Name</h4>
|
||
<div className="admin-study-user-row">
|
||
<input
|
||
type="text"
|
||
value={editDisplayName[user.id] ?? user.displayName}
|
||
onChange={e => setEditDisplayName(prev => ({ ...prev, [user.id]: e.target.value }))}
|
||
placeholder="Display name"
|
||
maxLength={80}
|
||
/>
|
||
<button type="button" className="btn-admin-save" onClick={() => handleSaveDisplayName(user)}>Save</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Password Reset */}
|
||
<div className="admin-study-user-section">
|
||
<h4>Reset Password</h4>
|
||
<div className="admin-study-user-row">
|
||
<input
|
||
type="text"
|
||
value={newPassword[user.id] ?? ''}
|
||
onChange={e => setNewPassword(prev => ({ ...prev, [user.id]: e.target.value }))}
|
||
placeholder="New password (min 8 chars)"
|
||
autoComplete="off"
|
||
/>
|
||
<button type="button" className="btn-admin-save" onClick={() => handleResetPassword(user)}>Reset</button>
|
||
</div>
|
||
<p className="admin-stats-note" style={{ marginTop: '0.25rem' }}>This immediately signs them out of all active sessions.</p>
|
||
</div>
|
||
|
||
{/* Enrollments */}
|
||
<div className="admin-study-user-section">
|
||
<h4>Enrollments</h4>
|
||
{user.enrolledStudies.length === 0
|
||
? <p className="admin-stats-note">Not enrolled in any studies.</p>
|
||
: (
|
||
<ul className="admin-study-user-enrollments">
|
||
{user.enrolledStudies.map(e => (
|
||
<li key={e.slug}>
|
||
<span>
|
||
{e.title}
|
||
{e.totalLessons != null && (
|
||
<span style={{ color: '#8a7a3a', fontSize: '0.8em', marginLeft: '0.5rem' }}>
|
||
{e.completedLessons ?? 0}/{e.totalLessons} lessons
|
||
</span>
|
||
)}
|
||
</span>
|
||
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.78rem', padding: '0.2rem 0.6rem' }} onClick={() => handleUnenroll(user, e.slug)}>Remove</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)
|
||
}
|
||
{unenrolledStudies.length > 0 && (
|
||
<div className="admin-study-user-row" style={{ marginTop: '0.5rem' }}>
|
||
<select
|
||
defaultValue=""
|
||
onChange={e => { if (e.target.value) { handleEnroll(user, e.target.value); e.target.value = '' } }}
|
||
>
|
||
<option value="">Enroll in a study…</option>
|
||
{unenrolledStudies.map(s => <option key={s.slug} value={s.slug}>{s.title}</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Re-engagement */}
|
||
{user.enrolledStudies.length > 0 && (
|
||
<div className="admin-study-user-section">
|
||
<h4>Send Re-engagement Email</h4>
|
||
<div className="admin-study-user-row">
|
||
<select
|
||
value={reengagementTier[user.id] ?? 't1'}
|
||
onChange={e => setReengagementTier(prev => ({ ...prev, [user.id]: e.target.value }))}
|
||
>
|
||
<option value="t1">Tier 1 (gentle nudge)</option>
|
||
<option value="t2">Tier 2 (momentum)</option>
|
||
<option value="t3">Tier 3 (still here)</option>
|
||
</select>
|
||
<button type="button" className="btn-admin-save" onClick={() => handleSendReengagement(user)}>Send</button>
|
||
</div>
|
||
<p className="admin-stats-note" style={{ marginTop: '0.25rem' }}>Sends the selected tier email immediately, regardless of inactivity threshold.</p>
|
||
</div>
|
||
)}
|
||
|
||
{msg[user.id] && <p className="admin-study-user-msg">{msg[user.id]}</p>}
|
||
|
||
{/* Delete */}
|
||
<div className="admin-study-user-section" style={{ borderTop: '1px solid rgba(201,168,76,0.1)', paddingTop: '0.75rem', marginTop: '0.5rem' }}>
|
||
{confirmDeleteId === user.id ? (
|
||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||
<span style={{ fontSize: '0.85rem', color: '#e05c5c' }}>Delete this account permanently?</span>
|
||
<button type="button" className="btn-admin-remove" onClick={() => handleDelete(user.id)}>Yes, Delete</button>
|
||
<button type="button" className="btn-admin-reset" onClick={() => setConfirmDeleteId(null)}>Cancel</button>
|
||
</div>
|
||
) : (
|
||
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.82rem' }} onClick={() => setConfirmDeleteId(user.id)}>Delete Account</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function EmailTemplatesPanel({
|
||
form,
|
||
handleChange,
|
||
renderSaveStatus,
|
||
}: {
|
||
form: SiteContent
|
||
handleChange: (key: StringField, value: string) => void
|
||
renderSaveStatus: () => React.ReactNode
|
||
}) {
|
||
const [expandedEmail, setExpandedEmail] = useState<string | null>(null)
|
||
|
||
function EmailTemplateCard({
|
||
type, icon, title, trigger, autoVars, fieldKeys, openKey,
|
||
}: {
|
||
type: 'newsletter' | 'study' | 'transactional'
|
||
icon: string
|
||
title: string
|
||
trigger: string
|
||
autoVars?: string
|
||
fieldKeys: string[]
|
||
openKey: string
|
||
}) {
|
||
const isOpen = expandedEmail === openKey
|
||
return (
|
||
<div className={`admin-et-card admin-et-card--${type}${isOpen ? ' admin-et-card--open' : ''}`}>
|
||
<button
|
||
type="button"
|
||
className="admin-et-card-header"
|
||
onClick={() => setExpandedEmail(isOpen ? null : openKey)}
|
||
>
|
||
<span className="admin-et-card-icon" aria-hidden="true">{icon}</span>
|
||
<div className="admin-et-card-meta">
|
||
<div className="admin-et-card-title-row">
|
||
<span className={`admin-et-badge admin-et-badge--${type}`}>{type}</span>
|
||
<strong className="admin-et-card-title">{title}</strong>
|
||
</div>
|
||
<p className="admin-et-card-trigger">⚡ {trigger}</p>
|
||
</div>
|
||
<span className="admin-et-card-chevron">{isOpen ? '▲' : '▼'}</span>
|
||
</button>
|
||
{isOpen && (
|
||
<div className="admin-et-card-body">
|
||
{autoVars && (
|
||
<p className="admin-et-card-vars">
|
||
<span className="admin-et-card-vars-label">Auto-inserted:</span> {autoVars}
|
||
</p>
|
||
)}
|
||
{fieldKeys.map(key => {
|
||
const field = FIELDS.find(f => f.key === key && f.section === 'email-templates')
|
||
if (!field) return null
|
||
return (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-et-${key}`}>{field.label}</label>
|
||
{field.multiline
|
||
? <textarea id={`field-et-${key}`} rows={3} value={form[key as StringField] as string} onChange={e => handleChange(key as StringField, e.target.value)} />
|
||
: <input id={`field-et-${key}`} type="text" value={form[key as StringField] as string} onChange={e => handleChange(key as StringField, e.target.value)} />
|
||
}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<section className="admin-panel-section" aria-label="Email Templates">
|
||
<div className="admin-panel-head">
|
||
<h2>Email Templates</h2>
|
||
<p>Every automated email the site sends — edit subject lines, body copy, and sign-offs. Publish to apply changes.</p>
|
||
</div>
|
||
|
||
<EmailTemplateCard type="newsletter" icon="📬" title="Newsletter Welcome"
|
||
trigger="Someone subscribes via the homepage form or contact page"
|
||
autoVars="Subscriber's first name, Spotify / Apple / Amazon links"
|
||
fieldKeys={['welcomeEmailSubject','welcomeEmailGreetingPrefix','welcomeEmailIntro','welcomeEmailCurrentSeries','welcomeEmailStartHereTitle','welcomeEmailStartHereSummary','welcomeEmailStartHereUrl','welcomeEmailStartHereLinkLabel','welcomeEmailWhatToExpect1','welcomeEmailWhatToExpect2','welcomeEmailWhatToExpect3','welcomeEmailScripture','welcomeEmailScriptureRef','welcomeEmailSignoff','welcomeEmailSpotifyUrl','welcomeEmailSpotifyBtnLabel','welcomeEmailAppleUrl','welcomeEmailAppleBtnLabel','welcomeEmailAmazonUrl']}
|
||
openKey="newsletter-welcome" />
|
||
|
||
<EmailTemplateCard type="study" icon="📖" title="Study Account — New Account"
|
||
trigger="A student creates a study account"
|
||
autoVars="Student's display name, link to study hub, link to account page"
|
||
fieldKeys={['studyWelcomeEmailSubject','studyWelcomeEmailBody','studyWelcomeEmailCtaLabel','studyWelcomeEmailCtaPath','studyWelcomeEmailSignoff']}
|
||
openKey="study-welcome" />
|
||
|
||
<EmailTemplateCard type="study" icon="🗑️" title="Study Account — Account Deleted"
|
||
trigger="A student (or admin) deletes a study account"
|
||
autoVars="Student's display name, link to create a new account"
|
||
fieldKeys={['studyDeletedEmailSubject','studyDeletedEmailBody','studyDeletedEmailCtaLabel','studyDeletedEmailCtaPath','studyDeletedEmailSignoff']}
|
||
openKey="study-deleted" />
|
||
|
||
<EmailTemplateCard type="study" icon="🔔" title="Study Account — New Lesson Unlocked"
|
||
trigger="A lesson releases for a student who has reminders turned on"
|
||
autoVars="Student's name, lesson title, scripture reference, study track name — button links directly to the lesson (auto-generated, not editable)"
|
||
fieldKeys={['studyReminderEmailSubjectPrefix','studyReminderEmailBody','studyReminderEmailCtaLabel','studyReminderEmailSignoff']}
|
||
openKey="study-reminder" />
|
||
|
||
<EmailTemplateCard type="study" icon="💤" title="Study Account — Re-engagement Reminders"
|
||
trigger="A student with reminders enabled hasn't logged in for the configured number of days (fires up to 3 tiers, one per check)"
|
||
autoVars="Student's name, study title, study URL, and a one-click unsubscribe link are auto-inserted"
|
||
fieldKeys={['studyReengagementDays','studyReengagementT1Subject','studyReengagementT1Body','studyReengagementT1Cta','studyReengagementT2Subject','studyReengagementT2Body','studyReengagementT2Cta','studyReengagementT3Subject','studyReengagementT3Body','studyReengagementT3Cta']}
|
||
openKey="study-reengagement" />
|
||
|
||
<EmailTemplateCard type="study" icon="🏅" title="Study Account — Completion Certificate"
|
||
trigger="A student earns their certificate by completing all lessons in a study"
|
||
autoVars="Student's name, study title, and a link to the public certificate are auto-inserted"
|
||
fieldKeys={['studyCertificateEmailSubject','studyCertificateEmailBody','studyCertificateEmailCtaLabel','studyCertificateEmailSignoff']}
|
||
openKey="study-certificate" />
|
||
|
||
<EmailTemplateCard type="study" icon="📅" title="Study Account — Weekly Lesson Digest"
|
||
trigger="Students who choose 'Weekly Digest' mode receive one summary email per week listing all newly released lessons"
|
||
autoVars="Student's name, lesson list (title, scripture reference, link), and a one-click unsubscribe link"
|
||
fieldKeys={['studyWeeklyDigestEmailSubject','studyWeeklyDigestEmailBody','studyWeeklyDigestEmailSignoff']}
|
||
openKey="study-weekly-digest" />
|
||
|
||
<EmailTemplateCard type="transactional" icon="🔐" title="Account Security — Email Change Confirmation"
|
||
trigger="A student requests to change their account email address"
|
||
autoVars="Button links to a one-time verification URL (auto-generated per request, not editable)"
|
||
fieldKeys={['emailChangeSubject','emailChangeBody','emailChangeCtaLabel']}
|
||
openKey="email-change" />
|
||
|
||
<EmailTemplateCard type="transactional" icon="📟" title="Two-Factor Auth — Email Sign-In Code"
|
||
trigger="A student with email-based 2FA signs in, or requests a resend during login"
|
||
autoVars="The 6-digit code is auto-generated and inserted between the body text and expiry note — not editable"
|
||
fieldKeys={['twoFaOtpEmailSubject','twoFaOtpEmailBody','twoFaOtpEmailExpiry']}
|
||
openKey="2fa-otp" />
|
||
|
||
<EmailTemplateCard type="transactional" icon="💬" title="Q&A — Question Answered Notification"
|
||
trigger={'An admin approves a Q&A question submitted with "Email me when answered" checked'}
|
||
autoVars={'Submitter\'s first name, their original question text, the answer (truncated to 600 chars), a direct link to the answer'}
|
||
fieldKeys={['qaAnsweredEmailSubject','qaAnsweredEmailBody','qaAnsweredEmailCtaLabel','qaAnsweredEmailSignoff']}
|
||
openKey="qa-answered" />
|
||
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
interface Props {
|
||
content: SiteContent
|
||
onSave: (c: SiteContent) => void
|
||
onLogout: () => void | Promise<void>
|
||
}
|
||
|
||
interface BackupPreview {
|
||
filename: string
|
||
sizeBytes: number
|
||
createdAt: string | null
|
||
reason: string
|
||
adminUpdatedAt: string | null
|
||
totalHits: number
|
||
totalVisits: number
|
||
}
|
||
|
||
export interface AdminStats {
|
||
totalHits: number
|
||
realHits: number
|
||
botHits: number
|
||
firstHitAt: string | null
|
||
lastHitAt: string | null
|
||
topPaths: Array<{ path: string; hits: number }>
|
||
topPathsReal: Array<{ path: string; hits: number }>
|
||
topPathsBot: Array<{ path: string; hits: number }>
|
||
last7Days: Array<{ day: string; hits: number }>
|
||
last7DaysReal: Array<{ day: string; hits: number }>
|
||
last7DaysBot: Array<{ day: string; hits: number }>
|
||
last30DaysTotal: number
|
||
last30DaysRealTotal: number
|
||
last30DaysBotTotal: number
|
||
botReasons: Array<{ reason: string; count: number }>
|
||
visitors: {
|
||
totalVisits: number
|
||
uniqueVisitors: number
|
||
returningVisits: number
|
||
firstVisitAt: string | null
|
||
lastVisitAt: string | null
|
||
topCountries: Array<{ name: string; hits: number }>
|
||
topStates: Array<{ name: string; hits: number }>
|
||
topCounties: Array<{ name: string; hits: number }>
|
||
topCities: Array<{ name: string; hits: number }>
|
||
deviceBreakdown: { mobile: number; desktop: number; tablet: number; unknown: number }
|
||
topReferrers: Array<{ referrer: string; count: number }>
|
||
last30DaysReal: Array<{ day: string; hits: number }>
|
||
recentVisits: Array<{
|
||
at: string
|
||
visitorId: string
|
||
ip: string
|
||
path: string
|
||
referrer?: string
|
||
device?: string
|
||
country: string
|
||
state: string
|
||
county: string
|
||
city: string
|
||
returningVisitor: boolean
|
||
visitCount: number
|
||
pageHistory?: Array<{ at: string; path: string; referrer?: string }>
|
||
studyUser?: { userId: string; username: string; displayName: string }
|
||
}>
|
||
}
|
||
writeStatus: {
|
||
hitStats: { ok: boolean; at: string | null; error: string | null }
|
||
visitorStats: { ok: boolean; at: string | null; error: string | null }
|
||
backups: { ok: boolean; at: string | null; error: string | null; file: string | null }
|
||
}
|
||
contactTotals: {
|
||
totalSubmissions: number
|
||
totalQuestions: number
|
||
}
|
||
studyEnrollment: {
|
||
totalUsers: number
|
||
enrolledUsers: number
|
||
totalEnrollments: number
|
||
enrollmentsByStudy: Array<{ slug: string; title: string; count: number }>
|
||
users: Array<{
|
||
id: string
|
||
username: string
|
||
displayName: string
|
||
enrolledStudies: Array<{ slug: string; title: string }>
|
||
}>
|
||
funnel?: { signups: number; firstVisit: number; firstCompletion: number }
|
||
}
|
||
episodePlays?: Array<{
|
||
title: string
|
||
total: number
|
||
byDay: Record<string, number>
|
||
last30Days: Array<{ day: string; plays: number }>
|
||
}>
|
||
engagement?: {
|
||
scrollDepth: Array<{ path: string; 25?: number; 50?: number; 75?: number; 90?: number }>
|
||
timeOnPage: Array<{ path: string; avgSeconds: number; count: number }>
|
||
topOutboundClicks: Array<{ url: string; count: number }>
|
||
topLinkClicks: Array<{ url: string; count: number; internal: boolean }>
|
||
topUTMSources: Array<{ source: string; count: number }>
|
||
topSearchQueries: Array<{ query: string; count: number }>
|
||
top404s: Array<{ path: string; count: number }>
|
||
audioEvents: Array<{ title: string; pauses: number; completions: number; totalListenSeconds: number }>
|
||
}
|
||
}
|
||
|
||
interface AdminAsset {
|
||
filename: string
|
||
url: string
|
||
sizeBytes: number
|
||
updatedAt: string
|
||
tags?: string[]
|
||
}
|
||
|
||
interface PublishState {
|
||
draftUpdatedAt: string | null
|
||
publishedAt: string | null
|
||
}
|
||
|
||
interface OpsStatus {
|
||
buildCommit: string | null
|
||
buildNumber: string | null
|
||
deployedAt: string | null
|
||
cachePurge: { ok: boolean; at: string | null; error: string | null }
|
||
deployHook: { ok: boolean; at: string | null; error: string | null }
|
||
}
|
||
|
||
interface Question {
|
||
id: string
|
||
submittedAt: string
|
||
firstName: string
|
||
email: string
|
||
question: string
|
||
answer: string
|
||
answeredAt: string | null
|
||
isApproved: boolean
|
||
approvedAt: string | null
|
||
upvotes?: number
|
||
pinned?: boolean
|
||
}
|
||
|
||
interface ContactSubmission {
|
||
id: string
|
||
submittedAt: string
|
||
name: string
|
||
email: string
|
||
message: string
|
||
messageType: 'question' | 'testimony' | 'topic' | 'general'
|
||
subscribe: boolean
|
||
archived?: boolean
|
||
source?: 'contact-form' | 'download' | 'inbound-email'
|
||
inboundTo?: string
|
||
htmlBody?: string | null
|
||
}
|
||
|
||
interface Subscriber {
|
||
name: string
|
||
email: string
|
||
subscribedAt: string
|
||
source: 'contact-form' | 'download'
|
||
}
|
||
|
||
|
||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'redirects' | 'podcastFeaturedLinks' | 'finishedBooks' | 'testimonials' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
|
||
|
||
type AdminView =
|
||
| 'dashboard'
|
||
| 'homepage:hero' | 'homepage:share' | 'homepage:prism' | 'homepage:where-to-next'
|
||
| 'start-here'
|
||
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
|
||
| 'contact:profile' | 'contact:copy'
|
||
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
|
||
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
|
||
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'webhooks'
|
||
|
||
interface AdminSectionLink {
|
||
id: string
|
||
label: string
|
||
}
|
||
|
||
type NavLeaf = { kind: 'leaf'; view: AdminView; label: string }
|
||
type NavParent = { kind: 'parent'; id: string; label: string; children: NavLeaf[] }
|
||
type NavEntry = NavLeaf | NavParent
|
||
interface NavGroup { group: string; items: NavEntry[] }
|
||
|
||
const ADMIN_NAV: NavGroup[] = [
|
||
{
|
||
group: 'Overview',
|
||
items: [{ kind: 'leaf', view: 'dashboard', label: 'Dashboard' }],
|
||
},
|
||
{
|
||
group: 'Site',
|
||
items: [
|
||
{ kind: 'parent', id: 'homepage', label: 'Homepage', children: [
|
||
{ kind: 'leaf', view: 'homepage:hero', label: 'Hero' },
|
||
{ kind: 'leaf', view: 'homepage:share', label: 'Share Section' },
|
||
{ kind: 'leaf', view: 'homepage:prism', label: 'PRISM Block' },
|
||
{ kind: 'leaf', view: 'homepage:where-to-next', label: 'Where to Next' },
|
||
]},
|
||
{ kind: 'leaf', view: 'start-here', label: 'Start Here' },
|
||
{ kind: 'parent', id: 'about', label: 'About', children: [
|
||
{ kind: 'leaf', view: 'about:show-copy', label: 'Show Copy' },
|
||
{ kind: 'leaf', view: 'about:bio', label: 'Nate Bio' },
|
||
{ kind: 'leaf', view: 'about:testimonials', label: 'Testimonials' },
|
||
]},
|
||
{ kind: 'parent', id: 'contact', label: 'Contact', children: [
|
||
{ kind: 'leaf', view: 'contact:profile', label: 'Profile' },
|
||
{ kind: 'leaf', view: 'contact:copy', label: 'Copy & Scripture' },
|
||
]},
|
||
],
|
||
},
|
||
{
|
||
group: 'Content',
|
||
items: [
|
||
{ kind: 'parent', id: 'podcast', label: 'Podcast Hub', children: [
|
||
{ kind: 'leaf', view: 'podcast:current-series', label: 'Current Series' },
|
||
{ kind: 'leaf', view: 'podcast:highlights', label: 'Ep. Highlights' },
|
||
{ kind: 'leaf', view: 'podcast:finished-books', label: 'Finished Books' },
|
||
{ kind: 'leaf', view: 'podcast:scripts', label: 'Episode Scripts' },
|
||
{ kind: 'leaf', view: 'podcast:rss', label: 'RSS Feed' },
|
||
]},
|
||
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
|
||
{ kind: 'leaf', view: 'downloads', label: 'Resources' },
|
||
{ kind: 'leaf', view: 'custom-links', label: 'Custom Links' },
|
||
{ kind: 'leaf', view: 'content-blocks', label: 'Content Blocks' },
|
||
{ kind: 'leaf', view: 'books', label: 'Books' },
|
||
{ kind: 'leaf', view: 'recommended-resources', label: 'Recommended Resources' },
|
||
],
|
||
},
|
||
{
|
||
group: 'Audience',
|
||
items: [
|
||
{ kind: 'leaf', view: 'questions', label: 'Questions' },
|
||
{ kind: 'leaf', view: 'study-comments', label: 'Study Comments' },
|
||
{ kind: 'leaf', view: 'subscribers', label: 'Subscribers' },
|
||
{ kind: 'leaf', view: 'broadcast', label: 'Broadcast Email' },
|
||
{ kind: 'leaf', view: 'study-users', label: 'Study Users' },
|
||
],
|
||
},
|
||
{
|
||
group: 'Insights & Tools',
|
||
items: [
|
||
{ kind: 'leaf', view: 'analytics', label: 'Analytics' },
|
||
{ kind: 'leaf', view: 'assets', label: 'Asset Manager' },
|
||
{ kind: 'leaf', view: 'qr-codes', label: 'QR Codes' },
|
||
],
|
||
},
|
||
{
|
||
group: 'Configure',
|
||
items: [
|
||
{ kind: 'leaf', view: 'global', label: 'Footer & Global' },
|
||
{ kind: 'leaf', view: 'seo', label: 'SEO & Redirects' },
|
||
{ kind: 'leaf', view: 'email-templates', label: 'Email Templates' },
|
||
{ kind: 'leaf', view: 'webhooks', label: 'Webhooks' },
|
||
{ kind: 'leaf', view: 'legal', label: 'Legal Pages' },
|
||
{ kind: 'leaf', view: 'security', label: 'Security' },
|
||
{ kind: 'leaf', view: 'brand', label: 'Brand Kit' },
|
||
],
|
||
},
|
||
]
|
||
|
||
function getNavParentId(view: AdminView): string | null {
|
||
for (const group of ADMIN_NAV) {
|
||
for (const item of group.items) {
|
||
if (item.kind === 'parent' && item.children.some(c => c.view === view)) return item.id
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function getNavBreadcrumb(view: AdminView): { parent?: string; label: string } {
|
||
for (const group of ADMIN_NAV) {
|
||
for (const item of group.items) {
|
||
if (item.kind === 'parent') {
|
||
const child = item.children.find(c => c.view === view)
|
||
if (child) return { parent: item.label, label: child.label }
|
||
} else if (item.view === view) {
|
||
return { label: item.label }
|
||
}
|
||
}
|
||
}
|
||
return { label: String(view) }
|
||
}
|
||
|
||
const ADMIN_SECTION_LINKS: Partial<Record<AdminView, AdminSectionLink[]>> = {
|
||
downloads: [
|
||
{ id: 'downloads-study-guide', label: 'Study Guide' },
|
||
{ id: 'downloads-library', label: 'Download Library' },
|
||
],
|
||
global: [
|
||
{ id: 'global-footer', label: 'Footer' },
|
||
{ id: 'global-header', label: 'Header' },
|
||
{ id: 'global-platforms', label: 'Platform URLs' },
|
||
{ id: 'global-banner', label: 'Analytics Banner' },
|
||
{ id: 'global-welcome-email', label: 'Welcome Email' },
|
||
],
|
||
seo: [
|
||
{ id: 'seo-metadata', label: 'Metadata' },
|
||
{ id: 'seo-redirects', label: 'Redirects' },
|
||
],
|
||
legal: [
|
||
{ id: 'legal-privacy', label: 'Privacy' },
|
||
{ id: 'legal-terms', label: 'Terms' },
|
||
],
|
||
}
|
||
|
||
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates'
|
||
|
||
const BRAND_KIT_SWATCHES = [
|
||
{ name: 'Rich Black', hex: '#0a0a08', role: 'Primary background' },
|
||
{ name: 'Soft Black', hex: '#0f0f0c', role: 'Secondary surfaces' },
|
||
{ name: 'Panel Black', hex: '#1a1a15', role: 'Cards and panels' },
|
||
{ name: 'Deep Brown', hex: '#2a2518', role: 'Borders and dividers' },
|
||
{ name: 'Antique Gold', hex: '#c9a84c', role: 'Primary accent' },
|
||
{ name: 'Light Gold', hex: '#e0c070', role: 'Hover and highlight' },
|
||
{ name: 'Warm White', hex: '#f0ead8', role: 'Primary text' },
|
||
{ name: 'Warm Gray', hex: '#7a7060', role: 'Secondary text' },
|
||
] as const
|
||
|
||
const BRAND_KIT_TYPE = [
|
||
{
|
||
label: 'Display / Heading Font',
|
||
spec: 'Cormorant Garamond · 300/400/600/700 · italic supported · for H1, H2, display text',
|
||
sample: 'Verse by Verse with Nate',
|
||
},
|
||
{
|
||
label: 'Body Font',
|
||
spec: 'Lora · 400/500/600 · italic supported · for paragraphs, labels, nav, and UI text',
|
||
sample: 'Verse by verse. Nugget by nugget.',
|
||
},
|
||
{
|
||
label: 'Subtitle / Small Caps',
|
||
spec: 'Lora uppercase with tracking for eyebrows, labels, and support text',
|
||
sample: 'A Journey Through Scripture',
|
||
},
|
||
] as const
|
||
|
||
const BRAND_KIT_VERIFICATION = [
|
||
'Headings use Cormorant Garamond (300–700, italic) for display elegance.',
|
||
'Body text uses Lora (400–600, italic) for screen readability.',
|
||
'Shared brand variables define black, gold, border, warm white, and muted text colors.',
|
||
'Live site CSS references brand variables for core text and accent styling.',
|
||
'Admin surfaces inherit the same typography and palette tokens used by the public site.',
|
||
] as const
|
||
|
||
const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; section: MainContentSection }> = [
|
||
{ key: 'podcastArtUrl', label: 'Hero — Podcast Art Image URL', section: 'hero' },
|
||
{ key: 'eyebrow', label: 'Hero Eyebrow Text', section: 'hero' },
|
||
{ key: 'heroTagline', label: 'Hero Tagline', section: 'hero' },
|
||
{ key: 'heroBtnSpotify', label: 'Hero — Spotify Button Label', section: 'hero' },
|
||
{ key: 'heroBtnEpisodes', label: 'Hero — Episodes Button Label', section: 'hero' },
|
||
{ key: 'heroBtnStartHere', label: 'Hero — Start Here Button Label', section: 'hero' },
|
||
{ key: 'startHereHeading', label: 'Start Here — Heading', section: 'start-here' },
|
||
{ key: 'startHereIntro', label: 'Start Here — Intro', multiline: true, section: 'start-here' },
|
||
{ key: 'startHereStep1Title', label: 'Start Here — Step 1 Title', section: 'start-here' },
|
||
{ key: 'startHereStep1Body', label: 'Start Here — Step 1 Body', multiline: true, section: 'start-here' },
|
||
{ key: 'startHereStep1Cta', label: 'Start Here — Step 1 Button Text', section: 'start-here' },
|
||
{ key: 'startHereStep2Title', label: 'Start Here — Step 2 Title', section: 'start-here' },
|
||
{ key: 'startHereStep2Body', label: 'Start Here — Step 2 Body', multiline: true, section: 'start-here' },
|
||
{ key: 'startHereStep2Cta', label: 'Start Here — Step 2 Button Text', section: 'start-here' },
|
||
{ key: 'startHereStep3Title', label: 'Start Here — Step 3 Title', section: 'start-here' },
|
||
{ key: 'startHereStep3Body', label: 'Start Here — Step 3 Body', multiline: true, section: 'start-here' },
|
||
{ key: 'startHereStep3Cta', label: 'Start Here — Step 3 Button Text', section: 'start-here' },
|
||
{ key: 'aboutShowHeading', label: 'About Show — Heading', section: 'about' },
|
||
{ key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true, section: 'about' },
|
||
{ key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true, section: 'about' },
|
||
{ key: 'aboutNate', label: 'About Nate', multiline: true, section: 'about' },
|
||
{ key: 'aboutPhotoUrl', label: 'About — Nate Portrait Image URL', section: 'about' },
|
||
{ key: 'aboutVerseArtUrl', label: 'About — Scripture Artwork Image URL', section: 'about' },
|
||
{ key: 'aboutEyebrow', label: 'About — "About Nate" Eyebrow', section: 'about' },
|
||
{ key: 'aboutListenBtnLabel', label: 'About — Listen Button Label', section: 'about' },
|
||
{ key: 'aboutShowEyebrow', label: 'About — "About the Show" Eyebrow', section: 'about' },
|
||
{ key: 'contactPhotoUrl', label: 'Contact — Profile Photo URL', section: 'contact' },
|
||
{ key: 'contactEyebrow', label: 'Contact — Section Eyebrow', section: 'contact' },
|
||
{ key: 'contactHeading', label: 'Contact — Heading', section: 'contact' },
|
||
{ key: 'contactName', label: 'Contact — Profile Name', section: 'contact' },
|
||
{ key: 'contactRole', label: 'Contact — Profile Role', section: 'contact' },
|
||
{ key: 'contactQuote', label: 'Contact — Quote', multiline: true, section: 'contact' },
|
||
{ key: 'contactIntro', label: 'Contact — Intro Text', multiline: true, section: 'contact' },
|
||
{ key: 'contactPoint1', label: 'Contact — Point 1', section: 'contact' },
|
||
{ key: 'contactPoint2', label: 'Contact — Point 2', section: 'contact' },
|
||
{ key: 'contactVerse', label: 'Contact — Scripture Text', section: 'contact' },
|
||
{ key: 'contactVerseRef', label: 'Contact — Scripture Reference', section: 'contact' },
|
||
{ key: 'episodesSeoIntro', label: 'Episodes Page — SEO Description (hidden from visitors, indexed by search engines)', multiline: true, section: 'series' },
|
||
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' },
|
||
{ key: 'seriesTitle', label: 'Series Title', section: 'series' },
|
||
{ key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' },
|
||
{ key: 'seriesImageUrl', label: 'Series Cover Image URL', section: 'series' },
|
||
{ key: 'seriesListenUrl', label: 'Series Listen URL', section: 'series' },
|
||
{ key: 'seriesListenBtnLabel', label: 'Series — Listen Button Label', section: 'series' },
|
||
{ key: 'seriesDownloadsBtnLabel', label: 'Series — Resources Button Label', section: 'series' },
|
||
{ key: 'studyGuideTitle', label: 'Study Guide Title', section: 'series' },
|
||
{ key: 'studyGuideDescription', label: 'Study Guide Description', multiline: true, section: 'series' },
|
||
{ key: 'studyGuideUrl', label: 'Study Guide URL (Amazon link)', section: 'series' },
|
||
{ key: 'shareHeading', label: 'Share Section — Heading', section: 'share' },
|
||
{ key: 'shareP', label: 'Share Section — Paragraph', multiline: true, section: 'share' },
|
||
{ key: 'shareQrImageUrl', label: 'Share Section — QR Code Image', section: 'share' },
|
||
{ key: 'prismVideoUrl', label: 'PRISM — Video URL', section: 'prism' },
|
||
{ key: 'prismEyebrow', label: 'PRISM — Eyebrow', section: 'prism' },
|
||
{ key: 'prismHeading', label: 'PRISM — Heading', section: 'prism' },
|
||
{ key: 'prismIntro', label: 'PRISM — Intro Text', multiline: true, section: 'prism' },
|
||
{ key: 'prismPatternIntro', label: 'PRISM — Pattern Intro', multiline: true, section: 'prism' },
|
||
{ key: 'prismStep1', label: 'PRISM — Step 1', section: 'prism' },
|
||
{ key: 'prismStep2', label: 'PRISM — Step 2', section: 'prism' },
|
||
{ key: 'prismStep3', label: 'PRISM — Step 3', section: 'prism' },
|
||
{ key: 'prismStep4', label: 'PRISM — Step 4', section: 'prism' },
|
||
{ key: 'prismStep5', label: 'PRISM — Step 5', section: 'prism' },
|
||
{ key: 'prismOutro', label: 'PRISM — Outro Text', multiline: true, section: 'prism' },
|
||
{ key: 'prismClosing', label: 'PRISM — Closing Line', multiline: true, section: 'prism' },
|
||
// Global / Footer / Platform
|
||
{ key: 'footerTitle', label: 'Footer — Brand Title', section: 'global' },
|
||
{ key: 'footerSubtitle', label: 'Footer — Subtitle', section: 'global' },
|
||
{ key: 'footerEmail', label: 'Footer — Contact Email', section: 'global' },
|
||
{ key: 'footerCopyright', label: 'Footer — Copyright Line', section: 'global' },
|
||
{ key: 'footerPrivacyNote', label: 'Footer — Privacy Note', multiline: true, section: 'global' },
|
||
{ key: 'headerFollowLabel', label: 'Header — Follow Button Label', section: 'global' },
|
||
{ key: 'cookieBannerText', label: 'Analytics Cookie Banner Text', multiline: true, section: 'global' },
|
||
{ key: 'platformSpotifyUrl', label: 'Platform — Spotify URL', section: 'global' },
|
||
{ key: 'platformAppleUrl', label: 'Platform — Apple Podcasts URL', section: 'global' },
|
||
{ key: 'platformYoutubeUrl', label: 'Platform — YouTube URL', section: 'global' },
|
||
{ key: 'platformAmazonUrl', label: 'Platform — Amazon Music URL', section: 'global' },
|
||
{ key: 'platformFacebookUrl', label: 'Platform — Facebook URL', section: 'global' },
|
||
{ key: 'platformCreatorUrl', label: 'Platform — Creator Profile URL', section: 'global' },
|
||
{ key: 'platformKofiUrl', label: 'Platform — Ko-fi URL', section: 'global' },
|
||
{ key: 'platformKofiQrUrl', label: 'Platform — Ko-fi QR Code Image URL', section: 'global' },
|
||
{ key: 'welcomeEmailSubject', label: 'Welcome Email — Subject', section: 'global' },
|
||
{ key: 'welcomeEmailGreetingPrefix', label: 'Welcome Email — Greeting Prefix', section: 'global' },
|
||
{ key: 'welcomeEmailIntro', label: 'Welcome Email — Intro Paragraph', multiline: true, section: 'global' },
|
||
{ key: 'welcomeEmailCurrentSeries', label: 'Welcome Email — Current Series Paragraph', multiline: true, section: 'global' },
|
||
{ key: 'welcomeEmailStartHereTitle', label: 'Welcome Email — Start Here Title', section: 'global' },
|
||
{ key: 'welcomeEmailStartHereSummary', label: 'Welcome Email — Start Here Summary', multiline: true, section: 'global' },
|
||
{ key: 'welcomeEmailStartHereUrl', label: 'Welcome Email — Start Here URL', section: 'global' },
|
||
{ key: 'welcomeEmailSpotifyUrl', label: 'Welcome Email — Spotify URL', section: 'global' },
|
||
{ key: 'welcomeEmailAppleUrl', label: 'Welcome Email — Apple URL', section: 'global' },
|
||
{ key: 'welcomeEmailAmazonUrl', label: 'Welcome Email — Amazon URL', section: 'global' },
|
||
{ key: 'welcomeEmailWebsiteUrl', label: 'Welcome Email — Website URL', section: 'global' },
|
||
{ key: 'welcomeEmailImageUrl', label: 'Welcome Email — Image URL', section: 'global' },
|
||
{ key: 'welcomeEmailWhatToExpect1', label: 'Welcome Email — What to Expect 1', multiline: true, section: 'global' },
|
||
{ key: 'welcomeEmailWhatToExpect2', label: 'Welcome Email — What to Expect 2', multiline: true, section: 'global' },
|
||
{ key: 'welcomeEmailWhatToExpect3', label: 'Welcome Email — What to Expect 3', multiline: true, section: 'global' },
|
||
{ key: 'welcomeEmailScripture', label: 'Welcome Email — Scripture Text', multiline: true, section: 'global' },
|
||
{ key: 'welcomeEmailScriptureRef', label: 'Welcome Email — Scripture Reference', section: 'global' },
|
||
{ key: 'welcomeEmailSignoff', label: 'Signoff', multiline: true, section: 'global' },
|
||
// ── Email Templates section ──
|
||
{ key: 'welcomeEmailSubject', label: 'Subject Line', section: 'email-templates' },
|
||
{ key: 'welcomeEmailGreetingPrefix', label: 'Greeting Prefix (before subscriber name)', section: 'email-templates' },
|
||
{ key: 'welcomeEmailIntro', label: 'Intro Paragraph', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailCurrentSeries', label: 'Current Series Blurb', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailStartHereTitle', label: 'Start Here Episode Title', section: 'email-templates' },
|
||
{ key: 'welcomeEmailStartHereSummary', label: 'Start Here Episode Summary', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailStartHereUrl', label: 'Start Here Episode URL', section: 'email-templates' },
|
||
{ key: 'welcomeEmailWhatToExpect1', label: 'What to Expect — Line 1', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailWhatToExpect2', label: 'What to Expect — Line 2', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailWhatToExpect3', label: 'What to Expect — Line 3', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailScripture', label: 'Closing Scripture Text', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailScriptureRef', label: 'Closing Scripture Reference', section: 'email-templates' },
|
||
{ key: 'welcomeEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailSpotifyUrl', label: 'Spotify URL', section: 'email-templates' },
|
||
{ key: 'welcomeEmailSpotifyBtnLabel', label: '"Listen on Spotify" button label', section: 'email-templates' },
|
||
{ key: 'welcomeEmailAppleUrl', label: 'Apple Podcasts URL', section: 'email-templates' },
|
||
{ key: 'welcomeEmailAppleBtnLabel', label: '"Apple Podcasts" button label', section: 'email-templates' },
|
||
{ key: 'welcomeEmailAmazonUrl', label: 'Amazon Music URL', section: 'email-templates' },
|
||
{ key: 'welcomeEmailStartHereLinkLabel', label: '"Start Here" link label', section: 'email-templates' },
|
||
{ key: 'studyWelcomeEmailSubject', label: 'Subject Line', section: 'email-templates' },
|
||
{ key: 'studyWelcomeEmailBody', label: 'Body Paragraph', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyWelcomeEmailCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||
{ key: 'studyWelcomeEmailCtaPath', label: 'Button Link (path or full URL)', section: 'email-templates' },
|
||
{ key: 'studyWelcomeEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyDeletedEmailSubject', label: 'Subject Line', section: 'email-templates' },
|
||
{ key: 'studyDeletedEmailBody', label: 'Body Paragraph', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyDeletedEmailCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||
{ key: 'studyDeletedEmailCtaPath', label: 'Button Link (path or full URL)', section: 'email-templates' },
|
||
{ key: 'studyDeletedEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyReminderEmailSubjectPrefix', label: 'Subject Prefix (lesson title auto-appended)', section: 'email-templates' },
|
||
{ key: 'studyReminderEmailBody', label: 'Body Paragraph', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyReminderEmailCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||
{ key: 'studyReminderEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyReengagementDays', label: 'Inactivity thresholds — comma-separated days for each tier (e.g. "7,14,30")', section: 'email-templates' },
|
||
{ key: 'studyReengagementT1Subject', label: 'Tier 1 — Subject Line', section: 'email-templates' },
|
||
{ key: 'studyReengagementT1Body', label: 'Tier 1 — Body', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyReengagementT1Cta', label: 'Tier 1 — Button Label', section: 'email-templates' },
|
||
{ key: 'studyReengagementT2Subject', label: 'Tier 2 — Subject Line', section: 'email-templates' },
|
||
{ key: 'studyReengagementT2Body', label: 'Tier 2 — Body', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyReengagementT2Cta', label: 'Tier 2 — Button Label', section: 'email-templates' },
|
||
{ key: 'studyReengagementT3Subject', label: 'Tier 3 — Subject Line', section: 'email-templates' },
|
||
{ key: 'studyReengagementT3Body', label: 'Tier 3 — Body', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyReengagementT3Cta', label: 'Tier 3 — Button Label', section: 'email-templates' },
|
||
{ key: 'studyCertificateEmailSubject', label: 'Subject Line', section: 'email-templates' },
|
||
{ key: 'studyCertificateEmailBody', label: 'Body Paragraph', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyCertificateEmailCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||
{ key: 'studyCertificateEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyWeeklyDigestEmailSubject', label: 'Subject Line', section: 'email-templates' },
|
||
{ key: 'studyWeeklyDigestEmailBody', label: 'Intro Paragraph', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyWeeklyDigestEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
|
||
{ key: 'emailChangeSubject', label: 'Subject Line', section: 'email-templates' },
|
||
{ key: 'emailChangeBody', label: 'Body Text', multiline: true, section: 'email-templates' },
|
||
{ key: 'emailChangeCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||
{ key: 'twoFaOtpEmailSubject', label: 'Subject Line', section: 'email-templates' },
|
||
{ key: 'twoFaOtpEmailBody', label: 'Body Text (shown above the code)', multiline: true, section: 'email-templates' },
|
||
{ key: 'twoFaOtpEmailExpiry', label: 'Expiry / Security Note (shown below the code)', multiline: true, section: 'email-templates' },
|
||
{ key: 'qaAnsweredEmailSubject', label: 'Subject Line', section: 'email-templates' },
|
||
{ key: 'qaAnsweredEmailBody', label: 'Body Text (paragraph shown below the greeting)', multiline: true, section: 'email-templates' },
|
||
{ key: 'qaAnsweredEmailCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||
{ key: 'qaAnsweredEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
|
||
]
|
||
|
||
function normalizeStudies(siteContent: SiteContent): StudyProgram[] {
|
||
if (Array.isArray(siteContent.studies) && siteContent.studies.length > 0) {
|
||
return siteContent.studies.map(study => ({
|
||
...study,
|
||
homepageEyebrow: typeof study.homepageEyebrow === 'string' ? study.homepageEyebrow : '',
|
||
showOnHomepage: study.showOnHomepage === true,
|
||
showNewTag: study.showNewTag === true,
|
||
newTagLabel: typeof study.newTagLabel === 'string' ? study.newTagLabel : 'NEW',
|
||
numberOfChapters: Number.isInteger(study.numberOfChapters) && study.numberOfChapters >= 1 && study.numberOfChapters <= 999 ? study.numberOfChapters : 1,
|
||
}))
|
||
}
|
||
|
||
const fallbackSections = siteContent.colossiansStudySections?.length
|
||
? siteContent.colossiansStudySections
|
||
: DEFAULTS.colossiansStudySections
|
||
|
||
return [
|
||
{
|
||
id: 'study-colossians',
|
||
slug: 'colossians',
|
||
title: 'Colossians: Rooted in Christ',
|
||
description: 'Walk through Colossians in guided lessons with commentary, Greek notes, and discussion prompts.',
|
||
homepageEyebrow: 'New Study',
|
||
showOnHomepage: true,
|
||
showNewTag: true,
|
||
newTagLabel: 'NEW',
|
||
status: 'active',
|
||
difficulty: 'intermediate',
|
||
estimatedHours: 12,
|
||
completionBadge: 'Colossians Completion',
|
||
numberOfChapters: 4,
|
||
sections: fallbackSections,
|
||
},
|
||
]
|
||
}
|
||
|
||
function normalizeSiteContentForAdmin(siteContent: SiteContent): SiteContent {
|
||
const studies = normalizeStudies(siteContent)
|
||
const colossians = studies.find(study => study.slug === 'colossians')
|
||
|
||
return {
|
||
...siteContent,
|
||
studies,
|
||
colossiansStudySections: colossians?.sections?.length
|
||
? colossians.sections
|
||
: (siteContent.colossiansStudySections?.length ? siteContent.colossiansStudySections : DEFAULTS.colossiansStudySections),
|
||
}
|
||
}
|
||
|
||
function buildSiteContentForSave(siteContent: SiteContent): SiteContent {
|
||
const normalized = normalizeSiteContentForAdmin(siteContent)
|
||
const colossians = normalized.studies.find(study => study.slug === 'colossians')
|
||
return {
|
||
...normalized,
|
||
colossiansStudySections: colossians?.sections ?? normalized.colossiansStudySections,
|
||
}
|
||
}
|
||
|
||
function QrCodeImage({ url, label }: { url: string; label: string }) {
|
||
const canvasRef = React.useRef<HTMLCanvasElement>(null)
|
||
const [dataUrl, setDataUrl] = React.useState<string>('')
|
||
|
||
React.useEffect(() => {
|
||
const canvas = canvasRef.current
|
||
if (!canvas) return
|
||
QRCode.toCanvas(canvas, url, { width: 256, margin: 2, color: { dark: '#000000', light: '#ffffff' } }, (err: Error | null | undefined) => {
|
||
if (err) { console.error('[QR]', err); return }
|
||
setDataUrl(canvas.toDataURL('image/png'))
|
||
})
|
||
}, [url])
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 6, marginTop: 10 }}>
|
||
<canvas ref={canvasRef} style={{ width: 128, height: 128, border: '1px solid var(--admin-border, #ddd)', borderRadius: 4 }} />
|
||
{dataUrl && (
|
||
<a
|
||
href={dataUrl}
|
||
download={`qr-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.png`}
|
||
className="btn-admin-reset"
|
||
style={{ fontSize: '0.8em' }}
|
||
>
|
||
Download PNG
|
||
</a>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||
const normalizedContent = normalizeSiteContentForAdmin(content)
|
||
const [form, setForm] = useState<SiteContent>(normalizedContent)
|
||
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(normalizedContent))
|
||
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||
const [errorMsg, setErrorMsg] = useState('')
|
||
const [adminView, setAdminView] = useState<AdminView>('dashboard')
|
||
const [navOpenGroups, setNavOpenGroups] = useState<Set<string>>(new Set())
|
||
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[]>([])
|
||
const [selectedBackup, setSelectedBackup] = useState('')
|
||
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
|
||
const [fullBackupBusy, setFullBackupBusy] = useState(false)
|
||
|
||
// TOTP management state
|
||
const [totpEnabled, setTotpEnabled] = useState<boolean | null>(null)
|
||
const [appVersion, setAppVersion] = useState<string>('')
|
||
const [totpSetupQr, setTotpSetupQr] = useState<string | null>(null)
|
||
const [totpSetupSecret, setTotpSetupSecret] = useState<string | null>(null)
|
||
const [totpConfirmCode, setTotpConfirmCode] = useState('')
|
||
const [totpMsg, setTotpMsg] = useState('')
|
||
const [totpRecoveryCodes, setTotpRecoveryCodes] = useState<string[] | null>(null)
|
||
const [publishState, setPublishState] = useState<PublishState>({ draftUpdatedAt: null, publishedAt: null })
|
||
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false)
|
||
const [assets, setAssets] = useState<AdminAsset[]>([])
|
||
const [assetTagEdits, setAssetTagEdits] = useState<Record<string, string>>({})
|
||
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
|
||
const [assetUploadPending, setAssetUploadPending] = useState(false)
|
||
|
||
const [endSeriesOpen, setEndSeriesOpen] = useState(false)
|
||
const [endSeriesNewTitle, setEndSeriesNewTitle] = useState('')
|
||
const [endSeriesNewImage, setEndSeriesNewImage] = useState('')
|
||
const [endSeriesNewSeason, setEndSeriesNewSeason] = useState('')
|
||
|
||
const [questions, setQuestions] = useState<Question[]>([])
|
||
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
|
||
const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
|
||
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null)
|
||
const [questionSearch, setQuestionSearch] = useState('')
|
||
const [questionFilter, setQuestionFilter] = useState<'all' | 'pending' | 'approved' | 'answered' | 'unanswered'>('all')
|
||
const [questionPage, setQuestionPage] = useState(0)
|
||
const [selectedQuestionIds, setSelectedQuestionIds] = useState<Set<string>>(new Set())
|
||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||
const [navSearch, setNavSearch] = useState('')
|
||
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
|
||
const [subscriberSearch, setSubscriberSearch] = 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('')
|
||
|
||
// QR codes state
|
||
interface QrCode { id: string; slug: string; label: string; destination: string; active: boolean; createdAt: string; scanCount?: number }
|
||
interface QrScan { id: string; qrId: string; slug: string; scannedAt: string; ip: string; userAgent: string }
|
||
const [qrCodes, setQrCodes] = useState<QrCode[]>([])
|
||
const [qrScans, setQrScans] = useState<QrScan[]>([])
|
||
const [qrLoading, setQrLoading] = useState(false)
|
||
const [qrLoaded, setQrLoaded] = useState(false)
|
||
const [qrMsg, setQrMsg] = useState('')
|
||
const [qrNewLabel, setQrNewLabel] = useState('')
|
||
const [qrNewSlug, setQrNewSlug] = useState('')
|
||
const [qrNewDest, setQrNewDest] = useState('')
|
||
const [qrEditId, setQrEditId] = useState<string | null>(null)
|
||
const [qrEditLabel, setQrEditLabel] = useState('')
|
||
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 [selectedHighlightId, setSelectedHighlightId] = useState<string | null>(null)
|
||
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null)
|
||
|
||
// Webhooks state
|
||
interface WebhookEntry { id: string; url: string; events: string[]; createdAt: string }
|
||
const [webhooks, setWebhooks] = useState<WebhookEntry[]>([])
|
||
const [webhooksLoaded, setWebhooksLoaded] = useState(false)
|
||
const [webhookUrl, setWebhookUrl] = useState('')
|
||
const [webhookEvents, setWebhookEvents] = useState('*')
|
||
const [webhookMsg, setWebhookMsg] = useState('')
|
||
|
||
// Broadcast state
|
||
const [broadcastSubject, setBroadcastSubject] = useState('')
|
||
const [broadcastBody, setBroadcastBody] = useState('')
|
||
const [broadcastSending, setBroadcastSending] = useState(false)
|
||
const [broadcastResult, setBroadcastResult] = useState<{ sent: number; failed: number; total: number } | null>(null)
|
||
const [broadcastMsg, setBroadcastMsg] = useState('')
|
||
const [broadcastConfirm, setBroadcastConfirm] = useState(false)
|
||
|
||
const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||
const [manualQuestion, setManualQuestion] = useState({
|
||
firstName: '',
|
||
email: '',
|
||
question: '',
|
||
answer: '',
|
||
approve: false,
|
||
})
|
||
const [manualQuestionStatus, setManualQuestionStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||
const [manualQuestionMsg, setManualQuestionMsg] = useState('')
|
||
const [previewOpen, setPreviewOpen] = useState(false)
|
||
const previewIframeRef = useRef<HTMLIFrameElement>(null)
|
||
const dragSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
|
||
const saveStatusTimeoutRef = useRef<number | null>(null)
|
||
const autosaveRestoreCheckedRef = useRef(false)
|
||
|
||
const isDirty = JSON.stringify(form) !== lastSavedSnapshot
|
||
const unreadEmailCount = contactSubmissions.filter(s => !s.archived).length
|
||
const unansweredCount = questions.filter(q => !q.answer?.trim()).length
|
||
const navSearchTerm = navSearch.trim().toLowerCase()
|
||
const filteredNav: NavGroup[] = ADMIN_NAV
|
||
.map(group => ({
|
||
...group,
|
||
items: group.items
|
||
.map(item => {
|
||
if (item.kind === 'parent') {
|
||
if (!navSearchTerm) return item
|
||
const matchesParent = `${group.group} ${item.label}`.toLowerCase().includes(navSearchTerm)
|
||
const filteredChildren = item.children.filter(c =>
|
||
`${group.group} ${item.label} ${c.label}`.toLowerCase().includes(navSearchTerm)
|
||
)
|
||
if (matchesParent) return item
|
||
if (filteredChildren.length > 0) return { ...item, children: filteredChildren }
|
||
return null
|
||
} else {
|
||
if (!navSearchTerm) return item
|
||
return `${group.group} ${item.label}`.toLowerCase().includes(navSearchTerm) ? item : null
|
||
}
|
||
})
|
||
.filter((item): item is NavEntry => item !== null),
|
||
}))
|
||
.filter(group => group.items.length > 0)
|
||
|
||
// Silently saves draft every 30s when there are unsaved changes
|
||
useAutosave(form, isDirty, handleSaveDraft)
|
||
|
||
function clearAutosaveDraft() {
|
||
try {
|
||
sessionStorage.removeItem('admin-autosave-draft')
|
||
} catch {
|
||
// Ignore storage failures; the server draft is still the source of truth.
|
||
}
|
||
}
|
||
|
||
// Broadcast live form state + active view into the preview iframe whenever they change
|
||
useEffect(() => {
|
||
if (!previewOpen) return
|
||
const timer = setTimeout(() => {
|
||
previewIframeRef.current?.contentWindow?.postMessage(
|
||
{ type: 'admin-preview-content', content: form, view: adminView },
|
||
window.location.origin
|
||
)
|
||
}, 150)
|
||
return () => clearTimeout(timer)
|
||
}, [form, adminView, previewOpen])
|
||
|
||
useEffect(() => {
|
||
const normalized = normalizeSiteContentForAdmin(content)
|
||
const nextSnapshot = JSON.stringify(normalized)
|
||
setForm(normalized)
|
||
setLastSavedSnapshot(nextSnapshot)
|
||
autosaveRestoreCheckedRef.current = false
|
||
}, [content])
|
||
|
||
useEffect(() => {
|
||
if (autosaveRestoreCheckedRef.current) return
|
||
autosaveRestoreCheckedRef.current = true
|
||
|
||
try {
|
||
const rawDraft = sessionStorage.getItem('admin-autosave-draft')
|
||
if (!rawDraft) return
|
||
|
||
const parsedDraft = JSON.parse(rawDraft) as SiteContent
|
||
const restoredDraft = normalizeSiteContentForAdmin(parsedDraft)
|
||
if (JSON.stringify(restoredDraft) === lastSavedSnapshot) {
|
||
clearAutosaveDraft()
|
||
return
|
||
}
|
||
|
||
if (confirm('Restore an unsaved draft from this browser session?')) {
|
||
setForm(restoredDraft)
|
||
}
|
||
} catch {
|
||
clearAutosaveDraft()
|
||
}
|
||
}, [lastSavedSnapshot])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (saveStatusTimeoutRef.current !== null) {
|
||
window.clearTimeout(saveStatusTimeoutRef.current)
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!mobileNavOpen) return
|
||
|
||
const previousOverflow = document.body.style.overflow
|
||
document.body.style.overflow = 'hidden'
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') {
|
||
setMobileNavOpen(false)
|
||
}
|
||
}
|
||
|
||
window.addEventListener('keydown', handleKeyDown)
|
||
|
||
return () => {
|
||
window.removeEventListener('keydown', handleKeyDown)
|
||
document.body.style.overflow = previousOverflow
|
||
}
|
||
}, [mobileNavOpen])
|
||
|
||
useEffect(() => {
|
||
const intervalId = window.setInterval(() => {
|
||
setDashboardNow(new Date())
|
||
}, 1000)
|
||
|
||
return () => window.clearInterval(intervalId)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||
if (!isDirty) return
|
||
event.preventDefault()
|
||
event.returnValue = ''
|
||
}
|
||
|
||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||
}, [isDirty])
|
||
|
||
useEffect(() => {
|
||
fetch('/api/episode-audio')
|
||
.then(r => r.ok ? r.json() : Promise.reject())
|
||
.then((data: { episodes: Array<{ title: string; audioUrl: string; duration: string; episode: string }> }) => {
|
||
setRssEpisodes(data.episodes ?? [])
|
||
})
|
||
.catch(() => {})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
fetch('/api/admin-auth/status')
|
||
.then(r => r.ok ? r.json() : Promise.reject())
|
||
.then((data: { totpEnabled?: boolean; version?: string; commit?: string }) => {
|
||
setTotpEnabled(!!data.totpEnabled)
|
||
if (data.version) setAppVersion(data.commit ? `v${data.version} (${data.commit})` : `v${data.version}`)
|
||
})
|
||
.catch(() => {})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
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)
|
||
setStatsStatus('ready')
|
||
})
|
||
.catch(() => {
|
||
setStatsStatus('error')
|
||
})
|
||
|
||
fetch('/api/admin-questions')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
|
||
.then(data => {
|
||
setQuestions((data as { questions: Question[] }).questions ?? [])
|
||
})
|
||
.catch(() => {})
|
||
|
||
fetch('/api/admin-contact-submissions')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load contact submissions'))))
|
||
.then(data => {
|
||
setContactSubmissions((data as { submissions: ContactSubmission[] }).submissions ?? [])
|
||
})
|
||
.catch(() => {})
|
||
|
||
fetch('/api/admin-stats/backups')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load backups'))))
|
||
.then(data => {
|
||
const files = Array.isArray((data as { backups?: unknown }).backups) ? (data as { backups: BackupPreview[] }).backups : []
|
||
setBackupFiles(files)
|
||
if (files.length > 0) {
|
||
setSelectedBackup(files[0].filename)
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
|
||
fetch('/api/admin-content-state')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load publish state'))))
|
||
.then(data => {
|
||
const state = (data as { publishState?: PublishState }).publishState
|
||
if (state) {
|
||
setPublishState({
|
||
draftUpdatedAt: state.draftUpdatedAt ?? null,
|
||
publishedAt: state.publishedAt ?? null,
|
||
})
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
|
||
void reloadAssets()
|
||
|
||
fetch('/api/admin-ops/status')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load operations status'))))
|
||
.then(data => {
|
||
setOpsStatus(data as OpsStatus)
|
||
})
|
||
.catch(() => {})
|
||
|
||
fetch('/api/admin-subscribers')
|
||
.then(r => (r.ok ? r.json() : Promise.reject()))
|
||
.then(data => setSubscribers((data as { subscribers: Subscriber[] }).subscribers ?? []))
|
||
.catch(() => {})
|
||
|
||
fetch('/api/admin-download-stats')
|
||
.then(r => (r.ok ? r.json() : Promise.reject()))
|
||
.then(data => setDownloadStats((data as { counts: Record<string, number> }).counts ?? {}))
|
||
.catch(() => {})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!selectedBackup) {
|
||
setSelectedBackupPreview(null)
|
||
return
|
||
}
|
||
|
||
fetch('/api/admin-stats/backup-preview', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: selectedBackup }),
|
||
})
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Preview failed'))))
|
||
.then(data => {
|
||
const preview = (data as { preview?: BackupPreview }).preview ?? null
|
||
setSelectedBackupPreview(preview)
|
||
})
|
||
.catch(() => {
|
||
setSelectedBackupPreview(null)
|
||
})
|
||
}, [selectedBackup])
|
||
|
||
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)
|
||
setStatsStatus('ready')
|
||
}
|
||
|
||
|
||
useEffect(() => {
|
||
setQuestionPage(0)
|
||
}, [questionSearch, questionFilter])
|
||
|
||
async function reloadBackups() {
|
||
const r = await fetch('/api/admin-stats/backups')
|
||
if (!r.ok) throw new Error('Could not refresh backups')
|
||
const data = await r.json() as { backups?: BackupPreview[] }
|
||
const files = Array.isArray(data.backups) ? data.backups : []
|
||
setBackupFiles(files)
|
||
const names = files.map(f => f.filename)
|
||
if (files.length > 0 && !names.includes(selectedBackup)) {
|
||
setSelectedBackup(files[0].filename)
|
||
}
|
||
}
|
||
|
||
async function reloadContentFromServer() {
|
||
const draftRes = await fetch('/api/admin-content?source=draft')
|
||
if (draftRes.ok) {
|
||
const draftData = await draftRes.json() as { siteContent?: Partial<SiteContent> }
|
||
if (draftData?.siteContent && typeof draftData.siteContent === 'object') {
|
||
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...draftData.siteContent })
|
||
setForm(next)
|
||
onSave(next)
|
||
return
|
||
}
|
||
}
|
||
|
||
const publishedRes = await fetch('/api/admin-content')
|
||
if (!publishedRes.ok) return
|
||
const publishedData = await publishedRes.json() as { siteContent?: Partial<SiteContent> }
|
||
if (publishedData?.siteContent && typeof publishedData.siteContent === 'object') {
|
||
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...publishedData.siteContent })
|
||
setForm(next)
|
||
onSave(next)
|
||
}
|
||
}
|
||
|
||
async function reloadAssets() {
|
||
const r = await fetch('/api/admin-assets')
|
||
if (!r.ok) throw new Error('Could not refresh assets')
|
||
const data = await r.json() as { assets?: AdminAsset[] }
|
||
const incomingAssets = Array.isArray(data.assets) ? data.assets : []
|
||
setAssets(incomingAssets)
|
||
setAssetTagEdits(incomingAssets.reduce<Record<string, string>>((memo, asset) => {
|
||
memo[asset.filename] = (asset.tags ?? []).join(', ')
|
||
return memo
|
||
}, {}))
|
||
}
|
||
|
||
async function reloadOpsStatus() {
|
||
const r = await fetch('/api/admin-ops/status')
|
||
if (!r.ok) throw new Error('Could not refresh ops status')
|
||
const data = await r.json() as OpsStatus
|
||
setOpsStatus(data)
|
||
}
|
||
|
||
async function handleSaveDraft() {
|
||
const payload = buildSiteContentForSave(form)
|
||
const formSnapshot = JSON.stringify(form)
|
||
setStatus('saving')
|
||
setErrorMsg('')
|
||
try {
|
||
const res = await fetch('/api/admin-content-draft', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ siteContent: payload }),
|
||
})
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Draft save failed')
|
||
}
|
||
const data = await res.json() as { updatedAt?: string }
|
||
setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() }))
|
||
setLastSavedSnapshot(formSnapshot)
|
||
clearAutosaveDraft()
|
||
setStatus('saved')
|
||
if (saveStatusTimeoutRef.current !== null) {
|
||
window.clearTimeout(saveStatusTimeoutRef.current)
|
||
}
|
||
saveStatusTimeoutRef.current = window.setTimeout(() => setStatus('idle'), 3500)
|
||
} catch (err) {
|
||
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
|
||
setStatus('error')
|
||
}
|
||
}
|
||
|
||
async function handlePublishDraft() {
|
||
const payload = buildSiteContentForSave(form)
|
||
setStatus('saving')
|
||
setErrorMsg('')
|
||
try {
|
||
// Always save the current form to draft first so publish never fails from a missing draft
|
||
const draftRes = await fetch('/api/admin-content-draft', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ siteContent: payload }),
|
||
})
|
||
if (!draftRes.ok) {
|
||
const data = await draftRes.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Draft save failed')
|
||
}
|
||
const draftData = await draftRes.json() as { updatedAt?: string }
|
||
setPublishState(prev => ({ ...prev, draftUpdatedAt: draftData.updatedAt ?? new Date().toISOString() }))
|
||
|
||
const publishRes = await fetch('/api/admin-content/publish', { method: 'POST' })
|
||
if (!publishRes.ok) {
|
||
const data = await publishRes.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Publish failed')
|
||
}
|
||
|
||
const published = await publishRes.json() as { publishedAt?: string }
|
||
const latestRes = await fetch('/api/admin-content?source=draft')
|
||
if (!latestRes.ok) throw new Error('Failed to refresh published content')
|
||
const latest = await latestRes.json() as { siteContent?: Partial<SiteContent> }
|
||
if (latest?.siteContent) {
|
||
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...latest.siteContent })
|
||
setForm(next)
|
||
onSave(next)
|
||
setLastSavedSnapshot(JSON.stringify(next))
|
||
}
|
||
|
||
setPublishState(prev => ({ ...prev, publishedAt: published.publishedAt ?? new Date().toISOString() }))
|
||
setPublishConfirmOpen(false)
|
||
clearAutosaveDraft()
|
||
await reloadStats()
|
||
setStatus('saved')
|
||
if (saveStatusTimeoutRef.current !== null) {
|
||
window.clearTimeout(saveStatusTimeoutRef.current)
|
||
}
|
||
saveStatusTimeoutRef.current = window.setTimeout(() => setStatus('idle'), 3500)
|
||
} catch (err) {
|
||
setPublishConfirmOpen(false)
|
||
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
|
||
setStatus('error')
|
||
}
|
||
}
|
||
|
||
function updateSeoField(field: keyof SeoSettings, value: string | string[]) {
|
||
setForm(f => ({
|
||
...f,
|
||
seo: {
|
||
...f.seo,
|
||
[field]: value,
|
||
},
|
||
}))
|
||
}
|
||
|
||
function updateLegalField(field: keyof LegalSettings, value: string | string[]) {
|
||
setForm(f => ({
|
||
...f,
|
||
legal: {
|
||
...f.legal,
|
||
[field]: value,
|
||
},
|
||
}))
|
||
}
|
||
|
||
async function handleTotpSetupInit() {
|
||
setTotpMsg('')
|
||
setTotpRecoveryCodes(null)
|
||
const res = await fetch('/api/admin-auth/totp-setup-init', { method: 'POST' })
|
||
const data = await res.json().catch(() => ({})) as { qrDataUrl?: string; secret?: string; message?: string }
|
||
if (!res.ok) { setTotpMsg(data.message ?? 'Setup failed.'); return }
|
||
setTotpSetupQr(data.qrDataUrl ?? null)
|
||
setTotpSetupSecret(data.secret ?? null)
|
||
setTotpConfirmCode('')
|
||
}
|
||
|
||
async function handleTotpSetupConfirm(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
setTotpMsg('')
|
||
const res = await fetch('/api/admin-auth/totp-setup-confirm', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ code: totpConfirmCode }),
|
||
})
|
||
const data = await res.json().catch(() => ({})) as { ok?: boolean; recoveryCodes?: string[]; message?: string }
|
||
if (!res.ok) { setTotpMsg(data.message ?? 'Confirmation failed.'); return }
|
||
setTotpEnabled(true)
|
||
setTotpSetupQr(null)
|
||
setTotpSetupSecret(null)
|
||
setTotpConfirmCode('')
|
||
setTotpRecoveryCodes(data.recoveryCodes ?? null)
|
||
setTotpMsg('Two-factor authentication enabled.')
|
||
}
|
||
|
||
async function handleTotpDisable() {
|
||
if (!confirm('Disable two-factor authentication? This will make your admin less secure.')) return
|
||
setTotpMsg('')
|
||
const res = await fetch('/api/admin-auth/totp-disable', { method: 'POST' })
|
||
if (res.ok) { setTotpEnabled(false); setTotpRecoveryCodes(null); setTotpMsg('Two-factor authentication disabled.') }
|
||
else { const d = await res.json().catch(() => ({})) as { message?: string }; setTotpMsg(d.message ?? 'Failed to disable TOTP.') }
|
||
}
|
||
|
||
async function handleTotpRegenRecovery() {
|
||
if (!confirm('Regenerate recovery codes? Your old codes will stop working immediately.')) return
|
||
setTotpMsg('')
|
||
const res = await fetch('/api/admin-auth/totp-regen-recovery', { method: 'POST' })
|
||
const data = await res.json().catch(() => ({})) as { ok?: boolean; recoveryCodes?: string[]; message?: string }
|
||
if (!res.ok) { setTotpMsg(data.message ?? 'Failed.'); return }
|
||
setTotpRecoveryCodes(data.recoveryCodes ?? null)
|
||
setTotpMsg('New recovery codes generated. Save these now.')
|
||
}
|
||
|
||
function addRedirectRule() {
|
||
setForm(f => ({
|
||
...f,
|
||
redirects: [
|
||
...(f.redirects ?? []),
|
||
{
|
||
id: Date.now().toString(36),
|
||
path: '/new-short-link',
|
||
target: 'https://',
|
||
statusCode: 301,
|
||
},
|
||
],
|
||
}))
|
||
}
|
||
|
||
function updateRedirectRule(id: string, field: keyof RedirectRule, value: string | number) {
|
||
setForm(f => ({
|
||
...f,
|
||
redirects: (f.redirects ?? []).map(rule => rule.id === id
|
||
? {
|
||
...rule,
|
||
[field]: field === 'statusCode' ? (Number(value) === 302 ? 302 : 301) : value,
|
||
}
|
||
: rule),
|
||
}))
|
||
}
|
||
|
||
function removeRedirectRule(id: string) {
|
||
setForm(f => ({ ...f, redirects: (f.redirects ?? []).filter(rule => rule.id !== id) }))
|
||
}
|
||
|
||
function addPodcastLink() {
|
||
setForm(f => ({
|
||
...f,
|
||
podcastFeaturedLinks: [
|
||
...(f.podcastFeaturedLinks ?? []),
|
||
{
|
||
id: Date.now().toString(36),
|
||
title: '',
|
||
episodeNumber: '',
|
||
summary: '',
|
||
url: '',
|
||
embedUrl: '',
|
||
showNotesSummary: '',
|
||
showNotesThemes: [],
|
||
showNotesScripture: [],
|
||
showNotesQuotes: [],
|
||
showNotesResources: [],
|
||
discussionQuestions: [],
|
||
},
|
||
],
|
||
}))
|
||
}
|
||
|
||
function updatePodcastLink(id: string, field: keyof PodcastFeaturedLink, value: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link => link.id === id ? { ...link, [field]: value } : link),
|
||
}))
|
||
}
|
||
|
||
function updatePodcastLinkStringArray(id: string, field: keyof PodcastFeaturedLink, raw: string) {
|
||
const items = raw.split('\n').map(s => s.trim()).filter(Boolean)
|
||
setForm(f => ({
|
||
...f,
|
||
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link =>
|
||
link.id === id ? { ...link, [field]: items } : link
|
||
),
|
||
}))
|
||
}
|
||
|
||
function updatePodcastLinkQuestions(id: string, raw: string) {
|
||
const questions = raw.split('\n').map(q => q.trim()).filter(Boolean)
|
||
setForm(f => ({
|
||
...f,
|
||
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link =>
|
||
link.id === id ? { ...link, discussionQuestions: questions } : link
|
||
),
|
||
}))
|
||
}
|
||
|
||
function removePodcastLink(id: string) {
|
||
setForm(f => ({ ...f, podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).filter(link => link.id !== id) }))
|
||
}
|
||
|
||
async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||
const file = event.target.files?.[0]
|
||
if (!file) return
|
||
|
||
setAssetUploadPending(true)
|
||
setOpsMsg('')
|
||
try {
|
||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||
const reader = new FileReader()
|
||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||
reader.onerror = () => reject(new Error('Failed to read file'))
|
||
reader.readAsDataURL(file)
|
||
})
|
||
|
||
const res = await fetch('/api/admin-assets', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: file.name, dataUrl }),
|
||
})
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Upload failed')
|
||
}
|
||
await reloadAssets()
|
||
setOpsMsg('Asset uploaded successfully.')
|
||
} catch (err) {
|
||
setOpsMsg(err instanceof Error ? err.message : 'Upload failed.')
|
||
} finally {
|
||
setAssetUploadPending(false)
|
||
event.target.value = ''
|
||
}
|
||
}
|
||
|
||
async function handleDeleteAsset(filename: string) {
|
||
if (!confirm(`Delete asset ${filename}?`)) return
|
||
try {
|
||
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, { method: 'DELETE' })
|
||
if (!res.ok) throw new Error('Delete failed')
|
||
await reloadAssets()
|
||
setOpsMsg('Asset deleted.')
|
||
} catch {
|
||
setOpsMsg('Asset delete failed.')
|
||
}
|
||
}
|
||
|
||
async function handleSaveAssetTags(filename: string) {
|
||
const tagsText = assetTagEdits[filename] ?? ''
|
||
const tags = tagsText.split(',').map(tag => tag.trim()).filter(Boolean)
|
||
try {
|
||
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tags }),
|
||
})
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Save failed')
|
||
}
|
||
await reloadAssets()
|
||
setOpsMsg('Asset tags saved.')
|
||
} catch (err) {
|
||
setOpsMsg(err instanceof Error ? err.message : 'Save failed.')
|
||
}
|
||
}
|
||
|
||
async function handlePurgeCache() {
|
||
try {
|
||
const res = await fetch('/api/admin-ops/purge-cache', { method: 'POST' })
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Cache purge failed')
|
||
}
|
||
await reloadOpsStatus()
|
||
setOpsMsg('Cache purge triggered.')
|
||
} catch (err) {
|
||
setOpsMsg(err instanceof Error ? err.message : 'Cache purge failed.')
|
||
}
|
||
}
|
||
|
||
async function handleDeployHook() {
|
||
try {
|
||
const res = await fetch('/api/admin-ops/deploy', { method: 'POST' })
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Deploy hook failed')
|
||
}
|
||
await reloadOpsStatus()
|
||
setOpsMsg('Deploy hook triggered.')
|
||
} catch (err) {
|
||
setOpsMsg(err instanceof Error ? err.message : 'Deploy hook failed.')
|
||
}
|
||
}
|
||
|
||
function maskIp(ip: string) {
|
||
if (!ip || ip === 'unknown') return 'unknown'
|
||
if (ip.includes('.')) {
|
||
const parts = ip.split('.')
|
||
if (parts.length === 4) return `${parts[0]}.${parts[1]}.x.x`
|
||
}
|
||
if (ip.includes(':')) {
|
||
const parts = ip.split(':')
|
||
return `${parts.slice(0, 3).join(':')}:x:x`
|
||
}
|
||
return ip
|
||
}
|
||
|
||
async function handleExport() {
|
||
try {
|
||
const r = await fetch('/api/admin-stats/export')
|
||
if (!r.ok) throw new Error('Export failed')
|
||
const data = await r.json()
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `siteforge-admin-export-${new Date().toISOString().slice(0, 10)}.json`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
setMaintenanceMsg('Export downloaded.')
|
||
} catch {
|
||
setMaintenanceMsg('Export failed.')
|
||
}
|
||
}
|
||
|
||
async function handlePrune() {
|
||
const input = prompt('Keep how many days of analytics data?', '180')
|
||
if (input === null) return
|
||
const days = Number(input)
|
||
if (!Number.isFinite(days) || days <= 0) {
|
||
setMaintenanceMsg('Invalid retention days.')
|
||
return
|
||
}
|
||
try {
|
||
const r = await fetch('/api/admin-stats/prune', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ days }),
|
||
})
|
||
if (!r.ok) throw new Error('Prune failed')
|
||
await reloadStats()
|
||
setMaintenanceMsg(`Pruned analytics to ${Math.floor(days)} days.`)
|
||
} catch {
|
||
setMaintenanceMsg('Prune failed.')
|
||
}
|
||
}
|
||
|
||
async function handleClear() {
|
||
if (!confirm('Clear ALL analytics data now? This cannot be undone.')) return
|
||
try {
|
||
const r = await fetch('/api/admin-stats/clear', { method: 'POST' })
|
||
if (!r.ok) throw new Error('Clear failed')
|
||
await reloadStats()
|
||
setMaintenanceMsg('All analytics data cleared.')
|
||
} catch {
|
||
setMaintenanceMsg('Clear failed.')
|
||
}
|
||
}
|
||
|
||
async function handleBackupNow() {
|
||
try {
|
||
const r = await fetch('/api/admin-stats/backup', { method: 'POST' })
|
||
if (!r.ok) throw new Error('Backup failed')
|
||
await reloadStats()
|
||
await reloadBackups()
|
||
setMaintenanceMsg('Backup snapshot created.')
|
||
} catch {
|
||
setMaintenanceMsg('Backup failed.')
|
||
}
|
||
}
|
||
|
||
async function handleDownloadFullBackup() {
|
||
setFullBackupBusy(true)
|
||
setMaintenanceMsg('Preparing full backup archive…')
|
||
try {
|
||
const r = await fetch('/api/admin-backup/export')
|
||
if (!r.ok) throw new Error('Export failed')
|
||
const blob = await r.blob()
|
||
const disposition = r.headers.get('Content-Disposition') ?? ''
|
||
const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? 'siteforge-data-backup.tar.gz'
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = filename
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
setMaintenanceMsg(`Full backup downloaded (${filename}). Keep it somewhere safe — it contains all site data.`)
|
||
} catch {
|
||
setMaintenanceMsg('Full backup download failed.')
|
||
} finally {
|
||
setFullBackupBusy(false)
|
||
}
|
||
}
|
||
|
||
async function handleImportFullBackup(file: File) {
|
||
if (!confirm(`Restore ALL site data from ${file.name}?\n\nThis replaces content, uploads, study accounts, notes, questions, and analytics with the archive contents. A pre-import snapshot is saved first, and study users will need to sign in again.`)) return
|
||
setFullBackupBusy(true)
|
||
setMaintenanceMsg('Restoring full backup — do not close this page…')
|
||
try {
|
||
const r = await fetch('/api/admin-backup/import', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/gzip' },
|
||
body: file,
|
||
})
|
||
const data = await r.json().catch(() => null)
|
||
if (!r.ok) throw new Error(data?.message ?? 'Restore failed')
|
||
await reloadContentFromServer()
|
||
await reloadStats()
|
||
await reloadBackups()
|
||
setMaintenanceMsg(`Full data restore complete — ${data?.restoredEntries ?? 'all'} items restored from ${file.name}.`)
|
||
} catch (err) {
|
||
setMaintenanceMsg(err instanceof Error ? err.message : 'Full data restore failed.')
|
||
} finally {
|
||
setFullBackupBusy(false)
|
||
}
|
||
}
|
||
|
||
async function handleRestoreBackup() {
|
||
if (!selectedBackup) {
|
||
setMaintenanceMsg('Select a backup first.')
|
||
return
|
||
}
|
||
if (!confirm(`Restore backup ${selectedBackup}? This will overwrite current admin data and analytics.`)) return
|
||
try {
|
||
const r = await fetch('/api/admin-stats/restore', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: selectedBackup }),
|
||
})
|
||
if (!r.ok) throw new Error('Restore failed')
|
||
await reloadContentFromServer()
|
||
await reloadStats()
|
||
await reloadBackups()
|
||
setMaintenanceMsg(`Restored from ${selectedBackup}. Content fields were refreshed from backup.`)
|
||
} catch {
|
||
setMaintenanceMsg('Restore failed.')
|
||
}
|
||
}
|
||
|
||
function formatDate(value: string | null) {
|
||
if (!value) return 'Not available yet'
|
||
const d = new Date(value)
|
||
return Number.isNaN(d.getTime()) ? 'Not available yet' : d.toLocaleString()
|
||
}
|
||
|
||
function handleChange(key: StringField, value: string) {
|
||
setForm(f => ({ ...f, [key]: value }))
|
||
}
|
||
|
||
function renderImageAssetSelector(value: string | null | undefined, onChange: (value: string) => void, fieldId: string) {
|
||
const assetOptions = [...assets.map(asset => ({ label: asset.filename, value: asset.url }))]
|
||
const currentValue = value?.trim() ?? ''
|
||
|
||
if (currentValue && !assetOptions.some(item => item.value === currentValue)) {
|
||
assetOptions.unshift({ label: `Current image (${currentValue})`, value: currentValue })
|
||
}
|
||
|
||
if (assetOptions.length === 0) return null
|
||
|
||
return (
|
||
<div className="admin-field-asset-picker">
|
||
<label htmlFor={fieldId}>Choose an existing image</label>
|
||
<select id={fieldId} value={assetOptions.some(a => a.value === currentValue) ? currentValue : ''} onChange={e => onChange(e.target.value)}>
|
||
<option value="">Select image</option>
|
||
{assetOptions.map(option => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function renderImagePreview(value: string | null | undefined, alt: string) {
|
||
const src = value?.trim() ?? ''
|
||
if (!src) return null
|
||
|
||
return (
|
||
<div className="admin-field-image-preview">
|
||
<span className="admin-field-image-preview-label">Preview</span>
|
||
<img src={src} alt={alt} className="admin-field-image-preview-img" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function getCustomLinkPlacementLabel(placement: CustomLink['placement']) {
|
||
if (placement === 'platforms') return 'Homepage listen/platform links'
|
||
if (placement === 'footer') return 'Footer navigation links'
|
||
if (placement === 'otherSites') return 'Other site links in footer dropdown'
|
||
if (placement === 'externalSites') return 'Homepage external site links'
|
||
if (placement === 'books') return 'Resources page (Reading List)'
|
||
if (placement === 'recommended') return 'Resources page (Recommended Resources)'
|
||
return 'Resources page (More Resources)'
|
||
}
|
||
|
||
function getCustomLinkPreviewPath(placement: CustomLink['placement']) {
|
||
if (placement === 'resources') return '/resources'
|
||
if (placement === 'books') return '/resources'
|
||
if (placement === 'recommended') return '/resources'
|
||
return '/'
|
||
}
|
||
|
||
function getContentBlockPageLabel(page: CustomBlock['page']) {
|
||
if (page === 'homepage') return 'Homepage'
|
||
if (page === 'start-here') return 'Start Here'
|
||
if (page === 'episodes') return 'Episodes'
|
||
if (page === 'downloads') return 'Resources'
|
||
if (page === 'about') return 'About'
|
||
if (page === 'contact') return 'Contact'
|
||
return 'Q&A'
|
||
}
|
||
|
||
function getContentBlockPreviewPath(page: CustomBlock['page']) {
|
||
if (page === 'homepage') return '/'
|
||
if (page === 'start-here') return '/start-here'
|
||
if (page === 'episodes') return '/episodes'
|
||
if (page === 'downloads') return '/resources'
|
||
if (page === 'about') return '/about'
|
||
if (page === 'contact') return '/contact'
|
||
return '/questions'
|
||
}
|
||
|
||
function renderFileAssetSelector(value: string | null | undefined, onChange: (value: string) => void, fieldId: string) {
|
||
const assetOptions = [...assets.map(asset => ({ label: asset.filename, value: asset.url }))]
|
||
const currentValue = value?.trim() ?? ''
|
||
|
||
if (currentValue && !assetOptions.some(item => item.value === currentValue)) {
|
||
assetOptions.unshift({ label: `Current file (${currentValue})`, value: currentValue })
|
||
}
|
||
|
||
if (assetOptions.length === 0) return null
|
||
|
||
return (
|
||
<div className="admin-field-asset-picker">
|
||
<label htmlFor={fieldId}>Choose an existing file</label>
|
||
<select id={fieldId} value={assetOptions.some(a => a.value === currentValue) ? currentValue : ''} onChange={e => onChange(e.target.value)}>
|
||
<option value="">Select file</option>
|
||
{assetOptions.map(option => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function isImageAsset(filename: string) {
|
||
return /\.(png|jpe?g|webp|gif)$/i.test(filename)
|
||
}
|
||
|
||
function confirmLeaveUnsavedChanges() {
|
||
if (!isDirty) return true
|
||
return confirm('You have unsaved changes. Leave this section without saving?')
|
||
}
|
||
|
||
function navigateTo(view: AdminView) {
|
||
if (view === adminView) return
|
||
if (!confirmLeaveUnsavedChanges()) return
|
||
setAdminView(view)
|
||
setMobileNavOpen(false)
|
||
const parentId = getNavParentId(view)
|
||
if (parentId) setNavOpenGroups(prev => new Set([...prev, parentId]))
|
||
}
|
||
|
||
function getAdminViewBadge(view: AdminView) {
|
||
if (view === 'questions' && unansweredCount > 0) {
|
||
return { count: unansweredCount, neutral: false }
|
||
}
|
||
if (view === 'subscribers' && subscribers.length > 0) {
|
||
return { count: subscribers.length, neutral: true }
|
||
}
|
||
return null
|
||
}
|
||
|
||
function renderAdminNavItem(view: AdminView, label: string) {
|
||
const badge = getAdminViewBadge(view)
|
||
|
||
return (
|
||
<button
|
||
key={view}
|
||
type="button"
|
||
className={`admin-nav-item${adminView === view ? ' admin-nav-item--active' : ''}`}
|
||
onClick={() => navigateTo(view)}
|
||
>
|
||
{label}
|
||
{badge && (
|
||
<span className={`admin-nav-badge${badge.neutral ? ' admin-nav-badge--neutral' : ''}`}>
|
||
{badge.count}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function jumpToAdminSection(sectionId: string) {
|
||
document.getElementById(sectionId)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
|
||
function renderSectionJumpNav(view: AdminView) {
|
||
const links = ADMIN_SECTION_LINKS[view] ?? []
|
||
if (links.length < 2) return null
|
||
|
||
return (
|
||
<div className="admin-section-jump-nav" aria-label="Jump to section">
|
||
<span className="admin-section-jump-nav-label">Jump to</span>
|
||
<div className="admin-section-jump-nav-links">
|
||
{links.map(link => (
|
||
<button
|
||
key={link.id}
|
||
type="button"
|
||
className="admin-section-jump-nav-btn"
|
||
onClick={() => jumpToAdminSection(link.id)}
|
||
>
|
||
{link.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function addLink() {
|
||
setForm(f => ({
|
||
...f,
|
||
customLinks: [
|
||
...(f.customLinks ?? []),
|
||
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'platforms' as const },
|
||
],
|
||
}))
|
||
}
|
||
|
||
function addOtherSiteLink() {
|
||
setForm(f => ({
|
||
...f,
|
||
customLinks: [
|
||
...(f.customLinks ?? []),
|
||
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'otherSites' as const },
|
||
],
|
||
}))
|
||
}
|
||
|
||
function addExternalSiteLink() {
|
||
setForm(f => ({
|
||
...f,
|
||
customLinks: [
|
||
...(f.customLinks ?? []),
|
||
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', placement: 'externalSites' as const },
|
||
],
|
||
}))
|
||
}
|
||
|
||
function addResource() {
|
||
setForm(f => ({
|
||
...f,
|
||
customLinks: [
|
||
...(f.customLinks ?? []),
|
||
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', placement: 'resources' as const },
|
||
],
|
||
}))
|
||
}
|
||
|
||
function addBook() {
|
||
setForm(f => ({
|
||
...f,
|
||
customLinks: [
|
||
...(f.customLinks ?? []),
|
||
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', amazonUrl: '', amazonLabel: 'View on Amazon', placement: 'books' as const },
|
||
],
|
||
}))
|
||
}
|
||
|
||
function addRecommended() {
|
||
setForm(f => ({
|
||
...f,
|
||
customLinks: [
|
||
...(f.customLinks ?? []),
|
||
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', placement: 'recommended' as const },
|
||
],
|
||
}))
|
||
}
|
||
|
||
function updateLink(id: string, field: keyof CustomLink, value: string | string[]) {
|
||
setForm(f => ({
|
||
...f,
|
||
customLinks: (f.customLinks ?? []).map(l => l.id === id ? { ...l, [field]: value } : l),
|
||
}))
|
||
}
|
||
|
||
function removeLink(id: string) {
|
||
setForm(f => ({ ...f, customLinks: (f.customLinks ?? []).filter(l => l.id !== id) }))
|
||
}
|
||
|
||
function moveLinkToResources(id: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
customLinks: (f.customLinks ?? []).map(link => (
|
||
link.id === id ? { ...link, placement: 'resources' as const } : link
|
||
)),
|
||
}))
|
||
}
|
||
|
||
function addBlock() {
|
||
setForm(f => ({
|
||
...f,
|
||
customBlocks: [
|
||
...(f.customBlocks ?? []),
|
||
{ id: Date.now().toString(36), heading: '', body: '', page: 'homepage' as const },
|
||
],
|
||
}))
|
||
}
|
||
|
||
function updateBlock(id: string, field: keyof CustomBlock, value: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
customBlocks: (f.customBlocks ?? []).map(b => b.id === id ? { ...b, [field]: value } : b),
|
||
}))
|
||
}
|
||
|
||
function removeBlock(id: string) {
|
||
setForm(f => ({ ...f, customBlocks: (f.customBlocks ?? []).filter(b => b.id !== id) }))
|
||
}
|
||
|
||
function addStudyProgram() {
|
||
setForm(f => ({
|
||
...f,
|
||
studies: [
|
||
...(f.studies ?? []),
|
||
{
|
||
id: `study-${Date.now().toString(36)}`,
|
||
slug: `new-study-${Date.now().toString(36)}`,
|
||
title: 'New Study',
|
||
description: 'Add study description',
|
||
homepageEyebrow: 'New Study',
|
||
showOnHomepage: false,
|
||
showNewTag: false,
|
||
newTagLabel: 'NEW',
|
||
status: 'planned',
|
||
difficulty: 'beginner',
|
||
estimatedHours: 8,
|
||
completionBadge: 'Study Completion',
|
||
numberOfChapters: 4,
|
||
sections: [],
|
||
},
|
||
],
|
||
}))
|
||
}
|
||
|
||
function updateStudyProgram(studyId: string, field: keyof Omit<StudyProgram, 'sections'>, value: string | number | boolean) {
|
||
setForm(f => ({
|
||
...f,
|
||
studies: (f.studies ?? []).map(study => study.id === studyId ? { ...study, [field]: value } : study),
|
||
}))
|
||
}
|
||
|
||
function removeStudyProgram(studyId: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
studies: (f.studies ?? []).filter(study => study.id !== studyId),
|
||
}))
|
||
}
|
||
|
||
function addStudySection(studyId: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
studies: (f.studies ?? []).map(study => study.id === studyId
|
||
? {
|
||
...study,
|
||
sections: [
|
||
...(study.sections ?? []),
|
||
{
|
||
id: `${study.slug || 'section'}-${Date.now().toString(36)}`,
|
||
chapter: 1,
|
||
reference: '',
|
||
title: '',
|
||
audioEmbedUrl: '',
|
||
passageText: '',
|
||
summary: '',
|
||
commentary: '',
|
||
greekNotes: [],
|
||
studyQuestions: [],
|
||
checkpointPrompt: '',
|
||
checkpointQuestions: [],
|
||
},
|
||
],
|
||
}
|
||
: study),
|
||
}))
|
||
}
|
||
|
||
function updateStudySection(studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | number | string[]) {
|
||
setForm(f => ({
|
||
...f,
|
||
studies: (f.studies ?? []).map(study => study.id === studyId
|
||
? {
|
||
...study,
|
||
sections: (study.sections ?? []).map(section => section.id === sectionId ? { ...section, [field]: value } : section),
|
||
}
|
||
: study),
|
||
}))
|
||
}
|
||
|
||
function removeStudySection(studyId: string, sectionId: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
studies: (f.studies ?? []).map(study => study.id === studyId
|
||
? { ...study, sections: (study.sections ?? []).filter(section => section.id !== sectionId) }
|
||
: study),
|
||
}))
|
||
}
|
||
|
||
async function handleAnswerQuestion(questionId: string, answer: string) {
|
||
if (!answer.trim()) return
|
||
try {
|
||
const res = await fetch(`/api/admin-questions/${questionId}/answer`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ answer: answer.trim() }),
|
||
})
|
||
if (!res.ok) throw new Error('Failed to answer question')
|
||
setQuestions(qs =>
|
||
qs.map(q =>
|
||
q.id === questionId
|
||
? { ...q, answer: answer.trim(), answeredAt: new Date().toISOString() }
|
||
: q
|
||
)
|
||
)
|
||
setEditingQuestionId(null)
|
||
setAnsweredQuestions(a => ({ ...a, [questionId]: '' }))
|
||
} catch {
|
||
alert('Failed to save answer')
|
||
}
|
||
}
|
||
|
||
async function handleApproveQuestion(questionId: string, approved: boolean) {
|
||
try {
|
||
const res = await fetch(`/api/admin-questions/${questionId}/approve`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ approved }),
|
||
})
|
||
if (!res.ok) throw new Error('Failed to update question')
|
||
setQuestions(qs =>
|
||
qs.map(q =>
|
||
q.id === questionId
|
||
? { ...q, isApproved: approved, approvedAt: approved ? new Date().toISOString() : null }
|
||
: q
|
||
)
|
||
)
|
||
} catch {
|
||
alert('Failed to update question')
|
||
}
|
||
}
|
||
|
||
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 loadWebhooksAdmin() {
|
||
try {
|
||
const res = await fetch('/api/admin-webhooks')
|
||
const data = await res.json()
|
||
if (Array.isArray(data.webhooks)) { setWebhooks(data.webhooks); setWebhooksLoaded(true) }
|
||
} catch { /* silent */ }
|
||
}
|
||
|
||
async function loadQrCodes() {
|
||
setQrLoading(true)
|
||
setQrMsg('')
|
||
try {
|
||
const res = await fetch('/api/admin/qr-codes')
|
||
const data = await res.json()
|
||
setQrCodes(Array.isArray(data.codes) ? data.codes : [])
|
||
setQrScans(Array.isArray(data.scans) ? data.scans : [])
|
||
setQrLoaded(true)
|
||
} catch { setQrMsg('Failed to load QR codes.') }
|
||
finally { setQrLoading(false) }
|
||
}
|
||
|
||
async function handleCreateQrCode() {
|
||
setQrMsg('')
|
||
try {
|
||
const res = await fetch('/api/admin/qr-codes', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ label: qrNewLabel, slug: qrNewSlug, destination: qrNewDest }),
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok) { setQrMsg(data.message || 'Failed to create QR code.'); return }
|
||
setQrCodes(prev => [...prev, data.code])
|
||
setQrNewLabel(''); setQrNewSlug(''); setQrNewDest('')
|
||
} catch { setQrMsg('Failed to create QR code.') }
|
||
}
|
||
|
||
async function handleSaveQrEdit(id: string) {
|
||
setQrMsg('')
|
||
try {
|
||
const res = await fetch(`/api/admin/qr-codes/${id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ label: qrEditLabel, destination: qrEditDest }),
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok) { setQrMsg(data.message || 'Failed to save.'); return }
|
||
setQrCodes(prev => prev.map(c => c.id === id ? { ...c, ...data.code } : c))
|
||
setQrEditId(null)
|
||
} catch { setQrMsg('Failed to save.') }
|
||
}
|
||
|
||
async function handleToggleQrActive(id: string, active: boolean) {
|
||
try {
|
||
await fetch(`/api/admin/qr-codes/${id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ active }),
|
||
})
|
||
setQrCodes(prev => prev.map(c => c.id === id ? { ...c, active } : c))
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function handleDeleteQrCode(id: string) {
|
||
if (!confirm('Delete this QR code and all its scan history?')) return
|
||
try {
|
||
await fetch(`/api/admin/qr-codes/${id}`, { method: 'DELETE' })
|
||
setQrCodes(prev => prev.filter(c => c.id !== id))
|
||
setQrScans(prev => prev.filter(s => s.qrId !== id))
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function handleClearQrScans(id: string) {
|
||
if (!confirm('Clear all scan history for this QR code?')) return
|
||
try {
|
||
await fetch(`/api/admin/qr-codes/${id}/scans`, { method: 'DELETE' })
|
||
setQrScans(prev => prev.filter(s => s.qrId !== id))
|
||
setQrCodes(prev => prev.map(c => c.id === id ? { ...c, scanCount: 0 } : c))
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
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 {
|
||
const res = await fetch(`/api/admin-questions/${questionId}`, { method: 'DELETE' })
|
||
if (!res.ok) throw new Error('Failed to delete question')
|
||
setQuestions(qs => qs.filter(q => q.id !== questionId))
|
||
setSelectedQuestionIds(prev => { const next = new Set(prev); next.delete(questionId); return next })
|
||
} catch {
|
||
alert('Failed to delete question')
|
||
}
|
||
}
|
||
|
||
async function handleBulkDeleteQuestions() {
|
||
if (selectedQuestionIds.size === 0) return
|
||
if (!confirm(`Delete ${selectedQuestionIds.size} question${selectedQuestionIds.size === 1 ? '' : 's'} permanently?`)) return
|
||
const ids = Array.from(selectedQuestionIds)
|
||
let deletedCount = 0
|
||
for (const id of ids) {
|
||
try {
|
||
const res = await fetch(`/api/admin-questions/${id}`, { method: 'DELETE' })
|
||
if (res.ok) {
|
||
deletedCount++
|
||
setQuestions(qs => qs.filter(q => q.id !== id))
|
||
}
|
||
} catch {
|
||
// continue with remaining
|
||
}
|
||
}
|
||
setSelectedQuestionIds(new Set())
|
||
if (deletedCount < ids.length) alert(`Deleted ${deletedCount} of ${ids.length} questions.`)
|
||
}
|
||
|
||
async function handleCreateManualQuestion() {
|
||
if (!manualQuestion.firstName.trim() || !manualQuestion.question.trim()) {
|
||
setManualQuestionStatus('error')
|
||
setManualQuestionMsg('First name and question are required.')
|
||
return
|
||
}
|
||
|
||
setManualQuestionStatus('saving')
|
||
setManualQuestionMsg('')
|
||
|
||
try {
|
||
const res = await fetch('/api/admin-questions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
firstName: manualQuestion.firstName,
|
||
email: manualQuestion.email,
|
||
question: manualQuestion.question,
|
||
answer: manualQuestion.answer,
|
||
approve: manualQuestion.approve,
|
||
}),
|
||
})
|
||
|
||
const data = await res.json().catch(() => ({})) as { question?: Question; message?: string }
|
||
if (!res.ok || !data.question) {
|
||
throw new Error(data.message ?? 'Failed to add question.')
|
||
}
|
||
|
||
setQuestions(items => [data.question as Question, ...items])
|
||
setManualQuestion({ firstName: '', email: '', question: '', answer: '', approve: false })
|
||
setManualQuestionStatus('saved')
|
||
setManualQuestionMsg('Question added to draft Q&A list.')
|
||
} catch (err) {
|
||
setManualQuestionStatus('error')
|
||
setManualQuestionMsg(err instanceof Error ? err.message : 'Failed to add question.')
|
||
}
|
||
}
|
||
|
||
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources')
|
||
|
||
const filteredAdminQuestions = questions.filter(question => {
|
||
const search = questionSearch.trim().toLowerCase()
|
||
const matchesSearch = !search
|
||
|| question.firstName.toLowerCase().includes(search)
|
||
|| question.question.toLowerCase().includes(search)
|
||
|| question.answer.toLowerCase().includes(search)
|
||
|
||
const matchesFilter = (
|
||
questionFilter === 'all'
|
||
|| (questionFilter === 'pending' && !question.isApproved)
|
||
|| (questionFilter === 'approved' && question.isApproved)
|
||
|| (questionFilter === 'answered' && Boolean(question.answer?.trim()))
|
||
|| (questionFilter === 'unanswered' && !question.answer?.trim())
|
||
)
|
||
|
||
return matchesSearch && matchesFilter
|
||
})
|
||
|
||
const QUESTION_PAGE_SIZE = 20
|
||
const totalQuestionPages = Math.max(1, Math.ceil(filteredAdminQuestions.length / QUESTION_PAGE_SIZE))
|
||
const visibleAdminQuestions = filteredAdminQuestions.slice(
|
||
questionPage * QUESTION_PAGE_SIZE,
|
||
(questionPage + 1) * QUESTION_PAGE_SIZE,
|
||
)
|
||
function renderSaveStatus() {
|
||
return (
|
||
<>
|
||
{status === 'saved' && <p className="admin-status admin-status--ok">✓ Changes saved.</p>}
|
||
{status === 'error' && <p className="admin-status admin-status--err">✗ {errorMsg}</p>}
|
||
</>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="admin-shell">
|
||
{/* ── Top bar ── */}
|
||
<header className="admin-topbar">
|
||
<div className="admin-topbar-left">
|
||
<button
|
||
type="button"
|
||
className={`admin-mobile-menu-btn${mobileNavOpen ? ' admin-mobile-menu-btn--open' : ''}`}
|
||
onClick={() => setMobileNavOpen(open => !open)}
|
||
aria-expanded={mobileNavOpen}
|
||
aria-controls="admin-sidebar-nav"
|
||
>
|
||
<span className="admin-menu-bar" /><span className="admin-menu-bar" /><span className="admin-menu-bar" />
|
||
</button>
|
||
<div className="admin-topbar-brand">
|
||
<span className="admin-topbar-ornament">✦</span>
|
||
<span className="admin-topbar-title">Siteforge</span>
|
||
</div>
|
||
{(() => {
|
||
const crumb = getNavBreadcrumb(adminView)
|
||
return (
|
||
<div className="admin-topbar-crumb">
|
||
{crumb.parent && <><span className="admin-crumb-parent">{crumb.parent}</span><span className="admin-crumb-sep">›</span></>}
|
||
<span className="admin-crumb-current">{crumb.label}</span>
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
<div className="admin-topbar-right">
|
||
{isDirty && <span className="admin-unsaved-dot">● Unsaved</span>}
|
||
<span className="admin-topbar-meta">Draft: {formatDate(publishState.draftUpdatedAt)}</span>
|
||
<span className="admin-topbar-meta">Live: {formatDate(publishState.publishedAt)}</span>
|
||
<button type="button" className="btn-admin-save" onClick={handleSaveDraft} disabled={status === 'saving' || !isDirty}>
|
||
{status === 'saving' ? 'Saving…' : 'Save Draft'}
|
||
</button>
|
||
<button type="button" className={`btn-admin-ghost${previewOpen ? ' btn-admin-ghost--active' : ''}`} onClick={() => setPreviewOpen(o => !o)}>
|
||
{previewOpen ? 'Close Preview' : 'Preview'}
|
||
</button>
|
||
{publishConfirmOpen ? (
|
||
<>
|
||
<span style={{ fontSize: '0.82rem', color: '#e0c070' }}>Publish to live site?</span>
|
||
<button type="button" className="btn-admin-save" onClick={() => { setPublishConfirmOpen(false); handlePublishDraft() }}>Yes, Publish</button>
|
||
<button type="button" className="btn-admin-ghost" onClick={() => setPublishConfirmOpen(false)}>Cancel</button>
|
||
</>
|
||
) : (
|
||
<button type="button" className="btn-admin-publish" onClick={() => setPublishConfirmOpen(true)}>Publish</button>
|
||
)}
|
||
</div>
|
||
</header>
|
||
|
||
<div className={`admin-layout${previewOpen ? ' admin-layout--split' : ''}`}>
|
||
{/* ── Sidebar ── */}
|
||
<nav id="admin-sidebar-nav" className={`admin-sidebar${mobileNavOpen ? ' admin-sidebar--open' : ''}`} aria-label="Admin navigation">
|
||
<div className="admin-sidebar-top">
|
||
<div className="admin-nav-search">
|
||
<input
|
||
id="admin-nav-search-input"
|
||
type="search"
|
||
value={navSearch}
|
||
onChange={e => setNavSearch(e.target.value)}
|
||
placeholder="Filter…"
|
||
aria-label="Filter admin sections"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="admin-sidebar-scroll">
|
||
{filteredNav.length === 0 ? (
|
||
<p className="admin-nav-empty">No sections match.</p>
|
||
) : (
|
||
filteredNav.map(group => (
|
||
<div key={group.group} className="admin-nav-group">
|
||
<span className="admin-nav-group-label">{group.group}</span>
|
||
{group.items.map(item => {
|
||
if (item.kind === 'parent') {
|
||
const isOpen = navOpenGroups.has(item.id) || (navSearchTerm !== '' && item.children.some(c => c.view === adminView))
|
||
const hasActive = item.children.some(c => c.view === adminView)
|
||
return (
|
||
<div key={item.id} className="admin-nav-parent-wrap">
|
||
<button
|
||
type="button"
|
||
className={`admin-nav-parent${hasActive ? ' admin-nav-parent--active' : ''}`}
|
||
onClick={() => setNavOpenGroups(prev => {
|
||
const next = new Set(prev)
|
||
if (next.has(item.id)) next.delete(item.id)
|
||
else next.add(item.id)
|
||
return next
|
||
})}
|
||
>
|
||
<span>{item.label}</span>
|
||
<svg className={`admin-nav-chevron${isOpen ? ' admin-nav-chevron--open' : ''}`} width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
{isOpen && (
|
||
<div className="admin-nav-sub">
|
||
{item.children.map(child => (
|
||
<button
|
||
key={child.view}
|
||
type="button"
|
||
className={`admin-nav-sub-item${adminView === child.view ? ' admin-nav-sub-item--active' : ''}`}
|
||
onClick={() => navigateTo(child.view)}
|
||
>
|
||
{child.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
} else {
|
||
return renderAdminNavItem(item.view, item.label)
|
||
}
|
||
})}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
<div className="admin-sidebar-footer">
|
||
<Link
|
||
to="/"
|
||
className="admin-meta-link"
|
||
onClick={e => {
|
||
if (!confirmLeaveUnsavedChanges()) e.preventDefault()
|
||
else setMobileNavOpen(false)
|
||
}}
|
||
>
|
||
← Back to site
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
className="admin-meta-link"
|
||
onClick={() => {
|
||
if (!confirmLeaveUnsavedChanges()) return
|
||
setMobileNavOpen(false)
|
||
void onLogout()
|
||
}}
|
||
>
|
||
Log Out
|
||
</button>
|
||
{appVersion && <span className="admin-sidebar-version">{appVersion}</span>}
|
||
</div>
|
||
</nav>
|
||
|
||
{mobileNavOpen && <button type="button" className="admin-sidebar-backdrop" aria-label="Close navigation menu" onClick={() => setMobileNavOpen(false)} />}
|
||
|
||
{/* ── Content panel ── */}
|
||
<main className="admin-panel">
|
||
|
||
{/* DASHBOARD */}
|
||
{adminView === 'dashboard' && (() => {
|
||
const thisWeekHits = stats?.last7Days?.reduce((s, d) => s + d.hits, 0) ?? 0
|
||
const thisWeekReal = stats?.last7DaysReal?.reduce((s, d) => s + d.hits, 0) ?? 0
|
||
const dashboardHour = dashboardNow.getHours()
|
||
const welcomeMessage = dashboardHour < 12
|
||
? 'Good morning.'
|
||
: dashboardHour < 18
|
||
? 'Good afternoon.'
|
||
: 'Good evening.'
|
||
const dashboardDateLabel = dashboardNow.toLocaleDateString(undefined, {
|
||
weekday: 'long',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
})
|
||
const dashboardTimeLabel = dashboardNow.toLocaleTimeString(undefined, {
|
||
hour: 'numeric',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
})
|
||
const recentContacts = [...contactSubmissions]
|
||
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
||
.slice(0, 5)
|
||
const recentQuestions = [...questions]
|
||
.sort((a, b) => new Date(b.submittedAt ?? '').getTime() - new Date(a.submittedAt ?? '').getTime())
|
||
.slice(0, 5)
|
||
const approvedCount = questions.filter(q => q.isApproved).length
|
||
const answeredCount = questions.filter(q => !!q.answer?.trim()).length
|
||
const topEnrollment = stats?.studyEnrollment?.enrollmentsByStudy?.[0]
|
||
return (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Dashboard</h2>
|
||
<p>Quick overview of your ministry site.</p>
|
||
</div>
|
||
|
||
<div className="admin-dashboard-welcome">
|
||
<div>
|
||
<h3 className="admin-dashboard-welcome-title">{welcomeMessage}</h3>
|
||
<p className="admin-dashboard-welcome-copy">Here's what needs your attention and how the site is performing today.</p>
|
||
</div>
|
||
<div className="admin-dashboard-clock" aria-label={`Current time ${dashboardTimeLabel} on ${dashboardDateLabel}`}>
|
||
<span className="admin-dashboard-clock-time">{dashboardTimeLabel}</span>
|
||
<span className="admin-dashboard-clock-date">{dashboardDateLabel}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="admin-dashboard-grid">
|
||
<div className={`admin-dashboard-card${unansweredCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
|
||
<div className="admin-dashboard-card-value">{unansweredCount}</div>
|
||
<div className="admin-dashboard-card-label">Unanswered Questions</div>
|
||
<div className="admin-dashboard-card-sub">{answeredCount} answered · {approvedCount} approved of {questions.length}</div>
|
||
{unansweredCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => { setQuestionFilter('unanswered'); navigateTo('questions') }}>Answer Now →</button>}
|
||
</div>
|
||
<div className={`admin-dashboard-card${unreadEmailCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
|
||
<div className="admin-dashboard-card-value">{unreadEmailCount}</div>
|
||
<div className="admin-dashboard-card-label">Inbox Messages</div>
|
||
<div className="admin-dashboard-card-sub">{contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total</div>
|
||
</div>
|
||
<div className="admin-dashboard-card">
|
||
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
|
||
<div className="admin-dashboard-card-label">Real Visits (7 Days)</div>
|
||
<div className="admin-dashboard-card-sub">{thisWeekHits.toLocaleString()} total · {stats?.visitors?.uniqueVisitors?.toLocaleString() ?? '—'} unique all time</div>
|
||
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('analytics')}>Full Analytics →</button>
|
||
</div>
|
||
<div className="admin-dashboard-card">
|
||
<div className="admin-dashboard-card-value">{subscribers.length}</div>
|
||
<div className="admin-dashboard-card-label">Email Subscribers</div>
|
||
<div className="admin-dashboard-card-sub">{contactSubmissions.length} total contact submissions</div>
|
||
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('subscribers')}>View List →</button>
|
||
</div>
|
||
<div className="admin-dashboard-card">
|
||
<div className="admin-dashboard-card-value">{stats?.studyEnrollment?.totalEnrollments ?? 0}</div>
|
||
<div className="admin-dashboard-card-label">Study Enrollments</div>
|
||
<div className="admin-dashboard-card-sub">
|
||
{stats?.studyEnrollment?.enrolledUsers ?? 0} of {stats?.studyEnrollment?.totalUsers ?? 0} students enrolled
|
||
{topEnrollment ? ` · Top: ${topEnrollment.title} (${topEnrollment.count})` : ''}
|
||
</div>
|
||
</div>
|
||
<div className="admin-dashboard-card">
|
||
<div className="admin-dashboard-card-value">{Math.max(0, ...Object.values(downloadStats))}</div>
|
||
<div className="admin-dashboard-card-label">Top Resource Downloads</div>
|
||
<div className="admin-dashboard-card-sub">
|
||
{Object.values(downloadStats).reduce((a, b) => a + b, 0)} total resource downloads
|
||
</div>
|
||
</div>
|
||
{publishState?.publishedAt && (
|
||
<div className="admin-dashboard-card">
|
||
<div className="admin-dashboard-card-value" style={{ fontSize: '1rem', marginTop: '0.25rem' }}>{formatDate(publishState.publishedAt)}</div>
|
||
<div className="admin-dashboard-card-label">Last Published</div>
|
||
{publishState.draftUpdatedAt && <div className="admin-dashboard-card-sub">Draft updated {formatDate(publishState.draftUpdatedAt)}</div>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="admin-dashboard-activity">
|
||
<div className="admin-dashboard-activity-col">
|
||
<h3 className="admin-dashboard-activity-heading">Recent Contacts</h3>
|
||
{recentContacts.length === 0
|
||
? <p className="admin-stats-note">No contacts yet.</p>
|
||
: (
|
||
<div className="admin-dashboard-feed">
|
||
{recentContacts.map(c => (
|
||
<div key={c.id} className="admin-dashboard-feed-item">
|
||
<div className="admin-dashboard-feed-meta">
|
||
<strong>{c.name}</strong>
|
||
<span className="admin-dashboard-feed-date">{formatDate(c.submittedAt)}</span>
|
||
</div>
|
||
<div className="admin-dashboard-feed-email">{c.email}</div>
|
||
{c.message && <div className="admin-dashboard-feed-preview">{c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}</div>}
|
||
</div>
|
||
))}
|
||
<button type="button" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }} onClick={() => navigateTo('contact:profile')}>View All Contacts →</button>
|
||
</div>
|
||
)
|
||
}
|
||
</div>
|
||
<div className="admin-dashboard-activity-col">
|
||
<h3 className="admin-dashboard-activity-heading">Recent Questions</h3>
|
||
{recentQuestions.length === 0
|
||
? <p className="admin-stats-note">No questions yet.</p>
|
||
: (
|
||
<div className="admin-dashboard-feed">
|
||
{recentQuestions.map(q => (
|
||
<div key={q.id} className="admin-dashboard-feed-item">
|
||
<div className="admin-dashboard-feed-meta">
|
||
<strong>{q.firstName}</strong>
|
||
<span className={`admin-badge ${q.isApproved ? 'admin-badge--approved' : 'admin-badge--pending'}`} style={{ fontSize: '0.65rem' }}>{q.isApproved ? 'Approved' : 'Pending'}</span>
|
||
<span className="admin-dashboard-feed-date">{formatDate(q.submittedAt ?? '')}</span>
|
||
</div>
|
||
<div className="admin-dashboard-feed-preview">{q.question.slice(0, 100)}{q.question.length > 100 ? '…' : ''}</div>
|
||
</div>
|
||
))}
|
||
<button type="button" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem' }} onClick={() => navigateTo('questions')}>View All Questions →</button>
|
||
</div>
|
||
)
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
|
||
<div className="admin-dashboard-activity-col" style={{ gridColumn: '1 / -1' }}>
|
||
<h3 className="admin-dashboard-activity-heading">Study Users & Enrollments</h3>
|
||
{stats?.studyEnrollment?.users?.length
|
||
? (
|
||
<div className="admin-dashboard-feed">
|
||
{stats.studyEnrollment.users.map(user => (
|
||
<div key={user.id} className="admin-dashboard-feed-item">
|
||
<div className="admin-dashboard-feed-meta">
|
||
<strong>{user.displayName || user.username}</strong>
|
||
{user.displayName && <span className="admin-dashboard-feed-email">{user.username}</span>}
|
||
</div>
|
||
<div className="admin-dashboard-feed-preview">
|
||
{user.enrolledStudies.length
|
||
? `Enrolled in: ${user.enrolledStudies.map(study => study.title).join(', ')}`
|
||
: 'Not enrolled in any studies yet.'}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
: <p className="admin-stats-note">No study users yet.</p>
|
||
}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
})()}
|
||
|
||
{/* SUBSCRIBERS */}
|
||
{adminView === 'subscribers' && (() => {
|
||
const filteredSubs = subscribers.filter(s =>
|
||
!subscriberSearch.trim() ||
|
||
s.name.toLowerCase().includes(subscriberSearch.toLowerCase()) ||
|
||
s.email.toLowerCase().includes(subscriberSearch.toLowerCase())
|
||
)
|
||
return (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Subscribers</h2>
|
||
<p>{subscribers.length} people have opted in to email updates.</p>
|
||
</div>
|
||
<div className="admin-toolbar" style={{ marginBottom: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<input
|
||
type="search"
|
||
placeholder="Search by name or email…"
|
||
value={subscriberSearch}
|
||
onChange={e => setSubscriberSearch(e.target.value)}
|
||
style={{ minWidth: '240px', maxWidth: '400px', width: '100%' }}
|
||
/>
|
||
<form method="post" action="/api/admin-subscribers/export" style={{ display: 'inline' }}>
|
||
<button type="submit" className="btn-admin-reset">Export CSV</button>
|
||
</form>
|
||
</div>
|
||
{filteredSubs.length === 0 ? (
|
||
<p className="admin-stats-note">No subscribers{subscriberSearch ? ' match your search' : ' yet'}.</p>
|
||
) : (
|
||
<div className="admin-visits-table-wrap">
|
||
<table className="admin-visits-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Email</th>
|
||
<th>Source</th>
|
||
<th>Subscribed</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredSubs.map((sub, i) => (
|
||
<tr key={`${sub.email}-${i}`}>
|
||
<td>{sub.name}</td>
|
||
<td><a href={`mailto:${sub.email}`}>{sub.email}</a></td>
|
||
<td>{sub.source === 'download' ? 'Download' : 'Contact Form'}</td>
|
||
<td>{formatDate(sub.subscribedAt)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)
|
||
})()}
|
||
|
||
{/* HOMEPAGE */}
|
||
{adminView === 'homepage:hero' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Hero</h2>
|
||
<p>Primary landing page headline, tagline, podcast art, and call-to-action button labels.</p>
|
||
</div>
|
||
{FIELDS.filter(f => f.section === 'hero').map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
{(key.includes('ArtUrl') || key.includes('ImageUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
||
{(key.includes('ArtUrl') || key.includes('ImageUrl')) && renderImagePreview(form[key] as string, `${label} preview`)}
|
||
</div>
|
||
))}
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{adminView === 'homepage:share' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Share Section</h2>
|
||
<p>Controls the share block that points visitors toward the next step.</p>
|
||
</div>
|
||
{FIELDS.filter(f => f.section === 'share').map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
{key.includes('ImageUrl') && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
||
{key.includes('ImageUrl') && renderImagePreview(form[key] as string, `${label} preview`)}
|
||
</div>
|
||
))}
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{adminView === 'homepage:prism' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>PRISM Block</h2>
|
||
<p>Video URL, intro text, pattern steps, and closing copy for the PRISM section.</p>
|
||
</div>
|
||
{FIELDS.filter(f => f.section === 'prism').map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
</div>
|
||
))}
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{adminView === 'homepage:where-to-next' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Where to Next</h2>
|
||
<p>Section heading, eyebrow, and navigation cards shown at the bottom of the homepage.</p>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="field-whereToNextEyebrow">Section Eyebrow</label>
|
||
<input id="field-whereToNextEyebrow" type="text" value={form.whereToNextEyebrow ?? ''} onChange={e => setForm(f => ({ ...f, whereToNextEyebrow: e.target.value }))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="field-whereToNextHeading">Section Heading</label>
|
||
<input id="field-whereToNextHeading" type="text" value={form.whereToNextHeading ?? ''} onChange={e => setForm(f => ({ ...f, whereToNextHeading: e.target.value }))} />
|
||
</div>
|
||
<div className="admin-panel-subhead" style={{ marginTop: '1.5rem' }}><h3>Navigation Cards</h3><p>Title, description, and path for each card.</p></div>
|
||
{(form.whereToNextCards ?? []).map((card, idx) => (
|
||
<div key={card.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`wtn-title-${card.id}`}>Card {idx + 1} — Title</label>
|
||
<input id={`wtn-title-${card.id}`} type="text" value={card.title} onChange={e => setForm(f => ({ ...f, whereToNextCards: (f.whereToNextCards ?? []).map(c => c.id === card.id ? { ...c, title: e.target.value } : c) }))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`wtn-desc-${card.id}`}>Card {idx + 1} — Description</label>
|
||
<input id={`wtn-desc-${card.id}`} type="text" value={card.description} onChange={e => setForm(f => ({ ...f, whereToNextCards: (f.whereToNextCards ?? []).map(c => c.id === card.id ? { ...c, description: e.target.value } : c) }))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`wtn-path-${card.id}`}>Card {idx + 1} — Link Path</label>
|
||
<input id={`wtn-path-${card.id}`} type="text" value={card.path} onChange={e => setForm(f => ({ ...f, whereToNextCards: (f.whereToNextCards ?? []).map(c => c.id === card.id ? { ...c, path: e.target.value } : c) }))} />
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => setForm(f => ({ ...f, whereToNextCards: (f.whereToNextCards ?? []).filter(c => c.id !== card.id) }))}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={() => setForm(f => ({ ...f, whereToNextCards: [...(f.whereToNextCards ?? []), { id: Date.now().toString(36), title: '', description: '', path: '/' }] }))}>
|
||
+ Add Card
|
||
</button>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* START HERE */}
|
||
{adminView === 'start-here' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Start Here Page</h2>
|
||
<p>Edit the /start-here page — heading, intro, and the three step cards.</p>
|
||
</div>
|
||
|
||
{renderSectionJumpNav('start-here')}
|
||
|
||
<div className="admin-panel-subhead" id="start-here-intro">
|
||
<h3>Intro</h3>
|
||
<p>Top-level heading and opening copy for the page.</p>
|
||
</div>
|
||
{FIELDS.filter(f => ['startHereHeading', 'startHereIntro'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="start-here-step-1">
|
||
<h3>Step 1</h3>
|
||
</div>
|
||
{FIELDS.filter(f => ['startHereStep1Title', 'startHereStep1Body', 'startHereStep1Cta'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="start-here-step-2">
|
||
<h3>Step 2</h3>
|
||
</div>
|
||
{FIELDS.filter(f => ['startHereStep2Title', 'startHereStep2Body', 'startHereStep2Cta'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="start-here-step-3">
|
||
<h3>Step 3</h3>
|
||
</div>
|
||
{FIELDS.filter(f => ['startHereStep3Title', 'startHereStep3Body', 'startHereStep3Cta'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
</div>
|
||
))}
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* ABOUT — SHOW COPY */}
|
||
{adminView === 'about:show-copy' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Show Copy</h2>
|
||
<p>Top section of the About page — eyebrow, heading, show description paragraphs, and listen button label.</p>
|
||
</div>
|
||
{FIELDS.filter(f => ['aboutShowEyebrow', 'aboutShowHeading', 'aboutShowP1', 'aboutShowP2', 'aboutListenBtnLabel'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline ? (
|
||
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
) : (
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
)}
|
||
</div>
|
||
))}
|
||
<div className="admin-field">
|
||
<label htmlFor="field-aboutVerseArtUrl">Scripture Artwork Image URL</label>
|
||
<input id="field-aboutVerseArtUrl" type="text" value={form.aboutVerseArtUrl ?? ''} onChange={e => handleChange('aboutVerseArtUrl', e.target.value)} />
|
||
{renderImageAssetSelector(form.aboutVerseArtUrl ?? '', value => handleChange('aboutVerseArtUrl', value), 'field-aboutVerseArtUrl-asset')}
|
||
<p className="admin-field-note">Scripture artwork shown under the "About the Show" text.</p>
|
||
{renderImagePreview(form.aboutVerseArtUrl ?? '', 'Scripture artwork preview')}
|
||
</div>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* ABOUT — BIO */}
|
||
{adminView === 'about:bio' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Nate Bio</h2>
|
||
<p>Profile eyebrow, biography copy, and portrait image for the About Nate block.</p>
|
||
</div>
|
||
{FIELDS.filter(f => ['aboutEyebrow', 'aboutNate'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline ? (
|
||
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={6} />
|
||
) : (
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
)}
|
||
</div>
|
||
))}
|
||
<div className="admin-field">
|
||
<label htmlFor="field-aboutPhotoUrl">Portrait Image URL</label>
|
||
<input id="field-aboutPhotoUrl" type="text" value={form.aboutPhotoUrl ?? ''} onChange={e => handleChange('aboutPhotoUrl', e.target.value)} />
|
||
{renderImageAssetSelector(form.aboutPhotoUrl ?? '', value => handleChange('aboutPhotoUrl', value), 'field-aboutPhotoUrl-asset')}
|
||
<p className="admin-field-note">Portrait shown on the left side of the About page.</p>
|
||
{renderImagePreview(form.aboutPhotoUrl ?? '', 'About page portrait preview')}
|
||
</div>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* ABOUT — TESTIMONIALS */}
|
||
{adminView === 'about:testimonials' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Testimonials</h2>
|
||
<p>Listener quotes shown on the About page and homepage. Add Apple Podcast reviews, emails, or any kind words.</p>
|
||
</div>
|
||
{(form.testimonials ?? []).length === 0 && (
|
||
<p className="admin-stats-note">No testimonials yet. Add one below.</p>
|
||
)}
|
||
{(form.testimonials ?? []).map(item => (
|
||
<AdminCollapsibleCard key={item.id} title={item.name || 'Unnamed listener'} subtitle={item.source || 'No source'}>
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`testimonial-quote-${item.id}`}>Quote</label>
|
||
<textarea id={`testimonial-quote-${item.id}`} rows={3} placeholder="What they said…" value={item.quote} onChange={e => setForm(f => ({ ...f, testimonials: (f.testimonials ?? []).map(t => t.id === item.id ? { ...t, quote: e.target.value } : t) }))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`testimonial-name-${item.id}`}>Name</label>
|
||
<input id={`testimonial-name-${item.id}`} type="text" placeholder="e.g. Sarah M." value={item.name} onChange={e => setForm(f => ({ ...f, testimonials: (f.testimonials ?? []).map(t => t.id === item.id ? { ...t, name: e.target.value } : t) }))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`testimonial-source-${item.id}`}>Source (optional)</label>
|
||
<input id={`testimonial-source-${item.id}`} type="text" placeholder="e.g. Apple Podcasts" value={item.source ?? ''} onChange={e => setForm(f => ({ ...f, testimonials: (f.testimonials ?? []).map(t => t.id === item.id ? { ...t, source: e.target.value } : t) }))} />
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => setForm(f => ({ ...f, testimonials: (f.testimonials ?? []).filter(t => t.id !== item.id) }))}>Remove</button>
|
||
</div>
|
||
</AdminCollapsibleCard>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={() => setForm(f => ({ ...f, testimonials: [...(f.testimonials ?? []), { id: Date.now().toString(36), quote: '', name: '', source: '' }] }))}>
|
||
+ Add Testimonial
|
||
</button>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* CONTACT — PROFILE */}
|
||
{adminView === 'contact:profile' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Profile</h2>
|
||
<p>Name, role, section eyebrow, page heading, and profile photo shown on the contact page.</p>
|
||
</div>
|
||
{FIELDS.filter(f => ['contactPhotoUrl', 'contactEyebrow', 'contactHeading', 'contactName', 'contactRole'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline ? (
|
||
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
) : (
|
||
<>
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
||
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImagePreview(form[key] as string, `${label} preview`)}
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* CONTACT — COPY & SCRIPTURE */}
|
||
{adminView === 'contact:copy' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Copy & Scripture</h2>
|
||
<p>Intro paragraph, quote, contact bullet points, and the scripture block.</p>
|
||
</div>
|
||
{FIELDS.filter(f => ['contactQuote', 'contactIntro', 'contactPoint1', 'contactPoint2', 'contactVerse', 'contactVerseRef'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline ? (
|
||
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
) : (
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
)}
|
||
</div>
|
||
))}
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* PODCAST — CURRENT SERIES */}
|
||
{adminView === 'podcast:current-series' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Current Series</h2>
|
||
<p>Update the active series card shown on the homepage and episodes page.</p>
|
||
</div>
|
||
{FIELDS.filter(f => f.section === 'series' && !['studyGuideTitle', 'studyGuideDescription', 'studyGuideUrl'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline ? (
|
||
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
) : (
|
||
<>
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
||
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImagePreview(form[key] as string, `${label} preview`)}
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
<p className="admin-stats-note">Companion study guide title, description, and Amazon link are now managed in Resources.</p>
|
||
{renderSaveStatus()}
|
||
<div className="admin-end-series-wrap">
|
||
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', margin: '2rem 0 1.5rem' }} />
|
||
{!endSeriesOpen ? (
|
||
<button type="button" className="btn-danger-outline" onClick={() => setEndSeriesOpen(true)}>
|
||
End Current Series & Start New Book
|
||
</button>
|
||
) : (
|
||
<div className="admin-end-series-panel">
|
||
<h3 style={{ marginBottom: '0.5rem' }}>Archive “{form.seriesTitle}” to Finished Books</h3>
|
||
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
|
||
This will save the current series as a finished book and clear Episode Highlights. Fill in the new series details below, then confirm.
|
||
</p>
|
||
<div className="admin-field">
|
||
<label>New Series Title</label>
|
||
<input type="text" placeholder="e.g. Study of Romans" value={endSeriesNewTitle} onChange={e => setEndSeriesNewTitle(e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>New Series Image URL</label>
|
||
<input type="text" placeholder="/images/romans-cover.png" value={endSeriesNewImage} onChange={e => setEndSeriesNewImage(e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>New Season Number</label>
|
||
<input type="number" placeholder="2" value={endSeriesNewSeason} onChange={e => setEndSeriesNewSeason(e.target.value)} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
|
||
<button
|
||
type="button"
|
||
className="btn-primary"
|
||
disabled={!endSeriesNewTitle.trim() || !endSeriesNewSeason.trim()}
|
||
onClick={() => {
|
||
const archivedBook = {
|
||
id: Date.now().toString(36),
|
||
title: form.seriesTitle,
|
||
description: form.seriesDescription,
|
||
imageUrl: form.seriesImageUrl,
|
||
season: parseInt(endSeriesNewSeason) - 1 || 1,
|
||
}
|
||
setForm(f => ({
|
||
...f,
|
||
finishedBooks: [...(f.finishedBooks ?? []), archivedBook],
|
||
podcastFeaturedLinks: [],
|
||
seriesTitle: endSeriesNewTitle.trim(),
|
||
seriesImageUrl: endSeriesNewImage.trim() || f.seriesImageUrl,
|
||
seriesLabel: 'Now Playing',
|
||
}))
|
||
setEndSeriesOpen(false)
|
||
setEndSeriesNewTitle('')
|
||
setEndSeriesNewImage('')
|
||
setEndSeriesNewSeason('')
|
||
}}
|
||
>
|
||
Confirm & Archive
|
||
</button>
|
||
<button type="button" className="btn-secondary" onClick={() => setEndSeriesOpen(false)}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* PODCAST — EPISODE HIGHLIGHTS */}
|
||
{adminView === 'podcast:highlights' && (() => {
|
||
const activeHighlight = selectedHighlightId ? (form.podcastFeaturedLinks ?? []).find(h => h.id === selectedHighlightId) ?? null : null
|
||
return (
|
||
<section className="admin-panel-section" aria-label="Episode highlights">
|
||
|
||
{/* List view */}
|
||
{!activeHighlight && (
|
||
<>
|
||
<div className="admin-panel-head">
|
||
<h2>Episode Highlights</h2>
|
||
<p>Featured episodes shown on the Episodes page. Each highlight gets its own detail page at <code>/episodes/[id]</code>.</p>
|
||
</div>
|
||
{(form.podcastFeaturedLinks ?? []).length === 0 && <p className="admin-stats-note">No episode highlights yet. Add one below.</p>}
|
||
<div className="study-lesson-list-header">
|
||
<span style={{ flex: '0 0 70px' }}>Ep #</span>
|
||
<span style={{ flex: 1 }}>Title</span>
|
||
<span style={{ flex: '0 0 80px' }}>Audio</span>
|
||
<span style={{ width: 160 }} />
|
||
</div>
|
||
{(form.podcastFeaturedLinks ?? []).map(item => (
|
||
<div key={item.id} className="study-lesson-row">
|
||
<span className="study-lesson-ref" style={{ width: 70 }}>{item.episodeNumber ? `Ep. ${item.episodeNumber}` : '—'}</span>
|
||
<span className="study-lesson-title">{item.title || <em style={{ opacity: 0.5 }}>Untitled</em>}</span>
|
||
<span className={`study-lesson-pill ${item.embedUrl ? 'live' : 'draft'}`} style={{ width: 68 }}>{item.embedUrl ? 'Audio' : 'No audio'}</span>
|
||
<div className="study-lesson-actions">
|
||
<button type="button" className="btn-admin-ghost" onClick={() => setSelectedHighlightId(item.id)}>Edit</button>
|
||
{item.id && <a href={`/episodes/${item.id}`} target="_blank" rel="noopener noreferrer" className="btn-admin-ghost">Preview</a>}
|
||
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.8rem', padding: '0.2rem 0.5rem' }} onClick={() => removePodcastLink(item.id)}>✕</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" style={{ marginTop: '0.75rem' }} onClick={addPodcastLink}>+ Add Episode Highlight</button>
|
||
{renderSaveStatus()}
|
||
</>
|
||
)}
|
||
|
||
{/* Detail view */}
|
||
{activeHighlight && (
|
||
<>
|
||
<div className="study-drill-header">
|
||
<button type="button" className="study-back-btn" onClick={() => setSelectedHighlightId(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>
|
||
Episode Highlights
|
||
</button>
|
||
{activeHighlight.id && <a href={`/episodes/${activeHighlight.id}`} target="_blank" rel="noopener noreferrer" className="btn-admin-ghost">Preview ↗</a>}
|
||
</div>
|
||
<div className="admin-panel-subhead" style={{ marginBottom: '1rem' }}>
|
||
<h3>{activeHighlight.title || 'Untitled Highlight'}{activeHighlight.episodeNumber ? ` · Ep. ${activeHighlight.episodeNumber}` : ''}</h3>
|
||
</div>
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`podcast-ep-${activeHighlight.id}`}>Episode Number</label>
|
||
<input id={`podcast-ep-${activeHighlight.id}`} type="text" placeholder="e.g. 42" value={activeHighlight.episodeNumber ?? ''} onChange={e => updatePodcastLink(activeHighlight.id, 'episodeNumber', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`podcast-title-${activeHighlight.id}`}>Title</label>
|
||
<input id={`podcast-title-${activeHighlight.id}`} type="text" value={activeHighlight.title} onChange={e => updatePodcastLink(activeHighlight.id, 'title', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`podcast-summary-${activeHighlight.id}`}>Summary</label>
|
||
<textarea id={`podcast-summary-${activeHighlight.id}`} rows={2} value={activeHighlight.summary} onChange={e => updatePodcastLink(activeHighlight.id, 'summary', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`podcast-url-${activeHighlight.id}`}>Platform URL (external link)</label>
|
||
<input id={`podcast-url-${activeHighlight.id}`} type="text" placeholder="https://open.spotify.com/..." value={activeHighlight.url} onChange={e => updatePodcastLink(activeHighlight.id, 'url', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`podcast-embed-${activeHighlight.id}`}>Episode Audio (optional — enables in-page player)</label>
|
||
<select id={`podcast-embed-${activeHighlight.id}`} value={activeHighlight.embedUrl ?? ''} onChange={e => updatePodcastLink(activeHighlight.id, 'embedUrl', e.target.value)}>
|
||
<option value="">— No audio —</option>
|
||
{rssEpisodes.map(ep => <option key={ep.audioUrl} value={ep.audioUrl}>{ep.title}</option>)}
|
||
{activeHighlight.embedUrl && !rssEpisodes.some(ep => ep.audioUrl === activeHighlight.embedUrl) && (
|
||
<option value={activeHighlight.embedUrl}>{activeHighlight.embedUrl}</option>
|
||
)}
|
||
</select>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`podcast-notes-summary-${activeHighlight.id}`}>Episode Summary</label>
|
||
<textarea
|
||
id={`podcast-notes-summary-${activeHighlight.id}`}
|
||
rows={3}
|
||
placeholder="2–3 sentences summarizing the episode..."
|
||
value={activeHighlight.showNotesSummary ?? (activeHighlight.showNotes && !(activeHighlight.showNotesThemes?.length) ? activeHighlight.showNotes : '')}
|
||
onChange={e => updatePodcastLink(activeHighlight.id, 'showNotesSummary', e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`podcast-notes-themes-${activeHighlight.id}`}>Key Themes (one per line)</label>
|
||
<textarea
|
||
id={`podcast-notes-themes-${activeHighlight.id}`}
|
||
rows={3}
|
||
placeholder="Faith and doubt Prayer in suffering Community"
|
||
value={(activeHighlight.showNotesThemes ?? []).join('\n')}
|
||
onChange={e => updatePodcastLinkStringArray(activeHighlight.id, 'showNotesThemes', e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`podcast-notes-scripture-${activeHighlight.id}`}>Scripture References (one per line)</label>
|
||
<textarea
|
||
id={`podcast-notes-scripture-${activeHighlight.id}`}
|
||
rows={3}
|
||
placeholder="Romans 8:28 Psalm 23:4"
|
||
value={(activeHighlight.showNotesScripture ?? []).join('\n')}
|
||
onChange={e => updatePodcastLinkStringArray(activeHighlight.id, 'showNotesScripture', e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`podcast-notes-quotes-${activeHighlight.id}`}>Key Quotes (one per line)</label>
|
||
<textarea
|
||
id={`podcast-notes-quotes-${activeHighlight.id}`}
|
||
rows={3}
|
||
placeholder="God meets us in the middle of our mess."
|
||
value={(activeHighlight.showNotesQuotes ?? []).join('\n')}
|
||
onChange={e => updatePodcastLinkStringArray(activeHighlight.id, 'showNotesQuotes', e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`podcast-notes-resources-${activeHighlight.id}`}>Resources & Links (one per line)</label>
|
||
<textarea
|
||
id={`podcast-notes-resources-${activeHighlight.id}`}
|
||
rows={3}
|
||
placeholder="Book: The Ruthless Elimination of Hurry by John Mark Comer https://example.com"
|
||
value={(activeHighlight.showNotesResources ?? []).join('\n')}
|
||
onChange={e => updatePodcastLinkStringArray(activeHighlight.id, 'showNotesResources', e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`podcast-dq-${activeHighlight.id}`}>Discussion Questions (one per line)</label>
|
||
<textarea id={`podcast-dq-${activeHighlight.id}`} rows={5} placeholder="What stood out to you in this passage?" value={(activeHighlight.discussionQuestions ?? []).join('\n')} onChange={e => updatePodcastLinkQuestions(activeHighlight.id, e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" style={{ marginTop: '1rem' }} onClick={() => { removePodcastLink(activeHighlight.id); setSelectedHighlightId(null) }}>Remove Highlight</button>
|
||
{renderSaveStatus()}
|
||
</>
|
||
)}
|
||
|
||
</section>
|
||
)
|
||
})()}
|
||
|
||
{/* PODCAST — FINISHED BOOKS */}
|
||
{adminView === 'podcast:finished-books' && (
|
||
<section className="admin-panel-section" aria-label="Finished books">
|
||
<div className="admin-panel-head">
|
||
<h2>Finished Books</h2>
|
||
<p>Series that have been completed. Each one gets a playlist page at <code>/finished/:id</code>.</p>
|
||
</div>
|
||
{(form.finishedBooks ?? []).length === 0 && (
|
||
<p className="admin-stats-note">No finished books yet. Use “End Current Series” on the Current Series tab to archive one, or add one manually below.</p>
|
||
)}
|
||
{(form.finishedBooks ?? []).map(book => (
|
||
<AdminCollapsibleCard key={book.id} title={book.title || 'Untitled'} subtitle={`Season ${book.season}`}>
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label>Title</label>
|
||
<input type="text" value={book.title} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, title: e.target.value } : b) }))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Description</label>
|
||
<textarea rows={3} value={book.description} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, description: e.target.value } : b) }))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Cover Image URL</label>
|
||
<input type="text" value={book.imageUrl} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, imageUrl: e.target.value } : b) }))} />
|
||
{renderImageAssetSelector(book.imageUrl, value => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, imageUrl: value } : b) })), `finished-book-img-${book.id}`)}
|
||
{renderImagePreview(book.imageUrl, `${book.title} cover`)}
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Season Number</label>
|
||
<input type="number" value={book.season} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, season: parseInt(e.target.value) || 1 } : b) }))} />
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).filter(b => b.id !== book.id) }))}>Remove</button>
|
||
</div>
|
||
</AdminCollapsibleCard>
|
||
))}
|
||
<button
|
||
type="button"
|
||
className="btn-admin-add"
|
||
onClick={() => setForm(f => ({ ...f, finishedBooks: [...(f.finishedBooks ?? []), { id: Date.now().toString(36), title: '', description: '', imageUrl: '', season: 1 }] }))}
|
||
>
|
||
+ Add Finished Book
|
||
</button>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* DOWNLOADS */}
|
||
{adminView === 'downloads' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Resources</h2>
|
||
<p>Manage the featured study guide, current download library, and previous series downloads.</p>
|
||
</div>
|
||
|
||
{renderSectionJumpNav('downloads')}
|
||
|
||
<div className="admin-content-summary">
|
||
<div className="admin-summary-card"><h3>Companion Study Guide</h3><p>Included</p></div>
|
||
<div className="admin-summary-card"><h3>Current Downloads</h3><p>{resourceLinks.length}</p></div>
|
||
</div>
|
||
|
||
<div className="admin-section-header" id="downloads-study-guide">
|
||
<h3>Companion Study Guide</h3>
|
||
<p>This is the featured primary download at the top of the Resources page.</p>
|
||
</div>
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor="resources-study-guide-title">Title</label>
|
||
<input id="resources-study-guide-title" type="text" value={form.studyGuideTitle} placeholder="Companion Study Guide" onChange={e => handleChange('studyGuideTitle', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="resources-study-guide-description">Description</label>
|
||
<textarea id="resources-study-guide-description" rows={3} value={form.studyGuideDescription} placeholder="Describe the study guide download." onChange={e => handleChange('studyGuideDescription', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="resources-study-guide-download-url">Primary Download URL</label>
|
||
<input id="resources-study-guide-download-url" type="url" value={form.studyGuideDownloadUrl} placeholder="/uploads/new-guide.pdf or https://..." onChange={e => handleChange('studyGuideDownloadUrl', e.target.value)} />
|
||
{renderFileAssetSelector(form.studyGuideDownloadUrl, value => handleChange('studyGuideDownloadUrl', value), 'resources-study-guide-download-url-asset')}
|
||
<p className="admin-stats-note">This is the file URL the main guide download form will deliver after submission.</p>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="resources-study-guide-url">Printed Copy URL (Amazon button)</label>
|
||
<input id="resources-study-guide-url" type="url" value={form.studyGuideUrl} placeholder="https://..." onChange={e => handleChange('studyGuideUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="resources-study-guide-amazon-label">Amazon Button Label</label>
|
||
<input id="resources-study-guide-amazon-label" type="text" value={form.studyGuideAmazonButtonLabel} placeholder="Get it on Amazon" onChange={e => handleChange('studyGuideAmazonButtonLabel', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="resources-study-guide-apple-books-url">Apple Books URL (optional)</label>
|
||
<input id="resources-study-guide-apple-books-url" type="url" value={form.studyGuideAppleBooksUrl} placeholder="https://books.apple.com/..." onChange={e => handleChange('studyGuideAppleBooksUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="resources-study-guide-apple-books-label">Apple Books Button Label</label>
|
||
<input id="resources-study-guide-apple-books-label" type="text" value={form.studyGuideAppleBooksLabel} placeholder="Get it on Apple Books" onChange={e => handleChange('studyGuideAppleBooksLabel', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Download Library — master-detail */}
|
||
{(() => {
|
||
const activeRes = selectedResourceId ? resourceLinks.find(l => l.id === selectedResourceId) ?? null : null
|
||
return (
|
||
<>
|
||
<div className="admin-section-header" id="downloads-library" style={activeRes ? { display: 'none' } : {}}>
|
||
<h3>Download Library</h3>
|
||
<p>Manage the current downloads shown below the featured guide.</p>
|
||
</div>
|
||
|
||
{!activeRes && (
|
||
<>
|
||
{resourceLinks.length === 0 && <p className="admin-stats-note">No resources yet. Add one below.</p>}
|
||
<div className="study-lesson-list-header">
|
||
<span style={{ flex: 1 }}>Label</span>
|
||
<span style={{ flex: '0 0 140px' }}>Tags</span>
|
||
<span style={{ width: 130 }} />
|
||
</div>
|
||
{resourceLinks.map(link => (
|
||
<div key={link.id} className="study-lesson-row">
|
||
<span className="study-lesson-title">{link.label || <em style={{ opacity: 0.5 }}>Untitled</em>}</span>
|
||
<span className="study-lesson-ref" style={{ width: 140, fontSize: '0.78rem' }}>{(link.tags ?? []).slice(0, 2).join(', ') || '—'}</span>
|
||
<div className="study-lesson-actions">
|
||
<button type="button" className="btn-admin-ghost" onClick={() => setSelectedResourceId(link.id)}>Edit</button>
|
||
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.8rem', padding: '0.2rem 0.5rem' }} onClick={() => removeLink(link.id)}>✕</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" style={{ marginTop: '0.75rem' }} onClick={addResource}>+ Add Download</button>
|
||
</>
|
||
)}
|
||
|
||
{activeRes && (
|
||
<>
|
||
<div className="study-drill-header">
|
||
<button type="button" className="study-back-btn" onClick={() => setSelectedResourceId(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>
|
||
Download Library
|
||
</button>
|
||
</div>
|
||
<div className="admin-panel-subhead" style={{ marginBottom: '1rem' }}>
|
||
<h3>{activeRes.label || 'Untitled Download'}</h3>
|
||
</div>
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-label-${activeRes.id}`}>Label</label>
|
||
<input id={`resource-label-${activeRes.id}`} type="text" value={activeRes.label} placeholder="Resource title" onChange={e => updateLink(activeRes.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-url-${activeRes.id}`}>URL</label>
|
||
<input id={`resource-url-${activeRes.id}`} type="url" value={activeRes.url} placeholder="https://..." onChange={e => updateLink(activeRes.id, 'url', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-description-${activeRes.id}`}>Description</label>
|
||
<textarea id={`resource-description-${activeRes.id}`} rows={3} value={activeRes.description ?? ''} placeholder="Description shown on the download page" onChange={e => updateLink(activeRes.id, 'description', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-amazon-url-${activeRes.id}`}>Amazon URL (optional)</label>
|
||
<input id={`resource-amazon-url-${activeRes.id}`} type="url" value={activeRes.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateLink(activeRes.id, 'amazonUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-amazon-label-${activeRes.id}`}>Amazon Button Label</label>
|
||
<input id={`resource-amazon-label-${activeRes.id}`} type="text" value={activeRes.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateLink(activeRes.id, 'amazonLabel', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-apple-books-url-${activeRes.id}`}>Apple Books URL (optional)</label>
|
||
<input id={`resource-apple-books-url-${activeRes.id}`} type="url" value={activeRes.appleBooksUrl ?? ''} placeholder="https://books.apple.com/..." onChange={e => updateLink(activeRes.id, 'appleBooksUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-apple-books-label-${activeRes.id}`}>Apple Books Button Label</label>
|
||
<input id={`resource-apple-books-label-${activeRes.id}`} type="text" value={activeRes.appleBooksLabel ?? ''} placeholder="Get it on Apple Books" onChange={e => updateLink(activeRes.id, 'appleBooksLabel', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-image-${activeRes.id}`}>Image URL</label>
|
||
<input id={`resource-image-${activeRes.id}`} type="text" value={activeRes.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(activeRes.id, 'imageUrl', e.target.value)} />
|
||
{renderImageAssetSelector(activeRes.imageUrl, value => updateLink(activeRes.id, 'imageUrl', value), `resource-image-${activeRes.id}-asset`)}
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-tags-${activeRes.id}`}>Tags</label>
|
||
<input id={`resource-tags-${activeRes.id}`} type="text" value={(activeRes.tags ?? []).join(', ')} placeholder="sermon, bible study, faith" onChange={e => updateLink(activeRes.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" style={{ marginTop: '1rem' }} onClick={() => { removeLink(activeRes.id); setSelectedResourceId(null) }}>Remove Download</button>
|
||
</>
|
||
)}
|
||
</>
|
||
)
|
||
})()}
|
||
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* CUSTOM LINKS */}
|
||
{adminView === 'custom-links' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Custom Links</h2>
|
||
<p>Add links to show in platform buttons, footer navigation, or the More Resources download library.</p>
|
||
</div>
|
||
|
||
<div className="admin-panel-subhead">
|
||
<h3>Homepage External Links</h3>
|
||
<p>Links shown on the homepage, separate from the footer.</p>
|
||
</div>
|
||
{(form.customLinks ?? []).filter(link => link.placement === 'externalSites').length === 0 && (
|
||
<p className="admin-stats-note">No homepage external links yet.</p>
|
||
)}
|
||
{(form.customLinks ?? []).filter(link => link.placement === 'externalSites').map(link => (
|
||
<div key={link.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`external-site-link-label-${link.id}`}>Label</label>
|
||
<input id={`external-site-link-label-${link.id}`} type="text" value={link.label} placeholder="e.g. Bible study partner" onChange={e => updateLink(link.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`external-site-link-url-${link.id}`}>URL</label>
|
||
<input id={`external-site-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateLink(link.id, 'url', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`external-site-link-description-${link.id}`}>Description</label>
|
||
<textarea id={`external-site-link-description-${link.id}`} rows={3} value={link.description ?? ''} placeholder="Short description shown on the homepage" onChange={e => updateLink(link.id, 'description', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`external-site-link-image-${link.id}`}>Image URL</label>
|
||
<input id={`external-site-link-image-${link.id}`} type="text" value={link.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(link.id, 'imageUrl', e.target.value)} />
|
||
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `external-site-link-image-${link.id}-asset`)}
|
||
{renderImagePreview(link.imageUrl, `${link.label || 'External site link'} image preview`)}
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`external-site-link-tags-${link.id}`}>Tags</label>
|
||
<input id={`external-site-link-tags-${link.id}`} type="text" value={(link.tags ?? []).join(', ')} placeholder="external, homepage, ministry" onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Quick Action</label>
|
||
<button type="button" className="btn-admin-apply" onClick={() => updateLink(link.id, 'placement', 'footer')}>Move to Footer Nav</button>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Shows On</label>
|
||
<p className="admin-stats-note">{getCustomLinkPlacementLabel(link.placement)}</p>
|
||
<a href={getCustomLinkPreviewPath(link.placement)} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary">Preview Destination Page</a>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addExternalSiteLink}>+ Add Homepage External Link</button>
|
||
|
||
<div className="admin-panel-subhead">
|
||
<h3>Platform + Footer Links</h3>
|
||
<p>Links that appear on the listen buttons row or footer nav.</p>
|
||
</div>
|
||
{(form.customLinks ?? []).filter(link => link.placement !== 'resources' && link.placement !== 'otherSites' && link.placement !== 'externalSites').length === 0 && (
|
||
<p className="admin-stats-note">No custom links yet.</p>
|
||
)}
|
||
{(form.customLinks ?? []).filter(link => link.placement !== 'resources' && link.placement !== 'otherSites' && link.placement !== 'externalSites').map(link => (
|
||
<div key={link.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`link-label-${link.id}`}>Label</label>
|
||
<input id={`link-label-${link.id}`} type="text" value={link.label} placeholder="e.g. iHeart Radio" onChange={e => updateLink(link.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`link-url-${link.id}`}>URL</label>
|
||
<input id={`link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateLink(link.id, 'url', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`link-image-${link.id}`}>Image URL</label>
|
||
<input id={`link-image-${link.id}`} type="text" value={link.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(link.id, 'imageUrl', e.target.value)} />
|
||
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `link-image-${link.id}-asset`)}
|
||
{renderImagePreview(link.imageUrl, `${link.label || 'Custom link'} image preview`)}
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`link-tags-${link.id}`}>Tags</label>
|
||
<input id={`link-tags-${link.id}`} type="text" value={(link.tags ?? []).join(', ')} placeholder="listen, podcast, study" onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`link-placement-${link.id}`}>Show in</label>
|
||
<select id={`link-placement-${link.id}`} value={link.placement} onChange={e => updateLink(link.id, 'placement', e.target.value)}>
|
||
<option value="platforms">Platform Buttons (Listen section)</option>
|
||
<option value="footer">Footer Nav</option>
|
||
<option value="resources">More Resources Section</option>
|
||
</select>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Quick Action</label>
|
||
<button type="button" className="btn-admin-apply" onClick={() => moveLinkToResources(link.id)}>Move to Resources</button>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Shows On</label>
|
||
<p className="admin-stats-note">{getCustomLinkPlacementLabel(link.placement)}</p>
|
||
<a href={getCustomLinkPreviewPath(link.placement)} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary">Preview Destination Page</a>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addLink}>+ Add Platform/Footer Link</button>
|
||
|
||
<div className="admin-panel-subhead">
|
||
<h3>Other Site Links</h3>
|
||
<p>External links you want to surface in the footer dropdown.</p>
|
||
</div>
|
||
{(form.customLinks ?? []).filter(link => link.placement === 'otherSites').length === 0 && (
|
||
<p className="admin-stats-note">No other site links yet.</p>
|
||
)}
|
||
{(form.customLinks ?? []).filter(link => link.placement === 'otherSites').map(link => (
|
||
<div key={link.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`other-site-link-label-${link.id}`}>Label</label>
|
||
<input id={`other-site-link-label-${link.id}`} type="text" value={link.label} placeholder="e.g. Recommended ministry" onChange={e => updateLink(link.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`other-site-link-url-${link.id}`}>URL</label>
|
||
<input id={`other-site-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateLink(link.id, 'url', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`other-site-link-image-${link.id}`}>Image URL</label>
|
||
<input id={`other-site-link-image-${link.id}`} type="text" value={link.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(link.id, 'imageUrl', e.target.value)} />
|
||
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `other-site-link-image-${link.id}-asset`)}
|
||
{renderImagePreview(link.imageUrl, `${link.label || 'Other site link'} image preview`)}
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`other-site-link-tags-${link.id}`}>Tags</label>
|
||
<input id={`other-site-link-tags-${link.id}`} type="text" value={(link.tags ?? []).join(', ')} placeholder="external, ministry, study" onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Quick Action</label>
|
||
<button type="button" className="btn-admin-apply" onClick={() => updateLink(link.id, 'placement', 'footer')}>Move to Footer Nav</button>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Shows On</label>
|
||
<p className="admin-stats-note">{getCustomLinkPlacementLabel(link.placement)}</p>
|
||
<a href={getCustomLinkPreviewPath(link.placement)} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary">Preview Destination Page</a>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addOtherSiteLink}>+ Add Other Site Link</button>
|
||
|
||
<div className="admin-panel-subhead">
|
||
<h3>More Resources Links</h3>
|
||
<p>These create cards in Resources → More guides, worksheets, and past study downloads.</p>
|
||
</div>
|
||
{(form.customLinks ?? []).filter(link => link.placement === 'resources').length === 0 && (
|
||
<p className="admin-stats-note">No resource links yet.</p>
|
||
)}
|
||
{(form.customLinks ?? []).filter(link => link.placement === 'resources').map(link => (
|
||
<div key={link.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-label-${link.id}`}>Label</label>
|
||
<input id={`resources-link-label-${link.id}`} type="text" value={link.label} placeholder="Resource title" onChange={e => updateLink(link.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-url-${link.id}`}>Download URL</label>
|
||
<input id={`resources-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateLink(link.id, 'url', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-description-${link.id}`}>Description</label>
|
||
<textarea id={`resources-link-description-${link.id}`} rows={3} value={link.description ?? ''} placeholder="Description shown on the download page" onChange={e => updateLink(link.id, 'description', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-image-${link.id}`}>Image URL</label>
|
||
<input id={`resources-link-image-${link.id}`} type="text" value={link.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(link.id, 'imageUrl', e.target.value)} />
|
||
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `resources-link-image-${link.id}-asset`)}
|
||
{renderImagePreview(link.imageUrl, `${link.label || 'Resource link'} image preview`)}
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-amazon-url-${link.id}`}>Amazon URL (optional)</label>
|
||
<input id={`resources-link-amazon-url-${link.id}`} type="url" value={link.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateLink(link.id, 'amazonUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-amazon-label-${link.id}`}>Amazon Button Label</label>
|
||
<input id={`resources-link-amazon-label-${link.id}`} type="text" value={link.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateLink(link.id, 'amazonLabel', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-apple-books-url-${link.id}`}>Apple Books URL (optional)</label>
|
||
<input id={`resources-link-apple-books-url-${link.id}`} type="url" value={link.appleBooksUrl ?? ''} placeholder="https://books.apple.com/..." onChange={e => updateLink(link.id, 'appleBooksUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-apple-books-label-${link.id}`}>Apple Books Button Label</label>
|
||
<input id={`resources-link-apple-books-label-${link.id}`} type="text" value={link.appleBooksLabel ?? ''} placeholder="Get it on Apple Books" onChange={e => updateLink(link.id, 'appleBooksLabel', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-tags-${link.id}`}>Tags</label>
|
||
<input id={`resources-link-tags-${link.id}`} type="text" value={(link.tags ?? []).join(', ')} placeholder="worksheet, study, pdf" onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Quick Action</label>
|
||
<button type="button" className="btn-admin-apply" onClick={() => updateLink(link.id, 'placement', 'platforms')}>Move to Platform Buttons</button>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Shows On</label>
|
||
<p className="admin-stats-note">{getCustomLinkPlacementLabel(link.placement)}</p>
|
||
<a href={getCustomLinkPreviewPath(link.placement)} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary">Preview Destination Page</a>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addResource}>+ Add Resource Link</button>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* CONTENT BLOCKS */}
|
||
{adminView === 'content-blocks' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Content Blocks</h2>
|
||
<p>Add extra text sections to any page. Choose which page each block appears on.</p>
|
||
</div>
|
||
{(form.customBlocks ?? []).length === 0 && <p className="admin-stats-note">No custom content blocks yet.</p>}
|
||
{(form.customBlocks ?? []).map(block => (
|
||
<div key={block.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`block-page-${block.id}`}>Page</label>
|
||
<select id={`block-page-${block.id}`} value={block.page ?? 'downloads'} onChange={e => updateBlock(block.id, 'page', e.target.value)}>
|
||
<option value="homepage">Homepage</option>
|
||
<option value="start-here">Start Here</option>
|
||
<option value="episodes">Episodes</option>
|
||
<option value="downloads">Resources</option>
|
||
<option value="about">About</option>
|
||
<option value="contact">Contact</option>
|
||
<option value="questions">Q&A</option>
|
||
</select>
|
||
<p className="admin-stats-note">Shows on: {getContentBlockPageLabel(block.page ?? 'downloads')}</p>
|
||
<a href={getContentBlockPreviewPath(block.page ?? 'downloads')} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary">Preview Destination Page</a>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`block-heading-${block.id}`}>Heading</label>
|
||
<input id={`block-heading-${block.id}`} type="text" value={block.heading} placeholder="Section heading" onChange={e => updateBlock(block.id, 'heading', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`block-body-${block.id}`}>Body Text</label>
|
||
<textarea id={`block-body-${block.id}`} value={block.body} rows={3} placeholder="Write your content here…" onChange={e => updateBlock(block.id, 'body', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`block-link-url-${block.id}`}>Button Link URL <span className="admin-field-optional">(optional)</span></label>
|
||
<input id={`block-link-url-${block.id}`} type="url" value={block.linkUrl ?? ''} placeholder="https://…" onChange={e => updateBlock(block.id, 'linkUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`block-link-label-${block.id}`}>Button Label <span className="admin-field-optional">(optional)</span></label>
|
||
<input id={`block-link-label-${block.id}`} type="text" value={block.linkLabel ?? ''} placeholder="e.g. Get the book on Amazon" onChange={e => updateBlock(block.id, 'linkLabel', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeBlock(block.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addBlock}>+ Add Content Block</button>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* BOOKS */}
|
||
{adminView === 'books' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Books</h2>
|
||
<p>Amazon books shown in the Reading List section on the Resources page. Add a cover image, description, and Amazon link for each book.</p>
|
||
</div>
|
||
{(form.customLinks ?? []).filter(l => l.placement === 'books').length === 0 && <p className="admin-stats-note">No books yet.</p>}
|
||
{(form.customLinks ?? []).filter(l => l.placement === 'books').map(book => (
|
||
<div key={book.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`book-title-${book.id}`}>Title</label>
|
||
<input id={`book-title-${book.id}`} type="text" value={book.label} placeholder="Book title" onChange={e => updateLink(book.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`book-description-${book.id}`}>Description</label>
|
||
<textarea id={`book-description-${book.id}`} rows={3} value={book.description ?? ''} placeholder="Short description of the book" onChange={e => updateLink(book.id, 'description', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`book-image-${book.id}`}>Cover Image URL</label>
|
||
<input id={`book-image-${book.id}`} type="text" value={book.imageUrl ?? ''} placeholder="/uploads/cover.jpg" onChange={e => updateLink(book.id, 'imageUrl', e.target.value)} />
|
||
{renderImageAssetSelector(book.imageUrl, value => updateLink(book.id, 'imageUrl', value), `book-image-${book.id}-asset`)}
|
||
{renderImagePreview(book.imageUrl, `${book.label || 'Book'} cover`)}
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`book-amazon-url-${book.id}`}>Amazon URL</label>
|
||
<input id={`book-amazon-url-${book.id}`} type="url" value={book.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateLink(book.id, 'amazonUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`book-amazon-label-${book.id}`}>Amazon Button Label</label>
|
||
<input id={`book-amazon-label-${book.id}`} type="text" value={book.amazonLabel ?? ''} placeholder="View on Amazon" onChange={e => updateLink(book.id, 'amazonLabel', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`book-apple-books-url-${book.id}`}>Apple Books URL (optional)</label>
|
||
<input id={`book-apple-books-url-${book.id}`} type="url" value={book.appleBooksUrl ?? ''} placeholder="https://books.apple.com/..." onChange={e => updateLink(book.id, 'appleBooksUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`book-apple-books-label-${book.id}`}>Apple Books Button Label</label>
|
||
<input id={`book-apple-books-label-${book.id}`} type="text" value={book.appleBooksLabel ?? ''} placeholder="Get it on Apple Books" onChange={e => updateLink(book.id, 'appleBooksLabel', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`book-url-${book.id}`}>Link URL <span className="admin-field-optional">(used if no Amazon URL is set)</span></label>
|
||
<input id={`book-url-${book.id}`} type="url" value={book.url ?? ''} placeholder="https://..." onChange={e => updateLink(book.id, 'url', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(book.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addBook}>+ Add Book</button>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* RECOMMENDED RESOURCES */}
|
||
{adminView === 'recommended-resources' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Recommended Resources</h2>
|
||
<p>Curated resources shown at the bottom of the Resources page. When at least one is added, it replaces the default Truth For Life widget. Add a title, image, link, and description for each resource.</p>
|
||
</div>
|
||
{(form.customLinks ?? []).filter(l => l.placement === 'recommended').length === 0 && (
|
||
<p className="admin-stats-note">No custom recommended resources yet — the Truth For Life widget shows by default.</p>
|
||
)}
|
||
{(form.customLinks ?? []).filter(l => l.placement === 'recommended').map(item => (
|
||
<div key={item.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`rec-title-${item.id}`}>Title</label>
|
||
<input id={`rec-title-${item.id}`} type="text" value={item.label} placeholder="Resource title" onChange={e => updateLink(item.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`rec-url-${item.id}`}>Link URL</label>
|
||
<input id={`rec-url-${item.id}`} type="url" value={item.url} placeholder="https://..." onChange={e => updateLink(item.id, 'url', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`rec-description-${item.id}`}>Description</label>
|
||
<textarea id={`rec-description-${item.id}`} rows={3} value={item.description ?? ''} placeholder="What is this resource?" onChange={e => updateLink(item.id, 'description', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`rec-image-${item.id}`}>Image URL</label>
|
||
<input id={`rec-image-${item.id}`} type="text" value={item.imageUrl ?? ''} placeholder="/uploads/example.jpg" onChange={e => updateLink(item.id, 'imageUrl', e.target.value)} />
|
||
{renderImageAssetSelector(item.imageUrl, value => updateLink(item.id, 'imageUrl', value), `rec-image-${item.id}-asset`)}
|
||
{renderImagePreview(item.imageUrl, `${item.label || 'Resource'} image`)}
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(item.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addRecommended}>+ Add Recommended Resource</button>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* STUDIES */}
|
||
{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>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>
|
||
|
||
<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-${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-${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-${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-${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-${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-${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-${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-${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-${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>
|
||
<button type="button" className="btn-admin-remove" style={{ marginTop: '1rem' }} onClick={() => { removeStudyProgram(activeStudy.id); setSelectedStudyId(null) }}>Remove Study</button>
|
||
</div>
|
||
</details>
|
||
|
||
<div className="admin-panel-subhead" style={{ marginBottom: '0.75rem' }}>
|
||
<h3>Lessons ({(activeStudy.sections ?? []).length})</h3>
|
||
</div>
|
||
|
||
{(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}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={({ active, over }) => {
|
||
const overId = over?.id
|
||
if (!overId || active.id === overId) return
|
||
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={(activeStudy.sections ?? []).map(s => s.id)} strategy={verticalListSortingStrategy}>
|
||
{(activeStudy.sections ?? []).map(section => (
|
||
<SortableLessonRow
|
||
key={section.id}
|
||
section={section}
|
||
study={activeStudy}
|
||
onEdit={() => setSelectedLessonId(section.id)}
|
||
removeStudySection={removeStudySection}
|
||
/>
|
||
))}
|
||
</SortableContext>
|
||
</DndContext>
|
||
|
||
<button type="button" className="btn-admin-add" style={{ marginTop: '0.75rem' }} onClick={() => addStudySection(activeStudy.id)}>+ Add Lesson</button>
|
||
{renderSaveStatus()}
|
||
</>
|
||
)}
|
||
|
||
{/* ── 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">
|
||
<div className="admin-panel-head">
|
||
<h2>Footer & Global Settings</h2>
|
||
<p>Edit the site footer text, platform links, header label, and analytics cookie banner.</p>
|
||
</div>
|
||
|
||
{renderSectionJumpNav('global')}
|
||
|
||
<div className="admin-panel-subhead" id="global-footer"><h3>Footer</h3></div>
|
||
{FIELDS.filter(f => f.section === 'global' && ['footerTitle','footerSubtitle','footerEmail','footerCopyright','footerPrivacyNote'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={3} />
|
||
: (
|
||
<>
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
{key.includes('ImageUrl') && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
||
{key.includes('ImageUrl') && renderImagePreview(form[key] as string, `${label} preview`)}
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="global-header"><h3>Header</h3></div>
|
||
{FIELDS.filter(f => f.section === 'global' && f.key === 'headerFollowLabel').map(({ key, label }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="global-platforms"><h3>Platform URLs</h3></div>
|
||
{FIELDS.filter(f => f.section === 'global' && f.key.startsWith('platform')).map(({ key, label }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="global-banner"><h3>Analytics Banner</h3></div>
|
||
{FIELDS.filter(f => f.section === 'global' && f.key === 'cookieBannerText').map(({ key, label }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={3} />
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="global-welcome-email"><h3>Welcome Email</h3></div>
|
||
{FIELDS.filter(f => f.section === 'global' && f.key.startsWith('welcomeEmail')).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={3} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
</div>
|
||
))}
|
||
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* EPISODE SCRIPTS */}
|
||
{adminView === 'podcast: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>
|
||
)}
|
||
|
||
{/* PODCAST — RSS FEED */}
|
||
{adminView === 'podcast:rss' && (() => {
|
||
const canonicalUrl = (form as { seo?: { canonicalUrl?: string } }).seo?.canonicalUrl?.replace(/\/$/, '') ?? ''
|
||
const feedUrl = canonicalUrl ? `${canonicalUrl}/feed.xml` : '/feed.xml'
|
||
const anchorFeedUrl = 'https://anchor.fm/s/11068d290/podcast/rss'
|
||
return (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>RSS Feed</h2>
|
||
<p>Your podcast feed is available at your own domain and proxies the Anchor source feed.</p>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||
<div style={{ background: '#1a1a16', border: '1px solid #2a2518', borderRadius: '10px', padding: '1.25rem 1.5rem' }}>
|
||
<p style={{ margin: '0 0 0.35rem', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: '#7a7060' }}>Your Feed URL</p>
|
||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<code style={{ fontSize: '0.95rem', color: '#e0d5b5', wordBreak: 'break-all' }}>{feedUrl}</code>
|
||
<button type="button" className="btn-admin-save" onClick={() => navigator.clipboard.writeText(feedUrl)}>Copy</button>
|
||
<a href={feedUrl} target="_blank" rel="noopener noreferrer" className="btn-admin-reset">Open ↗</a>
|
||
</div>
|
||
<p style={{ margin: '0.75rem 0 0', fontSize: '0.85rem', color: '#7a7060' }}>Submit this URL to Apple Podcasts, Spotify, and other directories to keep your feed on your own domain.</p>
|
||
</div>
|
||
<div style={{ background: '#1a1a16', border: '1px solid #2a2518', borderRadius: '10px', padding: '1.25rem 1.5rem' }}>
|
||
<p style={{ margin: '0 0 0.35rem', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: '#7a7060' }}>Anchor Source Feed</p>
|
||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<code style={{ fontSize: '0.88rem', color: '#7a7060', wordBreak: 'break-all' }}>{anchorFeedUrl}</code>
|
||
<a href={anchorFeedUrl} target="_blank" rel="noopener noreferrer" className="btn-admin-reset">Open ↗</a>
|
||
</div>
|
||
<p style={{ margin: '0.75rem 0 0', fontSize: '0.85rem', color: '#7a7060' }}>Your feed.xml proxies this source and rewrites the channel link to your domain. Episodes are cached for 30 minutes.</p>
|
||
</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} />
|
||
)}
|
||
|
||
{/* QUESTIONS */}
|
||
{adminView === 'questions' && (
|
||
<section className="admin-panel-section" aria-label="Q&A Management">
|
||
<div className="admin-panel-head">
|
||
<h2>Bible Questions & Answers</h2>
|
||
<p>Manage submitted questions, add manual questions, provide answers, and approve for public display.</p>
|
||
</div>
|
||
|
||
<div className="admin-actions admin-actions--maintenance" style={{ alignItems: 'center' }}>
|
||
<input
|
||
type="text"
|
||
value={questionSearch}
|
||
onChange={e => setQuestionSearch(e.target.value)}
|
||
placeholder="Search by name, question, or answer..."
|
||
style={{ minWidth: '320px', maxWidth: '520px', width: '100%' }}
|
||
/>
|
||
<button type="button" className={`btn-admin-reset${questionFilter === 'all' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('all')}>All</button>
|
||
<button type="button" className={`btn-admin-reset${questionFilter === 'pending' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('pending')}>Pending</button>
|
||
<button type="button" className={`btn-admin-reset${questionFilter === 'approved' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('approved')}>Approved</button>
|
||
<button type="button" className={`btn-admin-reset${questionFilter === 'answered' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('answered')}>Answered</button>
|
||
<button type="button" className={`btn-admin-reset${questionFilter === 'unanswered' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('unanswered')}>Unanswered</button>
|
||
</div>
|
||
<p className="admin-stats-note">Showing {filteredAdminQuestions.length} of {questions.length} questions.</p>
|
||
|
||
{selectedQuestionIds.size > 0 && (
|
||
<div className="admin-bulk-toolbar">
|
||
<span>{selectedQuestionIds.size} selected</span>
|
||
<button type="button" className="btn-admin-remove" onClick={handleBulkDeleteQuestions}>Delete Selected</button>
|
||
<button type="button" className="btn-admin-reset" onClick={() => setSelectedQuestionIds(new Set())}>Deselect All</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="admin-visits-table-wrap">
|
||
<h3>Add Question Manually</h3>
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor="manual-question-first-name">First Name</label>
|
||
<input
|
||
id="manual-question-first-name"
|
||
type="text"
|
||
value={manualQuestion.firstName}
|
||
onChange={e => setManualQuestion(curr => ({ ...curr, firstName: e.target.value }))}
|
||
placeholder="Nate"
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="manual-question-email">Email (optional)</label>
|
||
<input
|
||
id="manual-question-email"
|
||
type="email"
|
||
value={manualQuestion.email}
|
||
onChange={e => setManualQuestion(curr => ({ ...curr, email: e.target.value }))}
|
||
placeholder="optional@email.com"
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="manual-question-question">Question</label>
|
||
<textarea
|
||
id="manual-question-question"
|
||
rows={3}
|
||
value={manualQuestion.question}
|
||
onChange={e => setManualQuestion(curr => ({ ...curr, question: e.target.value }))}
|
||
placeholder="Type the question you want to add to the Q&A list..."
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="manual-question-answer">Answer (optional)</label>
|
||
<textarea
|
||
id="manual-question-answer"
|
||
rows={3}
|
||
value={manualQuestion.answer}
|
||
onChange={e => setManualQuestion(curr => ({ ...curr, answer: e.target.value }))}
|
||
placeholder="Optional: add an answer now"
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="manual-question-approve">Approve Immediately</label>
|
||
<select
|
||
id="manual-question-approve"
|
||
value={manualQuestion.approve ? 'yes' : 'no'}
|
||
onChange={e => setManualQuestion(curr => ({ ...curr, approve: e.target.value === 'yes' }))}
|
||
>
|
||
<option value="no">No</option>
|
||
<option value="yes">Yes</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn-admin-save"
|
||
onClick={handleCreateManualQuestion}
|
||
disabled={manualQuestionStatus === 'saving'}
|
||
>
|
||
{manualQuestionStatus === 'saving' ? 'Adding…' : 'Add Question'}
|
||
</button>
|
||
</div>
|
||
{manualQuestionMsg && (
|
||
<p className={`admin-status ${manualQuestionStatus === 'error' ? 'admin-status--err' : 'admin-status--ok'}`}>
|
||
{manualQuestionMsg}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{questions.length === 0 ? (
|
||
<p className="admin-stats-note">No questions submitted yet.</p>
|
||
) : filteredAdminQuestions.length === 0 ? (
|
||
<p className="admin-stats-note">No questions match this filter.</p>
|
||
) : (
|
||
<div className="admin-questions-list">
|
||
{visibleAdminQuestions.map(question => (
|
||
<div key={question.id} className={`admin-question-card${selectedQuestionIds.has(question.id) ? ' admin-question-card--selected' : ''}`}>
|
||
<div className="admin-question-header">
|
||
<label className="admin-question-checkbox" title="Select question">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedQuestionIds.has(question.id)}
|
||
onChange={e => {
|
||
setSelectedQuestionIds(prev => {
|
||
const next = new Set(prev)
|
||
if (e.target.checked) next.add(question.id)
|
||
else next.delete(question.id)
|
||
return next
|
||
})
|
||
}}
|
||
/>
|
||
</label>
|
||
<div>
|
||
<p className="admin-question-meta"><strong>{question.firstName}</strong> • {formatDate(question.submittedAt)}</p>
|
||
<p className="admin-question-text"><strong>Q:</strong> {question.question}</p>
|
||
</div>
|
||
<div className="admin-question-status">
|
||
<span className={`admin-badge ${question.isApproved ? 'admin-badge--approved' : 'admin-badge--pending'}`}>
|
||
{question.isApproved ? 'Approved' : 'Pending'}
|
||
</span>
|
||
{question.answer && <span className="admin-badge admin-badge--answered">Answered</span>}
|
||
</div>
|
||
</div>
|
||
{question.answer && (
|
||
<div className="admin-question-answer">
|
||
<p><strong>A:</strong> {question.answer}</p>
|
||
</div>
|
||
)}
|
||
{editingQuestionId === question.id ? (
|
||
<div className="admin-question-editor">
|
||
<textarea
|
||
value={answeredQuestions[question.id] ?? question.answer ?? ''}
|
||
onChange={e => setAnsweredQuestions(a => ({ ...a, [question.id]: e.target.value }))}
|
||
rows={4}
|
||
placeholder="Type your answer here..."
|
||
/>
|
||
<div className="admin-question-editor-actions">
|
||
<button type="button" className="btn-admin-save" onClick={() => handleAnswerQuestion(question.id, answeredQuestions[question.id] ?? '')}>Save Answer</button>
|
||
<button type="button" className="btn-admin-reset" onClick={() => setEditingQuestionId(null)}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="admin-question-actions">
|
||
<button type="button" className="btn-admin-reset" onClick={() => { setEditingQuestionId(question.id); setAnsweredQuestions(a => ({ ...a, [question.id]: question.answer ?? '' })) }}>
|
||
{question.answer ? 'Edit Answer' : 'Add Answer'}
|
||
</button>
|
||
<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>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{filteredAdminQuestions.length > QUESTION_PAGE_SIZE && (
|
||
<div className="qa-pagination" style={{ marginTop: '1rem', justifyContent: 'flex-start' }}>
|
||
<button
|
||
type="button"
|
||
className="qa-page-btn"
|
||
onClick={() => setQuestionPage(p => Math.max(0, p - 1))}
|
||
disabled={questionPage === 0}
|
||
>
|
||
Prev
|
||
</button>
|
||
<span className="qa-page-info">Page {questionPage + 1} / {totalQuestionPages}</span>
|
||
<button
|
||
type="button"
|
||
className="qa-page-btn"
|
||
onClick={() => setQuestionPage(p => Math.min(totalQuestionPages - 1, p + 1))}
|
||
disabled={questionPage >= totalQuestionPages - 1}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{/* STUDY USERS */}
|
||
{adminView === 'study-users' && <StudyUsersPanel studies={normalizeStudies(form)} />}
|
||
|
||
{/* ANALYTICS */}
|
||
{adminView === 'analytics' && (
|
||
<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}
|
||
maintenanceMsg={maintenanceMsg}
|
||
selectedBackup={selectedBackup}
|
||
backupFiles={backupFiles}
|
||
selectedBackupPreview={selectedBackupPreview}
|
||
onBackupSelect={setSelectedBackup}
|
||
onRestore={handleRestoreBackup}
|
||
onExport={handleExport}
|
||
onBackupNow={handleBackupNow}
|
||
onDownloadFullBackup={handleDownloadFullBackup}
|
||
onImportFullBackup={handleImportFullBackup}
|
||
fullBackupBusy={fullBackupBusy}
|
||
onPrune={handlePrune}
|
||
onClear={handleClear}
|
||
onPurgeCache={handlePurgeCache}
|
||
onDeployHook={handleDeployHook}
|
||
onRefreshStatus={() => { void reloadOpsStatus() }}
|
||
/>
|
||
)}
|
||
|
||
{/* ASSETS */}
|
||
{adminView === 'assets' && (
|
||
<section className="admin-panel-section" aria-label="Asset manager">
|
||
<div className="admin-panel-head">
|
||
<h2>Asset Manager</h2>
|
||
<p>Upload, tag, and reuse hosted images from this domain.</p>
|
||
</div>
|
||
<div className="admin-actions admin-actions--maintenance">
|
||
<label className="btn-admin-reset" style={{ cursor: 'pointer' }}>
|
||
{assetUploadPending ? 'Uploading…' : 'Upload Image or PDF'}
|
||
<input type="file" accept="image/*,.pdf" style={{ display: 'none' }} onChange={handleAssetUpload} disabled={assetUploadPending} />
|
||
</label>
|
||
</div>
|
||
{assets.length === 0 && <p className="admin-stats-note">No assets uploaded yet.</p>}
|
||
{assets.length > 0 && (
|
||
<div className="admin-visits-table-scroll">
|
||
<table className="admin-visits-table admin-assets-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Preview</th>
|
||
<th>Filename</th>
|
||
<th>Size</th>
|
||
<th>Updated</th>
|
||
<th>Tags</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{assets.map(asset => (
|
||
<tr key={asset.filename}>
|
||
<td>
|
||
{isImageAsset(asset.filename)
|
||
? <img src={asset.url} alt={asset.filename} className="admin-asset-thumb" style={{ width: '72px', height: '52px', objectFit: 'cover' }} />
|
||
: <span>PDF/File</span>}
|
||
</td>
|
||
<td>{asset.filename}</td>
|
||
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
|
||
<td>{formatDate(asset.updatedAt)}</td>
|
||
<td>
|
||
<input
|
||
type="text"
|
||
className="admin-asset-tags-input"
|
||
placeholder="Add tags…"
|
||
value={assetTagEdits[asset.filename] ?? (asset.tags ?? []).join(', ')}
|
||
onChange={e => setAssetTagEdits(t => ({ ...t, [asset.filename]: e.target.value }))}
|
||
onBlur={() => handleSaveAssetTags(asset.filename)}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<div className="admin-asset-actions-inline">
|
||
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => { navigator.clipboard.writeText(asset.url).catch(() => {}) }}>Copy URL</button>
|
||
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
|
||
</section>
|
||
)}
|
||
|
||
{/* QR CODES */}
|
||
{adminView === 'qr-codes' && (() => {
|
||
if (!qrLoaded && !qrLoading) loadQrCodes()
|
||
return (
|
||
<section className="admin-panel-section" aria-label="QR Codes">
|
||
<div className="admin-panel-head">
|
||
<h2>QR Codes</h2>
|
||
<p>Each QR code is a short redirect at <code>/qr/<slug></code>. Scans are logged here instead of emailed.</p>
|
||
</div>
|
||
|
||
{/* Add new */}
|
||
<div className="admin-form-row" style={{ gap: '8px', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||
<div style={{ flex: '1 1 140px' }}>
|
||
<label style={{ display: 'block', marginBottom: 4, fontSize: '0.85em' }}>Label</label>
|
||
<input type="text" placeholder="Spotify Podcast" value={qrNewLabel} onChange={e => setQrNewLabel(e.target.value)} className="admin-input" />
|
||
</div>
|
||
<div style={{ flex: '1 1 120px' }}>
|
||
<label style={{ display: 'block', marginBottom: 4, fontSize: '0.85em' }}>Slug</label>
|
||
<input type="text" placeholder="spotify" value={qrNewSlug} onChange={e => setQrNewSlug(e.target.value)} className="admin-input" />
|
||
</div>
|
||
<div style={{ flex: '2 1 200px' }}>
|
||
<label style={{ display: 'block', marginBottom: 4, fontSize: '0.85em' }}>Destination URL</label>
|
||
<input type="url" placeholder="https://…" value={qrNewDest} onChange={e => setQrNewDest(e.target.value)} className="admin-input" />
|
||
</div>
|
||
<button type="button" className="btn-admin-save" onClick={handleCreateQrCode} disabled={!qrNewLabel || !qrNewSlug || !qrNewDest}>
|
||
Add QR Code
|
||
</button>
|
||
</div>
|
||
{qrMsg && <p className="admin-stats-note" style={{ color: '#c0392b' }}>{qrMsg}</p>}
|
||
|
||
{qrLoading && <p className="admin-stats-note">Loading…</p>}
|
||
|
||
{/* Code list */}
|
||
{!qrLoading && qrCodes.length === 0 && <p className="admin-stats-note">No QR codes yet.</p>}
|
||
{qrCodes.map(code => {
|
||
const scans = qrScans.filter(s => s.qrId === code.id)
|
||
const isEditing = qrEditId === code.id
|
||
return (
|
||
<div key={code.id} style={{ border: '1px solid var(--admin-border, #ddd)', borderRadius: 6, padding: '12px 16px', marginBottom: 12 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||
<span style={{ fontWeight: 600, flex: 1 }}>{code.label}</span>
|
||
<code style={{ fontSize: '0.82em', background: 'var(--admin-bg2, #f5f5f5)', padding: '2px 6px', borderRadius: 4 }}>/qr/{code.slug}</code>
|
||
<span style={{ fontSize: '0.82em', color: '#888' }}>{scans.length} scan{scans.length !== 1 ? 's' : ''}</span>
|
||
<span style={{ fontSize: '0.8em', color: code.active ? '#27ae60' : '#c0392b' }}>{code.active ? 'Active' : 'Inactive'}</span>
|
||
<button type="button" className="btn-admin-reset" style={{ fontSize: '0.8em' }} onClick={() => { setQrEditId(isEditing ? null : code.id); setQrEditLabel(code.label); setQrEditDest(code.destination) }}>
|
||
{isEditing ? 'Cancel' : 'Edit'}
|
||
</button>
|
||
<button type="button" className="btn-admin-reset" style={{ fontSize: '0.8em' }} onClick={() => handleToggleQrActive(code.id, !code.active)}>
|
||
{code.active ? 'Disable' : 'Enable'}
|
||
</button>
|
||
<button type="button" className="btn-admin-reset" style={{ fontSize: '0.8em' }} onClick={() => handleClearQrScans(code.id)}>
|
||
Clear scans
|
||
</button>
|
||
<button type="button" className="btn-admin-reset" style={{ fontSize: '0.8em', color: '#c0392b' }} onClick={() => handleDeleteQrCode(code.id)}>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
<p style={{ margin: '4px 0 0', fontSize: '0.82em', color: '#888', wordBreak: 'break-all' }}>{code.destination}</p>
|
||
<QrCodeImage url={`${window.location.origin}/qr/${code.slug}`} label={code.label} />
|
||
{isEditing && (
|
||
<div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
|
||
<input type="text" value={qrEditLabel} onChange={e => setQrEditLabel(e.target.value)} className="admin-input" style={{ flex: '1 1 140px' }} placeholder="Label" />
|
||
<input type="url" value={qrEditDest} onChange={e => setQrEditDest(e.target.value)} className="admin-input" style={{ flex: '2 1 200px' }} placeholder="Destination URL" />
|
||
<button type="button" className="btn-admin-save" onClick={() => handleSaveQrEdit(code.id)}>Save</button>
|
||
</div>
|
||
)}
|
||
{scans.length > 0 && (
|
||
<details style={{ marginTop: 10 }}>
|
||
<summary style={{ cursor: 'pointer', fontSize: '0.85em', color: '#555' }}>Scan history ({scans.length})</summary>
|
||
<div className="admin-visits-table-scroll" style={{ marginTop: 6 }}>
|
||
<table className="admin-visits-table">
|
||
<thead><tr><th>Time</th><th>IP</th><th>Device / UA</th></tr></thead>
|
||
<tbody>
|
||
{scans.slice(0, 100).map(s => (
|
||
<tr key={s.id}>
|
||
<td style={{ whiteSpace: 'nowrap' }}>{new Date(s.scannedAt).toLocaleString()}</td>
|
||
<td>{s.ip}</td>
|
||
<td style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.userAgent}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</details>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</section>
|
||
)
|
||
})()}
|
||
|
||
{/* SEO & REDIRECTS */}
|
||
{adminView === 'seo' && (
|
||
<section className="admin-panel-section" aria-label="SEO & Redirects">
|
||
<div className="admin-panel-head">
|
||
<h2>SEO & Redirects</h2>
|
||
<p>Control metadata, canonical URL, robots policy, sitemap paths, and short-link redirects.</p>
|
||
</div>
|
||
|
||
{renderSectionJumpNav('seo')}
|
||
|
||
<div className="admin-section-header" id="seo-metadata">
|
||
<h3>Metadata</h3>
|
||
<p>Search title, descriptions, Open Graph image, canonical URL, robots policy, and sitemap paths.</p>
|
||
</div>
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field"><label htmlFor="seo-title">SEO Title</label><input id="seo-title" type="text" value={form.seo?.title ?? ''} onChange={e => updateSeoField('title', e.target.value)} /></div>
|
||
<div className="admin-field"><label htmlFor="seo-description">SEO Description</label><textarea id="seo-description" rows={3} value={form.seo?.description ?? ''} onChange={e => updateSeoField('description', e.target.value)} /></div>
|
||
<div className="admin-field"><label htmlFor="seo-og-title">Open Graph Title</label><input id="seo-og-title" type="text" value={form.seo?.ogTitle ?? ''} onChange={e => updateSeoField('ogTitle', e.target.value)} /></div>
|
||
<div className="admin-field"><label htmlFor="seo-og-description">Open Graph Description</label><textarea id="seo-og-description" rows={3} value={form.seo?.ogDescription ?? ''} onChange={e => updateSeoField('ogDescription', e.target.value)} /></div>
|
||
<div className="admin-field">
|
||
<label htmlFor="seo-og-image">Open Graph Image URL</label>
|
||
<input id="seo-og-image" type="text" value={form.seo?.ogImage ?? ''} onChange={e => updateSeoField('ogImage', e.target.value)} />
|
||
{renderImageAssetSelector(form.seo?.ogImage, value => updateSeoField('ogImage', value), 'seo-og-image-asset')}
|
||
</div>
|
||
<div className="admin-field"><label htmlFor="seo-canonical">Canonical URL</label><input id="seo-canonical" type="text" value={form.seo?.canonicalUrl ?? ''} onChange={e => updateSeoField('canonicalUrl', e.target.value)} /></div>
|
||
<div className="admin-field"><label htmlFor="seo-robots">Robots Policy</label><input id="seo-robots" type="text" value={form.seo?.robotsPolicy ?? ''} onChange={e => updateSeoField('robotsPolicy', e.target.value)} /></div>
|
||
<div className="admin-field">
|
||
<label htmlFor="seo-sitemap">Sitemap Paths (one per line)</label>
|
||
<textarea id="seo-sitemap" rows={4} value={(form.seo?.sitemapPaths ?? []).join('\n')} onChange={e => updateSeoField('sitemapPaths', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="admin-section-header" id="seo-redirects">
|
||
<h3>Redirect Manager</h3>
|
||
<p>Maintain first-party short links like /spotify without code changes.</p>
|
||
</div>
|
||
{(form.redirects ?? []).map(rule => (
|
||
<div key={rule.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field"><label htmlFor={`redirect-path-${rule.id}`}>Path</label><input id={`redirect-path-${rule.id}`} type="text" value={rule.path} onChange={e => updateRedirectRule(rule.id, 'path', e.target.value)} /></div>
|
||
<div className="admin-field"><label htmlFor={`redirect-target-${rule.id}`}>Target URL</label><input id={`redirect-target-${rule.id}`} type="text" value={rule.target} onChange={e => updateRedirectRule(rule.id, 'target', e.target.value)} /></div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`redirect-status-${rule.id}`}>Status</label>
|
||
<select id={`redirect-status-${rule.id}`} value={rule.statusCode} onChange={e => updateRedirectRule(rule.id, 'statusCode', Number(e.target.value))}>
|
||
<option value={301}>301 Permanent</option>
|
||
<option value={302}>302 Temporary</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeRedirectRule(rule.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addRedirectRule}>+ Add Redirect</button>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* LEGAL */}
|
||
{adminView === 'legal' && (
|
||
<section className="admin-panel-section" aria-label="Legal Pages">
|
||
<div className="admin-panel-head">
|
||
<h2>Legal Pages</h2>
|
||
<p>Edit Privacy and Terms pages from admin.</p>
|
||
</div>
|
||
|
||
{renderSectionJumpNav('legal')}
|
||
|
||
<div className="admin-section-header" id="legal-privacy">
|
||
<h3>Privacy</h3>
|
||
<p>Edit the title and body content for the privacy page.</p>
|
||
</div>
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field"><label htmlFor="legal-privacy-title">Privacy Title</label><input id="legal-privacy-title" type="text" value={form.legal?.privacyTitle ?? ''} onChange={e => updateLegalField('privacyTitle', e.target.value)} /></div>
|
||
<div className="admin-field">
|
||
<label htmlFor="legal-privacy-body">Privacy Body (one paragraph per line)</label>
|
||
<textarea id="legal-privacy-body" rows={4} value={(form.legal?.privacyBody ?? []).join('\n')} onChange={e => updateLegalField('privacyBody', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="admin-section-header" id="legal-terms">
|
||
<h3>Terms</h3>
|
||
<p>Edit the title and body content for the terms page.</p>
|
||
</div>
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field"><label htmlFor="legal-terms-title">Terms Title</label><input id="legal-terms-title" type="text" value={form.legal?.termsTitle ?? ''} onChange={e => updateLegalField('termsTitle', e.target.value)} /></div>
|
||
<div className="admin-field">
|
||
<label htmlFor="legal-terms-body">Terms Body (one paragraph per line)</label>
|
||
<textarea id="legal-terms-body" rows={4} value={(form.legal?.termsBody ?? []).join('\n')} onChange={e => updateLegalField('termsBody', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* SECURITY */}
|
||
{adminView === 'security' && (
|
||
<section className="admin-panel-section" aria-label="Security">
|
||
<div className="admin-panel-head">
|
||
<h2>Security</h2>
|
||
<p>Manage two-factor authentication for admin login.</p>
|
||
</div>
|
||
{totpEnabled === null && <p className="admin-stats-note">Loading…</p>}
|
||
{totpEnabled === false && !totpSetupQr && (
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<p className="admin-stats-note">2FA is currently <strong>off</strong>. Enable it to require an authenticator app at every login.</p>
|
||
<button type="button" className="btn-primary" onClick={handleTotpSetupInit}>Enable 2FA</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{totpSetupQr && (
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<p className="admin-stats-note">Scan this QR code with your authenticator app, then enter the 6-digit code below to confirm.</p>
|
||
<img src={totpSetupQr} alt="TOTP QR code" style={{ width: 200, height: 200, display: 'block', margin: '0.5rem 0' }} />
|
||
{totpSetupSecret && <p className="admin-stats-note" style={{ wordBreak: 'break-all' }}>Manual entry key: <code>{totpSetupSecret}</code></p>}
|
||
<form onSubmit={handleTotpSetupConfirm} style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
|
||
<label htmlFor="totp-confirm-code">Confirmation Code</label>
|
||
<input id="totp-confirm-code" type="text" inputMode="numeric" value={totpConfirmCode} onChange={e => setTotpConfirmCode(e.target.value)} placeholder="000000" autoFocus required />
|
||
</div>
|
||
<button type="submit" className="btn-primary">Confirm & Enable</button>
|
||
<button type="button" className="btn-secondary" onClick={() => { setTotpSetupQr(null); setTotpSetupSecret(null) }}>Cancel</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{totpEnabled === true && !totpSetupQr && (
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<p className="admin-stats-note">2FA is currently <strong>on</strong>. A code from your authenticator app is required at every login.</p>
|
||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||
<button type="button" className="btn-admin-reset" onClick={handleTotpRegenRecovery}>Regenerate Recovery Codes</button>
|
||
<button type="button" className="btn-admin-remove" onClick={handleTotpDisable}>Disable 2FA</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{totpRecoveryCodes && (
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<p className="admin-stats-note"><strong>Save these recovery codes somewhere safe.</strong> Each can be used once instead of the 6-digit code if you lose access to your authenticator app. They will not be shown again.</p>
|
||
<ul style={{ fontFamily: 'monospace', lineHeight: 2 }}>{totpRecoveryCodes.map(c => <li key={c}>{c}</li>)}</ul>
|
||
<button type="button" className="btn-secondary" onClick={() => setTotpRecoveryCodes(null)}>I've saved these</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{totpMsg && <p className="admin-stats-note">{totpMsg}</p>}
|
||
</section>
|
||
)}
|
||
|
||
{/* BRAND KIT */}
|
||
{adminView === 'brand' && (
|
||
<section className="admin-panel-section admin-brand-kit" aria-label="Brand kit">
|
||
<div className="admin-panel-head">
|
||
<h2>Brand Kit</h2>
|
||
<p>Reference palette, typography, and implementation status for the live site.</p>
|
||
</div>
|
||
<div className="admin-brand-hero">
|
||
<img src="/images/podcast-art.jpeg" alt="Verse by Verse with Nate artwork" />
|
||
<div>
|
||
<p className="admin-brand-kicker">Verse by Verse with Nate</p>
|
||
<h3>A Journey Through Scripture</h3>
|
||
<p className="admin-brand-tagline">Verse by verse. Nugget by nugget.</p>
|
||
</div>
|
||
</div>
|
||
<div className="admin-brand-grid">
|
||
<article className="admin-brand-card">
|
||
<h3>Color Palette</h3>
|
||
<div className="admin-brand-swatches">
|
||
{BRAND_KIT_SWATCHES.map(swatch => (
|
||
<div key={swatch.hex} className="admin-brand-swatch">
|
||
<div className="admin-brand-swatch-block" style={{ background: swatch.hex }} />
|
||
<div className="admin-brand-swatch-copy">
|
||
<strong>{swatch.name}</strong>
|
||
<span>{swatch.hex}</span>
|
||
<p>{swatch.role}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</article>
|
||
<article className="admin-brand-card">
|
||
<h3>Typography</h3>
|
||
<div className="admin-brand-type-list">
|
||
{BRAND_KIT_TYPE.map(item => (
|
||
<div key={item.label} className="admin-brand-type-row">
|
||
<p className="admin-brand-type-label">{item.label}</p>
|
||
<p className="admin-brand-type-spec">{item.spec}</p>
|
||
<p className="admin-brand-type-sample">{item.sample}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</article>
|
||
</div>
|
||
<article className="admin-brand-card admin-brand-card--verify">
|
||
<h3>Verified On Site</h3>
|
||
<ul className="admin-brand-checklist">
|
||
{BRAND_KIT_VERIFICATION.map(item => <li key={item}>{item}</li>)}
|
||
</ul>
|
||
<p className="admin-brand-note">This admin panel reflects the live implementation now loaded from the shared font import and brand tokens.</p>
|
||
</article>
|
||
</section>
|
||
)}
|
||
|
||
{/* ── BROADCAST EMAIL ── */}
|
||
{adminView === 'broadcast' && (() => {
|
||
const recipientCount = (() => {
|
||
const seen = new Set<string>()
|
||
return subscribers.filter(s => s.email && !seen.has(s.email) && !!seen.add(s.email)).length
|
||
})()
|
||
async function sendBroadcast() {
|
||
if (!broadcastSubject.trim() || !broadcastBody.trim()) {
|
||
setBroadcastMsg('Subject and body are required.'); return
|
||
}
|
||
setBroadcastSending(true); setBroadcastMsg(''); setBroadcastResult(null)
|
||
try {
|
||
const r = await fetch('/api/admin-broadcast', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ subject: broadcastSubject, html: broadcastBody }),
|
||
})
|
||
const d = await r.json()
|
||
if (!r.ok) { setBroadcastMsg(d.message || 'Send failed.') } else {
|
||
setBroadcastResult(d); setBroadcastConfirm(false)
|
||
setBroadcastMsg(`Sent to ${d.sent} subscriber${d.sent !== 1 ? 's' : ''}${d.failed > 0 ? ` (${d.failed} failed)` : ''}.`)
|
||
}
|
||
} catch { setBroadcastMsg('Network error. Try again.') }
|
||
finally { setBroadcastSending(false) }
|
||
}
|
||
return (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Broadcast Email</h2>
|
||
<p>Compose and send a one-off email to all {subscribers.length} subscribers.</p>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="bc-subject">Subject</label>
|
||
<input id="bc-subject" type="text" value={broadcastSubject} onChange={e => setBroadcastSubject(e.target.value)} placeholder="This week's episode + new study lesson" />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="bc-body">Body <span style={{ fontWeight: 400, opacity: 0.6, fontSize: '0.8em' }}>(HTML — use {'{{name}}'} for personalization)</span></label>
|
||
<textarea id="bc-body" rows={14} value={broadcastBody} onChange={e => setBroadcastBody(e.target.value)} placeholder={'<p>Hi {{name}},</p>\n<p>This week we covered...</p>'} style={{ fontFamily: 'monospace', fontSize: '0.85rem' }} />
|
||
</div>
|
||
{broadcastBody.trim() && (
|
||
<details style={{ marginBottom: '1rem' }}>
|
||
<summary style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}>Preview rendered HTML</summary>
|
||
<div style={{ marginTop: '0.75rem', padding: '1rem', background: 'var(--admin-card-bg)', borderRadius: '6px', border: '1px solid var(--admin-border)', fontSize: '0.9rem' }}
|
||
dangerouslySetInnerHTML={{ __html: broadcastBody.replace(/\{\{name\}\}/g, 'Nate (preview)') }} />
|
||
</details>
|
||
)}
|
||
{broadcastMsg && <p className={broadcastResult ? 'admin-save-ok' : 'admin-save-error'} style={{ marginBottom: '0.75rem' }}>{broadcastMsg}</p>}
|
||
{!broadcastConfirm ? (
|
||
<button type="button" className="btn-admin-apply" disabled={!broadcastSubject.trim() || !broadcastBody.trim()} onClick={() => setBroadcastConfirm(true)}>
|
||
Send to {recipientCount} subscriber{recipientCount !== 1 ? 's' : ''}…
|
||
</button>
|
||
) : (
|
||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<strong style={{ color: 'var(--brand-gold)' }}>Send "{broadcastSubject}" to {recipientCount} people?</strong>
|
||
<button type="button" className="btn-admin-apply" disabled={broadcastSending} onClick={sendBroadcast}>
|
||
{broadcastSending ? 'Sending…' : 'Confirm Send'}
|
||
</button>
|
||
<button type="button" className="btn-admin-reset" onClick={() => setBroadcastConfirm(false)}>Cancel</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)
|
||
})()}
|
||
|
||
{/* ── WEBHOOKS ── */}
|
||
{adminView === 'webhooks' && (() => {
|
||
if (!webhooksLoaded) loadWebhooksAdmin()
|
||
const ALL_EVENTS = ['*', 'contact.submitted', 'subscriber.added', 'question.submitted', 'study.lesson.released', 'study.user.enrolled']
|
||
async function addWebhook() {
|
||
if (!webhookUrl.trim()) { setWebhookMsg('A URL is required.'); return }
|
||
setWebhookMsg('')
|
||
try {
|
||
const r = await fetch('/api/admin-webhooks', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url: webhookUrl, events: webhookEvents.split(',').map(e => e.trim()).filter(Boolean) }),
|
||
})
|
||
const d = await r.json()
|
||
if (!r.ok) { setWebhookMsg(d.message || 'Failed to add webhook.') } else {
|
||
setWebhooks(prev => [...prev, d.webhook])
|
||
setWebhookUrl(''); setWebhookEvents('*')
|
||
setWebhookMsg('Webhook added.')
|
||
}
|
||
} catch { setWebhookMsg('Network error.') }
|
||
}
|
||
async function deleteWebhook(id: string) {
|
||
if (!confirm('Delete this webhook?')) return
|
||
try {
|
||
await fetch(`/api/admin-webhooks/${id}`, { method: 'DELETE' })
|
||
setWebhooks(prev => prev.filter(w => w.id !== id))
|
||
} catch { setWebhookMsg('Failed to delete.') }
|
||
}
|
||
return (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Webhooks</h2>
|
||
<p>Send POST notifications to external URLs when site events fire.</p>
|
||
</div>
|
||
{webhooks.length > 0 ? (
|
||
<div style={{ marginBottom: '1.5rem' }}>
|
||
{webhooks.map(wh => (
|
||
<div key={wh.id} className="study-lesson-row" style={{ alignItems: 'flex-start', padding: '0.75rem 0.5rem' }}>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.82rem', wordBreak: 'break-all', marginBottom: '0.25rem' }}>{wh.url}</div>
|
||
<div style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
||
Events: <span style={{ fontFamily: 'monospace' }}>{(wh.events ?? ['*']).join(', ')}</span>
|
||
{' · '}{new Date(wh.createdAt).toLocaleDateString()}
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.8rem', padding: '0.2rem 0.5rem', marginLeft: '0.5rem' }} onClick={() => deleteWebhook(wh.id)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="admin-stats-note" style={{ marginBottom: '1.5rem' }}>No webhooks configured.</p>
|
||
)}
|
||
<div className="admin-panel-head" style={{ marginTop: 0 }}>
|
||
<h3 style={{ fontSize: '0.95rem' }}>Add Webhook</h3>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="wh-url">Endpoint URL (HTTPS)</label>
|
||
<input id="wh-url" type="url" value={webhookUrl} onChange={e => setWebhookUrl(e.target.value)} placeholder="https://example.com/hook" />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="wh-events">Events <span style={{ fontWeight: 400, opacity: 0.6, fontSize: '0.8em' }}>(comma-separated; * = all)</span></label>
|
||
<input id="wh-events" type="text" value={webhookEvents} onChange={e => setWebhookEvents(e.target.value)} placeholder="*" />
|
||
<p className="admin-field-hint" style={{ marginTop: '0.3rem' }}>Available: {ALL_EVENTS.join(', ')}</p>
|
||
</div>
|
||
{webhookMsg && <p className="admin-save-ok" style={{ marginBottom: '0.5rem' }}>{webhookMsg}</p>}
|
||
<button type="button" className="btn-admin-apply" onClick={addWebhook}>Add Webhook</button>
|
||
</section>
|
||
)
|
||
})()}
|
||
|
||
</main>
|
||
|
||
{/* ── Live preview pane ── */}
|
||
{previewOpen && (
|
||
<div className="admin-preview-pane">
|
||
<div className="admin-preview-toolbar">
|
||
<span className="admin-preview-label">Live Preview</span>
|
||
<span className="admin-preview-hint">Updates as you type</span>
|
||
</div>
|
||
<iframe
|
||
ref={previewIframeRef}
|
||
src="/preview"
|
||
title="Site preview"
|
||
className="admin-preview-iframe"
|
||
onLoad={() => {
|
||
// Once the iframe loads, immediately push the current form state and view
|
||
previewIframeRef.current?.contentWindow?.postMessage(
|
||
{ type: 'admin-preview-content', content: form, view: adminView },
|
||
window.location.origin
|
||
)
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|