Files
Siteforge/src/AdminPage.tsx
T
nmemmertandClaude Sonnet 5.5 69d445013a
Publish Container / build (push) Successful in 16m19s
Podcast workflow: Finished seasons section grouping fully released seasons; v2.18.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 10:34:44 -04:00

7569 lines
426 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import DOMPurify from 'dompurify'
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 { Link } from 'react-router-dom'
import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings, PodcastHost, SecondSeries, StudyGuide } from './content'
import { DEFAULTS } from './content'
import { AnalyticsPanel } from './components/AnalyticsPanel'
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
import { AdminDashboardExtras } from './components/AdminDashboardExtras'
import { useAutosave } from './hooks/useAutosave'
import QRCode from 'qrcode'
import InboxPanel, { type InboxSubmission } from './InboxPanel'
import AssetManager, { type AdminAsset } from './AssetManager'
type HighlightLane = 'primary' | 'second' | 'general'
function highlightLaneOf(link: PodcastFeaturedLink): HighlightLane {
return link.lane === 'second' || link.lane === 'general' ? link.lane : 'primary'
}
// Splits highlights into the ones belonging to a series (moved onto its Finished Book) and the rest (kept).
function splitHighlightsForLane(links: PodcastFeaturedLink[] | undefined, lane: HighlightLane) {
const all = links ?? []
const ending = all.filter(link => highlightLaneOf(link) === lane).map(link => { const copy = { ...link }; delete copy.lane; return copy })
const kept = all.filter(link => highlightLaneOf(link) !== lane)
return { ending, kept }
}
function toLocalInputValue(iso?: string): string {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function fromLocalInputValue(val: string): string {
return val ? new Date(val).toISOString() : ''
}
interface SortableLessonRowProps {
section: ColossiansStudySection
study: StudyProgram
onEdit: () => void
removeStudySection: (studyId: string, sectionId: string) => void
onReleaseChange: (value: string) => void
}
function SortableLessonRow({ section, study, onEdit, removeStudySection, onReleaseChange }: 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-ep">{section.episodeNumber || '—'}</span>
<span className="study-lesson-ref">{section.reference || '—'}</span>
<span className="study-lesson-title">{section.title || <em style={{ opacity: 0.5 }}>Untitled</em>}</span>
<input
type="datetime-local"
className="study-lesson-release"
aria-label={`Release date for ${section.title || 'lesson'}`}
value={toLocalInputValue(section.releasedAt)}
onChange={e => onReleaseChange(fromLocalInputValue(e.target.value))}
/>
<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 {
const res = await fetch(`/api/admin/study-users/${id}`, { method: 'DELETE' })
if (!res.ok) { flashMsg(id, 'Failed to delete user.'); return }
setUsers(prev => prev.filter(u => u.id !== id))
setConfirmDeleteId(null)
setExpandedId(null)
} catch { flashMsg(id, 'Failed to delete user.') }
}
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>
)
}
// Module-level (not nested in EmailTemplatesPanel) so its identity is stable across
// renders — otherwise every keystroke remounts the card and the focused input loses focus.
function EmailTemplateCard({
type, icon, title, trigger, autoVars, fieldKeys, openKey,
form, handleChange, renderImageAssetSelector, renderImagePreview, expandedEmail, setExpandedEmail,
}: {
form: SiteContent
handleChange: (key: StringField, value: string) => void
renderImageAssetSelector: (value: string | null | undefined, onChange: (value: string) => void, fieldId: string) => React.ReactNode
renderImagePreview: (value: string | null | undefined, alt: string) => React.ReactNode
expandedEmail: string | null
setExpandedEmail: (key: string | null) => void
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)} />
}
{key.includes('ImageUrl') && renderImageAssetSelector(form[key as StringField] as string, value => handleChange(key as StringField, value), `field-et-${key}-asset`)}
{key.includes('ImageUrl') && renderImagePreview(form[key as StringField] as string, `${field.label} preview`)}
</div>
)
})}
</div>
)}
</div>
)
}
function EmailTemplatesPanel({
form,
handleChange,
renderSaveStatus,
renderImageAssetSelector,
renderImagePreview,
}: {
form: SiteContent
handleChange: (key: StringField, value: string) => void
renderSaveStatus: () => React.ReactNode
renderImageAssetSelector: (value: string | null | undefined, onChange: (value: string) => void, fieldId: string) => React.ReactNode
renderImagePreview: (value: string | null | undefined, alt: string) => React.ReactNode
}) {
const [expandedEmail, setExpandedEmail] = useState<string | null>(null)
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 form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} 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','welcomeEmailImageUrl','welcomeEmailGreetingPrefix','welcomeEmailIntro','welcomeEmailCurrentSeries','welcomeEmailStartHereTitle','welcomeEmailStartHereSummary','welcomeEmailStartHereUrl','welcomeEmailStartHereLinkLabel','welcomeEmailWhatToExpect1','welcomeEmailWhatToExpect2','welcomeEmailWhatToExpect3','welcomeEmailScripture','welcomeEmailScriptureRef','welcomeEmailSignoff','welcomeEmailSpotifyUrl','welcomeEmailSpotifyBtnLabel','welcomeEmailAppleUrl','welcomeEmailAppleBtnLabel','welcomeEmailAmazonUrl']}
openKey="newsletter-welcome" />
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} 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 form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} 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 form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} 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 form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} 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 form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} 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 form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} 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 form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} 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 form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="✅" title="Account Security — Verify Email Address"
trigger="A student creates a study account (sent alongside the welcome email)"
autoVars="Student's display name, button links to a one-time verification URL (auto-generated, expires in 7 days, not editable)"
fieldKeys={['emailVerifySubject','emailVerifyBody','emailVerifyCtaLabel']}
openKey="email-verify" />
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="🔑" title="Account Security — Password Reset"
trigger="A student requests a password reset from the sign-in page"
autoVars="Student's display name, button links to a one-time reset URL (auto-generated, expires in 1 hour, not editable)"
fieldKeys={['passwordResetSubject','passwordResetBody','passwordResetCtaLabel']}
openKey="password-reset" />
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="🛡️" title="Account Security — Password Changed"
trigger="A student's password is changed (self-service reset, in-account change, or admin reset) — confirms the change and that other sessions were signed out"
autoVars="Student's display name, link to account settings"
fieldKeys={['passwordChangedSubject','passwordChangedBody']}
openKey="password-changed" />
<EmailTemplateCard form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} 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 form={form} handleChange={handleChange} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} expandedEmail={expandedEmail} setExpandedEmail={setExpandedEmail} type="transactional" icon="💬" title="Q&amp;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
ipHash?: 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 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
}
type ContactSubmission = InboxSubmission
interface Subscriber {
name: string
email: string
subscribedAt: string
source: 'contact-form' | 'download' | 'resend-import'
}
interface ResendComparison {
appCount: number
resendCount: number
resendUnsubscribedCount: number
segmentConfigured: boolean
segmentCount: number | null
matched: number
onlyInApp: Array<{ name: string; email: string }>
onlyInResend: Array<{ name: string; email: string }>
unsubscribedInResend: Array<{ name: string; email: string }>
notInSegment: Array<{ name: string; email: string }>
}
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:workflow' | 'podcast:rss'
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'inbox'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast' | 'newsletter-archive'
| '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:workflow', label: 'Episode Workflow' },
{ kind: 'leaf', view: 'podcast:rss', label: 'RSS Feed' },
]},
{ kind: 'parent', id: 'nuggets', label: 'Nuggets', children: [
{ kind: 'leaf', view: 'nuggets:settings', label: 'Show Settings' },
{ kind: 'leaf', view: 'nuggets:hosts', label: 'Hosts' },
{ kind: 'leaf', view: 'nuggets:upload-portal', label: 'Upload Portal' },
{ kind: 'leaf', view: 'nuggets:announcement', label: 'Announcement' },
{ kind: 'leaf', view: 'nuggets:guidelines', label: 'Recording Guidelines' },
{ kind: 'leaf', view: 'nuggets:workflow', label: 'Episode Workflow' },
{ kind: 'leaf', view: 'nuggets:history', label: 'Upload History' },
]},
{ 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: 'inbox', label: 'Inbox' },
{ 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: 'newsletter-archive', label: 'Newsletter Archive' },
{ 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' },
],
'start-here': [
{ id: 'start-here-intro', label: 'Intro' },
{ id: 'start-here-step-1', label: 'Step 1' },
{ id: 'start-here-step-2', label: 'Step 2' },
{ id: 'start-here-step-3', label: 'Step 3' },
],
'custom-links': [
{ id: 'custom-links-homepage', label: 'Homepage External' },
{ id: 'custom-links-platform', label: 'Platform + Footer' },
{ id: 'custom-links-other', label: 'Other Sites' },
{ id: 'custom-links-more', label: 'More Resources' },
],
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: 'welcomeEmailImageUrl', label: 'Podcast Image (shown at top of email)', 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: 'emailVerifySubject', label: 'Subject Line', section: 'email-templates' },
{ key: 'emailVerifyBody', label: 'Body Text', multiline: true, section: 'email-templates' },
{ key: 'emailVerifyCtaLabel', label: 'Button Label', section: 'email-templates' },
{ key: 'passwordResetSubject', label: 'Subject Line', section: 'email-templates' },
{ key: 'passwordResetBody', label: 'Body Text', multiline: true, section: 'email-templates' },
{ key: 'passwordResetCtaLabel', label: 'Button Label', section: 'email-templates' },
{ key: 'passwordChangedSubject', label: 'Subject Line', section: 'email-templates' },
{ key: 'passwordChangedBody', label: 'Body Text', multiline: true, 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 toLocalDatetimeInput(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
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>
)
}
// ── Nuggets Upload Portal admin panel ─────────────────────────────────────
interface UploadHost {
id: string
name: string
email: string
active: boolean
createdAt: string
}
function NuggetsUploadPortalAdmin() {
const [hosts, setHosts] = useState<UploadHost[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [addName, setAddName] = useState('')
const [addEmail, setAddEmail] = useState('')
const [addPassword, setAddPassword] = useState('')
const [addError, setAddError] = useState('')
const [addSaving, setAddSaving] = useState(false)
const [addEmailSent, setAddEmailSent] = useState(false)
const [resetId, setResetId] = useState<string | null>(null)
const [resetPw, setResetPw] = useState('')
const [resetError, setResetError] = useState('')
const [resetSaving, setResetSaving] = useState(false)
const [resetEmailSent, setResetEmailSent] = useState(false)
function reload() {
setLoading(true)
fetch('/api/admin/nuggets-upload/hosts')
.then(r => r.json())
.then(d => setHosts(d.hosts ?? []))
.catch(() => setError('Failed to load hosts.'))
.finally(() => setLoading(false))
}
useEffect(() => { reload() }, [])
async function addHost(e: React.FormEvent) {
e.preventDefault()
setAddError('')
setAddSaving(true)
try {
const r = await fetch('/api/admin/nuggets-upload/hosts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: addName, email: addEmail, password: addPassword }),
})
const d = await r.json()
if (!r.ok) { setAddError(d.message ?? 'Failed.'); return }
setAddEmailSent(Boolean(d.emailSent))
setAddName(''); setAddEmail(''); setAddPassword('')
reload()
} finally {
setAddSaving(false)
}
}
async function toggleActive(host: UploadHost) {
await fetch(`/api/admin/nuggets-upload/hosts/${host.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active: !host.active }),
})
reload()
}
async function deleteHost(id: string) {
if (!window.confirm('Remove this host account? This cannot be undone.')) return
await fetch(`/api/admin/nuggets-upload/hosts/${id}`, { method: 'DELETE' })
reload()
}
async function resetPassword(e: React.FormEvent) {
e.preventDefault()
if (!resetId) return
setResetError('')
setResetSaving(true)
try {
const r = await fetch(`/api/admin/nuggets-upload/hosts/${resetId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: resetPw }),
})
const d = await r.json()
if (!r.ok) { setResetError(d.message ?? 'Failed.'); return }
setResetEmailSent(Boolean(d.emailSent))
setResetId(null); setResetPw('')
reload()
} finally {
setResetSaving(false)
}
}
return (
<section className="admin-panel-section" aria-label="Nuggets upload portal">
<div className="admin-panel-head">
<h2>Nuggets — Upload Portal</h2>
<p>
Manage host login accounts for the upload portal at{' '}
<a href="/host" target="_blank" rel="noopener noreferrer">/host</a>.
Each host gets a username and password to upload episode files to Seafile.
</p>
<p style={{ fontSize: '0.82rem', color: '#888', marginTop: 4 }}>
Make sure <code>SEAFILE_URL</code>, <code>SEAFILE_TOKEN</code>, and <code>SEAFILE_NUGGETS_REPO_ID</code> are set in your environment.
</p>
</div>
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
{loading && <p className="admin-stats-note">Loading…</p>}
{!loading && hosts.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '1.5rem', fontSize: '0.88rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--color-border, #e5e0d5)' }}>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Name</th>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Email (login)</th>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Status</th>
<th style={{ padding: '6px 8px' }}></th>
</tr>
</thead>
<tbody>
{hosts.map(h => (
<tr key={h.id} style={{ borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
<td style={{ padding: '7px 8px' }}>{h.name}</td>
<td style={{ padding: '7px 8px', fontSize: '0.85rem' }}>{h.email}</td>
<td style={{ padding: '7px 8px' }}>
<span style={{ color: h.active ? '#27ae60' : '#888', fontWeight: 600 }}>{h.active ? 'Active' : 'Disabled'}</span>
</td>
<td style={{ padding: '7px 8px', whiteSpace: 'nowrap', textAlign: 'right' }}>
<button className="btn-admin-ghost" style={{ fontSize: '0.78rem', marginRight: 6 }} onClick={() => toggleActive(h)}>
{h.active ? 'Disable' : 'Enable'}
</button>
<button className="btn-admin-ghost" style={{ fontSize: '0.78rem', marginRight: 6 }} onClick={() => { setResetId(h.id); setResetPw(''); setResetError('') }}>
Reset PW
</button>
<button className="btn-admin-remove" style={{ fontSize: '0.78rem' }} onClick={() => deleteHost(h.id)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{!loading && hosts.length === 0 && <p className="admin-stats-note">No upload hosts yet. Add one below.</p>}
{resetId && (
<form onSubmit={resetPassword} style={{ background: 'var(--color-surface-alt, #f7f5f0)', border: '1px solid var(--color-border, #e5e0d5)', borderRadius: 8, padding: '1rem', marginBottom: '1.5rem' }}>
<p style={{ fontWeight: 600, marginBottom: 8 }}>Reset password for {hosts.find(h => h.id === resetId)?.email}</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="admin-field" style={{ flex: '1 1 200px' }}>
<label>New Password (min 8 chars)</label>
<input type="password" value={resetPw} onChange={e => setResetPw(e.target.value)} required minLength={8} />
</div>
<button className="btn-admin-save" type="submit" disabled={resetSaving}>{resetSaving ? 'Saving…' : 'Save'}</button>
<button className="btn-admin-ghost" type="button" onClick={() => setResetId(null)}>Cancel</button>
</div>
{resetError && <p style={{ color: '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{resetError}</p>}
</form>
)}
{resetEmailSent && !resetId && <p style={{ color: '#27ae60', fontSize: '0.85rem', marginBottom: 8 }}>✓ New password emailed to host.</p>}
<form onSubmit={addHost}>
<h3 style={{ marginBottom: 10 }}>Add Host Account</h3>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div className="admin-field" style={{ flex: '1 1 160px' }}>
<label>Display Name</label>
<input type="text" placeholder="Jane Smith" value={addName} onChange={e => setAddName(e.target.value)} required />
</div>
<div className="admin-field" style={{ flex: '1 1 200px' }}>
<label>Email — used to log in</label>
<input type="email" placeholder="jane@example.com" value={addEmail} onChange={e => setAddEmail(e.target.value)} required />
</div>
<div className="admin-field" style={{ flex: '1 1 150px' }}>
<label>Password (min 8 chars)</label>
<input type="password" value={addPassword} onChange={e => setAddPassword(e.target.value)} required minLength={8} />
</div>
<button className="btn-admin-save" type="submit" disabled={addSaving}>{addSaving ? 'Adding…' : '+ Add Host'}</button>
</div>
{addError && <p style={{ color: '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{addError}</p>}
{addEmailSent && <p style={{ color: '#27ae60', marginTop: 6, fontSize: '0.85rem' }}>✓ Welcome email sent to host with login details.</p>}
</form>
</section>
)
}
// ── Nuggets Announcement admin ─────────────────────────────────────────────
function NuggetsAnnouncementAdmin() {
const [text, setText] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
useEffect(() => {
fetch('/api/admin/nuggets-upload/announcement')
.then(r => r.json())
.then(d => setText(d.text ?? ''))
.finally(() => setLoading(false))
}, [])
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
setMsg('')
try {
const r = await fetch('/api/admin/nuggets-upload/announcement', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
})
const d = await r.json()
if (!r.ok) { setMsg(d.message ?? 'Failed.'); return }
setMsg(text ? '✓ Announcement saved.' : '✓ Announcement cleared.')
} finally {
setSaving(false)
}
}
async function handleClear() {
setSaving(true)
setMsg('')
try {
await fetch('/api/admin/nuggets-upload/announcement', { method: 'DELETE' })
setText('')
setMsg('✓ Announcement cleared.')
} finally {
setSaving(false)
}
}
return (
<section className="admin-panel-section" aria-label="Nuggets announcement">
<div className="admin-panel-head">
<h2>Nuggets — Portal Announcement</h2>
<p>This text appears as a banner at the top of the host upload portal. Leave blank to hide.</p>
</div>
{loading ? <p className="admin-stats-note">Loading…</p> : (
<form onSubmit={handleSave}>
<div className="admin-field" style={{ maxWidth: 560 }}>
<label htmlFor="ann-text">Announcement (max 500 chars)</label>
<textarea
id="ann-text"
rows={3}
maxLength={500}
placeholder="e.g. Please use the updated naming convention for episode files this week."
value={text}
onChange={e => setText(e.target.value)}
/>
<span style={{ fontSize: '0.78rem', color: '#999' }}>{text.length}/500</span>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<button className="btn-admin-save" type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
{text && <button className="btn-admin-ghost" type="button" onClick={handleClear} disabled={saving}>Clear</button>}
</div>
{msg && <p style={{ color: msg.startsWith('✓') ? '#27ae60' : '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{msg}</p>}
</form>
)}
</section>
)
}
// ── Nuggets Guidelines admin ───────────────────────────────────────────────
const DEFAULT_GUIDELINE_LINES = [
'Export as MP3 or WAV at 44.1 kHz, 128 kbps minimum.',
'File name: YYYY-MM-DD-EpisodeName.mp3 (e.g. 2026-09-09-Romans-5.mp3).',
'Recommended length: 8–15 minutes. Keep a 0.5s lead-in and 1s fade-out.',
'Normalize to -1 dBFS before exporting.',
'Match the episode name here to the file name exactly so search stays clean.',
].join('\n')
function NuggetsGuidelinesAdmin() {
const [lines, setLines] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
useEffect(() => {
fetch('/api/nuggets-upload/guidelines')
.then(r => r.json())
.then(d => setLines(Array.isArray(d.lines) ? d.lines.join('\n') : DEFAULT_GUIDELINE_LINES))
.finally(() => setLoading(false))
}, [])
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
setMsg('')
try {
const r = await fetch('/api/admin/nuggets-upload/guidelines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lines }),
})
const d = await r.json()
if (!r.ok) { setMsg(d.message ?? 'Failed.'); return }
setMsg('✓ Guidelines saved.')
} finally {
setSaving(false)
}
}
async function handleReset() {
setSaving(true)
setMsg('')
try {
await fetch('/api/admin/nuggets-upload/guidelines', { method: 'DELETE' })
setLines(DEFAULT_GUIDELINE_LINES)
setMsg('✓ Reset to defaults.')
} finally {
setSaving(false)
}
}
return (
<section className="admin-panel-section" aria-label="Nuggets recording guidelines">
<div className="admin-panel-head">
<h2>Nuggets — Recording Guidelines</h2>
<p>
One guideline per line. These appear in the collapsible "Recording Guidelines" panel on the host upload portal.
Max 20 lines.
</p>
</div>
{loading ? <p className="admin-stats-note">Loading…</p> : (
<form onSubmit={handleSave}>
<div className="admin-field" style={{ maxWidth: 620 }}>
<label htmlFor="gl-lines">Guidelines (one per line)</label>
<textarea
id="gl-lines"
rows={10}
style={{ fontFamily: 'monospace', fontSize: '0.87rem' }}
value={lines}
onChange={e => setLines(e.target.value)}
/>
<span style={{ fontSize: '0.78rem', color: '#999' }}>
{lines.split('\n').filter(l => l.trim()).length} / 20 lines
</span>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<button className="btn-admin-save" type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
<button className="btn-admin-ghost" type="button" onClick={handleReset} disabled={saving}>Reset to defaults</button>
</div>
{msg && <p style={{ color: msg.startsWith('✓') ? '#27ae60' : '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{msg}</p>}
</form>
)}
</section>
)
}
// ── Nuggets Upload History admin ───────────────────────────────────────────
interface HistoryEntryAdmin {
hostId: string
hostName: string
episodeName: string
passage: string
filename: string
uploadedAt: string
}
function NuggetsHistoryAdmin() {
const [entries, setEntries] = useState<HistoryEntryAdmin[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('')
useEffect(() => {
fetch('/api/admin/nuggets-upload/history')
.then(r => r.json())
.then(d => setEntries(d.entries ?? []))
.finally(() => setLoading(false))
}, [])
const visible = filter.trim()
? entries.filter(e =>
e.hostName.toLowerCase().includes(filter.toLowerCase()) ||
e.episodeName.toLowerCase().includes(filter.toLowerCase()) ||
e.passage.toLowerCase().includes(filter.toLowerCase()),
)
: entries
return (
<section className="admin-panel-section" aria-label="Nuggets upload history">
<div className="admin-panel-head">
<h2>Nuggets — Upload History</h2>
<p>All episodes submitted through the host portal, newest first.</p>
</div>
{loading ? <p className="admin-stats-note">Loading…</p> : (
<>
{entries.length > 0 && (
<div className="admin-field" style={{ maxWidth: 320, marginBottom: 12 }}>
<input type="text" placeholder="Filter by host, episode, or passage…" value={filter} onChange={e => setFilter(e.target.value)} />
</div>
)}
{visible.length === 0 ? (
<p className="admin-stats-note">{entries.length === 0 ? 'No uploads yet.' : 'No results match that filter.'}</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--color-border, #e5e0d5)' }}>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Date</th>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Host</th>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Episode</th>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Passage</th>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>File</th>
</tr>
</thead>
<tbody>
{visible.map((e, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
<td style={{ padding: '6px 8px', whiteSpace: 'nowrap' }}>{new Date(e.uploadedAt).toLocaleDateString()}</td>
<td style={{ padding: '6px 8px' }}>{e.hostName}</td>
<td style={{ padding: '6px 8px' }}>{e.episodeName}</td>
<td style={{ padding: '6px 8px' }}>{e.passage}</td>
<td style={{ padding: '6px 8px', fontFamily: 'monospace', fontSize: '0.78rem', wordBreak: 'break-all' }}>{e.filename}</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</section>
)
}
// ── Nuggets Episode Workflow admin ─────────────────────────────────────────
type WorkflowStepKey = 'listen' | 'coldOpen' | 'recordColdOpen' | 'edit' | 'spotify'
interface WorkflowState {
steps: Record<WorkflowStepKey, string | null>
coldOpenText: string
notes: string
spotifyUrl: string
}
interface WorkflowItem {
id: string
entry: { hostName: string; episodeName: string; passage: string; description?: string; filename: string; uploadedAt: string }
state: WorkflowState
}
const WORKFLOW_STEP_DEFS: { key: WorkflowStepKey; label: string; hint: string }[] = [
{ key: 'listen', label: 'Listen to the uploaded file', hint: 'Audition the host’s recording and jot down notes.' },
{ key: 'coldOpen', label: 'Get a cold open', hint: 'Pick or write the hook that opens the episode.' },
{ key: 'recordColdOpen', label: 'Record the cold open', hint: 'Record the cold open take.' },
{ key: 'edit', label: 'Edit the episode', hint: 'Cold open + episode: trim, clean up, mix, master.' },
{ key: 'spotify', label: 'Upload to Spotify', hint: 'Publish with show notes and paste the episode link below.' },
]
function NuggetsWorkflowAdmin() {
const [items, setItems] = useState<WorkflowItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedId, setSelectedId] = useState('')
const [showDone, setShowDone] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
fetch('/api/admin/nuggets-workflow')
.then(r => r.json())
.then(d => setItems(d.items ?? []))
.catch(() => setError('Could not load uploads.'))
.finally(() => setLoading(false))
}, [])
const isDone = (i: WorkflowItem) => WORKFLOW_STEP_DEFS.every(s => i.state.steps[s.key])
const visible = showDone ? items : items.filter(i => !isDone(i))
const selected = items.find(i => i.id === selectedId) ?? null
const save = async (id: string, patch: Partial<Omit<WorkflowState, 'steps'>> & { steps?: Partial<Record<WorkflowStepKey, boolean>> }) => {
setSaving(true)
setError('')
try {
const r = await fetch(`/api/admin/nuggets-workflow/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
const d = await r.json()
if (!r.ok) throw new Error(d.message ?? 'Save failed.')
setItems(prev => prev.map(i => (i.id === id ? { ...i, state: d.state } : i)))
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed.')
} finally {
setSaving(false)
}
}
const patchLocal = (id: string, patch: Partial<WorkflowState>) =>
setItems(prev => prev.map(i => (i.id === id ? { ...i, state: { ...i.state, ...patch } } : i)))
return (
<section className="admin-panel-section" aria-label="Nuggets episode workflow">
<div className="admin-panel-head">
<h2>Nuggets — Episode Workflow</h2>
<p>Walk each portal upload through: listen → cold open → record cold open → edit → Spotify.</p>
</div>
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
{loading ? <p className="admin-stats-note">Loading…</p> : (
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(220px, 300px) 1fr', gap: 20, alignItems: 'start' }}>
<div>
<label style={{ fontSize: '0.82rem', display: 'block', marginBottom: 8 }}>
<input type="checkbox" checked={showDone} onChange={e => setShowDone(e.target.checked)} /> Show completed
</label>
{visible.length === 0 && <p className="admin-stats-note">{items.length === 0 ? 'No uploads yet.' : 'All caught up.'}</p>}
{visible.map(i => {
const done = WORKFLOW_STEP_DEFS.filter(s => i.state.steps[s.key]).length
return (
<button
key={i.id}
type="button"
onClick={() => setSelectedId(i.id)}
style={{
display: 'block', width: '100%', textAlign: 'left', padding: '8px 10px', marginBottom: 6,
border: `1px solid ${i.id === selectedId ? '#8a6d2f' : 'var(--color-border, #e5e0d5)'}`,
borderRadius: 6, background: 'transparent', cursor: 'pointer', color: 'inherit',
}}
>
<strong>{i.entry.episodeName}</strong>
<div style={{ fontSize: '0.78rem', opacity: 0.75 }}>
{i.entry.passage} · {i.entry.hostName} · {new Date(i.entry.uploadedAt).toLocaleDateString()}
</div>
<div style={{ fontSize: '0.78rem', marginTop: 2 }}>{done}/{WORKFLOW_STEP_DEFS.length} steps</div>
</button>
)
})}
</div>
<div>
{!selected ? <p className="admin-stats-note">Select an upload to work on.</p> : (
<>
<h3 style={{ marginTop: 0 }}>{selected.entry.episodeName}</h3>
<p style={{ fontSize: '0.86rem', opacity: 0.8 }}>
{selected.entry.passage} · uploaded by {selected.entry.hostName} · <code>{selected.entry.filename}</code>
</p>
{selected.entry.description && <p style={{ fontSize: '0.86rem' }}>{selected.entry.description}</p>}
<ol style={{ paddingLeft: 0, listStyle: 'none', margin: 0 }}>
{WORKFLOW_STEP_DEFS.map((s, idx) => {
const completedAt = selected.state.steps[s.key]
return (
<li key={s.key} style={{ padding: '12px 0', borderTop: '1px solid var(--color-border, #e5e0d5)' }}>
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start', cursor: 'pointer' }}>
<input
type="checkbox"
checked={Boolean(completedAt)}
onChange={e => save(selected.id, { steps: { [s.key]: e.target.checked } })}
style={{ marginTop: 4 }}
/>
<span>
<strong>{idx + 1}. {s.label}</strong>
<span style={{ display: 'block', fontSize: '0.8rem', opacity: 0.7 }}>
{s.hint}{completedAt ? ` · done ${new Date(completedAt).toLocaleString()}` : ''}
</span>
</span>
</label>
{s.key === 'listen' && (
<div style={{ marginTop: 8 }}>
<audio
key={selected.id}
controls
preload="none"
style={{ width: '100%' }}
src={`/api/admin/nuggets-workflow/${selected.id}/audio`}
onError={async () => {
const r = await fetch(`/api/admin/nuggets-workflow/${selected.id}/audio`, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
if (!r) setError('Audio request failed (network).')
else if (r.ok) setError('The server sent the audio but the browser could not play it. The file may be in an unsupported format.')
else setError(`Audio unavailable (${r.status}): ${(await r.json().catch(() => ({}))).message ?? 'unknown error'}`)
}}
/>
</div>
)}
{s.key === 'coldOpen' && (
<textarea
rows={4}
placeholder="Cold open script / hook…"
value={selected.state.coldOpenText}
onChange={e => patchLocal(selected.id, { coldOpenText: e.target.value })}
onBlur={e => save(selected.id, { coldOpenText: e.target.value })}
style={{ width: '100%', marginTop: 8 }}
/>
)}
{s.key === 'spotify' && (
<input
type="text"
placeholder="Spotify episode URL (optional)"
value={selected.state.spotifyUrl}
onChange={e => patchLocal(selected.id, { spotifyUrl: e.target.value })}
onBlur={e => save(selected.id, { spotifyUrl: e.target.value })}
style={{ width: '100%', marginTop: 8 }}
/>
)}
</li>
)
})}
</ol>
<div className="admin-field" style={{ marginTop: 12 }}>
<label htmlFor="wf-notes">Notes</label>
<textarea
id="wf-notes"
rows={3}
value={selected.state.notes}
onChange={e => patchLocal(selected.id, { notes: e.target.value })}
onBlur={e => save(selected.id, { notes: e.target.value })}
/>
</div>
{saving && <p className="admin-stats-note">Saving…</p>}
</>
)}
</div>
</div>
)}
</section>
)
}
type PodcastStepKey = 'readScript' | 'record' | 'edit' | 'upload'
interface PodcastWorkflowState { steps: Record<PodcastStepKey, string | null>; notes: string }
const PODCAST_STEP_DEFS: { key: PodcastStepKey; label: string; hint: string }[] = [
{ key: 'readScript', label: 'Read the script', hint: 'Read it through, mark pacing and emphasis.' },
{ key: 'record', label: 'Record the episode', hint: 'Capture the final take.' },
{ key: 'edit', label: 'Edit the episode', hint: 'Trim, clean up, mix and master.' },
{ key: 'upload', label: 'Upload to Spotify', hint: 'Publish with show notes. It moves to Released once it’s in the feed.' },
]
// ── Podcast Episode Workflow (Lane A / Lane B) ─────────────────────────────
interface PodcastWorkflowItem {
key: string
season: string
episodeNumber: string
title: string
lane: 'A' | 'B' | 'other'
laneOverride?: '' | 'A' | 'B' | 'other'
released: boolean
releaseDate?: string
spotifyUrl?: string
planned?: boolean
state: PodcastWorkflowState
}
function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string }; laneB: { title: string } }) {
const [laneSeasons, setLaneSeasons] = useState<{ A: string; B: string }>({ A: '', B: '' })
const [items, setItems] = useState<PodcastWorkflowItem[]>([])
const [feedOk, setFeedOk] = useState(true)
const [loading, setLoading] = useState(true)
const [lane, setLane] = useState<'A' | 'B' | 'other'>('A')
const [selectedKey, setSelectedKey] = useState('')
const [showReleased, setShowReleased] = useState(false)
const [starting, setStarting] = useState(false)
const [newSeason, setNewSeason] = useState('')
const [newNumber, setNewNumber] = useState('')
const [newTitle, setNewTitle] = useState('')
const [error, setError] = useState('')
const reload = () =>
fetch('/api/admin/podcast-workflow')
.then(r => r.json())
.then(d => { setItems(d.items ?? []); setFeedOk(d.feedOk !== false); setLaneSeasons(d.laneSeasons ?? { A: '', B: '' }) })
.catch(() => setError('Could not load episodes.'))
.finally(() => setLoading(false))
useEffect(() => { reload() }, [])
const laneSeason = lane === 'A' || lane === 'B' ? laneSeasons[lane] : ''
const saveLaneSeason = async (value: string) => {
if (lane === 'other') return
setError('')
const r = await fetch('/api/admin/podcast-workflow-lane-seasons', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [lane]: value }),
})
if (!r.ok) { const d = await r.json(); setError(d.message ?? 'Could not save season.'); return }
setSelectedKey('')
reload()
}
const hasOther = items.some(i => i.lane === 'other')
const inLane = items.filter(i => i.lane === lane)
const upcoming = inLane.filter(i => !i.released)
// A season is finished once none of its episodes are still in progress. Only the "Other seasons"
// tab groups these away; a lane's own season is always shown as current.
const seasonGroups = new Map<string, PodcastWorkflowItem[]>()
if (lane === 'other') {
for (const i of inLane) seasonGroups.set(i.season, [...(seasonGroups.get(i.season) ?? []), i])
}
const finishedSeasons = [...seasonGroups.entries()]
.filter(([, eps]) => eps.every(e => e.released))
.sort(([a], [b]) => (Number(b) || 0) - (Number(a) || 0))
const finishedKeys = new Set(finishedSeasons.flatMap(([, eps]) => eps.map(e => e.key)))
const released = inLane.filter(i => i.released && !finishedKeys.has(i.key))
const selected = items.find(i => i.key === selectedKey) ?? null
const save = async (key: string, patch: Record<string, unknown>) => {
setError('')
try {
const r = await fetch(`/api/admin/podcast-workflow/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
const d = await r.json()
if (!r.ok) throw new Error(d.message ?? 'Save failed.')
setItems(prev => prev.map(i => (i.key === key ? { ...i, state: d.state } : i)))
} catch (e) {
setError(e instanceof Error ? e.message : 'Save failed.')
}
}
const patchLocal = (key: string, patch: Partial<PodcastWorkflowState>) =>
setItems(prev => prev.map(i => (i.key === key ? { ...i, state: { ...i.state, ...patch } } : i)))
const addPlanned = async () => {
setError('')
const r = await fetch('/api/admin/podcast-workflow/planned', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ season: newSeason, episodeNumber: newNumber, title: newTitle }),
})
const d = await r.json()
if (!r.ok) { setError(d.message ?? 'Could not add episode.'); return }
setNewNumber(''); setNewTitle(''); setStarting(false)
await reload()
setSelectedKey(d.key)
}
const setLaneFor = async (key: string, value: string) => {
await fetch(`/api/admin/podcast-workflow/${encodeURIComponent(key)}/lane`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lane: value }),
})
setSelectedKey('')
reload()
}
const removePlanned = async (key: string) => {
if (!confirm('Remove this planned episode and its progress?')) return
await fetch(`/api/admin/podcast-workflow/planned/${encodeURIComponent(key)}`, { method: 'DELETE' })
setSelectedKey('')
reload()
}
const label = (i: PodcastWorkflowItem) => `${i.season ? `S${i.season} ` : ''}${i.episodeNumber ? `Ep ${i.episodeNumber}` : 'Ep'}${i.title ? ` — ${i.title}` : ''}`
const doneCount = (i: PodcastWorkflowItem) => (i.released ? PODCAST_STEP_DEFS.length : PODCAST_STEP_DEFS.filter(s => i.state.steps[s.key]).length)
const row = (i: PodcastWorkflowItem) => (
<button
key={i.key}
type="button"
onClick={() => setSelectedKey(i.key)}
style={{
display: 'block', width: '100%', textAlign: 'left', padding: '8px 10px', marginBottom: 6,
border: `1px solid ${i.key === selectedKey ? '#8a6d2f' : 'var(--color-border, #e5e0d5)'}`,
borderRadius: 6, background: 'transparent', cursor: 'pointer', color: 'inherit', font: 'inherit',
}}
>
<strong>{label(i)}</strong>
<div style={{ fontSize: '0.78rem', opacity: 0.75 }}>
{i.released ? `Released ${i.releaseDate || ''}` : `${doneCount(i)}/${PODCAST_STEP_DEFS.length} steps`}
</div>
</button>
)
return (
<section className="admin-panel-section" aria-label="Podcast episode workflow">
<div className="admin-panel-head">
<h2>Podcast — Episode Workflow</h2>
<p>Every episode for each series: what’s still in progress, and what’s already out (dates come from your Spotify feed).</p>
</div>
{!feedOk && <p style={{ color: '#c0392b' }}>Couldn’t reach the podcast feed, so released episodes may be missing right now.</p>}
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
{([['A', laneA.title || 'Primary series'], ['B', laneB.title || 'Second series']] as const).map(([k, t]) => (
<button key={k} type="button" className={lane === k ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => { setLane(k); setSelectedKey('') }}>
Lane {k} — {t} ({items.filter(i => i.lane === k && !i.released).length} in progress)
</button>
))}
{hasOther && (
<button type="button" className={lane === 'other' ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => { setLane('other'); setSelectedKey('') }}>
Other seasons ({items.filter(i => i.lane === 'other' && !i.released).length} in progress)
</button>
)}
</div>
{lane !== 'other' ? (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', margin: '0 0 12px', flexWrap: 'wrap' }}>
<label htmlFor="pw-season">Season number for Lane {lane}</label>
<input
key={`${lane}-${laneSeason}`}
id="pw-season"
type="number"
min={1}
defaultValue={laneSeason}
style={{ width: 80 }}
onBlur={e => { if (e.target.value !== laneSeason) saveLaneSeason(e.target.value) }}
/>
<span className="admin-stats-note">
{laneSeason ? `Only season ${laneSeason} episodes are shown here.` : 'Set a season number so this lane picks up its episodes, or start an episode and assign it by hand.'}
</span>
</div>
) : (
<p className="admin-stats-note">Episodes from other seasons (finished or not assigned to a lane).</p>
)}
{loading ? <p className="admin-stats-note">Loading…</p> : (
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(220px, 300px) 1fr', gap: 20, alignItems: 'start' }}>
<div>
<strong style={{ fontSize: '0.82rem' }}>Not released yet</strong>
<div style={{ marginTop: 6 }}>
{upcoming.length === 0 && <p className="admin-stats-note">Nothing in progress.</p>}
{upcoming.map(row)}
</div>
{!starting ? (
<button type="button" className="btn-admin-add" style={{ margin: '8px 0 14px' }} onClick={() => { setNewSeason(laneSeason); setStarting(true) }}>
+ Start new episode
</button>
) : (
<div style={{ margin: '8px 0 14px', padding: 10, border: '1px solid var(--color-border, #e5e0d5)', borderRadius: 6 }}>
<div className="admin-field">
<label htmlFor="pw-new-title">Episode name</label>
<input id="pw-new-title" type="text" value={newTitle} onChange={e => setNewTitle(e.target.value)} />
</div>
<div style={{ display: 'flex', gap: 10, marginBottom: 8 }}>
<div className="admin-field" style={{ flex: 1 }}>
<label htmlFor="pw-new-season">Season</label>
<input id="pw-new-season" type="number" min={1} value={newSeason} onChange={e => setNewSeason(e.target.value)} />
</div>
<div className="admin-field" style={{ flex: 1 }}>
<label htmlFor="pw-new-number">Episode #</label>
<input id="pw-new-number" type="number" min={1} value={newNumber} onChange={e => setNewNumber(e.target.value)} />
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn-admin-save" onClick={addPlanned} disabled={!newTitle.trim() || !newSeason || !newNumber}>Start workflow</button>
<button type="button" className="btn-admin-ghost" onClick={() => setStarting(false)}>Cancel</button>
</div>
</div>
)}
<label style={{ fontSize: '0.82rem', display: 'block', marginBottom: 6 }}>
<input type="checkbox" checked={showReleased} onChange={e => setShowReleased(e.target.checked)} /> Show released ({released.length})
</label>
{showReleased && released.map(row)}
{finishedSeasons.length > 0 && (
<div style={{ marginTop: 14 }}>
<strong style={{ fontSize: '0.82rem' }}>Finished seasons</strong>
{finishedSeasons.map(([season, eps]) => {
const last = eps.map(e => e.releaseDate ?? '').sort().pop()
return (
<details key={season || 'none'} style={{ marginTop: 6 }}>
<summary style={{ cursor: 'pointer', padding: '6px 2px' }}>
{season ? `Season ${season}` : 'No season number'} — {eps.length} episode{eps.length === 1 ? '' : 's'}{last ? ` · last released ${last}` : ''}
</summary>
{eps.map(row)}
</details>
)
})}
</div>
)}
</div>
<div>
{!selected ? <p className="admin-stats-note">Select an episode to work on.</p> : (
<>
<h3 style={{ marginTop: 0 }}>{label(selected)}</h3>
{selected.released ? (
<p style={{ fontSize: '0.86rem' }}>
Released {selected.releaseDate}
{selected.spotifyUrl && <> · <a href={selected.spotifyUrl} target="_blank" rel="noopener noreferrer">Listen</a></>}
</p>
) : (
<p style={{ fontSize: '0.86rem', opacity: 0.8 }}>
This moves to Released on its own once season {selected.season} episode {selected.episodeNumber} shows up in your Spotify feed.
</p>
)}
<div className="admin-field" style={{ maxWidth: 360, margin: '8px 0 12px' }}>
<label htmlFor="pw-lane">Lane</label>
<select id="pw-lane" value={selected.laneOverride || 'auto'} onChange={e => setLaneFor(selected.key, e.target.value)}>
<option value="auto">Automatic (by season number{selected.season ? ` ${selected.season}` : ''})</option>
<option value="A">Lane A — {laneA.title || 'Primary series'}</option>
<option value="B">Lane B — {laneB.title || 'Second series'}</option>
<option value="other">Other</option>
</select>
</div>
<ol style={{ paddingLeft: 0, listStyle: 'none', margin: 0 }}>
{PODCAST_STEP_DEFS.map((s, idx) => {
const completedAt = selected.state.steps[s.key]
const checked = selected.released || Boolean(completedAt)
return (
<li key={s.key} style={{ padding: '12px 0', borderTop: '1px solid var(--color-border, #e5e0d5)' }}>
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start', cursor: selected.released ? 'default' : 'pointer' }}>
<input
type="checkbox"
checked={checked}
disabled={selected.released}
onChange={e => save(selected.key, { steps: { [s.key]: e.target.checked } })}
style={{ marginTop: 4 }}
/>
<span>
<strong>{idx + 1}. {s.label}</strong>
<span style={{ display: 'block', fontSize: '0.8rem', opacity: 0.7 }}>
{s.hint}{completedAt && !selected.released ? ` · done ${new Date(completedAt).toLocaleString()}` : ''}
</span>
</span>
</label>
</li>
)
})}
</ol>
<div className="admin-field" style={{ marginTop: 12 }}>
<label htmlFor="pw-notes">Notes</label>
<textarea
id="pw-notes"
rows={3}
value={selected.state.notes}
onChange={e => patchLocal(selected.key, { notes: e.target.value })}
onBlur={e => save(selected.key, { notes: e.target.value })}
/>
</div>
{selected.planned && !selected.released && (
<button type="button" className="btn-admin-remove" onClick={() => removePlanned(selected.key)}>Remove this episode</button>
)}
</>
)}
</div>
</div>
)}
</section>
)
}
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' | 'all'>('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 [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
const [endSeriesOpen, setEndSeriesOpen] = useState(false)
const [endSeriesNewTitle, setEndSeriesNewTitle] = useState('')
const [endSeriesNewImage, setEndSeriesNewImage] = useState('')
const [endSeriesNewSeason, setEndSeriesNewSeason] = useState('')
const [bulkStart, setBulkStart] = useState('')
const [bulkInterval, setBulkInterval] = useState('14')
const [bulkOnlyEmpty, setBulkOnlyEmpty] = useState(false)
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 [resendCmp, setResendCmp] = useState<ResendComparison | null>(null)
const [resendCmpLoading, setResendCmpLoading] = useState(false)
const [resendCmpBusy, setResendCmpBusy] = useState(false)
const [resendCmpMsg, setResendCmpMsg] = useState('')
const [resendImportSel, setResendImportSel] = useState<Set<string>>(new Set())
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<{ key: string; episodeNumber: string; title: string; filename: string; season: string | null; uploadedAt: string | null; wordCount: number }>>([])
const [scriptsLoading, setScriptsLoading] = useState(false)
const [scriptUploadEpisode, setScriptUploadEpisode] = useState('')
const [scriptUploadTitle, setScriptUploadTitle] = useState('')
const [scriptUploadSeason, setScriptUploadSeason] = useState('')
const [scriptUploadFile, setScriptUploadFile] = useState<File | null>(null)
const [scriptUploadBusy, setScriptUploadBusy] = useState(false)
const [scriptUploadMsg, setScriptUploadMsg] = useState('')
const [scriptSeasonFilter, setScriptSeasonFilter] = useState('all')
const [scriptBackfillMsg, setScriptBackfillMsg] = 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<{ broadcastId: string; scheduledAt: string | null } | null>(null)
const [bcPreviewText, setBcPreviewText] = useState('')
const [bcTagNewEpisode, setBcTagNewEpisode] = useState(false)
const [bcSchedule, setBcSchedule] = useState('')
const [bcTestTo, setBcTestTo] = useState('')
const [bcTesting, setBcTesting] = useState(false)
const [bcAudience, setBcAudience] = useState<{ segmentConfigured: boolean; count: number | null } | null>(null)
const [bcAudienceLoading, setBcAudienceLoading] = useState(false)
const [broadcastMsg, setBroadcastMsg] = useState('')
const [broadcastConfirm, setBroadcastConfirm] = useState(false)
const [bcTemplates, setBcTemplates] = useState<Array<{ id: string; name: string; status: string }>>([])
const [bcTplLoaded, setBcTplLoaded] = useState(false)
const [bcTplLoading, setBcTplLoading] = useState(false)
const [bcTplId, setBcTplId] = useState('')
const [bcTplNote, setBcTplNote] = useState('')
// Newsletter archive state
interface ResendBroadcastRow { id: string; name: string; subject: string; sentAt: string | null; topicId: string | null; matchesNewEpisodeTopic: boolean; alreadyPublished: boolean }
interface NewsletterIssueAdmin { id: string; resendBroadcastId: string; slug: string; title: string; subject: string; description: string; sentAt: string | null; publishedAt: string; public: boolean }
const [nlBroadcasts, setNlBroadcasts] = useState<ResendBroadcastRow[]>([])
const [nlIssues, setNlIssues] = useState<NewsletterIssueAdmin[]>([])
const [nlLoaded, setNlLoaded] = useState(false)
const [nlLoading, setNlLoading] = useState(false)
const [nlTopicFound, setNlTopicFound] = useState(true)
const [nlMsg, setNlMsg] = useState('')
const [nlPublishingId, setNlPublishingId] = useState<string | null>(null)
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 && !s.read).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(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-fetch is driven explicitly when the range changes
}, [])
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' | 'all') {
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)
}
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),
lane: 'primary' as const,
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 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 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 === 'inbox' && unreadEmailCount > 0) {
return { count: unreadEmailCount, 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>
)
}
// Tabs inside long admin pages. The chosen tab is remembered per page while the admin is open.
const [adminTab, setAdminTab] = useState<Record<string, string>>({})
const tabOf = (view: string, first: string) => adminTab[view] ?? first
function renderTabs(view: string, first: string, tabs: { id: string; label: string }[]) {
const current = tabOf(view, first)
return (
<div className="admin-tabs admin-tabs--page" role="tablist" aria-label="Page sections">
{tabs.map(tab => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={current === tab.id}
className={`admin-tab${current === tab.id ? ' admin-tab--active' : ''}`}
onClick={() => setAdminTab(prev => ({ ...prev, [view]: tab.id }))}
>
{tab.label}
</button>
))}
</div>
)
}
// Sections of long pages are shown as tabs instead of one long scroll.
function renderSectionJumpNav(view: AdminView) {
const links = ADMIN_SECTION_LINKS[view] ?? []
if (links.length < 2) return null
return renderTabs(view, links[0].id, links)
}
// Each series has its own companion guide: the main series' lives in the studyGuide* fields,
// the second series' in secondSeries.guide, and finished books keep the one they had.
function patchSecondGuide(patch: Partial<StudyGuide>) {
setForm(f => ({
...f,
secondSeries: {
label: 'Now Playing', title: '', description: '', imageUrl: '', listenUrl: '', listenBtnLabel: '',
...(f.secondSeries ?? {}),
guide: { title: '', ...(f.secondSeries?.guide ?? {}), ...patch },
},
}))
}
function patchBookGuide(bookId: string, patch: Partial<StudyGuide>) {
setForm(f => ({
...f,
finishedBooks: (f.finishedBooks ?? []).map(b => b.id === bookId ? { ...b, guide: { title: '', ...(b.guide ?? {}), ...patch } } : b),
}))
}
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),
}))
}
// Stamps release dates on a study's lessons in order: lesson N = start + N × interval days.
// Uses local-time date arithmetic so the time of day survives daylight-saving changes.
function applyBulkSchedule(studyId: string) {
if (!bulkStart) return
const interval = Math.max(1, parseInt(bulkInterval) || 14)
const base = new Date(bulkStart)
if (isNaN(base.getTime())) return
setForm(f => ({
...f,
studies: (f.studies ?? []).map(study => study.id !== studyId ? study : {
...study,
sections: (study.sections ?? []).map((section, index) => {
if (bulkOnlyEmpty && section.releasedAt) return section
const date = new Date(base)
date.setDate(date.getDate() + index * interval)
return { ...section, releasedAt: date.toISOString() }
}),
}),
}))
}
// Lessons from this study that land on the same calendar day as a lesson in another study.
function findReleaseCollisions(study: StudyProgram) {
const dayKey = (iso?: string) => {
if (!iso) return ''
const d = new Date(iso)
return isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-CA')
}
const otherDays = new Map<string, string>()
for (const other of form.studies ?? []) {
if (other.id === study.id || other.status === 'planned') continue
for (const section of other.sections ?? []) {
const key = dayKey(section.releasedAt)
if (key) otherDays.set(key, other.title)
}
}
const hits: { date: string; title: string; other: string }[] = []
for (const section of study.sections ?? []) {
const key = dayKey(section.releasedAt)
if (key && otherDays.has(key)) hits.push({ date: key, title: section.title || section.reference, other: otherDays.get(key) ?? '' })
}
return hits
}
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 loadNewsletterAdmin() {
setNlLoading(true)
setNlMsg('')
try {
const [broadcastsRes, issuesRes] = await Promise.all([
fetch('/api/admin/newsletter/resend-broadcasts'),
fetch('/api/admin/newsletter/issues'),
])
const broadcastsData = await broadcastsRes.json()
const issuesData = await issuesRes.json()
if (!broadcastsRes.ok) { setNlMsg(broadcastsData.message || 'Failed to load Resend broadcasts.') }
setNlBroadcasts(Array.isArray(broadcastsData.broadcasts) ? broadcastsData.broadcasts : [])
setNlTopicFound(broadcastsData.newEpisodeTopicFound !== false)
setNlIssues(Array.isArray(issuesData.issues) ? issuesData.issues : [])
setNlLoaded(true)
} catch { setNlMsg('Network error loading newsletter data.') }
finally { setNlLoading(false) }
}
async function publishNewsletterIssue(broadcast: ResendBroadcastRow) {
setNlPublishingId(broadcast.id)
setNlMsg('')
try {
const res = await fetch('/api/admin/newsletter/issues', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ broadcastId: broadcast.id }),
})
const data = await res.json()
if (!res.ok) { setNlMsg(data.message || 'Failed to publish issue.'); return }
setNlIssues(prev => [data.issue, ...prev])
setNlBroadcasts(prev => prev.map(b => b.id === broadcast.id ? { ...b, alreadyPublished: true } : b))
} catch { setNlMsg('Network error. Try again.') }
finally { setNlPublishingId(null) }
}
async function toggleNewsletterIssuePublic(issue: NewsletterIssueAdmin) {
try {
const res = await fetch(`/api/admin/newsletter/issues/${issue.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ public: !issue.public }),
})
const data = await res.json()
if (res.ok) setNlIssues(prev => prev.map(i => i.id === issue.id ? data.issue : i))
} catch { /* silent */ }
}
async function deleteNewsletterIssue(issue: NewsletterIssueAdmin) {
if (!window.confirm(`Remove "${issue.title}" from the newsletter archive? This does not affect the sent email.`)) return
try {
const res = await fetch(`/api/admin/newsletter/issues/${issue.id}`, { method: 'DELETE' })
if (res.ok) {
setNlIssues(prev => prev.filter(i => i.id !== issue.id))
setNlBroadcasts(prev => prev.map(b => b.id === issue.resendBroadcastId ? { ...b, alreadyPublished: false } : b))
}
} catch { /* silent */ }
}
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 {
const res = await fetch(`/api/admin/qr-codes/${id}`, { method: 'DELETE' })
if (!res.ok) { alert('Failed to delete QR code.'); return }
setQrCodes(prev => prev.filter(c => c.id !== id))
setQrScans(prev => prev.filter(s => s.qrId !== id))
} catch { alert('Failed to delete QR code.') }
}
async function handleClearQrScans(id: string) {
if (!confirm('Clear all scan history for this QR code?')) return
try {
const res = await fetch(`/api/admin/qr-codes/${id}/scans`, { method: 'DELETE' })
if (!res.ok) { alert('Failed to clear scan history.'); return }
setQrScans(prev => prev.filter(s => s.qrId !== id))
setQrCodes(prev => prev.map(c => c.id === id ? { ...c, scanCount: 0 } : c))
} catch { alert('Failed to clear scan history.') }
}
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 (scriptUploadSeason.trim() && !/^\d+$/.test(scriptUploadSeason.trim())) {
setScriptUploadMsg('Season must be a positive whole 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()}`,
season: scriptUploadSeason.trim(),
}),
})
const data = await res.json()
if (!res.ok) { setScriptUploadMsg(data.message ?? 'Upload failed.'); return }
setScriptUploadMsg(`✓ Episode ${scriptUploadEpisode}${scriptUploadSeason.trim() ? ` (Season ${scriptUploadSeason.trim()})` : ''} uploaded — ${data.wordCount?.toLocaleString()} words indexed.`)
setScriptUploadEpisode('')
setScriptUploadTitle('')
setScriptUploadSeason('')
setScriptUploadFile(null)
loadScriptsList()
} catch {
setScriptUploadMsg('Upload failed. Please try again.')
} finally {
setScriptUploadBusy(false)
}
}
async function handleBackfillSeasons() {
setScriptsLoading(true)
setScriptBackfillMsg('')
try {
const res = await fetch('/api/admin-episode-scripts/backfill-seasons', { method: 'POST' })
const data = await res.json()
if (!res.ok) { setScriptBackfillMsg(data.message ?? 'Backfill failed.'); return }
const missing = (data.stillMissing ?? []) as string[]
setScriptBackfillMsg(
`✓ Filled in season for ${data.updated} script${data.updated === 1 ? '' : 's'} from the RSS feed.` +
(missing.length > 0 ? ` ${missing.length} episode${missing.length === 1 ? '' : 's'} not found in the feed (set season manually): ${missing.join(', ')}.` : '')
)
await loadScriptsList()
} catch {
setScriptBackfillMsg('Backfill failed. Please try again.')
} finally {
setScriptsLoading(false)
}
}
async function handleDeleteScript(key: string, label: string) {
if (!confirm(`Remove script for ${label}?`)) return
try {
const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(key)}`, { method: 'DELETE' })
if (!res.ok) { alert('Failed to delete script.'); return }
setScriptsList(prev => prev.filter(s => s.key !== key))
} 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>
{renderTabs('dashboard', 'overview', [{ id: 'overview', label: 'Overview' }, { id: 'activity', label: 'Recent Activity' }, { id: 'users', label: 'Study Users' }])}
{tabOf('dashboard', 'overview') === 'overview' && <>
<div className="admin-dashboard-welcome">
<div>
<h3 className="admin-dashboard-welcome-title">{welcomeMessage}</h3>
<p className="admin-dashboard-welcome-copy">Here&apos;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>
<AdminDashboardExtras
publishedAt={publishState.publishedAt}
draftUpdatedAt={publishState.draftUpdatedAt}
isDirty={isDirty}
onNavigate={view => navigateTo(view as AdminView)}
onRequestPublish={() => setPublishConfirmOpen(true)}
/>
<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>
{unreadEmailCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('inbox')}>View Inbox →</button>}
</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.filter(c => c.source !== 'resend-import').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>
</>}
{tabOf('dashboard', 'overview') === 'activity' && <>
<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>
</>}
{tabOf('dashboard', 'overview') === 'users' && <>
<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 &amp; 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>
)
})()}
{/* INBOX */}
{adminView === 'inbox' && (
<InboxPanel submissions={contactSubmissions} setSubmissions={setContactSubmissions} />
)}
{/* SUBSCRIBERS */}
{adminView === 'subscribers' && (() => {
const filteredSubs = subscribers.filter(s =>
!subscriberSearch.trim() ||
s.name.toLowerCase().includes(subscriberSearch.toLowerCase()) ||
s.email.toLowerCase().includes(subscriberSearch.toLowerCase())
)
async function runResendCompare() {
setResendCmpLoading(true); setResendCmpMsg('')
try {
const r = await fetch('/api/admin-subscribers/resend-compare')
const d = await r.json()
if (!r.ok) setResendCmpMsg(d.message || 'Comparison failed.')
else setResendCmp(d as ResendComparison)
} catch { setResendCmpMsg('Network error. Try again.') }
finally { setResendCmpLoading(false) }
}
async function runResendSync(action: 'add-to-resend' | 'add-to-segment' | 'apply-unsubscribes', prompt: string) {
if (!confirm(prompt)) return
setResendCmpBusy(true); setResendCmpMsg('')
try {
const r = await fetch('/api/admin-subscribers/resend-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
const d = await r.json()
if (!r.ok) { setResendCmpMsg(d.message || 'Sync failed.'); return }
setResendCmpMsg(`Done: ${d.done} updated${d.failed ? `, ${d.failed} failed` : ''}${d.remaining ? `, ${d.remaining} more — run the action again` : ''}.`)
const subsRes = await fetch('/api/admin-subscribers')
if (subsRes.ok) setSubscribers(((await subsRes.json()) as { subscribers: Subscriber[] }).subscribers ?? [])
await runResendCompare()
} catch { setResendCmpMsg('Network error. Try again.') }
finally { setResendCmpBusy(false) }
}
async function importFromResend() {
const emails = [...resendImportSel]
if (emails.length === 0) return
if (!confirm(`Add ${emails.length} contact${emails.length === 1 ? '' : 's'} from Resend to your subscriber list here?\n\nOnly do this for people who agreed to receive your emails.`)) return
setResendCmpBusy(true); setResendCmpMsg('')
try {
const r = await fetch('/api/admin-subscribers/import-from-resend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emails }),
})
const d = await r.json()
if (!r.ok) { setResendCmpMsg(d.message || 'Import failed.'); return }
setResendCmpMsg(`Imported ${d.imported}${d.skipped ? `, skipped ${d.skipped}` : ''}.`)
setResendImportSel(new Set())
const subsRes = await fetch('/api/admin-subscribers')
if (subsRes.ok) setSubscribers(((await subsRes.json()) as { subscribers: Subscriber[] }).subscribers ?? [])
await runResendCompare()
} catch { setResendCmpMsg('Network error. Try again.') }
finally { setResendCmpBusy(false) }
}
const renderCmpList = (title: string, note: string, rows: Array<{ name: string; email: string }>, action?: React.ReactNode, selectable = false) => (
<details className="cmp-group" open={rows.length > 0 && rows.length <= 10}>
<summary>
<span className={`cmp-count${rows.length === 0 ? ' is-zero' : ''}`}>{rows.length}</span>
<span className="cmp-title">{title}</span>
</summary>
<p className="cmp-note">{note}</p>
{rows.length > 0 && (
<ul className="cmp-rows">
{selectable && (
<li className="cmp-selectall">
<label><input type="checkbox" checked={rows.slice(0, 200).every(r => resendImportSel.has(r.email))} onChange={e => setResendImportSel(e.target.checked ? new Set(rows.slice(0, 200).map(r => r.email)) : new Set())} /> Select all{rows.length > 200 ? ' (first 200)' : ''}</label>
</li>
)}
{rows.slice(0, 200).map(r => (
<li key={r.email}>
{selectable && <input type="checkbox" checked={resendImportSel.has(r.email)} onChange={() => setResendImportSel(prev => { const n = new Set(prev); if (n.has(r.email)) n.delete(r.email); else n.add(r.email); return n })} aria-label={`Select ${r.email}`} />}
<span>{r.name || '—'}</span><a href={`mailto:${r.email}`}>{r.email}</a>
</li>
))}
{rows.length > 200 && <li className="cmp-more">…and {rows.length - 200} more</li>}
</ul>
)}
{rows.length > 0 && action}
</details>
)
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="cmp-card">
<div className="cmp-head">
<div>
<strong>Compare with Resend</strong>
<p className="cmp-note" style={{ margin: '0.2rem 0 0' }}>Checks that everyone who opted in here is also in your Resend audience, and that nobody who unsubscribed there is still active here.</p>
</div>
<button type="button" className="btn-admin-reset" disabled={resendCmpLoading || resendCmpBusy} onClick={() => void runResendCompare()}>
{resendCmpLoading ? 'Checking Resend…' : resendCmp ? 'Re-check' : 'Run comparison'}
</button>
</div>
{resendCmpMsg && <p className="admin-stats-note" role="status">{resendCmpMsg}</p>}
{resendCmp && (() => {
const c = resendCmp
const inSync = c.onlyInApp.length === 0 && c.unsubscribedInResend.length === 0 && c.notInSegment.length === 0
return (
<>
<div className="cmp-stats">
<div><b>{c.appCount}</b><span>Subscribers here</span></div>
<div><b>{c.resendCount}</b><span>Contacts in Resend{c.resendUnsubscribedCount ? ` (${c.resendUnsubscribedCount} unsubscribed)` : ''}</span></div>
{c.segmentCount !== null && <div><b>{c.segmentCount}</b><span>In your Resend segment</span></div>}
<div><b>{c.matched}</b><span>Fully matched</span></div>
</div>
{inSync && <p className="cmp-ok">✓ Every subscriber here is in Resend and active. {c.onlyInResend.length > 0 ? `Resend also has ${c.onlyInResend.length} other active contact${c.onlyInResend.length === 1 ? '' : 's'} (see below).` : ''}</p>}
{!c.segmentConfigured && <p className="cmp-note">Note: RESEND_SEGMENT_ID isn’t set on the server, so segment membership wasn’t checked and new contacts aren’t added to a segment.</p>}
{renderCmpList('Subscribed here, missing from Resend', 'These people opted in on the site but never made it into Resend, so Resend broadcasts won’t reach them.', c.onlyInApp,
<button type="button" className="btn-admin-apply" disabled={resendCmpBusy} onClick={() => void runResendSync('add-to-resend', `Add ${c.onlyInApp.length} subscriber${c.onlyInApp.length === 1 ? '' : 's'} to Resend? (Up to 100 at a time.)`)}>{resendCmpBusy ? 'Working…' : `Add ${Math.min(c.onlyInApp.length, 100)} to Resend`}</button>)}
{renderCmpList('Unsubscribed in Resend, still active here', 'They opted out through Resend. Marking them unsubscribed here stops the in-app Broadcast Email from sending to them.', c.unsubscribedInResend,
<button type="button" className="btn-admin-apply" disabled={resendCmpBusy} onClick={() => void runResendSync('apply-unsubscribes', `Mark ${c.unsubscribedInResend.length} person${c.unsubscribedInResend.length === 1 ? '' : 's'} as unsubscribed here? They will be removed from the subscriber list and future broadcasts.`)}>{resendCmpBusy ? 'Working…' : 'Mark unsubscribed here'}</button>)}
{c.segmentConfigured && renderCmpList('In Resend, but not in your segment', 'These subscribers exist in Resend but aren’t in the segment your broadcasts use.', c.notInSegment,
<button type="button" className="btn-admin-apply" disabled={resendCmpBusy} onClick={() => void runResendSync('add-to-segment', `Add ${c.notInSegment.length} contact${c.notInSegment.length === 1 ? '' : 's'} to the Resend segment? (Up to 100 at a time.)`)}>{resendCmpBusy ? 'Working…' : `Add ${Math.min(c.notInSegment.length, 100)} to segment`}</button>)}
{renderCmpList('In Resend, not subscribed here', 'These are usually study-account signups, or people who opted in before the site tracked it. Nothing changes unless you tick people and import them — only import people who agreed to receive your emails.', c.onlyInResend,
<button type="button" className="btn-admin-apply" disabled={resendCmpBusy || resendImportSel.size === 0} onClick={() => void importFromResend()}>{resendCmpBusy ? 'Working…' : `Import ${resendImportSel.size} selected`}</button>, true)}
</>
)
})()}
</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' : sub.source === 'resend-import' ? 'Imported from Resend' : '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')}
{tabOf('start-here', 'start-here-intro') === 'start-here-intro' && <>
<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>
))}
</>}
{tabOf('start-here', 'start-here-intro') === 'start-here-step-1' && <>
<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>
))}
</>}
{tabOf('start-here', 'start-here-intro') === 'start-here-step-2' && <>
<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>
))}
</>}
{tabOf('start-here', 'start-here-intro') === 'start-here-step-3' && <>
<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>
{renderTabs('series', 'main', [
{ id: 'main', label: 'Main Series' },
{ id: 'second', label: 'Second Series' },
{ id: 'schedule', label: 'Launch: Main' },
{ id: 'schedule2', label: 'Launch: Second' },
])}
{tabOf('series', 'main') === 'main' && <>
{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>
))}
<div className="admin-field">
<label htmlFor="series-study-slug">Linked Study (Lane A)</label>
<select id="series-study-slug" value={form.seriesStudySlug ?? ''} onChange={e => setForm(f => ({ ...f, seriesStudySlug: e.target.value }))}>
<option value="">None</option>
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
</select>
<p className="admin-field-help">Adds a &ldquo;Study Along&rdquo; button to the series card. When this series ends, its study is marked Complete.</p>
</div>
<div className="admin-field">
<label htmlFor="series-season">Podcast Season Number (Lane A)</label>
<input id="series-season" type="number" min="1" value={form.seriesSeason ?? ''} onChange={e => setForm(f => ({ ...f, seriesSeason: parseInt(e.target.value) || undefined }))} />
<p className="admin-field-help">Must match the season number you set on these episodes in Spotify for Creators. Used for the Finished Books playlist.</p>
</div>
</>}
{tabOf('series', 'main') === 'second' && (() => {
const second = form.secondSeries
const patchSecond = (p: Partial<SecondSeries>) =>
setForm(f => ({ ...f, secondSeries: { label: 'Now Playing', title: '', description: '', imageUrl: '', listenUrl: '', listenBtnLabel: '', ...(f.secondSeries ?? {}), ...p } }))
return (
<div style={{ marginTop: '2rem' }}>
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', marginBottom: '1.5rem' }} />
<h3 style={{ marginBottom: '0.35rem', fontSize: '1rem' }}>Second Series (Lane B)</h3>
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
Running two books at once? Fill this in and the homepage shows a second Now Playing card. Leave the title blank to show only one series.
</p>
<div className="admin-field">
<label htmlFor="second-label">Label</label>
<input id="second-label" type="text" value={second?.label ?? 'Now Playing'} onChange={e => patchSecond({ label: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="second-title">Series Title</label>
<input id="second-title" type="text" value={second?.title ?? ''} onChange={e => patchSecond({ title: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="second-desc">Series Description</label>
<textarea id="second-desc" rows={3} value={second?.description ?? ''} onChange={e => patchSecond({ description: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="second-image">Cover Image URL</label>
<input id="second-image" type="text" value={second?.imageUrl ?? ''} onChange={e => patchSecond({ imageUrl: e.target.value })} />
{renderImageAssetSelector(second?.imageUrl ?? '', value => patchSecond({ imageUrl: value }), 'second-image-asset')}
{renderImagePreview(second?.imageUrl ?? '', 'Second series preview')}
</div>
<div className="admin-field">
<label htmlFor="second-listen">Listen URL</label>
<input id="second-listen" type="text" placeholder="Leave blank to use the main Spotify link" value={second?.listenUrl ?? ''} onChange={e => patchSecond({ listenUrl: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="second-study">Linked Study (Lane B)</label>
<select id="second-study" value={second?.studySlug ?? ''} onChange={e => patchSecond({ studySlug: e.target.value })}>
<option value="">None</option>
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
</select>
</div>
<div className="admin-field">
<label htmlFor="second-season">Podcast Season Number</label>
<input id="second-season" type="number" min="1" value={second?.season ?? ''} onChange={e => patchSecond({ season: parseInt(e.target.value) || undefined })} />
</div>
{second?.title?.trim() && (
<button
type="button"
className="btn-danger-outline"
onClick={() => {
if (!window.confirm(`Archive "${second.title}" to Finished Books and remove the second series card?`)) return
setForm(f => {
const cur = f.secondSeries
if (!cur?.title?.trim()) return f
const { ending: endingHighlights, kept: keptHighlights } = splitHighlightsForLane(f.podcastFeaturedLinks, 'second')
const archived = {
id: Date.now().toString(36),
title: cur.title,
description: cur.description,
imageUrl: cur.imageUrl,
season: cur.season ?? 1,
...(cur.studySlug ? { studySlug: cur.studySlug } : {}),
...(cur.guide?.title ? { guide: cur.guide } : {}),
...(endingHighlights.length > 0 ? { highlights: endingHighlights } : {}),
}
return {
...f,
podcastFeaturedLinks: keptHighlights,
finishedBooks: [...(f.finishedBooks ?? []), archived],
studies: (f.studies ?? []).map(st => st.slug === cur.studySlug && st.status !== 'planned' ? { ...st, status: 'complete' as const } : st),
secondSeries: undefined,
}
})
}}
>End Second Series &amp; Archive</button>
)}
</div>
)
})()}
<p className="admin-stats-note">Companion study guide title, description, and Amazon link are now managed in Resources.</p>
{renderSaveStatus()}
{tabOf('series', 'main') === 'main' && <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 &amp; Start New Book
</button>
) : (
<div className="admin-end-series-panel">
<h3 style={{ marginBottom: '0.5rem' }}>Archive &ldquo;{form.seriesTitle}&rdquo; 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 { ending: endingHighlights, kept: keptHighlights } = splitHighlightsForLane(form.podcastFeaturedLinks, 'primary')
const archivedBook = {
id: Date.now().toString(36),
title: form.seriesTitle,
description: form.seriesDescription,
imageUrl: form.seriesImageUrl,
season: form.seriesSeason ?? (parseInt(endSeriesNewSeason) - 1 || 1),
...(form.seriesStudySlug ? { studySlug: form.seriesStudySlug } : {}),
...(form.studyGuideTitle?.trim()
? {
guide: {
title: form.studyGuideTitle,
description: form.studyGuideDescription,
downloadUrl: form.studyGuideDownloadUrl,
amazonUrl: form.studyGuideUrl,
amazonLabel: form.studyGuideAmazonButtonLabel,
appleBooksUrl: form.studyGuideAppleBooksUrl,
appleBooksLabel: form.studyGuideAppleBooksLabel,
},
}
: {}),
...(endingHighlights.length > 0 ? { highlights: endingHighlights } : {}),
}
setForm(f => ({
...f,
studies: (f.studies ?? []).map(st => st.slug === f.seriesStudySlug && st.status !== 'planned' ? { ...st, status: 'complete' as const } : st),
seriesStudySlug: '',
seriesSeason: parseInt(endSeriesNewSeason) || undefined,
// The guide moved onto the finished book; the new series gets its own
studyGuideTitle: '',
studyGuideDescription: '',
studyGuideDownloadUrl: '',
studyGuideUrl: '',
studyGuideAmazonButtonLabel: '',
studyGuideAppleBooksUrl: '',
studyGuideAppleBooksLabel: '',
finishedBooks: [...(f.finishedBooks ?? []), archivedBook],
podcastFeaturedLinks: keptHighlights,
seriesTitle: endSeriesNewTitle.trim(),
seriesImageUrl: endSeriesNewImage.trim() || f.seriesImageUrl,
seriesLabel: 'Now Playing',
}))
setEndSeriesOpen(false)
setEndSeriesNewTitle('')
setEndSeriesNewImage('')
setEndSeriesNewSeason('')
}}
>
Confirm &amp; Archive
</button>
<button type="button" className="btn-secondary" onClick={() => setEndSeriesOpen(false)}>Cancel</button>
</div>
</div>
)}
</div>}
{/* Schedule Auto-Transition */}
{tabOf('series', 'main') === 'schedule' && (() => {
const sched = form.scheduledSeriesTransition
const patch = (p: Partial<NonNullable<typeof sched>>) =>
setForm(f => ({ ...f, scheduledSeriesTransition: { ...(f.scheduledSeriesTransition ?? { triggersAt: '', newTitle: '', newSeason: 2 }), ...p } }))
return (
<div style={{ marginTop: '2rem' }}>
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', marginBottom: '1.5rem' }} />
<h3 style={{ marginBottom: '0.35rem', fontSize: '1rem' }}>Schedule Auto Series Launch</h3>
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
Set one date &amp; time to automatically archive the current series and swap in new content for the Series card, Start Here section, and Companion Study Guide. The server checks every minute. Leave Start Here fields blank to keep them unchanged. The ending series' Companion Study Guide moves onto its Finished Book, so fill in the guide fields below to publish the new series' guide (left blank, the guide is cleared).
</p>
{sched?.triggersAt && (
<div style={{ background: 'rgba(201,168,76,0.08)', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '10px', padding: '0.8rem 1rem', marginBottom: '1rem' }}>
<p style={{ margin: 0, fontSize: '0.88rem', color: '#c9a84c' }}>
<strong>Scheduled:</strong> Launch &ldquo;{sched.newTitle}&rdquo; on{' '}
{new Date(sched.triggersAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
</p>
</div>
)}
<div className="admin-field">
<label htmlFor="sched-triggers-at">Launch Date &amp; Time</label>
<input
id="sched-triggers-at"
type="datetime-local"
value={sched?.triggersAt ? toLocalDatetimeInput(sched.triggersAt) : ''}
onChange={e => patch({ triggersAt: e.target.value ? new Date(e.target.value).toISOString() : '' })}
/>
</div>
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>New Series Card</p>
<div className="admin-field">
<label htmlFor="sched-new-title">Series Title</label>
<input id="sched-new-title" type="text" placeholder="e.g. Study of Romans" value={sched?.newTitle ?? ''} onChange={e => patch({ newTitle: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-new-desc">Series Description</label>
<textarea id="sched-new-desc" rows={3} placeholder="Brief description of the new series…" value={sched?.newDescription ?? ''} onChange={e => patch({ newDescription: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-new-image">Series Image URL</label>
<input id="sched-new-image" type="text" placeholder="/images/romans-cover.png" value={sched?.newImageUrl ?? ''} onChange={e => patch({ newImageUrl: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-new-season">New Season Number</label>
<input id="sched-new-season" type="number" min="1" placeholder="2" value={sched?.newSeason ?? ''} onChange={e => patch({ newSeason: parseInt(e.target.value) || 2 })} />
</div>
<div className="admin-field">
<label htmlFor="sched-new-study">Study to Open</label>
<select id="sched-new-study" value={sched?.newStudySlug ?? ''} onChange={e => patch({ newStudySlug: e.target.value })}>
<option value="">None</option>
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
</select>
<p className="admin-field-help">At launch this study switches from Planned to Active, and the ending series&apos; study is marked Complete.</p>
</div>
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>Start Here Section <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional)</span></p>
<div className="admin-field">
<label htmlFor="sched-sh-heading">Heading</label>
<input id="sched-sh-heading" type="text" placeholder="New Here? Start Here" value={sched?.newStartHereHeading ?? ''} onChange={e => patch({ newStartHereHeading: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-intro">Intro</label>
<textarea id="sched-sh-intro" rows={2} placeholder="Intro paragraph…" value={sched?.newStartHereIntro ?? ''} onChange={e => patch({ newStartHereIntro: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s1t">Step 1 Title</label>
<input id="sched-sh-s1t" type="text" value={sched?.newStartHereStep1Title ?? ''} onChange={e => patch({ newStartHereStep1Title: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s1b">Step 1 Body</label>
<textarea id="sched-sh-s1b" rows={2} value={sched?.newStartHereStep1Body ?? ''} onChange={e => patch({ newStartHereStep1Body: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s1c">Step 1 Button Text</label>
<input id="sched-sh-s1c" type="text" value={sched?.newStartHereStep1Cta ?? ''} onChange={e => patch({ newStartHereStep1Cta: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s2t">Step 2 Title</label>
<input id="sched-sh-s2t" type="text" value={sched?.newStartHereStep2Title ?? ''} onChange={e => patch({ newStartHereStep2Title: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s2b">Step 2 Body</label>
<textarea id="sched-sh-s2b" rows={2} value={sched?.newStartHereStep2Body ?? ''} onChange={e => patch({ newStartHereStep2Body: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s2c">Step 2 Button Text</label>
<input id="sched-sh-s2c" type="text" value={sched?.newStartHereStep2Cta ?? ''} onChange={e => patch({ newStartHereStep2Cta: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s3t">Step 3 Title</label>
<input id="sched-sh-s3t" type="text" value={sched?.newStartHereStep3Title ?? ''} onChange={e => patch({ newStartHereStep3Title: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s3b">Step 3 Body</label>
<textarea id="sched-sh-s3b" rows={2} value={sched?.newStartHereStep3Body ?? ''} onChange={e => patch({ newStartHereStep3Body: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sh-s3c">Step 3 Button Text</label>
<input id="sched-sh-s3c" type="text" value={sched?.newStartHereStep3Cta ?? ''} onChange={e => patch({ newStartHereStep3Cta: e.target.value })} />
</div>
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>Companion Study Guide <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional)</span></p>
<div className="admin-field">
<label htmlFor="sched-sg-title">Guide Title</label>
<input id="sched-sg-title" type="text" placeholder="Companion Study Guide" value={sched?.newStudyGuideTitle ?? ''} onChange={e => patch({ newStudyGuideTitle: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sg-desc">Guide Description</label>
<textarea id="sched-sg-desc" rows={3} value={sched?.newStudyGuideDescription ?? ''} onChange={e => patch({ newStudyGuideDescription: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sg-download">Download URL</label>
<input id="sched-sg-download" type="url" placeholder="/uploads/guide.pdf" value={sched?.newStudyGuideDownloadUrl ?? ''} onChange={e => patch({ newStudyGuideDownloadUrl: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sg-amazon">Amazon URL</label>
<input id="sched-sg-amazon" type="url" placeholder="https://amazon.com/…" value={sched?.newStudyGuideUrl ?? ''} onChange={e => patch({ newStudyGuideUrl: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sg-amazon-label">Amazon Button Label</label>
<input id="sched-sg-amazon-label" type="text" placeholder="Get it on Amazon" value={sched?.newStudyGuideAmazonButtonLabel ?? ''} onChange={e => patch({ newStudyGuideAmazonButtonLabel: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sg-apple-url">Apple Books URL</label>
<input id="sched-sg-apple-url" type="url" placeholder="https://books.apple.com/…" value={sched?.newStudyGuideAppleBooksUrl ?? ''} onChange={e => patch({ newStudyGuideAppleBooksUrl: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched-sg-apple-label">Apple Books Button Label</label>
<input id="sched-sg-apple-label" type="text" placeholder="Get it on Apple Books" value={sched?.newStudyGuideAppleBooksLabel ?? ''} onChange={e => patch({ newStudyGuideAppleBooksLabel: e.target.value })} />
</div>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn-primary"
disabled={!sched?.triggersAt || !sched?.newTitle?.trim()}
onClick={() => handleSaveDraft()}
>Save Schedule</button>
{sched?.triggersAt && (
<button
type="button"
className="btn-danger-outline"
onClick={() => setForm(f => ({ ...f, scheduledSeriesTransition: undefined }))}
>Clear Schedule</button>
)}
</div>
</div>
)
})()}
{/* Schedule Auto-Transition — second series (Lane B) */}
{tabOf('series', 'main') === 'schedule2' && (() => {
const sched = form.scheduledSecondSeriesTransition
const patch = (p: Partial<NonNullable<typeof sched>>) =>
setForm(f => ({ ...f, scheduledSecondSeriesTransition: { ...(f.scheduledSecondSeriesTransition ?? { triggersAt: '', newTitle: '', newSeason: 2 }), ...p } }))
return (
<div style={{ marginTop: '2rem' }}>
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', marginBottom: '1.5rem' }} />
<h3 style={{ marginBottom: '0.35rem', fontSize: '1rem' }}>Schedule Second Series (Lane B) Launch</h3>
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
Same as above, but only the second series changes: its current book is archived to Finished Books and marked Complete, and the main series keeps playing. If no second series is running yet, nothing is archived and the new book simply appears.
</p>
{sched?.triggersAt && (
<div style={{ background: 'rgba(201,168,76,0.08)', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '10px', padding: '0.8rem 1rem', marginBottom: '1rem' }}>
<p style={{ margin: 0, fontSize: '0.88rem', color: '#c9a84c' }}>
<strong>Scheduled:</strong> Launch &ldquo;{sched.newTitle}&rdquo; on{' '}
{new Date(sched.triggersAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
</p>
</div>
)}
<div className="admin-field">
<label htmlFor="sched2-triggers-at">Launch Date &amp; Time</label>
<input id="sched2-triggers-at" type="datetime-local" value={sched?.triggersAt ? toLocalDatetimeInput(sched.triggersAt) : ''} onChange={e => patch({ triggersAt: e.target.value ? new Date(e.target.value).toISOString() : '' })} />
</div>
<div className="admin-field">
<label htmlFor="sched2-new-title">Series Title</label>
<input id="sched2-new-title" type="text" value={sched?.newTitle ?? ''} onChange={e => patch({ newTitle: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched2-new-desc">Series Description</label>
<textarea id="sched2-new-desc" rows={3} value={sched?.newDescription ?? ''} onChange={e => patch({ newDescription: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched2-new-image">Series Image URL</label>
<input id="sched2-new-image" type="text" value={sched?.newImageUrl ?? ''} onChange={e => patch({ newImageUrl: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched2-new-season">New Season Number</label>
<input id="sched2-new-season" type="number" min="1" value={sched?.newSeason ?? ''} onChange={e => patch({ newSeason: parseInt(e.target.value) || 2 })} />
</div>
<div className="admin-field">
<label htmlFor="sched2-new-study">Study to Open</label>
<select id="sched2-new-study" value={sched?.newStudySlug ?? ''} onChange={e => patch({ newStudySlug: e.target.value })}>
<option value="">None</option>
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
</select>
</div>
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>Companion Study Guide <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional)</span></p>
<p className="admin-stats-note" style={{ marginBottom: '0.75rem' }}>The ending series&apos; guide moves onto its Finished Book at launch. Fill these in to publish the new series&apos; guide at the same time.</p>
{(() => {
const g = sched?.newGuide
const patchGuide = (p: Partial<StudyGuide>) => patch({ newGuide: { title: '', ...(g ?? {}), ...p } })
return (
<>
<div className="admin-field"><label htmlFor="sched2-guide-title">Guide Title</label><input id="sched2-guide-title" type="text" value={g?.title ?? ''} onChange={e => patchGuide({ title: e.target.value })} /></div>
<div className="admin-field"><label htmlFor="sched2-guide-desc">Guide Description</label><textarea id="sched2-guide-desc" rows={3} value={g?.description ?? ''} onChange={e => patchGuide({ description: e.target.value })} /></div>
<div className="admin-field"><label htmlFor="sched2-guide-download">Download URL</label><input id="sched2-guide-download" type="url" placeholder="/uploads/guide.pdf" value={g?.downloadUrl ?? ''} onChange={e => patchGuide({ downloadUrl: e.target.value })} /></div>
<div className="admin-field"><label htmlFor="sched2-guide-amazon">Amazon URL</label><input id="sched2-guide-amazon" type="url" value={g?.amazonUrl ?? ''} onChange={e => patchGuide({ amazonUrl: e.target.value })} /></div>
<div className="admin-field"><label htmlFor="sched2-guide-amazon-label">Amazon Button Label</label><input id="sched2-guide-amazon-label" type="text" placeholder="Get it on Amazon" value={g?.amazonLabel ?? ''} onChange={e => patchGuide({ amazonLabel: e.target.value })} /></div>
<div className="admin-field"><label htmlFor="sched2-guide-apple">Apple Books URL</label><input id="sched2-guide-apple" type="url" value={g?.appleBooksUrl ?? ''} onChange={e => patchGuide({ appleBooksUrl: e.target.value })} /></div>
<div className="admin-field"><label htmlFor="sched2-guide-apple-label">Apple Books Button Label</label><input id="sched2-guide-apple-label" type="text" placeholder="Get it on Apple Books" value={g?.appleBooksLabel ?? ''} onChange={e => patchGuide({ appleBooksLabel: e.target.value })} /></div>
</>
)
})()}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-primary" disabled={!sched?.triggersAt || !sched?.newTitle?.trim()} onClick={() => handleSaveDraft()}>Save Schedule</button>
{sched?.triggersAt && (
<button type="button" className="btn-danger-outline" onClick={() => setForm(f => ({ ...f, scheduledSecondSeriesTransition: undefined }))}>Clear Schedule</button>
)}
</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 draft" style={{ width: 70 }}>{highlightLaneOf(item) === 'second' ? 'Lane B' : highlightLaneOf(item) === 'general' ? 'General' : 'Lane A'}</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-lane-${activeHighlight.id}`}>Series</label>
<select id={`podcast-lane-${activeHighlight.id}`} value={highlightLaneOf(activeHighlight)} onChange={e => updatePodcastLink(activeHighlight.id, 'lane', e.target.value)}>
<option value="primary">Lane A — {form.seriesTitle || 'main series'}</option>
<option value="second">Lane B — {form.secondSeries?.title?.trim() || 'second series (not set)'}</option>
<option value="general">General (not tied to a series)</option>
</select>
<p className="admin-field-help">Groups this highlight under its series on the Episodes page. When a series ends, its highlights move onto that book&apos;s Finished Books page. General highlights stay.</p>
</div>
<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&#10;Prayer in suffering&#10;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&#10;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 &amp; 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&#10;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 &ldquo;End Current Series&rdquo; 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 className="admin-field">
<label>Linked Study</label>
<select value={book.studySlug ?? ''} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, studySlug: e.target.value } : b) }))}>
<option value="">None</option>
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
</select>
<p className="admin-field-help">Adds a &ldquo;Study This Book&rdquo; button to the playlist page and an audio-only link on the study hub.</p>
</div>
{(book.highlights ?? []).length > 0 && (
<div className="admin-field">
<label>Episode Highlights on this book&apos;s page ({(book.highlights ?? []).length})</label>
{(book.highlights ?? []).map(link => (
<div key={link.id} className="study-lesson-row">
<span className="study-lesson-ref" style={{ width: 70 }}>{link.episodeNumber ? `Ep. ${link.episodeNumber}` : '—'}</span>
<span className="study-lesson-title">{link.title || <em style={{ opacity: 0.5 }}>Untitled</em>}</span>
<div className="study-lesson-actions">
<button
type="button"
className="btn-admin-ghost"
title="Move back to the Episodes page as a General highlight so it can be edited"
onClick={() => setForm(f => ({
...f,
podcastFeaturedLinks: [...(f.podcastFeaturedLinks ?? []), { ...link, lane: 'general' as const }],
finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, highlights: (b.highlights ?? []).filter(h => h.id !== link.id) } : b),
}))}
>Restore</button>
<button
type="button"
className="btn-admin-remove"
style={{ fontSize: '0.8rem', padding: '0.2rem 0.5rem' }}
onClick={() => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, highlights: (b.highlights ?? []).filter(h => h.id !== link.id) } : b) }))}
>✕</button>
</div>
</div>
))}
<p className="admin-field-help">Highlights that moved here when this series ended. Restore puts one back on the Episodes page (as General) so you can edit it.</p>
</div>
)}
<div className="admin-field">
<label>Companion Guide Title</label>
<input type="text" placeholder="Companion Study Guide" value={book.guide?.title ?? ''} onChange={e => patchBookGuide(book.id, { title: e.target.value })} />
<p className="admin-field-help">Saved here automatically when a series ends. Shows a guide button on the book&apos;s page.</p>
</div>
{book.guide?.title && (
<>
<div className="admin-field">
<label>Guide Download URL</label>
<input type="url" placeholder="/uploads/guide.pdf or https://..." value={book.guide?.downloadUrl ?? ''} onChange={e => patchBookGuide(book.id, { downloadUrl: e.target.value })} />
</div>
<div className="admin-field">
<label>Guide Amazon URL</label>
<input type="url" placeholder="https://..." value={book.guide?.amazonUrl ?? ''} onChange={e => patchBookGuide(book.id, { amazonUrl: e.target.value })} />
</div>
</>
)}
<div className="admin-field">
<label>Download URL <span style={{ fontWeight: 400, opacity: 0.7 }}>(ZIP — Google Drive, Dropbox, etc.)</span></label>
<input type="url" placeholder="https://drive.google.com/…" value={book.downloadUrl ?? ''} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, downloadUrl: e.target.value } : b) }))} />
</div>
<div className="admin-field">
<label>Download QR Code <span style={{ fontWeight: 400, opacity: 0.7 }}>(image URL)</span></label>
<input type="url" placeholder="https://…/qr.png" value={book.downloadQrUrl ?? ''} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, downloadQrUrl: e.target.value } : b) }))} />
{renderImageAssetSelector(book.downloadQrUrl, value => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, downloadQrUrl: value } : b) })), `finished-book-qr-${book.id}`)}
{renderImagePreview(book.downloadQrUrl, `${book.title} QR code`)}
</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>
{tabOf('downloads', 'downloads-study-guide') === 'downloads-study-guide' && <>
<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>
<div className="admin-section-header" style={{ marginTop: '2rem' }}>
<h3>Second Series Guide{form.secondSeries?.title?.trim() ? ` — ${form.secondSeries.title}` : ''}</h3>
<p>The companion guide for the second series (Lane B). The guide above belongs to the main series. When a series ends, its guide moves onto its Finished Book.</p>
</div>
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor="second-guide-title">Guide Title</label>
<input id="second-guide-title" type="text" value={form.secondSeries?.guide?.title ?? ''} placeholder="Companion Study Guide" onChange={e => patchSecondGuide({ title: e.target.value })} />
<p className="admin-field-help">The guide only appears on the Resources page once it has a title.</p>
</div>
<div className="admin-field">
<label htmlFor="second-guide-description">Description</label>
<textarea id="second-guide-description" rows={3} value={form.secondSeries?.guide?.description ?? ''} onChange={e => patchSecondGuide({ description: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="second-guide-download">Download URL</label>
<input id="second-guide-download" type="url" value={form.secondSeries?.guide?.downloadUrl ?? ''} placeholder="/uploads/new-guide.pdf or https://..." onChange={e => patchSecondGuide({ downloadUrl: e.target.value })} />
{renderFileAssetSelector(form.secondSeries?.guide?.downloadUrl, value => patchSecondGuide({ downloadUrl: value }), 'second-guide-download-asset')}
</div>
<div className="admin-field">
<label htmlFor="second-guide-amazon">Amazon URL (optional)</label>
<input id="second-guide-amazon" type="url" value={form.secondSeries?.guide?.amazonUrl ?? ''} placeholder="https://..." onChange={e => patchSecondGuide({ amazonUrl: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="second-guide-amazon-label">Amazon Button Label</label>
<input id="second-guide-amazon-label" type="text" value={form.secondSeries?.guide?.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => patchSecondGuide({ amazonLabel: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="second-guide-apple">Apple Books URL (optional)</label>
<input id="second-guide-apple" type="url" value={form.secondSeries?.guide?.appleBooksUrl ?? ''} placeholder="https://books.apple.com/..." onChange={e => patchSecondGuide({ appleBooksUrl: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="second-guide-apple-label">Apple Books Button Label</label>
<input id="second-guide-apple-label" type="text" value={form.secondSeries?.guide?.appleBooksLabel ?? ''} placeholder="Get it on Apple Books" onChange={e => patchSecondGuide({ appleBooksLabel: e.target.value })} />
</div>
</div>
</>}
{tabOf('downloads', 'downloads-study-guide') === 'downloads-library' && <>
{/* 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>
{renderSectionJumpNav('custom-links')}
{tabOf('custom-links', 'custom-links-homepage') === 'custom-links-homepage' && <>
<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>
</>}
{tabOf('custom-links', 'custom-links-homepage') === 'custom-links-platform' && <>
<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>
</>}
{tabOf('custom-links', 'custom-links-homepage') === 'custom-links-other' && <>
<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>
</>}
{tabOf('custom-links', 'custom-links-homepage') === 'custom-links-more' && <>
<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&amp;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>
{renderTabs('study', 'lessons', [{ id: 'lessons', label: 'Lessons' }, { id: 'settings', label: 'Study Settings' }])}
{tabOf('study', 'lessons') === 'settings' && <>
<details open 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="complete">Complete (finished — self-paced)</option>
<option value="planned">Planned</option>
</select>
<p className="admin-field-help">A study whose lessons have all been released is shown as Complete automatically. Complete studies stay open for enrollment.</p>
</div>
<div className="admin-field">
<label htmlFor={`study-lane-${activeStudy.id}`}>Release Lane</label>
<select id={`study-lane-${activeStudy.id}`} value={activeStudy.lane ?? ''} onChange={e => updateStudyProgram(activeStudy.id, 'lane', e.target.value)}>
<option value="">None</option>
<option value="A">Lane A (primary series)</option>
<option value="B">Lane B (second series)</option>
</select>
<p className="admin-field-help">Two series can air at once on opposite Mondays. Lane is a label for planning; dates come from each lesson.</p>
</div>
{activeStudy.status === 'planned' && (
<div className="admin-field">
<label htmlFor={`study-starts-at-${activeStudy.id}`}>Expected Start Date</label>
<input id={`study-starts-at-${activeStudy.id}`} type="datetime-local" value={activeStudy.startsAt ? toLocalInputValue(activeStudy.startsAt) : ''} onChange={e => updateStudyProgram(activeStudy.id, 'startsAt', fromLocalInputValue(e.target.value))} />
<p className="admin-field-help">Shown as &ldquo;Starts &lt;date&gt;&rdquo; on the study hub while the study is still Planned.</p>
</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>
</>}
{tabOf('study', 'lessons') === 'lessons' && <>
<div className="admin-panel-subhead" style={{ marginBottom: '0.75rem' }}>
<h3>Lessons ({(activeStudy.sections ?? []).length})</h3>
</div>
{(activeStudy.sections ?? []).length > 0 && (
<details className="admin-collapsible-card" style={{ marginBottom: '1rem' }}>
<summary className="admin-collapsible-summary">
<div><strong>Bulk Schedule Releases</strong><p>Set every lesson&apos;s date at once (e.g. every other Monday)</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="bulk-start">First lesson releases</label>
<input id="bulk-start" type="datetime-local" value={bulkStart} onChange={e => setBulkStart(e.target.value)} />
<p className="admin-field-help">Pick the Monday and time for lesson 1. Lane B should start the Monday after Lane A.</p>
</div>
<div className="admin-field">
<label htmlFor="bulk-interval">Days between lessons</label>
<input id="bulk-interval" type="number" min="1" value={bulkInterval} onChange={e => setBulkInterval(e.target.value)} />
<p className="admin-field-help">14 = every other week.</p>
</div>
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<input id="bulk-only-empty" type="checkbox" checked={bulkOnlyEmpty} onChange={e => setBulkOnlyEmpty(e.target.checked)} />
<label htmlFor="bulk-only-empty">Only fill lessons that have no date yet</label>
</div>
</div>
<button type="button" className="btn-primary" disabled={!bulkStart} onClick={() => applyBulkSchedule(activeStudy.id)}>Apply to {(activeStudy.sections ?? []).length} lessons</button>
<p className="admin-field-help" style={{ marginTop: '0.5rem' }}>Remember to Save Draft and Publish afterwards.</p>
</div>
</details>
)}
{(() => {
const hits = findReleaseCollisions(activeStudy)
if (hits.length === 0) return null
return (
<div style={{ background: 'rgba(211,47,47,0.1)', border: '1px solid rgba(211,47,47,0.4)', borderRadius: '10px', padding: '0.8rem 1rem', marginBottom: '1rem' }}>
<p style={{ margin: 0, fontSize: '0.88rem', color: '#ef9a9a' }}>
<strong>{hits.length} lesson{hits.length === 1 ? '' : 's'} release on the same day as another study:</strong>{' '}
{hits.slice(0, 4).map(h => `${h.date} (${h.title} · ${h.other})`).join('; ')}{hits.length > 4 ? '…' : ''}
</p>
</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: 44 }}>Ep #</span>
<span style={{ width: 80 }}>Reference</span>
<span style={{ flex: 1 }}>Title</span>
<span style={{ width: 190 }}>Release</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}
onReleaseChange={value => updateStudySection(activeStudy.id, section.id, 'releasedAt', value)}
/>
))}
</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-episode-${activeLesson.id}`}>Episode # (admin reference only)</label>
<input id={`sl-episode-${activeLesson.id}`} type="text" value={activeLesson.episodeNumber ?? ''} placeholder="12" maxLength={20} onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'episodeNumber', e.target.value)} />
</div>
<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={toLocalInputValue(activeLesson.releasedAt)} onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'releasedAt', fromLocalInputValue(e.target.value))} />
</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 &amp; Global Settings</h2>
<p>Edit the site footer text, platform links, header label, and analytics cookie banner.</p>
</div>
{renderSectionJumpNav('global')}
{tabOf('global', 'global-footer') === 'global-footer' && <>
<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>
))}
</>}
{tabOf('global', 'global-footer') === 'global-header' && <>
<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>
))}
</>}
{tabOf('global', 'global-footer') === 'global-platforms' && <>
<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>
))}
</>}
{tabOf('global', 'global-footer') === 'global-banner' && <>
<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>
))}
</>}
{tabOf('global', 'global-footer') === 'global-welcome-email' && <>
<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:workflow' && <PodcastWorkflowAdmin laneA={{ title: form.seriesTitle }} laneB={{ title: form.secondSeries?.title ?? '' }} />}
{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&apos;s global search bar.</p>
</div>
{/* Upload form */}
<div className="admin-script-upload-form">
<div className="admin-field">
<label htmlFor="script-episode-num">Episode Number</label>
<input
id="script-episode-num"
type="number"
min="1"
placeholder="e.g. 42"
value={scriptUploadEpisode}
onChange={e => setScriptUploadEpisode(e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor="script-episode-title">Episode Title <span style={{ color: '#5a5440', fontWeight: 400 }}>(optional — will be pulled from search results)</span></label>
<input
id="script-episode-title"
type="text"
placeholder="e.g. Colossians 1:15 — The Image of God"
value={scriptUploadTitle}
onChange={e => setScriptUploadTitle(e.target.value)}
/>
</div>
<div className="admin-field">
<label htmlFor="script-season">Season <span style={{ color: '#5a5440', fontWeight: 400 }}>(recommended — scripts are filed by season + episode, so episode numbers can repeat in each book)</span></label>
<input
id="script-season"
type="number"
min="1"
placeholder="e.g. 3"
value={scriptUploadSeason}
onChange={e => setScriptUploadSeason(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>
{scriptsList.length > 0 && (() => {
const seasons = Array.from(new Set(scriptsList.map(s => s.season).filter((s): s is string => Boolean(s)))).sort((a, b) => Number(a) - Number(b))
return seasons.length > 0 ? (
<select
value={scriptSeasonFilter}
onChange={e => setScriptSeasonFilter(e.target.value)}
style={{ marginLeft: 'auto', marginRight: '0.75rem' }}
>
<option value="all">All Seasons</option>
{seasons.map(s => <option key={s} value={s}>Season {s}</option>)}
</select>
) : null
})()}
<button type="button" className="btn-admin-reset" onClick={handleBackfillSeasons} disabled={scriptsLoading} style={{ marginLeft: scriptsList.length === 0 ? 'auto' : undefined, marginRight: '0.5rem' }}>
Backfill Seasons from RSS
</button>
<button type="button" className="btn-admin-reset" onClick={loadScriptsList} disabled={scriptsLoading}>
{scriptsLoading ? 'Loading…' : 'Refresh'}
</button>
</div>
{scriptBackfillMsg && (
<p style={{ fontSize: '0.85rem', color: scriptBackfillMsg.startsWith('✓') ? '#6fcf97' : '#e07a7a', marginTop: '0.5rem' }}>
{scriptBackfillMsg}
</p>
)}
{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
.filter(script => scriptSeasonFilter === 'all' || script.season === scriptSeasonFilter)
.map(script => (
<div key={script.key} className="admin-script-row">
<div className="admin-script-row-info">
<span className="admin-script-ep">Ep. {script.episodeNumber}</span>
{script.season && <span className="admin-script-ep">Season {script.season}</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.key, `Episode ${script.episodeNumber}${script.season ? ` (Season ${script.season})` : ''}`)}>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>
)
})()}
{/* NUGGETS — SHOW SETTINGS */}
{adminView === 'nuggets:settings' && (() => {
const nuggets = form.nuggets ?? DEFAULTS.nuggets
const updateNuggets = <K extends keyof typeof nuggets>(key: K, value: (typeof nuggets)[K]) =>
setForm(f => ({ ...f, nuggets: { ...(f.nuggets ?? DEFAULTS.nuggets), [key]: value } }))
return (
<section className="admin-panel-section" aria-label="Nuggets show settings">
<div className="admin-panel-head">
<h2>Nuggets — Show Settings</h2>
<p>Verse By Verse: Nuggets is a companion show. Visitors reach it at <code>/nuggets</code>.</p>
</div>
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor="nuggets-title">Show Title</label>
<input id="nuggets-title" type="text" value={nuggets.title} onChange={e => updateNuggets('title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="nuggets-tagline">Tagline</label>
<input id="nuggets-tagline" type="text" placeholder="Short verse-bite teaching, one nugget at a time." value={nuggets.tagline} onChange={e => updateNuggets('tagline', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="nuggets-description">Description</label>
<textarea id="nuggets-description" rows={4} value={nuggets.description} onChange={e => updateNuggets('description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="nuggets-art">Podcast Art</label>
<input id="nuggets-art" type="text" placeholder="/uploads/nuggets-art.png" value={nuggets.podcastArtUrl} onChange={e => updateNuggets('podcastArtUrl', e.target.value)} />
{renderImageAssetSelector(nuggets.podcastArtUrl, value => updateNuggets('podcastArtUrl', value), 'nuggets-art-asset')}
{renderImagePreview(nuggets.podcastArtUrl, `${nuggets.title} podcast art`)}
</div>
<div className="admin-field">
<label htmlFor="nuggets-rss">RSS Feed URL</label>
<input id="nuggets-rss" type="text" placeholder="https://anchor.fm/s/xxxxxxxx/podcast/rss" value={nuggets.rssFeedUrl} onChange={e => updateNuggets('rssFeedUrl', e.target.value)} />
<p className="admin-field-help">
{nuggets.rssFeedUrl.trim()
? 'The /nuggets page pulls its episode list from this feed (cached 30 minutes).'
: 'Leave blank until the show has a feed — the page will show "Episodes coming soon" until this is set.'}
</p>
</div>
<div className="admin-field">
<label htmlFor="nuggets-spotify">Spotify URL</label>
<input id="nuggets-spotify" type="text" placeholder="https://open.spotify.com/show/..." value={nuggets.spotifyUrl} onChange={e => updateNuggets('spotifyUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="nuggets-apple">Apple Podcasts URL</label>
<input id="nuggets-apple" type="text" placeholder="https://podcasts.apple.com/..." value={nuggets.appleUrl} onChange={e => updateNuggets('appleUrl', e.target.value)} />
</div>
</div>
{renderSaveStatus()}
</section>
)
})()}
{/* NUGGETS — HOSTS */}
{adminView === 'nuggets:hosts' && (() => {
const hosts = form.nuggets?.hosts ?? []
const activeHosts = hosts.filter(h => !h.archived)
const archivedHosts = hosts.filter(h => h.archived)
const updateHost = (id: string, patch: Partial<PodcastHost>) =>
setForm(f => ({
...f,
nuggets: { ...(f.nuggets ?? DEFAULTS.nuggets), hosts: (f.nuggets?.hosts ?? []).map(h => h.id === id ? { ...h, ...patch } : h) },
}))
return (
<section className="admin-panel-section" aria-label="Nuggets hosts">
<div className="admin-panel-head">
<h2>Nuggets — Hosts</h2>
<p>Hosts shown on the public Nuggets page. Removing a host archives them — they're kept on record, never deleted, and can be restored.</p>
</div>
{activeHosts.length === 0 && (
<p className="admin-stats-note">No active hosts yet. Add one below.</p>
)}
{activeHosts.map(host => (
<AdminCollapsibleCard key={host.id} title={host.name || 'Unnamed host'} subtitle={host.role || 'No role set'}>
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label>Name</label>
<input type="text" value={host.name} onChange={e => updateHost(host.id, { name: e.target.value })} />
</div>
<div className="admin-field">
<label>Role</label>
<input type="text" placeholder="Co-Host" value={host.role} onChange={e => updateHost(host.id, { role: e.target.value })} />
</div>
<div className="admin-field">
<label>Bio</label>
<textarea rows={8} value={host.bio} onChange={e => updateHost(host.id, { bio: e.target.value })} />
</div>
<div className="admin-field">
<label>Photo</label>
<input type="text" placeholder="/uploads/host-photo.jpg" value={host.imageUrl} onChange={e => updateHost(host.id, { imageUrl: e.target.value })} />
{renderImageAssetSelector(host.imageUrl, value => updateHost(host.id, { imageUrl: value }), `nuggets-host-img-${host.id}`)}
{renderImagePreview(host.imageUrl, host.name || 'Host photo')}
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => updateHost(host.id, { archived: true })}>Archive</button>
</div>
</AdminCollapsibleCard>
))}
<button
type="button"
className="btn-admin-add"
onClick={() => setForm(f => ({
...f,
nuggets: {
...(f.nuggets ?? DEFAULTS.nuggets),
hosts: [...(f.nuggets?.hosts ?? []), { id: Date.now().toString(36), name: '', role: '', bio: '', imageUrl: '', archived: false }],
},
}))}
>
+ Add Host
</button>
{archivedHosts.length > 0 && (
<div style={{ marginTop: '2.5rem' }}>
<div className="admin-panel-head">
<h3>Archived Hosts</h3>
<p>Hidden from the public site. Restore to bring a host back.</p>
</div>
{archivedHosts.map(host => (
<div key={host.id} className="admin-array-row" style={{ alignItems: 'center' }}>
<div className="admin-array-fields" style={{ flexDirection: 'row', alignItems: 'center', gap: '1rem' }}>
{host.imageUrl && <img src={host.imageUrl} alt={host.name} style={{ width: 36, height: 36, borderRadius: '50%', objectFit: 'cover' }} />}
<span>{host.name || 'Unnamed host'}</span>
{host.role && <span style={{ color: '#7a7060', fontSize: '0.85rem' }}>{host.role}</span>}
</div>
<button type="button" className="btn-admin-save" onClick={() => updateHost(host.id, { archived: false })}>Restore</button>
</div>
))}
</div>
)}
{renderSaveStatus()}
</section>
)
})()}
{/* NUGGETS UPLOAD PORTAL */}
{adminView === 'nuggets:upload-portal' && <NuggetsUploadPortalAdmin />}
{/* NUGGETS ANNOUNCEMENT */}
{adminView === 'nuggets:announcement' && <NuggetsAnnouncementAdmin />}
{/* NUGGETS RECORDING GUIDELINES */}
{adminView === 'nuggets:guidelines' && <NuggetsGuidelinesAdmin />}
{/* NUGGETS UPLOAD HISTORY */}
{adminView === 'nuggets:workflow' && <NuggetsWorkflowAdmin />}
{adminView === 'nuggets:history' && <NuggetsHistoryAdmin />}
{/* 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} renderImageAssetSelector={renderImageAssetSelector} renderImagePreview={renderImagePreview} />
)}
{/* QUESTIONS */}
{adminView === 'questions' && (
<section className="admin-panel-section" aria-label="Q&A Management">
<div className="admin-panel-head">
<h2>Bible Questions &amp; 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: '7d' | '30d' | '90d' | 'all') => {
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' && (
<AssetManager assets={assets} onReload={reloadAssets} onMessage={setOpsMsg} message={opsMsg} />
)}
{/* 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/&lt;slug&gt;</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 &amp; Redirects</h2>
<p>Control metadata, canonical URL, robots policy, sitemap paths, and short-link redirects.</p>
</div>
{renderSectionJumpNav('seo')}
{tabOf('seo', 'seo-metadata') === 'seo-metadata' && <>
<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>
</>}
{tabOf('seo', 'seo-metadata') === 'seo-redirects' && <>
<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')}
{tabOf('legal', 'legal-privacy') === 'legal-privacy' && <>
<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>
</>}
{tabOf('legal', 'legal-privacy') === 'legal-terms' && <>
<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 &amp; 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 = bcAudience?.count ?? null
async function loadBcAudience() {
setBcAudienceLoading(true)
try {
const r = await fetch('/api/admin-broadcast/audience')
const d = await r.json()
setBcAudience(r.ok ? d : { segmentConfigured: false, count: null })
if (!r.ok) setBroadcastMsg(d.message || 'Could not read the Resend segment.')
} catch { setBcAudience({ segmentConfigured: false, count: null }) }
finally { setBcAudienceLoading(false) }
}
if (bcAudience === null && !bcAudienceLoading) void loadBcAudience()
async function sendBcTest() {
setBcTesting(true); setBroadcastMsg(''); setBroadcastResult(null)
try {
const r = await fetch('/api/admin-broadcast/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject: broadcastSubject, html: broadcastBody, to: bcTestTo }),
})
const d = await r.json()
setBroadcastMsg(r.ok ? `Test sent to ${bcTestTo}.` : d.message || 'Test failed.')
} catch { setBroadcastMsg('Network error. Try again.') }
finally { setBcTesting(false) }
}
async function loadBcTemplates() {
setBcTplLoading(true); setBcTplNote('')
try {
const r = await fetch('/api/admin-broadcast/templates')
const d = await r.json()
if (!r.ok) setBcTplNote(d.message || 'Could not load Resend templates.')
else setBcTemplates(Array.isArray(d.templates) ? d.templates : [])
} catch { setBcTplNote('Network error loading templates.') }
finally { setBcTplLoaded(true); setBcTplLoading(false) }
}
if (!bcTplLoaded && !bcTplLoading) void loadBcTemplates()
async function applyBcTemplate(id: string) {
setBcTplId(id)
if (!id) return
if ((broadcastSubject.trim() || broadcastBody.trim()) && !confirm('Replace the current subject and body with this template?')) { setBcTplId(''); return }
setBcTplNote('Loading template…')
try {
const r = await fetch(`/api/admin-broadcast/templates/${encodeURIComponent(id)}`)
const d = await r.json()
if (!r.ok) { setBcTplNote(d.message || 'Could not load that template.'); return }
setBroadcastSubject(d.subject || '')
setBroadcastBody(d.html || '')
setBroadcastConfirm(false)
const warnings: string[] = []
if (Array.isArray(d.unresolved) && d.unresolved.length) warnings.push(`Unfilled variables: ${d.unresolved.join(', ')}.`)
setBcTplNote(`Loaded “${d.name}”. ${warnings.join(' ')}`.trim())
} catch { setBcTplNote('Network error loading template.') }
}
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,
previewText: bcPreviewText,
tagNewEpisode: bcTagNewEpisode,
scheduledAt: bcSchedule ? new Date(bcSchedule).toISOString() : undefined,
}),
})
const d = await r.json()
if (!r.ok) { setBroadcastMsg(d.message || 'Send failed.') } else {
setBroadcastResult(d); setBroadcastConfirm(false)
setBroadcastMsg(d.scheduledAt
? `Scheduled in Resend for ${new Date(d.scheduledAt).toLocaleString()}.`
: 'Sent through Resend. Delivery and open stats are in your Resend dashboard under Broadcasts.')
}
} 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 an email and send it as a Resend Broadcast to your Resend segment{recipientCount !== null ? ` (${recipientCount} active contacts)` : ''}. Unsubscribe links and opt-outs are handled by Resend.</p>
</div>
<div className="admin-field">
<label htmlFor="bc-template">Start from a Resend template</label>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<select id="bc-template" value={bcTplId} onChange={e => void applyBcTemplate(e.target.value)} disabled={bcTplLoading} style={{ flex: '1 1 260px', minWidth: 0 }}>
<option value="">{bcTplLoading ? 'Loading templates…' : bcTemplates.length ? 'Choose a template…' : 'No templates found'}</option>
{bcTemplates.map(t => <option key={t.id} value={t.id}>{t.name}{t.status === 'draft' ? ' (draft)' : ''}</option>)}
</select>
<button type="button" className="btn-admin-reset btn-admin-reset--compact" disabled={bcTplLoading} onClick={() => void loadBcTemplates()}>Refresh</button>
</div>
{bcTplNote && <p className="admin-stats-note" style={{ marginTop: '0.4rem' }}>{bcTplNote}</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 — {'{{name}}'} becomes each contact’s first name)</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>
<div className="admin-field">
<label htmlFor="bc-preview">Preview text <span style={{ fontWeight: 400, opacity: 0.6, fontSize: '0.8em' }}>(optional — shown after the subject in the inbox)</span></label>
<input id="bc-preview" type="text" value={bcPreviewText} onChange={e => setBcPreviewText(e.target.value)} maxLength={200} />
</div>
<div className="admin-field" style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
<input type="checkbox" checked={bcTagNewEpisode} onChange={e => setBcTagNewEpisode(e.target.checked)} />
Tag as “New Episode” (only contacts subscribed to that topic; shows up in Newsletter Archive)
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
Schedule
<input type="datetime-local" value={bcSchedule} onChange={e => setBcSchedule(e.target.value)} />
{bcSchedule && <button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => setBcSchedule('')}>Send now instead</button>}
</label>
</div>
<div className="admin-field" style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input type="email" placeholder="Send a test to…" value={bcTestTo} onChange={e => setBcTestTo(e.target.value)} style={{ flex: '1 1 240px', minWidth: 0 }} />
<button type="button" className="btn-admin-reset" disabled={bcTesting || !bcTestTo.trim() || !broadcastSubject.trim() || !broadcastBody.trim()} onClick={() => void sendBcTest()}>{bcTesting ? 'Sending test…' : 'Send test'}</button>
</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: DOMPurify.sanitize(broadcastBody.replace(/\{\{name\}\}/g, 'Nate (preview)')) }} />
</details>
)}
{bcAudience?.segmentConfigured === false && <p className="admin-save-error">RESEND_SEGMENT_ID isn’t set on the server, so there’s no Resend segment to send to.</p>}
{broadcastMsg && <p className={broadcastResult || /^Test sent/.test(broadcastMsg) ? '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() || bcAudience?.segmentConfigured === false} onClick={() => setBroadcastConfirm(true)}>
{bcSchedule ? 'Schedule' : 'Send'}{recipientCount !== null ? ` to ${recipientCount} contact${recipientCount !== 1 ? 's' : ''}` : ''}…
</button>
) : (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<strong style={{ color: 'var(--brand-gold)' }}>{bcSchedule ? 'Schedule' : 'Send'} "{broadcastSubject}"{recipientCount !== null ? ` to ${recipientCount} people` : ' to your Resend segment'}? This can’t be undone once it starts sending.</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>
)
})()}
{/* ── NEWSLETTER ARCHIVE ── */}
{adminView === 'newsletter-archive' && (() => {
if (!nlLoaded && !nlLoading) loadNewsletterAdmin()
const unpublished = nlBroadcasts.filter(b => !b.alreadyPublished && b.matchesNewEpisodeTopic)
return (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Newsletter Archive</h2>
<p>Publish a web copy of each episode newsletter so it can be shared (e.g. on Facebook) and found via search. Only Resend broadcasts tagged with the <strong>New Episode</strong> topic show up here.</p>
</div>
{!nlTopicFound && (
<p className="admin-save-error" style={{ marginBottom: '1rem' }}>
No "New Episode" topic found in Resend. Broadcasts won't be flagged for publishing until that topic exists and is applied when sending.
</p>
)}
{nlMsg && <p className="admin-save-error" style={{ marginBottom: '1rem' }}>{nlMsg}</p>}
<h3 style={{ marginTop: 0 }}>Published Issues ({nlIssues.length})</h3>
{nlLoading && nlIssues.length === 0 ? <p className="admin-stats-note">Loading…</p> : null}
{!nlLoading && nlIssues.length === 0 && <p className="admin-stats-note">No issues published yet.</p>}
{nlIssues.map(issue => (
<div key={issue.id} className="admin-list-row" style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap', padding: '0.6rem 0', borderBottom: '1px solid var(--admin-border)' }}>
<div style={{ flex: 1, minWidth: '200px' }}>
<strong>{issue.title}</strong>
<div style={{ fontSize: '0.8rem', opacity: 0.65 }}>
/newsletter/{issue.slug} · {issue.sentAt ? new Date(issue.sentAt).toLocaleDateString() : 'no send date'} · {issue.public ? 'Public' : 'Unlisted'}
</div>
</div>
<a href={`/newsletter/${issue.slug}`} target="_blank" rel="noreferrer" className="btn-admin-reset">View</a>
<button type="button" className="btn-admin-reset" onClick={() => toggleNewsletterIssuePublic(issue)}>
{issue.public ? 'Unlist' : 'Make Public'}
</button>
<button type="button" className="btn-admin-reset" onClick={() => deleteNewsletterIssue(issue)}>Remove</button>
</div>
))}
<h3>Ready to Publish ({unpublished.length})</h3>
<p className="admin-stats-note">Sent broadcasts tagged "New Episode" that aren't on the site yet.</p>
{nlLoading && unpublished.length === 0 ? <p className="admin-stats-note">Loading from Resend…</p> : null}
{!nlLoading && unpublished.length === 0 && <p className="admin-stats-note">Nothing new to publish.</p>}
{unpublished.map(broadcast => (
<div key={broadcast.id} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap', padding: '0.6rem 0', borderBottom: '1px solid var(--admin-border)' }}>
<div style={{ flex: 1, minWidth: '200px' }}>
<strong>{broadcast.subject || broadcast.name}</strong>
<div style={{ fontSize: '0.8rem', opacity: 0.65 }}>
{broadcast.sentAt ? new Date(broadcast.sentAt).toLocaleDateString() : 'no send date'}
</div>
</div>
<button
type="button"
className="btn-admin-apply"
disabled={nlPublishingId === broadcast.id}
onClick={() => publishNewsletterIssue(broadcast)}
>
{nlPublishingId === broadcast.id ? 'Publishing…' : 'Publish'}
</button>
</div>
))}
<button type="button" className="btn-admin-reset" style={{ marginTop: '1rem' }} onClick={loadNewsletterAdmin} disabled={nlLoading}>
{nlLoading ? 'Refreshing…' : 'Refresh from Resend'}
</button>
</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>
)
}