Rework Q&A: admin inbox with one-step publish, topics and scripture references; simpler public page with ask-a-question; stop exposing asker emails; v2.22.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-12 14:27:17 -04:00
1 parent e6bef529e6
commit 960ee41375
9 files changed
+1170 -2392

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "siteforge",
"version": "2.21.0",
"version": "2.22.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "siteforge",
"version": "2.21.0",
"version": "2.22.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.21.0",
"version": "2.22.0",
"type": "module",
"scripts": {
"dev": "vite",
+2 -1
View File
@@ -153,7 +153,8 @@ export function register(app) {
if (!id || !/^[\w-]{1,120}$/.test(id)) {
res.redirect(302, '/questions'); return
}
const sourceQuestions = state.questions
// Same source as /api/questions, so a freshly published answer's share link works straight away
const sourceQuestions = state.draftQuestions ?? state.questions
const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer)
if (!question) {
res.redirect(302, '/questions'); return
+98 -23
View File
@@ -20,6 +20,51 @@ const upvoteRateLimiter = rateLimit({
const upvotedBy = new Set()
const UPVOTED_BY_MAX = 50000
// Only these fields are ever sent to the public; the stored record also holds the asker's email.
function toPublicQuestion(q) {
return {
id: q.id,
firstName: q.firstName,
question: q.question,
answer: q.answer,
topic: q.topic || '',
reference: q.reference || '',
submittedAt: q.submittedAt,
answeredAt: q.answeredAt,
upvotes: q.upvotes ?? 0,
pinned: q.pinned === true,
}
}
const clean = (value, max) => (typeof value === 'string' ? value.trim().slice(0, max) : '')
// Email the asker once when their answer goes live (if they asked to be told).
async function notifyAsker(question) {
if (!question.notifyOnAnswer || !question.email || !question.answer || question.notifiedAt) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const from = getResendFromAddress()
const replyTo = getResendReplyToAddress()
const baseUrl = getCanonicalBaseUrl()
const questionUrl = buildAbsoluteUrl(baseUrl, `/questions#qa-${question.id}`)
const template = buildQuestionAnsweredEmailTemplate({
firstName: question.firstName,
question: question.question,
answer: question.answer,
questionUrl,
})
await sendResendEmailWithRetry({
resend,
payload: { from, replyTo, to: question.email, subject: template.subject, text: template.text, html: template.html },
context: `notify-on-answer:${question.id}`,
})
question.notifiedAt = new Date().toISOString()
queueDraftQuestionsWrite()
} catch (err) {
console.error('[notify-on-answer] Failed to send notification email:', err?.message ?? err)
}
}
function ensureDraftQuestions() {
if (state.draftQuestions !== null) return
state.draftQuestions = state.questions.slice(0, MAX_QUESTIONS)
@@ -36,7 +81,7 @@ export function register(app) {
if (!a.pinned && b.pinned) return 1
return 0
})
res.json({ questions: publicQuestions })
res.json({ questions: publicQuestions.map(toPublicQuestion) })
})
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
@@ -75,6 +120,8 @@ export function register(app) {
answeredAt: answerText ? now : null,
isApproved: approveNow,
approvedAt: approveNow ? now : null,
topic: clean(req.body?.topic, 60),
reference: clean(req.body?.reference, 80),
}
state.draftQuestions.unshift(created)
@@ -119,32 +166,60 @@ export function register(app) {
question.approvedAt = approved === true ? new Date().toISOString() : null
queueDraftQuestionsWrite()
if (approved === true && question.notifyOnAnswer && question.email && question.answer) {
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const from = getResendFromAddress()
const replyTo = getResendReplyToAddress()
const baseUrl = getCanonicalBaseUrl()
const questionUrl = buildAbsoluteUrl(baseUrl, `/questions#qa-${question.id}`)
const template = buildQuestionAnsweredEmailTemplate({
firstName: question.firstName,
question: question.question,
answer: question.answer,
questionUrl,
})
await sendResendEmailWithRetry({
resend,
payload: { from, replyTo, to: question.email, subject: template.subject, text: template.text, html: template.html },
context: `notify-on-answer:${question.id}`,
})
} catch (err) {
console.error('[notify-on-answer] Failed to send notification email:', err?.message ?? err)
}
}
if (approved === true) await notifyAsker(question)
res.json({ ok: true, question })
})
// Edit any part of a question (typo fixes, answer, topic, scripture reference)
app.patch('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
ensureDraftQuestions()
const question = state.draftQuestions.find(q => q.id === req.params.id)
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
const body = req.body ?? {}
if (typeof body.question === 'string') {
const text = body.question.trim()
if (text.length < 5 || text.length > 3000) { res.status(400).json({ message: 'Question must be between 5 and 3000 characters.' }); return }
question.question = text
}
if (typeof body.answer === 'string') {
const text = body.answer.trim()
if (text.length > 5000) { res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return }
if (text !== (question.answer ?? '')) question.answeredAt = text ? new Date().toISOString() : null
question.answer = text
}
if (typeof body.firstName === 'string' && body.firstName.trim()) question.firstName = clean(body.firstName, 100)
if (typeof body.topic === 'string') question.topic = clean(body.topic, 60)
if (typeof body.reference === 'string') question.reference = clean(body.reference, 80)
queueDraftQuestionsWrite()
res.json({ ok: true, question })
})
// Save the answer, make it public and email the asker (once) — all in one step
app.post('/api/admin-questions/:id/publish', requireAdminAuth, async (req, res) => {
ensureDraftQuestions()
const question = state.draftQuestions.find(q => q.id === req.params.id)
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
const body = req.body ?? {}
if (typeof body.answer === 'string') {
const text = body.answer.trim()
if (text.length > 5000) { res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return }
if (text !== (question.answer ?? '')) question.answeredAt = text ? new Date().toISOString() : null
question.answer = text
}
if (typeof body.topic === 'string') question.topic = clean(body.topic, 60)
if (typeof body.reference === 'string') question.reference = clean(body.reference, 80)
if (!question.answer?.trim()) { res.status(400).json({ message: 'Write an answer before publishing.' }); return }
question.isApproved = true
question.approvedAt = new Date().toISOString()
queueDraftQuestionsWrite()
await notifyAsker(question)
res.json({ ok: true, question })
})
app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
ensureDraftQuestions()
+4 -381
View File
@@ -9,6 +9,7 @@ import { DEFAULTS } from './content'
import { AnalyticsPanel } from './components/AnalyticsPanel'
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
import { AdminDashboardExtras } from './components/AdminDashboardExtras'
import { QuestionsAdmin, type AdminQuestion } from './components/QuestionsAdmin'
import { useAutosave } from './hooks/useAutosave'
import QRCode from 'qrcode'
import InboxPanel, { type InboxSubmission } from './InboxPanel'
@@ -685,19 +686,7 @@ interface OpsStatus {
deployHook: { ok: boolean; at: string | null; error: string | null }
}
interface Question {
id: string
submittedAt: string
firstName: string
email: string
question: string
answer: string
answeredAt: string | null
isApproved: boolean
approvedAt: string | null
upvotes?: number
pinned?: boolean
}
type Question = AdminQuestion
type ContactSubmission = InboxSubmission
@@ -2285,12 +2274,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [questions, setQuestions] = useState<Question[]>([])
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null)
const [questionSearch, setQuestionSearch] = useState('')
const [questionFilter, setQuestionFilter] = useState<'all' | 'pending' | 'approved' | 'answered' | 'unanswered'>('all')
const [questionPage, setQuestionPage] = useState(0)
const [selectedQuestionIds, setSelectedQuestionIds] = useState<Set<string>>(new Set())
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [navSearch, setNavSearch] = useState('')
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
@@ -2382,15 +2365,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [nlPublishingId, setNlPublishingId] = useState<string | null>(null)
const [dashboardNow, setDashboardNow] = useState(() => new Date())
const [manualQuestion, setManualQuestion] = useState({
firstName: '',
email: '',
question: '',
answer: '',
approve: false,
})
const [manualQuestionStatus, setManualQuestionStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [manualQuestionMsg, setManualQuestionMsg] = useState('')
const [previewOpen, setPreviewOpen] = useState(false)
const previewIframeRef = useRef<HTMLIFrameElement>(null)
const dragSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
@@ -2644,10 +2618,6 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
}
useEffect(() => {
setQuestionPage(0)
}, [questionSearch, questionFilter])
async function reloadBackups() {
const r = await fetch('/api/admin-stats/backups')
if (!r.ok) throw new Error('Could not refresh backups')
@@ -3545,63 +3515,6 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
}))
}
async function handleAnswerQuestion(questionId: string, answer: string) {
if (!answer.trim()) return
try {
const res = await fetch(`/api/admin-questions/${questionId}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer: answer.trim() }),
})
if (!res.ok) throw new Error('Failed to answer question')
setQuestions(qs =>
qs.map(q =>
q.id === questionId
? { ...q, answer: answer.trim(), answeredAt: new Date().toISOString() }
: q
)
)
setEditingQuestionId(null)
setAnsweredQuestions(a => ({ ...a, [questionId]: '' }))
} catch {
alert('Failed to save answer')
}
}
async function handleApproveQuestion(questionId: string, approved: boolean) {
try {
const res = await fetch(`/api/admin-questions/${questionId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approved }),
})
if (!res.ok) throw new Error('Failed to update question')
setQuestions(qs =>
qs.map(q =>
q.id === questionId
? { ...q, isApproved: approved, approvedAt: approved ? new Date().toISOString() : null }
: q
)
)
} catch {
alert('Failed to update question')
}
}
async function handlePinQuestion(questionId: string, pin: boolean) {
try {
const method = pin ? 'POST' : 'DELETE'
const res = await fetch(`/api/admin-questions/${questionId}/pin`, { method })
if (!res.ok) throw new Error('Failed to update pin')
const data = await res.json().catch(() => ({})) as { question?: Question }
if (data.question) {
setQuestions(items => items.map(q => q.id === questionId ? { ...q, pinned: data.question!.pinned } : q))
}
} catch (err) {
console.error(err)
}
}
async function loadWebhooksAdmin() {
try {
const res = await fetch('/api/admin-webhooks')
@@ -3852,102 +3765,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
} catch { alert('Failed to delete script.') }
}
async function handleDeleteQuestion(questionId: string) {
if (!confirm('Delete this question permanently?')) return
try {
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')
setManualQuestionMsg('First name and question are required.')
return
}
setManualQuestionStatus('saving')
setManualQuestionMsg('')
try {
const res = await fetch('/api/admin-questions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: manualQuestion.firstName,
email: manualQuestion.email,
question: manualQuestion.question,
answer: manualQuestion.answer,
approve: manualQuestion.approve,
}),
})
const data = await res.json().catch(() => ({})) as { question?: Question; message?: string }
if (!res.ok || !data.question) {
throw new Error(data.message ?? 'Failed to add question.')
}
setQuestions(items => [data.question as Question, ...items])
setManualQuestion({ firstName: '', email: '', question: '', answer: '', approve: false })
setManualQuestionStatus('saved')
setManualQuestionMsg('Question added to draft Q&A list.')
} catch (err) {
setManualQuestionStatus('error')
setManualQuestionMsg(err instanceof Error ? err.message : 'Failed to add question.')
}
}
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources')
const filteredAdminQuestions = questions.filter(question => {
const search = questionSearch.trim().toLowerCase()
const matchesSearch = !search
|| question.firstName.toLowerCase().includes(search)
|| question.question.toLowerCase().includes(search)
|| question.answer.toLowerCase().includes(search)
const matchesFilter = (
questionFilter === 'all'
|| (questionFilter === 'pending' && !question.isApproved)
|| (questionFilter === 'approved' && question.isApproved)
|| (questionFilter === 'answered' && Boolean(question.answer?.trim()))
|| (questionFilter === 'unanswered' && !question.answer?.trim())
)
return matchesSearch && matchesFilter
})
const QUESTION_PAGE_SIZE = 20
const totalQuestionPages = Math.max(1, Math.ceil(filteredAdminQuestions.length / QUESTION_PAGE_SIZE))
const visibleAdminQuestions = filteredAdminQuestions.slice(
questionPage * QUESTION_PAGE_SIZE,
(questionPage + 1) * QUESTION_PAGE_SIZE,
)
function renderSaveStatus() {
return (
<>
@@ -4169,7 +3988,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<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>}
{unansweredCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => 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>
@@ -6840,203 +6659,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
)}
{/* QUESTIONS */}
{adminView === 'questions' && (
<section className="admin-panel-section" aria-label="Q&A Management">
<div className="admin-panel-head">
<h2>Bible Questions &amp; Answers</h2>
<p>Manage submitted questions, add manual questions, provide answers, and approve for public display.</p>
</div>
<div className="admin-actions admin-actions--maintenance" style={{ alignItems: 'center' }}>
<input
type="text"
value={questionSearch}
onChange={e => setQuestionSearch(e.target.value)}
placeholder="Search by name, question, or answer..."
style={{ minWidth: '320px', maxWidth: '520px', width: '100%' }}
/>
<button type="button" className={`btn-admin-reset${questionFilter === 'all' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('all')}>All</button>
<button type="button" className={`btn-admin-reset${questionFilter === 'pending' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('pending')}>Pending</button>
<button type="button" className={`btn-admin-reset${questionFilter === 'approved' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('approved')}>Approved</button>
<button type="button" className={`btn-admin-reset${questionFilter === 'answered' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('answered')}>Answered</button>
<button type="button" className={`btn-admin-reset${questionFilter === 'unanswered' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('unanswered')}>Unanswered</button>
</div>
<p className="admin-stats-note">Showing {filteredAdminQuestions.length} of {questions.length} questions.</p>
{selectedQuestionIds.size > 0 && (
<div className="admin-bulk-toolbar">
<span>{selectedQuestionIds.size} selected</span>
<button type="button" className="btn-admin-remove" onClick={handleBulkDeleteQuestions}>Delete Selected</button>
<button type="button" className="btn-admin-reset" onClick={() => setSelectedQuestionIds(new Set())}>Deselect All</button>
</div>
)}
<div className="admin-visits-table-wrap">
<h3>Add Question Manually</h3>
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor="manual-question-first-name">First Name</label>
<input
id="manual-question-first-name"
type="text"
value={manualQuestion.firstName}
onChange={e => setManualQuestion(curr => ({ ...curr, firstName: e.target.value }))}
placeholder="Nate"
/>
</div>
<div className="admin-field">
<label htmlFor="manual-question-email">Email (optional)</label>
<input
id="manual-question-email"
type="email"
value={manualQuestion.email}
onChange={e => setManualQuestion(curr => ({ ...curr, email: e.target.value }))}
placeholder="optional@email.com"
/>
</div>
<div className="admin-field">
<label htmlFor="manual-question-question">Question</label>
<textarea
id="manual-question-question"
rows={3}
value={manualQuestion.question}
onChange={e => setManualQuestion(curr => ({ ...curr, question: e.target.value }))}
placeholder="Type the question you want to add to the Q&A list..."
/>
</div>
<div className="admin-field">
<label htmlFor="manual-question-answer">Answer (optional)</label>
<textarea
id="manual-question-answer"
rows={3}
value={manualQuestion.answer}
onChange={e => setManualQuestion(curr => ({ ...curr, answer: e.target.value }))}
placeholder="Optional: add an answer now"
/>
</div>
<div className="admin-field">
<label htmlFor="manual-question-approve">Approve Immediately</label>
<select
id="manual-question-approve"
value={manualQuestion.approve ? 'yes' : 'no'}
onChange={e => setManualQuestion(curr => ({ ...curr, approve: e.target.value === 'yes' }))}
>
<option value="no">No</option>
<option value="yes">Yes</option>
</select>
</div>
</div>
<button
type="button"
className="btn-admin-save"
onClick={handleCreateManualQuestion}
disabled={manualQuestionStatus === 'saving'}
>
{manualQuestionStatus === 'saving' ? 'Adding…' : 'Add Question'}
</button>
</div>
{manualQuestionMsg && (
<p className={`admin-status ${manualQuestionStatus === 'error' ? 'admin-status--err' : 'admin-status--ok'}`}>
{manualQuestionMsg}
</p>
)}
</div>
{questions.length === 0 ? (
<p className="admin-stats-note">No questions submitted yet.</p>
) : filteredAdminQuestions.length === 0 ? (
<p className="admin-stats-note">No questions match this filter.</p>
) : (
<div className="admin-questions-list">
{visibleAdminQuestions.map(question => (
<div key={question.id} className={`admin-question-card${selectedQuestionIds.has(question.id) ? ' admin-question-card--selected' : ''}`}>
<div className="admin-question-header">
<label className="admin-question-checkbox" title="Select question">
<input
type="checkbox"
checked={selectedQuestionIds.has(question.id)}
onChange={e => {
setSelectedQuestionIds(prev => {
const next = new Set(prev)
if (e.target.checked) next.add(question.id)
else next.delete(question.id)
return next
})
}}
/>
</label>
<div>
<p className="admin-question-meta"><strong>{question.firstName}</strong> • {formatDate(question.submittedAt)}</p>
<p className="admin-question-text"><strong>Q:</strong> {question.question}</p>
</div>
<div className="admin-question-status">
<span className={`admin-badge ${question.isApproved ? 'admin-badge--approved' : 'admin-badge--pending'}`}>
{question.isApproved ? 'Approved' : 'Pending'}
</span>
{question.answer && <span className="admin-badge admin-badge--answered">Answered</span>}
</div>
</div>
{question.answer && (
<div className="admin-question-answer">
<p><strong>A:</strong> {question.answer}</p>
</div>
)}
{editingQuestionId === question.id ? (
<div className="admin-question-editor">
<textarea
value={answeredQuestions[question.id] ?? question.answer ?? ''}
onChange={e => setAnsweredQuestions(a => ({ ...a, [question.id]: e.target.value }))}
rows={4}
placeholder="Type your answer here..."
/>
<div className="admin-question-editor-actions">
<button type="button" className="btn-admin-save" onClick={() => handleAnswerQuestion(question.id, answeredQuestions[question.id] ?? '')}>Save Answer</button>
<button type="button" className="btn-admin-reset" onClick={() => setEditingQuestionId(null)}>Cancel</button>
</div>
</div>
) : (
<div className="admin-question-actions">
<button type="button" className="btn-admin-reset" onClick={() => { setEditingQuestionId(question.id); setAnsweredQuestions(a => ({ ...a, [question.id]: question.answer ?? '' })) }}>
{question.answer ? 'Edit Answer' : 'Add Answer'}
</button>
<button type="button" className={`btn-admin-${question.isApproved ? 'remove' : 'reset'}`} onClick={() => handleApproveQuestion(question.id, !question.isApproved)}>
{question.isApproved ? 'Unapprove' : 'Approve'}
</button>
<button type="button" className={`btn-admin-${question.pinned ? 'remove' : 'reset'}`} onClick={() => handlePinQuestion(question.id, !question.pinned)}>
{question.pinned ? '📌 Unpin' : '📌 Pin'}
</button>
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteQuestion(question.id)}>Delete</button>
</div>
)}
</div>
))}
</div>
)}
{filteredAdminQuestions.length > QUESTION_PAGE_SIZE && (
<div className="qa-pagination" style={{ marginTop: '1rem', justifyContent: 'flex-start' }}>
<button
type="button"
className="qa-page-btn"
onClick={() => setQuestionPage(p => Math.max(0, p - 1))}
disabled={questionPage === 0}
>
Prev
</button>
<span className="qa-page-info">Page {questionPage + 1} / {totalQuestionPages}</span>
<button
type="button"
className="qa-page-btn"
onClick={() => setQuestionPage(p => Math.min(totalQuestionPages - 1, p + 1))}
disabled={questionPage >= totalQuestionPages - 1}
>
Next
</button>
</div>
)}
</section>
)}
{adminView === 'questions' && <QuestionsAdmin questions={questions} onChange={setQuestions} />}
{/* STUDY USERS */}
{adminView === 'study-users' && <StudyUsersPanel studies={normalizeStudies(form)} />}
+218 -916
View File
File diff suppressed because it is too large. Load diff
+240 -1068
View File
File diff suppressed because it is too large. Load diff
+363
View File
@@ -0,0 +1,363 @@
import { useMemo, useState } from 'react'
export interface AdminQuestion {
id: string
submittedAt: string
firstName: string
email: string
question: string
answer: string
answeredAt: string | null
isApproved: boolean
approvedAt: string | null
topic?: string
reference?: string
notifyOnAnswer?: boolean
notifiedAt?: string
upvotes?: number
pinned?: boolean
}
type Tab = 'new' | 'ready' | 'published' | 'all'
const hasAnswer = (q: AdminQuestion) => Boolean(q.answer?.trim())
const statusOf = (q: AdminQuestion): Exclude<Tab, 'all'> => (!hasAnswer(q) ? 'new' : q.isApproved ? 'published' : 'ready')
const TAB_LABELS: Record<Tab, string> = {
new: 'Needs answer',
ready: 'Ready to publish',
published: 'Published',
all: 'All',
}
function formatDate(value: string | null | undefined) {
if (!value) return ''
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
async function call(url: string, method: string, body?: unknown) {
const res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
})
const data = await res.json().catch(() => ({})) as { question?: AdminQuestion; message?: string }
if (!res.ok) throw new Error(data.message ?? 'Something went wrong.')
return data
}
interface Props {
questions: AdminQuestion[]
onChange: (questions: AdminQuestion[]) => void
}
export function QuestionsAdmin({ questions, onChange }: Props) {
const counts = useMemo(() => ({
new: questions.filter(q => statusOf(q) === 'new').length,
ready: questions.filter(q => statusOf(q) === 'ready').length,
published: questions.filter(q => statusOf(q) === 'published').length,
all: questions.length,
}), [questions])
const [tab, setTab] = useState<Tab>(counts.new > 0 ? 'new' : 'all')
const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState('')
const [checked, setChecked] = useState<Set<string>>(new Set())
const [adding, setAdding] = useState(false)
const [message, setMessage] = useState('')
const [busy, setBusy] = useState(false)
// Edits live in a draft for the open question until saved
const [draft, setDraft] = useState<{ id: string; question: string; answer: string; topic: string; reference: string } | null>(null)
const topics = useMemo(() => {
const tally = new Map<string, number>()
for (const q of questions) if (q.topic?.trim()) tally.set(q.topic.trim(), (tally.get(q.topic.trim()) ?? 0) + 1)
return [...tally.entries()].sort((a, b) => b[1] - a[1]).map(([t]) => t)
}, [questions])
const visible = useMemo(() => {
const needle = search.trim().toLowerCase()
return questions
.filter(q => tab === 'all' || statusOf(q) === tab)
.filter(q => !needle || [q.firstName, q.question, q.answer, q.topic ?? '', q.reference ?? ''].some(v => v.toLowerCase().includes(needle)))
}, [questions, tab, search])
const selected = questions.find(q => q.id === selectedId) ?? null
const current = selected && draft?.id === selected.id
? draft
: selected
? { id: selected.id, question: selected.question, answer: selected.answer ?? '', topic: selected.topic ?? '', reference: selected.reference ?? '' }
: null
const dirty = Boolean(selected && current && (
current.question !== selected.question || current.answer !== (selected.answer ?? '') ||
current.topic !== (selected.topic ?? '') || current.reference !== (selected.reference ?? '')
))
const open = (id: string) => {
if (dirty && !confirm('You have unsaved changes to this question. Discard them?')) return
setSelectedId(id)
setDraft(null)
setMessage('')
}
const edit = (patch: Partial<NonNullable<typeof draft>>) => {
if (!selected || !current) return
setDraft({ ...current, ...patch })
}
const replace = (q: AdminQuestion) => onChange(questions.map(item => (item.id === q.id ? { ...item, ...q } : item)))
// After publishing, move on to the next question waiting in the same queue
const advance = (fromId: string) => {
const list = visible.filter(q => q.id !== fromId)
const idx = visible.findIndex(q => q.id === fromId)
const next = list[Math.min(idx, list.length - 1)]
setSelectedId(next?.id ?? '')
setDraft(null)
}
const run = async (label: string, fn: () => Promise<void>) => {
setBusy(true)
setMessage('')
try {
await fn()
} catch (err) {
setMessage(err instanceof Error ? err.message : `Could not ${label}.`)
} finally {
setBusy(false)
}
}
const saveFields = () => run('save', async () => {
if (!selected || !current) return
const d = await call(`/api/admin-questions/${selected.id}`, 'PATCH', current)
if (d.question) replace(d.question)
setDraft(null)
setMessage('Saved.')
})
const publish = () => run('publish', async () => {
if (!selected || !current) return
if (current.question !== selected.question) await call(`/api/admin-questions/${selected.id}`, 'PATCH', { question: current.question })
const d = await call(`/api/admin-questions/${selected.id}/publish`, 'POST', { answer: current.answer, topic: current.topic, reference: current.reference })
if (d.question) {
const id = selected.id
replace(d.question)
advance(tab === 'published' || tab === 'all' ? '' : id)
setMessage(selected.notifyOnAnswer && selected.email && !selected.notifiedAt ? 'Published, and the asker was emailed.' : 'Published.')
}
})
const unpublish = () => run('unpublish', async () => {
if (!selected) return
const d = await call(`/api/admin-questions/${selected.id}/approve`, 'POST', { approved: false })
if (d.question) replace(d.question)
setMessage('Taken off the site.')
})
const togglePin = () => run('pin', async () => {
if (!selected) return
const d = await call(`/api/admin-questions/${selected.id}/pin`, selected.pinned ? 'DELETE' : 'POST')
if (d.question) replace(d.question)
})
const remove = (ids: string[]) => {
if (ids.length === 0) return
if (!confirm(`Delete ${ids.length === 1 ? 'this question' : `${ids.length} questions`} permanently?`)) return
return run('delete', async () => {
const gone: string[] = []
for (const id of ids) {
try { await call(`/api/admin-questions/${id}`, 'DELETE'); gone.push(id) } catch { /* keep going */ }
}
onChange(questions.filter(q => !gone.includes(q.id)))
setChecked(new Set())
if (gone.includes(selectedId)) { setSelectedId(''); setDraft(null) }
if (gone.length < ids.length) setMessage(`Deleted ${gone.length} of ${ids.length}.`)
})
}
const [manual, setManual] = useState({ firstName: '', email: '', question: '', answer: '', topic: '', reference: '', publish: false })
const addManual = () => run('add the question', async () => {
const d = await call('/api/admin-questions', 'POST', { ...manual, approve: manual.publish && Boolean(manual.answer.trim()) })
if (d.question) {
onChange([d.question, ...questions])
setSelectedId(d.question.id)
setDraft(null)
}
setManual({ firstName: '', email: '', question: '', answer: '', topic: '', reference: '', publish: false })
setAdding(false)
setMessage('Question added.')
})
const status = selected ? statusOf(selected) : null
return (
<section className="admin-panel-section" aria-label="Questions and answers">
<div className="admin-panel-head">
<h2>Bible Questions &amp; Answers</h2>
<p>Work through questions from the top: write an answer, set a topic, and publish. Published answers appear on the public Q&amp;A page right away.</p>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12, alignItems: 'center' }}>
{(Object.keys(TAB_LABELS) as Tab[]).map(t => (
<button key={t} type="button" className={tab === t ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => setTab(t)}>
{TAB_LABELS[t]} ({counts[t]})
</button>
))}
<button type="button" className="btn-admin-add" style={{ marginLeft: 'auto' }} onClick={() => setAdding(a => !a)}>
{adding ? 'Cancel' : '+ Add question'}
</button>
</div>
{message && <p className="admin-stats-note" role="status">{message}</p>}
{adding && (
<div style={{ border: '1px solid var(--color-border, rgba(255,255,255,0.14))', borderRadius: 6, padding: 12, marginBottom: 16 }}>
<h3 style={{ marginTop: 0 }}>Add a question</h3>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
<label htmlFor="qa-new-name">First name</label>
<input id="qa-new-name" type="text" value={manual.firstName} onChange={e => setManual({ ...manual, firstName: e.target.value })} />
</div>
<div className="admin-field" style={{ flex: 1, minWidth: 200 }}>
<label htmlFor="qa-new-email">Email (optional)</label>
<input id="qa-new-email" type="email" value={manual.email} onChange={e => setManual({ ...manual, email: e.target.value })} />
</div>
</div>
<div className="admin-field">
<label htmlFor="qa-new-question">Question</label>
<textarea id="qa-new-question" rows={3} value={manual.question} onChange={e => setManual({ ...manual, question: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="qa-new-answer">Answer (optional)</label>
<textarea id="qa-new-answer" rows={4} value={manual.answer} onChange={e => setManual({ ...manual, answer: e.target.value })} />
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
<label htmlFor="qa-new-topic">Topic</label>
<input id="qa-new-topic" type="text" list="qa-topic-options" value={manual.topic} onChange={e => setManual({ ...manual, topic: e.target.value })} />
</div>
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
<label htmlFor="qa-new-ref">Scripture reference</label>
<input id="qa-new-ref" type="text" placeholder="e.g. Titus 1:5-9" value={manual.reference} onChange={e => setManual({ ...manual, reference: e.target.value })} />
</div>
</div>
<label style={{ display: 'block', margin: '8px 0' }}>
<input type="checkbox" checked={manual.publish} onChange={e => setManual({ ...manual, publish: e.target.checked })} /> Publish right away (needs an answer)
</label>
<button type="button" className="btn-admin-save" onClick={addManual} disabled={busy || !manual.firstName.trim() || manual.question.trim().length < 5}>Add question</button>
</div>
)}
<datalist id="qa-topic-options">{topics.map(t => <option key={t} value={t} />)}</datalist>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(240px, 340px) 1fr', gap: 20, alignItems: 'start' }}>
<div>
<div className="admin-field" style={{ marginBottom: 8 }}>
<input type="search" placeholder="Search questions, answers, topics…" value={search} onChange={e => setSearch(e.target.value)} aria-label="Search questions" />
</div>
{checked.size > 0 && (
<div className="admin-bulk-toolbar" style={{ marginBottom: 8 }}>
<span>{checked.size} selected</span>
<button type="button" className="btn-admin-remove" onClick={() => remove([...checked])}>Delete</button>
<button type="button" className="btn-admin-reset" onClick={() => setChecked(new Set())}>Clear</button>
</div>
)}
{visible.length === 0 && <p className="admin-stats-note">{tab === 'new' ? 'Nothing waiting for an answer. 🎉' : 'No questions here.'}</p>}
{visible.map(q => (
<div
key={q.id}
style={{
display: 'flex', gap: 8, alignItems: 'flex-start', padding: '8px 10px', marginBottom: 6, borderRadius: 6,
border: `1px solid ${q.id === selectedId ? '#8a6d2f' : 'var(--color-border, rgba(255,255,255,0.14))'}`,
}}
>
<input
type="checkbox"
aria-label={`Select question from ${q.firstName}`}
checked={checked.has(q.id)}
onChange={e => setChecked(prev => { const next = new Set(prev); if (e.target.checked) next.add(q.id); else next.delete(q.id); return next })}
style={{ marginTop: 4 }}
/>
<button
type="button"
onClick={() => open(q.id)}
style={{ flex: 1, textAlign: 'left', background: 'transparent', border: 0, padding: 0, cursor: 'pointer', color: 'inherit', font: 'inherit' }}
>
<strong>{q.pinned ? '📌 ' : ''}{q.firstName || 'Someone'}</strong>
<span style={{ fontSize: '0.76rem', opacity: 0.65 }}> · {formatDate(q.submittedAt)}</span>
<div style={{ fontSize: '0.84rem', opacity: 0.85, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{q.question}</div>
<div style={{ fontSize: '0.74rem', opacity: 0.7, marginTop: 2 }}>
{tab === 'all' && <>{TAB_LABELS[statusOf(q)]} · </>}
{q.topic ? `${q.topic} · ` : ''}{q.notifyOnAnswer && !q.notifiedAt && !q.isApproved ? '✉ wants an email · ' : ''}{(q.upvotes ?? 0) > 0 ? `▲ ${q.upvotes}` : ''}
</div>
</button>
</div>
))}
</div>
<div>
{!selected || !current ? (
<p className="admin-stats-note">Select a question to answer it.</p>
) : (
<>
<p style={{ margin: '0 0 8px', fontSize: '0.86rem' }}>
From <strong>{selected.firstName || 'Someone'}</strong>
{selected.email && <> · <a href={`mailto:${selected.email}`}>{selected.email}</a></>}
{' '}· {formatDate(selected.submittedAt)} · <em>{TAB_LABELS[status!]}</em>
</p>
{selected.notifyOnAnswer && selected.email && (
<p className="admin-stats-note" style={{ marginTop: 0 }}>
{selected.notifiedAt ? `They were emailed on ${formatDate(selected.notifiedAt)}.` : 'They asked to be emailed when this is answered. Publishing sends it.'}
</p>
)}
<div className="admin-field">
<label htmlFor="qa-edit-question">Question</label>
<textarea id="qa-edit-question" rows={3} value={current.question} onChange={e => edit({ question: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="qa-edit-answer">Answer</label>
<textarea id="qa-edit-answer" rows={9} value={current.answer} onChange={e => edit({ answer: e.target.value })} />
<p className="admin-field-help">{current.answer.length} / 5000</p>
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div className="admin-field" style={{ flex: 1, minWidth: 180 }}>
<label htmlFor="qa-edit-topic">Topic</label>
<input id="qa-edit-topic" type="text" list="qa-topic-options" value={current.topic} onChange={e => edit({ topic: e.target.value })} placeholder="e.g. Salvation" />
</div>
<div className="admin-field" style={{ flex: 1, minWidth: 180 }}>
<label htmlFor="qa-edit-ref">Scripture reference</label>
<input id="qa-edit-ref" type="text" value={current.reference} onChange={e => edit({ reference: e.target.value })} placeholder="e.g. Titus 1:5-9" />
</div>
</div>
{topics.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', margin: '0 0 12px' }}>
{topics.slice(0, 8).map(t => (
<button key={t} type="button" className={current.topic === t ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => edit({ topic: t })}>{t}</button>
))}
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{status === 'published' ? (
<>
<button type="button" className="btn-admin-save" onClick={saveFields} disabled={busy || !dirty}>Save changes</button>
<button type="button" className="btn-admin-ghost" onClick={unpublish} disabled={busy}>Unpublish</button>
</>
) : (
<>
<button type="button" className="btn-admin-save" onClick={publish} disabled={busy || !current.answer.trim()}>Save &amp; publish</button>
<button type="button" className="btn-admin-ghost" onClick={saveFields} disabled={busy || !dirty}>Save draft</button>
</>
)}
<button type="button" className="btn-admin-ghost" onClick={togglePin} disabled={busy}>{selected.pinned ? 'Unpin' : 'Pin to top'}</button>
<button type="button" className="btn-admin-remove" onClick={() => remove([selected.id])} disabled={busy}>Delete</button>
{dirty && <span className="admin-stats-note">Unsaved changes</span>}
</div>
</>
)}
</div>
</div>
</section>
)
}
+242
View File
@@ -0,0 +1,242 @@
import { useEffect, useRef, useState } from 'react'
// Turns scripture references ("Titus 1:5-9") in answer text into hover/tap verse popups and URLs into links.
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function renderHighlightedText(text: string, query: string) {
const trimmed = query.trim()
if (!trimmed) return text
const regex = new RegExp(`(${escapeRegExp(trimmed)})`, 'ig')
const parts = text.split(regex)
return parts.map((part, index) => {
if (part.toLowerCase() === trimmed.toLowerCase()) {
return <mark key={`mark-${index}`} className="qa-highlight">{part}</mark>
}
return <span key={`plain-${index}`}>{part}</span>
})
}
function renderTextWithLinks(text: string, highlightQuery = '') {
const BOOK_ID_MAP: Record<string, string> = {
gen: 'GEN', genesis: 'GEN', exo: 'EXO', exodus: 'EXO', lev: 'LEV', leviticus: 'LEV',
num: 'NUM', numbers: 'NUM', deut: 'DEU', deuteronomy: 'DEU', josh: 'JOS', joshua: 'JOS',
judg: 'JDG', judges: 'JDG', ruth: 'RUT', '1 sam': 'SA1', '2 sam': 'SA2',
'1 kgs': 'KI1', '1 kings': 'KI1', '2 kgs': 'KI2', '2 kings': 'KI2',
'1 chr': 'CH1', '1 chron': 'CH1', '1 chronicles': 'CH1', '2 chr': 'CH2', '2 chron': 'CH2', '2 chronicles': 'CH2',
ezra: 'EZR', neh: 'NEH', nehemiah: 'NEH', esth: 'EST', esther: 'EST',
job: 'JOB', ps: 'PSA', psalms: 'PSA', psalm: 'PSA', prov: 'PRO', proverbs: 'PRO',
eccl: 'ECC', ecclesiastes: 'ECC', song: 'SNG', 'song of sol': 'SNG', 'song of solomon': 'SNG',
isa: 'ISA', isaiah: 'ISA', jer: 'JER', jeremiah: 'JER', lam: 'LAM', lamentations: 'LAM',
ezek: 'EZK', ezekiel: 'EZK', dan: 'DAN', daniel: 'DAN', hos: 'HOS', hosea: 'HOS',
joel: 'JOL', amos: 'AMO', obad: 'OBA', obadiah: 'OBA', jonah: 'JNA', mic: 'MIC', micah: 'MIC',
nah: 'NAH', nahum: 'NAH', hab: 'HAB', habakkuk: 'HAB', zeph: 'ZEP', zephaniah: 'ZEP',
hag: 'HAG', haggai: 'HAG', zech: 'ZEC', zechariah: 'ZEC', mal: 'MAL', malachi: 'MAL',
matt: 'MAT', matthew: 'MAT', mark: 'MRK', luke: 'LUK', john: 'JHN', acts: 'ACT',
rom: 'ROM', romans: 'ROM', '1 cor': 'CO1', '1 corinthians': 'CO1', '2 cor': 'CO2', '2 corinthians': 'CO2',
gal: 'GAL', galatians: 'GAL', eph: 'EPH', ephesians: 'EPH', phil: 'PHP', philippians: 'PHP',
col: 'COL', colossians: 'COL', '1 thess': 'TH1', '1 thessalonians': 'TH1', '2 thess': 'TH2', '2 thessalonians': 'TH2',
'1 tim': 'TI1', '1 timothy': 'TI1', '2 tim': 'TI2', '2 timothy': 'TI2',
titus: 'TIT', philem: 'PHM', philemon: 'PHM', heb: 'HEB', hebrews: 'HEB',
jas: 'JAM', james: 'JAM', '1 pet': 'PE1', '1 peter': 'PE1', '2 pet': 'PE2', '2 peter': 'PE2',
'1 john': 'JO1', '2 john': 'JO2', '3 john': 'JO3', jude: 'JDE', rev: 'REV', revelation: 'REV',
}
function parseScriptureRef(refText: string): { bookId: string; chapter: number; verseStart: number; verseEnd: number } | null {
const match = refText.match(/^(.*?)\s+(\d+):(\d+)(?:-(\d+))?$/)
if (!match) return null
const [, bookRaw, chapterStr, verseStartStr, verseEndStr] = match
const bookKey = bookRaw.toLowerCase().replace(/\.\s*/g, ' ').trim()
const bookId = BOOK_ID_MAP[bookKey]
if (!bookId) return null
return {
bookId,
chapter: parseInt(chapterStr, 10),
verseStart: parseInt(verseStartStr, 10),
verseEnd: verseEndStr ? parseInt(verseEndStr, 10) : parseInt(verseStartStr, 10),
}
}
// Split the text by both scripture refs and URLs
const combined = /(https?:\/\/[^\s]+)|\b((?:(?:1|2|3)\s)?(?:Gen(?:esis)?|Exo(?:dus)?|Lev(?:iticus)?|Num(?:bers)?|Deut(?:eronomy)?|Josh(?:ua)?|Judg(?:es)?|Ruth|1\s?Sam|2\s?Sam|1\s?Kgs?|2\s?Kgs?|1\s?Chr(?:on)?|2\s?Chr(?:on)?|Ezra|Neh(?:emiah)?|Esth(?:er)?|Job|Ps(?:alms?)?|Prov(?:erbs)?|Eccl(?:esiastes)?|Song(?:\s?of\s?Sol(?:omon)?)?|Isa(?:iah)?|Jer(?:emiah)?|Lam(?:entations)?|Ezek(?:iel)?|Dan(?:iel)?|Hos(?:ea)?|Joel|Amos|Obad(?:iah)?|Jonah|Mic(?:ah)?|Nah(?:um)?|Hab(?:akkuk)?|Zeph(?:aniah)?|Hag(?:gai)?|Zech(?:ariah)?|Mal(?:achi)?|Matt(?:hew)?|Mark|Luke|John|Acts|Rom(?:ans)?|1\s?Cor(?:inthians)?|2\s?Cor(?:inthians)?|Gal(?:atians)?|Eph(?:esians)?|Phil(?:ippians)?|Col(?:ossians)?|1\s?Thess|2\s?Thess|1\s?Tim(?:othy)?|2\s?Tim(?:othy)?|Titus|Philem(?:on)?|Heb(?:rews)?|Jas(?:mes)?|1\s?Pet(?:er)?|2\s?Pet(?:er)?|1\s?John|2\s?John|3\s?John|Jude|Rev(?:elation)?)\.?\s+\d+:\d+(?:-\d+)?)\b/gi
const parts: string[] = []
let lastIndex = 0
let m: RegExpExecArray | null
// reset lastIndex for combined
combined.lastIndex = 0
while ((m = combined.exec(text)) !== null) {
if (m.index > lastIndex) parts.push(text.slice(lastIndex, m.index))
parts.push(m[0])
lastIndex = m.index + m[0].length
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex))
return parts.map((part, index) => {
if (/^https?:\/\//i.test(part)) {
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 parsed = parseScriptureRef(part.replace(/\.$/, ''))
if (parsed) {
return <ScriptureTooltip key={`scripture-${index}`} refText={part} bookId={parsed.bookId} chapter={parsed.chapter} verseStart={parsed.verseStart} verseEnd={parsed.verseEnd} />
}
return <span key={`text-${index}`}>{renderHighlightedText(part, highlightQuery)}</span>
})
}
interface VerseData {
verse: number
value: string
}
// Maps helloao API book IDs → bible.com book codes
const BIBLE_COM_BOOK_IDS: Record<string, string> = {
GEN: 'GEN', EXO: 'EXO', LEV: 'LEV', NUM: 'NUM', DEU: 'DEU', JOS: 'JOS', JDG: 'JDG', RUT: 'RUT',
SA1: '1SA', SA2: '2SA', KI1: '1KI', KI2: '2KI', CH1: '1CH', CH2: '2CH',
EZR: 'EZR', NEH: 'NEH', EST: 'EST', JOB: 'JOB', PSA: 'PSA', PRO: 'PRO', ECC: 'ECC', SNG: 'SNG',
ISA: 'ISA', JER: 'JER', LAM: 'LAM', EZK: 'EZK', DAN: 'DAN', HOS: 'HOS', JOL: 'JOL', AMO: 'AMO',
OBA: 'OBA', JNA: 'JON', MIC: 'MIC', NAH: 'NAH', HAB: 'HAB', ZEP: 'ZEP', HAG: 'HAG', ZEC: 'ZEC', MAL: 'MAL',
MAT: 'MAT', MRK: 'MRK', LUK: 'LUK', JHN: 'JHN', ACT: 'ACT', ROM: 'ROM',
CO1: '1CO', CO2: '2CO', GAL: 'GAL', EPH: 'EPH', PHP: 'PHP', COL: 'COL',
TH1: '1TH', TH2: '2TH', TI1: '1TI', TI2: '2TI', TIT: 'TIT', PHM: 'PHM', HEB: 'HEB',
JAM: 'JAS', PE1: '1PE', PE2: '2PE', JO1: '1JN', JO2: '2JN', JO3: '3JN', JDE: 'JUD', REV: 'REV',
}
function ScriptureTooltip({ refText, bookId, chapter, verseStart, verseEnd }: {
refText: string
bookId: string
chapter: number
verseStart: number
verseEnd: number
}) {
const [open, setOpen] = useState(false)
const [verses, setVerses] = useState<VerseData[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
const wrapRef = useRef<HTMLSpanElement>(null)
const btnRef = useRef<HTMLButtonElement>(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])
// Compute fixed position from the trigger button so the popup escapes overflow:hidden parents
useEffect(() => {
if (!open || !btnRef.current) { setPos(null); return }
const rect = btnRef.current.getBoundingClientRect()
const POPUP_W = 360
const left = Math.max(8, Math.min(rect.left, window.innerWidth - POPUP_W - 12))
setPos({ top: rect.bottom + 8, left })
}, [open])
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false)
}
function handleDismiss() { setOpen(false) }
document.addEventListener('mousedown', handleClickOutside)
window.addEventListener('scroll', handleDismiss, { passive: true, capture: true })
window.addEventListener('resize', handleDismiss)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
window.removeEventListener('scroll', handleDismiss, { capture: true })
window.removeEventListener('resize', handleDismiss)
}
}, [open])
return (
<span ref={wrapRef} className="scripture-ref-wrap">
<button
ref={btnRef}
type="button"
className="scripture-ref"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
title={`View ${refText}`}
>
{refText}
</button>
{open && pos && (
<span
className="scripture-popup"
role="tooltip"
style={{ position: 'fixed', top: pos.top, left: pos.left, bottom: 'auto' }}
>
<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>
)
}
export function AnswerText({ text, highlight = '' }: { text: string; highlight?: string }) {
return <>{renderTextWithLinks(text, highlight)}</>
}