5385 lines
270 KiB
TypeScript
5385 lines
270 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
|
||
import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
|
||
import { CSS } from '@dnd-kit/utilities'
|
||
import type { ChangeEvent } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
|
||
import { DEFAULTS } from './content'
|
||
import { AnalyticsPanel } from './components/AnalyticsPanel'
|
||
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
|
||
import { useAutosave } from './hooks/useAutosave'
|
||
|
||
interface SortableLessonSectionProps {
|
||
section: ColossiansStudySection
|
||
study: StudyProgram
|
||
updateStudySection: (studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | string[] | number) => void
|
||
removeStudySection: (studyId: string, sectionId: string) => void
|
||
}
|
||
|
||
function SortableLessonSection({ section, study, updateStudySection, removeStudySection }: SortableLessonSectionProps) {
|
||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id })
|
||
const style = {
|
||
transform: CSS.Transform.toString(transform),
|
||
transition,
|
||
opacity: isDragging ? 0.5 : 1,
|
||
background: isDragging ? '#f8f7f2' : undefined,
|
||
border: isDragging ? '2px solid #e0c070' : undefined,
|
||
marginBottom: '0.5rem',
|
||
}
|
||
|
||
return (
|
||
<details ref={setNodeRef} style={style} className="admin-collapsible-card" open={false}>
|
||
<summary className="admin-collapsible-summary" {...attributes} {...listeners} style={{ cursor: 'grab', userSelect: 'none' }}>
|
||
<div>
|
||
<strong>Lesson {section.reference || section.id}</strong>
|
||
<p>{section.title || 'Untitled section'}</p>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||
<a href={`/study/${study.slug}/${section.id}`} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary" onClick={e => e.stopPropagation()}>Preview</a>
|
||
<span className="admin-collapsible-hint">Expand to edit lesson</span>
|
||
</div>
|
||
</summary>
|
||
<div className="admin-collapsible-body">
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-section-chapter-${study.id}-${section.id}`}>Chapter</label>
|
||
<select id={`study-section-chapter-${study.id}-${section.id}`} value={section.chapter} onChange={e => updateStudySection(study.id, section.id, 'chapter', Number(e.target.value) || 1)}>
|
||
{Array.from({ length: Math.max(study.numberOfChapters, section.chapter, 1) }, (_, index) => index + 1).map(chapterNumber => (
|
||
<option key={chapterNumber} value={chapterNumber}>Chapter {chapterNumber}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-section-reference-${study.id}-${section.id}`}>Passage Reference</label>
|
||
<input id={`study-section-reference-${study.id}-${section.id}`} type="text" value={section.reference} placeholder="1:1-2" onChange={e => updateStudySection(study.id, section.id, 'reference', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-section-title-${study.id}-${section.id}`}>Section Title</label>
|
||
<input id={`study-section-title-${study.id}-${section.id}`} type="text" value={section.title} placeholder="Paul's Greeting" onChange={e => updateStudySection(study.id, section.id, 'title', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-section-audio-${study.id}-${section.id}`}>Spotify Embed URL (optional)</label>
|
||
<input id={`study-section-audio-${study.id}-${section.id}`} type="url" value={section.audioEmbedUrl ?? ''} placeholder="https://open.spotify.com/embed/episode/..." onChange={e => updateStudySection(study.id, section.id, 'audioEmbedUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-section-released-${study.id}-${section.id}`}>Release Date (optional)</label>
|
||
<input id={`study-section-released-${study.id}-${section.id}`} type="datetime-local" value={section.releasedAt ? new Date(section.releasedAt).toISOString().slice(0, 16) : ''} onChange={e => {
|
||
const val = e.target.value;
|
||
if (val) {
|
||
const date = new Date(val + ':00Z');
|
||
updateStudySection(study.id, section.id, 'releasedAt', date.toISOString());
|
||
} else {
|
||
updateStudySection(study.id, section.id, 'releasedAt', '');
|
||
}
|
||
}} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-section-summary-${study.id}-${section.id}`}>Short Summary</label>
|
||
<textarea id={`study-section-summary-${study.id}-${section.id}`} rows={3} value={section.summary} placeholder="One to two sentences that summarize the section." onChange={e => updateStudySection(study.id, section.id, 'summary', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-section-announcement-${study.id}-${section.id}`}>Lesson Announcement</label>
|
||
<textarea id={`study-section-announcement-${study.id}-${section.id}`} rows={3} value={section.announcement ?? ''} placeholder="A short instructor announcement for this lesson." onChange={e => updateStudySection(study.id, section.id, 'announcement', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-section-passage-${study.id}-${section.id}`}>Passage Text</label>
|
||
<textarea id={`study-section-passage-${study.id}-${section.id}`} rows={4} value={section.passageText} placeholder="Paste the passage text here." onChange={e => updateStudySection(study.id, section.id, 'passageText', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-section-commentary-${study.id}-${section.id}`}>Commentary</label>
|
||
<textarea id={`study-section-commentary-${study.id}-${section.id}`} rows={10} value={section.commentary} placeholder="Add your teaching notes and explanation here. Press Enter twice to start a new paragraph." onChange={e => updateStudySection(study.id, section.id, 'commentary', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-section-greek-${study.id}-${section.id}`}>Greek Words</label>
|
||
<textarea id={`study-section-greek-${study.id}-${section.id}`} rows={4} value={(section.greekNotes ?? []).join('\n')} placeholder="One note per line" onChange={e => updateStudySection(study.id, section.id, 'greekNotes', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-section-focus-${study.id}-${section.id}`}>Focus Question</label>
|
||
<input id={`study-section-focus-${study.id}-${section.id}`} type="text" value={section.focusQuestion ?? ''} placeholder="Shown on the lesson card (defaults to first study question)" onChange={e => updateStudySection(study.id, section.id, 'focusQuestion', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-section-questions-${study.id}-${section.id}`}>Study Questions</label>
|
||
<textarea id={`study-section-questions-${study.id}-${section.id}`} rows={5} value={(section.studyQuestions ?? []).join('\n')} placeholder="One question per line" onChange={e => updateStudySection(study.id, section.id, 'studyQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-section-checkpoint-prompt-${study.id}-${section.id}`}>Checkpoint Prompt</label>
|
||
<input id={`study-section-checkpoint-prompt-${study.id}-${section.id}`} type="text" value={section.checkpointPrompt ?? ''} placeholder="A short prompt for the checkpoint" onChange={e => updateStudySection(study.id, section.id, 'checkpointPrompt', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-section-checkpoint-questions-${study.id}-${section.id}`}>Checkpoint Questions</label>
|
||
<textarea id={`study-section-checkpoint-questions-${study.id}-${section.id}`} rows={5} value={(section.checkpointQuestions ?? []).join('\n')} placeholder="One checkpoint question per line" onChange={e => updateStudySection(study.id, section.id, 'checkpointQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
|
||
<p className="admin-field-help">Optional reflection questions that learners answer before completing the lesson.</p>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeStudySection(study.id, section.id)}>Remove Lesson</button>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
)
|
||
}
|
||
|
||
interface StudyUserRecord {
|
||
id: string
|
||
username: string
|
||
displayName: string
|
||
createdAt: string | null
|
||
lastLoginAt: string | null
|
||
enrolledStudies: Array<{ slug: string; title: string }>
|
||
noteCount: number
|
||
subscribeNewsletter: boolean
|
||
studyRemindersEnabled: boolean
|
||
}
|
||
|
||
function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
||
const [users, setUsers] = useState<StudyUserRecord[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [search, setSearch] = useState('')
|
||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||
const [editDisplayName, setEditDisplayName] = useState<Record<string, string>>({})
|
||
const [newPassword, setNewPassword] = useState<Record<string, string>>({})
|
||
const [msg, setMsg] = useState<Record<string, string>>({})
|
||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
||
|
||
function flashMsg(id: string, text: string) {
|
||
setMsg(prev => ({ ...prev, [id]: text }))
|
||
setTimeout(() => setMsg(prev => { const n = { ...prev }; delete n[id]; return n }), 4000)
|
||
}
|
||
|
||
useEffect(() => {
|
||
fetch('/api/admin/study-users')
|
||
.then(r => r.json())
|
||
.then((d: { users: StudyUserRecord[] }) => { setUsers(d.users ?? []); setLoading(false) })
|
||
.catch(() => setLoading(false))
|
||
}, [])
|
||
|
||
async function patchUser(id: string, body: Record<string, string>) {
|
||
const res = await fetch(`/api/admin/study-users/${id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok) throw new Error(data.message ?? 'Error')
|
||
return data
|
||
}
|
||
|
||
async function handleSaveDisplayName(user: StudyUserRecord) {
|
||
try {
|
||
const name = editDisplayName[user.id] ?? user.displayName
|
||
await patchUser(user.id, { displayName: name })
|
||
setUsers(prev => prev.map(u => u.id === user.id ? { ...u, displayName: name } : u))
|
||
flashMsg(user.id, 'Display name updated.')
|
||
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
|
||
}
|
||
|
||
async function handleResetPassword(user: StudyUserRecord) {
|
||
const pw = newPassword[user.id] ?? ''
|
||
if (pw.length < 8) { flashMsg(user.id, 'Password must be at least 8 characters.'); return }
|
||
try {
|
||
await patchUser(user.id, { newPassword: pw })
|
||
setNewPassword(prev => { const n = { ...prev }; delete n[user.id]; return n })
|
||
flashMsg(user.id, 'Password reset. Their active sessions have been signed out.')
|
||
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
|
||
}
|
||
|
||
async function handleEnroll(user: StudyUserRecord, slug: string) {
|
||
try {
|
||
const data = await patchUser(user.id, { addEnrollment: slug })
|
||
setUsers(prev => prev.map(u => {
|
||
if (u.id !== user.id) return u
|
||
const study = studies.find(s => s.slug === slug)
|
||
const already = u.enrolledStudies.find(e => e.slug === slug)
|
||
return { ...u, enrolledStudies: already ? u.enrolledStudies : [...u.enrolledStudies, { slug, title: study?.title ?? slug }] }
|
||
}))
|
||
flashMsg(user.id, `Enrolled in ${slug}.`)
|
||
return data
|
||
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
|
||
}
|
||
|
||
async function handleUnenroll(user: StudyUserRecord, slug: string) {
|
||
try {
|
||
await patchUser(user.id, { removeEnrollment: slug })
|
||
setUsers(prev => prev.map(u => u.id === user.id ? { ...u, enrolledStudies: u.enrolledStudies.filter(e => e.slug !== slug) } : u))
|
||
flashMsg(user.id, `Removed from ${slug}.`)
|
||
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
|
||
}
|
||
|
||
async function handleDelete(id: string) {
|
||
try {
|
||
await fetch(`/api/admin/study-users/${id}`, { method: 'DELETE' })
|
||
setUsers(prev => prev.filter(u => u.id !== id))
|
||
setConfirmDeleteId(null)
|
||
setExpandedId(null)
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
const filtered = users.filter(u =>
|
||
u.username.toLowerCase().includes(search.toLowerCase()) ||
|
||
u.displayName.toLowerCase().includes(search.toLowerCase())
|
||
)
|
||
|
||
if (loading) return <p className="admin-stats-note">Loading study users…</p>
|
||
|
||
return (
|
||
<section className="admin-panel-section" aria-label="Study Users">
|
||
<div className="admin-panel-head">
|
||
<h2>Study Users</h2>
|
||
<p>Manage student accounts — reset passwords, update display names, adjust enrollments, or delete accounts.</p>
|
||
</div>
|
||
|
||
<div className="admin-field" style={{ maxWidth: '360px', marginBottom: '1.25rem' }}>
|
||
<input
|
||
type="search"
|
||
placeholder="Search by email or display name…"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
{filtered.length === 0 && <p className="admin-stats-note">{users.length === 0 ? 'No study users yet.' : 'No users match your search.'}</p>}
|
||
|
||
<div className="admin-study-users-list">
|
||
{filtered.map(user => {
|
||
const isExpanded = expandedId === user.id
|
||
const unenrolledStudies = studies.filter(s => !user.enrolledStudies.find(e => e.slug === s.slug))
|
||
return (
|
||
<div key={user.id} className={`admin-study-user-card${isExpanded ? ' admin-study-user-card--open' : ''}`}>
|
||
<button
|
||
type="button"
|
||
className="admin-study-user-header"
|
||
onClick={() => {
|
||
setExpandedId(isExpanded ? null : user.id)
|
||
setEditDisplayName(prev => ({ ...prev, [user.id]: user.displayName }))
|
||
}}
|
||
>
|
||
<div className="admin-study-user-header-left">
|
||
<strong>{user.displayName || user.username}</strong>
|
||
{user.displayName && <span className="admin-study-user-email">{user.username}</span>}
|
||
</div>
|
||
<div className="admin-study-user-header-right">
|
||
<span className="admin-study-user-pill">{user.enrolledStudies.length} enrolled</span>
|
||
<span className="admin-study-user-pill">{user.noteCount} notes</span>
|
||
<span style={{ color: '#5a5440', fontSize: '0.8rem' }}>{isExpanded ? '▲' : '▼'}</span>
|
||
</div>
|
||
</button>
|
||
|
||
{isExpanded && (
|
||
<div className="admin-study-user-body">
|
||
<div className="admin-study-user-meta">
|
||
<span>Joined: {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'}</span>
|
||
<span>Last login: {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : '—'}</span>
|
||
<span>Newsletter: {user.subscribeNewsletter ? 'Yes' : 'No'}</span>
|
||
<span>Reminders: {user.studyRemindersEnabled ? 'On' : 'Off'}</span>
|
||
</div>
|
||
|
||
{/* Display Name */}
|
||
<div className="admin-study-user-section">
|
||
<h4>Display Name</h4>
|
||
<div className="admin-study-user-row">
|
||
<input
|
||
type="text"
|
||
value={editDisplayName[user.id] ?? user.displayName}
|
||
onChange={e => setEditDisplayName(prev => ({ ...prev, [user.id]: e.target.value }))}
|
||
placeholder="Display name"
|
||
maxLength={80}
|
||
/>
|
||
<button type="button" className="btn-admin-save" onClick={() => handleSaveDisplayName(user)}>Save</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Password Reset */}
|
||
<div className="admin-study-user-section">
|
||
<h4>Reset Password</h4>
|
||
<div className="admin-study-user-row">
|
||
<input
|
||
type="text"
|
||
value={newPassword[user.id] ?? ''}
|
||
onChange={e => setNewPassword(prev => ({ ...prev, [user.id]: e.target.value }))}
|
||
placeholder="New password (min 8 chars)"
|
||
autoComplete="off"
|
||
/>
|
||
<button type="button" className="btn-admin-save" onClick={() => handleResetPassword(user)}>Reset</button>
|
||
</div>
|
||
<p className="admin-stats-note" style={{ marginTop: '0.25rem' }}>This immediately signs them out of all active sessions.</p>
|
||
</div>
|
||
|
||
{/* Enrollments */}
|
||
<div className="admin-study-user-section">
|
||
<h4>Enrollments</h4>
|
||
{user.enrolledStudies.length === 0
|
||
? <p className="admin-stats-note">Not enrolled in any studies.</p>
|
||
: (
|
||
<ul className="admin-study-user-enrollments">
|
||
{user.enrolledStudies.map(e => (
|
||
<li key={e.slug}>
|
||
<span>{e.title}</span>
|
||
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.78rem', padding: '0.2rem 0.6rem' }} onClick={() => handleUnenroll(user, e.slug)}>Remove</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)
|
||
}
|
||
{unenrolledStudies.length > 0 && (
|
||
<div className="admin-study-user-row" style={{ marginTop: '0.5rem' }}>
|
||
<select
|
||
defaultValue=""
|
||
onChange={e => { if (e.target.value) { handleEnroll(user, e.target.value); e.target.value = '' } }}
|
||
>
|
||
<option value="">Enroll in a study…</option>
|
||
{unenrolledStudies.map(s => <option key={s.slug} value={s.slug}>{s.title}</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{msg[user.id] && <p className="admin-study-user-msg">{msg[user.id]}</p>}
|
||
|
||
{/* Delete */}
|
||
<div className="admin-study-user-section" style={{ borderTop: '1px solid rgba(201,168,76,0.1)', paddingTop: '0.75rem', marginTop: '0.5rem' }}>
|
||
{confirmDeleteId === user.id ? (
|
||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||
<span style={{ fontSize: '0.85rem', color: '#e05c5c' }}>Delete this account permanently?</span>
|
||
<button type="button" className="btn-admin-remove" onClick={() => handleDelete(user.id)}>Yes, Delete</button>
|
||
<button type="button" className="btn-admin-reset" onClick={() => setConfirmDeleteId(null)}>Cancel</button>
|
||
</div>
|
||
) : (
|
||
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.82rem' }} onClick={() => setConfirmDeleteId(user.id)}>Delete Account</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function EmailTemplatesPanel({
|
||
form,
|
||
handleChange,
|
||
renderSaveStatus,
|
||
}: {
|
||
form: SiteContent
|
||
handleChange: (key: StringField, value: string) => void
|
||
renderSaveStatus: () => React.ReactNode
|
||
}) {
|
||
const [expandedEmail, setExpandedEmail] = useState<string | null>(null)
|
||
|
||
function EmailTemplateCard({
|
||
type, icon, title, trigger, autoVars, fieldKeys, openKey,
|
||
}: {
|
||
type: 'newsletter' | 'study' | 'transactional'
|
||
icon: string
|
||
title: string
|
||
trigger: string
|
||
autoVars?: string
|
||
fieldKeys: string[]
|
||
openKey: string
|
||
}) {
|
||
const isOpen = expandedEmail === openKey
|
||
return (
|
||
<div className={`admin-et-card admin-et-card--${type}${isOpen ? ' admin-et-card--open' : ''}`}>
|
||
<button
|
||
type="button"
|
||
className="admin-et-card-header"
|
||
onClick={() => setExpandedEmail(isOpen ? null : openKey)}
|
||
>
|
||
<span className="admin-et-card-icon" aria-hidden="true">{icon}</span>
|
||
<div className="admin-et-card-meta">
|
||
<div className="admin-et-card-title-row">
|
||
<span className={`admin-et-badge admin-et-badge--${type}`}>{type}</span>
|
||
<strong className="admin-et-card-title">{title}</strong>
|
||
</div>
|
||
<p className="admin-et-card-trigger">⚡ {trigger}</p>
|
||
</div>
|
||
<span className="admin-et-card-chevron">{isOpen ? '▲' : '▼'}</span>
|
||
</button>
|
||
{isOpen && (
|
||
<div className="admin-et-card-body">
|
||
{autoVars && (
|
||
<p className="admin-et-card-vars">
|
||
<span className="admin-et-card-vars-label">Auto-inserted:</span> {autoVars}
|
||
</p>
|
||
)}
|
||
{fieldKeys.map(key => {
|
||
const field = FIELDS.find(f => f.key === key && f.section === 'email-templates')
|
||
if (!field) return null
|
||
return (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-et-${key}`}>{field.label}</label>
|
||
{field.multiline
|
||
? <textarea id={`field-et-${key}`} rows={3} value={form[key as StringField] as string} onChange={e => handleChange(key as StringField, e.target.value)} />
|
||
: <input id={`field-et-${key}`} type="text" value={form[key as StringField] as string} onChange={e => handleChange(key as StringField, e.target.value)} />
|
||
}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<section className="admin-panel-section" aria-label="Email Templates">
|
||
<div className="admin-panel-head">
|
||
<h2>Email Templates</h2>
|
||
<p>Every automated email the site sends — edit subject lines, body copy, and sign-offs. Publish to apply changes.</p>
|
||
</div>
|
||
|
||
<EmailTemplateCard type="newsletter" icon="📬" title="Newsletter Welcome"
|
||
trigger="Someone subscribes via the homepage form or contact page"
|
||
autoVars="Subscriber's first name, Spotify / Apple / Amazon links"
|
||
fieldKeys={['welcomeEmailSubject','welcomeEmailGreetingPrefix','welcomeEmailIntro','welcomeEmailCurrentSeries','welcomeEmailStartHereTitle','welcomeEmailStartHereSummary','welcomeEmailStartHereUrl','welcomeEmailStartHereLinkLabel','welcomeEmailWhatToExpect1','welcomeEmailWhatToExpect2','welcomeEmailWhatToExpect3','welcomeEmailScripture','welcomeEmailScriptureRef','welcomeEmailSignoff','welcomeEmailSpotifyUrl','welcomeEmailSpotifyBtnLabel','welcomeEmailAppleUrl','welcomeEmailAppleBtnLabel','welcomeEmailAmazonUrl']}
|
||
openKey="newsletter-welcome" />
|
||
|
||
<EmailTemplateCard type="study" icon="📖" title="Study Account — New Account"
|
||
trigger="A student creates a study account"
|
||
autoVars="Student's display name, link to study hub, link to account page"
|
||
fieldKeys={['studyWelcomeEmailSubject','studyWelcomeEmailBody','studyWelcomeEmailCtaLabel','studyWelcomeEmailCtaPath','studyWelcomeEmailSignoff']}
|
||
openKey="study-welcome" />
|
||
|
||
<EmailTemplateCard type="study" icon="🗑️" title="Study Account — Account Deleted"
|
||
trigger="A student (or admin) deletes a study account"
|
||
autoVars="Student's display name, link to create a new account"
|
||
fieldKeys={['studyDeletedEmailSubject','studyDeletedEmailBody','studyDeletedEmailCtaLabel','studyDeletedEmailCtaPath','studyDeletedEmailSignoff']}
|
||
openKey="study-deleted" />
|
||
|
||
<EmailTemplateCard type="study" icon="🔔" title="Study Account — New Lesson Unlocked"
|
||
trigger="A lesson releases for a student who has reminders turned on"
|
||
autoVars="Student's name, lesson title, scripture reference, study track name — button links directly to the lesson (auto-generated, not editable)"
|
||
fieldKeys={['studyReminderEmailSubjectPrefix','studyReminderEmailBody','studyReminderEmailCtaLabel','studyReminderEmailSignoff']}
|
||
openKey="study-reminder" />
|
||
|
||
<EmailTemplateCard type="transactional" icon="🔐" title="Account Security — Email Change Confirmation"
|
||
trigger="A student requests to change their account email address"
|
||
autoVars="Button links to a one-time verification URL (auto-generated per request, not editable)"
|
||
fieldKeys={['emailChangeSubject','emailChangeBody','emailChangeCtaLabel']}
|
||
openKey="email-change" />
|
||
|
||
<EmailTemplateCard type="transactional" icon="📟" title="Two-Factor Auth — Email Sign-In Code"
|
||
trigger="A student with email-based 2FA signs in, or requests a resend during login"
|
||
autoVars="The 6-digit code is auto-generated and inserted between the body text and expiry note — not editable"
|
||
fieldKeys={['twoFaOtpEmailSubject','twoFaOtpEmailBody','twoFaOtpEmailExpiry']}
|
||
openKey="2fa-otp" />
|
||
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
interface Props {
|
||
content: SiteContent
|
||
onSave: (c: SiteContent) => void
|
||
onLogout: () => void | Promise<void>
|
||
}
|
||
|
||
interface BackupPreview {
|
||
filename: string
|
||
sizeBytes: number
|
||
createdAt: string | null
|
||
reason: string
|
||
adminUpdatedAt: string | null
|
||
totalHits: number
|
||
totalVisits: number
|
||
}
|
||
|
||
export interface AdminStats {
|
||
totalHits: number
|
||
realHits: number
|
||
botHits: number
|
||
firstHitAt: string | null
|
||
lastHitAt: string | null
|
||
topPaths: Array<{ path: string; hits: number }>
|
||
topPathsReal: Array<{ path: string; hits: number }>
|
||
topPathsBot: Array<{ path: string; hits: number }>
|
||
last7Days: Array<{ day: string; hits: number }>
|
||
last7DaysReal: Array<{ day: string; hits: number }>
|
||
last7DaysBot: Array<{ day: string; hits: number }>
|
||
last30DaysTotal: number
|
||
last30DaysRealTotal: number
|
||
last30DaysBotTotal: number
|
||
botReasons: Array<{ reason: string; count: number }>
|
||
visitors: {
|
||
totalVisits: number
|
||
uniqueVisitors: number
|
||
returningVisits: number
|
||
firstVisitAt: string | null
|
||
lastVisitAt: string | null
|
||
topCountries: Array<{ name: string; hits: number }>
|
||
topStates: Array<{ name: string; hits: number }>
|
||
topCounties: Array<{ name: string; hits: number }>
|
||
topCities: Array<{ name: string; hits: number }>
|
||
deviceBreakdown: { mobile: number; desktop: number; tablet: number; unknown: number }
|
||
topReferrers: Array<{ referrer: string; count: number }>
|
||
last30DaysReal: Array<{ day: string; hits: number }>
|
||
recentVisits: Array<{
|
||
at: string
|
||
visitorId: string
|
||
ip: string
|
||
path: string
|
||
referrer?: string
|
||
device?: string
|
||
country: string
|
||
state: string
|
||
county: string
|
||
city: string
|
||
returningVisitor: boolean
|
||
visitCount: number
|
||
pageHistory?: Array<{ at: string; path: string; referrer?: string }>
|
||
}>
|
||
}
|
||
writeStatus: {
|
||
hitStats: { ok: boolean; at: string | null; error: string | null }
|
||
visitorStats: { ok: boolean; at: string | null; error: string | null }
|
||
backups: { ok: boolean; at: string | null; error: string | null; file: string | null }
|
||
}
|
||
contactTotals: {
|
||
totalSubmissions: number
|
||
totalQuestions: number
|
||
}
|
||
studyEnrollment: {
|
||
totalUsers: number
|
||
enrolledUsers: number
|
||
totalEnrollments: number
|
||
enrollmentsByStudy: Array<{ slug: string; title: string; count: number }>
|
||
users: Array<{
|
||
id: string
|
||
username: string
|
||
displayName: string
|
||
enrolledStudies: Array<{ slug: string; title: string }>
|
||
}>
|
||
}
|
||
}
|
||
|
||
interface AdminAsset {
|
||
filename: string
|
||
url: string
|
||
sizeBytes: number
|
||
updatedAt: string
|
||
tags?: string[]
|
||
}
|
||
|
||
interface PublishState {
|
||
draftUpdatedAt: string | null
|
||
publishedAt: string | null
|
||
}
|
||
|
||
interface OpsStatus {
|
||
buildCommit: string | null
|
||
buildNumber: string | null
|
||
deployedAt: string | null
|
||
cachePurge: { ok: boolean; at: string | null; error: string | null }
|
||
deployHook: { ok: boolean; at: string | null; error: string | null }
|
||
}
|
||
|
||
interface Question {
|
||
id: string
|
||
submittedAt: string
|
||
firstName: string
|
||
email: string
|
||
question: string
|
||
answer: string
|
||
answeredAt: string | null
|
||
isApproved: boolean
|
||
approvedAt: string | null
|
||
}
|
||
|
||
interface ContactSubmission {
|
||
id: string
|
||
submittedAt: string
|
||
name: string
|
||
email: string
|
||
message: string
|
||
messageType: 'question' | 'testimony' | 'topic' | 'general'
|
||
subscribe: boolean
|
||
archived?: boolean
|
||
}
|
||
|
||
interface ContactReplyDraft {
|
||
submissionId: string
|
||
recipientName: string
|
||
recipientEmail: string
|
||
subject: string
|
||
message: string
|
||
}
|
||
|
||
interface ContactReplyTemplate {
|
||
id: string
|
||
label: string
|
||
subject: string
|
||
message: string
|
||
}
|
||
|
||
interface ContactReplyHistoryItem {
|
||
id: string
|
||
submissionId: string
|
||
toEmail: string
|
||
toName: string
|
||
fromEmail: string
|
||
subject: string
|
||
preview: string
|
||
sentAt: string
|
||
}
|
||
|
||
interface Subscriber {
|
||
name: string
|
||
email: string
|
||
subscribedAt: string
|
||
source: 'contact-form' | 'download'
|
||
}
|
||
|
||
interface ContactReplyConfig {
|
||
fromEmail: string
|
||
fromIdentity: string
|
||
resendApiConfigured: boolean
|
||
canSendReplies: boolean
|
||
note: string
|
||
}
|
||
|
||
type ChecklistPhase = 'pre' | 'post'
|
||
|
||
interface PodcastChecklistTask {
|
||
id: string
|
||
label: string
|
||
phase: ChecklistPhase
|
||
}
|
||
|
||
interface PodcastChecklistEpisode {
|
||
id: string
|
||
series: string
|
||
episodeNumber: number | null
|
||
title: string
|
||
datePublished: string
|
||
expanded: boolean
|
||
tasks: Record<string, boolean>
|
||
}
|
||
|
||
interface PodcastChecklistData {
|
||
tasks: PodcastChecklistTask[]
|
||
episodes: PodcastChecklistEpisode[]
|
||
}
|
||
|
||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
|
||
|
||
type AdminView =
|
||
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
|
||
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
|
||
| 'downloads' | 'custom-links' | 'content-blocks'
|
||
| 'questions' | 'analytics' | 'assets' | 'colossians-study'
|
||
| 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
|
||
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
|
||
|
||
interface AdminSectionLink {
|
||
id: string
|
||
label: string
|
||
}
|
||
|
||
const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminView; label: string }> }> = [
|
||
{
|
||
group: 'Overview',
|
||
options: [{ value: 'dashboard', label: 'Dashboard' }],
|
||
},
|
||
{
|
||
group: 'Site',
|
||
options: [
|
||
{ value: 'homepage', label: 'Homepage' },
|
||
{ value: 'start-here', label: 'Start Here' },
|
||
{ value: 'about', label: 'About' },
|
||
{ value: 'contact', label: 'Contact' },
|
||
],
|
||
},
|
||
{
|
||
group: 'Content',
|
||
options: [
|
||
{ value: 'podcast', label: 'Podcast Hub' },
|
||
{ value: 'colossians-study', label: 'Studies' },
|
||
{ value: 'downloads', label: 'Downloads' },
|
||
{ value: 'custom-links', label: 'Custom Links' },
|
||
{ value: 'content-blocks', label: 'Content Blocks' },
|
||
],
|
||
},
|
||
{
|
||
group: 'Manage',
|
||
options: [
|
||
{ value: 'questions', label: 'Questions' },
|
||
{ value: 'emails', label: 'Emails' },
|
||
{ value: 'contacts', label: 'Contacts' },
|
||
{ value: 'subscribers', label: 'Subscribers' },
|
||
{ value: 'study-users', label: 'Study Users' },
|
||
{ value: 'email-templates', label: 'Email Templates' },
|
||
{ value: 'analytics', label: 'Analytics' },
|
||
{ value: 'assets', label: 'Asset Manager' },
|
||
],
|
||
},
|
||
{
|
||
group: 'Configure',
|
||
options: [
|
||
{ value: 'global', label: 'Footer & Global' },
|
||
{ value: 'seo', label: 'SEO & Redirects' },
|
||
{ value: 'legal', label: 'Legal Pages' },
|
||
{ value: 'security', label: 'Security' },
|
||
{ value: 'brand', label: 'Brand Kit' },
|
||
],
|
||
},
|
||
]
|
||
|
||
const ADMIN_SECTION_LINKS: Partial<Record<AdminView, AdminSectionLink[]>> = {
|
||
homepage: [
|
||
{ id: 'homepage-hero', label: 'Hero' },
|
||
{ id: 'homepage-share', label: 'Share Section' },
|
||
{ id: 'homepage-prism', label: 'PRISM Block' },
|
||
{ id: 'homepage-where-to-next-labels', label: 'Where to Next Labels' },
|
||
{ id: 'homepage-where-to-next-cards', label: 'Where to Next Cards' },
|
||
],
|
||
'start-here': [
|
||
{ id: 'start-here-intro', label: 'Intro' },
|
||
{ id: 'start-here-step-1', label: 'Step 1' },
|
||
{ id: 'start-here-step-2', label: 'Step 2' },
|
||
{ id: 'start-here-step-3', label: 'Step 3' },
|
||
],
|
||
about: [
|
||
{ id: 'about-show-copy', label: 'Show Copy' },
|
||
{ id: 'about-nate-bio', label: 'Nate Bio' },
|
||
{ id: 'about-images', label: 'Images' },
|
||
],
|
||
contact: [
|
||
{ id: 'contact-profile', label: 'Profile' },
|
||
{ id: 'contact-intro', label: 'Intro Copy' },
|
||
{ id: 'contact-scripture', label: 'Scripture' },
|
||
],
|
||
downloads: [
|
||
{ id: 'downloads-study-guide', label: 'Study Guide' },
|
||
{ id: 'downloads-library', label: 'Download Library' },
|
||
{ id: 'downloads-previous-studies', label: 'Previous Studies' },
|
||
],
|
||
global: [
|
||
{ id: 'global-footer', label: 'Footer' },
|
||
{ id: 'global-header', label: 'Header' },
|
||
{ id: 'global-platforms', label: 'Platform URLs' },
|
||
{ id: 'global-banner', label: 'Analytics Banner' },
|
||
{ id: 'global-welcome-email', label: 'Welcome Email' },
|
||
],
|
||
seo: [
|
||
{ id: 'seo-metadata', label: 'Metadata' },
|
||
{ id: 'seo-redirects', label: 'Redirects' },
|
||
],
|
||
legal: [
|
||
{ id: 'legal-privacy', label: 'Privacy' },
|
||
{ id: 'legal-terms', label: 'Terms' },
|
||
],
|
||
}
|
||
|
||
type PodcastTab = 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
|
||
|
||
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates'
|
||
|
||
const BRAND_KIT_SWATCHES = [
|
||
{ name: 'Rich Black', hex: '#0a0a08', role: 'Primary background' },
|
||
{ name: 'Soft Black', hex: '#0f0f0c', role: 'Secondary surfaces' },
|
||
{ name: 'Panel Black', hex: '#1a1a15', role: 'Cards and panels' },
|
||
{ name: 'Deep Brown', hex: '#2a2518', role: 'Borders and dividers' },
|
||
{ name: 'Antique Gold', hex: '#c9a84c', role: 'Primary accent' },
|
||
{ name: 'Light Gold', hex: '#e0c070', role: 'Hover and highlight' },
|
||
{ name: 'Warm White', hex: '#f0ead8', role: 'Primary text' },
|
||
{ name: 'Warm Gray', hex: '#7a7060', role: 'Secondary text' },
|
||
] as const
|
||
|
||
const BRAND_KIT_TYPE = [
|
||
{
|
||
label: 'Display / Heading Font',
|
||
spec: 'Cormorant Garamond · 300/400/600/700 · italic supported · for H1, H2, display text',
|
||
sample: 'Verse by Verse with Nate',
|
||
},
|
||
{
|
||
label: 'Body Font',
|
||
spec: 'Lora · 400/500/600 · italic supported · for paragraphs, labels, nav, and UI text',
|
||
sample: 'Verse by verse. Nugget by nugget.',
|
||
},
|
||
{
|
||
label: 'Subtitle / Small Caps',
|
||
spec: 'Lora uppercase with tracking for eyebrows, labels, and support text',
|
||
sample: 'A Journey Through Scripture',
|
||
},
|
||
] as const
|
||
|
||
const BRAND_KIT_VERIFICATION = [
|
||
'Headings use Cormorant Garamond (300–700, italic) for display elegance.',
|
||
'Body text uses Lora (400–600, italic) for screen readability.',
|
||
'Shared brand variables define black, gold, border, warm white, and muted text colors.',
|
||
'Live site CSS references brand variables for core text and accent styling.',
|
||
'Admin surfaces inherit the same typography and palette tokens used by the public site.',
|
||
] as const
|
||
|
||
const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; section: MainContentSection }> = [
|
||
{ key: 'eyebrow', label: 'Hero Eyebrow Text', section: 'hero' },
|
||
{ key: 'heroTagline', label: 'Hero Tagline', section: 'hero' },
|
||
{ key: 'heroBtnSpotify', label: 'Hero — Spotify Button Label', section: 'hero' },
|
||
{ key: 'heroBtnEpisodes', label: 'Hero — Episodes Button Label', section: 'hero' },
|
||
{ key: 'heroBtnStartHere', label: 'Hero — Start Here Button Label', section: 'hero' },
|
||
{ key: 'startHereHeading', label: 'Start Here — Heading', section: 'start-here' },
|
||
{ key: 'startHereIntro', label: 'Start Here — Intro', multiline: true, section: 'start-here' },
|
||
{ key: 'startHereStep1Title', label: 'Start Here — Step 1 Title', section: 'start-here' },
|
||
{ key: 'startHereStep1Body', label: 'Start Here — Step 1 Body', multiline: true, section: 'start-here' },
|
||
{ key: 'startHereStep1Cta', label: 'Start Here — Step 1 Button Text', section: 'start-here' },
|
||
{ key: 'startHereStep2Title', label: 'Start Here — Step 2 Title', section: 'start-here' },
|
||
{ key: 'startHereStep2Body', label: 'Start Here — Step 2 Body', multiline: true, section: 'start-here' },
|
||
{ key: 'startHereStep2Cta', label: 'Start Here — Step 2 Button Text', section: 'start-here' },
|
||
{ key: 'startHereStep3Title', label: 'Start Here — Step 3 Title', section: 'start-here' },
|
||
{ key: 'startHereStep3Body', label: 'Start Here — Step 3 Body', multiline: true, section: 'start-here' },
|
||
{ key: 'startHereStep3Cta', label: 'Start Here — Step 3 Button Text', section: 'start-here' },
|
||
{ key: 'aboutShowHeading', label: 'About Show — Heading', section: 'about' },
|
||
{ key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true, section: 'about' },
|
||
{ key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true, section: 'about' },
|
||
{ key: 'aboutNate', label: 'About Nate', multiline: true, section: 'about' },
|
||
{ key: 'aboutPhotoUrl', label: 'About — Nate Portrait Image URL', section: 'about' },
|
||
{ key: 'aboutVerseArtUrl', label: 'About — Scripture Artwork Image URL', section: 'about' },
|
||
{ key: 'aboutEyebrow', label: 'About — "About Nate" Eyebrow', section: 'about' },
|
||
{ key: 'aboutListenBtnLabel', label: 'About — Listen Button Label', section: 'about' },
|
||
{ key: 'aboutShowEyebrow', label: 'About — "About the Show" Eyebrow', section: 'about' },
|
||
{ key: 'contactPhotoUrl', label: 'Contact — Profile Photo URL', section: 'contact' },
|
||
{ key: 'contactEyebrow', label: 'Contact — Section Eyebrow', section: 'contact' },
|
||
{ key: 'contactHeading', label: 'Contact — Heading', section: 'contact' },
|
||
{ key: 'contactName', label: 'Contact — Profile Name', section: 'contact' },
|
||
{ key: 'contactRole', label: 'Contact — Profile Role', section: 'contact' },
|
||
{ key: 'contactQuote', label: 'Contact — Quote', multiline: true, section: 'contact' },
|
||
{ key: 'contactIntro', label: 'Contact — Intro Text', multiline: true, section: 'contact' },
|
||
{ key: 'contactPoint1', label: 'Contact — Point 1', section: 'contact' },
|
||
{ key: 'contactPoint2', label: 'Contact — Point 2', section: 'contact' },
|
||
{ key: 'contactVerse', label: 'Contact — Scripture Text', section: 'contact' },
|
||
{ key: 'contactVerseRef', label: 'Contact — Scripture Reference', section: 'contact' },
|
||
{ key: 'episodesSeoIntro', label: 'Episodes Page — SEO Description (hidden from visitors, indexed by search engines)', multiline: true, section: 'series' },
|
||
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' },
|
||
{ key: 'seriesTitle', label: 'Series Title', section: 'series' },
|
||
{ key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' },
|
||
{ key: 'seriesImageUrl', label: 'Series Cover Image URL', section: 'series' },
|
||
{ key: 'seriesListenUrl', label: 'Series Listen URL', section: 'series' },
|
||
{ key: 'seriesListenBtnLabel', label: 'Series — Listen Button Label', section: 'series' },
|
||
{ key: 'seriesDownloadsBtnLabel', label: 'Series — 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' },
|
||
{ key: 'prismVideoUrl', label: 'PRISM — Video URL', section: 'prism' },
|
||
{ key: 'prismEyebrow', label: 'PRISM — Eyebrow', section: 'prism' },
|
||
{ key: 'prismHeading', label: 'PRISM — Heading', section: 'prism' },
|
||
{ key: 'prismIntro', label: 'PRISM — Intro Text', multiline: true, section: 'prism' },
|
||
{ key: 'prismPatternIntro', label: 'PRISM — Pattern Intro', multiline: true, section: 'prism' },
|
||
{ key: 'prismStep1', label: 'PRISM — Step 1', section: 'prism' },
|
||
{ key: 'prismStep2', label: 'PRISM — Step 2', section: 'prism' },
|
||
{ key: 'prismStep3', label: 'PRISM — Step 3', section: 'prism' },
|
||
{ key: 'prismStep4', label: 'PRISM — Step 4', section: 'prism' },
|
||
{ key: 'prismStep5', label: 'PRISM — Step 5', section: 'prism' },
|
||
{ key: 'prismOutro', label: 'PRISM — Outro Text', multiline: true, section: 'prism' },
|
||
{ key: 'prismClosing', label: 'PRISM — Closing Line', multiline: true, section: 'prism' },
|
||
// Global / Footer / Platform
|
||
{ key: 'footerTitle', label: 'Footer — Brand Title', section: 'global' },
|
||
{ key: 'footerSubtitle', label: 'Footer — Subtitle', section: 'global' },
|
||
{ key: 'footerEmail', label: 'Footer — Contact Email', section: 'global' },
|
||
{ key: 'footerCopyright', label: 'Footer — Copyright Line', section: 'global' },
|
||
{ key: 'footerPrivacyNote', label: 'Footer — Privacy Note', multiline: true, section: 'global' },
|
||
{ key: 'headerFollowLabel', label: 'Header — Follow Button Label', section: 'global' },
|
||
{ key: 'cookieBannerText', label: 'Analytics Cookie Banner Text', multiline: true, section: 'global' },
|
||
{ key: 'platformSpotifyUrl', label: 'Platform — Spotify URL', section: 'global' },
|
||
{ key: 'platformAppleUrl', label: 'Platform — Apple Podcasts URL', section: 'global' },
|
||
{ key: 'platformYoutubeUrl', label: 'Platform — YouTube URL', section: 'global' },
|
||
{ key: 'platformAmazonUrl', label: 'Platform — Amazon Music URL', section: 'global' },
|
||
{ key: 'platformFacebookUrl', label: 'Platform — Facebook URL', section: 'global' },
|
||
{ key: 'platformCreatorUrl', label: 'Platform — Creator Profile URL', section: 'global' },
|
||
{ key: 'welcomeEmailSubject', label: 'Welcome Email — Subject', section: 'global' },
|
||
{ key: 'welcomeEmailGreetingPrefix', label: 'Welcome Email — Greeting Prefix', section: 'global' },
|
||
{ key: 'welcomeEmailIntro', label: 'Welcome Email — Intro Paragraph', multiline: true, section: 'global' },
|
||
{ key: 'welcomeEmailCurrentSeries', label: 'Welcome Email — Current Series Paragraph', multiline: true, section: 'global' },
|
||
{ key: 'welcomeEmailStartHereTitle', label: 'Welcome Email — Start Here Title', section: 'global' },
|
||
{ key: 'welcomeEmailStartHereSummary', label: 'Welcome Email — Start Here Summary', multiline: true, section: 'global' },
|
||
{ key: 'welcomeEmailStartHereUrl', label: 'Welcome Email — Start Here URL', section: 'global' },
|
||
{ key: 'welcomeEmailSpotifyUrl', label: 'Welcome Email — Spotify URL', section: 'global' },
|
||
{ key: 'welcomeEmailAppleUrl', label: 'Welcome Email — Apple URL', section: 'global' },
|
||
{ key: 'welcomeEmailAmazonUrl', label: 'Welcome Email — Amazon URL', section: 'global' },
|
||
{ key: 'welcomeEmailWebsiteUrl', label: 'Welcome Email — Website URL', section: 'global' },
|
||
{ key: 'welcomeEmailImageUrl', label: 'Welcome Email — Image URL', section: 'global' },
|
||
{ key: 'welcomeEmailWhatToExpect1', label: 'Welcome Email — What to Expect 1', multiline: true, section: 'global' },
|
||
{ key: 'welcomeEmailWhatToExpect2', label: 'Welcome Email — What to Expect 2', multiline: true, section: 'global' },
|
||
{ key: 'welcomeEmailWhatToExpect3', label: 'Welcome Email — What to Expect 3', multiline: true, section: 'global' },
|
||
{ key: 'welcomeEmailScripture', label: 'Welcome Email — Scripture Text', multiline: true, section: 'global' },
|
||
{ key: 'welcomeEmailScriptureRef', label: 'Welcome Email — Scripture Reference', section: 'global' },
|
||
{ key: 'welcomeEmailSignoff', label: 'Signoff', multiline: true, section: 'global' },
|
||
// ── Email Templates section ──
|
||
{ key: 'welcomeEmailSubject', label: 'Subject Line', section: 'email-templates' },
|
||
{ key: 'welcomeEmailGreetingPrefix', label: 'Greeting Prefix (before subscriber name)', section: 'email-templates' },
|
||
{ key: 'welcomeEmailIntro', label: 'Intro Paragraph', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailCurrentSeries', label: 'Current Series Blurb', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailStartHereTitle', label: 'Start Here Episode Title', section: 'email-templates' },
|
||
{ key: 'welcomeEmailStartHereSummary', label: 'Start Here Episode Summary', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailStartHereUrl', label: 'Start Here Episode URL', section: 'email-templates' },
|
||
{ key: 'welcomeEmailWhatToExpect1', label: 'What to Expect — Line 1', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailWhatToExpect2', label: 'What to Expect — Line 2', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailWhatToExpect3', label: 'What to Expect — Line 3', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailScripture', label: 'Closing Scripture Text', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailScriptureRef', label: 'Closing Scripture Reference', section: 'email-templates' },
|
||
{ key: 'welcomeEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
|
||
{ key: 'welcomeEmailSpotifyUrl', label: 'Spotify URL', section: 'email-templates' },
|
||
{ key: 'welcomeEmailSpotifyBtnLabel', label: '"Listen on Spotify" button label', section: 'email-templates' },
|
||
{ key: 'welcomeEmailAppleUrl', label: 'Apple Podcasts URL', section: 'email-templates' },
|
||
{ key: 'welcomeEmailAppleBtnLabel', label: '"Apple Podcasts" button label', section: 'email-templates' },
|
||
{ key: 'welcomeEmailAmazonUrl', label: 'Amazon Music URL', section: 'email-templates' },
|
||
{ key: 'welcomeEmailStartHereLinkLabel', label: '"Start Here" link label', section: 'email-templates' },
|
||
{ key: 'studyWelcomeEmailSubject', label: 'Subject Line', section: 'email-templates' },
|
||
{ key: 'studyWelcomeEmailBody', label: 'Body Paragraph', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyWelcomeEmailCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||
{ key: 'studyWelcomeEmailCtaPath', label: 'Button Link (path or full URL)', section: 'email-templates' },
|
||
{ key: 'studyWelcomeEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyDeletedEmailSubject', label: 'Subject Line', section: 'email-templates' },
|
||
{ key: 'studyDeletedEmailBody', label: 'Body Paragraph', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyDeletedEmailCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||
{ key: 'studyDeletedEmailCtaPath', label: 'Button Link (path or full URL)', section: 'email-templates' },
|
||
{ key: 'studyDeletedEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyReminderEmailSubjectPrefix', label: 'Subject Prefix (lesson title auto-appended)', section: 'email-templates' },
|
||
{ key: 'studyReminderEmailBody', label: 'Body Paragraph', multiline: true, section: 'email-templates' },
|
||
{ key: 'studyReminderEmailCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||
{ key: 'studyReminderEmailSignoff', label: 'Signoff', multiline: true, section: 'email-templates' },
|
||
{ key: 'emailChangeSubject', label: 'Subject Line', section: 'email-templates' },
|
||
{ key: 'emailChangeBody', label: 'Body Text', multiline: true, section: 'email-templates' },
|
||
{ key: 'emailChangeCtaLabel', label: 'Button Label', section: 'email-templates' },
|
||
{ key: 'twoFaOtpEmailSubject', label: 'Subject Line', section: 'email-templates' },
|
||
{ key: 'twoFaOtpEmailBody', label: 'Body Text (shown above the code)', multiline: true, section: 'email-templates' },
|
||
{ key: 'twoFaOtpEmailExpiry', label: 'Expiry / Security Note (shown below the code)', multiline: true, section: 'email-templates' },
|
||
]
|
||
|
||
function normalizeStudies(siteContent: SiteContent): StudyProgram[] {
|
||
if (Array.isArray(siteContent.studies) && siteContent.studies.length > 0) {
|
||
return siteContent.studies.map(study => ({
|
||
...study,
|
||
homepageEyebrow: typeof study.homepageEyebrow === 'string' ? study.homepageEyebrow : '',
|
||
showOnHomepage: study.showOnHomepage === true,
|
||
showNewTag: study.showNewTag === true,
|
||
newTagLabel: typeof study.newTagLabel === 'string' ? study.newTagLabel : 'NEW',
|
||
numberOfChapters: Number.isInteger(study.numberOfChapters) && study.numberOfChapters >= 1 && study.numberOfChapters <= 999 ? study.numberOfChapters : 1,
|
||
}))
|
||
}
|
||
|
||
const fallbackSections = siteContent.colossiansStudySections?.length
|
||
? siteContent.colossiansStudySections
|
||
: DEFAULTS.colossiansStudySections
|
||
|
||
return [
|
||
{
|
||
id: 'study-colossians',
|
||
slug: 'colossians',
|
||
title: 'Colossians: Rooted in Christ',
|
||
description: 'Walk through Colossians in guided lessons with commentary, Greek notes, and discussion prompts.',
|
||
homepageEyebrow: 'New Study',
|
||
showOnHomepage: true,
|
||
showNewTag: true,
|
||
newTagLabel: 'NEW',
|
||
status: 'active',
|
||
difficulty: 'intermediate',
|
||
estimatedHours: 12,
|
||
completionBadge: 'Colossians Completion',
|
||
numberOfChapters: 4,
|
||
sections: fallbackSections,
|
||
},
|
||
]
|
||
}
|
||
|
||
function normalizeSiteContentForAdmin(siteContent: SiteContent): SiteContent {
|
||
const studies = normalizeStudies(siteContent)
|
||
const colossians = studies.find(study => study.slug === 'colossians')
|
||
|
||
return {
|
||
...siteContent,
|
||
studies,
|
||
colossiansStudySections: colossians?.sections?.length
|
||
? colossians.sections
|
||
: (siteContent.colossiansStudySections?.length ? siteContent.colossiansStudySections : DEFAULTS.colossiansStudySections),
|
||
}
|
||
}
|
||
|
||
function buildSiteContentForSave(siteContent: SiteContent): SiteContent {
|
||
const normalized = normalizeSiteContentForAdmin(siteContent)
|
||
const colossians = normalized.studies.find(study => study.slug === 'colossians')
|
||
return {
|
||
...normalized,
|
||
colossiansStudySections: colossians?.sections ?? normalized.colossiansStudySections,
|
||
}
|
||
}
|
||
|
||
export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||
const normalizedContent = normalizeSiteContentForAdmin(content)
|
||
const [form, setForm] = useState<SiteContent>(normalizedContent)
|
||
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(normalizedContent))
|
||
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||
const [errorMsg, setErrorMsg] = useState('')
|
||
const [adminView, setAdminView] = useState<AdminView>('dashboard')
|
||
const [podcastTab, setPodcastTab] = useState<PodcastTab>('current-series')
|
||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||
const [maintenanceMsg, setMaintenanceMsg] = useState('')
|
||
const [opsMsg, setOpsMsg] = useState('')
|
||
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
|
||
const [selectedBackup, setSelectedBackup] = useState('')
|
||
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
|
||
|
||
// TOTP management state
|
||
const [totpEnabled, setTotpEnabled] = useState<boolean | null>(null)
|
||
const [totpSetupQr, setTotpSetupQr] = useState<string | null>(null)
|
||
const [totpSetupSecret, setTotpSetupSecret] = useState<string | null>(null)
|
||
const [totpConfirmCode, setTotpConfirmCode] = useState('')
|
||
const [totpMsg, setTotpMsg] = useState('')
|
||
const [totpRecoveryCodes, setTotpRecoveryCodes] = useState<string[] | null>(null)
|
||
const [publishState, setPublishState] = useState<PublishState>({ draftUpdatedAt: null, publishedAt: null })
|
||
const [assets, setAssets] = useState<AdminAsset[]>([])
|
||
const [assetTagEdits, setAssetTagEdits] = useState<Record<string, string>>({})
|
||
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
|
||
const [assetUploadPending, setAssetUploadPending] = useState(false)
|
||
|
||
const [questions, setQuestions] = useState<Question[]>([])
|
||
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
|
||
const [contactStatus, setContactStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||
const [contactReplyDraft, setContactReplyDraft] = useState<ContactReplyDraft | null>(null)
|
||
const [contactReplyStatus, setContactReplyStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
|
||
const [contactReplyMsg, setContactReplyMsg] = useState('')
|
||
const [contactReplyTemplates, setContactReplyTemplates] = useState<ContactReplyTemplate[]>([])
|
||
const [contactReplyHistory, setContactReplyHistory] = useState<ContactReplyHistoryItem[]>([])
|
||
const [contactReplyConfig, setContactReplyConfig] = useState<ContactReplyConfig | null>(null)
|
||
const [contactTemplateStatus, setContactTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||
const [emailMailboxView, setEmailMailboxView] = useState<'inbox' | 'archived'>('inbox')
|
||
const [selectedEmailId, setSelectedEmailId] = useState<string | null>(null)
|
||
const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
|
||
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null)
|
||
const [questionSearch, setQuestionSearch] = useState('')
|
||
const [questionFilter, setQuestionFilter] = useState<'all' | 'pending' | 'approved' | 'answered' | 'unanswered'>('all')
|
||
const [questionPage, setQuestionPage] = useState(0)
|
||
const [selectedQuestionIds, setSelectedQuestionIds] = useState<Set<string>>(new Set())
|
||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||
const [navSearch, setNavSearch] = useState('')
|
||
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
|
||
const [subscriberSearch, setSubscriberSearch] = useState('')
|
||
const [contactSearch, setContactSearch] = useState('')
|
||
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
|
||
const [podcastChecklist, setPodcastChecklist] = useState<PodcastChecklistData>({ tasks: [], episodes: [] })
|
||
const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||
const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('')
|
||
const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||
const [manualQuestion, setManualQuestion] = useState({
|
||
firstName: '',
|
||
email: '',
|
||
question: '',
|
||
answer: '',
|
||
approve: false,
|
||
})
|
||
const [manualQuestionStatus, setManualQuestionStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||
const [manualQuestionMsg, setManualQuestionMsg] = useState('')
|
||
const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({})
|
||
const [previewOpen, setPreviewOpen] = useState(false)
|
||
const previewIframeRef = useRef<HTMLIFrameElement>(null)
|
||
const dragSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
|
||
const saveStatusTimeoutRef = useRef<number | null>(null)
|
||
const autosaveRestoreCheckedRef = useRef(false)
|
||
|
||
const isDirty = JSON.stringify(form) !== lastSavedSnapshot
|
||
const unreadEmailCount = contactSubmissions.filter(s => !s.archived).length
|
||
const unansweredCount = questions.filter(q => !q.answer?.trim()).length
|
||
const navSearchTerm = navSearch.trim().toLowerCase()
|
||
const filteredAdminViewOptions = ADMIN_VIEW_OPTIONS
|
||
.map(group => ({
|
||
...group,
|
||
options: group.options.filter(option => {
|
||
if (!navSearchTerm) return true
|
||
return `${group.group} ${option.label} ${option.value}`.toLowerCase().includes(navSearchTerm)
|
||
}),
|
||
}))
|
||
.filter(group => group.options.length > 0)
|
||
|
||
// Silently saves draft every 30s when there are unsaved changes
|
||
useAutosave(form, isDirty, handleSaveDraft)
|
||
|
||
function clearAutosaveDraft() {
|
||
try {
|
||
sessionStorage.removeItem('admin-autosave-draft')
|
||
} catch {
|
||
// Ignore storage failures; the server draft is still the source of truth.
|
||
}
|
||
}
|
||
|
||
// Broadcast live form state + active view into the preview iframe whenever they change
|
||
useEffect(() => {
|
||
if (!previewOpen) return
|
||
const timer = setTimeout(() => {
|
||
previewIframeRef.current?.contentWindow?.postMessage(
|
||
{ type: 'admin-preview-content', content: form, view: adminView },
|
||
window.location.origin
|
||
)
|
||
}, 150)
|
||
return () => clearTimeout(timer)
|
||
}, [form, adminView, previewOpen])
|
||
|
||
useEffect(() => {
|
||
const normalized = normalizeSiteContentForAdmin(content)
|
||
const nextSnapshot = JSON.stringify(normalized)
|
||
setForm(normalized)
|
||
setLastSavedSnapshot(nextSnapshot)
|
||
autosaveRestoreCheckedRef.current = false
|
||
}, [content])
|
||
|
||
useEffect(() => {
|
||
if (autosaveRestoreCheckedRef.current) return
|
||
autosaveRestoreCheckedRef.current = true
|
||
|
||
try {
|
||
const rawDraft = sessionStorage.getItem('admin-autosave-draft')
|
||
if (!rawDraft) return
|
||
|
||
const parsedDraft = JSON.parse(rawDraft) as SiteContent
|
||
const restoredDraft = normalizeSiteContentForAdmin(parsedDraft)
|
||
if (JSON.stringify(restoredDraft) === lastSavedSnapshot) {
|
||
clearAutosaveDraft()
|
||
return
|
||
}
|
||
|
||
if (confirm('Restore an unsaved draft from this browser session?')) {
|
||
setForm(restoredDraft)
|
||
}
|
||
} catch {
|
||
clearAutosaveDraft()
|
||
}
|
||
}, [lastSavedSnapshot])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (saveStatusTimeoutRef.current !== null) {
|
||
window.clearTimeout(saveStatusTimeoutRef.current)
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!mobileNavOpen) return
|
||
|
||
const previousOverflow = document.body.style.overflow
|
||
document.body.style.overflow = 'hidden'
|
||
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') {
|
||
setMobileNavOpen(false)
|
||
}
|
||
}
|
||
|
||
window.addEventListener('keydown', handleKeyDown)
|
||
|
||
return () => {
|
||
window.removeEventListener('keydown', handleKeyDown)
|
||
document.body.style.overflow = previousOverflow
|
||
}
|
||
}, [mobileNavOpen])
|
||
|
||
useEffect(() => {
|
||
const intervalId = window.setInterval(() => {
|
||
setDashboardNow(new Date())
|
||
}, 1000)
|
||
|
||
return () => window.clearInterval(intervalId)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||
if (!isDirty) return
|
||
event.preventDefault()
|
||
event.returnValue = ''
|
||
}
|
||
|
||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||
}, [isDirty])
|
||
|
||
useEffect(() => {
|
||
fetch('/api/admin-auth/status')
|
||
.then(r => r.ok ? r.json() : Promise.reject())
|
||
.then(data => setTotpEnabled(!!(data as { totpEnabled?: boolean }).totpEnabled))
|
||
.catch(() => {})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
fetch('/api/admin-stats')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats'))))
|
||
.then(data => {
|
||
setStats(data as AdminStats)
|
||
setStatsStatus('ready')
|
||
})
|
||
.catch(() => {
|
||
setStatsStatus('error')
|
||
})
|
||
|
||
fetch('/api/admin-questions')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
|
||
.then(data => {
|
||
setQuestions((data as { questions: Question[] }).questions ?? [])
|
||
})
|
||
.catch(() => {})
|
||
|
||
fetch('/api/admin-contact-submissions')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load contact submissions'))))
|
||
.then(data => {
|
||
setContactSubmissions((data as { submissions: ContactSubmission[] }).submissions ?? [])
|
||
setContactStatus('ready')
|
||
})
|
||
.catch(() => {
|
||
setContactStatus('error')
|
||
})
|
||
|
||
fetch('/api/admin-contact-reply-templates')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply templates'))))
|
||
.then(data => {
|
||
setContactReplyTemplates((data as { templates: ContactReplyTemplate[] }).templates ?? [])
|
||
})
|
||
.catch(() => {})
|
||
|
||
fetch('/api/admin-contact-reply-history')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply history'))))
|
||
.then(data => {
|
||
setContactReplyHistory((data as { items: ContactReplyHistoryItem[] }).items ?? [])
|
||
})
|
||
.catch(() => {})
|
||
|
||
fetch('/api/admin-reply-config')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply config'))))
|
||
.then(data => {
|
||
setContactReplyConfig(data as ContactReplyConfig)
|
||
})
|
||
.catch(() => {})
|
||
|
||
fetch('/api/admin-stats/backups')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load backups'))))
|
||
.then(data => {
|
||
const files = Array.isArray((data as { backups?: unknown }).backups) ? (data as { backups: BackupPreview[] }).backups : []
|
||
setBackupFiles(files)
|
||
if (files.length > 0) {
|
||
setSelectedBackup(files[0].filename)
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
|
||
fetch('/api/admin-content-state')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load publish state'))))
|
||
.then(data => {
|
||
const state = (data as { publishState?: PublishState }).publishState
|
||
if (state) {
|
||
setPublishState({
|
||
draftUpdatedAt: state.draftUpdatedAt ?? null,
|
||
publishedAt: state.publishedAt ?? null,
|
||
})
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
|
||
void reloadAssets()
|
||
|
||
fetch('/api/admin-ops/status')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load operations status'))))
|
||
.then(data => {
|
||
setOpsStatus(data as OpsStatus)
|
||
})
|
||
.catch(() => {})
|
||
|
||
fetch('/api/admin-subscribers')
|
||
.then(r => (r.ok ? r.json() : Promise.reject()))
|
||
.then(data => setSubscribers((data as { subscribers: Subscriber[] }).subscribers ?? []))
|
||
.catch(() => {})
|
||
|
||
fetch('/api/admin-download-stats')
|
||
.then(r => (r.ok ? r.json() : Promise.reject()))
|
||
.then(data => setDownloadStats((data as { counts: Record<string, number> }).counts ?? {}))
|
||
.catch(() => {})
|
||
|
||
fetch('/api/admin-podcast-checklist')
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load podcast checklist'))))
|
||
.then(data => {
|
||
const checklist = (data as { checklist?: PodcastChecklistData }).checklist
|
||
if (checklist?.tasks && checklist?.episodes) {
|
||
setPodcastChecklist(checklist)
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!selectedBackup) {
|
||
setSelectedBackupPreview(null)
|
||
return
|
||
}
|
||
|
||
fetch('/api/admin-stats/backup-preview', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: selectedBackup }),
|
||
})
|
||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Preview failed'))))
|
||
.then(data => {
|
||
const preview = (data as { preview?: BackupPreview }).preview ?? null
|
||
setSelectedBackupPreview(preview)
|
||
})
|
||
.catch(() => {
|
||
setSelectedBackupPreview(null)
|
||
})
|
||
}, [selectedBackup])
|
||
|
||
async function reloadStats() {
|
||
const r = await fetch('/api/admin-stats')
|
||
if (!r.ok) throw new Error('Could not refresh stats')
|
||
const data = await r.json()
|
||
setStats(data as AdminStats)
|
||
setStatsStatus('ready')
|
||
}
|
||
|
||
async function reloadContactSubmissions() {
|
||
const r = await fetch('/api/admin-contact-submissions')
|
||
if (!r.ok) throw new Error('Could not refresh contact submissions')
|
||
const data = await r.json() as { submissions?: ContactSubmission[] }
|
||
setContactSubmissions(Array.isArray(data.submissions) ? data.submissions : [])
|
||
setContactStatus('ready')
|
||
}
|
||
|
||
useEffect(() => {
|
||
const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
|
||
if (visible.length === 0) {
|
||
setSelectedEmailId(null)
|
||
return
|
||
}
|
||
if (!selectedEmailId || !visible.some(item => item.id === selectedEmailId)) {
|
||
setSelectedEmailId(visible[0].id)
|
||
}
|
||
}, [contactSubmissions, emailMailboxView, selectedEmailId])
|
||
|
||
useEffect(() => {
|
||
setQuestionPage(0)
|
||
}, [questionSearch, questionFilter])
|
||
|
||
async function reloadContactReplyHistory() {
|
||
const r = await fetch('/api/admin-contact-reply-history')
|
||
if (!r.ok) throw new Error('Could not refresh reply history')
|
||
const data = await r.json() as { items?: ContactReplyHistoryItem[] }
|
||
setContactReplyHistory(Array.isArray(data.items) ? data.items : [])
|
||
}
|
||
|
||
async function reloadContactReplyTemplates() {
|
||
const r = await fetch('/api/admin-contact-reply-templates')
|
||
if (!r.ok) throw new Error('Could not refresh reply templates')
|
||
const data = await r.json() as { templates?: ContactReplyTemplate[] }
|
||
setContactReplyTemplates(Array.isArray(data.templates) ? data.templates : [])
|
||
}
|
||
|
||
async function reloadBackups() {
|
||
const r = await fetch('/api/admin-stats/backups')
|
||
if (!r.ok) throw new Error('Could not refresh backups')
|
||
const data = await r.json() as { backups?: BackupPreview[] }
|
||
const files = Array.isArray(data.backups) ? data.backups : []
|
||
setBackupFiles(files)
|
||
const names = files.map(f => f.filename)
|
||
if (files.length > 0 && !names.includes(selectedBackup)) {
|
||
setSelectedBackup(files[0].filename)
|
||
}
|
||
}
|
||
|
||
async function reloadContentFromServer() {
|
||
const draftRes = await fetch('/api/admin-content?source=draft')
|
||
if (draftRes.ok) {
|
||
const draftData = await draftRes.json() as { siteContent?: Partial<SiteContent> }
|
||
if (draftData?.siteContent && typeof draftData.siteContent === 'object') {
|
||
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...draftData.siteContent })
|
||
setForm(next)
|
||
onSave(next)
|
||
return
|
||
}
|
||
}
|
||
|
||
const publishedRes = await fetch('/api/admin-content')
|
||
if (!publishedRes.ok) return
|
||
const publishedData = await publishedRes.json() as { siteContent?: Partial<SiteContent> }
|
||
if (publishedData?.siteContent && typeof publishedData.siteContent === 'object') {
|
||
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...publishedData.siteContent })
|
||
setForm(next)
|
||
onSave(next)
|
||
}
|
||
}
|
||
|
||
async function reloadAssets() {
|
||
const r = await fetch('/api/admin-assets')
|
||
if (!r.ok) throw new Error('Could not refresh assets')
|
||
const data = await r.json() as { assets?: AdminAsset[] }
|
||
const incomingAssets = Array.isArray(data.assets) ? data.assets : []
|
||
setAssets(incomingAssets)
|
||
setAssetTagEdits(incomingAssets.reduce<Record<string, string>>((memo, asset) => {
|
||
memo[asset.filename] = (asset.tags ?? []).join(', ')
|
||
return memo
|
||
}, {}))
|
||
}
|
||
|
||
async function reloadOpsStatus() {
|
||
const r = await fetch('/api/admin-ops/status')
|
||
if (!r.ok) throw new Error('Could not refresh ops status')
|
||
const data = await r.json() as OpsStatus
|
||
setOpsStatus(data)
|
||
}
|
||
|
||
async function handleSaveDraft() {
|
||
const payload = buildSiteContentForSave(form)
|
||
setStatus('saving')
|
||
setErrorMsg('')
|
||
try {
|
||
const res = await fetch('/api/admin-content-draft', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ siteContent: payload }),
|
||
})
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Draft save failed')
|
||
}
|
||
const data = await res.json() as { updatedAt?: string }
|
||
setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() }))
|
||
setLastSavedSnapshot(JSON.stringify(payload))
|
||
clearAutosaveDraft()
|
||
setStatus('saved')
|
||
if (saveStatusTimeoutRef.current !== null) {
|
||
window.clearTimeout(saveStatusTimeoutRef.current)
|
||
}
|
||
saveStatusTimeoutRef.current = window.setTimeout(() => setStatus('idle'), 3500)
|
||
} catch (err) {
|
||
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
|
||
setStatus('error')
|
||
}
|
||
}
|
||
|
||
async function handlePublishDraft() {
|
||
if (!confirm('Publish current changes to the live site now?')) return
|
||
const payload = buildSiteContentForSave(form)
|
||
setStatus('saving')
|
||
setErrorMsg('')
|
||
try {
|
||
// Always save the current form to draft first so publish never fails from a missing draft
|
||
const draftRes = await fetch('/api/admin-content-draft', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ siteContent: payload }),
|
||
})
|
||
if (!draftRes.ok) {
|
||
const data = await draftRes.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Draft save failed')
|
||
}
|
||
const draftData = await draftRes.json() as { updatedAt?: string }
|
||
setPublishState(prev => ({ ...prev, draftUpdatedAt: draftData.updatedAt ?? new Date().toISOString() }))
|
||
|
||
const publishRes = await fetch('/api/admin-content/publish', { method: 'POST' })
|
||
if (!publishRes.ok) {
|
||
const data = await publishRes.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Publish failed')
|
||
}
|
||
|
||
const published = await publishRes.json() as { publishedAt?: string }
|
||
const latestRes = await fetch('/api/admin-content?source=draft')
|
||
if (!latestRes.ok) throw new Error('Failed to refresh published content')
|
||
const latest = await latestRes.json() as { siteContent?: Partial<SiteContent> }
|
||
if (latest?.siteContent) {
|
||
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...latest.siteContent })
|
||
setForm(next)
|
||
onSave(next)
|
||
setLastSavedSnapshot(JSON.stringify(next))
|
||
}
|
||
|
||
setPublishState(prev => ({ ...prev, publishedAt: published.publishedAt ?? new Date().toISOString() }))
|
||
clearAutosaveDraft()
|
||
await reloadStats()
|
||
setStatus('saved')
|
||
if (saveStatusTimeoutRef.current !== null) {
|
||
window.clearTimeout(saveStatusTimeoutRef.current)
|
||
}
|
||
saveStatusTimeoutRef.current = window.setTimeout(() => setStatus('idle'), 3500)
|
||
} catch (err) {
|
||
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
|
||
setStatus('error')
|
||
}
|
||
}
|
||
|
||
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) }))
|
||
}
|
||
|
||
function addChecklistTask(phase: ChecklistPhase) {
|
||
const id = `task-${Date.now().toString(36)}`
|
||
setPodcastChecklist(prev => ({
|
||
tasks: [...prev.tasks, { id, label: '', phase }],
|
||
episodes: prev.episodes.map(episode => ({
|
||
...episode,
|
||
tasks: {
|
||
...episode.tasks,
|
||
[id]: false,
|
||
},
|
||
})),
|
||
}))
|
||
}
|
||
|
||
function updateChecklistTask(id: string, field: 'label' | 'phase', value: string) {
|
||
setPodcastChecklist(prev => ({
|
||
...prev,
|
||
tasks: prev.tasks.map(task => task.id === id
|
||
? {
|
||
...task,
|
||
[field]: field === 'phase' ? (value === 'post' ? 'post' : 'pre') : value,
|
||
}
|
||
: task),
|
||
}))
|
||
}
|
||
|
||
function removeChecklistTask(id: string) {
|
||
setPodcastChecklist(prev => ({
|
||
tasks: prev.tasks.filter(task => task.id !== id),
|
||
episodes: prev.episodes.map(episode => {
|
||
const nextTasks = { ...episode.tasks }
|
||
delete nextTasks[id]
|
||
return {
|
||
...episode,
|
||
tasks: nextTasks,
|
||
}
|
||
}),
|
||
}))
|
||
}
|
||
|
||
function addChecklistEpisode() {
|
||
const id = `episode-${Date.now().toString(36)}`
|
||
setPodcastChecklist(prev => ({
|
||
...prev,
|
||
episodes: [
|
||
...prev.episodes,
|
||
{
|
||
id,
|
||
series: 'Colossians',
|
||
episodeNumber: null,
|
||
title: '',
|
||
datePublished: '',
|
||
expanded: false,
|
||
tasks: Object.fromEntries(prev.tasks.map(task => [task.id, false])),
|
||
},
|
||
],
|
||
}))
|
||
}
|
||
|
||
function updateChecklistEpisode(id: string, field: 'series' | 'episodeNumber' | 'title' | 'datePublished', value: string) {
|
||
setPodcastChecklist(prev => ({
|
||
...prev,
|
||
episodes: prev.episodes.map(episode => {
|
||
if (episode.id !== id) return episode
|
||
if (field === 'episodeNumber') {
|
||
const parsed = Number.parseInt(value, 10)
|
||
return {
|
||
...episode,
|
||
episodeNumber: Number.isNaN(parsed) ? null : parsed,
|
||
}
|
||
}
|
||
return {
|
||
...episode,
|
||
[field]: value,
|
||
}
|
||
}),
|
||
}))
|
||
}
|
||
|
||
function toggleChecklistEpisodeTask(episodeId: string, taskId: string) {
|
||
setPodcastChecklist(prev => ({
|
||
...prev,
|
||
episodes: prev.episodes.map(episode => {
|
||
if (episode.id !== episodeId) return episode
|
||
return {
|
||
...episode,
|
||
tasks: {
|
||
...episode.tasks,
|
||
[taskId]: !episode.tasks[taskId],
|
||
},
|
||
}
|
||
}),
|
||
}))
|
||
}
|
||
|
||
function resetChecklistEpisode(episodeId: string) {
|
||
setPodcastChecklist(prev => ({
|
||
...prev,
|
||
episodes: prev.episodes.map(episode => {
|
||
if (episode.id !== episodeId) return episode
|
||
return {
|
||
...episode,
|
||
datePublished: '',
|
||
tasks: Object.fromEntries(prev.tasks.map(task => [task.id, false])),
|
||
}
|
||
}),
|
||
}))
|
||
}
|
||
|
||
function removeChecklistEpisode(id: string) {
|
||
setPodcastChecklist(prev => ({
|
||
...prev,
|
||
episodes: prev.episodes.filter(episode => episode.id !== id),
|
||
}))
|
||
}
|
||
|
||
async function handleSavePodcastChecklist() {
|
||
setPodcastChecklistStatus('saving')
|
||
setPodcastChecklistMsg('')
|
||
try {
|
||
const res = await fetch('/api/admin-podcast-checklist', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ checklist: podcastChecklist }),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({})) as { message?: string }
|
||
throw new Error(data.message ?? 'Failed to save checklist')
|
||
}
|
||
|
||
const data = await res.json() as { checklist?: PodcastChecklistData }
|
||
if (data.checklist?.tasks && data.checklist?.episodes) {
|
||
setPodcastChecklist(data.checklist)
|
||
}
|
||
setPodcastChecklistStatus('saved')
|
||
setTimeout(() => setPodcastChecklistStatus('idle'), 3000)
|
||
} catch (err) {
|
||
setPodcastChecklistMsg(err instanceof Error ? err.message : 'Failed to save checklist')
|
||
setPodcastChecklistStatus('error')
|
||
}
|
||
}
|
||
|
||
async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||
const file = event.target.files?.[0]
|
||
if (!file) return
|
||
|
||
setAssetUploadPending(true)
|
||
setOpsMsg('')
|
||
try {
|
||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||
const reader = new FileReader()
|
||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||
reader.onerror = () => reject(new Error('Failed to read file'))
|
||
reader.readAsDataURL(file)
|
||
})
|
||
|
||
const res = await fetch('/api/admin-assets', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: file.name, dataUrl }),
|
||
})
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Upload failed')
|
||
}
|
||
await reloadAssets()
|
||
setOpsMsg('Asset uploaded successfully.')
|
||
} catch (err) {
|
||
setOpsMsg(err instanceof Error ? err.message : 'Upload failed.')
|
||
} finally {
|
||
setAssetUploadPending(false)
|
||
event.target.value = ''
|
||
}
|
||
}
|
||
|
||
async function handleDeleteAsset(filename: string) {
|
||
if (!confirm(`Delete asset ${filename}?`)) return
|
||
try {
|
||
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, { method: 'DELETE' })
|
||
if (!res.ok) throw new Error('Delete failed')
|
||
await reloadAssets()
|
||
setOpsMsg('Asset deleted.')
|
||
} catch {
|
||
setOpsMsg('Asset delete failed.')
|
||
}
|
||
}
|
||
|
||
async function handleSaveAssetTags(filename: string) {
|
||
const tagsText = assetTagEdits[filename] ?? ''
|
||
const tags = tagsText.split(',').map(tag => tag.trim()).filter(Boolean)
|
||
try {
|
||
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tags }),
|
||
})
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Save failed')
|
||
}
|
||
await reloadAssets()
|
||
setOpsMsg('Asset tags saved.')
|
||
} catch (err) {
|
||
setOpsMsg(err instanceof Error ? err.message : 'Save failed.')
|
||
}
|
||
}
|
||
|
||
async function handlePurgeCache() {
|
||
try {
|
||
const res = await fetch('/api/admin-ops/purge-cache', { method: 'POST' })
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Cache purge failed')
|
||
}
|
||
await reloadOpsStatus()
|
||
setOpsMsg('Cache purge triggered.')
|
||
} catch (err) {
|
||
setOpsMsg(err instanceof Error ? err.message : 'Cache purge failed.')
|
||
}
|
||
}
|
||
|
||
async function handleDeployHook() {
|
||
try {
|
||
const res = await fetch('/api/admin-ops/deploy', { method: 'POST' })
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Deploy hook failed')
|
||
}
|
||
await reloadOpsStatus()
|
||
setOpsMsg('Deploy hook triggered.')
|
||
} catch (err) {
|
||
setOpsMsg(err instanceof Error ? err.message : 'Deploy hook failed.')
|
||
}
|
||
}
|
||
|
||
function maskIp(ip: string) {
|
||
if (!ip || ip === 'unknown') return 'unknown'
|
||
if (ip.includes('.')) {
|
||
const parts = ip.split('.')
|
||
if (parts.length === 4) return `${parts[0]}.${parts[1]}.x.x`
|
||
}
|
||
if (ip.includes(':')) {
|
||
const parts = ip.split(':')
|
||
return `${parts.slice(0, 3).join(':')}:x:x`
|
||
}
|
||
return ip
|
||
}
|
||
|
||
async function handleExport() {
|
||
try {
|
||
const r = await fetch('/api/admin-stats/export')
|
||
if (!r.ok) throw new Error('Export failed')
|
||
const data = await r.json()
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `siteforge-admin-export-${new Date().toISOString().slice(0, 10)}.json`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
setMaintenanceMsg('Export downloaded.')
|
||
} catch {
|
||
setMaintenanceMsg('Export failed.')
|
||
}
|
||
}
|
||
|
||
async function handlePrune() {
|
||
const input = prompt('Keep how many days of analytics data?', '180')
|
||
if (input === null) return
|
||
const days = Number(input)
|
||
if (!Number.isFinite(days) || days <= 0) {
|
||
setMaintenanceMsg('Invalid retention days.')
|
||
return
|
||
}
|
||
try {
|
||
const r = await fetch('/api/admin-stats/prune', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ days }),
|
||
})
|
||
if (!r.ok) throw new Error('Prune failed')
|
||
await reloadStats()
|
||
setMaintenanceMsg(`Pruned analytics to ${Math.floor(days)} days.`)
|
||
} catch {
|
||
setMaintenanceMsg('Prune failed.')
|
||
}
|
||
}
|
||
|
||
async function handleClear() {
|
||
if (!confirm('Clear ALL analytics data now? This cannot be undone.')) return
|
||
try {
|
||
const r = await fetch('/api/admin-stats/clear', { method: 'POST' })
|
||
if (!r.ok) throw new Error('Clear failed')
|
||
await reloadStats()
|
||
setMaintenanceMsg('All analytics data cleared.')
|
||
} catch {
|
||
setMaintenanceMsg('Clear failed.')
|
||
}
|
||
}
|
||
|
||
async function handleBackupNow() {
|
||
try {
|
||
const r = await fetch('/api/admin-stats/backup', { method: 'POST' })
|
||
if (!r.ok) throw new Error('Backup failed')
|
||
await reloadStats()
|
||
await reloadBackups()
|
||
setMaintenanceMsg('Backup snapshot created.')
|
||
} catch {
|
||
setMaintenanceMsg('Backup failed.')
|
||
}
|
||
}
|
||
|
||
async function handleRestoreBackup() {
|
||
if (!selectedBackup) {
|
||
setMaintenanceMsg('Select a backup first.')
|
||
return
|
||
}
|
||
if (!confirm(`Restore backup ${selectedBackup}? This will overwrite current admin data and analytics.`)) return
|
||
try {
|
||
const r = await fetch('/api/admin-stats/restore', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename: selectedBackup }),
|
||
})
|
||
if (!r.ok) throw new Error('Restore failed')
|
||
await reloadContentFromServer()
|
||
await reloadStats()
|
||
await reloadBackups()
|
||
setMaintenanceMsg(`Restored from ${selectedBackup}. Content fields were refreshed from backup.`)
|
||
} catch {
|
||
setMaintenanceMsg('Restore failed.')
|
||
}
|
||
}
|
||
|
||
function formatDate(value: string | null) {
|
||
if (!value) return 'Not available yet'
|
||
const d = new Date(value)
|
||
return Number.isNaN(d.getTime()) ? 'Not available yet' : d.toLocaleString()
|
||
}
|
||
|
||
function handleChange(key: StringField, value: string) {
|
||
setForm(f => ({ ...f, [key]: value }))
|
||
}
|
||
|
||
function renderImageAssetSelector(value: string | null | undefined, onChange: (value: string) => void, fieldId: string) {
|
||
const assetOptions = [...assets.map(asset => ({ label: asset.filename, value: asset.url }))]
|
||
const currentValue = value?.trim() ?? ''
|
||
|
||
if (currentValue && !assetOptions.some(item => item.value === currentValue)) {
|
||
assetOptions.unshift({ label: `Current image (${currentValue})`, value: currentValue })
|
||
}
|
||
|
||
if (assetOptions.length === 0) return null
|
||
|
||
return (
|
||
<div className="admin-field-asset-picker">
|
||
<label htmlFor={fieldId}>Choose an existing image</label>
|
||
<select id={fieldId} value={assetOptions.some(a => a.value === currentValue) ? currentValue : ''} onChange={e => onChange(e.target.value)}>
|
||
<option value="">Select image</option>
|
||
{assetOptions.map(option => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function renderImagePreview(value: string | null | undefined, alt: string) {
|
||
const src = value?.trim() ?? ''
|
||
if (!src) return null
|
||
|
||
return (
|
||
<div className="admin-field-image-preview">
|
||
<span className="admin-field-image-preview-label">Preview</span>
|
||
<img src={src} alt={alt} className="admin-field-image-preview-img" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function getCustomLinkPlacementLabel(placement: CustomLink['placement']) {
|
||
if (placement === 'platforms') return 'Homepage listen/platform links'
|
||
if (placement === 'footer') return 'Footer navigation links'
|
||
if (placement === 'otherSites') return 'Other site links in footer dropdown'
|
||
if (placement === 'externalSites') return 'Homepage external site links'
|
||
return 'Downloads page (More Resources)'
|
||
}
|
||
|
||
function getCustomLinkPreviewPath(placement: CustomLink['placement']) {
|
||
if (placement === 'resources') return '/resources'
|
||
return '/'
|
||
}
|
||
|
||
function getContentBlockPageLabel(page: CustomBlock['page']) {
|
||
if (page === 'homepage') return 'Homepage'
|
||
if (page === 'start-here') return 'Start Here'
|
||
if (page === 'episodes') return 'Episodes'
|
||
if (page === 'downloads') return 'Downloads'
|
||
if (page === 'about') return 'About'
|
||
if (page === 'contact') return 'Contact'
|
||
return 'Q&A'
|
||
}
|
||
|
||
function getContentBlockPreviewPath(page: CustomBlock['page']) {
|
||
if (page === 'homepage') return '/'
|
||
if (page === 'start-here') return '/start-here'
|
||
if (page === 'episodes') return '/episodes'
|
||
if (page === 'downloads') return '/resources'
|
||
if (page === 'about') return '/about'
|
||
if (page === 'contact') return '/contact'
|
||
return '/questions'
|
||
}
|
||
|
||
function renderFileAssetSelector(value: string | null | undefined, onChange: (value: string) => void, fieldId: string) {
|
||
const assetOptions = [...assets.map(asset => ({ label: asset.filename, value: asset.url }))]
|
||
const currentValue = value?.trim() ?? ''
|
||
|
||
if (currentValue && !assetOptions.some(item => item.value === currentValue)) {
|
||
assetOptions.unshift({ label: `Current file (${currentValue})`, value: currentValue })
|
||
}
|
||
|
||
if (assetOptions.length === 0) return null
|
||
|
||
return (
|
||
<div className="admin-field-asset-picker">
|
||
<label htmlFor={fieldId}>Choose an existing file</label>
|
||
<select id={fieldId} value={assetOptions.some(a => a.value === currentValue) ? currentValue : ''} onChange={e => onChange(e.target.value)}>
|
||
<option value="">Select file</option>
|
||
{assetOptions.map(option => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function isImageAsset(filename: string) {
|
||
return /\.(png|jpe?g|webp|gif)$/i.test(filename)
|
||
}
|
||
|
||
function confirmLeaveUnsavedChanges() {
|
||
if (!isDirty) return true
|
||
return confirm('You have unsaved changes. Leave this section without saving?')
|
||
}
|
||
|
||
function navigateTo(view: AdminView) {
|
||
if (view === adminView) return
|
||
if (!confirmLeaveUnsavedChanges()) return
|
||
setAdminView(view)
|
||
setMobileNavOpen(false)
|
||
}
|
||
|
||
function getAdminViewBadge(view: AdminView) {
|
||
if (view === 'questions' && unansweredCount > 0) {
|
||
return { count: unansweredCount, neutral: false }
|
||
}
|
||
if (view === 'emails' && unreadEmailCount > 0) {
|
||
return { count: unreadEmailCount, neutral: false }
|
||
}
|
||
if (view === 'contacts' && contactSubmissions.length > 0) {
|
||
return { count: contactSubmissions.length, neutral: true }
|
||
}
|
||
if (view === 'subscribers' && subscribers.length > 0) {
|
||
return { count: subscribers.length, neutral: true }
|
||
}
|
||
return null
|
||
}
|
||
|
||
function renderAdminNavItem(view: AdminView, label: string) {
|
||
const badge = getAdminViewBadge(view)
|
||
|
||
return (
|
||
<button
|
||
key={view}
|
||
type="button"
|
||
className={`admin-nav-item${adminView === view ? ' admin-nav-item--active' : ''}`}
|
||
onClick={() => navigateTo(view)}
|
||
>
|
||
{label}
|
||
{badge && (
|
||
<span className={`admin-nav-badge${badge.neutral ? ' admin-nav-badge--neutral' : ''}`}>
|
||
{badge.count}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function jumpToAdminSection(sectionId: string) {
|
||
document.getElementById(sectionId)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
|
||
function renderSectionJumpNav(view: AdminView) {
|
||
const links = ADMIN_SECTION_LINKS[view] ?? []
|
||
if (links.length < 2) return null
|
||
|
||
return (
|
||
<div className="admin-section-jump-nav" aria-label="Jump to section">
|
||
<span className="admin-section-jump-nav-label">Jump to</span>
|
||
<div className="admin-section-jump-nav-links">
|
||
{links.map(link => (
|
||
<button
|
||
key={link.id}
|
||
type="button"
|
||
className="admin-section-jump-nav-btn"
|
||
onClick={() => jumpToAdminSection(link.id)}
|
||
>
|
||
{link.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function addLink() {
|
||
setForm(f => ({
|
||
...f,
|
||
customLinks: [
|
||
...(f.customLinks ?? []),
|
||
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'platforms' as const },
|
||
],
|
||
}))
|
||
}
|
||
|
||
function addOtherSiteLink() {
|
||
setForm(f => ({
|
||
...f,
|
||
customLinks: [
|
||
...(f.customLinks ?? []),
|
||
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'otherSites' as const },
|
||
],
|
||
}))
|
||
}
|
||
|
||
function addExternalSiteLink() {
|
||
setForm(f => ({
|
||
...f,
|
||
customLinks: [
|
||
...(f.customLinks ?? []),
|
||
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', placement: 'externalSites' as const },
|
||
],
|
||
}))
|
||
}
|
||
|
||
function addResource() {
|
||
setForm(f => ({
|
||
...f,
|
||
customLinks: [
|
||
...(f.customLinks ?? []),
|
||
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', placement: 'resources' as const },
|
||
],
|
||
}))
|
||
}
|
||
|
||
function updateLink(id: string, field: keyof CustomLink, value: string | string[]) {
|
||
setForm(f => ({
|
||
...f,
|
||
customLinks: (f.customLinks ?? []).map(l => l.id === id ? { ...l, [field]: value } : l),
|
||
}))
|
||
}
|
||
|
||
function removeLink(id: string) {
|
||
setForm(f => ({ ...f, customLinks: (f.customLinks ?? []).filter(l => l.id !== id) }))
|
||
}
|
||
|
||
function moveLinkToResources(id: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
customLinks: (f.customLinks ?? []).map(link => (
|
||
link.id === id ? { ...link, placement: 'resources' as const } : link
|
||
)),
|
||
}))
|
||
}
|
||
|
||
function addBlock() {
|
||
setForm(f => ({
|
||
...f,
|
||
customBlocks: [
|
||
...(f.customBlocks ?? []),
|
||
{ id: Date.now().toString(36), heading: '', body: '', page: 'homepage' as const },
|
||
],
|
||
}))
|
||
}
|
||
|
||
function updateBlock(id: string, field: keyof CustomBlock, value: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
customBlocks: (f.customBlocks ?? []).map(b => b.id === id ? { ...b, [field]: value } : b),
|
||
}))
|
||
}
|
||
|
||
function removeBlock(id: string) {
|
||
setForm(f => ({ ...f, customBlocks: (f.customBlocks ?? []).filter(b => b.id !== id) }))
|
||
}
|
||
|
||
function addArchivedSeries() {
|
||
setForm(f => ({
|
||
...f,
|
||
archivedSeries: [
|
||
...(f.archivedSeries ?? []),
|
||
{
|
||
id: Date.now().toString(36),
|
||
label: 'Archived Study',
|
||
title: '',
|
||
description: '',
|
||
imageUrl: '',
|
||
listenUrl: '',
|
||
studyGuideTitle: '',
|
||
studyGuideDescription: '',
|
||
studyGuideUrl: '',
|
||
resourceLinks: [],
|
||
notes: [],
|
||
},
|
||
],
|
||
}))
|
||
}
|
||
|
||
function updateArchivedSeries(id: string, field: keyof ArchivedSeries, value: string | ArchivedSeriesResourceLink[] | ArchivedSeriesNote[] | { from: number; to: number } | undefined) {
|
||
setForm(f => ({
|
||
...f,
|
||
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === id ? { ...series, [field]: value } : series),
|
||
}))
|
||
}
|
||
|
||
function removeArchivedSeries(id: string) {
|
||
setForm(f => ({ ...f, archivedSeries: (f.archivedSeries ?? []).filter(series => series.id !== id) }))
|
||
}
|
||
|
||
function addArchivedSeriesLink(seriesId: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
|
||
? {
|
||
...series,
|
||
resourceLinks: [
|
||
...(series.resourceLinks ?? []),
|
||
{ id: `${seriesId}-${Date.now().toString(36)}`, label: '', description: '', url: '', amazonUrl: '', amazonLabel: '' },
|
||
],
|
||
}
|
||
: series),
|
||
}))
|
||
}
|
||
|
||
function updateArchivedSeriesLink(seriesId: string, linkId: string, field: keyof ArchivedSeriesResourceLink, value: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
|
||
? {
|
||
...series,
|
||
resourceLinks: (series.resourceLinks ?? []).map(link => link.id === linkId ? { ...link, [field]: value } : link),
|
||
}
|
||
: series),
|
||
}))
|
||
}
|
||
|
||
function removeArchivedSeriesLink(seriesId: string, linkId: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
|
||
? { ...series, resourceLinks: (series.resourceLinks ?? []).filter(link => link.id !== linkId) }
|
||
: series),
|
||
}))
|
||
}
|
||
|
||
function addExistingCustomLinkToArchivedSeries(seriesId: string) {
|
||
const selectedLinkId = archiveLinkSelectionBySeries[seriesId]
|
||
if (!selectedLinkId) return
|
||
|
||
const source = (form.customLinks ?? []).find(link => link.id === selectedLinkId)
|
||
if (!source) return
|
||
|
||
setForm(f => ({
|
||
...f,
|
||
archivedSeries: (f.archivedSeries ?? []).map(series => {
|
||
if (series.id !== seriesId) return series
|
||
|
||
const alreadyExists = (series.resourceLinks ?? []).some(link =>
|
||
link.url.trim().toLowerCase() === source.url.trim().toLowerCase(),
|
||
)
|
||
|
||
if (alreadyExists) return series
|
||
|
||
return {
|
||
...series,
|
||
resourceLinks: [
|
||
...(series.resourceLinks ?? []),
|
||
{
|
||
id: `${seriesId}-${Date.now().toString(36)}`,
|
||
label: source.label,
|
||
description: source.description ?? '',
|
||
url: source.url,
|
||
amazonUrl: source.amazonUrl ?? '',
|
||
amazonLabel: source.amazonLabel ?? '',
|
||
},
|
||
],
|
||
}
|
||
}),
|
||
}))
|
||
}
|
||
|
||
function addAllExistingCustomLinksToArchivedSeries(seriesId: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
archivedSeries: (f.archivedSeries ?? []).map(series => {
|
||
if (series.id !== seriesId) return series
|
||
|
||
const existingUrls = new Set(
|
||
(series.resourceLinks ?? [])
|
||
.map(link => link.url.trim().toLowerCase())
|
||
.filter(Boolean),
|
||
)
|
||
|
||
const toAdd = (f.customLinks ?? [])
|
||
.filter(link => link.url.trim().length > 0)
|
||
.filter(link => !existingUrls.has(link.url.trim().toLowerCase()))
|
||
.map(link => ({
|
||
id: `${seriesId}-${Date.now().toString(36)}-${link.id}`,
|
||
label: link.label,
|
||
description: link.description ?? '',
|
||
url: link.url,
|
||
amazonUrl: link.amazonUrl ?? '',
|
||
amazonLabel: link.amazonLabel ?? '',
|
||
}))
|
||
|
||
if (toAdd.length === 0) return series
|
||
|
||
return {
|
||
...series,
|
||
resourceLinks: [
|
||
...(series.resourceLinks ?? []),
|
||
...toAdd,
|
||
],
|
||
}
|
||
}),
|
||
}))
|
||
}
|
||
|
||
function addArchivedSeriesNote(seriesId: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
|
||
? {
|
||
...series,
|
||
notes: [
|
||
...(series.notes ?? []),
|
||
{ id: `${seriesId}-note-${Date.now().toString(36)}`, heading: '', body: '' },
|
||
],
|
||
}
|
||
: series),
|
||
}))
|
||
}
|
||
|
||
function updateArchivedSeriesNote(seriesId: string, noteId: string, field: keyof ArchivedSeriesNote, value: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
|
||
? {
|
||
...series,
|
||
notes: (series.notes ?? []).map(note => note.id === noteId ? { ...note, [field]: value } : note),
|
||
}
|
||
: series),
|
||
}))
|
||
}
|
||
|
||
function removeArchivedSeriesNote(seriesId: string, noteId: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
|
||
? { ...series, notes: (series.notes ?? []).filter(note => note.id !== noteId) }
|
||
: series),
|
||
}))
|
||
}
|
||
|
||
function addStudyProgram() {
|
||
setForm(f => ({
|
||
...f,
|
||
studies: [
|
||
...(f.studies ?? []),
|
||
{
|
||
id: `study-${Date.now().toString(36)}`,
|
||
slug: `new-study-${Date.now().toString(36)}`,
|
||
title: 'New Study',
|
||
description: 'Add study description',
|
||
homepageEyebrow: 'New Study',
|
||
showOnHomepage: false,
|
||
showNewTag: false,
|
||
newTagLabel: 'NEW',
|
||
status: 'planned',
|
||
difficulty: 'beginner',
|
||
estimatedHours: 8,
|
||
completionBadge: 'Study Completion',
|
||
numberOfChapters: 4,
|
||
sections: [],
|
||
},
|
||
],
|
||
}))
|
||
}
|
||
|
||
function updateStudyProgram(studyId: string, field: keyof Omit<StudyProgram, 'sections'>, value: string | number | boolean) {
|
||
setForm(f => ({
|
||
...f,
|
||
studies: (f.studies ?? []).map(study => study.id === studyId ? { ...study, [field]: value } : study),
|
||
}))
|
||
}
|
||
|
||
function removeStudyProgram(studyId: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
studies: (f.studies ?? []).filter(study => study.id !== studyId),
|
||
}))
|
||
}
|
||
|
||
function addStudySection(studyId: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
studies: (f.studies ?? []).map(study => study.id === studyId
|
||
? {
|
||
...study,
|
||
sections: [
|
||
...(study.sections ?? []),
|
||
{
|
||
id: `${study.slug || 'section'}-${Date.now().toString(36)}`,
|
||
chapter: 1,
|
||
reference: '',
|
||
title: '',
|
||
audioEmbedUrl: '',
|
||
passageText: '',
|
||
summary: '',
|
||
commentary: '',
|
||
greekNotes: [],
|
||
studyQuestions: [],
|
||
checkpointPrompt: '',
|
||
checkpointQuestions: [],
|
||
},
|
||
],
|
||
}
|
||
: study),
|
||
}))
|
||
}
|
||
|
||
function updateStudySection(studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | number | string[]) {
|
||
setForm(f => ({
|
||
...f,
|
||
studies: (f.studies ?? []).map(study => study.id === studyId
|
||
? {
|
||
...study,
|
||
sections: (study.sections ?? []).map(section => section.id === sectionId ? { ...section, [field]: value } : section),
|
||
}
|
||
: study),
|
||
}))
|
||
}
|
||
|
||
function removeStudySection(studyId: string, sectionId: string) {
|
||
setForm(f => ({
|
||
...f,
|
||
studies: (f.studies ?? []).map(study => study.id === studyId
|
||
? { ...study, sections: (study.sections ?? []).filter(section => section.id !== sectionId) }
|
||
: study),
|
||
}))
|
||
}
|
||
|
||
function archiveCurrentSeriesSnapshot() {
|
||
const currentTitle = form.seriesTitle.trim()
|
||
if (!currentTitle) {
|
||
alert('Set a current series title first, then archive it.')
|
||
return
|
||
}
|
||
|
||
const existing = (form.archivedSeries ?? []).some(
|
||
series => series.title.trim().toLowerCase() === currentTitle.toLowerCase(),
|
||
)
|
||
|
||
if (existing && !confirm(`An archived series named "${currentTitle}" already exists. Create another snapshot anyway?`)) {
|
||
return
|
||
}
|
||
|
||
const resourceLinks = (form.customLinks ?? [])
|
||
.filter(link => link.placement === 'resources')
|
||
.filter(link => link.label.trim().length > 0 || link.url.trim().length > 0)
|
||
.map(link => ({
|
||
id: `archive-link-${Date.now().toString(36)}-${link.id}`,
|
||
label: link.label,
|
||
url: link.url,
|
||
}))
|
||
|
||
const notes = (form.customBlocks ?? [])
|
||
.filter(block => block.heading.trim().length > 0 || block.body.trim().length > 0)
|
||
.map(block => ({
|
||
id: `archive-note-${Date.now().toString(36)}-${block.id}`,
|
||
heading: block.heading,
|
||
body: block.body,
|
||
}))
|
||
|
||
const archived: ArchivedSeries = {
|
||
id: `archive-${Date.now().toString(36)}`,
|
||
label: form.seriesLabel?.trim() || 'Archived Study',
|
||
title: form.seriesTitle,
|
||
description: form.seriesDescription,
|
||
imageUrl: form.seriesImageUrl,
|
||
listenUrl: form.seriesListenUrl,
|
||
studyGuideTitle: form.studyGuideTitle,
|
||
studyGuideDescription: form.studyGuideDescription,
|
||
studyGuideUrl: form.studyGuideUrl,
|
||
resourceLinks,
|
||
notes,
|
||
}
|
||
|
||
setForm(f => ({
|
||
...f,
|
||
archivedSeries: [archived, ...(f.archivedSeries ?? [])],
|
||
}))
|
||
navigateTo('archived-series')
|
||
}
|
||
|
||
async function handleAnswerQuestion(questionId: string, answer: string) {
|
||
if (!answer.trim()) return
|
||
try {
|
||
const res = await fetch(`/api/admin-questions/${questionId}/answer`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ answer: answer.trim() }),
|
||
})
|
||
if (!res.ok) throw new Error('Failed to answer question')
|
||
setQuestions(qs =>
|
||
qs.map(q =>
|
||
q.id === questionId
|
||
? { ...q, answer: answer.trim(), answeredAt: new Date().toISOString() }
|
||
: q
|
||
)
|
||
)
|
||
setEditingQuestionId(null)
|
||
setAnsweredQuestions(a => ({ ...a, [questionId]: '' }))
|
||
} catch {
|
||
alert('Failed to save answer')
|
||
}
|
||
}
|
||
|
||
async function handleApproveQuestion(questionId: string, approved: boolean) {
|
||
try {
|
||
const res = await fetch(`/api/admin-questions/${questionId}/approve`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ approved }),
|
||
})
|
||
if (!res.ok) throw new Error('Failed to update question')
|
||
setQuestions(qs =>
|
||
qs.map(q =>
|
||
q.id === questionId
|
||
? { ...q, isApproved: approved, approvedAt: approved ? new Date().toISOString() : null }
|
||
: q
|
||
)
|
||
)
|
||
} catch {
|
||
alert('Failed to update question')
|
||
}
|
||
}
|
||
|
||
async function handleDeleteQuestion(questionId: string) {
|
||
if (!confirm('Delete this question permanently?')) return
|
||
try {
|
||
const res = await fetch(`/api/admin-questions/${questionId}`, { method: 'DELETE' })
|
||
if (!res.ok) throw new Error('Failed to delete question')
|
||
setQuestions(qs => qs.filter(q => q.id !== questionId))
|
||
setSelectedQuestionIds(prev => { const next = new Set(prev); next.delete(questionId); return next })
|
||
} catch {
|
||
alert('Failed to delete question')
|
||
}
|
||
}
|
||
|
||
async function handleBulkDeleteQuestions() {
|
||
if (selectedQuestionIds.size === 0) return
|
||
if (!confirm(`Delete ${selectedQuestionIds.size} question${selectedQuestionIds.size === 1 ? '' : 's'} permanently?`)) return
|
||
const ids = Array.from(selectedQuestionIds)
|
||
let deletedCount = 0
|
||
for (const id of ids) {
|
||
try {
|
||
const res = await fetch(`/api/admin-questions/${id}`, { method: 'DELETE' })
|
||
if (res.ok) {
|
||
deletedCount++
|
||
setQuestions(qs => qs.filter(q => q.id !== id))
|
||
}
|
||
} catch {
|
||
// continue with remaining
|
||
}
|
||
}
|
||
setSelectedQuestionIds(new Set())
|
||
if (deletedCount < ids.length) alert(`Deleted ${deletedCount} of ${ids.length} questions.`)
|
||
}
|
||
|
||
async function handleCreateManualQuestion() {
|
||
if (!manualQuestion.firstName.trim() || !manualQuestion.question.trim()) {
|
||
setManualQuestionStatus('error')
|
||
setManualQuestionMsg('First name and question are required.')
|
||
return
|
||
}
|
||
|
||
setManualQuestionStatus('saving')
|
||
setManualQuestionMsg('')
|
||
|
||
try {
|
||
const res = await fetch('/api/admin-questions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
firstName: manualQuestion.firstName,
|
||
email: manualQuestion.email,
|
||
question: manualQuestion.question,
|
||
answer: manualQuestion.answer,
|
||
approve: manualQuestion.approve,
|
||
}),
|
||
})
|
||
|
||
const data = await res.json().catch(() => ({})) as { question?: Question; message?: string }
|
||
if (!res.ok || !data.question) {
|
||
throw new Error(data.message ?? 'Failed to add question.')
|
||
}
|
||
|
||
setQuestions(items => [data.question as Question, ...items])
|
||
setManualQuestion({ firstName: '', email: '', question: '', answer: '', approve: false })
|
||
setManualQuestionStatus('saved')
|
||
setManualQuestionMsg('Question added to draft Q&A list.')
|
||
} catch (err) {
|
||
setManualQuestionStatus('error')
|
||
setManualQuestionMsg(err instanceof Error ? err.message : 'Failed to add question.')
|
||
}
|
||
}
|
||
|
||
async function handleDeleteContactSubmission(submissionId: string) {
|
||
if (!confirm('Delete this contact submission permanently?')) return
|
||
try {
|
||
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, { method: 'DELETE' })
|
||
if (!res.ok) throw new Error('Failed to delete submission')
|
||
await reloadContactSubmissions()
|
||
await reloadStats()
|
||
setMaintenanceMsg('Contact submission deleted.')
|
||
} catch {
|
||
setMaintenanceMsg('Failed to delete contact submission.')
|
||
}
|
||
}
|
||
|
||
async function handleArchiveContactSubmission(submissionId: string, archived: boolean) {
|
||
try {
|
||
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ archived }),
|
||
})
|
||
if (!res.ok) throw new Error('Failed to update submission')
|
||
await reloadContactSubmissions()
|
||
await reloadStats()
|
||
setContactReplyMsg(archived ? 'Message archived.' : 'Message moved back to inbox.')
|
||
} catch {
|
||
setContactReplyMsg('Failed to update archive status.')
|
||
}
|
||
}
|
||
|
||
function openContactReplyComposer(submission: ContactSubmission) {
|
||
const firstName = submission.name?.trim().split(/\s+/)[0] || 'there'
|
||
setContactReplyDraft({
|
||
submissionId: submission.id,
|
||
recipientName: submission.name,
|
||
recipientEmail: submission.email,
|
||
subject: 'Thanks for reaching out to Verse by Verse with Nate',
|
||
message: `Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.`,
|
||
})
|
||
setContactReplyStatus('idle')
|
||
setContactReplyMsg(`Composing a reply to ${firstName}.`)
|
||
}
|
||
|
||
function applyContactReplyTemplate(templateId: string) {
|
||
if (!contactReplyDraft) return
|
||
const template = contactReplyTemplates.find(item => item.id === templateId)
|
||
if (!template) return
|
||
|
||
setContactReplyDraft({
|
||
...contactReplyDraft,
|
||
subject: template.subject,
|
||
message: template.message,
|
||
})
|
||
setContactReplyMsg(`Applied template: ${template.label}.`)
|
||
}
|
||
|
||
function addContactReplyTemplate() {
|
||
setContactReplyTemplates(items => ([
|
||
...items,
|
||
{
|
||
id: Date.now().toString(36),
|
||
label: '',
|
||
subject: '',
|
||
message: '',
|
||
},
|
||
]))
|
||
setContactTemplateStatus('idle')
|
||
}
|
||
|
||
function updateContactReplyTemplate(id: string, field: keyof ContactReplyTemplate, value: string) {
|
||
setContactReplyTemplates(items => items.map(item => item.id === id ? { ...item, [field]: value } : item))
|
||
setContactTemplateStatus('idle')
|
||
}
|
||
|
||
function removeContactReplyTemplate(id: string) {
|
||
setContactReplyTemplates(items => items.filter(item => item.id !== id))
|
||
setContactTemplateStatus('idle')
|
||
}
|
||
|
||
async function handleSaveContactReplyTemplates() {
|
||
setContactTemplateStatus('saving')
|
||
try {
|
||
const res = await fetch('/api/admin-contact-reply-templates', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ templates: contactReplyTemplates }),
|
||
})
|
||
if (!res.ok) throw new Error('Failed to save templates')
|
||
await reloadContactReplyTemplates()
|
||
setContactTemplateStatus('saved')
|
||
setContactReplyMsg('Reply templates saved.')
|
||
} catch {
|
||
setContactTemplateStatus('error')
|
||
setContactReplyMsg('Failed to save reply templates.')
|
||
}
|
||
}
|
||
|
||
async function handleSendContactReply() {
|
||
if (!contactReplyDraft) return
|
||
setContactReplyStatus('sending')
|
||
setContactReplyMsg('')
|
||
|
||
try {
|
||
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(contactReplyDraft.submissionId)}/reply`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
subject: contactReplyDraft.subject,
|
||
message: contactReplyDraft.message,
|
||
}),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}))
|
||
throw new Error((data as { message?: string }).message ?? 'Failed to send email.')
|
||
}
|
||
|
||
setContactReplyStatus('sent')
|
||
setContactReplyMsg(`Reply sent to ${contactReplyDraft.recipientEmail} from hello@versebyversewithnate.us.`)
|
||
await reloadContactReplyHistory()
|
||
setContactReplyDraft(null)
|
||
} catch (err) {
|
||
setContactReplyStatus('error')
|
||
setContactReplyMsg(err instanceof Error ? err.message : 'Failed to send email.')
|
||
}
|
||
}
|
||
|
||
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources')
|
||
const archivedResourceCount = (form.archivedSeries ?? []).reduce((count, series) => {
|
||
return count + (series.resourceLinks ?? []).length
|
||
}, 0)
|
||
|
||
const filteredAdminQuestions = questions.filter(question => {
|
||
const search = questionSearch.trim().toLowerCase()
|
||
const matchesSearch = !search
|
||
|| question.firstName.toLowerCase().includes(search)
|
||
|| question.question.toLowerCase().includes(search)
|
||
|| question.answer.toLowerCase().includes(search)
|
||
|
||
const matchesFilter = (
|
||
questionFilter === 'all'
|
||
|| (questionFilter === 'pending' && !question.isApproved)
|
||
|| (questionFilter === 'approved' && question.isApproved)
|
||
|| (questionFilter === 'answered' && Boolean(question.answer?.trim()))
|
||
|| (questionFilter === 'unanswered' && !question.answer?.trim())
|
||
)
|
||
|
||
return matchesSearch && matchesFilter
|
||
})
|
||
|
||
const QUESTION_PAGE_SIZE = 20
|
||
const totalQuestionPages = Math.max(1, Math.ceil(filteredAdminQuestions.length / QUESTION_PAGE_SIZE))
|
||
const visibleAdminQuestions = filteredAdminQuestions.slice(
|
||
questionPage * QUESTION_PAGE_SIZE,
|
||
(questionPage + 1) * QUESTION_PAGE_SIZE,
|
||
)
|
||
const checklistTaskOrder = [
|
||
'verify_script',
|
||
'read_script',
|
||
'record',
|
||
'mix',
|
||
'edit',
|
||
'video_script',
|
||
'post_spotify',
|
||
'update_website',
|
||
'send_email',
|
||
]
|
||
const checklistTaskOrderIndex = new Map(checklistTaskOrder.map((id, index) => [id, index]))
|
||
const checklistTasksSorted = [...podcastChecklist.tasks].sort((a, b) => {
|
||
const aIndex = checklistTaskOrderIndex.get(a.id)
|
||
const bIndex = checklistTaskOrderIndex.get(b.id)
|
||
const aKnown = typeof aIndex === 'number'
|
||
const bKnown = typeof bIndex === 'number'
|
||
|
||
if (aKnown && bKnown) return aIndex - bIndex
|
||
if (aKnown && !bKnown) return -1
|
||
if (!aKnown && bKnown) return 1
|
||
return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
|
||
})
|
||
const checklistPreTasks = checklistTasksSorted.filter(task => task.phase === 'pre')
|
||
const checklistPostTasks = checklistTasksSorted.filter(task => task.phase === 'post')
|
||
const checklistEpisodesSorted = [...podcastChecklist.episodes].sort((a, b) => {
|
||
const isDraft = (episode: PodcastChecklistEpisode) => {
|
||
const hasNumber = episode.episodeNumber !== null
|
||
const hasTitle = Boolean(episode.title?.trim())
|
||
const hasDate = Boolean(episode.datePublished?.trim())
|
||
return !hasNumber && !hasTitle && !hasDate
|
||
}
|
||
|
||
const aDraft = isDraft(a)
|
||
const bDraft = isDraft(b)
|
||
if (aDraft && !bDraft) return -1
|
||
if (!aDraft && bDraft) return 1
|
||
|
||
const seriesOrder = (series: string) => {
|
||
const key = series.trim().toLowerCase()
|
||
if (key === 'titus') return 0
|
||
if (key === 'colossians') return 1
|
||
return 2
|
||
}
|
||
|
||
const bySeries = seriesOrder(a.series) - seriesOrder(b.series)
|
||
if (bySeries !== 0) return bySeries
|
||
|
||
const nameSort = a.series.localeCompare(b.series, undefined, { sensitivity: 'base' })
|
||
if (nameSort !== 0) return nameSort
|
||
|
||
const aNum = a.episodeNumber
|
||
const bNum = b.episodeNumber
|
||
if (aNum === null && bNum === null) return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
|
||
if (aNum === null) return 1
|
||
if (bNum === null) return -1
|
||
if (aNum !== bNum) return aNum - bNum
|
||
|
||
return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
|
||
})
|
||
|
||
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>}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function renderPodcastChecklistStatus() {
|
||
return (
|
||
<>
|
||
{podcastChecklistStatus === 'saved' && <p className="admin-status admin-status--ok">✓ Checklist saved.</p>}
|
||
{podcastChecklistStatus === 'error' && <p className="admin-status admin-status--err">✗ {podcastChecklistMsg}</p>}
|
||
</>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="admin-shell">
|
||
{/* ── Top bar ── */}
|
||
<header className="admin-topbar">
|
||
<div className="admin-topbar-left">
|
||
<button
|
||
type="button"
|
||
className={`admin-mobile-menu-btn${mobileNavOpen ? ' admin-mobile-menu-btn--open' : ''}`}
|
||
onClick={() => setMobileNavOpen(open => !open)}
|
||
aria-expanded={mobileNavOpen}
|
||
aria-controls="admin-sidebar-nav"
|
||
>
|
||
{mobileNavOpen ? 'Close' : 'Menu'}
|
||
</button>
|
||
<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>
|
||
<div className="admin-topbar-state">
|
||
{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">Live: {formatDate(publishState.publishedAt)}</span>
|
||
</div>
|
||
<div className="admin-topbar-actions">
|
||
<label className="admin-view-picker" aria-label="Jump to admin section">
|
||
<span className="admin-view-picker-label">Jump to</span>
|
||
<select
|
||
className="admin-view-picker-select"
|
||
value={adminView}
|
||
onChange={e => navigateTo(e.target.value as AdminView)}
|
||
>
|
||
{ADMIN_VIEW_OPTIONS.map(group => (
|
||
<optgroup key={group.group} label={group.group}>
|
||
{group.options.map(option => (
|
||
<option key={option.value} value={option.value}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</optgroup>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<button type="button" className="btn-admin-save" onClick={handleSaveDraft} disabled={status === 'saving' || !isDirty}>
|
||
{status === 'saving' ? 'Saving…' : 'Save Draft'}
|
||
</button>
|
||
<button type="button" className={`btn-admin-reset${previewOpen ? ' btn-admin-reset--active' : ''}`} onClick={() => setPreviewOpen(o => !o)}>
|
||
{previewOpen ? 'Close Preview' : 'Preview'}
|
||
</button>
|
||
<button type="button" className="btn-admin-reset" onClick={handlePublishDraft}>Publish</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div className={`admin-layout${previewOpen ? ' admin-layout--split' : ''}`}>
|
||
{/* ── Sidebar ── */}
|
||
<nav id="admin-sidebar-nav" className={`admin-sidebar${mobileNavOpen ? ' admin-sidebar--open' : ''}`} aria-label="Admin navigation">
|
||
<div className="admin-sidebar-meta-links">
|
||
<Link
|
||
to="/"
|
||
className="admin-meta-link"
|
||
onClick={e => {
|
||
if (!confirmLeaveUnsavedChanges()) e.preventDefault()
|
||
else setMobileNavOpen(false)
|
||
}}
|
||
>
|
||
← Back to site
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
className="admin-meta-link"
|
||
onClick={() => {
|
||
if (!confirmLeaveUnsavedChanges()) return
|
||
setMobileNavOpen(false)
|
||
void onLogout()
|
||
}}
|
||
>
|
||
Log Out
|
||
</button>
|
||
</div>
|
||
|
||
<div className="admin-nav-search">
|
||
<label htmlFor="admin-nav-search-input">Find a section</label>
|
||
<input
|
||
id="admin-nav-search-input"
|
||
type="search"
|
||
value={navSearch}
|
||
onChange={e => setNavSearch(e.target.value)}
|
||
placeholder="Search admin..."
|
||
/>
|
||
</div>
|
||
|
||
{filteredAdminViewOptions.length === 0 ? (
|
||
<p className="admin-stats-note admin-nav-empty">No admin sections match this search.</p>
|
||
) : (
|
||
filteredAdminViewOptions.map(group => (
|
||
<div key={group.group} className="admin-nav-group">
|
||
<span className="admin-nav-label">{group.group}</span>
|
||
{group.options.map(option => renderAdminNavItem(option.value, option.label))}
|
||
</div>
|
||
))
|
||
)}
|
||
</nav>
|
||
|
||
{mobileNavOpen && <button type="button" className="admin-sidebar-backdrop" aria-label="Close navigation menu" onClick={() => setMobileNavOpen(false)} />}
|
||
|
||
{/* ── Content panel ── */}
|
||
<main className="admin-panel">
|
||
|
||
{/* DASHBOARD */}
|
||
{adminView === 'dashboard' && (() => {
|
||
const thisWeekHits = stats?.last7Days?.reduce((s, d) => s + d.hits, 0) ?? 0
|
||
const thisWeekReal = stats?.last7DaysReal?.reduce((s, d) => s + d.hits, 0) ?? 0
|
||
const dashboardHour = dashboardNow.getHours()
|
||
const welcomeMessage = dashboardHour < 12
|
||
? 'Good morning.'
|
||
: dashboardHour < 18
|
||
? 'Good afternoon.'
|
||
: 'Good evening.'
|
||
const dashboardDateLabel = dashboardNow.toLocaleDateString(undefined, {
|
||
weekday: 'long',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
})
|
||
const dashboardTimeLabel = dashboardNow.toLocaleTimeString(undefined, {
|
||
hour: 'numeric',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
})
|
||
const recentContacts = [...contactSubmissions]
|
||
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
||
.slice(0, 5)
|
||
const recentQuestions = [...questions]
|
||
.sort((a, b) => new Date(b.submittedAt ?? '').getTime() - new Date(a.submittedAt ?? '').getTime())
|
||
.slice(0, 5)
|
||
const approvedCount = questions.filter(q => q.isApproved).length
|
||
const answeredCount = questions.filter(q => !!q.answer?.trim()).length
|
||
const topEnrollment = stats?.studyEnrollment?.enrollmentsByStudy?.[0]
|
||
return (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Dashboard</h2>
|
||
<p>Quick overview of your ministry site.</p>
|
||
</div>
|
||
|
||
<div className="admin-dashboard-welcome">
|
||
<div>
|
||
<h3 className="admin-dashboard-welcome-title">{welcomeMessage}</h3>
|
||
<p className="admin-dashboard-welcome-copy">Here's what needs your attention and how the site is performing today.</p>
|
||
</div>
|
||
<div className="admin-dashboard-clock" aria-label={`Current time ${dashboardTimeLabel} on ${dashboardDateLabel}`}>
|
||
<span className="admin-dashboard-clock-time">{dashboardTimeLabel}</span>
|
||
<span className="admin-dashboard-clock-date">{dashboardDateLabel}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="admin-dashboard-grid">
|
||
<div className={`admin-dashboard-card${unansweredCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
|
||
<div className="admin-dashboard-card-value">{unansweredCount}</div>
|
||
<div className="admin-dashboard-card-label">Unanswered Questions</div>
|
||
<div className="admin-dashboard-card-sub">{answeredCount} answered · {approvedCount} approved of {questions.length}</div>
|
||
{unansweredCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => { setQuestionFilter('unanswered'); navigateTo('questions') }}>Answer Now →</button>}
|
||
</div>
|
||
<div className={`admin-dashboard-card${unreadEmailCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
|
||
<div className="admin-dashboard-card-value">{unreadEmailCount}</div>
|
||
<div className="admin-dashboard-card-label">Unread Emails</div>
|
||
<div className="admin-dashboard-card-sub">{contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total</div>
|
||
{unreadEmailCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('emails')}>Open Inbox →</button>}
|
||
</div>
|
||
<div className="admin-dashboard-card">
|
||
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
|
||
<div className="admin-dashboard-card-label">Real Visits (7 Days)</div>
|
||
<div className="admin-dashboard-card-sub">{thisWeekHits.toLocaleString()} total · {stats?.visitors?.uniqueVisitors?.toLocaleString() ?? '—'} unique all time</div>
|
||
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('analytics')}>Full Analytics →</button>
|
||
</div>
|
||
<div className="admin-dashboard-card">
|
||
<div className="admin-dashboard-card-value">{subscribers.length}</div>
|
||
<div className="admin-dashboard-card-label">Email Subscribers</div>
|
||
<div className="admin-dashboard-card-sub">{contactSubmissions.length} total contact submissions</div>
|
||
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('subscribers')}>View List →</button>
|
||
</div>
|
||
<div className="admin-dashboard-card">
|
||
<div className="admin-dashboard-card-value">{stats?.studyEnrollment?.totalEnrollments ?? 0}</div>
|
||
<div className="admin-dashboard-card-label">Study Enrollments</div>
|
||
<div className="admin-dashboard-card-sub">
|
||
{stats?.studyEnrollment?.enrolledUsers ?? 0} of {stats?.studyEnrollment?.totalUsers ?? 0} students enrolled
|
||
{topEnrollment ? ` · Top: ${topEnrollment.title} (${topEnrollment.count})` : ''}
|
||
</div>
|
||
</div>
|
||
<div className="admin-dashboard-card">
|
||
<div className="admin-dashboard-card-value">{downloadStats['titus-study'] ?? 0}</div>
|
||
<div className="admin-dashboard-card-label">Titus Study Downloads</div>
|
||
<div className="admin-dashboard-card-sub">
|
||
{Object.values(downloadStats).reduce((a, b) => a + b, 0)} total resource downloads
|
||
</div>
|
||
</div>
|
||
{publishState?.publishedAt && (
|
||
<div className="admin-dashboard-card">
|
||
<div className="admin-dashboard-card-value" style={{ fontSize: '1rem', marginTop: '0.25rem' }}>{formatDate(publishState.publishedAt)}</div>
|
||
<div className="admin-dashboard-card-label">Last Published</div>
|
||
{publishState.draftUpdatedAt && <div className="admin-dashboard-card-sub">Draft updated {formatDate(publishState.draftUpdatedAt)}</div>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="admin-dashboard-activity">
|
||
<div className="admin-dashboard-activity-col">
|
||
<h3 className="admin-dashboard-activity-heading">Recent Contacts</h3>
|
||
{recentContacts.length === 0
|
||
? <p className="admin-stats-note">No contacts yet.</p>
|
||
: (
|
||
<div className="admin-dashboard-feed">
|
||
{recentContacts.map(c => (
|
||
<div key={c.id} className="admin-dashboard-feed-item">
|
||
<div className="admin-dashboard-feed-meta">
|
||
<strong>{c.name}</strong>
|
||
<span className="admin-dashboard-feed-date">{formatDate(c.submittedAt)}</span>
|
||
</div>
|
||
<div className="admin-dashboard-feed-email">{c.email}</div>
|
||
{c.message && <div className="admin-dashboard-feed-preview">{c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}</div>}
|
||
</div>
|
||
))}
|
||
<button type="button" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem' }} onClick={() => navigateTo('contacts')}>View All Contacts →</button>
|
||
</div>
|
||
)
|
||
}
|
||
</div>
|
||
<div className="admin-dashboard-activity-col">
|
||
<h3 className="admin-dashboard-activity-heading">Recent Questions</h3>
|
||
{recentQuestions.length === 0
|
||
? <p className="admin-stats-note">No questions yet.</p>
|
||
: (
|
||
<div className="admin-dashboard-feed">
|
||
{recentQuestions.map(q => (
|
||
<div key={q.id} className="admin-dashboard-feed-item">
|
||
<div className="admin-dashboard-feed-meta">
|
||
<strong>{q.firstName}</strong>
|
||
<span className={`admin-badge ${q.isApproved ? 'admin-badge--approved' : 'admin-badge--pending'}`} style={{ fontSize: '0.65rem' }}>{q.isApproved ? 'Approved' : 'Pending'}</span>
|
||
<span className="admin-dashboard-feed-date">{formatDate(q.submittedAt ?? '')}</span>
|
||
</div>
|
||
<div className="admin-dashboard-feed-preview">{q.question.slice(0, 100)}{q.question.length > 100 ? '…' : ''}</div>
|
||
</div>
|
||
))}
|
||
<button type="button" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem' }} onClick={() => navigateTo('questions')}>View All Questions →</button>
|
||
</div>
|
||
)
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
|
||
<div className="admin-dashboard-activity-col" style={{ gridColumn: '1 / -1' }}>
|
||
<h3 className="admin-dashboard-activity-heading">Study Users & Enrollments</h3>
|
||
{stats?.studyEnrollment?.users?.length
|
||
? (
|
||
<div className="admin-dashboard-feed">
|
||
{stats.studyEnrollment.users.map(user => (
|
||
<div key={user.id} className="admin-dashboard-feed-item">
|
||
<div className="admin-dashboard-feed-meta">
|
||
<strong>{user.displayName || user.username}</strong>
|
||
{user.displayName && <span className="admin-dashboard-feed-email">{user.username}</span>}
|
||
</div>
|
||
<div className="admin-dashboard-feed-preview">
|
||
{user.enrolledStudies.length
|
||
? `Enrolled in: ${user.enrolledStudies.map(study => study.title).join(', ')}`
|
||
: 'Not enrolled in any studies yet.'}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
: <p className="admin-stats-note">No study users yet.</p>
|
||
}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
})()}
|
||
|
||
{/* CONTACTS */}
|
||
{adminView === 'contacts' && (() => {
|
||
const searchTerm = contactSearch.trim().toLowerCase()
|
||
const grouped = new Map<string, ContactSubmission[]>()
|
||
|
||
for (const submission of contactSubmissions) {
|
||
const emailKey = submission.email.trim().toLowerCase()
|
||
const nameKey = submission.name.trim().toLowerCase()
|
||
const key = emailKey || nameKey || submission.id
|
||
const entries = grouped.get(key)
|
||
if (entries) entries.push(submission)
|
||
else grouped.set(key, [submission])
|
||
}
|
||
|
||
const rolledUp = Array.from(grouped.values())
|
||
.map(entries => {
|
||
const sortedEntries = [...entries].sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
||
const latest = sortedEntries[0]
|
||
return {
|
||
...latest,
|
||
archived: sortedEntries.every(entry => entry.archived === true),
|
||
subscribe: sortedEntries.some(entry => entry.subscribe),
|
||
message: latest.message || sortedEntries.find(entry => entry.message)?.message || '',
|
||
submissionCount: sortedEntries.length,
|
||
}
|
||
})
|
||
.filter(contact => {
|
||
if (!searchTerm) return true
|
||
return contact.name.toLowerCase().includes(searchTerm)
|
||
|| contact.email.toLowerCase().includes(searchTerm)
|
||
|| contact.message.toLowerCase().includes(searchTerm)
|
||
})
|
||
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
||
|
||
return (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Contacts</h2>
|
||
<p>{rolledUp.length} contacts from {contactSubmissions.length} total submissions — repeat senders are grouped together.</p>
|
||
</div>
|
||
<div className="admin-toolbar" style={{ marginBottom: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<input
|
||
type="search"
|
||
placeholder="Search name, email, or message…"
|
||
value={contactSearch}
|
||
onChange={e => setContactSearch(e.target.value)}
|
||
style={{ minWidth: '260px', maxWidth: '440px', width: '100%' }}
|
||
/>
|
||
<span className="admin-stats-note" style={{ margin: 0 }}>{rolledUp.length} result{rolledUp.length !== 1 ? 's' : ''}</span>
|
||
</div>
|
||
{rolledUp.length === 0
|
||
? <p className="admin-stats-note">No contacts{contactSearch ? ' match your search' : ' yet'}.</p>
|
||
: (
|
||
<div className="admin-visits-table-wrap">
|
||
<table className="admin-visits-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Email</th>
|
||
<th>Type</th>
|
||
<th>Subscriber</th>
|
||
<th>Date</th>
|
||
<th>Message</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rolledUp.map(c => (
|
||
<tr key={c.id} className={c.archived ? 'admin-contacts-row--archived' : ''}>
|
||
<td>
|
||
<div>{c.name}</div>
|
||
{c.submissionCount > 1 && <div className="admin-stats-note" style={{ margin: '0.2rem 0 0' }}>{c.submissionCount} submissions</div>}
|
||
</td>
|
||
<td><a href={`mailto:${c.email}`}>{c.email}</a></td>
|
||
<td><span className="admin-badge admin-badge--pending" style={{ fontSize: '0.7rem' }}>{c.messageType ?? 'contact'}</span></td>
|
||
<td style={{ textAlign: 'center' }}>{c.subscribe ? '✓' : ''}</td>
|
||
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(c.submittedAt)}</td>
|
||
<td style={{ maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={c.message}>{c.message ?? '—'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
</section>
|
||
)
|
||
})()}
|
||
|
||
{/* SUBSCRIBERS */}
|
||
{adminView === 'subscribers' && (() => {
|
||
const filteredSubs = subscribers.filter(s =>
|
||
!subscriberSearch.trim() ||
|
||
s.name.toLowerCase().includes(subscriberSearch.toLowerCase()) ||
|
||
s.email.toLowerCase().includes(subscriberSearch.toLowerCase())
|
||
)
|
||
return (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Subscribers</h2>
|
||
<p>{subscribers.length} people have opted in to email updates.</p>
|
||
</div>
|
||
<div className="admin-toolbar" style={{ marginBottom: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<input
|
||
type="search"
|
||
placeholder="Search by name or email…"
|
||
value={subscriberSearch}
|
||
onChange={e => setSubscriberSearch(e.target.value)}
|
||
style={{ minWidth: '240px', maxWidth: '400px', width: '100%' }}
|
||
/>
|
||
<form method="post" action="/api/admin-subscribers/export" style={{ display: 'inline' }}>
|
||
<button type="submit" className="btn-admin-reset">Export CSV</button>
|
||
</form>
|
||
</div>
|
||
{filteredSubs.length === 0 ? (
|
||
<p className="admin-stats-note">No subscribers{subscriberSearch ? ' match your search' : ' yet'}.</p>
|
||
) : (
|
||
<div className="admin-visits-table-wrap">
|
||
<table className="admin-visits-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Email</th>
|
||
<th>Source</th>
|
||
<th>Subscribed</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredSubs.map((sub, i) => (
|
||
<tr key={`${sub.email}-${i}`}>
|
||
<td>{sub.name}</td>
|
||
<td><a href={`mailto:${sub.email}`}>{sub.email}</a></td>
|
||
<td>{sub.source === 'download' ? 'Download' : 'Contact Form'}</td>
|
||
<td>{formatDate(sub.subscribedAt)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)
|
||
})()}
|
||
|
||
{/* HOMEPAGE */}
|
||
{adminView === 'homepage' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Homepage</h2>
|
||
<p>Controls the hero, share section, PRISM video block, and "Where to Next" navigation cards.</p>
|
||
</div>
|
||
|
||
{renderSectionJumpNav('homepage')}
|
||
|
||
<div className="admin-panel-subhead" id="homepage-hero">
|
||
<h3>Hero</h3>
|
||
<p>Primary landing page headline, tag line, and hero call-to-action labels.</p>
|
||
</div>
|
||
{FIELDS.filter(f => f.section === 'hero').map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="homepage-share">
|
||
<h3>Share Section</h3>
|
||
<p>Controls the share block that points visitors toward the next step.</p>
|
||
</div>
|
||
{FIELDS.filter(f => f.section === 'share').map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="homepage-prism">
|
||
<h3>PRISM Block</h3>
|
||
<p>Video URL, intro text, pattern steps, and closing copy for the PRISM section.</p>
|
||
</div>
|
||
{FIELDS.filter(f => f.section === 'prism').map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="homepage-where-to-next-labels">
|
||
<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" id="homepage-where-to-next-cards">
|
||
<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>
|
||
|
||
{renderSectionJumpNav('start-here')}
|
||
|
||
<div className="admin-panel-subhead" id="start-here-intro">
|
||
<h3>Intro</h3>
|
||
<p>Top-level heading and opening copy for the page.</p>
|
||
</div>
|
||
{FIELDS.filter(f => ['startHereHeading', 'startHereIntro'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="start-here-step-1">
|
||
<h3>Step 1</h3>
|
||
</div>
|
||
{FIELDS.filter(f => ['startHereStep1Title', 'startHereStep1Body', 'startHereStep1Cta'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="start-here-step-2">
|
||
<h3>Step 2</h3>
|
||
</div>
|
||
{FIELDS.filter(f => ['startHereStep2Title', 'startHereStep2Body', 'startHereStep2Cta'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="start-here-step-3">
|
||
<h3>Step 3</h3>
|
||
</div>
|
||
{FIELDS.filter(f => ['startHereStep3Title', 'startHereStep3Body', 'startHereStep3Cta'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
</div>
|
||
))}
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* ABOUT */}
|
||
{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>
|
||
|
||
{renderSectionJumpNav('about')}
|
||
|
||
<div className="admin-panel-subhead" id="about-show-copy">
|
||
<h3>Show Copy</h3>
|
||
<p>Top section content that explains the show and listening invitation.</p>
|
||
</div>
|
||
{FIELDS.filter(f => ['aboutShowHeading', 'aboutShowP1', 'aboutShowP2', 'aboutShowEyebrow', 'aboutListenBtnLabel'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline ? (
|
||
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
) : (
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="about-nate-bio">
|
||
<h3>Nate Bio</h3>
|
||
<p>Profile eyebrow and biography copy for the About Nate block.</p>
|
||
</div>
|
||
{FIELDS.filter(f => ['aboutEyebrow', 'aboutNate'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline ? (
|
||
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={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" id="about-images">
|
||
<h3>Images</h3>
|
||
<p>Portrait and scripture artwork used on the About page.</p>
|
||
</div>
|
||
{FIELDS.filter(f => ['aboutPhotoUrl', 'aboutVerseArtUrl'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline ? (
|
||
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
) : (
|
||
<>
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
{(key.includes('ImageUrl') || key.includes('PhotoUrl') || key.includes('ArtUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
||
{key === 'aboutPhotoUrl' && <p className="admin-field-note">This controls the portrait shown on the left side of the About page.</p>}
|
||
{key === 'aboutVerseArtUrl' && <p className="admin-field-note">This controls the scripture artwork shown under the “About the Show” text. It is separate from the Contact page image.</p>}
|
||
{key === 'aboutPhotoUrl' && renderImagePreview(form[key] as string, 'About page portrait preview')}
|
||
{key === 'aboutVerseArtUrl' && renderImagePreview(form[key] as string, 'About page scripture artwork preview')}
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* CONTACT */}
|
||
{adminView === 'contact' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Contact</h2>
|
||
<p>Manage the contact page profile image and contact copy.</p>
|
||
</div>
|
||
|
||
{renderSectionJumpNav('contact')}
|
||
|
||
<div className="admin-panel-subhead" id="contact-profile">
|
||
<h3>Profile</h3>
|
||
<p>Name, role, headline, and image shown at the top of the contact page.</p>
|
||
</div>
|
||
{FIELDS.filter(f => ['contactPhotoUrl', 'contactEyebrow', 'contactHeading', 'contactName', 'contactRole'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline ? (
|
||
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
) : (
|
||
<>
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
||
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImagePreview(form[key] as string, `${label} preview`)}
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="contact-intro">
|
||
<h3>Intro Copy</h3>
|
||
<p>Intro paragraph, quote, and main contact bullet points.</p>
|
||
</div>
|
||
{FIELDS.filter(f => ['contactQuote', 'contactIntro', 'contactPoint1', 'contactPoint2'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline ? (
|
||
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
) : (
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="contact-scripture">
|
||
<h3>Scripture</h3>
|
||
</div>
|
||
{FIELDS.filter(f => ['contactVerse', 'contactVerseRef'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline ? (
|
||
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
) : (
|
||
<>
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
||
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImagePreview(form[key] as string, `${label} preview`)}
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* PODCAST HUB */}
|
||
{adminView === 'podcast' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Podcast Hub</h2>
|
||
<p>Manage all podcast-related content from one place.</p>
|
||
</div>
|
||
<div className="admin-tabs admin-tabs--podcast">
|
||
<button type="button" className={`admin-tab${podcastTab === 'current-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('current-series')}>Current Series</button>
|
||
<button type="button" className={`admin-tab${podcastTab === 'episode-highlights' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-highlights')}>Ep. Highlights</button>
|
||
<button type="button" className={`admin-tab${podcastTab === 'podcast-checklist' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('podcast-checklist')}>Production Checklist</button>
|
||
<button type="button" className={`admin-tab${podcastTab === 'archived-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('archived-series')}>Archived Series</button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* CURRENT SERIES */}
|
||
{(adminView === 'current-series' || (adminView === 'podcast' && podcastTab === 'current-series')) && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Current Series</h2>
|
||
<p>Update the active series card shown on the homepage and episodes page.</p>
|
||
</div>
|
||
{FIELDS.filter(f => f.section === 'series' && !['studyGuideTitle', 'studyGuideDescription', 'studyGuideUrl'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline ? (
|
||
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
||
) : (
|
||
<>
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
||
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImagePreview(form[key] as string, `${label} preview`)}
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
<p className="admin-stats-note">Companion study guide title, description, and Amazon link are now managed in Downloads.</p>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* EPISODE HIGHLIGHTS */}
|
||
{(adminView === 'episode-highlights' || (adminView === 'podcast' && podcastTab === '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 => (
|
||
<AdminCollapsibleCard
|
||
key={item.id}
|
||
title={item.title || 'Untitled episode highlight'}
|
||
subtitle={item.episodeNumber ? `Episode ${item.episodeNumber}` : 'No episode number yet'}
|
||
>
|
||
<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>
|
||
</AdminCollapsibleCard>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addPodcastLink}>+ Add Episode Highlight</button>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* PODCAST CHECKLIST */}
|
||
{(adminView === 'podcast-checklist' || (adminView === 'podcast' && podcastTab === 'podcast-checklist')) && (
|
||
<section className="admin-panel-section" aria-label="Podcast production checklist">
|
||
<div className="admin-panel-head">
|
||
<h2>Podcast Production Checklist</h2>
|
||
<p>Track production progress for each episode. Add as many tasks and episodes as you need, then save.</p>
|
||
</div>
|
||
|
||
<div className="admin-content-summary">
|
||
<article className="admin-summary-card">
|
||
<h3>Total Episodes</h3>
|
||
<p>{podcastChecklist.episodes.length}</p>
|
||
</article>
|
||
<article className="admin-summary-card">
|
||
<h3>Pre-Publish Tasks</h3>
|
||
<p>{checklistPreTasks.length}</p>
|
||
</article>
|
||
<article className="admin-summary-card">
|
||
<h3>Post-Publish Tasks</h3>
|
||
<p>{checklistPostTasks.length}</p>
|
||
</article>
|
||
</div>
|
||
|
||
<AdminCollapsibleCard
|
||
title="Task Template"
|
||
subtitle={`${podcastChecklist.tasks.length} tasks configured`}
|
||
className="admin-collapsible-card--group"
|
||
>
|
||
<div className="admin-archive-subsection-actions" style={{ marginBottom: '0.75rem' }}>
|
||
<button type="button" className="btn-admin-add" onClick={() => addChecklistTask('pre')}>+ Add Pre-Publish Task</button>
|
||
<button type="button" className="btn-admin-add" onClick={() => addChecklistTask('post')}>+ Add Post-Publish Task</button>
|
||
</div>
|
||
|
||
{podcastChecklist.tasks.length === 0 && (
|
||
<p className="admin-stats-note">No tasks yet. Add a pre-publish or post-publish task above.</p>
|
||
)}
|
||
|
||
{checklistTasksSorted.map(task => (
|
||
<div key={task.id} className="admin-array-row admin-array-row--nested">
|
||
<div className="admin-array-fields" style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: '1fr minmax(11rem, 15rem)' }}>
|
||
<div className="admin-field">
|
||
<label htmlFor={`checklist-task-label-${task.id}`}>Task Name</label>
|
||
<input
|
||
id={`checklist-task-label-${task.id}`}
|
||
type="text"
|
||
value={task.label}
|
||
onChange={e => updateChecklistTask(task.id, 'label', e.target.value)}
|
||
placeholder="e.g. Upload transcript"
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`checklist-task-phase-${task.id}`}>Phase</label>
|
||
<select
|
||
id={`checklist-task-phase-${task.id}`}
|
||
value={task.phase}
|
||
onChange={e => updateChecklistTask(task.id, 'phase', e.target.value)}
|
||
>
|
||
<option value="pre">Pre-Publish</option>
|
||
<option value="post">Post-Publish</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeChecklistTask(task.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
</AdminCollapsibleCard>
|
||
|
||
<div className="admin-archive-subsection">
|
||
<div className="admin-archive-subsection-head">
|
||
<h5>Episodes</h5>
|
||
<div className="admin-archive-subsection-actions">
|
||
<button type="button" className="btn-admin-add" onClick={addChecklistEpisode}>+ Add Episode</button>
|
||
<button type="button" className="btn-admin-save" onClick={handleSavePodcastChecklist} disabled={podcastChecklistStatus === 'saving'}>
|
||
{podcastChecklistStatus === 'saving' ? 'Saving Checklist…' : 'Save Checklist'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{podcastChecklist.episodes.length === 0 && (
|
||
<p className="admin-stats-note">No episodes yet. Add one above to start tracking progress.</p>
|
||
)}
|
||
|
||
{checklistEpisodesSorted.map(episode => {
|
||
const doneCount = checklistTasksSorted.reduce((count, task) => count + (episode.tasks[task.id] ? 1 : 0), 0)
|
||
const totalCount = checklistTasksSorted.length
|
||
const nextTask = checklistTasksSorted.find(task => !episode.tasks[task.id])
|
||
const episodeLabelParts = [episode.series?.trim()]
|
||
if (episode.episodeNumber !== null) {
|
||
episodeLabelParts.push(String(episode.episodeNumber))
|
||
}
|
||
if (episode.title?.trim()) {
|
||
episodeLabelParts.push(episode.title.trim())
|
||
}
|
||
const episodeLabel = episodeLabelParts.filter(Boolean).join(' - ') || 'Untitled'
|
||
|
||
return (
|
||
<AdminCollapsibleCard
|
||
key={episode.id}
|
||
title={episodeLabel}
|
||
subtitle={totalCount > 0
|
||
? `${doneCount}/${totalCount} tasks completed • Next: ${nextTask?.label ?? 'All done'}`
|
||
: 'No tasks assigned yet'}
|
||
>
|
||
<div className="admin-array-row admin-array-row--nested">
|
||
<div className="admin-array-fields" style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))' }}>
|
||
<div className="admin-field">
|
||
<label htmlFor={`checklist-series-${episode.id}`}>Series</label>
|
||
<input
|
||
id={`checklist-series-${episode.id}`}
|
||
type="text"
|
||
value={episode.series}
|
||
onChange={e => updateChecklistEpisode(episode.id, 'series', e.target.value)}
|
||
placeholder="Colossians"
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`checklist-episode-number-${episode.id}`}>Episode Number</label>
|
||
<input
|
||
id={`checklist-episode-number-${episode.id}`}
|
||
type="number"
|
||
value={episode.episodeNumber ?? ''}
|
||
onChange={e => updateChecklistEpisode(episode.id, 'episodeNumber', e.target.value)}
|
||
min={0}
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`checklist-title-${episode.id}`}>Title (optional)</label>
|
||
<input
|
||
id={`checklist-title-${episode.id}`}
|
||
type="text"
|
||
value={episode.title}
|
||
onChange={e => updateChecklistEpisode(episode.id, 'title', e.target.value)}
|
||
placeholder="Grace that Trains Us"
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`checklist-date-${episode.id}`}>Date Published</label>
|
||
<input
|
||
id={`checklist-date-${episode.id}`}
|
||
type="date"
|
||
value={episode.datePublished}
|
||
onChange={e => updateChecklistEpisode(episode.id, 'datePublished', e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{checklistPreTasks.length > 0 && (
|
||
<div className="admin-archive-subsection">
|
||
<div className="admin-archive-subsection-head">
|
||
<h5>Pre-Publish Tasks</h5>
|
||
</div>
|
||
<div className="admin-array-fields">
|
||
{checklistPreTasks.map(task => (
|
||
<label key={task.id} style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={episode.tasks[task.id] === true}
|
||
onChange={() => toggleChecklistEpisodeTask(episode.id, task.id)}
|
||
/>
|
||
<span>{task.label || 'Untitled task'}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{checklistPostTasks.length > 0 && (
|
||
<div className="admin-archive-subsection">
|
||
<div className="admin-archive-subsection-head">
|
||
<h5>Post-Publish Tasks</h5>
|
||
</div>
|
||
<div className="admin-array-fields">
|
||
{checklistPostTasks.map(task => (
|
||
<label key={task.id} style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={episode.tasks[task.id] === true}
|
||
onChange={() => toggleChecklistEpisodeTask(episode.id, task.id)}
|
||
/>
|
||
<span>{task.label || 'Untitled task'}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', gap: '0.6rem', marginTop: '0.8rem' }}>
|
||
<button type="button" className="btn-admin-remove" onClick={() => resetChecklistEpisode(episode.id)}>Reset Progress</button>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeChecklistEpisode(episode.id)}>Remove Episode</button>
|
||
</div>
|
||
</AdminCollapsibleCard>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{renderPodcastChecklistStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* ARCHIVED SERIES */}
|
||
{(adminView === 'archived-series' || (adminView === 'podcast' && podcastTab === 'archived-series')) && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Archived Series</h2>
|
||
<p>Move finished studies here so users can still access old resources after you switch the current series.</p>
|
||
</div>
|
||
<div className="admin-archive-helper">
|
||
<h3>Archive Current Series</h3>
|
||
<p>Use this when you move from one study to the next. It creates a pre-filled archived entry from the current series, study guide, custom resource links, and custom content blocks.</p>
|
||
<button type="button" className="btn-admin-add" onClick={archiveCurrentSeriesSnapshot}>+ Archive Current Series Snapshot</button>
|
||
</div>
|
||
{(form.archivedSeries ?? []).length === 0 && (
|
||
<p className="admin-stats-note">No archived series yet.</p>
|
||
)}
|
||
{(form.archivedSeries ?? []).map(series => (
|
||
<div key={series.id} className="admin-archive-card">
|
||
<div className="admin-archive-card-head">
|
||
<div>
|
||
<h4>{series.title || 'Untitled archived series'}</h4>
|
||
<p>{series.label || 'Archived Study'}</p>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeries(series.id)}>Remove Series</button>
|
||
</div>
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`archive-label-${series.id}`}>Label</label>
|
||
<input id={`archive-label-${series.id}`} type="text" value={series.label} placeholder="Archived Study" onChange={e => updateArchivedSeries(series.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`archive-title-${series.id}`}>Series Title</label>
|
||
<input id={`archive-title-${series.id}`} type="text" value={series.title} placeholder="Study of Titus: Sound Doctrine" onChange={e => updateArchivedSeries(series.id, 'title', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`archive-description-${series.id}`}>Description</label>
|
||
<textarea id={`archive-description-${series.id}`} value={series.description} rows={4} placeholder="Describe the archived study and why it still matters." onChange={e => updateArchivedSeries(series.id, 'description', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`archive-image-${series.id}`}>Cover Image URL</label>
|
||
<input id={`archive-image-${series.id}`} type="text" value={series.imageUrl} placeholder="/images/titus-cover.png" onChange={e => updateArchivedSeries(series.id, 'imageUrl', e.target.value)} />
|
||
{renderImageAssetSelector(series.imageUrl, value => updateArchivedSeries(series.id, 'imageUrl', value), `archive-image-${series.id}-asset`)}
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`archive-listen-${series.id}`}>Listen URL</label>
|
||
<input id={`archive-listen-${series.id}`} type="url" value={series.listenUrl} placeholder="https://..." onChange={e => updateArchivedSeries(series.id, 'listenUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`archive-guide-title-${series.id}`}>Study Guide Title</label>
|
||
<input id={`archive-guide-title-${series.id}`} type="text" value={series.studyGuideTitle} placeholder="Companion Study Guide" onChange={e => updateArchivedSeries(series.id, 'studyGuideTitle', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`archive-guide-description-${series.id}`}>Study Guide Description</label>
|
||
<textarea id={`archive-guide-description-${series.id}`} value={series.studyGuideDescription} rows={3} placeholder="Describe the archived guide or workbook." onChange={e => updateArchivedSeries(series.id, 'studyGuideDescription', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`archive-guide-url-${series.id}`}>Study Guide URL</label>
|
||
<input id={`archive-guide-url-${series.id}`} type="url" value={series.studyGuideUrl} placeholder="https://..." onChange={e => updateArchivedSeries(series.id, 'studyGuideUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Episode Range (for archive grouping on Episodes page)</label>
|
||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||
<input type="number" value={series.episodeRange?.from ?? ''} placeholder="First ep #" min={1} style={{ width: '7rem' }} onChange={e => { const from = parseInt(e.target.value, 10); updateArchivedSeries(series.id, 'episodeRange', { from: isNaN(from) ? 0 : from, to: series.episodeRange?.to ?? 0 }) }} />
|
||
<span style={{ color: 'var(--brand-gold)' }}>to</span>
|
||
<input type="number" value={series.episodeRange?.to ?? ''} placeholder="Last ep #" min={1} style={{ width: '7rem' }} onChange={e => { const to = parseInt(e.target.value, 10); updateArchivedSeries(series.id, 'episodeRange', { from: series.episodeRange?.from ?? 0, to: isNaN(to) ? 0 : to }) }} />
|
||
</div>
|
||
<p className="admin-stats-note" style={{ marginTop: '0.35rem' }}>Episodes in this range will be grouped under this series on the public Episodes page.</p>
|
||
</div>
|
||
</div>
|
||
<div className="admin-archive-subsection">
|
||
<div className="admin-archive-subsection-head">
|
||
<h5>Archived Resource Links</h5>
|
||
<div className="admin-archive-subsection-actions">
|
||
<select value={archiveLinkSelectionBySeries[series.id] ?? ''} onChange={e => setArchiveLinkSelectionBySeries(prev => ({ ...prev, [series.id]: e.target.value }))}>
|
||
<option value="">Pick existing custom link…</option>
|
||
{(form.customLinks ?? []).filter(link => link.url.trim().length > 0).map(link => (
|
||
<option key={`pick-${series.id}-${link.id}`} value={link.id}>{link.label || link.url}</option>
|
||
))}
|
||
</select>
|
||
<button type="button" className="btn-admin-add" onClick={() => addExistingCustomLinkToArchivedSeries(series.id)} disabled={!archiveLinkSelectionBySeries[series.id]}>+ Add Picked Link</button>
|
||
<button type="button" className="btn-admin-add" onClick={() => addAllExistingCustomLinksToArchivedSeries(series.id)} disabled={(form.customLinks ?? []).filter(link => link.url.trim().length > 0).length === 0}>+ Add All Custom Links</button>
|
||
<button type="button" className="btn-admin-add" onClick={() => addArchivedSeriesLink(series.id)}>+ Add Blank Link</button>
|
||
</div>
|
||
</div>
|
||
{(series.resourceLinks ?? []).length === 0 && <p className="admin-stats-note">No archived resource links yet.</p>}
|
||
{(series.resourceLinks ?? []).map(link => (
|
||
<div key={link.id} className="admin-array-row admin-array-row--nested">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`archive-link-label-${link.id}`}>Label</label>
|
||
<input id={`archive-link-label-${link.id}`} type="text" value={link.label} placeholder="Episode guide" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`archive-link-url-${link.id}`}>URL</label>
|
||
<input id={`archive-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'url', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`archive-link-amazon-url-${link.id}`}>Amazon URL (optional)</label>
|
||
<input id={`archive-link-amazon-url-${link.id}`} type="url" value={link.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`archive-link-amazon-label-${link.id}`}>Amazon Button Label</label>
|
||
<input id={`archive-link-amazon-label-${link.id}`} type="text" value={link.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonLabel', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeriesLink(series.id, link.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="admin-archive-subsection">
|
||
<div className="admin-archive-subsection-head">
|
||
<h5>Archived Notes / Blocks</h5>
|
||
<button type="button" className="btn-admin-add" onClick={() => addArchivedSeriesNote(series.id)}>+ Add Note Block</button>
|
||
</div>
|
||
{(series.notes ?? []).length === 0 && <p className="admin-stats-note">No archived note blocks yet.</p>}
|
||
{(series.notes ?? []).map(note => (
|
||
<div key={note.id} className="admin-array-row admin-array-row--nested">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`archive-note-heading-${note.id}`}>Heading</label>
|
||
<input id={`archive-note-heading-${note.id}`} type="text" value={note.heading} placeholder="Titus overview" onChange={e => updateArchivedSeriesNote(series.id, note.id, 'heading', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`archive-note-body-${note.id}`}>Body</label>
|
||
<textarea id={`archive-note-body-${note.id}`} value={note.body} rows={3} placeholder="Add archived notes, explanation, or links context." onChange={e => updateArchivedSeriesNote(series.id, note.id, 'body', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeriesNote(series.id, note.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addArchivedSeries}>+ Add Archived Series</button>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* DOWNLOADS */}
|
||
{adminView === 'downloads' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Downloads</h2>
|
||
<p>Manage the featured study guide, current download library, and previous series downloads.</p>
|
||
</div>
|
||
|
||
{renderSectionJumpNav('downloads')}
|
||
|
||
<div className="admin-content-summary">
|
||
<div className="admin-summary-card"><h3>Companion Study Guide</h3><p>Included</p></div>
|
||
<div className="admin-summary-card"><h3>Current Downloads</h3><p>{resourceLinks.length}</p></div>
|
||
<div 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" id="downloads-study-guide">
|
||
<h3>Companion Study Guide</h3>
|
||
<p>This is the featured primary download at the top of the Downloads page.</p>
|
||
</div>
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor="resources-study-guide-title">Title</label>
|
||
<input id="resources-study-guide-title" type="text" value={form.studyGuideTitle} placeholder="Companion Study Guide" onChange={e => handleChange('studyGuideTitle', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="resources-study-guide-description">Description</label>
|
||
<textarea id="resources-study-guide-description" rows={3} value={form.studyGuideDescription} placeholder="Describe the study guide download." onChange={e => handleChange('studyGuideDescription', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="resources-study-guide-download-url">Primary Download URL</label>
|
||
<input id="resources-study-guide-download-url" type="url" value={form.studyGuideDownloadUrl} placeholder="/uploads/new-guide.pdf or https://..." onChange={e => handleChange('studyGuideDownloadUrl', e.target.value)} />
|
||
{renderFileAssetSelector(form.studyGuideDownloadUrl, value => handleChange('studyGuideDownloadUrl', value), 'resources-study-guide-download-url-asset')}
|
||
<p className="admin-stats-note">This is the file URL the main guide download form will deliver after submission.</p>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="resources-study-guide-url">Printed Copy URL (Amazon button)</label>
|
||
<input id="resources-study-guide-url" type="url" value={form.studyGuideUrl} placeholder="https://..." onChange={e => handleChange('studyGuideUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="resources-study-guide-amazon-label">Amazon Button Label</label>
|
||
<input id="resources-study-guide-amazon-label" type="text" value={form.studyGuideAmazonButtonLabel} placeholder="Get it on Amazon" onChange={e => handleChange('studyGuideAmazonButtonLabel', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="admin-section-header" id="downloads-library">
|
||
<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 => (
|
||
<AdminCollapsibleCard
|
||
key={link.id}
|
||
title={link.label || 'Untitled download'}
|
||
subtitle={link.description || 'Current download item'}
|
||
>
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-label-${link.id}`}>Label</label>
|
||
<input id={`resource-label-${link.id}`} type="text" value={link.label} placeholder="Resource title" onChange={e => updateLink(link.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-url-${link.id}`}>URL</label>
|
||
<input id={`resource-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateLink(link.id, 'url', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-description-${link.id}`}>Description</label>
|
||
<textarea id={`resource-description-${link.id}`} rows={3} value={link.description ?? ''} placeholder="Description shown on the download page" onChange={e => updateLink(link.id, 'description', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-amazon-url-${link.id}`}>Amazon URL (optional)</label>
|
||
<input id={`resource-amazon-url-${link.id}`} type="url" value={link.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateLink(link.id, 'amazonUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-amazon-label-${link.id}`}>Amazon Button Label</label>
|
||
<input id={`resource-amazon-label-${link.id}`} type="text" value={link.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateLink(link.id, 'amazonLabel', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-image-${link.id}`}>Image URL</label>
|
||
<input id={`resource-image-${link.id}`} type="text" value={link.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(link.id, 'imageUrl', e.target.value)} />
|
||
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `resource-image-${link.id}-asset`)}
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resource-tags-${link.id}`}>Tags</label>
|
||
<input id={`resource-tags-${link.id}`} type="text" value={(link.tags ?? []).join(', ')} placeholder="sermon, bible study, faith" onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
|
||
</div>
|
||
</AdminCollapsibleCard>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addResource}>+ Add Download</button>
|
||
|
||
<div className="admin-section-header" id="downloads-previous-studies">
|
||
<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 => (
|
||
<AdminCollapsibleCard
|
||
key={series.id}
|
||
title={series.title || 'Untitled archived series'}
|
||
subtitle={`${(series.resourceLinks ?? []).length} download${(series.resourceLinks ?? []).length === 1 ? '' : 's'}`}
|
||
hint="Expand to manage"
|
||
className="admin-collapsible-card--group"
|
||
>
|
||
<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 => (
|
||
<AdminCollapsibleCard
|
||
key={link.id}
|
||
title={link.label || 'Untitled archived download'}
|
||
subtitle={link.description || 'Previous study download'}
|
||
className="admin-collapsible-card--nested"
|
||
>
|
||
<div className="admin-array-row admin-array-row--nested">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-archive-link-label-${link.id}`}>Label</label>
|
||
<input id={`resources-archive-link-label-${link.id}`} type="text" value={link.label} placeholder="Episode guide" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-archive-link-url-${link.id}`}>URL</label>
|
||
<input id={`resources-archive-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'url', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-archive-link-description-${link.id}`}>Description</label>
|
||
<textarea id={`resources-archive-link-description-${link.id}`} rows={3} value={link.description ?? ''} placeholder="Description shown on the download page" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'description', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-archive-link-amazon-url-${link.id}`}>Amazon URL (optional)</label>
|
||
<input id={`resources-archive-link-amazon-url-${link.id}`} type="url" value={link.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-archive-link-amazon-label-${link.id}`}>Amazon Button Label</label>
|
||
<input id={`resources-archive-link-amazon-label-${link.id}`} type="text" value={link.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonLabel', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeriesLink(series.id, link.id)}>Remove</button>
|
||
</div>
|
||
</AdminCollapsibleCard>
|
||
))}
|
||
</div>
|
||
</AdminCollapsibleCard>
|
||
))}
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* CUSTOM LINKS */}
|
||
{adminView === 'custom-links' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Custom Links</h2>
|
||
<p>Add links to show in platform buttons, footer navigation, or the More Resources download library.</p>
|
||
</div>
|
||
|
||
<div className="admin-panel-subhead">
|
||
<h3>Homepage External Links</h3>
|
||
<p>Links shown on the homepage, separate from the footer.</p>
|
||
</div>
|
||
{(form.customLinks ?? []).filter(link => link.placement === 'externalSites').length === 0 && (
|
||
<p className="admin-stats-note">No homepage external links yet.</p>
|
||
)}
|
||
{(form.customLinks ?? []).filter(link => link.placement === 'externalSites').map(link => (
|
||
<div key={link.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`external-site-link-label-${link.id}`}>Label</label>
|
||
<input id={`external-site-link-label-${link.id}`} type="text" value={link.label} placeholder="e.g. Bible study partner" onChange={e => updateLink(link.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`external-site-link-url-${link.id}`}>URL</label>
|
||
<input id={`external-site-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateLink(link.id, 'url', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`external-site-link-description-${link.id}`}>Description</label>
|
||
<textarea id={`external-site-link-description-${link.id}`} rows={3} value={link.description ?? ''} placeholder="Short description shown on the homepage" onChange={e => updateLink(link.id, 'description', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`external-site-link-image-${link.id}`}>Image URL</label>
|
||
<input id={`external-site-link-image-${link.id}`} type="text" value={link.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(link.id, 'imageUrl', e.target.value)} />
|
||
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `external-site-link-image-${link.id}-asset`)}
|
||
{renderImagePreview(link.imageUrl, `${link.label || 'External site link'} image preview`)}
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`external-site-link-tags-${link.id}`}>Tags</label>
|
||
<input id={`external-site-link-tags-${link.id}`} type="text" value={(link.tags ?? []).join(', ')} placeholder="external, homepage, ministry" onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Quick Action</label>
|
||
<button type="button" className="btn-admin-apply" onClick={() => updateLink(link.id, 'placement', 'footer')}>Move to Footer Nav</button>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Shows On</label>
|
||
<p className="admin-stats-note">{getCustomLinkPlacementLabel(link.placement)}</p>
|
||
<a href={getCustomLinkPreviewPath(link.placement)} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary">Preview Destination Page</a>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addExternalSiteLink}>+ Add Homepage External Link</button>
|
||
|
||
<div className="admin-panel-subhead">
|
||
<h3>Platform + Footer Links</h3>
|
||
<p>Links that appear on the listen buttons row or footer nav.</p>
|
||
</div>
|
||
{(form.customLinks ?? []).filter(link => link.placement !== 'resources' && link.placement !== 'otherSites' && link.placement !== 'externalSites').length === 0 && (
|
||
<p className="admin-stats-note">No custom links yet.</p>
|
||
)}
|
||
{(form.customLinks ?? []).filter(link => link.placement !== 'resources' && link.placement !== 'otherSites' && link.placement !== 'externalSites').map(link => (
|
||
<div key={link.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`link-label-${link.id}`}>Label</label>
|
||
<input id={`link-label-${link.id}`} type="text" value={link.label} placeholder="e.g. iHeart Radio" onChange={e => updateLink(link.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`link-url-${link.id}`}>URL</label>
|
||
<input id={`link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateLink(link.id, 'url', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`link-image-${link.id}`}>Image URL</label>
|
||
<input id={`link-image-${link.id}`} type="text" value={link.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(link.id, 'imageUrl', e.target.value)} />
|
||
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `link-image-${link.id}-asset`)}
|
||
{renderImagePreview(link.imageUrl, `${link.label || 'Custom link'} image preview`)}
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`link-tags-${link.id}`}>Tags</label>
|
||
<input id={`link-tags-${link.id}`} type="text" value={(link.tags ?? []).join(', ')} placeholder="listen, podcast, study" onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`link-placement-${link.id}`}>Show in</label>
|
||
<select id={`link-placement-${link.id}`} value={link.placement} onChange={e => updateLink(link.id, 'placement', e.target.value)}>
|
||
<option value="platforms">Platform Buttons (Listen section)</option>
|
||
<option value="footer">Footer Nav</option>
|
||
<option value="resources">More Resources Section</option>
|
||
</select>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Quick Action</label>
|
||
<button type="button" className="btn-admin-apply" onClick={() => moveLinkToResources(link.id)}>Move to Resources</button>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Shows On</label>
|
||
<p className="admin-stats-note">{getCustomLinkPlacementLabel(link.placement)}</p>
|
||
<a href={getCustomLinkPreviewPath(link.placement)} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary">Preview Destination Page</a>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addLink}>+ Add Platform/Footer Link</button>
|
||
|
||
<div className="admin-panel-subhead">
|
||
<h3>Other Site Links</h3>
|
||
<p>External links you want to surface in the footer dropdown.</p>
|
||
</div>
|
||
{(form.customLinks ?? []).filter(link => link.placement === 'otherSites').length === 0 && (
|
||
<p className="admin-stats-note">No other site links yet.</p>
|
||
)}
|
||
{(form.customLinks ?? []).filter(link => link.placement === 'otherSites').map(link => (
|
||
<div key={link.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`other-site-link-label-${link.id}`}>Label</label>
|
||
<input id={`other-site-link-label-${link.id}`} type="text" value={link.label} placeholder="e.g. Recommended ministry" onChange={e => updateLink(link.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`other-site-link-url-${link.id}`}>URL</label>
|
||
<input id={`other-site-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateLink(link.id, 'url', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`other-site-link-image-${link.id}`}>Image URL</label>
|
||
<input id={`other-site-link-image-${link.id}`} type="text" value={link.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(link.id, 'imageUrl', e.target.value)} />
|
||
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `other-site-link-image-${link.id}-asset`)}
|
||
{renderImagePreview(link.imageUrl, `${link.label || 'Other site link'} image preview`)}
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`other-site-link-tags-${link.id}`}>Tags</label>
|
||
<input id={`other-site-link-tags-${link.id}`} type="text" value={(link.tags ?? []).join(', ')} placeholder="external, ministry, study" onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Quick Action</label>
|
||
<button type="button" className="btn-admin-apply" onClick={() => updateLink(link.id, 'placement', 'footer')}>Move to Footer Nav</button>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Shows On</label>
|
||
<p className="admin-stats-note">{getCustomLinkPlacementLabel(link.placement)}</p>
|
||
<a href={getCustomLinkPreviewPath(link.placement)} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary">Preview Destination Page</a>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addOtherSiteLink}>+ Add Other Site Link</button>
|
||
|
||
<div className="admin-panel-subhead">
|
||
<h3>More Resources Links</h3>
|
||
<p>These create cards in Downloads → More guides, worksheets, and past study downloads.</p>
|
||
</div>
|
||
{(form.customLinks ?? []).filter(link => link.placement === 'resources').length === 0 && (
|
||
<p className="admin-stats-note">No resource links yet.</p>
|
||
)}
|
||
{(form.customLinks ?? []).filter(link => link.placement === 'resources').map(link => (
|
||
<div key={link.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-label-${link.id}`}>Label</label>
|
||
<input id={`resources-link-label-${link.id}`} type="text" value={link.label} placeholder="Resource title" onChange={e => updateLink(link.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-url-${link.id}`}>Download URL</label>
|
||
<input id={`resources-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateLink(link.id, 'url', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-description-${link.id}`}>Description</label>
|
||
<textarea id={`resources-link-description-${link.id}`} rows={3} value={link.description ?? ''} placeholder="Description shown on the download page" onChange={e => updateLink(link.id, 'description', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-image-${link.id}`}>Image URL</label>
|
||
<input id={`resources-link-image-${link.id}`} type="text" value={link.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(link.id, 'imageUrl', e.target.value)} />
|
||
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `resources-link-image-${link.id}-asset`)}
|
||
{renderImagePreview(link.imageUrl, `${link.label || 'Resource link'} image preview`)}
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-amazon-url-${link.id}`}>Amazon URL (optional)</label>
|
||
<input id={`resources-link-amazon-url-${link.id}`} type="url" value={link.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateLink(link.id, 'amazonUrl', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-amazon-label-${link.id}`}>Amazon Button Label</label>
|
||
<input id={`resources-link-amazon-label-${link.id}`} type="text" value={link.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateLink(link.id, 'amazonLabel', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`resources-link-tags-${link.id}`}>Tags</label>
|
||
<input id={`resources-link-tags-${link.id}`} type="text" value={(link.tags ?? []).join(', ')} placeholder="worksheet, study, pdf" onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Quick Action</label>
|
||
<button type="button" className="btn-admin-apply" onClick={() => updateLink(link.id, 'placement', 'platforms')}>Move to Platform Buttons</button>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label>Shows On</label>
|
||
<p className="admin-stats-note">{getCustomLinkPlacementLabel(link.placement)}</p>
|
||
<a href={getCustomLinkPreviewPath(link.placement)} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary">Preview Destination Page</a>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addResource}>+ Add Resource Link</button>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* CONTENT BLOCKS */}
|
||
{adminView === 'content-blocks' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Content Blocks</h2>
|
||
<p>Add extra text sections to any page. Choose which page each block appears on.</p>
|
||
</div>
|
||
{(form.customBlocks ?? []).length === 0 && <p className="admin-stats-note">No custom content blocks yet.</p>}
|
||
{(form.customBlocks ?? []).map(block => (
|
||
<div key={block.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`block-page-${block.id}`}>Page</label>
|
||
<select id={`block-page-${block.id}`} value={block.page ?? 'downloads'} onChange={e => updateBlock(block.id, 'page', e.target.value)}>
|
||
<option value="homepage">Homepage</option>
|
||
<option value="start-here">Start Here</option>
|
||
<option value="episodes">Episodes</option>
|
||
<option value="downloads">Downloads</option>
|
||
<option value="about">About</option>
|
||
<option value="contact">Contact</option>
|
||
<option value="questions">Q&A</option>
|
||
</select>
|
||
<p className="admin-stats-note">Shows on: {getContentBlockPageLabel(block.page ?? 'downloads')}</p>
|
||
<a href={getContentBlockPreviewPath(block.page ?? 'downloads')} target="_blank" rel="noopener noreferrer" className="btn-admin-secondary">Preview Destination Page</a>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`block-heading-${block.id}`}>Heading</label>
|
||
<input id={`block-heading-${block.id}`} type="text" value={block.heading} placeholder="Section heading" onChange={e => updateBlock(block.id, 'heading', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`block-body-${block.id}`}>Body Text</label>
|
||
<textarea id={`block-body-${block.id}`} value={block.body} rows={3} placeholder="Write your content here…" onChange={e => updateBlock(block.id, 'body', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeBlock(block.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addBlock}>+ Add Content Block</button>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* STUDIES */}
|
||
{adminView === 'colossians-study' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Studies</h2>
|
||
<p>Manage multiple study tracks and their lesson sections (text, commentary, Greek notes, audio, and questions).</p>
|
||
</div>
|
||
|
||
{(form.studies ?? []).length === 0 && <p className="admin-stats-note">No studies yet.</p>}
|
||
|
||
{(form.studies ?? []).map(study => (
|
||
<AdminCollapsibleCard
|
||
key={study.id}
|
||
title={`${study.title || 'Untitled study'} · /study/${study.slug || 'slug'}`}
|
||
subtitle={study.description || 'Study description'}
|
||
open={false}
|
||
>
|
||
<div className="admin-array-row" style={{ marginBottom: '1rem' }}>
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-title-${study.id}`}>Study Title</label>
|
||
<input id={`study-title-${study.id}`} type="text" value={study.title} placeholder="Colossians: Rooted in Christ" onChange={e => updateStudyProgram(study.id, 'title', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-slug-${study.id}`}>Slug</label>
|
||
<input id={`study-slug-${study.id}`} type="text" value={study.slug} placeholder="colossians" onChange={e => updateStudyProgram(study.id, 'slug', e.target.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'))} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-status-${study.id}`}>Status</label>
|
||
<select id={`study-status-${study.id}`} value={study.status} onChange={e => updateStudyProgram(study.id, 'status', e.target.value)}>
|
||
<option value="active">Active</option>
|
||
<option value="planned">Planned</option>
|
||
</select>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-description-${study.id}`}>Description</label>
|
||
<textarea id={`study-description-${study.id}`} rows={3} value={study.description} placeholder="Brief description for the study hub card" onChange={e => updateStudyProgram(study.id, 'description', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-home-eyebrow-${study.id}`}>Homepage Card Eyebrow</label>
|
||
<input id={`study-home-eyebrow-${study.id}`} type="text" value={study.homepageEyebrow ?? ''} placeholder="New Study" onChange={e => updateStudyProgram(study.id, 'homepageEyebrow', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-new-tag-label-${study.id}`}>NEW Tag Label</label>
|
||
<input id={`study-new-tag-label-${study.id}`} type="text" value={study.newTagLabel ?? 'NEW'} placeholder="NEW" onChange={e => updateStudyProgram(study.id, 'newTagLabel', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||
<input id={`study-home-feature-${study.id}`} type="checkbox" checked={study.showOnHomepage === true} onChange={e => updateStudyProgram(study.id, 'showOnHomepage', e.target.checked)} />
|
||
<label htmlFor={`study-home-feature-${study.id}`}>Feature this study card on homepage</label>
|
||
</div>
|
||
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||
<input id={`study-show-new-tag-${study.id}`} type="checkbox" checked={study.showNewTag === true} onChange={e => updateStudyProgram(study.id, 'showNewTag', e.target.checked)} />
|
||
<label htmlFor={`study-show-new-tag-${study.id}`}>Show NEW tag on homepage card</label>
|
||
</div>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`study-chapters-${study.id}`}>Number of Chapters</label>
|
||
<input id={`study-chapters-${study.id}`} type="number" min="1" max="999" value={study.numberOfChapters} placeholder="4" onChange={e => updateStudyProgram(study.id, "numberOfChapters", Number(e.target.value) || 1)} />
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeStudyProgram(study.id)}>Remove Study</button>
|
||
</div>
|
||
|
||
{(study.sections ?? []).length === 0 && (
|
||
<p className="admin-stats-note">No sections yet for this study.</p>
|
||
)}
|
||
|
||
<DndContext
|
||
sensors={dragSensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={({ active, over }) => {
|
||
const overId = over?.id
|
||
if (!overId || active.id === overId) return
|
||
setForm(f => {
|
||
const studies = f.studies.map(s => {
|
||
if (s.id !== study.id) return s
|
||
const oldIndex = s.sections.findIndex(sec => sec.id === active.id)
|
||
const newIndex = s.sections.findIndex(sec => sec.id === overId)
|
||
if (oldIndex === -1 || newIndex === -1) return s
|
||
const newSections = arrayMove(s.sections, oldIndex, newIndex)
|
||
return { ...s, sections: newSections }
|
||
})
|
||
return { ...f, studies }
|
||
})
|
||
}}
|
||
>
|
||
<SortableContext
|
||
items={(study.sections ?? []).map(section => section.id)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
{(study.sections ?? []).map(section => (
|
||
<SortableLessonSection
|
||
key={section.id}
|
||
section={section}
|
||
study={study}
|
||
updateStudySection={updateStudySection}
|
||
removeStudySection={removeStudySection}
|
||
/>
|
||
))}
|
||
</SortableContext>
|
||
</DndContext>
|
||
|
||
|
||
<button type="button" className="btn-admin-add" onClick={() => addStudySection(study.id)}>+ Add Lesson Section</button>
|
||
</AdminCollapsibleCard>
|
||
))}
|
||
|
||
<button type="button" className="btn-admin-add" onClick={addStudyProgram}>+ Add Study Program</button>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* GLOBAL / FOOTER & PLATFORM */}
|
||
{adminView === 'global' && (
|
||
<section className="admin-panel-section">
|
||
<div className="admin-panel-head">
|
||
<h2>Footer & Global Settings</h2>
|
||
<p>Edit the site footer text, platform links, header label, and analytics cookie banner.</p>
|
||
</div>
|
||
|
||
{renderSectionJumpNav('global')}
|
||
|
||
<div className="admin-panel-subhead" id="global-footer"><h3>Footer</h3></div>
|
||
{FIELDS.filter(f => f.section === 'global' && ['footerTitle','footerSubtitle','footerEmail','footerCopyright','footerPrivacyNote'].includes(f.key)).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={3} />
|
||
: (
|
||
<>
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
{key.includes('ImageUrl') && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
||
{key.includes('ImageUrl') && renderImagePreview(form[key] as string, `${label} preview`)}
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="global-header"><h3>Header</h3></div>
|
||
{FIELDS.filter(f => f.section === 'global' && f.key === 'headerFollowLabel').map(({ key, label }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="global-platforms"><h3>Platform URLs</h3></div>
|
||
{FIELDS.filter(f => f.section === 'global' && f.key.startsWith('platform')).map(({ key, label }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="global-banner"><h3>Analytics Banner</h3></div>
|
||
{FIELDS.filter(f => f.section === 'global' && f.key === 'cookieBannerText').map(({ key, label }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={3} />
|
||
</div>
|
||
))}
|
||
|
||
<div className="admin-panel-subhead" id="global-welcome-email"><h3>Welcome Email</h3></div>
|
||
{FIELDS.filter(f => f.section === 'global' && f.key.startsWith('welcomeEmail')).map(({ key, label, multiline }) => (
|
||
<div className="admin-field" key={key}>
|
||
<label htmlFor={`field-${key}`}>{label}</label>
|
||
{multiline
|
||
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={3} />
|
||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||
</div>
|
||
))}
|
||
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* EMAIL TEMPLATES */}
|
||
{adminView === 'email-templates' && (
|
||
<EmailTemplatesPanel form={form} handleChange={handleChange} renderSaveStatus={renderSaveStatus} />
|
||
)}
|
||
|
||
{/* 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, add manual questions, provide answers, and approve for public display.</p>
|
||
</div>
|
||
|
||
<div className="admin-actions admin-actions--maintenance" style={{ alignItems: 'center' }}>
|
||
<input
|
||
type="text"
|
||
value={questionSearch}
|
||
onChange={e => setQuestionSearch(e.target.value)}
|
||
placeholder="Search by name, question, or answer..."
|
||
style={{ minWidth: '320px', maxWidth: '520px', width: '100%' }}
|
||
/>
|
||
<button type="button" className={`btn-admin-reset${questionFilter === 'all' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('all')}>All</button>
|
||
<button type="button" className={`btn-admin-reset${questionFilter === 'pending' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('pending')}>Pending</button>
|
||
<button type="button" className={`btn-admin-reset${questionFilter === 'approved' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('approved')}>Approved</button>
|
||
<button type="button" className={`btn-admin-reset${questionFilter === 'answered' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('answered')}>Answered</button>
|
||
<button type="button" className={`btn-admin-reset${questionFilter === 'unanswered' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('unanswered')}>Unanswered</button>
|
||
</div>
|
||
<p className="admin-stats-note">Showing {filteredAdminQuestions.length} of {questions.length} questions.</p>
|
||
|
||
{selectedQuestionIds.size > 0 && (
|
||
<div className="admin-bulk-toolbar">
|
||
<span>{selectedQuestionIds.size} selected</span>
|
||
<button type="button" className="btn-admin-remove" onClick={handleBulkDeleteQuestions}>Delete Selected</button>
|
||
<button type="button" className="btn-admin-reset" onClick={() => setSelectedQuestionIds(new Set())}>Deselect All</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="admin-visits-table-wrap">
|
||
<h3>Add Question Manually</h3>
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor="manual-question-first-name">First Name</label>
|
||
<input
|
||
id="manual-question-first-name"
|
||
type="text"
|
||
value={manualQuestion.firstName}
|
||
onChange={e => setManualQuestion(curr => ({ ...curr, firstName: e.target.value }))}
|
||
placeholder="Nate"
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="manual-question-email">Email (optional)</label>
|
||
<input
|
||
id="manual-question-email"
|
||
type="email"
|
||
value={manualQuestion.email}
|
||
onChange={e => setManualQuestion(curr => ({ ...curr, email: e.target.value }))}
|
||
placeholder="optional@email.com"
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="manual-question-question">Question</label>
|
||
<textarea
|
||
id="manual-question-question"
|
||
rows={3}
|
||
value={manualQuestion.question}
|
||
onChange={e => setManualQuestion(curr => ({ ...curr, question: e.target.value }))}
|
||
placeholder="Type the question you want to add to the Q&A list..."
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="manual-question-answer">Answer (optional)</label>
|
||
<textarea
|
||
id="manual-question-answer"
|
||
rows={3}
|
||
value={manualQuestion.answer}
|
||
onChange={e => setManualQuestion(curr => ({ ...curr, answer: e.target.value }))}
|
||
placeholder="Optional: add an answer now"
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="manual-question-approve">Approve Immediately</label>
|
||
<select
|
||
id="manual-question-approve"
|
||
value={manualQuestion.approve ? 'yes' : 'no'}
|
||
onChange={e => setManualQuestion(curr => ({ ...curr, approve: e.target.value === 'yes' }))}
|
||
>
|
||
<option value="no">No</option>
|
||
<option value="yes">Yes</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn-admin-save"
|
||
onClick={handleCreateManualQuestion}
|
||
disabled={manualQuestionStatus === 'saving'}
|
||
>
|
||
{manualQuestionStatus === 'saving' ? 'Adding…' : 'Add Question'}
|
||
</button>
|
||
</div>
|
||
{manualQuestionMsg && (
|
||
<p className={`admin-status ${manualQuestionStatus === 'error' ? 'admin-status--err' : 'admin-status--ok'}`}>
|
||
{manualQuestionMsg}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{questions.length === 0 ? (
|
||
<p className="admin-stats-note">No questions submitted yet.</p>
|
||
) : filteredAdminQuestions.length === 0 ? (
|
||
<p className="admin-stats-note">No questions match this filter.</p>
|
||
) : (
|
||
<div className="admin-questions-list">
|
||
{visibleAdminQuestions.map(question => (
|
||
<div key={question.id} className={`admin-question-card${selectedQuestionIds.has(question.id) ? ' admin-question-card--selected' : ''}`}>
|
||
<div className="admin-question-header">
|
||
<label className="admin-question-checkbox" title="Select question">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedQuestionIds.has(question.id)}
|
||
onChange={e => {
|
||
setSelectedQuestionIds(prev => {
|
||
const next = new Set(prev)
|
||
if (e.target.checked) next.add(question.id)
|
||
else next.delete(question.id)
|
||
return next
|
||
})
|
||
}}
|
||
/>
|
||
</label>
|
||
<div>
|
||
<p className="admin-question-meta"><strong>{question.firstName}</strong> • {formatDate(question.submittedAt)}</p>
|
||
<p className="admin-question-text"><strong>Q:</strong> {question.question}</p>
|
||
</div>
|
||
<div className="admin-question-status">
|
||
<span className={`admin-badge ${question.isApproved ? 'admin-badge--approved' : 'admin-badge--pending'}`}>
|
||
{question.isApproved ? 'Approved' : 'Pending'}
|
||
</span>
|
||
{question.answer && <span className="admin-badge admin-badge--answered">Answered</span>}
|
||
</div>
|
||
</div>
|
||
{question.answer && (
|
||
<div className="admin-question-answer">
|
||
<p><strong>A:</strong> {question.answer}</p>
|
||
</div>
|
||
)}
|
||
{editingQuestionId === question.id ? (
|
||
<div className="admin-question-editor">
|
||
<textarea
|
||
value={answeredQuestions[question.id] ?? question.answer ?? ''}
|
||
onChange={e => setAnsweredQuestions(a => ({ ...a, [question.id]: e.target.value }))}
|
||
rows={4}
|
||
placeholder="Type your answer here..."
|
||
/>
|
||
<div className="admin-question-editor-actions">
|
||
<button type="button" className="btn-admin-save" onClick={() => handleAnswerQuestion(question.id, answeredQuestions[question.id] ?? '')}>Save Answer</button>
|
||
<button type="button" className="btn-admin-reset" onClick={() => setEditingQuestionId(null)}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="admin-question-actions">
|
||
<button type="button" className="btn-admin-reset" onClick={() => { setEditingQuestionId(question.id); setAnsweredQuestions(a => ({ ...a, [question.id]: question.answer ?? '' })) }}>
|
||
{question.answer ? 'Edit Answer' : 'Add Answer'}
|
||
</button>
|
||
<button type="button" className={`btn-admin-${question.isApproved ? 'remove' : 'reset'}`} onClick={() => handleApproveQuestion(question.id, !question.isApproved)}>
|
||
{question.isApproved ? 'Unapprove' : 'Approve'}
|
||
</button>
|
||
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteQuestion(question.id)}>Delete</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{filteredAdminQuestions.length > QUESTION_PAGE_SIZE && (
|
||
<div className="qa-pagination" style={{ marginTop: '1rem', justifyContent: 'flex-start' }}>
|
||
<button
|
||
type="button"
|
||
className="qa-page-btn"
|
||
onClick={() => setQuestionPage(p => Math.max(0, p - 1))}
|
||
disabled={questionPage === 0}
|
||
>
|
||
Prev
|
||
</button>
|
||
<span className="qa-page-info">Page {questionPage + 1} / {totalQuestionPages}</span>
|
||
<button
|
||
type="button"
|
||
className="qa-page-btn"
|
||
onClick={() => setQuestionPage(p => Math.min(totalQuestionPages - 1, p + 1))}
|
||
disabled={questionPage >= totalQuestionPages - 1}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{/* STUDY USERS */}
|
||
{adminView === 'study-users' && <StudyUsersPanel studies={normalizeStudies(form)} />}
|
||
|
||
{/* ANALYTICS */}
|
||
{adminView === 'analytics' && (
|
||
<AnalyticsPanel
|
||
stats={stats}
|
||
statsStatus={statsStatus}
|
||
opsStatus={opsStatus}
|
||
formatDate={formatDate}
|
||
maskIp={maskIp}
|
||
maintenanceMsg={maintenanceMsg}
|
||
selectedBackup={selectedBackup}
|
||
backupFiles={backupFiles}
|
||
selectedBackupPreview={selectedBackupPreview}
|
||
onBackupSelect={setSelectedBackup}
|
||
onRestore={handleRestoreBackup}
|
||
onExport={handleExport}
|
||
onBackupNow={handleBackupNow}
|
||
onPrune={handlePrune}
|
||
onClear={handleClear}
|
||
onPurgeCache={handlePurgeCache}
|
||
onDeployHook={handleDeployHook}
|
||
onRefreshStatus={() => { void reloadOpsStatus() }}
|
||
/>
|
||
)}
|
||
|
||
{/* EMAILS */}
|
||
{adminView === 'emails' && (
|
||
<section className="admin-panel-section" aria-label="Email center">
|
||
<div className="admin-panel-head">
|
||
<h2>Email Center</h2>
|
||
<p>Manage inbound contact emails, archive threads, reply with templates, and review sent history.</p>
|
||
</div>
|
||
|
||
{contactReplyConfig && (
|
||
<div className="admin-restore-preview" style={{ marginBottom: '0.9rem' }}>
|
||
<h3>Sender Status</h3>
|
||
<p><strong>From:</strong> {contactReplyConfig.fromIdentity}</p>
|
||
<p><strong>Resend Configured:</strong> {contactReplyConfig.resendApiConfigured ? 'Yes' : 'No'}</p>
|
||
<p><strong>Can Send Replies:</strong> {contactReplyConfig.canSendReplies ? 'Yes' : 'No'}</p>
|
||
<p>{contactReplyConfig.note}</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="admin-actions admin-actions--maintenance" style={{ marginBottom: '0.75rem' }}>
|
||
<button type="button" className={`btn-admin-reset${emailMailboxView === 'inbox' ? ' btn-admin-reset--active' : ''}`} onClick={() => setEmailMailboxView('inbox')}>Inbox</button>
|
||
<button type="button" className={`btn-admin-reset${emailMailboxView === 'archived' ? ' btn-admin-reset--active' : ''}`} onClick={() => setEmailMailboxView('archived')}>Archived</button>
|
||
</div>
|
||
|
||
{contactStatus === 'loading' && <p className="admin-stats-note">Loading emails…</p>}
|
||
{contactStatus === 'error' && <p className="admin-stats-note">Could not load contact submissions.</p>}
|
||
|
||
{contactStatus === 'ready' && (
|
||
<div className="admin-email-layout">
|
||
<aside className="admin-email-list">
|
||
{contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true)).length === 0 && (
|
||
<p className="admin-stats-note">No messages in this mailbox.</p>
|
||
)}
|
||
{contactSubmissions
|
||
.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
|
||
.map(item => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className={`admin-email-list-item${selectedEmailId === item.id ? ' admin-email-list-item--active' : ''}`}
|
||
onClick={() => setSelectedEmailId(item.id)}
|
||
>
|
||
<div className="admin-email-list-head">
|
||
<strong>{item.name}</strong>
|
||
<span>{formatDate(item.submittedAt)}</span>
|
||
</div>
|
||
<p>{item.message}</p>
|
||
</button>
|
||
))}
|
||
</aside>
|
||
|
||
<div className="admin-email-detail">
|
||
{(() => {
|
||
const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
|
||
const selected = visible.find(item => item.id === selectedEmailId) ?? null
|
||
if (!selected) return <p className="admin-stats-note">Select an email to view details.</p>
|
||
|
||
return (
|
||
<>
|
||
<div className="admin-email-meta">
|
||
<p><strong>From:</strong> {selected.name} <{selected.email}></p>
|
||
<p><strong>Type:</strong> {selected.messageType}</p>
|
||
<p><strong>Subscribed:</strong> {selected.subscribe ? 'Yes' : 'No'}</p>
|
||
<p><strong>Received:</strong> {formatDate(selected.submittedAt)}</p>
|
||
</div>
|
||
|
||
<div className="admin-email-body">
|
||
<p>{selected.message}</p>
|
||
</div>
|
||
|
||
<div className="admin-actions admin-actions--maintenance">
|
||
<button type="button" className="btn-admin-save admin-email-action-btn" onClick={() => openContactReplyComposer(selected)}>Reply</button>
|
||
<button
|
||
type="button"
|
||
className="btn-admin-reset admin-email-action-btn"
|
||
onClick={() => handleArchiveContactSubmission(selected.id, !(selected.archived === true))}
|
||
>
|
||
{selected.archived === true ? 'Move to Inbox' : 'Archive'}
|
||
</button>
|
||
<button type="button" className="btn-admin-remove admin-email-action-btn" onClick={() => handleDeleteContactSubmission(selected.id)}>Delete</button>
|
||
</div>
|
||
</>
|
||
)
|
||
})()}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{contactReplyDraft && (
|
||
<div className="admin-array-row" style={{ marginTop: '1rem' }}>
|
||
<div className="admin-array-fields">
|
||
{contactReplyTemplates.length > 0 && (
|
||
<div className="admin-field">
|
||
<label htmlFor="reply-template">Saved Template</label>
|
||
<select id="reply-template" defaultValue="" onChange={e => applyContactReplyTemplate(e.target.value)}>
|
||
<option value="">Choose a template…</option>
|
||
{contactReplyTemplates.map(template => (
|
||
<option key={template.id} value={template.id}>{template.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
<div className="admin-field">
|
||
<label htmlFor="reply-to">To</label>
|
||
<input id="reply-to" type="text" value={`${contactReplyDraft.recipientName} <${contactReplyDraft.recipientEmail}>`} readOnly />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="reply-from">From</label>
|
||
<input id="reply-from" type="text" value="hello@versebyversewithnate.us" readOnly />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="reply-subject">Subject</label>
|
||
<input
|
||
id="reply-subject"
|
||
type="text"
|
||
value={contactReplyDraft.subject}
|
||
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, subject: e.target.value } : draft)}
|
||
/>
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor="reply-message">Message</label>
|
||
<textarea
|
||
id="reply-message"
|
||
rows={8}
|
||
value={contactReplyDraft.message}
|
||
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, message: e.target.value } : draft)}
|
||
/>
|
||
<p className="admin-stats-note">This will be wrapped in a professional HTML email template automatically.</p>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||
<button type="button" className="btn-admin-save" onClick={handleSendContactReply} disabled={contactReplyStatus === 'sending'}>
|
||
{contactReplyStatus === 'sending' ? 'Sending…' : 'Send Reply'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn-admin-reset"
|
||
onClick={() => {
|
||
setContactReplyDraft(null)
|
||
setContactReplyStatus('idle')
|
||
setContactReplyMsg('')
|
||
}}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{contactReplyMsg && <p className="admin-stats-note">{contactReplyMsg}</p>}
|
||
|
||
<AdminCollapsibleCard
|
||
title="Saved Reply Templates"
|
||
subtitle={`${contactReplyTemplates.length} template${contactReplyTemplates.length === 1 ? '' : 's'} configured`}
|
||
>
|
||
{contactReplyTemplates.length === 0 && <p className="admin-stats-note">No saved templates yet.</p>}
|
||
{contactReplyTemplates.map(template => (
|
||
<AdminCollapsibleCard
|
||
key={template.id}
|
||
title={template.label || 'Untitled template'}
|
||
subtitle={template.subject || 'No subject set'}
|
||
className="admin-collapsible-card--nested"
|
||
>
|
||
<div className="admin-array-row admin-array-row--nested">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field">
|
||
<label htmlFor={`reply-template-label-${template.id}`}>Label</label>
|
||
<input id={`reply-template-label-${template.id}`} type="text" value={template.label} onChange={e => updateContactReplyTemplate(template.id, 'label', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`reply-template-subject-${template.id}`}>Subject</label>
|
||
<input id={`reply-template-subject-${template.id}`} type="text" value={template.subject} onChange={e => updateContactReplyTemplate(template.id, 'subject', e.target.value)} />
|
||
</div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`reply-template-message-${template.id}`}>Message</label>
|
||
<textarea id={`reply-template-message-${template.id}`} rows={5} value={template.message} onChange={e => updateContactReplyTemplate(template.id, 'message', e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeContactReplyTemplate(template.id)}>Remove</button>
|
||
</div>
|
||
</AdminCollapsibleCard>
|
||
))}
|
||
<div className="admin-actions admin-actions--maintenance">
|
||
<button type="button" className="btn-admin-reset" onClick={addContactReplyTemplate}>Add Template</button>
|
||
<button type="button" className="btn-admin-save" onClick={handleSaveContactReplyTemplates} disabled={contactTemplateStatus === 'saving'}>
|
||
{contactTemplateStatus === 'saving' ? 'Saving…' : 'Save Templates'}
|
||
</button>
|
||
</div>
|
||
</AdminCollapsibleCard>
|
||
|
||
<div className="admin-visits-table-wrap">
|
||
<h3>Reply History</h3>
|
||
{contactReplyHistory.length === 0 ? <p className="admin-stats-note">No admin replies have been sent yet.</p> : (
|
||
<div className="admin-visits-table-scroll">
|
||
<table className="admin-visits-table">
|
||
<thead>
|
||
<tr><th>Sent</th><th>To</th><th>From</th><th>Subject</th><th>Preview</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{contactReplyHistory.map(item => (
|
||
<tr key={item.id}>
|
||
<td>{formatDate(item.sentAt)}</td>
|
||
<td>{item.toName} ({item.toEmail})</td>
|
||
<td>{item.fromEmail}</td>
|
||
<td>{item.subject}</td>
|
||
<td>{item.preview}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* ASSETS */}
|
||
{adminView === 'assets' && (
|
||
<section className="admin-panel-section" aria-label="Asset manager">
|
||
<div className="admin-panel-head">
|
||
<h2>Asset Manager</h2>
|
||
<p>Upload, tag, and reuse hosted images from this domain.</p>
|
||
</div>
|
||
<div className="admin-actions admin-actions--maintenance">
|
||
<label className="btn-admin-reset" style={{ cursor: 'pointer' }}>
|
||
{assetUploadPending ? 'Uploading…' : 'Upload Image or PDF'}
|
||
<input type="file" accept="image/*,.pdf" style={{ display: 'none' }} onChange={handleAssetUpload} disabled={assetUploadPending} />
|
||
</label>
|
||
</div>
|
||
{assets.length === 0 && <p className="admin-stats-note">No assets uploaded yet.</p>}
|
||
{assets.length > 0 && (
|
||
<div className="admin-visits-table-scroll">
|
||
<table className="admin-visits-table admin-assets-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Preview</th>
|
||
<th>Filename</th>
|
||
<th>Size</th>
|
||
<th>Updated</th>
|
||
<th>Tags</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{assets.map(asset => (
|
||
<tr key={asset.filename}>
|
||
<td>
|
||
{isImageAsset(asset.filename)
|
||
? <img src={asset.url} alt={asset.filename} className="admin-asset-thumb" style={{ width: '72px', height: '52px', objectFit: 'cover' }} />
|
||
: <span>PDF/File</span>}
|
||
</td>
|
||
<td>{asset.filename}</td>
|
||
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
|
||
<td>{formatDate(asset.updatedAt)}</td>
|
||
<td>
|
||
<input
|
||
type="text"
|
||
className="admin-asset-tags-input"
|
||
placeholder="Add tags…"
|
||
value={assetTagEdits[asset.filename] ?? (asset.tags ?? []).join(', ')}
|
||
onChange={e => setAssetTagEdits(t => ({ ...t, [asset.filename]: e.target.value }))}
|
||
onBlur={() => handleSaveAssetTags(asset.filename)}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<div className="admin-asset-actions-inline">
|
||
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => { navigator.clipboard.writeText(asset.url).catch(() => {}) }}>Copy URL</button>
|
||
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
|
||
</section>
|
||
)}
|
||
|
||
{/* SEO & REDIRECTS */}
|
||
{adminView === 'seo' && (
|
||
<section className="admin-panel-section" aria-label="SEO & Redirects">
|
||
<div className="admin-panel-head">
|
||
<h2>SEO & Redirects</h2>
|
||
<p>Control metadata, canonical URL, robots policy, sitemap paths, and short-link redirects.</p>
|
||
</div>
|
||
|
||
{renderSectionJumpNav('seo')}
|
||
|
||
<div className="admin-section-header" id="seo-metadata">
|
||
<h3>Metadata</h3>
|
||
<p>Search title, descriptions, Open Graph image, canonical URL, robots policy, and sitemap paths.</p>
|
||
</div>
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field"><label htmlFor="seo-title">SEO Title</label><input id="seo-title" type="text" value={form.seo?.title ?? ''} onChange={e => updateSeoField('title', e.target.value)} /></div>
|
||
<div className="admin-field"><label htmlFor="seo-description">SEO Description</label><textarea id="seo-description" rows={3} value={form.seo?.description ?? ''} onChange={e => updateSeoField('description', e.target.value)} /></div>
|
||
<div className="admin-field"><label htmlFor="seo-og-title">Open Graph Title</label><input id="seo-og-title" type="text" value={form.seo?.ogTitle ?? ''} onChange={e => updateSeoField('ogTitle', e.target.value)} /></div>
|
||
<div className="admin-field"><label htmlFor="seo-og-description">Open Graph Description</label><textarea id="seo-og-description" rows={3} value={form.seo?.ogDescription ?? ''} onChange={e => updateSeoField('ogDescription', e.target.value)} /></div>
|
||
<div className="admin-field">
|
||
<label htmlFor="seo-og-image">Open Graph Image URL</label>
|
||
<input id="seo-og-image" type="text" value={form.seo?.ogImage ?? ''} onChange={e => updateSeoField('ogImage', e.target.value)} />
|
||
{renderImageAssetSelector(form.seo?.ogImage, value => updateSeoField('ogImage', value), 'seo-og-image-asset')}
|
||
</div>
|
||
<div className="admin-field"><label htmlFor="seo-canonical">Canonical URL</label><input id="seo-canonical" type="text" value={form.seo?.canonicalUrl ?? ''} onChange={e => updateSeoField('canonicalUrl', e.target.value)} /></div>
|
||
<div className="admin-field"><label htmlFor="seo-robots">Robots Policy</label><input id="seo-robots" type="text" value={form.seo?.robotsPolicy ?? ''} onChange={e => updateSeoField('robotsPolicy', e.target.value)} /></div>
|
||
<div className="admin-field">
|
||
<label htmlFor="seo-sitemap">Sitemap Paths (one per line)</label>
|
||
<textarea id="seo-sitemap" rows={4} value={(form.seo?.sitemapPaths ?? []).join('\n')} onChange={e => updateSeoField('sitemapPaths', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="admin-section-header" id="seo-redirects">
|
||
<h3>Redirect Manager</h3>
|
||
<p>Maintain first-party short links like /spotify without code changes.</p>
|
||
</div>
|
||
{(form.redirects ?? []).map(rule => (
|
||
<div key={rule.id} className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field"><label htmlFor={`redirect-path-${rule.id}`}>Path</label><input id={`redirect-path-${rule.id}`} type="text" value={rule.path} onChange={e => updateRedirectRule(rule.id, 'path', e.target.value)} /></div>
|
||
<div className="admin-field"><label htmlFor={`redirect-target-${rule.id}`}>Target URL</label><input id={`redirect-target-${rule.id}`} type="text" value={rule.target} onChange={e => updateRedirectRule(rule.id, 'target', e.target.value)} /></div>
|
||
<div className="admin-field">
|
||
<label htmlFor={`redirect-status-${rule.id}`}>Status</label>
|
||
<select id={`redirect-status-${rule.id}`} value={rule.statusCode} onChange={e => updateRedirectRule(rule.id, 'statusCode', Number(e.target.value))}>
|
||
<option value={301}>301 Permanent</option>
|
||
<option value={302}>302 Temporary</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-admin-remove" onClick={() => removeRedirectRule(rule.id)}>Remove</button>
|
||
</div>
|
||
))}
|
||
<button type="button" className="btn-admin-add" onClick={addRedirectRule}>+ Add Redirect</button>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* LEGAL */}
|
||
{adminView === 'legal' && (
|
||
<section className="admin-panel-section" aria-label="Legal Pages">
|
||
<div className="admin-panel-head">
|
||
<h2>Legal Pages</h2>
|
||
<p>Edit Privacy and Terms pages from admin.</p>
|
||
</div>
|
||
|
||
{renderSectionJumpNav('legal')}
|
||
|
||
<div className="admin-section-header" id="legal-privacy">
|
||
<h3>Privacy</h3>
|
||
<p>Edit the title and body content for the privacy page.</p>
|
||
</div>
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field"><label htmlFor="legal-privacy-title">Privacy Title</label><input id="legal-privacy-title" type="text" value={form.legal?.privacyTitle ?? ''} onChange={e => updateLegalField('privacyTitle', e.target.value)} /></div>
|
||
<div className="admin-field">
|
||
<label htmlFor="legal-privacy-body">Privacy Body (one paragraph per line)</label>
|
||
<textarea id="legal-privacy-body" rows={4} value={(form.legal?.privacyBody ?? []).join('\n')} onChange={e => updateLegalField('privacyBody', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="admin-section-header" id="legal-terms">
|
||
<h3>Terms</h3>
|
||
<p>Edit the title and body content for the terms page.</p>
|
||
</div>
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<div className="admin-field"><label htmlFor="legal-terms-title">Terms Title</label><input id="legal-terms-title" type="text" value={form.legal?.termsTitle ?? ''} onChange={e => updateLegalField('termsTitle', e.target.value)} /></div>
|
||
<div className="admin-field">
|
||
<label htmlFor="legal-terms-body">Terms Body (one paragraph per line)</label>
|
||
<textarea id="legal-terms-body" rows={4} value={(form.legal?.termsBody ?? []).join('\n')} onChange={e => updateLegalField('termsBody', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{renderSaveStatus()}
|
||
</section>
|
||
)}
|
||
|
||
{/* SECURITY */}
|
||
{adminView === 'security' && (
|
||
<section className="admin-panel-section" aria-label="Security">
|
||
<div className="admin-panel-head">
|
||
<h2>Security</h2>
|
||
<p>Manage two-factor authentication for admin login.</p>
|
||
</div>
|
||
{totpEnabled === null && <p className="admin-stats-note">Loading…</p>}
|
||
{totpEnabled === false && !totpSetupQr && (
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<p className="admin-stats-note">2FA is currently <strong>off</strong>. Enable it to require an authenticator app at every login.</p>
|
||
<button type="button" className="btn-primary" onClick={handleTotpSetupInit}>Enable 2FA</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{totpSetupQr && (
|
||
<div className="admin-array-row">
|
||
<div className="admin-array-fields">
|
||
<p className="admin-stats-note">Scan this QR code with your authenticator app, then enter the 6-digit code below to confirm.</p>
|
||
<img src={totpSetupQr} alt="TOTP QR code" style={{ width: 200, height: 200, display: 'block', margin: '0.5rem 0' }} />
|
||
{totpSetupSecret && <p className="admin-stats-note" style={{ wordBreak: 'break-all' }}>Manual entry key: <code>{totpSetupSecret}</code></p>}
|
||
<form onSubmit={handleTotpSetupConfirm} style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
|
||
<label htmlFor="totp-confirm-code">Confirmation Code</label>
|
||
<input id="totp-confirm-code" type="text" inputMode="numeric" value={totpConfirmCode} onChange={e => setTotpConfirmCode(e.target.value)} placeholder="000000" autoFocus required />
|
||
</div>
|
||
<button type="submit" className="btn-primary">Confirm & 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>
|
||
)
|
||
}
|