From becc1b5b27e6e44e2275a28fb1ae86934faf9e31 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Tue, 12 May 2026 09:12:55 -0400 Subject: [PATCH] Enhance admin dashboard and scripture linking --- server.js | 68 +++++++ src/AdminPage.tsx | 381 ++++++++++++++++++++++++++++++++++- src/App.css | 349 +++++++++++++++++++++++++++++++- src/components/QASection.tsx | 199 ++++++++++++++++-- 4 files changed, 976 insertions(+), 21 deletions(-) diff --git a/server.js b/server.js index 22a8310..96a08e2 100644 --- a/server.js +++ b/server.js @@ -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 => { diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 36ef2d8..1ffd2d4 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -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 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('homepage') + const [adminView, setAdminView] = useState('dashboard') const [stats, setStats] = useState(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>(new Set()) const [mobileNavOpen, setMobileNavOpen] = useState(false) + const [subscribers, setSubscribers] = useState([]) + const [subscriberSearch, setSubscriberSearch] = useState('') + const [contactSearch, setContactSearch] = useState('') + const [downloadStats, setDownloadStats] = useState>({}) + 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(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 }).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) { +
+ Overview + +
+
Site @@ -1674,8 +1733,18 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
Manage - - + + + +
@@ -1695,6 +1764,284 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { {/* ── Content 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 ( +
+
+

Dashboard

+

Quick overview of your ministry site.

+
+ +
+
+

{welcomeMessage}

+

Here's what needs your attention and how the site is performing today.

+
+
+ {dashboardTimeLabel} + {dashboardDateLabel} +
+
+ +
+
0 ? ' admin-dashboard-card--alert' : ''}`}> +
{unansweredCount}
+
Unanswered Questions
+
{answeredCount} answered · {approvedCount} approved of {questions.length}
+ {unansweredCount > 0 && } +
+
0 ? ' admin-dashboard-card--alert' : ''}`}> +
{unreadEmailCount}
+
Unread Emails
+
{contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total
+ {unreadEmailCount > 0 && } +
+
+
{thisWeekReal.toLocaleString()}
+
Real Visits (7 Days)
+
{thisWeekHits.toLocaleString()} total · {stats?.visitors?.uniqueVisitors?.toLocaleString() ?? '—'} unique all time
+ +
+
+
{subscribers.length}
+
Email Subscribers
+
{contactSubmissions.length} total contact submissions
+ +
+
+
{downloadStats['titus-study'] ?? 0}
+
Titus Study Downloads
+
+ {Object.values(downloadStats).reduce((a, b) => a + b, 0)} total resource downloads +
+
+ {publishState?.publishedAt && ( +
+
{formatDate(publishState.publishedAt)}
+
Last Published
+ {publishState.draftUpdatedAt &&
Draft updated {formatDate(publishState.draftUpdatedAt)}
} +
+ )} +
+ +
+
+

Recent Contacts

+ {recentContacts.length === 0 + ?

No contacts yet.

+ : ( +
+ {recentContacts.map(c => ( +
+
+ {c.name} + {formatDate(c.submittedAt)} +
+
{c.email}
+ {c.message &&
{c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}
} +
+ ))} + +
+ ) + } +
+
+

Recent Questions

+ {recentQuestions.length === 0 + ?

No questions yet.

+ : ( +
+ {recentQuestions.map(q => ( +
+
+ {q.firstName} + {q.isApproved ? 'Approved' : 'Pending'} + {formatDate(q.submittedAt ?? '')} +
+
{q.question.slice(0, 100)}{q.question.length > 100 ? '…' : ''}
+
+ ))} + +
+ ) + } +
+
+
+ ) + })()} + + {/* CONTACTS */} + {adminView === 'contacts' && (() => { + const searchTerm = contactSearch.trim().toLowerCase() + const grouped = new Map() + + 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 ( +
+
+

Contacts

+

{rolledUp.length} contacts from {contactSubmissions.length} total submissions — repeat senders are grouped together.

+
+
+ setContactSearch(e.target.value)} + style={{ minWidth: '260px', maxWidth: '440px', width: '100%' }} + /> + {rolledUp.length} result{rolledUp.length !== 1 ? 's' : ''} +
+ {rolledUp.length === 0 + ?

No contacts{contactSearch ? ' match your search' : ' yet'}.

+ : ( +
+ + + + + + + + + + + + + {rolledUp.map(c => ( + + + + + + + + + ))} + +
NameEmailTypeSubscriberDateMessage
+
{c.name}
+ {c.submissionCount > 1 &&
{c.submissionCount} submissions
} +
{c.email}{c.messageType ?? 'contact'}{c.subscribe ? '✓' : ''}{formatDate(c.submittedAt)}{c.message ?? '—'}
+
+ ) + } +
+ ) + })()} + + {/* SUBSCRIBERS */} + {adminView === 'subscribers' && (() => { + const filteredSubs = subscribers.filter(s => + !subscriberSearch.trim() || + s.name.toLowerCase().includes(subscriberSearch.toLowerCase()) || + s.email.toLowerCase().includes(subscriberSearch.toLowerCase()) + ) + return ( +
+
+

Subscribers

+

{subscribers.length} people have opted in to email updates.

+
+
+ setSubscriberSearch(e.target.value)} + style={{ minWidth: '240px', maxWidth: '400px', width: '100%' }} + /> +
+ +
+
+ {filteredSubs.length === 0 ? ( +

No subscribers{subscriberSearch ? ' match your search' : ' yet'}.

+ ) : ( +
+ + + + + + + + + + + {filteredSubs.map((sub, i) => ( + + + + + + + ))} + +
NameEmailSourceSubscribed
{sub.name}{sub.email}{sub.source === 'download' ? 'Download' : 'Contact Form'}{formatDate(sub.subscribedAt)}
+
+ )} +
+ ) + })()} + {/* HOMEPAGE */} {adminView === 'homepage' && (
@@ -2441,6 +2788,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {

Showing {filteredAdminQuestions.length} of {questions.length} questions.

+ {selectedQuestionIds.size > 0 && ( +
+ {selectedQuestionIds.size} selected + + +
+ )} +

Add Question Manually

@@ -2520,8 +2875,22 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { ) : (
{visibleAdminQuestions.map(question => ( -
+
+

{question.firstName} • {formatDate(question.submittedAt)}

Q: {question.question}

diff --git a/src/App.css b/src/App.css index c090969..70759d8 100644 --- a/src/App.css +++ b/src/App.css @@ -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; diff --git a/src/components/QASection.tsx b/src/components/QASection.tsx index e5aaf3b..f094703 100644 --- a/src/components/QASection.tsx +++ b/src/components/QASection.tsx @@ -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 = { + 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 {renderHighlightedText(part, highlightQuery)} + if (/^https?:\/\//i.test(part)) { + const safeHref = part.replace(/[),.;!?]+$/g, '') + const trailing = part.slice(safeHref.length) + return ( + + {safeHref} + {trailing} + + ) } - const safeHref = part.replace(/[),.;!?]+$/g, '') - const trailing = part.slice(safeHref.length) + const parsed = parseScriptureRef(part.replace(/\.$/, '')) + if (parsed) { + return + } - return ( - - - {safeHref} - - {trailing} - - ) + return {renderHighlightedText(part, highlightQuery)} }) } +interface VerseData { + verse: number + value: string +} + +// Maps helloao API book IDs → bible.com book codes +const BIBLE_COM_BOOK_IDS: Record = { + 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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(false) + const wrapRef = useRef(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 ( + + + {open && ( + + + {refText} + + + {loading && Loading…} + {error && Could not load verse.} + {!loading && !error && verses.length === 0 && Verse not found.} + {!loading && !error && verses.map(v => ( + + {verseStart !== verseEnd && {v.verse} }{v.value} + + ))} + + + Read on Bible.com ↗ + + Berean Standard Bible + + + )} + + ) +} + +// ─── old renderTextWithLinks removed, replaced above ─── + function readEngagementFromStorage(): EngagementMap { try { const raw = window.localStorage.getItem(ENGAGEMENT_STORAGE_KEY)