Enhance admin dashboard and scripture linking
This commit is contained in:
@@ -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 UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
||||
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 INDEX_FILE = path.join(DIST_DIR, 'index.html')
|
||||
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) {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(UPLOADS_META_FILE, JSON.stringify(metadata, null, 2), 'utf8')
|
||||
@@ -359,6 +387,8 @@ let contactSubmissions = []
|
||||
let contactSubmissionsWritePromise = Promise.resolve()
|
||||
let questions = []
|
||||
let questionsWritePromise = Promise.resolve()
|
||||
let downloadCounts = {}
|
||||
let downloadCountsWritePromise = Promise.resolve()
|
||||
let lastVisitorStatsWrite = { ok: true, at: null, error: null }
|
||||
let lastHitStatsWrite = { ok: true, at: null, error: 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) => {
|
||||
let adminContent = null
|
||||
let draftContent = null
|
||||
@@ -2105,6 +2169,8 @@ app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res)
|
||||
await syncContactToResend(trimmedName, trimmedEmail)
|
||||
}
|
||||
|
||||
incrementDownloadCount('titus-study')
|
||||
|
||||
if (configuredDownloadUrl) {
|
||||
res.json({ ok: true, downloadUrl: configuredDownloadUrl })
|
||||
return
|
||||
@@ -2212,6 +2278,7 @@ app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => {
|
||||
await syncContactToResend(trimmedName, trimmedEmail)
|
||||
}
|
||||
|
||||
incrementDownloadCount(`resource:${resourceId}`)
|
||||
res.json({ ok: true, downloadUrl: resource.url.trim() })
|
||||
} catch (err) {
|
||||
console.error('[resource-download] request error:', err)
|
||||
@@ -2991,6 +3058,7 @@ Promise.all([
|
||||
loadReplyHistoryFromDisk(),
|
||||
loadQuestionsFromDisk(),
|
||||
loadDraftQuestionsFromDisk(),
|
||||
loadDownloadCountsFromDisk(),
|
||||
refreshContentCaches(),
|
||||
])
|
||||
.catch(err => {
|
||||
|
||||
+375
-6
@@ -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'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>
|
||||
|
||||
+347
-2
@@ -3934,7 +3934,12 @@
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 0.375rem;
|
||||
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 {
|
||||
@@ -3945,6 +3950,34 @@
|
||||
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 {
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
@@ -4232,7 +4265,9 @@
|
||||
}
|
||||
|
||||
.admin-nav-item {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
@@ -4246,6 +4281,27 @@
|
||||
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 {
|
||||
color: var(--brand-warm-white);
|
||||
background: rgba(201,168,76,0.06);
|
||||
@@ -4681,6 +4737,295 @@
|
||||
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) {
|
||||
.start-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
+186
-13
@@ -109,27 +109,200 @@ function renderHighlightedText(text: string, query: string) {
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (!/^https?:\/\//i.test(part)) {
|
||||
return <span key={`text-${index}`}>{renderHighlightedText(part, highlightQuery)}</span>
|
||||
if (/^https?:\/\//i.test(part)) {
|
||||
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 trailing = part.slice(safeHref.length)
|
||||
const parsed = parseScriptureRef(part.replace(/\.$/, ''))
|
||||
if (parsed) {
|
||||
return <ScriptureTooltip key={`scripture-${index}`} refText={part} bookId={parsed.bookId} chapter={parsed.chapter} verseStart={parsed.verseStart} verseEnd={parsed.verseEnd} />
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={`link-${index}`}>
|
||||
<a href={safeHref} target="_blank" rel="noopener noreferrer">
|
||||
{safeHref}
|
||||
</a>
|
||||
{trailing}
|
||||
</span>
|
||||
)
|
||||
return <span key={`text-${index}`}>{renderHighlightedText(part, highlightQuery)}</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 {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ENGAGEMENT_STORAGE_KEY)
|
||||
|
||||
Reference in New Issue
Block a user