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

+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>
)
}