import { useEffect, useState } from 'react' import { Link } from 'react-router-dom' import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote } from './App' import { DEFAULTS } from './App' 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 } interface AdminStats { totalHits: number firstHitAt: string | null lastHitAt: string | null topPaths: Array<{ path: string; hits: number }> last7Days: Array<{ day: string; hits: number }> last30DaysTotal: 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 Question { id: string submittedAt: string firstName: string email: string question: string answer: string answeredAt: string | null isApproved: boolean approvedAt: string | null } type StringField = Exclude const FIELDS: { key: StringField; label: string; multiline?: boolean }[] = [ { key: 'eyebrow', label: 'Hero Eyebrow Text' }, { key: 'heroTagline', label: 'Hero Tagline' }, { key: 'aboutShowHeading', label: 'About Show — Heading' }, { key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true }, { key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true }, { key: 'aboutNate', label: 'About Nate', multiline: true }, { key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")' }, { key: 'seriesTitle', label: 'Series Title' }, { key: 'seriesDescription', label: 'Series Description', multiline: true }, { key: 'seriesImageUrl', label: 'Series Cover Image URL' }, { key: 'seriesListenUrl', label: 'Series Listen URL' }, { key: 'studyGuideTitle', label: 'Study Guide Title' }, { key: 'studyGuideDescription', label: 'Study Guide Description', multiline: true }, { key: 'studyGuideUrl', label: 'Study Guide URL (Amazon link)' }, { key: 'shareHeading', label: 'Share Section — Heading' }, { key: 'shareP', label: 'Share Section — Paragraph', multiline: true }, ] export default function AdminPage({ content, onSave, onLogout }: Props) { const [form, setForm] = useState(content) const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [errorMsg, setErrorMsg] = useState('') const [adminTab, setAdminTab] = useState<'content' | 'stats' | 'questions'>('content') const [contentTab, setContentTab] = useState<'main' | 'custom'>('main') const [stats, setStats] = useState(null) const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [maintenanceMsg, setMaintenanceMsg] = useState('') const [backupFiles, setBackupFiles] = useState([]) const [selectedBackup, setSelectedBackup] = useState('') const [selectedBackupPreview, setSelectedBackupPreview] = useState(null) const [questions, setQuestions] = useState([]) const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({}) const [editingQuestionId, setEditingQuestionId] = useState(null) const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({}) 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-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(() => {}) }, []) 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 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 = { ...DEFAULTS, ...data.siteContent } setForm(next) onSave(next) } } 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 addLink() { setForm(f => ({ ...f, customLinks: [ ...(f.customLinks ?? []), { id: Date.now().toString(36), label: '', url: '', placement: 'platforms' as const }, ], })) } function updateLink(id: string, field: keyof CustomLink, value: 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 addBlock() { setForm(f => ({ ...f, customBlocks: [ ...(f.customBlocks ?? []), { id: Date.now().toString(36), heading: '', body: '' }, ], })) } 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[]) { 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: '', url: '' }, ], } : 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, url: source.url, }, ], } }), })) } 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, url: link.url, })) 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 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 ?? [])], })) setContentTab('custom') } async function handleSave() { setStatus('saving') setErrorMsg('') try { const res = await fetch('/api/admin-content', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ siteContent: form }), }) if (!res.ok) { const data = await res.json().catch(() => ({})) throw new Error((data as { message?: string }).message ?? 'Save failed') } onSave(form) setStatus('saved') setTimeout(() => setStatus('idle'), 3500) } catch (err) { setErrorMsg(err instanceof Error ? err.message : 'Unknown error') setStatus('error') } } function handleReset() { if (confirm('Reset all fields to defaults?')) { setForm(DEFAULTS) setStatus('idle') } } 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)) } catch { alert('Failed to delete question') } } return (
✦   ✦   ✦

Site Admin

Verse by Verse with Nate

← Back to site
{adminTab === 'stats' && (

Site Hit Stats

Built-in page traffic and visitor intelligence from this server.

{statsStatus === 'loading' &&

Loading stats...

} {statsStatus === 'error' &&

Could not load stats right now.

} {statsStatus === 'ready' && stats && ( <>

Total Hits

{stats.totalHits.toLocaleString()}

Last 30 Days

{stats.last30DaysTotal.toLocaleString()}

First Hit

{formatDate(stats.firstHitAt)}

Latest Hit

{formatDate(stats.lastHitAt)}

Top Paths

{stats.topPaths.length === 0 ? (

No hits tracked yet.

) : (
    {stats.topPaths.map(item => (
  • {item.path} {item.hits.toLocaleString()}
  • ))}
)}

Daily Hits (7 Days)

    {stats.last7Days.map(item => (
  • {item.day} {item.hits.toLocaleString()}
  • ))}

Visitor Details

IP, geography, and returning visitor behavior.

Privacy: visitor analytics only run after cookie consent. IPs below are masked.

Total Visits

{stats.visitors.totalVisits.toLocaleString()}

Unique Visitors

{stats.visitors.uniqueVisitors.toLocaleString()}

Returning Visits

{stats.visitors.returningVisits.toLocaleString()}

Returning Rate

{stats.visitors.totalVisits > 0 ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` : '0%'}

Top Countries

    {stats.visitors.topCountries.map(item => (
  • {item.name} {item.hits.toLocaleString()}
  • ))}

Top States

    {stats.visitors.topStates.map(item => (
  • {item.name} {item.hits.toLocaleString()}
  • ))}

Top Counties

    {stats.visitors.topCounties.map(item => (
  • {item.name} {item.hits.toLocaleString()}
  • ))}

Top Cities

    {stats.visitors.topCities.map(item => (
  • {item.name} {item.hits.toLocaleString()}
  • ))}

Recent Visitor Log

{stats.visitors.recentVisits.length === 0 ? (

No visitor records yet.

) : (
{stats.visitors.recentVisits.map(row => ( ))}
Time IP Country State County City Path Returning Visit #
{formatDate(row.at)} {maskIp(row.ip)} {row.country} {row.state} {row.county} {row.city} {row.path} {row.returningVisitor ? 'Yes' : 'No'} {row.visitCount}
)}

Contact Summary

Submission totals from the contact form.

Total Contact Messages

{stats.contactTotals.totalSubmissions.toLocaleString()}

Total Bible Questions

{stats.contactTotals.totalQuestions.toLocaleString()}

Data Management

Export, backup, or retain only recent analytics data.

Hit Stats Write

{stats.writeStatus.hitStats.ok ? 'Healthy' : 'Error'}

{formatDate(stats.writeStatus.hitStats.at)}

Visitor Stats Write

{stats.writeStatus.visitorStats.ok ? 'Healthy' : 'Error'}

{formatDate(stats.writeStatus.visitorStats.at)}

Backup Status

{stats.writeStatus.backups.ok ? 'Healthy' : 'Error'}

{formatDate(stats.writeStatus.backups.at)}

Latest Backup File

{stats.writeStatus.backups.file ?? 'Not available yet'}

{selectedBackupPreview && (

Restore Preview

Backup: {selectedBackupPreview.filename}

Created: {formatDate(selectedBackupPreview.createdAt)}

Reason: {selectedBackupPreview.reason}

Size: {(selectedBackupPreview.sizeBytes / 1024).toFixed(1)} KB

Content Updated At: {formatDate(selectedBackupPreview.adminUpdatedAt)}

Total Hits: {selectedBackupPreview.totalHits.toLocaleString()}

Total Visits: {selectedBackupPreview.totalVisits.toLocaleString()}

)} {maintenanceMsg &&

{maintenanceMsg}

} )}
)} {adminTab === 'content' && (
{ e.preventDefault(); handleSave() }} >
{contentTab === 'main' && ( <>

Archive Current Series

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.

{FIELDS.map(({ key, label, multiline }) => (
{multiline ? (