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 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
@@ -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'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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user