v1.2.0: Broadcast emails, webhook admin UI, dynamic sitemap, scripture index
Publish Container / docker (push) Successful in 14m11s
Publish Container / docker (push) Successful in 14m11s
- Admin > Audience > Broadcast Email: compose and send one-off HTML emails
to all subscribers via Resend batch API; {{name}} personalization; confirm step
- Admin > Configure > Webhooks: CRUD UI for managing POST event webhooks;
backend GET/POST/DELETE /api/admin-webhooks routes wired to existing state.webhooks
- Sitemap: dynamically appends released study lesson URLs (/study/:slug/:id)
and episode highlight URLs (/episodes/:id) to /sitemap.xml
- Scripture Index: public /scripture page listing every released lesson grouped
by study with passage reference links; served by /api/public/scripture-index
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
0bf2d92b1f
commit
63d4f9e316
7 files changed
+348
-3
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "1.1.52",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
queueReplyTemplatesWrite,
|
||||
queueReplyHistoryWrite,
|
||||
queuePodcastChecklistWrite,
|
||||
queueWebhooksWrite,
|
||||
createBackupSnapshot,
|
||||
listBackupPreviews,
|
||||
readBackupPreview,
|
||||
@@ -331,4 +332,39 @@ export function register(app) {
|
||||
res.status(400).json({ message: err instanceof Error ? err.message : 'Restore failed.' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Webhooks ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/admin-webhooks', requireAdminAuth, (_req, res) => {
|
||||
res.json({ webhooks: state.webhooks })
|
||||
})
|
||||
|
||||
app.post('/api/admin-webhooks', requireAdminAuth, (req, res) => {
|
||||
const { url, events } = req.body ?? {}
|
||||
if (!url || typeof url !== 'string' || !/^https:\/\//i.test(url.trim())) {
|
||||
res.status(400).json({ message: 'A valid HTTPS URL is required.' })
|
||||
return
|
||||
}
|
||||
if (state.webhooks.length >= 50) {
|
||||
res.status(400).json({ message: 'Maximum of 50 webhooks reached.' })
|
||||
return
|
||||
}
|
||||
const webhook = {
|
||||
id: crypto.randomUUID(),
|
||||
url: url.trim().slice(0, 500),
|
||||
events: Array.isArray(events) ? events.map(String).slice(0, 20) : (typeof events === 'string' && events.trim() ? events.split(',').map(e => e.trim()).filter(Boolean) : ['*']),
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
state.webhooks.push(webhook)
|
||||
queueWebhooksWrite()
|
||||
res.status(201).json({ webhook })
|
||||
})
|
||||
|
||||
app.delete('/api/admin-webhooks/:id', requireAdminAuth, (req, res) => {
|
||||
const idx = state.webhooks.findIndex(w => w.id === req.params.id)
|
||||
if (idx === -1) { res.status(404).json({ message: 'Not found.' }); return }
|
||||
state.webhooks.splice(idx, 1)
|
||||
queueWebhooksWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
}
|
||||
@@ -386,4 +386,50 @@ export function register(app) {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="subscribers-${new Date().toISOString().slice(0, 10)}.csv"`)
|
||||
res.send(csv)
|
||||
})
|
||||
|
||||
app.post('/api/admin-broadcast', requireAdminAuth, async (req, res) => {
|
||||
const { subject, html } = req.body ?? {}
|
||||
if (!subject || typeof subject !== 'string' || !subject.trim()) {
|
||||
res.status(400).json({ message: 'Subject is required.' }); return
|
||||
}
|
||||
if (!html || typeof html !== 'string' || !html.trim()) {
|
||||
res.status(400).json({ message: 'Email body is required.' }); return
|
||||
}
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||
}
|
||||
|
||||
const seen = new Set()
|
||||
const recipients = state.contactSubmissions
|
||||
.filter(e => e.subscribe === true && e.email && !seen.has(e.email) && seen.add(e.email))
|
||||
.map(e => ({ name: e.name ?? '', email: e.email }))
|
||||
|
||||
if (recipients.length === 0) {
|
||||
res.status(400).json({ message: 'No subscribers to send to.' }); return
|
||||
}
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const from = getResendFromAddress()
|
||||
const replyTo = getResendReplyToAddress()
|
||||
const BATCH = 50
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
|
||||
for (let i = 0; i < recipients.length; i += BATCH) {
|
||||
const batch = recipients.slice(i, i + BATCH)
|
||||
const emails = batch.map(r => ({
|
||||
from,
|
||||
to: r.email,
|
||||
reply_to: replyTo,
|
||||
subject: subject.trim(),
|
||||
html: html.replace(/\{\{name\}\}/g, escapeHtml(r.name || 'Friend')),
|
||||
}))
|
||||
try {
|
||||
const { error } = await resend.batch.send(emails)
|
||||
if (error) { failed += batch.length } else { sent += batch.length }
|
||||
} catch { failed += batch.length }
|
||||
}
|
||||
|
||||
res.json({ ok: true, sent, failed, total: recipients.length })
|
||||
})
|
||||
}
|
||||
@@ -63,6 +63,23 @@ export function register(app) {
|
||||
.filter(Boolean)
|
||||
.map(item => `${root}${item}`)
|
||||
|
||||
// Dynamic study lesson URLs
|
||||
const studies = Array.isArray(state.cachedSiteContent?.studies) ? state.cachedSiteContent.studies : []
|
||||
const now = new Date()
|
||||
for (const study of studies) {
|
||||
if (!study.slug || !Array.isArray(study.sections)) continue
|
||||
for (const section of study.sections) {
|
||||
if (section.id && section.releasedAt && new Date(section.releasedAt) <= now) {
|
||||
urls.push(`${root}/study/${encodeURIComponent(study.slug)}/${encodeURIComponent(section.id)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dynamic episode highlight URLs
|
||||
const highlights = Array.isArray(state.cachedSiteContent?.podcastFeaturedLinks) ? state.cachedSiteContent.podcastFeaturedLinks : []
|
||||
for (const ep of highlights) {
|
||||
if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`)
|
||||
}
|
||||
|
||||
const xml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
@@ -142,6 +159,25 @@ export function register(app) {
|
||||
</html>`)
|
||||
})
|
||||
|
||||
// Scripture index — all released study lessons with their passage references
|
||||
app.get('/api/public/scripture-index', (_req, res) => {
|
||||
const content = state.cachedSiteContent
|
||||
const allStudies = Array.isArray(content?.studies) ? content.studies : []
|
||||
const cutoff = new Date()
|
||||
const studies = allStudies
|
||||
.filter(s => s.slug && Array.isArray(s.sections))
|
||||
.map(s => ({
|
||||
slug: s.slug,
|
||||
title: s.title || s.slug,
|
||||
entries: s.sections
|
||||
.filter(sec => sec.id && sec.reference && sec.releasedAt && new Date(sec.releasedAt) <= cutoff)
|
||||
.map(sec => ({ sectionId: sec.id, title: sec.title || '', reference: sec.reference }))
|
||||
.sort((a, b) => a.reference.localeCompare(b.reference, undefined, { numeric: true })),
|
||||
}))
|
||||
.filter(s => s.entries.length > 0)
|
||||
res.json({ studies })
|
||||
})
|
||||
|
||||
// SPA static files and fallback
|
||||
app.use(express.static(DIST_DIR))
|
||||
|
||||
|
||||
+162
-2
@@ -612,8 +612,8 @@ type AdminView =
|
||||
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
|
||||
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||
| 'subscribers' | 'study-users' | 'email-templates'
|
||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
|
||||
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
|
||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'webhooks'
|
||||
|
||||
interface AdminSectionLink {
|
||||
id: string
|
||||
@@ -675,6 +675,7 @@ const ADMIN_NAV: NavGroup[] = [
|
||||
{ kind: 'leaf', view: 'questions', label: 'Questions' },
|
||||
{ kind: 'leaf', view: 'study-comments', label: 'Study Comments' },
|
||||
{ kind: 'leaf', view: 'subscribers', label: 'Subscribers' },
|
||||
{ kind: 'leaf', view: 'broadcast', label: 'Broadcast Email' },
|
||||
{ kind: 'leaf', view: 'study-users', label: 'Study Users' },
|
||||
],
|
||||
},
|
||||
@@ -692,6 +693,7 @@ const ADMIN_NAV: NavGroup[] = [
|
||||
{ kind: 'leaf', view: 'global', label: 'Footer & Global' },
|
||||
{ kind: 'leaf', view: 'seo', label: 'SEO & Redirects' },
|
||||
{ kind: 'leaf', view: 'email-templates', label: 'Email Templates' },
|
||||
{ kind: 'leaf', view: 'webhooks', label: 'Webhooks' },
|
||||
{ kind: 'leaf', view: 'legal', label: 'Legal Pages' },
|
||||
{ kind: 'leaf', view: 'security', label: 'Security' },
|
||||
{ kind: 'leaf', view: 'brand', label: 'Brand Kit' },
|
||||
@@ -1114,6 +1116,22 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [selectedHighlightId, setSelectedHighlightId] = useState<string | null>(null)
|
||||
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null)
|
||||
|
||||
// Webhooks state
|
||||
interface WebhookEntry { id: string; url: string; events: string[]; createdAt: string }
|
||||
const [webhooks, setWebhooks] = useState<WebhookEntry[]>([])
|
||||
const [webhooksLoaded, setWebhooksLoaded] = useState(false)
|
||||
const [webhookUrl, setWebhookUrl] = useState('')
|
||||
const [webhookEvents, setWebhookEvents] = useState('*')
|
||||
const [webhookMsg, setWebhookMsg] = useState('')
|
||||
|
||||
// Broadcast state
|
||||
const [broadcastSubject, setBroadcastSubject] = useState('')
|
||||
const [broadcastBody, setBroadcastBody] = useState('')
|
||||
const [broadcastSending, setBroadcastSending] = useState(false)
|
||||
const [broadcastResult, setBroadcastResult] = useState<{ sent: number; failed: number; total: number } | null>(null)
|
||||
const [broadcastMsg, setBroadcastMsg] = useState('')
|
||||
const [broadcastConfirm, setBroadcastConfirm] = useState(false)
|
||||
|
||||
const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
const [manualQuestion, setManualQuestion] = useState({
|
||||
firstName: '',
|
||||
@@ -2320,6 +2338,14 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWebhooksAdmin() {
|
||||
try {
|
||||
const res = await fetch('/api/admin-webhooks')
|
||||
const data = await res.json()
|
||||
if (Array.isArray(data.webhooks)) { setWebhooks(data.webhooks); setWebhooksLoaded(true) }
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function loadQrCodes() {
|
||||
setQrLoading(true)
|
||||
setQrMsg('')
|
||||
@@ -5108,6 +5134,140 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── BROADCAST EMAIL ── */}
|
||||
{adminView === 'broadcast' && (() => {
|
||||
const recipientCount = (() => {
|
||||
const seen = new Set<string>()
|
||||
return subscribers.filter(s => s.email && !seen.has(s.email) && !!seen.add(s.email)).length
|
||||
})()
|
||||
async function sendBroadcast() {
|
||||
if (!broadcastSubject.trim() || !broadcastBody.trim()) {
|
||||
setBroadcastMsg('Subject and body are required.'); return
|
||||
}
|
||||
setBroadcastSending(true); setBroadcastMsg(''); setBroadcastResult(null)
|
||||
try {
|
||||
const r = await fetch('/api/admin-broadcast', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subject: broadcastSubject, html: broadcastBody }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setBroadcastMsg(d.message || 'Send failed.') } else {
|
||||
setBroadcastResult(d); setBroadcastConfirm(false)
|
||||
setBroadcastMsg(`Sent to ${d.sent} subscriber${d.sent !== 1 ? 's' : ''}${d.failed > 0 ? ` (${d.failed} failed)` : ''}.`)
|
||||
}
|
||||
} catch { setBroadcastMsg('Network error. Try again.') }
|
||||
finally { setBroadcastSending(false) }
|
||||
}
|
||||
return (
|
||||
<section className="admin-panel-section">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Broadcast Email</h2>
|
||||
<p>Compose and send a one-off email to all {subscribers.length} subscribers.</p>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="bc-subject">Subject</label>
|
||||
<input id="bc-subject" type="text" value={broadcastSubject} onChange={e => setBroadcastSubject(e.target.value)} placeholder="This week's episode + new study lesson" />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="bc-body">Body <span style={{ fontWeight: 400, opacity: 0.6, fontSize: '0.8em' }}>(HTML — use {'{{name}}'} for personalization)</span></label>
|
||||
<textarea id="bc-body" rows={14} value={broadcastBody} onChange={e => setBroadcastBody(e.target.value)} placeholder={'<p>Hi {{name}},</p>\n<p>This week we covered...</p>'} style={{ fontFamily: 'monospace', fontSize: '0.85rem' }} />
|
||||
</div>
|
||||
{broadcastBody.trim() && (
|
||||
<details style={{ marginBottom: '1rem' }}>
|
||||
<summary style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}>Preview rendered HTML</summary>
|
||||
<div style={{ marginTop: '0.75rem', padding: '1rem', background: 'var(--admin-card-bg)', borderRadius: '6px', border: '1px solid var(--admin-border)', fontSize: '0.9rem' }}
|
||||
dangerouslySetInnerHTML={{ __html: broadcastBody.replace(/\{\{name\}\}/g, 'Nate (preview)') }} />
|
||||
</details>
|
||||
)}
|
||||
{broadcastMsg && <p className={broadcastResult ? 'admin-save-ok' : 'admin-save-error'} style={{ marginBottom: '0.75rem' }}>{broadcastMsg}</p>}
|
||||
{!broadcastConfirm ? (
|
||||
<button type="button" className="btn-admin-apply" disabled={!broadcastSubject.trim() || !broadcastBody.trim()} onClick={() => setBroadcastConfirm(true)}>
|
||||
Send to {recipientCount} subscriber{recipientCount !== 1 ? 's' : ''}…
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<strong style={{ color: 'var(--brand-gold)' }}>Send "{broadcastSubject}" to {recipientCount} people?</strong>
|
||||
<button type="button" className="btn-admin-apply" disabled={broadcastSending} onClick={sendBroadcast}>
|
||||
{broadcastSending ? 'Sending…' : 'Confirm Send'}
|
||||
</button>
|
||||
<button type="button" className="btn-admin-reset" onClick={() => setBroadcastConfirm(false)}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* ── WEBHOOKS ── */}
|
||||
{adminView === 'webhooks' && (() => {
|
||||
if (!webhooksLoaded) loadWebhooksAdmin()
|
||||
const ALL_EVENTS = ['*', 'contact.submitted', 'subscriber.added', 'question.submitted', 'study.lesson.released', 'study.user.enrolled']
|
||||
async function addWebhook() {
|
||||
if (!webhookUrl.trim()) { setWebhookMsg('A URL is required.'); return }
|
||||
setWebhookMsg('')
|
||||
try {
|
||||
const r = await fetch('/api/admin-webhooks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: webhookUrl, events: webhookEvents.split(',').map(e => e.trim()).filter(Boolean) }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setWebhookMsg(d.message || 'Failed to add webhook.') } else {
|
||||
setWebhooks(prev => [...prev, d.webhook])
|
||||
setWebhookUrl(''); setWebhookEvents('*')
|
||||
setWebhookMsg('Webhook added.')
|
||||
}
|
||||
} catch { setWebhookMsg('Network error.') }
|
||||
}
|
||||
async function deleteWebhook(id: string) {
|
||||
if (!confirm('Delete this webhook?')) return
|
||||
try {
|
||||
await fetch(`/api/admin-webhooks/${id}`, { method: 'DELETE' })
|
||||
setWebhooks(prev => prev.filter(w => w.id !== id))
|
||||
} catch { setWebhookMsg('Failed to delete.') }
|
||||
}
|
||||
return (
|
||||
<section className="admin-panel-section">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Webhooks</h2>
|
||||
<p>Send POST notifications to external URLs when site events fire.</p>
|
||||
</div>
|
||||
{webhooks.length > 0 ? (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
{webhooks.map(wh => (
|
||||
<div key={wh.id} className="study-lesson-row" style={{ alignItems: 'flex-start', padding: '0.75rem 0.5rem' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.82rem', wordBreak: 'break-all', marginBottom: '0.25rem' }}>{wh.url}</div>
|
||||
<div style={{ fontSize: '0.75rem', opacity: 0.6 }}>
|
||||
Events: <span style={{ fontFamily: 'monospace' }}>{(wh.events ?? ['*']).join(', ')}</span>
|
||||
{' · '}{new Date(wh.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" style={{ fontSize: '0.8rem', padding: '0.2rem 0.5rem', marginLeft: '0.5rem' }} onClick={() => deleteWebhook(wh.id)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="admin-stats-note" style={{ marginBottom: '1.5rem' }}>No webhooks configured.</p>
|
||||
)}
|
||||
<div className="admin-panel-head" style={{ marginTop: 0 }}>
|
||||
<h3 style={{ fontSize: '0.95rem' }}>Add Webhook</h3>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="wh-url">Endpoint URL (HTTPS)</label>
|
||||
<input id="wh-url" type="url" value={webhookUrl} onChange={e => setWebhookUrl(e.target.value)} placeholder="https://example.com/hook" />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="wh-events">Events <span style={{ fontWeight: 400, opacity: 0.6, fontSize: '0.8em' }}>(comma-separated; * = all)</span></label>
|
||||
<input id="wh-events" type="text" value={webhookEvents} onChange={e => setWebhookEvents(e.target.value)} placeholder="*" />
|
||||
<p className="admin-field-hint" style={{ marginTop: '0.3rem' }}>Available: {ALL_EVENTS.join(', ')}</p>
|
||||
</div>
|
||||
{webhookMsg && <p className="admin-save-ok" style={{ marginBottom: '0.5rem' }}>{webhookMsg}</p>}
|
||||
<button type="button" className="btn-admin-apply" onClick={addWebhook}>Add Webhook</button>
|
||||
</section>
|
||||
)
|
||||
})()}
|
||||
|
||||
</main>
|
||||
|
||||
{/* ── Live preview pane ── */}
|
||||
|
||||
+17
@@ -12312,3 +12312,20 @@
|
||||
.kofi-qr-caption { font-size: 0.78rem; color: var(--brand-muted, #b0a48c); letter-spacing: 0.03em; }
|
||||
.kofi-btn { display: inline-block; background: transparent; color: var(--brand-gold, #c9a84c); border: 1px solid var(--brand-gold, #c9a84c); font-size: 0.9rem; font-weight: 600; padding: 0.7rem 1.75rem; border-radius: 4px; text-decoration: none; letter-spacing: 0.04em; transition: background 0.2s, color 0.2s; }
|
||||
.kofi-btn:hover { background: var(--brand-gold, #c9a84c); color: var(--brand-black, #0a0a08); }
|
||||
|
||||
/* ── Scripture index page ────────────────────────────────────────────────── */
|
||||
.scripture-index-main { padding: 3rem 1.5rem 5rem; }
|
||||
.scripture-index-note { color: var(--brand-muted, #b0a48c); margin: 2rem 0; }
|
||||
.scripture-study-group { margin-bottom: 3rem; }
|
||||
.scripture-study-title { font-family: var(--brand-font-heading, 'Cormorant Garamond', serif); font-size: 1.6rem; font-weight: 600; color: var(--brand-gold, #c9a84c); margin: 0 0 1.25rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--brand-border, #2a2518); }
|
||||
.scripture-entry-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.25rem 1.5rem; }
|
||||
.scripture-entry { margin: 0; }
|
||||
.scripture-entry-link { display: flex; align-items: baseline; gap: 0.6rem; padding: 0.4rem 0; color: var(--brand-warm-white, #f0ead8); text-decoration: none; border-bottom: 1px solid transparent; transition: color 0.15s, border-color 0.15s; }
|
||||
.scripture-entry-link:hover { color: var(--brand-gold, #c9a84c); border-bottom-color: var(--brand-gold, #c9a84c); }
|
||||
.scripture-entry-ref { font-family: var(--brand-font-body, 'EB Garamond', serif); font-size: 0.95rem; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
|
||||
.scripture-entry-title { font-size: 0.82rem; color: var(--brand-muted, #b0a48c); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ── Admin status messages ───────────────────────────────────────────────── */
|
||||
.admin-save-ok { color: #6ebd7c; font-size: 0.85rem; margin: 0; }
|
||||
.admin-save-error { color: #e05c5c; font-size: 0.85rem; margin: 0; }
|
||||
.admin-field-hint { font-size: 0.78rem; color: var(--admin-muted, #a0957e); margin: 0; }
|
||||
+50
@@ -1925,6 +1925,55 @@ function ResourcesPage({ content }: { content: SiteContent }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ScriptureIndexPage({ content }: { content: SiteContent }) {
|
||||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||||
usePageMeta(
|
||||
`Scripture Index | ${siteTitle}`,
|
||||
'Every passage referenced across all Bible studies — find the lesson that covers a particular verse.',
|
||||
)
|
||||
interface ScriptureEntry { sectionId: string; title: string; reference: string }
|
||||
interface StudyGroup { slug: string; title: string; entries: ScriptureEntry[] }
|
||||
const [studies, setStudies] = useState<StudyGroup[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
fetch('/api/public/scripture-index')
|
||||
.then(r => r.json())
|
||||
.then(d => { if (Array.isArray(d.studies)) setStudies(d.studies) })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<PageBanner eyebrow="Reference" title="Scripture Index" desc="Every passage covered in our studies, organized by reference. Find the lesson for any verse." />
|
||||
<main className="scripture-index-main page-width">
|
||||
{loading ? (
|
||||
<p className="scripture-index-note">Loading…</p>
|
||||
) : studies.length === 0 ? (
|
||||
<p className="scripture-index-note">No study lessons are published yet.</p>
|
||||
) : studies.map(study => (
|
||||
<section key={study.slug} className="scripture-study-group">
|
||||
<h2 className="scripture-study-title">{study.title}</h2>
|
||||
<ul className="scripture-entry-list">
|
||||
{study.entries.map(entry => (
|
||||
<li key={entry.sectionId} className="scripture-entry">
|
||||
<Link to={`/study/${study.slug}/${entry.sectionId}`} className="scripture-entry-link">
|
||||
<span className="scripture-entry-ref">{entry.reference}</span>
|
||||
{entry.title && <span className="scripture-entry-title">{entry.title}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</main>
|
||||
<SiteFooter content={content} />
|
||||
<AnalyticsConsentBanner content={content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AboutPage({ content }: { content: SiteContent }) {
|
||||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||||
usePageMeta(
|
||||
@@ -2711,6 +2760,7 @@ export default function App() {
|
||||
<Route path="/about" element={<AboutPage content={content} />} />
|
||||
<Route path="/contact" element={<ContactPage content={content} />} />
|
||||
<Route path="/questions" element={<QuestionsPage content={content} />} />
|
||||
<Route path="/scripture" element={<ScriptureIndexPage content={content} />} />
|
||||
<Route path="/thanks" element={<ThankYouPage />} />
|
||||
<Route path="/subscribe" element={<SubscribePage />} />
|
||||
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
|
||||
|
||||
Reference in new issue
Block a user