Files
Siteforge/src/AdminPage.tsx
T

3807 lines
197 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent } from 'react'
import { Link } from 'react-router-dom'
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
import { DEFAULTS } from './content'
import { AnalyticsPanel } from './components/AnalyticsPanel'
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 }>
recentVisits: Array<{
at: string
visitorId: string
ip: string
path: string
country: string
state: string
county: string
city: string
returningVisitor: boolean
visitCount: number
}>
}
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
}
}
interface AdminAsset {
filename: string
url: string
sizeBytes: number
updatedAt: string
tags?: string[]
}
interface PublishState {
draftUpdatedAt: string | null
publishedAt: string | null
}
interface OpsStatus {
buildCommit: string | null
buildNumber: string | null
deployedAt: string | null
cachePurge: { ok: boolean; at: string | null; error: string | null }
deployHook: { ok: boolean; at: string | null; error: string | null }
}
interface Question {
id: string
submittedAt: string
firstName: string
email: string
question: string
answer: string
answeredAt: string | null
isApproved: boolean
approvedAt: string | null
}
interface ContactSubmission {
id: string
submittedAt: string
name: string
email: string
message: string
messageType: 'question' | 'testimony' | 'topic' | 'general'
subscribe: boolean
archived?: boolean
}
interface ContactReplyDraft {
submissionId: string
recipientName: string
recipientEmail: string
subject: string
message: string
}
interface ContactReplyTemplate {
id: string
label: string
subject: string
message: string
}
interface ContactReplyHistoryItem {
id: string
submissionId: string
toEmail: string
toName: string
fromEmail: string
subject: string
preview: string
sentAt: string
}
interface Subscriber {
name: string
email: string
subscribedAt: string
source: 'contact-form' | 'download'
}
interface ContactReplyConfig {
fromEmail: string
fromIdentity: string
resendApiConfigured: boolean
canSendReplies: boolean
note: string
}
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
type AdminView =
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
| 'current-series' | 'episode-highlights' | 'archived-series'
| 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'analytics' | 'assets' | 'colossians-study'
| 'emails' | 'subscribers' | 'contacts'
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'global'
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: 'Brand Font',
spec: 'Cormorant Garamond · 300/400/600/700 · italic supported',
sample: 'Verse by Verse with Nate',
},
{
label: 'Subtitle / Small Caps',
spec: 'Uppercase with tracking for labels, nav, and support text',
sample: 'A Journey Through Scripture',
},
{
label: 'Body / Quote',
spec: 'Cormorant Garamond light/italic for long-form text and pull copy',
sample: 'Verse by verse. Nugget by nugget.',
},
] as const
const BRAND_KIT_VERIFICATION = [
'Global font import updated to Cormorant Garamond.',
'Shared brand variables now define black, gold, border, warm white, and muted text colors.',
'Live site CSS now references the 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: '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: '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 — Downloads 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' },
// 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: '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: 'Welcome Email — Signoff HTML', multiline: true, section: 'global' },
]
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,
}
}
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 [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [maintenanceMsg, setMaintenanceMsg] = useState('')
const [opsMsg, setOpsMsg] = useState('')
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
const [selectedBackup, setSelectedBackup] = useState('')
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
// TOTP management state
const [totpEnabled, setTotpEnabled] = useState<boolean | null>(null)
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 [assets, setAssets] = useState<AdminAsset[]>([])
const [assetTagEdits, setAssetTagEdits] = useState<Record<string, string>>({})
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
const [assetUploadPending, setAssetUploadPending] = useState(false)
const [questions, setQuestions] = useState<Question[]>([])
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
const [contactStatus, setContactStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [contactReplyDraft, setContactReplyDraft] = useState<ContactReplyDraft | null>(null)
const [contactReplyStatus, setContactReplyStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
const [contactReplyMsg, setContactReplyMsg] = useState('')
const [contactReplyTemplates, setContactReplyTemplates] = useState<ContactReplyTemplate[]>([])
const [contactReplyHistory, setContactReplyHistory] = useState<ContactReplyHistoryItem[]>([])
const [contactReplyConfig, setContactReplyConfig] = useState<ContactReplyConfig | null>(null)
const [contactTemplateStatus, setContactTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [emailMailboxView, setEmailMailboxView] = useState<'inbox' | 'archived'>('inbox')
const [selectedEmailId, setSelectedEmailId] = useState<string | null>(null)
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 [subscribers, setSubscribers] = useState<Subscriber[]>([])
const [subscriberSearch, setSubscriberSearch] = useState('')
const [contactSearch, setContactSearch] = useState('')
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
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 [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({})
const [previewOpen, setPreviewOpen] = useState(false)
const previewIframeRef = useRef<HTMLIFrameElement>(null)
const isDirty = JSON.stringify(form) !== lastSavedSnapshot
const unreadEmailCount = contactSubmissions.filter(s => !s.archived).length
const unansweredCount = questions.filter(q => !q.answer?.trim()).length
// 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)
}, [content])
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/admin-auth/status')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => setTotpEnabled(!!(data as { totpEnabled?: boolean }).totpEnabled))
.catch(() => {})
}, [])
useEffect(() => {
fetch('/api/admin-stats')
.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 ?? [])
setContactStatus('ready')
})
.catch(() => {
setContactStatus('error')
})
fetch('/api/admin-contact-reply-templates')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply templates'))))
.then(data => {
setContactReplyTemplates((data as { templates: ContactReplyTemplate[] }).templates ?? [])
})
.catch(() => {})
fetch('/api/admin-contact-reply-history')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply history'))))
.then(data => {
setContactReplyHistory((data as { items: ContactReplyHistoryItem[] }).items ?? [])
})
.catch(() => {})
fetch('/api/admin-reply-config')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply config'))))
.then(data => {
setContactReplyConfig(data as ContactReplyConfig)
})
.catch(() => {})
fetch('/api/admin-stats/backups')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load backups'))))
.then(data => {
const files = Array.isArray((data as { backups?: unknown }).backups) ? (data as { backups: BackupPreview[] }).backups : []
setBackupFiles(files)
if (files.length > 0) {
setSelectedBackup(files[0].filename)
}
})
.catch(() => {})
fetch('/api/admin-content-state')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load publish state'))))
.then(data => {
const state = (data as { publishState?: PublishState }).publishState
if (state) {
setPublishState({
draftUpdatedAt: state.draftUpdatedAt ?? null,
publishedAt: state.publishedAt ?? null,
})
}
})
.catch(() => {})
void reloadAssets()
fetch('/api/admin-ops/status')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load operations status'))))
.then(data => {
setOpsStatus(data as OpsStatus)
})
.catch(() => {})
fetch('/api/admin-subscribers')
.then(r => (r.ok ? r.json() : Promise.reject()))
.then(data => setSubscribers((data as { subscribers: Subscriber[] }).subscribers ?? []))
.catch(() => {})
fetch('/api/admin-download-stats')
.then(r => (r.ok ? r.json() : Promise.reject()))
.then(data => setDownloadStats((data as { counts: Record<string, number> }).counts ?? {}))
.catch(() => {})
}, [])
useEffect(() => {
if (!selectedBackup) {
setSelectedBackupPreview(null)
return
}
fetch('/api/admin-stats/backup-preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: selectedBackup }),
})
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Preview failed'))))
.then(data => {
const preview = (data as { preview?: BackupPreview }).preview ?? null
setSelectedBackupPreview(preview)
})
.catch(() => {
setSelectedBackupPreview(null)
})
}, [selectedBackup])
async function reloadStats() {
const r = await fetch('/api/admin-stats')
if (!r.ok) throw new Error('Could not refresh stats')
const data = await r.json()
setStats(data as AdminStats)
setStatsStatus('ready')
}
async function reloadContactSubmissions() {
const r = await fetch('/api/admin-contact-submissions')
if (!r.ok) throw new Error('Could not refresh contact submissions')
const data = await r.json() as { submissions?: ContactSubmission[] }
setContactSubmissions(Array.isArray(data.submissions) ? data.submissions : [])
setContactStatus('ready')
}
useEffect(() => {
const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
if (visible.length === 0) {
setSelectedEmailId(null)
return
}
if (!selectedEmailId || !visible.some(item => item.id === selectedEmailId)) {
setSelectedEmailId(visible[0].id)
}
}, [contactSubmissions, emailMailboxView, selectedEmailId])
useEffect(() => {
setQuestionPage(0)
}, [questionSearch, questionFilter])
async function reloadContactReplyHistory() {
const r = await fetch('/api/admin-contact-reply-history')
if (!r.ok) throw new Error('Could not refresh reply history')
const data = await r.json() as { items?: ContactReplyHistoryItem[] }
setContactReplyHistory(Array.isArray(data.items) ? data.items : [])
}
async function reloadContactReplyTemplates() {
const r = await fetch('/api/admin-contact-reply-templates')
if (!r.ok) throw new Error('Could not refresh reply templates')
const data = await r.json() as { templates?: ContactReplyTemplate[] }
setContactReplyTemplates(Array.isArray(data.templates) ? data.templates : [])
}
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 r = await fetch('/api/admin-content')
if (!r.ok) return
const data = await r.json() as { siteContent?: Partial<SiteContent> }
if (data?.siteContent && typeof data.siteContent === 'object') {
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...data.siteContent })
setForm(next)
onSave(next)
}
}
async function reloadAssets() {
const r = await fetch('/api/admin-assets')
if (!r.ok) throw new Error('Could not refresh assets')
const data = await r.json() as { assets?: AdminAsset[] }
const incomingAssets = Array.isArray(data.assets) ? data.assets : []
setAssets(incomingAssets)
setAssetTagEdits(incomingAssets.reduce<Record<string, string>>((memo, asset) => {
memo[asset.filename] = (asset.tags ?? []).join(', ')
return memo
}, {}))
}
async function reloadOpsStatus() {
const r = await fetch('/api/admin-ops/status')
if (!r.ok) throw new Error('Could not refresh ops status')
const data = await r.json() as OpsStatus
setOpsStatus(data)
}
async function handleSaveDraft() {
const payload = buildSiteContentForSave(form)
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(JSON.stringify(payload))
setStatus('saved')
setTimeout(() => setStatus('idle'), 3500)
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
setStatus('error')
}
}
async function handlePublishDraft() {
if (!confirm('Publish current changes to the live site now?')) return
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')
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() }))
await reloadStats()
setStatus('saved')
setTimeout(() => setStatus('idle'), 3500)
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
setStatus('error')
}
}
function updateSeoField(field: keyof SeoSettings, value: string | string[]) {
setForm(f => ({
...f,
seo: {
...f.seo,
[field]: value,
},
}))
}
function updateLegalField(field: keyof LegalSettings, value: string | string[]) {
setForm(f => ({
...f,
legal: {
...f.legal,
[field]: value,
},
}))
}
async function handleTotpSetupInit() {
setTotpMsg('')
setTotpRecoveryCodes(null)
const res = await fetch('/api/admin-auth/totp-setup-init', { method: 'POST' })
const data = await res.json().catch(() => ({})) as { qrDataUrl?: string; secret?: string; message?: string }
if (!res.ok) { setTotpMsg(data.message ?? 'Setup failed.'); return }
setTotpSetupQr(data.qrDataUrl ?? null)
setTotpSetupSecret(data.secret ?? null)
setTotpConfirmCode('')
}
async function handleTotpSetupConfirm(e: React.FormEvent) {
e.preventDefault()
setTotpMsg('')
const res = await fetch('/api/admin-auth/totp-setup-confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: totpConfirmCode }),
})
const data = await res.json().catch(() => ({})) as { ok?: boolean; recoveryCodes?: string[]; message?: string }
if (!res.ok) { setTotpMsg(data.message ?? 'Confirmation failed.'); return }
setTotpEnabled(true)
setTotpSetupQr(null)
setTotpSetupSecret(null)
setTotpConfirmCode('')
setTotpRecoveryCodes(data.recoveryCodes ?? null)
setTotpMsg('Two-factor authentication enabled.')
}
async function handleTotpDisable() {
if (!confirm('Disable two-factor authentication? This will make your admin less secure.')) return
setTotpMsg('')
const res = await fetch('/api/admin-auth/totp-disable', { method: 'POST' })
if (res.ok) { setTotpEnabled(false); setTotpRecoveryCodes(null); setTotpMsg('Two-factor authentication disabled.') }
else { const d = await res.json().catch(() => ({})) as { message?: string }; setTotpMsg(d.message ?? 'Failed to disable TOTP.') }
}
async function handleTotpRegenRecovery() {
if (!confirm('Regenerate recovery codes? Your old codes will stop working immediately.')) return
setTotpMsg('')
const res = await fetch('/api/admin-auth/totp-regen-recovery', { method: 'POST' })
const data = await res.json().catch(() => ({})) as { ok?: boolean; recoveryCodes?: string[]; message?: string }
if (!res.ok) { setTotpMsg(data.message ?? 'Failed.'); return }
setTotpRecoveryCodes(data.recoveryCodes ?? null)
setTotpMsg('New recovery codes generated. Save these now.')
}
function addRedirectRule() {
setForm(f => ({
...f,
redirects: [
...(f.redirects ?? []),
{
id: Date.now().toString(36),
path: '/new-short-link',
target: 'https://',
statusCode: 301,
},
],
}))
}
function updateRedirectRule(id: string, field: keyof RedirectRule, value: string | number) {
setForm(f => ({
...f,
redirects: (f.redirects ?? []).map(rule => rule.id === id
? {
...rule,
[field]: field === 'statusCode' ? (Number(value) === 302 ? 302 : 301) : value,
}
: rule),
}))
}
function removeRedirectRule(id: string) {
setForm(f => ({ ...f, redirects: (f.redirects ?? []).filter(rule => rule.id !== id) }))
}
function addPodcastLink() {
setForm(f => ({
...f,
podcastFeaturedLinks: [
...(f.podcastFeaturedLinks ?? []),
{
id: Date.now().toString(36),
title: '',
episodeNumber: '',
summary: '',
url: '',
embedUrl: '',
showNotes: '',
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 updatePodcastLinkQuestions(id: string, raw: string) {
const questions = raw.split('\n').map(q => q.trim()).filter(Boolean)
setForm(f => ({
...f,
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link =>
link.id === id ? { ...link, discussionQuestions: questions } : link
),
}))
}
function removePodcastLink(id: string) {
setForm(f => ({ ...f, podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).filter(link => link.id !== id) }))
}
async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
if (!file) return
setAssetUploadPending(true)
setOpsMsg('')
try {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
const res = await fetch('/api/admin-assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, dataUrl }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Upload failed')
}
await reloadAssets()
setOpsMsg('Asset uploaded successfully.')
} catch (err) {
setOpsMsg(err instanceof Error ? err.message : 'Upload failed.')
} finally {
setAssetUploadPending(false)
event.target.value = ''
}
}
async function handleDeleteAsset(filename: string) {
if (!confirm(`Delete asset ${filename}?`)) return
try {
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Delete failed')
await reloadAssets()
setOpsMsg('Asset deleted.')
} catch {
setOpsMsg('Asset delete failed.')
}
}
async function handleSaveAssetTags(filename: string) {
const tagsText = assetTagEdits[filename] ?? ''
const tags = tagsText.split(',').map(tag => tag.trim()).filter(Boolean)
try {
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Save failed')
}
await reloadAssets()
setOpsMsg('Asset tags saved.')
} catch (err) {
setOpsMsg(err instanceof Error ? err.message : 'Save failed.')
}
}
async function handlePurgeCache() {
try {
const res = await fetch('/api/admin-ops/purge-cache', { method: 'POST' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Cache purge failed')
}
await reloadOpsStatus()
setOpsMsg('Cache purge triggered.')
} catch (err) {
setOpsMsg(err instanceof Error ? err.message : 'Cache purge failed.')
}
}
async function handleDeployHook() {
try {
const res = await fetch('/api/admin-ops/deploy', { method: 'POST' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Deploy hook failed')
}
await reloadOpsStatus()
setOpsMsg('Deploy hook triggered.')
} catch (err) {
setOpsMsg(err instanceof Error ? err.message : 'Deploy hook failed.')
}
}
function maskIp(ip: string) {
if (!ip || ip === 'unknown') return 'unknown'
if (ip.includes('.')) {
const parts = ip.split('.')
if (parts.length === 4) return `${parts[0]}.${parts[1]}.x.x`
}
if (ip.includes(':')) {
const parts = ip.split(':')
return `${parts.slice(0, 3).join(':')}:x:x`
}
return ip
}
async function handleExport() {
try {
const r = await fetch('/api/admin-stats/export')
if (!r.ok) throw new Error('Export failed')
const data = await r.json()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `siteforge-admin-export-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
setMaintenanceMsg('Export downloaded.')
} catch {
setMaintenanceMsg('Export failed.')
}
}
async function handlePrune() {
const input = prompt('Keep how many days of analytics data?', '180')
if (input === null) return
const days = Number(input)
if (!Number.isFinite(days) || days <= 0) {
setMaintenanceMsg('Invalid retention days.')
return
}
try {
const r = await fetch('/api/admin-stats/prune', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days }),
})
if (!r.ok) throw new Error('Prune failed')
await reloadStats()
setMaintenanceMsg(`Pruned analytics to ${Math.floor(days)} days.`)
} catch {
setMaintenanceMsg('Prune failed.')
}
}
async function handleClear() {
if (!confirm('Clear ALL analytics data now? This cannot be undone.')) return
try {
const r = await fetch('/api/admin-stats/clear', { method: 'POST' })
if (!r.ok) throw new Error('Clear failed')
await reloadStats()
setMaintenanceMsg('All analytics data cleared.')
} catch {
setMaintenanceMsg('Clear failed.')
}
}
async function handleBackupNow() {
try {
const r = await fetch('/api/admin-stats/backup', { method: 'POST' })
if (!r.ok) throw new Error('Backup failed')
await reloadStats()
await reloadBackups()
setMaintenanceMsg('Backup snapshot created.')
} catch {
setMaintenanceMsg('Backup failed.')
}
}
async function 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 renderFileAssetSelector(value: string | null | undefined, onChange: (value: string) => void, fieldId: string) {
const assetOptions = [...assets.map(asset => ({ label: asset.filename, value: asset.url }))]
const currentValue = value?.trim() ?? ''
if (currentValue && !assetOptions.some(item => item.value === currentValue)) {
assetOptions.unshift({ label: `Current file (${currentValue})`, value: currentValue })
}
if (assetOptions.length === 0) return null
return (
<div className="admin-field-asset-picker">
<label htmlFor={fieldId}>Choose an existing file</label>
<select id={fieldId} value={assetOptions.some(a => a.value === currentValue) ? currentValue : ''} onChange={e => onChange(e.target.value)}>
<option value="">Select file</option>
{assetOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)
}
function isImageAsset(filename: string) {
return /\.(png|jpe?g|webp|gif)$/i.test(filename)
}
function confirmLeaveUnsavedChanges() {
if (!isDirty) return true
return confirm('You have unsaved changes. Leave this section without saving?')
}
function navigateTo(view: AdminView) {
if (view === adminView) return
if (!confirmLeaveUnsavedChanges()) return
setAdminView(view)
setMobileNavOpen(false)
}
function addLink() {
setForm(f => ({
...f,
customLinks: [
...(f.customLinks ?? []),
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'platforms' as const },
],
}))
}
function addResource() {
setForm(f => ({
...f,
customLinks: [
...(f.customLinks ?? []),
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', placement: 'resources' 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 addArchivedSeries() {
setForm(f => ({
...f,
archivedSeries: [
...(f.archivedSeries ?? []),
{
id: Date.now().toString(36),
label: 'Archived Study',
title: '',
description: '',
imageUrl: '',
listenUrl: '',
studyGuideTitle: '',
studyGuideDescription: '',
studyGuideUrl: '',
resourceLinks: [],
notes: [],
},
],
}))
}
function updateArchivedSeries(id: string, field: keyof ArchivedSeries, value: string | ArchivedSeriesResourceLink[] | ArchivedSeriesNote[] | { from: number; to: number } | undefined) {
setForm(f => ({
...f,
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === id ? { ...series, [field]: value } : series),
}))
}
function removeArchivedSeries(id: string) {
setForm(f => ({ ...f, archivedSeries: (f.archivedSeries ?? []).filter(series => series.id !== id) }))
}
function addArchivedSeriesLink(seriesId: string) {
setForm(f => ({
...f,
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
? {
...series,
resourceLinks: [
...(series.resourceLinks ?? []),
{ id: `${seriesId}-${Date.now().toString(36)}`, label: '', description: '', url: '', amazonUrl: '', amazonLabel: '' },
],
}
: series),
}))
}
function updateArchivedSeriesLink(seriesId: string, linkId: string, field: keyof ArchivedSeriesResourceLink, value: string) {
setForm(f => ({
...f,
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
? {
...series,
resourceLinks: (series.resourceLinks ?? []).map(link => link.id === linkId ? { ...link, [field]: value } : link),
}
: series),
}))
}
function removeArchivedSeriesLink(seriesId: string, linkId: string) {
setForm(f => ({
...f,
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
? { ...series, resourceLinks: (series.resourceLinks ?? []).filter(link => link.id !== linkId) }
: series),
}))
}
function addExistingCustomLinkToArchivedSeries(seriesId: string) {
const selectedLinkId = archiveLinkSelectionBySeries[seriesId]
if (!selectedLinkId) return
const source = (form.customLinks ?? []).find(link => link.id === selectedLinkId)
if (!source) return
setForm(f => ({
...f,
archivedSeries: (f.archivedSeries ?? []).map(series => {
if (series.id !== seriesId) return series
const alreadyExists = (series.resourceLinks ?? []).some(link =>
link.url.trim().toLowerCase() === source.url.trim().toLowerCase(),
)
if (alreadyExists) return series
return {
...series,
resourceLinks: [
...(series.resourceLinks ?? []),
{
id: `${seriesId}-${Date.now().toString(36)}`,
label: source.label,
description: source.description ?? '',
url: source.url,
amazonUrl: source.amazonUrl ?? '',
amazonLabel: source.amazonLabel ?? '',
},
],
}
}),
}))
}
function addAllExistingCustomLinksToArchivedSeries(seriesId: string) {
setForm(f => ({
...f,
archivedSeries: (f.archivedSeries ?? []).map(series => {
if (series.id !== seriesId) return series
const existingUrls = new Set(
(series.resourceLinks ?? [])
.map(link => link.url.trim().toLowerCase())
.filter(Boolean),
)
const toAdd = (f.customLinks ?? [])
.filter(link => link.url.trim().length > 0)
.filter(link => !existingUrls.has(link.url.trim().toLowerCase()))
.map(link => ({
id: `${seriesId}-${Date.now().toString(36)}-${link.id}`,
label: link.label,
description: link.description ?? '',
url: link.url,
amazonUrl: link.amazonUrl ?? '',
amazonLabel: link.amazonLabel ?? '',
}))
if (toAdd.length === 0) return series
return {
...series,
resourceLinks: [
...(series.resourceLinks ?? []),
...toAdd,
],
}
}),
}))
}
function addArchivedSeriesNote(seriesId: string) {
setForm(f => ({
...f,
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
? {
...series,
notes: [
...(series.notes ?? []),
{ id: `${seriesId}-note-${Date.now().toString(36)}`, heading: '', body: '' },
],
}
: series),
}))
}
function updateArchivedSeriesNote(seriesId: string, noteId: string, field: keyof ArchivedSeriesNote, value: string) {
setForm(f => ({
...f,
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
? {
...series,
notes: (series.notes ?? []).map(note => note.id === noteId ? { ...note, [field]: value } : note),
}
: series),
}))
}
function removeArchivedSeriesNote(seriesId: string, noteId: string) {
setForm(f => ({
...f,
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
? { ...series, notes: (series.notes ?? []).filter(note => note.id !== noteId) }
: series),
}))
}
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: [],
},
],
}
: study),
}))
}
function updateStudySection(studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | number | string[]) {
setForm(f => ({
...f,
studies: (f.studies ?? []).map(study => study.id === studyId
? {
...study,
sections: (study.sections ?? []).map(section => section.id === sectionId ? { ...section, [field]: value } : section),
}
: study),
}))
}
function removeStudySection(studyId: string, sectionId: string) {
setForm(f => ({
...f,
studies: (f.studies ?? []).map(study => study.id === studyId
? { ...study, sections: (study.sections ?? []).filter(section => section.id !== sectionId) }
: study),
}))
}
function archiveCurrentSeriesSnapshot() {
const currentTitle = form.seriesTitle.trim()
if (!currentTitle) {
alert('Set a current series title first, then archive it.')
return
}
const existing = (form.archivedSeries ?? []).some(
series => series.title.trim().toLowerCase() === currentTitle.toLowerCase(),
)
if (existing && !confirm(`An archived series named "${currentTitle}" already exists. Create another snapshot anyway?`)) {
return
}
const resourceLinks = (form.customLinks ?? [])
.filter(link => link.placement === 'resources')
.filter(link => link.label.trim().length > 0 || link.url.trim().length > 0)
.map(link => ({
id: `archive-link-${Date.now().toString(36)}-${link.id}`,
label: link.label,
url: link.url,
}))
const notes = (form.customBlocks ?? [])
.filter(block => block.heading.trim().length > 0 || block.body.trim().length > 0)
.map(block => ({
id: `archive-note-${Date.now().toString(36)}-${block.id}`,
heading: block.heading,
body: block.body,
}))
const archived: ArchivedSeries = {
id: `archive-${Date.now().toString(36)}`,
label: form.seriesLabel?.trim() || 'Archived Study',
title: form.seriesTitle,
description: form.seriesDescription,
imageUrl: form.seriesImageUrl,
listenUrl: form.seriesListenUrl,
studyGuideTitle: form.studyGuideTitle,
studyGuideDescription: form.studyGuideDescription,
studyGuideUrl: form.studyGuideUrl,
resourceLinks,
notes,
}
setForm(f => ({
...f,
archivedSeries: [archived, ...(f.archivedSeries ?? [])],
}))
navigateTo('archived-series')
}
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 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.')
}
}
async function handleDeleteContactSubmission(submissionId: string) {
if (!confirm('Delete this contact submission permanently?')) return
try {
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete submission')
await reloadContactSubmissions()
await reloadStats()
setMaintenanceMsg('Contact submission deleted.')
} catch {
setMaintenanceMsg('Failed to delete contact submission.')
}
}
async function handleArchiveContactSubmission(submissionId: string, archived: boolean) {
try {
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ archived }),
})
if (!res.ok) throw new Error('Failed to update submission')
await reloadContactSubmissions()
await reloadStats()
setContactReplyMsg(archived ? 'Message archived.' : 'Message moved back to inbox.')
} catch {
setContactReplyMsg('Failed to update archive status.')
}
}
function openContactReplyComposer(submission: ContactSubmission) {
const firstName = submission.name?.trim().split(/\s+/)[0] || 'there'
setContactReplyDraft({
submissionId: submission.id,
recipientName: submission.name,
recipientEmail: submission.email,
subject: 'Thanks for reaching out to Verse by Verse with Nate',
message: `Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.`,
})
setContactReplyStatus('idle')
setContactReplyMsg(`Composing a reply to ${firstName}.`)
}
function applyContactReplyTemplate(templateId: string) {
if (!contactReplyDraft) return
const template = contactReplyTemplates.find(item => item.id === templateId)
if (!template) return
setContactReplyDraft({
...contactReplyDraft,
subject: template.subject,
message: template.message,
})
setContactReplyMsg(`Applied template: ${template.label}.`)
}
function addContactReplyTemplate() {
setContactReplyTemplates(items => ([
...items,
{
id: Date.now().toString(36),
label: '',
subject: '',
message: '',
},
]))
setContactTemplateStatus('idle')
}
function updateContactReplyTemplate(id: string, field: keyof ContactReplyTemplate, value: string) {
setContactReplyTemplates(items => items.map(item => item.id === id ? { ...item, [field]: value } : item))
setContactTemplateStatus('idle')
}
function removeContactReplyTemplate(id: string) {
setContactReplyTemplates(items => items.filter(item => item.id !== id))
setContactTemplateStatus('idle')
}
async function handleSaveContactReplyTemplates() {
setContactTemplateStatus('saving')
try {
const res = await fetch('/api/admin-contact-reply-templates', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ templates: contactReplyTemplates }),
})
if (!res.ok) throw new Error('Failed to save templates')
await reloadContactReplyTemplates()
setContactTemplateStatus('saved')
setContactReplyMsg('Reply templates saved.')
} catch {
setContactTemplateStatus('error')
setContactReplyMsg('Failed to save reply templates.')
}
}
async function handleSendContactReply() {
if (!contactReplyDraft) return
setContactReplyStatus('sending')
setContactReplyMsg('')
try {
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(contactReplyDraft.submissionId)}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: contactReplyDraft.subject,
message: contactReplyDraft.message,
}),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Failed to send email.')
}
setContactReplyStatus('sent')
setContactReplyMsg(`Reply sent to ${contactReplyDraft.recipientEmail} from hello@versebyversewithnate.us.`)
await reloadContactReplyHistory()
setContactReplyDraft(null)
} catch (err) {
setContactReplyStatus('error')
setContactReplyMsg(err instanceof Error ? err.message : 'Failed to send email.')
}
}
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources')
const archivedResourceCount = (form.archivedSeries ?? []).reduce((count, series) => {
return count + (series.resourceLinks ?? []).length
}, 0)
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-brand">
<span className="admin-topbar-ornament"></span>
<span className="admin-topbar-title">Site Admin</span>
<span className="admin-topbar-sub">Verse by Verse with Nate</span>
</div>
<div className="admin-topbar-actions">
<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"
>
{mobileNavOpen ? 'Close Menu' : 'Menu'}
</button>
{isDirty && <span className="admin-status admin-status--warn"> Unsaved</span>}
<span className="admin-topbar-meta">Draft: {formatDate(publishState.draftUpdatedAt)}</span>
<span className="admin-topbar-meta">Published: {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-reset${previewOpen ? ' btn-admin-reset--active' : ''}`} onClick={() => setPreviewOpen(o => !o)}>
{previewOpen ? 'Close Preview' : 'Preview'}
</button>
<button type="button" className="btn-admin-reset" onClick={handlePublishDraft}>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-meta-links">
<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>
</div>
<div className="admin-nav-group">
<span className="admin-nav-label">Overview</span>
<button type="button" className={`admin-nav-item${adminView === 'dashboard' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('dashboard')}>Dashboard</button>
</div>
<div className="admin-nav-group">
<span className="admin-nav-label">Site</span>
<button type="button" className={`admin-nav-item${adminView === 'homepage' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('homepage')}>Homepage</button>
<button type="button" className={`admin-nav-item${adminView === 'start-here' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('start-here')}>Start Here</button>
<button type="button" className={`admin-nav-item${adminView === 'about' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('about')}>About</button>
<button type="button" className={`admin-nav-item${adminView === 'contact' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('contact')}>Contact</button>
</div>
<div className="admin-nav-group">
<span className="admin-nav-label">Podcast</span>
<button type="button" className={`admin-nav-item${adminView === 'current-series' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('current-series')}>Current Series</button>
<button type="button" className={`admin-nav-item${adminView === 'episode-highlights' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('episode-highlights')}>Ep. Highlights</button>
<button type="button" className={`admin-nav-item${adminView === 'archived-series' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('archived-series')}>Archived Series</button>
</div>
<div className="admin-nav-group">
<span className="admin-nav-label">Content</span>
<button type="button" className={`admin-nav-item${adminView === 'colossians-study' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('colossians-study')}>Studies</button>
<button type="button" className={`admin-nav-item${adminView === 'downloads' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('downloads')}>Downloads</button>
<button type="button" className={`admin-nav-item${adminView === 'custom-links' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('custom-links')}>Custom Links</button>
<button type="button" className={`admin-nav-item${adminView === 'content-blocks' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('content-blocks')}>Content Blocks</button>
</div>
<div className="admin-nav-group">
<span className="admin-nav-label">Manage</span>
<button type="button" className={`admin-nav-item${adminView === 'questions' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('questions')}>
Questions ({questions.length}){unansweredCount > 0 && <span className="admin-nav-badge">{unansweredCount}</span>}
</button>
<button type="button" className={`admin-nav-item${adminView === 'emails' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('emails')}>
Emails{unreadEmailCount > 0 && <span className="admin-nav-badge">{unreadEmailCount}</span>}
</button>
<button type="button" className={`admin-nav-item${adminView === 'contacts' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('contacts')}>
Contacts{contactSubmissions.length > 0 && <span className="admin-nav-badge admin-nav-badge--neutral">{contactSubmissions.length}</span>}
</button>
<button type="button" className={`admin-nav-item${adminView === 'subscribers' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('subscribers')}>
Subscribers{subscribers.length > 0 && <span className="admin-nav-badge admin-nav-badge--neutral">{subscribers.length}</span>}
</button>
<button type="button" className={`admin-nav-item${adminView === 'analytics' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('analytics')}>Analytics</button>
<button type="button" className={`admin-nav-item${adminView === 'assets' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('assets')}>Asset Manager</button>
</div>
<div className="admin-nav-group">
<span className="admin-nav-label">Configure</span>
<button type="button" className={`admin-nav-item${adminView === 'global' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('global')}>Footer &amp; Global</button>
<button type="button" className={`admin-nav-item${adminView === 'seo' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('seo')}>SEO &amp; Redirects</button>
<button type="button" className={`admin-nav-item${adminView === 'legal' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('legal')}>Legal Pages</button>
<button type="button" className={`admin-nav-item${adminView === 'security' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('security')}>Security</button>
<button type="button" className={`admin-nav-item${adminView === 'brand' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('brand')}>Brand Kit</button>
</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
return (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Dashboard</h2>
<p>Quick overview of your ministry site.</p>
</div>
<div className="admin-dashboard-welcome">
<div>
<h3 className="admin-dashboard-welcome-title">{welcomeMessage}</h3>
<p className="admin-dashboard-welcome-copy">Here&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>
<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">Unread Emails</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('emails')}>Open 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.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">{downloadStats['titus-study'] ?? 0}</div>
<div className="admin-dashboard-card-label">Titus Study Downloads</div>
<div className="admin-dashboard-card-sub">
{Object.values(downloadStats).reduce((a, b) => a + b, 0)} total resource downloads
</div>
</div>
{publishState?.publishedAt && (
<div className="admin-dashboard-card">
<div className="admin-dashboard-card-value" style={{ fontSize: '1rem', marginTop: '0.25rem' }}>{formatDate(publishState.publishedAt)}</div>
<div className="admin-dashboard-card-label">Last Published</div>
{publishState.draftUpdatedAt && <div className="admin-dashboard-card-sub">Draft updated {formatDate(publishState.draftUpdatedAt)}</div>}
</div>
)}
</div>
<div className="admin-dashboard-activity">
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Recent Contacts</h3>
{recentContacts.length === 0
? <p className="admin-stats-note">No contacts yet.</p>
: (
<div className="admin-dashboard-feed">
{recentContacts.map(c => (
<div key={c.id} className="admin-dashboard-feed-item">
<div className="admin-dashboard-feed-meta">
<strong>{c.name}</strong>
<span className="admin-dashboard-feed-date">{formatDate(c.submittedAt)}</span>
</div>
<div className="admin-dashboard-feed-email">{c.email}</div>
{c.message && <div className="admin-dashboard-feed-preview">{c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}</div>}
</div>
))}
<button type="button" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem' }} onClick={() => navigateTo('contacts')}>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>
</section>
)
})()}
{/* CONTACTS */}
{adminView === 'contacts' && (() => {
const searchTerm = contactSearch.trim().toLowerCase()
const grouped = new Map<string, ContactSubmission[]>()
for (const submission of contactSubmissions) {
const emailKey = submission.email.trim().toLowerCase()
const nameKey = submission.name.trim().toLowerCase()
const key = emailKey || nameKey || submission.id
const entries = grouped.get(key)
if (entries) entries.push(submission)
else grouped.set(key, [submission])
}
const rolledUp = Array.from(grouped.values())
.map(entries => {
const sortedEntries = [...entries].sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
const latest = sortedEntries[0]
return {
...latest,
archived: sortedEntries.every(entry => entry.archived === true),
subscribe: sortedEntries.some(entry => entry.subscribe),
message: latest.message || sortedEntries.find(entry => entry.message)?.message || '',
submissionCount: sortedEntries.length,
}
})
.filter(contact => {
if (!searchTerm) return true
return contact.name.toLowerCase().includes(searchTerm)
|| contact.email.toLowerCase().includes(searchTerm)
|| contact.message.toLowerCase().includes(searchTerm)
})
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
return (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Contacts</h2>
<p>{rolledUp.length} contacts from {contactSubmissions.length} total submissions repeat senders are grouped together.</p>
</div>
<div className="admin-toolbar" style={{ marginBottom: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="search"
placeholder="Search name, email, or message…"
value={contactSearch}
onChange={e => setContactSearch(e.target.value)}
style={{ minWidth: '260px', maxWidth: '440px', width: '100%' }}
/>
<span className="admin-stats-note" style={{ margin: 0 }}>{rolledUp.length} result{rolledUp.length !== 1 ? 's' : ''}</span>
</div>
{rolledUp.length === 0
? <p className="admin-stats-note">No contacts{contactSearch ? ' 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>Type</th>
<th>Subscriber</th>
<th>Date</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{rolledUp.map(c => (
<tr key={c.id} className={c.archived ? 'admin-contacts-row--archived' : ''}>
<td>
<div>{c.name}</div>
{c.submissionCount > 1 && <div className="admin-stats-note" style={{ margin: '0.2rem 0 0' }}>{c.submissionCount} submissions</div>}
</td>
<td><a href={`mailto:${c.email}`}>{c.email}</a></td>
<td><span className="admin-badge admin-badge--pending" style={{ fontSize: '0.7rem' }}>{c.messageType ?? 'contact'}</span></td>
<td style={{ textAlign: 'center' }}>{c.subscribe ? '✓' : ''}</td>
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(c.submittedAt)}</td>
<td style={{ maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={c.message}>{c.message ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
</section>
)
})()}
{/* SUBSCRIBERS */}
{adminView === 'subscribers' && (() => {
const filteredSubs = subscribers.filter(s =>
!subscriberSearch.trim() ||
s.name.toLowerCase().includes(subscriberSearch.toLowerCase()) ||
s.email.toLowerCase().includes(subscriberSearch.toLowerCase())
)
return (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Subscribers</h2>
<p>{subscribers.length} people have opted in to email updates.</p>
</div>
<div className="admin-toolbar" style={{ marginBottom: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="search"
placeholder="Search by name or email…"
value={subscriberSearch}
onChange={e => setSubscriberSearch(e.target.value)}
style={{ minWidth: '240px', maxWidth: '400px', width: '100%' }}
/>
<form method="post" action="/api/admin-subscribers/export" style={{ display: 'inline' }}>
<button type="submit" className="btn-admin-reset">Export CSV</button>
</form>
</div>
{filteredSubs.length === 0 ? (
<p className="admin-stats-note">No subscribers{subscriberSearch ? ' match your search' : ' yet'}.</p>
) : (
<div className="admin-visits-table-wrap">
<table className="admin-visits-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Source</th>
<th>Subscribed</th>
</tr>
</thead>
<tbody>
{filteredSubs.map((sub, i) => (
<tr key={`${sub.email}-${i}`}>
<td>{sub.name}</td>
<td><a href={`mailto:${sub.email}`}>{sub.email}</a></td>
<td>{sub.source === 'download' ? 'Download' : 'Contact Form'}</td>
<td>{formatDate(sub.subscribedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
})()}
{/* HOMEPAGE */}
{adminView === 'homepage' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Homepage</h2>
<p>Controls the hero, button labels, share section, and "Where to Next" navigation cards.</p>
</div>
{FIELDS.filter(f => f.section === 'hero' || 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)} />}
</div>
))}
<div className="admin-panel-subhead">
<h3>Where to Next Section Labels</h3>
</div>
<div className="admin-field">
<label htmlFor="field-whereToNextEyebrow">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">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">
<h3>Where to Next Navigation Cards</h3>
<p>Edit the title, description, and link 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>
{FIELDS.filter(f => f.section === 'start-here').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 */}
{adminView === 'about' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>About</h2>
<p>Manage the main show description, Nate bio, and about images.</p>
</div>
{FIELDS.filter(f => f.section === 'about').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') || key.includes('ArtUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
{key === 'aboutPhotoUrl' && <p className="admin-field-note">This controls the portrait shown on the left side of the About page.</p>}
{key === 'aboutVerseArtUrl' && <p className="admin-field-note">This controls the scripture artwork shown under the About the Show text. It is separate from the Contact page image.</p>}
{key === 'aboutPhotoUrl' && renderImagePreview(form[key] as string, 'About page portrait preview')}
{key === 'aboutVerseArtUrl' && renderImagePreview(form[key] as string, 'About page scripture artwork preview')}
</>
)}
</div>
))}
{renderSaveStatus()}
</section>
)}
{/* CONTACT */}
{adminView === 'contact' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Contact</h2>
<p>Manage the contact page profile image and contact copy.</p>
</div>
{FIELDS.filter(f => f.section === 'contact').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`)}
</>
)}
</div>
))}
{renderSaveStatus()}
</section>
)}
{/* CURRENT SERIES */}
{adminView === 'current-series' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Current Series</h2>
<p>Update the active series card shown on the homepage and episodes page.</p>
</div>
{FIELDS.filter(f => f.section === 'series' && !['studyGuideTitle', 'studyGuideDescription', 'studyGuideUrl'].includes(f.key)).map(({ key, label, multiline }) => (
<div className="admin-field" key={key}>
<label htmlFor={`field-${key}`}>{label}</label>
{multiline ? (
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
) : (
<>
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
</>
)}
</div>
))}
<p className="admin-stats-note">Companion study guide title, description, and Amazon link are now managed in Downloads.</p>
{renderSaveStatus()}
</section>
)}
{/* EPISODE HIGHLIGHTS */}
{adminView === 'episode-highlights' && (
<section className="admin-panel-section" aria-label="Episode highlights">
<div className="admin-panel-head">
<h2>Episode Highlights</h2>
<p>Featured episodes shown on the Episodes page. Fill in discussion questions, show notes, or an embed URL and the highlight automatically 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>
)}
{(form.podcastFeaturedLinks ?? []).map(item => (
<details key={item.id} className="admin-collapsible-card">
<summary className="admin-collapsible-summary">
<div>
<strong>{item.title || 'Untitled episode highlight'}</strong>
<p>{item.episodeNumber ? `Episode ${item.episodeNumber}` : 'No episode number yet'}</p>
</div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`podcast-ep-${item.id}`}>Episode Number</label>
<input id={`podcast-ep-${item.id}`} type="text" placeholder="e.g. 42" value={item.episodeNumber ?? ''} onChange={e => updatePodcastLink(item.id, 'episodeNumber', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-title-${item.id}`}>Title</label>
<input id={`podcast-title-${item.id}`} type="text" value={item.title} onChange={e => updatePodcastLink(item.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-summary-${item.id}`}>Summary</label>
<textarea id={`podcast-summary-${item.id}`} rows={2} value={item.summary} onChange={e => updatePodcastLink(item.id, 'summary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-url-${item.id}`}>Platform URL (external link)</label>
<input id={`podcast-url-${item.id}`} type="text" placeholder="https://open.spotify.com/..." value={item.url} onChange={e => updatePodcastLink(item.id, 'url', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-embed-${item.id}`}>Embed URL (optional enables in-page player)</label>
<input id={`podcast-embed-${item.id}`} type="text" placeholder="https://open.spotify.com/embed/episode/..." value={item.embedUrl ?? ''} onChange={e => updatePodcastLink(item.id, 'embedUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-notes-${item.id}`}>Show Notes</label>
<textarea id={`podcast-notes-${item.id}`} rows={4} placeholder="Key points, scripture references, timestamps..." value={item.showNotes ?? ''} onChange={e => updatePodcastLink(item.id, 'showNotes', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-dq-${item.id}`}>Discussion Questions (one per line)</label>
<textarea id={`podcast-dq-${item.id}`} rows={5} placeholder="What stood out to you in this passage?" value={(item.discussionQuestions ?? []).join('\n')} onChange={e => updatePodcastLinkQuestions(item.id, e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removePodcastLink(item.id)}>Remove</button>
</div>
</div>
</details>
))}
<button type="button" className="btn-admin-add" onClick={addPodcastLink}>+ Add Episode Highlight</button>
{renderSaveStatus()}
</section>
)}
{/* ARCHIVED SERIES */}
{adminView === 'archived-series' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Archived Series</h2>
<p>Move finished studies here so users can still access old resources after you switch the current series.</p>
</div>
<div className="admin-archive-helper">
<h3>Archive Current Series</h3>
<p>Use this when you move from one study to the next. It creates a pre-filled archived entry from the current series, study guide, custom resource links, and custom content blocks.</p>
<button type="button" className="btn-admin-add" onClick={archiveCurrentSeriesSnapshot}>+ Archive Current Series Snapshot</button>
</div>
{(form.archivedSeries ?? []).length === 0 && (
<p className="admin-stats-note">No archived series yet.</p>
)}
{(form.archivedSeries ?? []).map(series => (
<div key={series.id} className="admin-archive-card">
<div className="admin-archive-card-head">
<div>
<h4>{series.title || 'Untitled archived series'}</h4>
<p>{series.label || 'Archived Study'}</p>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeries(series.id)}>Remove Series</button>
</div>
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`archive-label-${series.id}`}>Label</label>
<input id={`archive-label-${series.id}`} type="text" value={series.label} placeholder="Archived Study" onChange={e => updateArchivedSeries(series.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-title-${series.id}`}>Series Title</label>
<input id={`archive-title-${series.id}`} type="text" value={series.title} placeholder="Study of Titus: Sound Doctrine" onChange={e => updateArchivedSeries(series.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-description-${series.id}`}>Description</label>
<textarea id={`archive-description-${series.id}`} value={series.description} rows={4} placeholder="Describe the archived study and why it still matters." onChange={e => updateArchivedSeries(series.id, 'description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-image-${series.id}`}>Cover Image URL</label>
<input id={`archive-image-${series.id}`} type="text" value={series.imageUrl} placeholder="/images/titus-cover.png" onChange={e => updateArchivedSeries(series.id, 'imageUrl', e.target.value)} />
{renderImageAssetSelector(series.imageUrl, value => updateArchivedSeries(series.id, 'imageUrl', value), `archive-image-${series.id}-asset`)}
</div>
<div className="admin-field">
<label htmlFor={`archive-listen-${series.id}`}>Listen URL</label>
<input id={`archive-listen-${series.id}`} type="url" value={series.listenUrl} placeholder="https://..." onChange={e => updateArchivedSeries(series.id, 'listenUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-guide-title-${series.id}`}>Study Guide Title</label>
<input id={`archive-guide-title-${series.id}`} type="text" value={series.studyGuideTitle} placeholder="Companion Study Guide" onChange={e => updateArchivedSeries(series.id, 'studyGuideTitle', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-guide-description-${series.id}`}>Study Guide Description</label>
<textarea id={`archive-guide-description-${series.id}`} value={series.studyGuideDescription} rows={3} placeholder="Describe the archived guide or workbook." onChange={e => updateArchivedSeries(series.id, 'studyGuideDescription', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-guide-url-${series.id}`}>Study Guide URL</label>
<input id={`archive-guide-url-${series.id}`} type="url" value={series.studyGuideUrl} placeholder="https://..." onChange={e => updateArchivedSeries(series.id, 'studyGuideUrl', e.target.value)} />
</div>
<div className="admin-field">
<label>Episode Range (for archive grouping on Episodes page)</label>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input type="number" value={series.episodeRange?.from ?? ''} placeholder="First ep #" min={1} style={{ width: '7rem' }} onChange={e => { const from = parseInt(e.target.value, 10); updateArchivedSeries(series.id, 'episodeRange', { from: isNaN(from) ? 0 : from, to: series.episodeRange?.to ?? 0 }) }} />
<span style={{ color: 'var(--brand-gold)' }}>to</span>
<input type="number" value={series.episodeRange?.to ?? ''} placeholder="Last ep #" min={1} style={{ width: '7rem' }} onChange={e => { const to = parseInt(e.target.value, 10); updateArchivedSeries(series.id, 'episodeRange', { from: series.episodeRange?.from ?? 0, to: isNaN(to) ? 0 : to }) }} />
</div>
<p className="admin-stats-note" style={{ marginTop: '0.35rem' }}>Episodes in this range will be grouped under this series on the public Episodes page.</p>
</div>
</div>
<div className="admin-archive-subsection">
<div className="admin-archive-subsection-head">
<h5>Archived Resource Links</h5>
<div className="admin-archive-subsection-actions">
<select value={archiveLinkSelectionBySeries[series.id] ?? ''} onChange={e => setArchiveLinkSelectionBySeries(prev => ({ ...prev, [series.id]: e.target.value }))}>
<option value="">Pick existing custom link</option>
{(form.customLinks ?? []).filter(link => link.url.trim().length > 0).map(link => (
<option key={`pick-${series.id}-${link.id}`} value={link.id}>{link.label || link.url}</option>
))}
</select>
<button type="button" className="btn-admin-add" onClick={() => addExistingCustomLinkToArchivedSeries(series.id)} disabled={!archiveLinkSelectionBySeries[series.id]}>+ Add Picked Link</button>
<button type="button" className="btn-admin-add" onClick={() => addAllExistingCustomLinksToArchivedSeries(series.id)} disabled={(form.customLinks ?? []).filter(link => link.url.trim().length > 0).length === 0}>+ Add All Custom Links</button>
<button type="button" className="btn-admin-add" onClick={() => addArchivedSeriesLink(series.id)}>+ Add Blank Link</button>
</div>
</div>
{(series.resourceLinks ?? []).length === 0 && <p className="admin-stats-note">No archived resource links yet.</p>}
{(series.resourceLinks ?? []).map(link => (
<div key={link.id} className="admin-array-row admin-array-row--nested">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`archive-link-label-${link.id}`}>Label</label>
<input id={`archive-link-label-${link.id}`} type="text" value={link.label} placeholder="Episode guide" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-link-url-${link.id}`}>URL</label>
<input id={`archive-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'url', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-link-amazon-url-${link.id}`}>Amazon URL (optional)</label>
<input id={`archive-link-amazon-url-${link.id}`} type="url" value={link.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-link-amazon-label-${link.id}`}>Amazon Button Label</label>
<input id={`archive-link-amazon-label-${link.id}`} type="text" value={link.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonLabel', e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeriesLink(series.id, link.id)}>Remove</button>
</div>
))}
</div>
<div className="admin-archive-subsection">
<div className="admin-archive-subsection-head">
<h5>Archived Notes / Blocks</h5>
<button type="button" className="btn-admin-add" onClick={() => addArchivedSeriesNote(series.id)}>+ Add Note Block</button>
</div>
{(series.notes ?? []).length === 0 && <p className="admin-stats-note">No archived note blocks yet.</p>}
{(series.notes ?? []).map(note => (
<div key={note.id} className="admin-array-row admin-array-row--nested">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`archive-note-heading-${note.id}`}>Heading</label>
<input id={`archive-note-heading-${note.id}`} type="text" value={note.heading} placeholder="Titus overview" onChange={e => updateArchivedSeriesNote(series.id, note.id, 'heading', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-note-body-${note.id}`}>Body</label>
<textarea id={`archive-note-body-${note.id}`} value={note.body} rows={3} placeholder="Add archived notes, explanation, or links context." onChange={e => updateArchivedSeriesNote(series.id, note.id, 'body', e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeriesNote(series.id, note.id)}>Remove</button>
</div>
))}
</div>
</div>
))}
<button type="button" className="btn-admin-add" onClick={addArchivedSeries}>+ Add Archived Series</button>
{renderSaveStatus()}
</section>
)}
{/* DOWNLOADS */}
{adminView === 'downloads' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Downloads</h2>
<p>Manage the featured study guide, current download library, and previous series downloads.</p>
</div>
<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 className="admin-summary-card"><h3>Previous Study Downloads</h3><p>{archivedResourceCount}</p></div>
<div className="admin-summary-card"><h3>Total Library Items</h3><p>{resourceLinks.length + archivedResourceCount}</p></div>
</div>
<div className="admin-section-header">
<h3>Companion Study Guide</h3>
<p>This is the featured primary download at the top of the Downloads 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>
</div>
<div className="admin-section-header">
<h3>Download Library</h3>
<p>Manage the current downloads shown below the featured guide.</p>
</div>
{resourceLinks.length === 0 && <p className="admin-stats-note">No resources yet.</p>}
{resourceLinks.map(link => (
<details key={link.id} className="admin-collapsible-card">
<summary className="admin-collapsible-summary">
<div>
<strong>{link.label || 'Untitled download'}</strong>
<p>{link.description || 'Current download item'}</p>
</div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`resource-label-${link.id}`}>Label</label>
<input id={`resource-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={`resource-url-${link.id}`}>URL</label>
<input id={`resource-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={`resource-description-${link.id}`}>Description</label>
<textarea id={`resource-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={`resource-amazon-url-${link.id}`}>Amazon URL (optional)</label>
<input id={`resource-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={`resource-amazon-label-${link.id}`}>Amazon Button Label</label>
<input id={`resource-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={`resource-image-${link.id}`}>Image URL</label>
<input id={`resource-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), `resource-image-${link.id}-asset`)}
</div>
<div className="admin-field">
<label htmlFor={`resource-tags-${link.id}`}>Tags</label>
<input id={`resource-tags-${link.id}`} type="text" value={(link.tags ?? []).join(', ')} placeholder="sermon, bible study, faith" onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
</div>
</div>
</details>
))}
<button type="button" className="btn-admin-add" onClick={addResource}>+ Add Download</button>
<div className="admin-section-header">
<h3>Previous Study Downloads</h3>
<p>These downloads appear in the Previous Studies area of the download library.</p>
</div>
{(form.archivedSeries ?? []).length === 0 && (
<p className="admin-stats-note">No archived series yet. Add one in Archived Series, then manage its downloads here.</p>
)}
{(form.archivedSeries ?? []).map(series => (
<details key={series.id} className="admin-collapsible-card admin-collapsible-card--group">
<summary className="admin-collapsible-summary">
<div>
<strong>{series.title || 'Untitled archived series'}</strong>
<p>{(series.resourceLinks ?? []).length} download{(series.resourceLinks ?? []).length === 1 ? '' : 's'}</p>
</div>
<span className="admin-collapsible-hint">Expand to manage</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-archive-subsection">
<div className="admin-archive-subsection-head">
<h5>{series.title || 'Untitled archived series'}</h5>
<button type="button" className="btn-admin-add" onClick={() => addArchivedSeriesLink(series.id)}>+ Add Link</button>
</div>
{(series.resourceLinks ?? []).length === 0 && <p className="admin-stats-note">No archived resource links yet.</p>}
{(series.resourceLinks ?? []).map(link => (
<details key={link.id} className="admin-collapsible-card admin-collapsible-card--nested">
<summary className="admin-collapsible-summary">
<div>
<strong>{link.label || 'Untitled archived download'}</strong>
<p>{link.description || 'Previous study download'}</p>
</div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row admin-array-row--nested">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`resources-archive-link-label-${link.id}`}>Label</label>
<input id={`resources-archive-link-label-${link.id}`} type="text" value={link.label} placeholder="Episode guide" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resources-archive-link-url-${link.id}`}>URL</label>
<input id={`resources-archive-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'url', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resources-archive-link-description-${link.id}`}>Description</label>
<textarea id={`resources-archive-link-description-${link.id}`} rows={3} value={link.description ?? ''} placeholder="Description shown on the download page" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resources-archive-link-amazon-url-${link.id}`}>Amazon URL (optional)</label>
<input id={`resources-archive-link-amazon-url-${link.id}`} type="url" value={link.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resources-archive-link-amazon-label-${link.id}`}>Amazon Button Label</label>
<input id={`resources-archive-link-amazon-label-${link.id}`} type="text" value={link.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonLabel', e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeriesLink(series.id, link.id)}>Remove</button>
</div>
</div>
</details>
))}
</div>
</div>
</details>
))}
{renderSaveStatus()}
</section>
)}
{/* CUSTOM LINKS */}
{adminView === 'custom-links' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Custom Links</h2>
<p>Add links to show in platform buttons, footer navigation, or the More Resources download library.</p>
</div>
<div className="admin-panel-subhead">
<h3>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').length === 0 && (
<p className="admin-stats-note">No custom 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={`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`)}
</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>
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
</div>
))}
<button type="button" className="btn-admin-add" onClick={addLink}>+ Add Link</button>
<div className="admin-panel-subhead">
<h3>More Resources Links</h3>
<p>These create cards in Downloads 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`)}
</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-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>
<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">Downloads</option>
<option value="about">About</option>
<option value="contact">Contact</option>
<option value="questions">Q&amp;A</option>
</select>
</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>
<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>
)}
{/* STUDIES */}
{adminView === 'colossians-study' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Studies</h2>
<p>Manage multiple study tracks and their lesson sections (text, commentary, Greek notes, audio, and questions).</p>
</div>
{(form.studies ?? []).length === 0 && <p className="admin-stats-note">No studies yet.</p>}
{(form.studies ?? []).map(study => (
<details key={study.id} className="admin-collapsible-card" open={false}>
<summary className="admin-collapsible-summary">
<div>
<strong>{study.title || 'Untitled study'} · /study/{study.slug || 'slug'}</strong>
<p>{study.description || 'Study description'}</p>
</div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row" style={{ marginBottom: '1rem' }}>
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`study-title-${study.id}`}>Study Title</label>
<input id={`study-title-${study.id}`} type="text" value={study.title} placeholder="Colossians: Rooted in Christ" onChange={e => updateStudyProgram(study.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-slug-${study.id}`}>Slug</label>
<input id={`study-slug-${study.id}`} type="text" value={study.slug} placeholder="colossians" onChange={e => updateStudyProgram(study.id, 'slug', e.target.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'))} />
</div>
<div className="admin-field">
<label htmlFor={`study-status-${study.id}`}>Status</label>
<select id={`study-status-${study.id}`} value={study.status} onChange={e => updateStudyProgram(study.id, 'status', e.target.value)}>
<option value="active">Active</option>
<option value="planned">Planned</option>
</select>
</div>
<div className="admin-field">
<label htmlFor={`study-description-${study.id}`}>Description</label>
<textarea id={`study-description-${study.id}`} rows={3} value={study.description} placeholder="Brief description for the study hub card" onChange={e => updateStudyProgram(study.id, 'description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-home-eyebrow-${study.id}`}>Homepage Card Eyebrow</label>
<input id={`study-home-eyebrow-${study.id}`} type="text" value={study.homepageEyebrow ?? ''} placeholder="New Study" onChange={e => updateStudyProgram(study.id, 'homepageEyebrow', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-new-tag-label-${study.id}`}>NEW Tag Label</label>
<input id={`study-new-tag-label-${study.id}`} type="text" value={study.newTagLabel ?? 'NEW'} placeholder="NEW" onChange={e => updateStudyProgram(study.id, 'newTagLabel', e.target.value)} />
</div>
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<input id={`study-home-feature-${study.id}`} type="checkbox" checked={study.showOnHomepage === true} onChange={e => updateStudyProgram(study.id, 'showOnHomepage', e.target.checked)} />
<label htmlFor={`study-home-feature-${study.id}`}>Feature this study card on homepage</label>
</div>
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<input id={`study-show-new-tag-${study.id}`} type="checkbox" checked={study.showNewTag === true} onChange={e => updateStudyProgram(study.id, 'showNewTag', e.target.checked)} />
<label htmlFor={`study-show-new-tag-${study.id}`}>Show NEW tag on homepage card</label>
</div>
</div>
<div className="admin-field">
<label htmlFor={`study-chapters-${study.id}`}>Number of Chapters</label>
<input id={`study-chapters-${study.id}`} type="number" min="1" max="999" value={study.numberOfChapters} placeholder="4" onChange={e => updateStudyProgram(study.id, "numberOfChapters", Number(e.target.value) || 1)} />
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeStudyProgram(study.id)}>Remove Study</button>
</div>
{(study.sections ?? []).length === 0 && (
<p className="admin-stats-note">No sections yet for this study.</p>
)}
{(study.sections ?? []).map(section => (
<details key={section.id} className="admin-collapsible-card" open={false}>
<summary className="admin-collapsible-summary">
<div>
<strong>Lesson {section.reference || section.id}</strong>
<p>{section.title || 'Untitled section'}</p>
</div>
<span className="admin-collapsible-hint">Expand to edit lesson</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`study-section-chapter-${study.id}-${section.id}`}>Chapter</label>
<select id={`study-section-chapter-${study.id}-${section.id}`} value={section.chapter} onChange={e => updateStudySection(study.id, section.id, 'chapter', Number(e.target.value) || 1)}>
{Array.from({ length: Math.max(study.numberOfChapters, section.chapter, 1) }, (_, index) => index + 1).map(chapterNumber => (
<option key={chapterNumber} value={chapterNumber}>Chapter {chapterNumber}</option>
))}
</select>
</div>
<div className="admin-field">
<label htmlFor={`study-section-reference-${study.id}-${section.id}`}>Passage Reference</label>
<input id={`study-section-reference-${study.id}-${section.id}`} type="text" value={section.reference} placeholder="1:1-2" onChange={e => updateStudySection(study.id, section.id, 'reference', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-title-${study.id}-${section.id}`}>Section Title</label>
<input id={`study-section-title-${study.id}-${section.id}`} type="text" value={section.title} placeholder="Paul's Greeting" onChange={e => updateStudySection(study.id, section.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-audio-${study.id}-${section.id}`}>Spotify Embed URL (optional)</label>
<input id={`study-section-audio-${study.id}-${section.id}`} type="url" value={section.audioEmbedUrl ?? ''} placeholder="https://open.spotify.com/embed/episode/..." onChange={e => updateStudySection(study.id, section.id, 'audioEmbedUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-released-${study.id}-${section.id}`}>Release Date (optional)</label>
<input id={`study-section-released-${study.id}-${section.id}`} type="datetime-local" value={section.releasedAt ? new Date(section.releasedAt).toISOString().slice(0, 16) : ''} onChange={e => {
const val = e.target.value;
if (val) {
const date = new Date(val + ':00Z');
updateStudySection(study.id, section.id, 'releasedAt', date.toISOString());
} else {
updateStudySection(study.id, section.id, 'releasedAt', '');
}
}} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-summary-${study.id}-${section.id}`}>Short Summary</label>
<textarea id={`study-section-summary-${study.id}-${section.id}`} rows={3} value={section.summary} placeholder="One to two sentences that summarize the section." onChange={e => updateStudySection(study.id, section.id, 'summary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-passage-${study.id}-${section.id}`}>Passage Text</label>
<textarea id={`study-section-passage-${study.id}-${section.id}`} rows={4} value={section.passageText} placeholder="Paste the passage text here." onChange={e => updateStudySection(study.id, section.id, 'passageText', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-commentary-${study.id}-${section.id}`}>Commentary</label>
<textarea id={`study-section-commentary-${study.id}-${section.id}`} rows={5} value={section.commentary} placeholder="Add your teaching notes and explanation here." onChange={e => updateStudySection(study.id, section.id, 'commentary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-greek-${study.id}-${section.id}`}>Greek Words</label>
<textarea id={`study-section-greek-${study.id}-${section.id}`} rows={4} value={(section.greekNotes ?? []).join('\n')} placeholder="One note per line" onChange={e => updateStudySection(study.id, section.id, 'greekNotes', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-questions-${study.id}-${section.id}`}>Study Questions</label>
<textarea id={`study-section-questions-${study.id}-${section.id}`} rows={5} value={(section.studyQuestions ?? []).join('\n')} placeholder="One question per line" onChange={e => updateStudySection(study.id, section.id, 'studyQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeStudySection(study.id, section.id)}>Remove Lesson</button>
</div>
</div>
</details>
))}
<button type="button" className="btn-admin-add" onClick={() => addStudySection(study.id)}>+ Add Lesson Section</button>
</div>
</details>
))}
<button type="button" className="btn-admin-add" onClick={addStudyProgram}>+ Add Study Program</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>
<div className="admin-panel-subhead"><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)} />}
</div>
))}
<div className="admin-panel-subhead"><h3>Header</h3></div>
{FIELDS.filter(f => f.section === 'global' && f.key === 'headerFollowLabel').map(({ key, label }) => (
<div className="admin-field" key={key}>
<label htmlFor={`field-${key}`}>{label}</label>
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
</div>
))}
<div className="admin-panel-subhead"><h3>Platform URLs</h3></div>
{FIELDS.filter(f => f.section === 'global' && f.key.startsWith('platform')).map(({ key, label }) => (
<div className="admin-field" key={key}>
<label htmlFor={`field-${key}`}>{label}</label>
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
</div>
))}
<div className="admin-panel-subhead"><h3>Analytics Banner</h3></div>
{FIELDS.filter(f => f.section === 'global' && f.key === 'cookieBannerText').map(({ key, label }) => (
<div className="admin-field" key={key}>
<label htmlFor={`field-${key}`}>{label}</label>
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={3} />
</div>
))}
<div className="admin-panel-subhead"><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>
)}
{/* 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-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>
)}
{/* ANALYTICS */}
{adminView === 'analytics' && (
<AnalyticsPanel
stats={stats}
statsStatus={statsStatus}
opsStatus={opsStatus}
formatDate={formatDate}
maskIp={maskIp}
maintenanceMsg={maintenanceMsg}
selectedBackup={selectedBackup}
backupFiles={backupFiles}
selectedBackupPreview={selectedBackupPreview}
onBackupSelect={setSelectedBackup}
onRestore={handleRestoreBackup}
onExport={handleExport}
onBackupNow={handleBackupNow}
onPrune={handlePrune}
onClear={handleClear}
onPurgeCache={handlePurgeCache}
onDeployHook={handleDeployHook}
onRefreshStatus={() => { void reloadOpsStatus() }}
/>
)}
{/* EMAILS */}
{adminView === 'emails' && (
<section className="admin-panel-section" aria-label="Email center">
<div className="admin-panel-head">
<h2>Email Center</h2>
<p>Manage inbound contact emails, archive threads, reply with templates, and review sent history.</p>
</div>
{contactReplyConfig && (
<div className="admin-restore-preview" style={{ marginBottom: '0.9rem' }}>
<h3>Sender Status</h3>
<p><strong>From:</strong> {contactReplyConfig.fromIdentity}</p>
<p><strong>Resend Configured:</strong> {contactReplyConfig.resendApiConfigured ? 'Yes' : 'No'}</p>
<p><strong>Can Send Replies:</strong> {contactReplyConfig.canSendReplies ? 'Yes' : 'No'}</p>
<p>{contactReplyConfig.note}</p>
</div>
)}
<div className="admin-actions admin-actions--maintenance" style={{ marginBottom: '0.75rem' }}>
<button type="button" className={`btn-admin-reset${emailMailboxView === 'inbox' ? ' btn-admin-reset--active' : ''}`} onClick={() => setEmailMailboxView('inbox')}>Inbox</button>
<button type="button" className={`btn-admin-reset${emailMailboxView === 'archived' ? ' btn-admin-reset--active' : ''}`} onClick={() => setEmailMailboxView('archived')}>Archived</button>
</div>
{contactStatus === 'loading' && <p className="admin-stats-note">Loading emails</p>}
{contactStatus === 'error' && <p className="admin-stats-note">Could not load contact submissions.</p>}
{contactStatus === 'ready' && (
<div className="admin-email-layout">
<aside className="admin-email-list">
{contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true)).length === 0 && (
<p className="admin-stats-note">No messages in this mailbox.</p>
)}
{contactSubmissions
.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
.map(item => (
<button
key={item.id}
type="button"
className={`admin-email-list-item${selectedEmailId === item.id ? ' admin-email-list-item--active' : ''}`}
onClick={() => setSelectedEmailId(item.id)}
>
<div className="admin-email-list-head">
<strong>{item.name}</strong>
<span>{formatDate(item.submittedAt)}</span>
</div>
<p>{item.message}</p>
</button>
))}
</aside>
<div className="admin-email-detail">
{(() => {
const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
const selected = visible.find(item => item.id === selectedEmailId) ?? null
if (!selected) return <p className="admin-stats-note">Select an email to view details.</p>
return (
<>
<div className="admin-email-meta">
<p><strong>From:</strong> {selected.name} &lt;{selected.email}&gt;</p>
<p><strong>Type:</strong> {selected.messageType}</p>
<p><strong>Subscribed:</strong> {selected.subscribe ? 'Yes' : 'No'}</p>
<p><strong>Received:</strong> {formatDate(selected.submittedAt)}</p>
</div>
<div className="admin-email-body">
<p>{selected.message}</p>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-save admin-email-action-btn" onClick={() => openContactReplyComposer(selected)}>Reply</button>
<button
type="button"
className="btn-admin-reset admin-email-action-btn"
onClick={() => handleArchiveContactSubmission(selected.id, !(selected.archived === true))}
>
{selected.archived === true ? 'Move to Inbox' : 'Archive'}
</button>
<button type="button" className="btn-admin-remove admin-email-action-btn" onClick={() => handleDeleteContactSubmission(selected.id)}>Delete</button>
</div>
</>
)
})()}
</div>
</div>
)}
{contactReplyDraft && (
<div className="admin-array-row" style={{ marginTop: '1rem' }}>
<div className="admin-array-fields">
{contactReplyTemplates.length > 0 && (
<div className="admin-field">
<label htmlFor="reply-template">Saved Template</label>
<select id="reply-template" defaultValue="" onChange={e => applyContactReplyTemplate(e.target.value)}>
<option value="">Choose a template</option>
{contactReplyTemplates.map(template => (
<option key={template.id} value={template.id}>{template.label}</option>
))}
</select>
</div>
)}
<div className="admin-field">
<label htmlFor="reply-to">To</label>
<input id="reply-to" type="text" value={`${contactReplyDraft.recipientName} <${contactReplyDraft.recipientEmail}>`} readOnly />
</div>
<div className="admin-field">
<label htmlFor="reply-from">From</label>
<input id="reply-from" type="text" value="hello@versebyversewithnate.us" readOnly />
</div>
<div className="admin-field">
<label htmlFor="reply-subject">Subject</label>
<input
id="reply-subject"
type="text"
value={contactReplyDraft.subject}
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, subject: e.target.value } : draft)}
/>
</div>
<div className="admin-field">
<label htmlFor="reply-message">Message</label>
<textarea
id="reply-message"
rows={8}
value={contactReplyDraft.message}
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, message: e.target.value } : draft)}
/>
<p className="admin-stats-note">This will be wrapped in a professional HTML email template automatically.</p>
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button type="button" className="btn-admin-save" onClick={handleSendContactReply} disabled={contactReplyStatus === 'sending'}>
{contactReplyStatus === 'sending' ? 'Sending…' : 'Send Reply'}
</button>
<button
type="button"
className="btn-admin-reset"
onClick={() => {
setContactReplyDraft(null)
setContactReplyStatus('idle')
setContactReplyMsg('')
}}
>
Cancel
</button>
</div>
</div>
)}
{contactReplyMsg && <p className="admin-stats-note">{contactReplyMsg}</p>}
<details className="admin-collapsible-card">
<summary className="admin-collapsible-summary">
<div>
<strong>Saved Reply Templates</strong>
<p>{contactReplyTemplates.length} template{contactReplyTemplates.length === 1 ? '' : 's'} configured</p>
</div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
{contactReplyTemplates.length === 0 && <p className="admin-stats-note">No saved templates yet.</p>}
{contactReplyTemplates.map(template => (
<details key={template.id} className="admin-collapsible-card admin-collapsible-card--nested">
<summary className="admin-collapsible-summary">
<div>
<strong>{template.label || 'Untitled template'}</strong>
<p>{template.subject || 'No subject set'}</p>
</div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row admin-array-row--nested">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`reply-template-label-${template.id}`}>Label</label>
<input id={`reply-template-label-${template.id}`} type="text" value={template.label} onChange={e => updateContactReplyTemplate(template.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`reply-template-subject-${template.id}`}>Subject</label>
<input id={`reply-template-subject-${template.id}`} type="text" value={template.subject} onChange={e => updateContactReplyTemplate(template.id, 'subject', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`reply-template-message-${template.id}`}>Message</label>
<textarea id={`reply-template-message-${template.id}`} rows={5} value={template.message} onChange={e => updateContactReplyTemplate(template.id, 'message', e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeContactReplyTemplate(template.id)}>Remove</button>
</div>
</div>
</details>
))}
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={addContactReplyTemplate}>Add Template</button>
<button type="button" className="btn-admin-save" onClick={handleSaveContactReplyTemplates} disabled={contactTemplateStatus === 'saving'}>
{contactTemplateStatus === 'saving' ? 'Saving…' : 'Save Templates'}
</button>
</div>
</div>
</details>
<div className="admin-visits-table-wrap">
<h3>Reply History</h3>
{contactReplyHistory.length === 0 ? <p className="admin-stats-note">No admin replies have been sent yet.</p> : (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table">
<thead>
<tr><th>Sent</th><th>To</th><th>From</th><th>Subject</th><th>Preview</th></tr>
</thead>
<tbody>
{contactReplyHistory.map(item => (
<tr key={item.id}>
<td>{formatDate(item.sentAt)}</td>
<td>{item.toName} ({item.toEmail})</td>
<td>{item.fromEmail}</td>
<td>{item.subject}</td>
<td>{item.preview}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
)}
{/* ASSETS */}
{adminView === 'assets' && (
<section className="admin-panel-section" aria-label="Asset manager">
<div className="admin-panel-head">
<h2>Asset Manager</h2>
<p>Upload, tag, and reuse hosted images from this domain.</p>
</div>
<div className="admin-actions admin-actions--maintenance">
<label className="btn-admin-reset" style={{ cursor: 'pointer' }}>
{assetUploadPending ? 'Uploading…' : 'Upload Image or PDF'}
<input type="file" accept="image/*,.pdf" style={{ display: 'none' }} onChange={handleAssetUpload} disabled={assetUploadPending} />
</label>
</div>
{assets.length === 0 && <p className="admin-stats-note">No assets uploaded yet.</p>}
{assets.length > 0 && (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table admin-assets-table">
<thead>
<tr>
<th>Preview</th>
<th>Filename</th>
<th>Size</th>
<th>Updated</th>
<th>Tags</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{assets.map(asset => (
<tr key={asset.filename}>
<td>
{isImageAsset(asset.filename)
? <img src={asset.url} alt={asset.filename} className="admin-asset-thumb" style={{ width: '72px', height: '52px', objectFit: 'cover' }} />
: <span>PDF/File</span>}
</td>
<td>{asset.filename}</td>
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
<td>{formatDate(asset.updatedAt)}</td>
<td>
<input
type="text"
className="admin-asset-tags-input"
placeholder="Add tags…"
value={assetTagEdits[asset.filename] ?? (asset.tags ?? []).join(', ')}
onChange={e => setAssetTagEdits(t => ({ ...t, [asset.filename]: e.target.value }))}
onBlur={() => handleSaveAssetTags(asset.filename)}
/>
</td>
<td>
<div className="admin-asset-actions-inline">
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => { navigator.clipboard.writeText(asset.url).catch(() => {}) }}>Copy URL</button>
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
</section>
)}
{/* 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>
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field"><label htmlFor="seo-title">SEO Title</label><input id="seo-title" type="text" value={form.seo?.title ?? ''} onChange={e => updateSeoField('title', e.target.value)} /></div>
<div className="admin-field"><label htmlFor="seo-description">SEO Description</label><textarea id="seo-description" rows={3} value={form.seo?.description ?? ''} onChange={e => updateSeoField('description', e.target.value)} /></div>
<div className="admin-field"><label htmlFor="seo-og-title">Open Graph Title</label><input id="seo-og-title" type="text" value={form.seo?.ogTitle ?? ''} onChange={e => updateSeoField('ogTitle', e.target.value)} /></div>
<div className="admin-field"><label htmlFor="seo-og-description">Open Graph Description</label><textarea id="seo-og-description" rows={3} value={form.seo?.ogDescription ?? ''} onChange={e => updateSeoField('ogDescription', e.target.value)} /></div>
<div className="admin-field">
<label htmlFor="seo-og-image">Open Graph Image URL</label>
<input id="seo-og-image" type="text" value={form.seo?.ogImage ?? ''} onChange={e => updateSeoField('ogImage', e.target.value)} />
{renderImageAssetSelector(form.seo?.ogImage, value => updateSeoField('ogImage', value), 'seo-og-image-asset')}
</div>
<div className="admin-field"><label htmlFor="seo-canonical">Canonical URL</label><input id="seo-canonical" type="text" value={form.seo?.canonicalUrl ?? ''} onChange={e => updateSeoField('canonicalUrl', e.target.value)} /></div>
<div className="admin-field"><label htmlFor="seo-robots">Robots Policy</label><input id="seo-robots" type="text" value={form.seo?.robotsPolicy ?? ''} onChange={e => updateSeoField('robotsPolicy', e.target.value)} /></div>
<div className="admin-field">
<label htmlFor="seo-sitemap">Sitemap Paths (one per line)</label>
<textarea id="seo-sitemap" rows={4} value={(form.seo?.sitemapPaths ?? []).join('\n')} onChange={e => updateSeoField('sitemapPaths', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))} />
</div>
</div>
</div>
<div className="admin-section-header">
<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>
<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 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>
)}
</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>
)
}