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 => (!hasAnswer(q) ? 'new' : q.isApproved ? 'published' : 'ready') const TAB_LABELS: Record = { 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(counts.new > 0 ? 'new' : 'all') const [search, setSearch] = useState('') const [selectedId, setSelectedId] = useState('') const [checked, setChecked] = useState>(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() 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>) => { 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) => { 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 (

Bible Questions & Answers

Work through questions from the top: write an answer, set a topic, and publish. Published answers appear on the public Q&A page right away. Pinned questions show first there and are featured on the home page.

{(Object.keys(TAB_LABELS) as Tab[]).map(t => ( ))}
{message &&

{message}

} {adding && (

Add a question

setManual({ ...manual, firstName: e.target.value })} />
setManual({ ...manual, email: e.target.value })} />