2404 lines
124 KiB
TypeScript
2404 lines
124 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, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
|
|
import { DEFAULTS } from './content'
|
|
|
|
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
|
|
}
|
|
|
|
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 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
|
|
}
|
|
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards'>
|
|
|
|
type AdminView =
|
|
| 'homepage' | 'start-here' | 'about' | 'contact'
|
|
| 'current-series' | 'episode-highlights' | 'archived-series'
|
|
| 'downloads' | 'custom-links' | 'content-blocks'
|
|
| 'questions' | 'analytics' | 'assets'
|
|
| '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 Section Photo URL', section: 'about' },
|
|
{ key: 'aboutVerseArtUrl', label: 'About Verse Art 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' },
|
|
]
|
|
|
|
export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|
const [form, setForm] = useState<SiteContent>(content)
|
|
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content))
|
|
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
|
const [errorMsg, setErrorMsg] = useState('')
|
|
const [adminView, setAdminView] = useState<AdminView>('homepage')
|
|
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 [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
|
|
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null)
|
|
const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({})
|
|
const [previewOpen, setPreviewOpen] = useState(false)
|
|
const previewIframeRef = useRef<HTMLIFrameElement>(null)
|
|
|
|
const isDirty = JSON.stringify(form) !== lastSavedSnapshot
|
|
|
|
// 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 nextSnapshot = JSON.stringify(content)
|
|
setForm(content)
|
|
setLastSavedSnapshot(nextSnapshot)
|
|
}, [content])
|
|
|
|
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-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(() => {})
|
|
}, [])
|
|
|
|
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<SiteContent> }
|
|
if (data?.siteContent && typeof data.siteContent === 'object') {
|
|
const next = { ...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() {
|
|
setStatus('saving')
|
|
setErrorMsg('')
|
|
try {
|
|
const res = await fetch('/api/admin-content-draft', {
|
|
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 ?? 'Draft save failed')
|
|
}
|
|
const data = await res.json() as { updatedAt?: string }
|
|
setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() }))
|
|
setLastSavedSnapshot(JSON.stringify(form))
|
|
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
|
|
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: form }),
|
|
})
|
|
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 = { ...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 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)
|
|
}
|
|
|
|
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: '' },
|
|
],
|
|
}
|
|
: 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,
|
|
},
|
|
],
|
|
}
|
|
}),
|
|
}))
|
|
}
|
|
|
|
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,
|
|
}))
|
|
|
|
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 ?? [])],
|
|
}))
|
|
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))
|
|
} catch {
|
|
alert('Failed to delete question')
|
|
}
|
|
}
|
|
|
|
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources')
|
|
const archivedResourceCount = (form.archivedSeries ?? []).reduce((count, series) => {
|
|
return count + (series.resourceLinks ?? []).length
|
|
}, 0)
|
|
|
|
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">
|
|
{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 className="admin-sidebar" aria-label="Admin navigation">
|
|
<div className="admin-sidebar-meta-links">
|
|
<Link
|
|
to="/"
|
|
className="admin-meta-link"
|
|
onClick={e => {
|
|
if (!confirmLeaveUnsavedChanges()) e.preventDefault()
|
|
}}
|
|
>
|
|
← Back to site
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
className="admin-meta-link"
|
|
onClick={() => {
|
|
if (!confirmLeaveUnsavedChanges()) return
|
|
void onLogout()
|
|
}}
|
|
>
|
|
Log Out
|
|
</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 === '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})</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')}>Assets</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 & Global</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'seo' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('seo')}>SEO & 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>
|
|
|
|
{/* ── Content panel ── */}
|
|
<main className="admin-panel">
|
|
|
|
{/* 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`)}
|
|
</>
|
|
)}
|
|
</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 study card and companion guide content.</p>
|
|
</div>
|
|
{FIELDS.filter(f => f.section === 'series').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>
|
|
)}
|
|
|
|
{/* 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>
|
|
<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-series-image-url">Cover Image URL</label>
|
|
<input id="resources-series-image-url" type="text" value={form.seriesImageUrl} placeholder="/uploads/study-guide-cover.png" onChange={e => handleChange('seriesImageUrl', e.target.value)} />
|
|
{renderImageAssetSelector(form.seriesImageUrl, value => handleChange('seriesImageUrl', value), 'resources-series-image-url-asset')}
|
|
</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>
|
|
</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-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>
|
|
<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 the platform buttons row or footer navigation.</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>
|
|
{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&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>
|
|
)}
|
|
|
|
{/* GLOBAL / FOOTER & PLATFORM */}
|
|
{adminView === 'global' && (
|
|
<section className="admin-panel-section">
|
|
<div className="admin-panel-head">
|
|
<h2>Footer & Global Settings</h2>
|
|
<p>Edit the site footer text, platform links, header label, and analytics cookie banner.</p>
|
|
</div>
|
|
|
|
<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>
|
|
))}
|
|
|
|
{renderSaveStatus()}
|
|
</section>
|
|
)}
|
|
|
|
{/* QUESTIONS */}
|
|
{adminView === 'questions' && (
|
|
<section className="admin-panel-section" aria-label="Q&A Management">
|
|
<div className="admin-panel-head">
|
|
<h2>Bible Questions & Answers</h2>
|
|
<p>Manage submitted questions, provide answers, and approve for public display.</p>
|
|
</div>
|
|
{questions.length === 0 ? (
|
|
<p className="admin-stats-note">No questions submitted yet.</p>
|
|
) : (
|
|
<div className="admin-questions-list">
|
|
{questions.map(question => (
|
|
<div key={question.id} className="admin-question-card">
|
|
<div className="admin-question-header">
|
|
<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>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* ANALYTICS */}
|
|
{adminView === 'analytics' && (
|
|
<section className="admin-panel-section" aria-label="Analytics">
|
|
<div className="admin-panel-head">
|
|
<h2>Analytics</h2>
|
|
<p>Page hits, visitor geography, and data management.</p>
|
|
</div>
|
|
{statsStatus === 'loading' && <p className="admin-stats-note">Loading analytics…</p>}
|
|
{statsStatus === 'error' && <p className="admin-stats-note">Failed to load analytics.</p>}
|
|
{statsStatus === 'ready' && stats && (
|
|
<>
|
|
<div className="admin-stats-grid">
|
|
<article><h3>Total Hits</h3><p>{stats.totalHits.toLocaleString()}</p></article>
|
|
<article><h3>Last 30 Days</h3><p>{stats.last30DaysTotal.toLocaleString()}</p></article>
|
|
<article><h3>First Hit</h3><p>{formatDate(stats.firstHitAt)}</p></article>
|
|
<article><h3>Latest Hit</h3><p>{formatDate(stats.lastHitAt)}</p></article>
|
|
</div>
|
|
<div className="admin-stats-lists">
|
|
<div>
|
|
<h3>Top Paths</h3>
|
|
{stats.topPaths.length === 0 ? <p className="admin-stats-note">No hits tracked yet.</p> : (
|
|
<ul>{stats.topPaths.map(item => <li key={item.path}><span>{item.path}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<h3>Daily Hits (7 Days)</h3>
|
|
<ul>{stats.last7Days.map(item => <li key={item.day}><span>{item.day}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul>
|
|
</div>
|
|
</div>
|
|
<div className="admin-stats-head admin-stats-head--visitors">
|
|
<h2>Visitor Details</h2>
|
|
<p>IP, geography, and returning visitor behavior.</p>
|
|
</div>
|
|
<p className="admin-privacy-note">Privacy: visitor analytics only run after cookie consent. IPs below are masked.</p>
|
|
<div className="admin-stats-grid">
|
|
<article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p></article>
|
|
<article><h3>Unique Visitors</h3><p>{stats.visitors.uniqueVisitors.toLocaleString()}</p></article>
|
|
<article><h3>Returning Visits</h3><p>{stats.visitors.returningVisits.toLocaleString()}</p></article>
|
|
<article><h3>Returning Rate</h3><p>{stats.visitors.totalVisits > 0 ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` : '0%'}</p></article>
|
|
</div>
|
|
<div className="admin-stats-lists">
|
|
<div><h3>Top Countries</h3><ul>{stats.visitors.topCountries.map(item => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
|
|
<div><h3>Top States</h3><ul>{stats.visitors.topStates.map(item => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
|
|
<div><h3>Top Counties</h3><ul>{stats.visitors.topCounties.map(item => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
|
|
<div><h3>Top Cities</h3><ul>{stats.visitors.topCities.map(item => <li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>)}</ul></div>
|
|
</div>
|
|
<div className="admin-visits-table-wrap">
|
|
<h3>Recent Visitor Log</h3>
|
|
{stats.visitors.recentVisits.length === 0 ? <p className="admin-stats-note">No visitor records yet.</p> : (
|
|
<div className="admin-visits-table-scroll">
|
|
<table className="admin-visits-table">
|
|
<thead>
|
|
<tr><th>Time</th><th>IP</th><th>Country</th><th>State</th><th>County</th><th>City</th><th>Path</th><th>Returning</th><th>Visit #</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{stats.visitors.recentVisits.map(row => (
|
|
<tr key={`${row.visitorId}-${row.at}`}>
|
|
<td>{formatDate(row.at)}</td><td>{maskIp(row.ip)}</td><td>{row.country}</td><td>{row.state}</td><td>{row.county}</td><td>{row.city}</td><td>{row.path}</td><td>{row.returningVisitor ? 'Yes' : 'No'}</td><td>{row.visitCount}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="admin-stats-head admin-stats-head--visitors">
|
|
<h2>Contact Summary</h2>
|
|
<p>Submission totals from the contact form.</p>
|
|
</div>
|
|
<div className="admin-stats-grid">
|
|
<article><h3>Total Contact Messages</h3><p>{stats.contactTotals.totalSubmissions.toLocaleString()}</p></article>
|
|
<article><h3>Total Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
|
|
</div>
|
|
<div className="admin-stats-head admin-stats-head--visitors">
|
|
<h2>Data Management</h2>
|
|
<p>Export, backup, or retain only recent analytics data.</p>
|
|
</div>
|
|
<div className="admin-stats-grid">
|
|
<article><h3>Hit Stats Write</h3><p>{stats.writeStatus.hitStats.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.hitStats.at)}</p></article>
|
|
<article><h3>Visitor Stats Write</h3><p>{stats.writeStatus.visitorStats.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.visitorStats.at)}</p></article>
|
|
<article><h3>Backup Status</h3><p>{stats.writeStatus.backups.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.backups.at)}</p></article>
|
|
<article><h3>Latest Backup File</h3><p>{stats.writeStatus.backups.file ?? 'Not available yet'}</p></article>
|
|
</div>
|
|
<div className="admin-stats-head admin-stats-head--visitors">
|
|
<h2>Deployment & Cache Status</h2>
|
|
<p>Current deployment metadata and cache purge health for the live app.</p>
|
|
</div>
|
|
<div className="admin-stats-grid">
|
|
<article><h3>Build Commit</h3><p>{opsStatus?.buildCommit ?? 'Not available'}</p></article>
|
|
<article><h3>Build Number</h3><p>{opsStatus?.buildNumber ?? 'Not available'}</p></article>
|
|
<article><h3>Deployed At</h3><p>{formatDate(opsStatus?.deployedAt ?? null)}</p></article>
|
|
<article><h3>Cache Purge</h3><p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p><p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p></article>
|
|
</div>
|
|
<div className="admin-actions admin-actions--maintenance">
|
|
<button type="button" className="btn-admin-reset" onClick={handleExport}>Export JSON</button>
|
|
<button type="button" className="btn-admin-reset" onClick={handleBackupNow}>Backup Now</button>
|
|
<button type="button" className="btn-admin-reset" onClick={handlePrune}>Prune Old Data</button>
|
|
<button type="button" className="btn-admin-remove" onClick={handleClear}>Clear Analytics</button>
|
|
</div>
|
|
<div className="admin-actions admin-actions--maintenance">
|
|
<button type="button" className="btn-admin-reset" onClick={handlePurgeCache}>Purge Cache</button>
|
|
<button type="button" className="btn-admin-reset" onClick={handleDeployHook}>Trigger Deploy</button>
|
|
<button type="button" className="btn-admin-reset" onClick={() => { void reloadOpsStatus() }}>Refresh Status</button>
|
|
</div>
|
|
<div className="admin-restore-row">
|
|
<label htmlFor="restore-backup">Restore Backup</label>
|
|
<select id="restore-backup" value={selectedBackup} onChange={e => setSelectedBackup(e.target.value)} disabled={backupFiles.length === 0}>
|
|
{backupFiles.length === 0 && <option value="">No backups found</option>}
|
|
{backupFiles.map(file => <option key={file.filename} value={file.filename}>{file.filename}</option>)}
|
|
</select>
|
|
<button type="button" className="btn-admin-reset" onClick={handleRestoreBackup} disabled={!selectedBackup}>Restore Selected Backup</button>
|
|
</div>
|
|
{selectedBackupPreview && (
|
|
<div className="admin-restore-preview">
|
|
<h3>Restore Preview</h3>
|
|
<p><strong>Backup:</strong> {selectedBackupPreview.filename}</p>
|
|
<p><strong>Created:</strong> {formatDate(selectedBackupPreview.createdAt)}</p>
|
|
<p><strong>Reason:</strong> {selectedBackupPreview.reason}</p>
|
|
<p><strong>Size:</strong> {(selectedBackupPreview.sizeBytes / 1024).toFixed(1)} KB</p>
|
|
<p><strong>Content Updated At:</strong> {formatDate(selectedBackupPreview.adminUpdatedAt)}</p>
|
|
<p><strong>Total Hits:</strong> {selectedBackupPreview.totalHits.toLocaleString()}</p>
|
|
<p><strong>Total Visits:</strong> {selectedBackupPreview.totalVisits.toLocaleString()}</p>
|
|
</div>
|
|
)}
|
|
{maintenanceMsg && <p className="admin-stats-note">{maintenanceMsg}</p>}
|
|
</>
|
|
)}
|
|
</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>}
|
|
<div className="admin-asset-grid">
|
|
{assets.map(asset => (
|
|
<div key={asset.filename} className="admin-asset-card">
|
|
{isImageAsset(asset.filename) && <img src={asset.url} alt={asset.filename} className="admin-asset-thumb" />}
|
|
<div className="admin-asset-meta">
|
|
<p className="admin-asset-name">{asset.filename}</p>
|
|
<p className="admin-asset-info">{(asset.sizeBytes / 1024).toFixed(1)} KB · {formatDate(asset.updatedAt)}</p>
|
|
<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)}
|
|
/>
|
|
<div className="admin-asset-actions">
|
|
<button type="button" className="btn-admin-reset" onClick={() => { navigator.clipboard.writeText(asset.url).catch(() => {}) }}>Copy URL</button>
|
|
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button>
|
|
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* SEO & REDIRECTS */}
|
|
{adminView === 'seo' && (
|
|
<section className="admin-panel-section" aria-label="SEO & Redirects">
|
|
<div className="admin-panel-head">
|
|
<h2>SEO & Redirects</h2>
|
|
<p>Control metadata, canonical URL, robots policy, sitemap paths, and short-link redirects.</p>
|
|
</div>
|
|
<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 & 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>
|
|
)
|
|
}
|