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
+68
View File
@@ -58,6 +58,7 @@ const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
const BACKUP_DIR = path.join(DATA_DIR, 'backups') const BACKUP_DIR = path.join(DATA_DIR, 'backups')
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads') const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json') const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
const DOWNLOAD_COUNTS_FILE = path.join(DATA_DIR, 'download-counts.json')
const DIST_DIR = path.join(__dirname, 'dist') const DIST_DIR = path.join(__dirname, 'dist')
const INDEX_FILE = path.join(DIST_DIR, 'index.html') const INDEX_FILE = path.join(DIST_DIR, 'index.html')
const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images') const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images')
@@ -258,6 +259,33 @@ async function readUploadsMetadata() {
} }
} }
function loadDownloadCountsFromDisk() {
return readFile(DOWNLOAD_COUNTS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
downloadCounts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
})
.catch(() => {
downloadCounts = {}
})
}
function queueDownloadCountsWrite() {
downloadCountsWritePromise = downloadCountsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(DOWNLOAD_COUNTS_FILE, JSON.stringify(downloadCounts, null, 2), 'utf8')
})
.catch(err => {
console.error('[download-counts] failed to write:', err)
})
}
function incrementDownloadCount(resourceKey) {
downloadCounts[resourceKey] = (downloadCounts[resourceKey] ?? 0) + 1
queueDownloadCountsWrite()
}
async function writeUploadsMetadata(metadata) { async function writeUploadsMetadata(metadata) {
await mkdir(DATA_DIR, { recursive: true }) await mkdir(DATA_DIR, { recursive: true })
await writeFile(UPLOADS_META_FILE, JSON.stringify(metadata, null, 2), 'utf8') await writeFile(UPLOADS_META_FILE, JSON.stringify(metadata, null, 2), 'utf8')
@@ -359,6 +387,8 @@ let contactSubmissions = []
let contactSubmissionsWritePromise = Promise.resolve() let contactSubmissionsWritePromise = Promise.resolve()
let questions = [] let questions = []
let questionsWritePromise = Promise.resolve() let questionsWritePromise = Promise.resolve()
let downloadCounts = {}
let downloadCountsWritePromise = Promise.resolve()
let lastVisitorStatsWrite = { ok: true, at: null, error: null } let lastVisitorStatsWrite = { ok: true, at: null, error: null }
let lastHitStatsWrite = { ok: true, at: null, error: null } let lastHitStatsWrite = { ok: true, at: null, error: null }
let lastBackupStatus = { ok: true, at: null, error: null, file: null } let lastBackupStatus = { ok: true, at: null, error: null, file: null }
@@ -1918,6 +1948,40 @@ app.post('/api/admin-contact-submissions/:id/reply', requireAdminAuth, async (re
} }
}) })
app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => {
res.json({ counts: downloadCounts })
})
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
const seen = new Set()
const subscribers = contactSubmissions
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
.map(entry => ({
name: entry.name,
email: entry.email,
subscribedAt: entry.submittedAt,
source: entry.message?.startsWith('Requested') ? 'download' : 'contact-form',
}))
.sort((a, b) => new Date(b.subscribedAt).getTime() - new Date(a.subscribedAt).getTime())
res.json({ subscribers, total: subscribers.length })
})
app.post('/api/admin-subscribers/export', requireAdminAuth, (_req, res) => {
const seen = new Set()
const rows = [['Name', 'Email', 'Subscribed At', 'Source']]
contactSubmissions
.filter(entry => entry.subscribe === true && entry.email && !seen.has(entry.email) && seen.add(entry.email))
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
.forEach(entry => {
const source = entry.message?.startsWith('Requested') ? 'download' : 'contact-form'
rows.push([entry.name, entry.email, entry.submittedAt, source])
})
const csv = rows.map(row => row.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')).join('\n')
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`)
res.send(csv)
})
app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => { app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
let adminContent = null let adminContent = null
let draftContent = null let draftContent = null
@@ -2105,6 +2169,8 @@ app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res)
await syncContactToResend(trimmedName, trimmedEmail) await syncContactToResend(trimmedName, trimmedEmail)
} }
incrementDownloadCount('titus-study')
if (configuredDownloadUrl) { if (configuredDownloadUrl) {
res.json({ ok: true, downloadUrl: configuredDownloadUrl }) res.json({ ok: true, downloadUrl: configuredDownloadUrl })
return return
@@ -2212,6 +2278,7 @@ app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => {
await syncContactToResend(trimmedName, trimmedEmail) await syncContactToResend(trimmedName, trimmedEmail)
} }
incrementDownloadCount(`resource:${resourceId}`)
res.json({ ok: true, downloadUrl: resource.url.trim() }) res.json({ ok: true, downloadUrl: resource.url.trim() })
} catch (err) { } catch (err) {
console.error('[resource-download] request error:', err) console.error('[resource-download] request error:', err)
@@ -2991,6 +3058,7 @@ Promise.all([
loadReplyHistoryFromDisk(), loadReplyHistoryFromDisk(),
loadQuestionsFromDisk(), loadQuestionsFromDisk(),
loadDraftQuestionsFromDisk(), loadDraftQuestionsFromDisk(),
loadDownloadCountsFromDisk(),
refreshContentCaches(), refreshContentCaches(),
]) ])
.catch(err => { .catch(err => {
+375 -6
View File
@@ -141,6 +141,13 @@ interface ContactReplyHistoryItem {
sentAt: string sentAt: string
} }
interface Subscriber {
name: string
email: string
subscribedAt: string
source: 'contact-form' | 'download'
}
interface ContactReplyConfig { interface ContactReplyConfig {
fromEmail: string fromEmail: string
fromIdentity: string fromIdentity: string
@@ -152,11 +159,11 @@ interface ContactReplyConfig {
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards'> type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards'>
type AdminView = type AdminView =
| 'homepage' | 'start-here' | 'about' | 'contact' | 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
| 'current-series' | 'episode-highlights' | 'archived-series' | 'current-series' | 'episode-highlights' | 'archived-series'
| 'downloads' | 'custom-links' | 'content-blocks' | 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'analytics' | 'assets' | 'questions' | 'analytics' | 'assets'
| 'emails' | 'emails' | 'subscribers' | 'contacts'
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'seo' | 'legal' | 'security' | 'brand' | 'global'
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | '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 [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content))
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const [adminView, setAdminView] = useState<AdminView>('homepage') const [adminView, setAdminView] = useState<AdminView>('dashboard')
const [stats, setStats] = useState<AdminStats | null>(null) const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [maintenanceMsg, setMaintenanceMsg] = useState('') const [maintenanceMsg, setMaintenanceMsg] = useState('')
@@ -324,7 +331,13 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [questionSearch, setQuestionSearch] = useState('') const [questionSearch, setQuestionSearch] = useState('')
const [questionFilter, setQuestionFilter] = useState<'all' | 'pending' | 'approved' | 'answered' | 'unanswered'>('all') const [questionFilter, setQuestionFilter] = useState<'all' | 'pending' | 'approved' | 'answered' | 'unanswered'>('all')
const [questionPage, setQuestionPage] = useState(0) const [questionPage, setQuestionPage] = useState(0)
const [selectedQuestionIds, setSelectedQuestionIds] = useState<Set<string>>(new Set())
const [mobileNavOpen, setMobileNavOpen] = useState(false) 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({ const [manualQuestion, setManualQuestion] = useState({
firstName: '', firstName: '',
email: '', email: '',
@@ -339,6 +352,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const previewIframeRef = useRef<HTMLIFrameElement>(null) const previewIframeRef = useRef<HTMLIFrameElement>(null)
const isDirty = JSON.stringify(form) !== lastSavedSnapshot 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 // Broadcast live form state + active view into the preview iframe whenever they change
useEffect(() => { useEffect(() => {
@@ -358,6 +373,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setLastSavedSnapshot(nextSnapshot) setLastSavedSnapshot(nextSnapshot)
}, [content]) }, [content])
useEffect(() => {
const intervalId = window.setInterval(() => {
setDashboardNow(new Date())
}, 1000)
return () => window.clearInterval(intervalId)
}, [])
useEffect(() => { useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => { const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty) return if (!isDirty) return
@@ -457,6 +480,16 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setOpsStatus(data as OpsStatus) setOpsStatus(data as OpsStatus)
}) })
.catch(() => {}) .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(() => { useEffect(() => {
@@ -1383,11 +1416,32 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const res = await fetch(`/api/admin-questions/${questionId}`, { method: 'DELETE' }) const res = await fetch(`/api/admin-questions/${questionId}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete question') if (!res.ok) throw new Error('Failed to delete question')
setQuestions(qs => qs.filter(q => q.id !== questionId)) setQuestions(qs => qs.filter(q => q.id !== questionId))
setSelectedQuestionIds(prev => { const next = new Set(prev); next.delete(questionId); return next })
} catch { } catch {
alert('Failed to delete question') 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() { async function handleCreateManualQuestion() {
if (!manualQuestion.firstName.trim() || !manualQuestion.question.trim()) { if (!manualQuestion.firstName.trim() || !manualQuestion.question.trim()) {
setManualQuestionStatus('error') setManualQuestionStatus('error')
@@ -1650,6 +1704,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</button> </button>
</div> </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"> <div className="admin-nav-group">
<span className="admin-nav-label">Site</span> <span className="admin-nav-label">Site</span>
<button type="button" className={`admin-nav-item${adminView === 'homepage' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('homepage')}>Homepage</button> <button type="button" className={`admin-nav-item${adminView === '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"> <div className="admin-nav-group">
<span className="admin-nav-label">Manage</span> <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 === 'questions' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('questions')}>
<button type="button" className={`admin-nav-item${adminView === 'emails' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('emails')}>Emails</button> 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 === '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> <button type="button" className={`admin-nav-item${adminView === 'assets' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('assets')}>Asset Manager</button>
</div> </div>
@@ -1695,6 +1764,284 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
{/* ── Content panel ── */} {/* ── Content panel ── */}
<main className="admin-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 */} {/* HOMEPAGE */}
{adminView === 'homepage' && ( {adminView === 'homepage' && (
<section className="admin-panel-section"> <section className="admin-panel-section">
@@ -2441,6 +2788,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</div> </div>
<p className="admin-stats-note">Showing {filteredAdminQuestions.length} of {questions.length} questions.</p> <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"> <div className="admin-visits-table-wrap">
<h3>Add Question Manually</h3> <h3>Add Question Manually</h3>
<div className="admin-array-row"> <div className="admin-array-row">
@@ -2520,8 +2875,22 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
) : ( ) : (
<div className="admin-questions-list"> <div className="admin-questions-list">
{visibleAdminQuestions.map(question => ( {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"> <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> <div>
<p className="admin-question-meta"><strong>{question.firstName}</strong> {formatDate(question.submittedAt)}</p> <p className="admin-question-meta"><strong>{question.firstName}</strong> {formatDate(question.submittedAt)}</p>
<p className="admin-question-text"><strong>Q:</strong> {question.question}</p> <p className="admin-question-text"><strong>Q:</strong> {question.question}</p>
+347 -2
View File
@@ -3934,7 +3934,12 @@
border: 1px solid var(--border-color, #444); border: 1px solid var(--border-color, #444);
border-radius: 0.375rem; border-radius: 0.375rem;
padding: 1.5rem; padding: 1.5rem;
transition: background 0.2s ease; transition: background 0.2s ease, border-color 0.2s ease;
}
.admin-question-card--selected {
background: rgba(201,168,76,0.07);
border-color: rgba(201,168,76,0.35);
} }
.admin-question-header { .admin-question-header {
@@ -3945,6 +3950,34 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.admin-question-checkbox {
display: flex;
align-items: flex-start;
padding-top: 0.2rem;
cursor: pointer;
flex-shrink: 0;
}
.admin-question-checkbox input[type="checkbox"] {
width: 1rem;
height: 1rem;
accent-color: var(--brand-gold);
cursor: pointer;
}
.admin-bulk-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
background: rgba(201,168,76,0.1);
border: 1px solid rgba(201,168,76,0.25);
border-radius: 0.375rem;
margin-bottom: 1rem;
font-size: 0.88rem;
color: var(--brand-warm-white);
}
.admin-question-meta { .admin-question-meta {
font-size: 0.85rem; font-size: 0.85rem;
color: #999; color: #999;
@@ -4232,7 +4265,9 @@
} }
.admin-nav-item { .admin-nav-item {
display: block; display: flex;
align-items: center;
gap: 0.5rem;
width: 100%; width: 100%;
text-align: left; text-align: left;
background: none; background: none;
@@ -4246,6 +4281,27 @@
line-height: 1.4; line-height: 1.4;
} }
.admin-nav-badge {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.35rem;
border-radius: 999px;
background: var(--brand-gold);
color: #1a160d;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
}
.admin-nav-badge--neutral {
background: rgba(201,168,76,0.25);
color: var(--brand-gold);
}
.admin-nav-item:hover { .admin-nav-item:hover {
color: var(--brand-warm-white); color: var(--brand-warm-white);
background: rgba(201,168,76,0.06); background: rgba(201,168,76,0.06);
@@ -4681,6 +4737,295 @@
background: rgba(201, 168, 76, 0.09); background: rgba(201, 168, 76, 0.09);
} }
.qa-related-btn:hover {
background: rgba(201, 168, 76, 0.09);
}
/* ── Admin Dashboard ── */
.admin-dashboard-welcome {
margin-top: 1.25rem;
padding: 1rem 1.25rem;
border: 1px solid rgba(201,168,76,0.18);
border-radius: 0.75rem;
background: linear-gradient(135deg, rgba(201,168,76,0.12), rgba(50,50,50,0.5));
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.admin-dashboard-welcome-title {
margin: 0;
font-family: var(--brand-font-heading);
font-size: 1.5rem;
color: var(--brand-warm-white);
}
.admin-dashboard-welcome-copy {
margin: 0.35rem 0 0;
color: var(--brand-muted);
}
.admin-dashboard-clock {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
min-width: 170px;
}
.admin-dashboard-clock-time {
font-family: var(--brand-font-heading);
font-size: 1.75rem;
line-height: 1;
color: var(--brand-gold);
}
.admin-dashboard-clock-date {
font-size: 0.82rem;
color: rgba(240,234,216,0.62);
}
.admin-dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.admin-dashboard-card {
background: rgba(50,50,50,0.55);
border: 1px solid rgba(201,168,76,0.15);
border-radius: 0.5rem;
padding: 1.25rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.admin-dashboard-card--alert {
border-color: rgba(201,168,76,0.4);
background: rgba(201,168,76,0.06);
}
.admin-dashboard-card-value {
font-family: var(--brand-font-heading);
font-size: 2.4rem;
font-weight: 700;
color: var(--brand-gold);
line-height: 1;
}
.admin-dashboard-card-label {
font-size: 0.82rem;
color: rgba(240,234,216,0.55);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.admin-dashboard-card-action {
margin-top: 0.5rem;
background: none;
border: none;
color: var(--brand-gold);
font-size: 0.82rem;
cursor: pointer;
padding: 0;
text-align: left;
text-decoration: underline;
text-underline-offset: 2px;
}
.admin-dashboard-card-sub {
font-size: 0.76rem;
color: rgba(240,234,216,0.4);
line-height: 1.4;
}
.admin-dashboard-activity {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-top: 2rem;
}
@media (max-width: 700px) {
.admin-dashboard-welcome {
flex-direction: column;
align-items: flex-start;
}
.admin-dashboard-clock {
align-items: flex-start;
min-width: 0;
}
.admin-dashboard-activity {
grid-template-columns: 1fr;
}
}
.admin-dashboard-activity-heading {
font-family: var(--brand-font-heading);
font-size: 1rem;
color: var(--brand-warm-white);
margin: 0 0 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(201,168,76,0.12);
}
.admin-dashboard-feed {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.admin-dashboard-feed-item {
background: rgba(50,50,50,0.4);
border: 1px solid rgba(201,168,76,0.1);
border-radius: 0.375rem;
padding: 0.6rem 0.8rem;
}
.admin-dashboard-feed-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.82rem;
color: var(--brand-warm-white);
margin-bottom: 0.2rem;
}
.admin-dashboard-feed-date {
margin-left: auto;
font-size: 0.75rem;
color: rgba(240,234,216,0.4);
white-space: nowrap;
}
.admin-dashboard-feed-email {
font-size: 0.78rem;
color: rgba(240,234,216,0.5);
margin-bottom: 0.2rem;
}
.admin-dashboard-feed-preview {
font-size: 0.8rem;
color: rgba(240,234,216,0.65);
line-height: 1.4;
}
.admin-contacts-row--archived td {
opacity: 0.45;
}
/* ── Scripture Reference Tooltip ── */
.scripture-ref-wrap {
position: relative;
display: inline;
}
.scripture-ref {
background: none;
border: none;
border-bottom: 1px dotted var(--brand-gold, #c9a84c);
color: var(--brand-gold, #c9a84c);
cursor: pointer;
font-size: inherit;
font-family: inherit;
padding: 0;
line-height: inherit;
}
.scripture-ref:hover {
color: #e0c060;
border-bottom-style: solid;
}
.scripture-popup {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
z-index: 200;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 260px;
max-width: 360px;
background: #1e1a11;
border: 1px solid rgba(201,168,76,0.35);
border-radius: 0.5rem;
padding: 0.85rem 1rem;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
font-size: 0.9rem;
line-height: 1.6;
color: rgba(240,234,216,0.92);
}
.scripture-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.85rem;
}
.scripture-popup-close {
background: none;
border: none;
color: rgba(240,234,216,0.5);
cursor: pointer;
font-size: 0.8rem;
padding: 0;
line-height: 1;
flex-shrink: 0;
}
.scripture-popup-close:hover {
color: var(--brand-warm-white);
}
.scripture-popup-body {
display: block;
color: rgba(240,234,216,0.88);
}
.scripture-popup-body sup {
color: rgba(201,168,76,0.7);
font-size: 0.7em;
}
.scripture-popup-error {
color: #f87171;
}
.scripture-popup-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-top: 1px solid rgba(201,168,76,0.15);
padding-top: 0.4rem;
margin-top: 0.15rem;
}
.scripture-popup-link {
font-size: 0.78rem;
color: var(--brand-gold);
text-decoration: none;
}
.scripture-popup-link:hover {
text-decoration: underline;
}
.scripture-popup-attribution {
font-size: 0.72rem;
color: rgba(240,234,216,0.35);
font-style: italic;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.start-grid { .start-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+186 -13
View File
@@ -109,27 +109,200 @@ function renderHighlightedText(text: string, query: string) {
} }
function renderTextWithLinks(text: string, highlightQuery = '') { function renderTextWithLinks(text: string, highlightQuery = '') {
const parts = text.split(/(https?:\/\/[^\s]+)/g)
const BOOK_ID_MAP: Record<string, string> = {
gen: 'GEN', genesis: 'GEN', exo: 'EXO', exodus: 'EXO', lev: 'LEV', leviticus: 'LEV',
num: 'NUM', numbers: 'NUM', deut: 'DEU', deuteronomy: 'DEU', josh: 'JOS', joshua: 'JOS',
judg: 'JDG', judges: 'JDG', ruth: 'RUT', '1 sam': 'SA1', '2 sam': 'SA2',
'1 kgs': 'KI1', '1 kings': 'KI1', '2 kgs': 'KI2', '2 kings': 'KI2',
'1 chr': 'CH1', '1 chron': 'CH1', '1 chronicles': 'CH1', '2 chr': 'CH2', '2 chron': 'CH2', '2 chronicles': 'CH2',
ezra: 'EZR', neh: 'NEH', nehemiah: 'NEH', esth: 'EST', esther: 'EST',
job: 'JOB', ps: 'PSA', psalms: 'PSA', psalm: 'PSA', prov: 'PRO', proverbs: 'PRO',
eccl: 'ECC', ecclesiastes: 'ECC', song: 'SNG', 'song of sol': 'SNG', 'song of solomon': 'SNG',
isa: 'ISA', isaiah: 'ISA', jer: 'JER', jeremiah: 'JER', lam: 'LAM', lamentations: 'LAM',
ezek: 'EZK', ezekiel: 'EZK', dan: 'DAN', daniel: 'DAN', hos: 'HOS', hosea: 'HOS',
joel: 'JOL', amos: 'AMO', obad: 'OBA', obadiah: 'OBA', jonah: 'JNA', mic: 'MIC', micah: 'MIC',
nah: 'NAH', nahum: 'NAH', hab: 'HAB', habakkuk: 'HAB', zeph: 'ZEP', zephaniah: 'ZEP',
hag: 'HAG', haggai: 'HAG', zech: 'ZEC', zechariah: 'ZEC', mal: 'MAL', malachi: 'MAL',
matt: 'MAT', matthew: 'MAT', mark: 'MRK', luke: 'LUK', john: 'JHN', acts: 'ACT',
rom: 'ROM', romans: 'ROM', '1 cor': 'CO1', '1 corinthians': 'CO1', '2 cor': 'CO2', '2 corinthians': 'CO2',
gal: 'GAL', galatians: 'GAL', eph: 'EPH', ephesians: 'EPH', phil: 'PHP', philippians: 'PHP',
col: 'COL', colossians: 'COL', '1 thess': 'TH1', '1 thessalonians': 'TH1', '2 thess': 'TH2', '2 thessalonians': 'TH2',
'1 tim': 'TI1', '1 timothy': 'TI1', '2 tim': 'TI2', '2 timothy': 'TI2',
titus: 'TIT', philem: 'PHM', philemon: 'PHM', heb: 'HEB', hebrews: 'HEB',
jas: 'JAM', james: 'JAM', '1 pet': 'PE1', '1 peter': 'PE1', '2 pet': 'PE2', '2 peter': 'PE2',
'1 john': 'JO1', '2 john': 'JO2', '3 john': 'JO3', jude: 'JDE', rev: 'REV', revelation: 'REV',
}
function parseScriptureRef(refText: string): { bookId: string; chapter: number; verseStart: number; verseEnd: number } | null {
const match = refText.match(/^(.*?)\s+(\d+):(\d+)(?:-(\d+))?$/)
if (!match) return null
const [, bookRaw, chapterStr, verseStartStr, verseEndStr] = match
const bookKey = bookRaw.toLowerCase().replace(/\.\s*/g, ' ').trim()
const bookId = BOOK_ID_MAP[bookKey]
if (!bookId) return null
return {
bookId,
chapter: parseInt(chapterStr, 10),
verseStart: parseInt(verseStartStr, 10),
verseEnd: verseEndStr ? parseInt(verseEndStr, 10) : parseInt(verseStartStr, 10),
}
}
// Split the text by both scripture refs and URLs
const combined = /(https?:\/\/[^\s]+)|\b((?:(?:1|2|3)\s)?(?:Gen(?:esis)?|Exo(?:dus)?|Lev(?:iticus)?|Num(?:bers)?|Deut(?:eronomy)?|Josh(?:ua)?|Judg(?:es)?|Ruth|1\s?Sam|2\s?Sam|1\s?Kgs?|2\s?Kgs?|1\s?Chr(?:on)?|2\s?Chr(?:on)?|Ezra|Neh(?:emiah)?|Esth(?:er)?|Job|Ps(?:alms?)?|Prov(?:erbs)?|Eccl(?:esiastes)?|Song(?:\s?of\s?Sol(?:omon)?)?|Isa(?:iah)?|Jer(?:emiah)?|Lam(?:entations)?|Ezek(?:iel)?|Dan(?:iel)?|Hos(?:ea)?|Joel|Amos|Obad(?:iah)?|Jonah|Mic(?:ah)?|Nah(?:um)?|Hab(?:akkuk)?|Zeph(?:aniah)?|Hag(?:gai)?|Zech(?:ariah)?|Mal(?:achi)?|Matt(?:hew)?|Mark|Luke|John|Acts|Rom(?:ans)?|1\s?Cor(?:inthians)?|2\s?Cor(?:inthians)?|Gal(?:atians)?|Eph(?:esians)?|Phil(?:ippians)?|Col(?:ossians)?|1\s?Thess|2\s?Thess|1\s?Tim(?:othy)?|2\s?Tim(?:othy)?|Titus|Philem(?:on)?|Heb(?:rews)?|Jas(?:mes)?|1\s?Pet(?:er)?|2\s?Pet(?:er)?|1\s?John|2\s?John|3\s?John|Jude|Rev(?:elation)?)\.?\s+\d+:\d+(?:-\d+)?)\b/gi
const parts: string[] = []
let lastIndex = 0
let m: RegExpExecArray | null
// reset lastIndex for combined
combined.lastIndex = 0
while ((m = combined.exec(text)) !== null) {
if (m.index > lastIndex) parts.push(text.slice(lastIndex, m.index))
parts.push(m[0])
lastIndex = m.index + m[0].length
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex))
return parts.map((part, index) => { return parts.map((part, index) => {
if (!/^https?:\/\//i.test(part)) { if (/^https?:\/\//i.test(part)) {
return <span key={`text-${index}`}>{renderHighlightedText(part, highlightQuery)}</span> const safeHref = part.replace(/[),.;!?]+$/g, '')
const trailing = part.slice(safeHref.length)
return (
<span key={`link-${index}`}>
<a href={safeHref} target="_blank" rel="noopener noreferrer">{safeHref}</a>
{trailing}
</span>
)
} }
const safeHref = part.replace(/[),.;!?]+$/g, '') const parsed = parseScriptureRef(part.replace(/\.$/, ''))
const trailing = part.slice(safeHref.length) if (parsed) {
return <ScriptureTooltip key={`scripture-${index}`} refText={part} bookId={parsed.bookId} chapter={parsed.chapter} verseStart={parsed.verseStart} verseEnd={parsed.verseEnd} />
}
return ( return <span key={`text-${index}`}>{renderHighlightedText(part, highlightQuery)}</span>
<span key={`link-${index}`}>
<a href={safeHref} target="_blank" rel="noopener noreferrer">
{safeHref}
</a>
{trailing}
</span>
)
}) })
} }
interface VerseData {
verse: number
value: string
}
// Maps helloao API book IDs → bible.com book codes
const BIBLE_COM_BOOK_IDS: Record<string, string> = {
GEN: 'GEN', EXO: 'EXO', LEV: 'LEV', NUM: 'NUM', DEU: 'DEU', JOS: 'JOS', JDG: 'JDG', RUT: 'RUT',
SA1: '1SA', SA2: '2SA', KI1: '1KI', KI2: '2KI', CH1: '1CH', CH2: '2CH',
EZR: 'EZR', NEH: 'NEH', EST: 'EST', JOB: 'JOB', PSA: 'PSA', PRO: 'PRO', ECC: 'ECC', SNG: 'SNG',
ISA: 'ISA', JER: 'JER', LAM: 'LAM', EZK: 'EZK', DAN: 'DAN', HOS: 'HOS', JOL: 'JOL', AMO: 'AMO',
OBA: 'OBA', JNA: 'JON', MIC: 'MIC', NAH: 'NAH', HAB: 'HAB', ZEP: 'ZEP', HAG: 'HAG', ZEC: 'ZEC', MAL: 'MAL',
MAT: 'MAT', MRK: 'MRK', LUK: 'LUK', JHN: 'JHN', ACT: 'ACT', ROM: 'ROM',
CO1: '1CO', CO2: '2CO', GAL: 'GAL', EPH: 'EPH', PHP: 'PHP', COL: 'COL',
TH1: '1TH', TH2: '2TH', TI1: '1TI', TI2: '2TI', TIT: 'TIT', PHM: 'PHM', HEB: 'HEB',
JAM: 'JAS', PE1: '1PE', PE2: '2PE', JO1: '1JN', JO2: '2JN', JO3: '3JN', JDE: 'JUD', REV: 'REV',
}
function ScriptureTooltip({ refText, bookId, chapter, verseStart, verseEnd }: {
refText: string
bookId: string
chapter: number
verseStart: number
verseEnd: number
}) {
const [open, setOpen] = useState(false)
const [verses, setVerses] = useState<VerseData[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const wrapRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
if (!open) return
if (verses.length > 0) return
setLoading(true)
setError(false)
fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`)
.then(r => {
if (!r.ok) throw new Error('Not found')
return r.json() as Promise<{ chapter: { content: Array<{ type: string; number?: number; content?: Array<{ text?: string; poem?: number } | string> }> } }>
})
.then(data => {
const content = data?.chapter?.content ?? []
const found: VerseData[] = []
for (const item of content) {
if (item.type === 'verse' && item.number != null && item.number >= verseStart && item.number <= verseEnd) {
const text = (item.content ?? [])
.map(c => (typeof c === 'string' ? c : (c.text ?? '')))
.join('')
.trim()
if (text) found.push({ verse: item.number, value: text })
}
}
setVerses(found)
setLoading(false)
})
.catch(() => {
setError(true)
setLoading(false)
})
}, [open, bookId, chapter, verseStart, verseEnd, verses.length])
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
return (
<span ref={wrapRef} className="scripture-ref-wrap">
<button
type="button"
className="scripture-ref"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
title={`View ${refText}`}
>
{refText}
</button>
{open && (
<span className="scripture-popup" role="tooltip">
<span className="scripture-popup-header">
<strong>{refText}</strong>
<button type="button" className="scripture-popup-close" onClick={() => setOpen(false)} aria-label="Close"></button>
</span>
{loading && <span className="scripture-popup-body">Loading</span>}
{error && <span className="scripture-popup-body scripture-popup-error">Could not load verse.</span>}
{!loading && !error && verses.length === 0 && <span className="scripture-popup-body">Verse not found.</span>}
{!loading && !error && verses.map(v => (
<span key={v.verse} className="scripture-popup-body">
{verseStart !== verseEnd && <sup>{v.verse} </sup>}{v.value}
</span>
))}
<span className="scripture-popup-footer">
<a
href={`https://www.bible.com/bible/3034/${BIBLE_COM_BOOK_IDS[bookId] ?? bookId}.${chapter}.BSB`}
target="_blank"
rel="noopener noreferrer"
className="scripture-popup-link"
>
Read on Bible.com
</a>
<span className="scripture-popup-attribution">Berean Standard Bible</span>
</span>
</span>
)}
</span>
)
}
// ─── old renderTextWithLinks removed, replaced above ───
function readEngagementFromStorage(): EngagementMap { function readEngagementFromStorage(): EngagementMap {
try { try {
const raw = window.localStorage.getItem(ENGAGEMENT_STORAGE_KEY) const raw = window.localStorage.getItem(ENGAGEMENT_STORAGE_KEY)