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 } 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 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(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('dashboard') const [stats, setStats] = useState(null) const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [maintenanceMsg, setMaintenanceMsg] = useState('') const [opsMsg, setOpsMsg] = useState('') const [backupFiles, setBackupFiles] = useState([]) const [selectedBackup, setSelectedBackup] = useState('') const [selectedBackupPreview, setSelectedBackupPreview] = useState(null) // TOTP management state const [totpEnabled, setTotpEnabled] = useState(null) const [totpSetupQr, setTotpSetupQr] = useState(null) const [totpSetupSecret, setTotpSetupSecret] = useState(null) const [totpConfirmCode, setTotpConfirmCode] = useState('') const [totpMsg, setTotpMsg] = useState('') const [totpRecoveryCodes, setTotpRecoveryCodes] = useState(null) const [publishState, setPublishState] = useState({ draftUpdatedAt: null, publishedAt: null }) const [assets, setAssets] = useState([]) const [assetTagEdits, setAssetTagEdits] = useState>({}) const [opsStatus, setOpsStatus] = useState(null) const [assetUploadPending, setAssetUploadPending] = useState(false) const [questions, setQuestions] = useState([]) const [contactSubmissions, setContactSubmissions] = useState([]) const [contactStatus, setContactStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [contactReplyDraft, setContactReplyDraft] = useState(null) const [contactReplyStatus, setContactReplyStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle') const [contactReplyMsg, setContactReplyMsg] = useState('') const [contactReplyTemplates, setContactReplyTemplates] = useState([]) const [contactReplyHistory, setContactReplyHistory] = useState([]) const [contactReplyConfig, setContactReplyConfig] = useState(null) const [contactTemplateStatus, setContactTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [emailMailboxView, setEmailMailboxView] = useState<'inbox' | 'archived'>('inbox') const [selectedEmailId, setSelectedEmailId] = useState(null) const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({}) const [editingQuestionId, setEditingQuestionId] = useState(null) const [questionSearch, setQuestionSearch] = useState('') const [questionFilter, setQuestionFilter] = useState<'all' | 'pending' | 'approved' | 'answered' | 'unanswered'>('all') const [questionPage, setQuestionPage] = useState(0) const [selectedQuestionIds, setSelectedQuestionIds] = useState>(new Set()) const [mobileNavOpen, setMobileNavOpen] = useState(false) const [subscribers, setSubscribers] = useState([]) const [subscriberSearch, setSubscriberSearch] = useState('') const [contactSearch, setContactSearch] = useState('') const [downloadStats, setDownloadStats] = useState>({}) 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(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 }).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 } 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>((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 } 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) { const file = event.target.files?.[0] if (!file) return setAssetUploadPending(true) setOpsMsg('') try { const dataUrl = await new Promise((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 (
) } function renderImagePreview(value: string | null | undefined, alt: string) { const src = value?.trim() ?? '' if (!src) return null return (
Preview {alt}
) } 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 (
) } 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, 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' &&

✓ Changes saved.

} {status === 'error' &&

✗ {errorMsg}

} ) } return (
{/* ── Top bar ── */}
Site Admin Verse by Verse with Nate
{isDirty && ● Unsaved} Draft: {formatDate(publishState.draftUpdatedAt)} Published: {formatDate(publishState.publishedAt)}
{/* ── Sidebar ── */} {mobileNavOpen && }
0 ? ' admin-dashboard-card--alert' : ''}`}>
{unreadEmailCount}
Unread Emails
{contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total
{unreadEmailCount > 0 && }
{thisWeekReal.toLocaleString()}
Real Visits (7 Days)
{thisWeekHits.toLocaleString()} total · {stats?.visitors?.uniqueVisitors?.toLocaleString() ?? '—'} unique all time
{subscribers.length}
Email Subscribers
{contactSubmissions.length} total contact submissions
{downloadStats['titus-study'] ?? 0}
Titus Study Downloads
{Object.values(downloadStats).reduce((a, b) => a + b, 0)} total resource downloads
{publishState?.publishedAt && (
{formatDate(publishState.publishedAt)}
Last Published
{publishState.draftUpdatedAt &&
Draft updated {formatDate(publishState.draftUpdatedAt)}
}
)}

Recent Contacts

{recentContacts.length === 0 ?

No contacts yet.

: (
{recentContacts.map(c => (
{c.name} {formatDate(c.submittedAt)}
{c.email}
{c.message &&
{c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}
}
))}
) }

Recent Questions

{recentQuestions.length === 0 ?

No questions yet.

: (
{recentQuestions.map(q => (
{q.firstName} {q.isApproved ? 'Approved' : 'Pending'} {formatDate(q.submittedAt ?? '')}
{q.question.slice(0, 100)}{q.question.length > 100 ? '…' : ''}
))}
) }
) })()} {/* CONTACTS */} {adminView === 'contacts' && (() => { const searchTerm = contactSearch.trim().toLowerCase() const grouped = new Map() 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 (

Contacts

{rolledUp.length} contacts from {contactSubmissions.length} total submissions — repeat senders are grouped together.

setContactSearch(e.target.value)} style={{ minWidth: '260px', maxWidth: '440px', width: '100%' }} /> {rolledUp.length} result{rolledUp.length !== 1 ? 's' : ''}
{rolledUp.length === 0 ?

No contacts{contactSearch ? ' match your search' : ' yet'}.

: (
{rolledUp.map(c => ( ))}
Name Email Type Subscriber Date Message
{c.name}
{c.submissionCount > 1 &&
{c.submissionCount} submissions
}
{c.email} {c.messageType ?? 'contact'} {c.subscribe ? '✓' : ''} {formatDate(c.submittedAt)} {c.message ?? '—'}
) }
) })()} {/* SUBSCRIBERS */} {adminView === 'subscribers' && (() => { const filteredSubs = subscribers.filter(s => !subscriberSearch.trim() || s.name.toLowerCase().includes(subscriberSearch.toLowerCase()) || s.email.toLowerCase().includes(subscriberSearch.toLowerCase()) ) return (

Subscribers

{subscribers.length} people have opted in to email updates.

setSubscriberSearch(e.target.value)} style={{ minWidth: '240px', maxWidth: '400px', width: '100%' }} />
{filteredSubs.length === 0 ? (

No subscribers{subscriberSearch ? ' match your search' : ' yet'}.

) : (
{filteredSubs.map((sub, i) => ( ))}
Name Email Source Subscribed
{sub.name} {sub.email} {sub.source === 'download' ? 'Download' : 'Contact Form'} {formatDate(sub.subscribedAt)}
)}
) })()} {/* HOMEPAGE */} {adminView === 'homepage' && (

Homepage

Controls the hero, button labels, share section, and "Where to Next" navigation cards.

{FIELDS.filter(f => f.section === 'hero' || f.section === 'share').map(({ key, label, multiline }) => (
{multiline ?