Enhance admin dashboard and scripture linking

This commit is contained in:
nmemmert
2026-05-12 09:12:55 -04:00
parent 0703a802ff
commit becc1b5b27
4 changed files with 976 additions and 21 deletions
+375 -6
View File
@@ -141,6 +141,13 @@ interface ContactReplyHistoryItem {
sentAt: string
}
interface Subscriber {
name: string
email: string
subscribedAt: string
source: 'contact-form' | 'download'
}
interface ContactReplyConfig {
fromEmail: string
fromIdentity: string
@@ -152,11 +159,11 @@ interface ContactReplyConfig {
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards'>
type AdminView =
| 'homepage' | 'start-here' | 'about' | 'contact'
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
| 'current-series' | 'episode-highlights' | 'archived-series'
| 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'analytics' | 'assets'
| 'emails'
| 'emails' | 'subscribers' | 'contacts'
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'global'
@@ -285,7 +292,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content))
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [adminView, setAdminView] = useState<AdminView>('homepage')
const [adminView, setAdminView] = useState<AdminView>('dashboard')
const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [maintenanceMsg, setMaintenanceMsg] = useState('')
@@ -324,7 +331,13 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [questionSearch, setQuestionSearch] = useState('')
const [questionFilter, setQuestionFilter] = useState<'all' | 'pending' | 'approved' | 'answered' | 'unanswered'>('all')
const [questionPage, setQuestionPage] = useState(0)
const [selectedQuestionIds, setSelectedQuestionIds] = useState<Set<string>>(new Set())
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
const [subscriberSearch, setSubscriberSearch] = useState('')
const [contactSearch, setContactSearch] = useState('')
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
const [dashboardNow, setDashboardNow] = useState(() => new Date())
const [manualQuestion, setManualQuestion] = useState({
firstName: '',
email: '',
@@ -339,6 +352,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const previewIframeRef = useRef<HTMLIFrameElement>(null)
const isDirty = JSON.stringify(form) !== lastSavedSnapshot
const unreadEmailCount = contactSubmissions.filter(s => !s.archived).length
const unansweredCount = questions.filter(q => !q.answer?.trim()).length
// Broadcast live form state + active view into the preview iframe whenever they change
useEffect(() => {
@@ -358,6 +373,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setLastSavedSnapshot(nextSnapshot)
}, [content])
useEffect(() => {
const intervalId = window.setInterval(() => {
setDashboardNow(new Date())
}, 1000)
return () => window.clearInterval(intervalId)
}, [])
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty) return
@@ -457,6 +480,16 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setOpsStatus(data as OpsStatus)
})
.catch(() => {})
fetch('/api/admin-subscribers')
.then(r => (r.ok ? r.json() : Promise.reject()))
.then(data => setSubscribers((data as { subscribers: Subscriber[] }).subscribers ?? []))
.catch(() => {})
fetch('/api/admin-download-stats')
.then(r => (r.ok ? r.json() : Promise.reject()))
.then(data => setDownloadStats((data as { counts: Record<string, number> }).counts ?? {}))
.catch(() => {})
}, [])
useEffect(() => {
@@ -1383,11 +1416,32 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
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')
@@ -1650,6 +1704,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</button>
</div>
<div className="admin-nav-group">
<span className="admin-nav-label">Overview</span>
<button type="button" className={`admin-nav-item${adminView === 'dashboard' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('dashboard')}>Dashboard</button>
</div>
<div className="admin-nav-group">
<span className="admin-nav-label">Site</span>
<button type="button" className={`admin-nav-item${adminView === 'homepage' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('homepage')}>Homepage</button>
@@ -1674,8 +1733,18 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<div className="admin-nav-group">
<span className="admin-nav-label">Manage</span>
<button type="button" className={`admin-nav-item${adminView === 'questions' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('questions')}>Questions ({questions.length})</button>
<button type="button" className={`admin-nav-item${adminView === 'emails' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('emails')}>Emails</button>
<button type="button" className={`admin-nav-item${adminView === 'questions' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('questions')}>
Questions ({questions.length}){unansweredCount > 0 && <span className="admin-nav-badge">{unansweredCount}</span>}
</button>
<button type="button" className={`admin-nav-item${adminView === 'emails' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('emails')}>
Emails{unreadEmailCount > 0 && <span className="admin-nav-badge">{unreadEmailCount}</span>}
</button>
<button type="button" className={`admin-nav-item${adminView === 'contacts' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('contacts')}>
Contacts{contactSubmissions.length > 0 && <span className="admin-nav-badge admin-nav-badge--neutral">{contactSubmissions.length}</span>}
</button>
<button type="button" className={`admin-nav-item${adminView === 'subscribers' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('subscribers')}>
Subscribers{subscribers.length > 0 && <span className="admin-nav-badge admin-nav-badge--neutral">{subscribers.length}</span>}
</button>
<button type="button" className={`admin-nav-item${adminView === 'analytics' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('analytics')}>Analytics</button>
<button type="button" className={`admin-nav-item${adminView === 'assets' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('assets')}>Asset Manager</button>
</div>
@@ -1695,6 +1764,284 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
{/* ── Content panel ── */}
<main className="admin-panel">
{/* DASHBOARD */}
{adminView === 'dashboard' && (() => {
const thisWeekHits = stats?.last7Days?.reduce((s, d) => s + d.hits, 0) ?? 0
const thisWeekReal = stats?.last7DaysReal?.reduce((s, d) => s + d.hits, 0) ?? 0
const dashboardHour = dashboardNow.getHours()
const welcomeMessage = dashboardHour < 12
? 'Good morning.'
: dashboardHour < 18
? 'Good afternoon.'
: 'Good evening.'
const dashboardDateLabel = dashboardNow.toLocaleDateString(undefined, {
weekday: 'long',
month: 'long',
day: 'numeric',
})
const dashboardTimeLabel = dashboardNow.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
})
const recentContacts = [...contactSubmissions]
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
.slice(0, 5)
const recentQuestions = [...questions]
.sort((a, b) => new Date(b.submittedAt ?? '').getTime() - new Date(a.submittedAt ?? '').getTime())
.slice(0, 5)
const approvedCount = questions.filter(q => q.isApproved).length
const answeredCount = questions.filter(q => !!q.answer?.trim()).length
return (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Dashboard</h2>
<p>Quick overview of your ministry site.</p>
</div>
<div className="admin-dashboard-welcome">
<div>
<h3 className="admin-dashboard-welcome-title">{welcomeMessage}</h3>
<p className="admin-dashboard-welcome-copy">Here&apos;s what needs your attention and how the site is performing today.</p>
</div>
<div className="admin-dashboard-clock" aria-label={`Current time ${dashboardTimeLabel} on ${dashboardDateLabel}`}>
<span className="admin-dashboard-clock-time">{dashboardTimeLabel}</span>
<span className="admin-dashboard-clock-date">{dashboardDateLabel}</span>
</div>
</div>
<div className="admin-dashboard-grid">
<div className={`admin-dashboard-card${unansweredCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
<div className="admin-dashboard-card-value">{unansweredCount}</div>
<div className="admin-dashboard-card-label">Unanswered Questions</div>
<div className="admin-dashboard-card-sub">{answeredCount} answered · {approvedCount} approved of {questions.length}</div>
{unansweredCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => { setQuestionFilter('unanswered'); navigateTo('questions') }}>Answer Now </button>}
</div>
<div className={`admin-dashboard-card${unreadEmailCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
<div className="admin-dashboard-card-value">{unreadEmailCount}</div>
<div className="admin-dashboard-card-label">Unread Emails</div>
<div className="admin-dashboard-card-sub">{contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total</div>
{unreadEmailCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('emails')}>Open Inbox </button>}
</div>
<div className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
<div className="admin-dashboard-card-label">Real Visits (7 Days)</div>
<div className="admin-dashboard-card-sub">{thisWeekHits.toLocaleString()} total · {stats?.visitors?.uniqueVisitors?.toLocaleString() ?? '—'} unique all time</div>
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('analytics')}>Full Analytics </button>
</div>
<div className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{subscribers.length}</div>
<div className="admin-dashboard-card-label">Email Subscribers</div>
<div className="admin-dashboard-card-sub">{contactSubmissions.length} total contact submissions</div>
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('subscribers')}>View List </button>
</div>
<div className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{downloadStats['titus-study'] ?? 0}</div>
<div className="admin-dashboard-card-label">Titus Study Downloads</div>
<div className="admin-dashboard-card-sub">
{Object.values(downloadStats).reduce((a, b) => a + b, 0)} total resource downloads
</div>
</div>
{publishState?.publishedAt && (
<div className="admin-dashboard-card">
<div className="admin-dashboard-card-value" style={{ fontSize: '1rem', marginTop: '0.25rem' }}>{formatDate(publishState.publishedAt)}</div>
<div className="admin-dashboard-card-label">Last Published</div>
{publishState.draftUpdatedAt && <div className="admin-dashboard-card-sub">Draft updated {formatDate(publishState.draftUpdatedAt)}</div>}
</div>
)}
</div>
<div className="admin-dashboard-activity">
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Recent Contacts</h3>
{recentContacts.length === 0
? <p className="admin-stats-note">No contacts yet.</p>
: (
<div className="admin-dashboard-feed">
{recentContacts.map(c => (
<div key={c.id} className="admin-dashboard-feed-item">
<div className="admin-dashboard-feed-meta">
<strong>{c.name}</strong>
<span className="admin-dashboard-feed-date">{formatDate(c.submittedAt)}</span>
</div>
<div className="admin-dashboard-feed-email">{c.email}</div>
{c.message && <div className="admin-dashboard-feed-preview">{c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}</div>}
</div>
))}
<button type="button" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem' }} onClick={() => navigateTo('contacts')}>View All Contacts </button>
</div>
)
}
</div>
<div className="admin-dashboard-activity-col">
<h3 className="admin-dashboard-activity-heading">Recent Questions</h3>
{recentQuestions.length === 0
? <p className="admin-stats-note">No questions yet.</p>
: (
<div className="admin-dashboard-feed">
{recentQuestions.map(q => (
<div key={q.id} className="admin-dashboard-feed-item">
<div className="admin-dashboard-feed-meta">
<strong>{q.firstName}</strong>
<span className={`admin-badge ${q.isApproved ? 'admin-badge--approved' : 'admin-badge--pending'}`} style={{ fontSize: '0.65rem' }}>{q.isApproved ? 'Approved' : 'Pending'}</span>
<span className="admin-dashboard-feed-date">{formatDate(q.submittedAt ?? '')}</span>
</div>
<div className="admin-dashboard-feed-preview">{q.question.slice(0, 100)}{q.question.length > 100 ? '…' : ''}</div>
</div>
))}
<button type="button" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem' }} onClick={() => navigateTo('questions')}>View All Questions </button>
</div>
)
}
</div>
</div>
</section>
)
})()}
{/* CONTACTS */}
{adminView === 'contacts' && (() => {
const searchTerm = contactSearch.trim().toLowerCase()
const grouped = new Map<string, ContactSubmission[]>()
for (const submission of contactSubmissions) {
const emailKey = submission.email.trim().toLowerCase()
const nameKey = submission.name.trim().toLowerCase()
const key = emailKey || nameKey || submission.id
const entries = grouped.get(key)
if (entries) entries.push(submission)
else grouped.set(key, [submission])
}
const rolledUp = Array.from(grouped.values())
.map(entries => {
const sortedEntries = [...entries].sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
const latest = sortedEntries[0]
return {
...latest,
archived: sortedEntries.every(entry => entry.archived === true),
subscribe: sortedEntries.some(entry => entry.subscribe),
message: latest.message || sortedEntries.find(entry => entry.message)?.message || '',
submissionCount: sortedEntries.length,
}
})
.filter(contact => {
if (!searchTerm) return true
return contact.name.toLowerCase().includes(searchTerm)
|| contact.email.toLowerCase().includes(searchTerm)
|| contact.message.toLowerCase().includes(searchTerm)
})
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
return (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Contacts</h2>
<p>{rolledUp.length} contacts from {contactSubmissions.length} total submissions repeat senders are grouped together.</p>
</div>
<div className="admin-toolbar" style={{ marginBottom: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="search"
placeholder="Search name, email, or message…"
value={contactSearch}
onChange={e => setContactSearch(e.target.value)}
style={{ minWidth: '260px', maxWidth: '440px', width: '100%' }}
/>
<span className="admin-stats-note" style={{ margin: 0 }}>{rolledUp.length} result{rolledUp.length !== 1 ? 's' : ''}</span>
</div>
{rolledUp.length === 0
? <p className="admin-stats-note">No contacts{contactSearch ? ' match your search' : ' yet'}.</p>
: (
<div className="admin-visits-table-wrap">
<table className="admin-visits-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Type</th>
<th>Subscriber</th>
<th>Date</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{rolledUp.map(c => (
<tr key={c.id} className={c.archived ? 'admin-contacts-row--archived' : ''}>
<td>
<div>{c.name}</div>
{c.submissionCount > 1 && <div className="admin-stats-note" style={{ margin: '0.2rem 0 0' }}>{c.submissionCount} submissions</div>}
</td>
<td><a href={`mailto:${c.email}`}>{c.email}</a></td>
<td><span className="admin-badge admin-badge--pending" style={{ fontSize: '0.7rem' }}>{c.messageType ?? 'contact'}</span></td>
<td style={{ textAlign: 'center' }}>{c.subscribe ? '✓' : ''}</td>
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(c.submittedAt)}</td>
<td style={{ maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={c.message}>{c.message ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
</section>
)
})()}
{/* SUBSCRIBERS */}
{adminView === 'subscribers' && (() => {
const filteredSubs = subscribers.filter(s =>
!subscriberSearch.trim() ||
s.name.toLowerCase().includes(subscriberSearch.toLowerCase()) ||
s.email.toLowerCase().includes(subscriberSearch.toLowerCase())
)
return (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Subscribers</h2>
<p>{subscribers.length} people have opted in to email updates.</p>
</div>
<div className="admin-toolbar" style={{ marginBottom: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="search"
placeholder="Search by name or email…"
value={subscriberSearch}
onChange={e => setSubscriberSearch(e.target.value)}
style={{ minWidth: '240px', maxWidth: '400px', width: '100%' }}
/>
<form method="post" action="/api/admin-subscribers/export" style={{ display: 'inline' }}>
<button type="submit" className="btn-admin-reset">Export CSV</button>
</form>
</div>
{filteredSubs.length === 0 ? (
<p className="admin-stats-note">No subscribers{subscriberSearch ? ' match your search' : ' yet'}.</p>
) : (
<div className="admin-visits-table-wrap">
<table className="admin-visits-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Source</th>
<th>Subscribed</th>
</tr>
</thead>
<tbody>
{filteredSubs.map((sub, i) => (
<tr key={`${sub.email}-${i}`}>
<td>{sub.name}</td>
<td><a href={`mailto:${sub.email}`}>{sub.email}</a></td>
<td>{sub.source === 'download' ? 'Download' : 'Contact Form'}</td>
<td>{formatDate(sub.subscribedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
})()}
{/* HOMEPAGE */}
{adminView === 'homepage' && (
<section className="admin-panel-section">
@@ -2441,6 +2788,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</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">
@@ -2520,8 +2875,22 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
) : (
<div className="admin-questions-list">
{visibleAdminQuestions.map(question => (
<div key={question.id} className="admin-question-card">
<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>