Add episode transcripts, expand chatbot Q&A database to 28 entries, and build scripts

This commit is contained in:
nmemmert
2026-04-13 10:29:06 -04:00
parent 6e8b360398
commit 063f5d2a0e
31 changed files with 2779 additions and 134 deletions
+638 -43
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import type { SiteContent, CustomLink, CustomBlock } from './App'
import { DEFAULTS } from './App'
@@ -54,21 +54,23 @@ interface AdminStats {
visitorStats: { ok: boolean; at: string | null; error: string | null }
backups: { ok: boolean; at: string | null; error: string | null; file: string | null }
}
bibleQuestions: Array<{
id: string
submittedAt: string
name: string
email: string
message: string
messageType: 'question' | 'testimony' | 'topic' | 'general'
subscribe: boolean
}>
contactTotals: {
totalSubmissions: number
totalQuestions: number
}
}
interface Question {
id: string
submittedAt: string
firstName: string
email: string
question: string
answer: string
answeredAt: string | null
isApproved: boolean
approvedAt: string | null
}
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks'>
const FIELDS: { key: StringField; label: string; multiline?: boolean }[] = [
@@ -92,7 +94,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [form, setForm] = useState<SiteContent>(content)
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [adminTab, setAdminTab] = useState<'content' | 'stats'>('content')
const [adminTab, setAdminTab] = useState<'content' | 'stats' | 'questions' | 'chatbot'>('content')
const [contentTab, setContentTab] = useState<'main' | 'custom'>('main')
const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
@@ -101,6 +103,40 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [selectedBackup, setSelectedBackup] = useState('')
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
const [questions, setQuestions] = useState<Question[]>([])
const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null)
// Chatbot knowledge base
interface ChatbotEntry {
id: string
type: 'qa' | 'topic' | 'episode'
title: string
content: string
sourceLabel?: string
priority?: boolean
keywords: string[]
createdAt: string
updatedAt: string
}
const [chatbotEntries, setChatbotEntries] = useState<ChatbotEntry[]>([])
const [chatbotDraft, setChatbotDraft] = useState<ChatbotEntry | null>(null)
const chatbotEditorRef = useRef<HTMLDivElement>(null)
const [chatbotSaving, setChatbotSaving] = useState(false)
const [chatbotMsg, setChatbotMsg] = useState('')
const [quickNoteMode, setQuickNoteMode] = useState<'new' | 'append'>('new')
const [quickNoteType, setQuickNoteType] = useState<ChatbotEntry['type']>('topic')
const [quickNoteTitle, setQuickNoteTitle] = useState('')
const [quickNoteContent, setQuickNoteContent] = useState('')
const [quickNoteKeywords, setQuickNoteKeywords] = useState('')
const [quickNoteTargetId, setQuickNoteTargetId] = useState('')
const [quickNoteSourceLabel, setQuickNoteSourceLabel] = useState('')
const [quickNotePriority, setQuickNotePriority] = useState(false)
useEffect(() => {
if (chatbotDraft !== null) {
setTimeout(() => chatbotEditorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 50)
}
}, [chatbotDraft !== null]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetch('/api/admin-stats')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats'))))
@@ -112,6 +148,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setStatsStatus('error')
})
fetch('/api/admin-questions')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
.then(data => {
setQuestions((data as { questions: Question[] }).questions ?? [])
})
.catch(() => {})
fetch('/api/admin-stats/backups')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load backups'))))
.then(data => {
@@ -356,6 +398,59 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}
}
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 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))
} catch {
alert('Failed to delete question')
}
}
return (
<div className="admin-page">
<div className="admin-header">
@@ -390,6 +485,32 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
>
Site Stats
</button>
<button
type="button"
role="tab"
aria-selected={adminTab === 'questions'}
className={`admin-tab ${adminTab === 'questions' ? 'admin-tab--active' : ''}`}
onClick={() => setAdminTab('questions')}
>
Questions ({questions.length})
</button>
<button
type="button"
role="tab"
aria-selected={adminTab === 'chatbot'}
className={`admin-tab ${adminTab === 'chatbot' ? 'admin-tab--active' : ''}`}
onClick={() => {
setAdminTab('chatbot')
if (chatbotEntries.length === 0) {
fetch('/api/admin/chatbot-content', { credentials: 'include' })
.then(r => r.ok ? r.json() : [])
.then(data => setChatbotEntries(Array.isArray(data) ? data : []))
.catch(() => {})
}
}}
>
Chatbot ({chatbotEntries.length})
</button>
</div>
{adminTab === 'stats' && (
@@ -570,8 +691,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</div>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Bible Questions Inbox</h2>
<p>Questions submitted from the contact form emails.</p>
<h2>Contact Summary</h2>
<p>Submission totals from the contact form.</p>
</div>
<div className="admin-stats-grid">
@@ -585,36 +706,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</article>
</div>
<div className="admin-visits-table-wrap">
<h3>Recent Bible Questions</h3>
{stats.bibleQuestions.length === 0 ? (
<p className="admin-stats-note">No Bible questions yet.</p>
) : (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table">
<thead>
<tr>
<th>Submitted</th>
<th>Name</th>
<th>Email</th>
<th>Question</th>
</tr>
</thead>
<tbody>
{stats.bibleQuestions.map(item => (
<tr key={item.id}>
<td>{formatDate(item.submittedAt)}</td>
<td>{item.name}</td>
<td>{item.email}</td>
<td>{item.message}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Data Management</h2>
<p>Export, backup, or retain only recent analytics data.</p>
@@ -849,7 +940,511 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<p className="admin-status admin-status--err"> {errorMsg}</p>
)}
</form>
)}
{adminTab === 'questions' && (
<section className="admin-questions" aria-label="Q&A Management">
<div className="admin-stats-head">
<h2>Bible Questions & Answers</h2>
<p>Manage submitted questions, provide answers, and approve for public display.</p>
</div>
{questions.length === 0 ? (
<p className="admin-stats-note">No questions submitted yet.</p>
) : (
<div className="admin-questions-list">
{questions.map(question => (
<div key={question.id} className="admin-question-card">
<div className="admin-question-header">
<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-remove"
onClick={() => handleDeleteQuestion(question.id)}
>
Delete
</button>
</div>
)}
</div>
))}
</div>
)}
</section>
)}
{adminTab === 'chatbot' && (() => {
const emptyDraft = (): ChatbotEntry => ({
id: '',
type: 'qa',
title: '',
content: '',
sourceLabel: '',
priority: false,
keywords: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
const extractVerseRefs = (text: string) => {
const matches = text.match(/\b(?:[1-3]\s)?[A-Z][a-z]+\s\d+:\d+(?:[-]\d+)?\b/g) ?? []
return [...new Set(matches.map(m => m.trim()))].slice(0, 8)
}
const deriveKeywords = (text: string, existing: string[] = []) => {
const words = text
.toLowerCase()
.replace(/[^a-z0-9:\s-]/g, ' ')
.split(/\s+/)
.filter(word => word.length > 3)
.filter(word => !['that', 'this', 'with', 'from', 'have', 'your', 'about', 'into', 'because'].includes(word))
return [...new Set([...existing, ...extractVerseRefs(text).map(v => v.toLowerCase()), ...words])].slice(0, 16)
}
const deriveTitleFromNotes = (text: string) => {
const firstMeaningfulLine = text.split(/\n+/).map(line => line.trim()).find(Boolean) ?? ''
if (firstMeaningfulLine.length > 0 && firstMeaningfulLine.length <= 80) return firstMeaningfulLine
const verse = extractVerseRefs(text)[0]
if (verse) return `${verse} Notes`
return 'New Notes'
}
const populateQuickNoteFromRaw = () => {
if (!quickNoteContent.trim()) return
if (!quickNoteTitle.trim()) setQuickNoteTitle(deriveTitleFromNotes(quickNoteContent))
if (!quickNoteSourceLabel.trim()) {
const verse = extractVerseRefs(quickNoteContent)[0]
if (verse) setQuickNoteSourceLabel(verse)
}
if (!quickNoteKeywords.trim()) {
setQuickNoteKeywords(deriveKeywords(quickNoteContent).join(', '))
}
}
const saveChatbot = (entries: ChatbotEntry[]) => {
setChatbotSaving(true)
fetch('/api/admin/chatbot-content', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entries),
})
.then(r => r.ok ? r.json() : Promise.reject(new Error('Save failed')))
.then(() => {
setChatbotEntries(entries)
setChatbotMsg('Saved!')
setTimeout(() => setChatbotMsg(''), 2500)
})
.catch(() => setChatbotMsg('Error saving — try again.'))
.finally(() => setChatbotSaving(false))
}
const deleteEntry = (id: string) => {
const next = chatbotEntries.filter(e => e.id !== id)
saveChatbot(next)
}
const parseKeywords = (raw: string) => raw
.split(',')
.map(k => k.trim().toLowerCase())
.filter(Boolean)
const saveQuickNote = () => {
const content = quickNoteContent.trim()
if (!content) return
const keywordList = parseKeywords(quickNoteKeywords)
if (quickNoteMode === 'append' && quickNoteTargetId) {
const existing = chatbotEntries.find(e => e.id === quickNoteTargetId)
if (!existing) return
const next = chatbotEntries.map(e => {
if (e.id !== quickNoteTargetId) return e
return {
...e,
content: `${e.content.trim()}\n\n${content}`,
keywords: [...new Set([...e.keywords, ...keywordList])],
priority: quickNotePriority || e.priority,
sourceLabel: quickNoteSourceLabel.trim() || e.sourceLabel || '',
updatedAt: new Date().toISOString(),
}
})
saveChatbot(next)
setQuickNoteContent('')
setQuickNoteKeywords('')
setQuickNoteSourceLabel('')
setQuickNotePriority(false)
return
}
const title = quickNoteTitle.trim() || deriveTitleFromNotes(quickNoteContent)
if (!title) return
const entry: ChatbotEntry = {
id: crypto.randomUUID(),
type: quickNoteType,
title,
content,
sourceLabel: quickNoteSourceLabel.trim(),
priority: quickNotePriority,
keywords: keywordList.length > 0 ? keywordList : deriveKeywords(`${title} ${content}`),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
saveChatbot([...chatbotEntries, entry])
setQuickNoteTitle('')
setQuickNoteContent('')
setQuickNoteKeywords('')
setQuickNoteSourceLabel('')
setQuickNotePriority(false)
}
const saveEntry = (draft: ChatbotEntry) => {
const isNew = !draft.id
const entry: ChatbotEntry = {
...draft,
id: draft.id || crypto.randomUUID(),
sourceLabel: typeof draft.sourceLabel === 'string' ? draft.sourceLabel.trim() : '',
priority: draft.priority === true,
keywords: typeof (draft.keywords as unknown) === 'string'
? (draft.keywords as unknown as string).split(',').map(k => k.trim().toLowerCase()).filter(Boolean)
: (Array.isArray(draft.keywords) ? draft.keywords : []).filter(Boolean),
updatedAt: new Date().toISOString(),
createdAt: draft.createdAt || new Date().toISOString(),
}
const next = isNew
? [...chatbotEntries, entry]
: chatbotEntries.map(e => e.id === entry.id ? entry : e)
saveChatbot(next)
setChatbotDraft(null)
}
const TYPE_LABELS: Record<ChatbotEntry['type'], string> = { qa: 'Q&A', topic: 'Topic', episode: 'Episode' }
const sortedChatbotEntries = [...chatbotEntries].sort((a, b) => {
if ((a.priority === true) !== (b.priority === true)) return a.priority === true ? -1 : 1
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
})
return (
<section className="admin-chatbot" aria-label="Chatbot Knowledge Base">
<div className="admin-stats-head">
<h2>Chatbot Knowledge Base</h2>
<p className="admin-stats-sub">
Entries the floating chatbot uses to answer visitor questions. Add Q&amp;A pairs, topic summaries, or episode notes. The chatbot fuzzy-matches visitor questions against these entries.
</p>
</div>
{chatbotMsg && <p className="admin-save-msg">{chatbotMsg}</p>}
<div className="admin-chatbot-actions">
<button
type="button"
className="btn-admin-primary"
onClick={() => setChatbotDraft(emptyDraft())}
disabled={!!chatbotDraft}
>
+ Add Entry
</button>
</div>
<div className="admin-chatbot-editor">
<h3>Quick Add Notes</h3>
<label>
Save Mode
<select
value={quickNoteMode}
onChange={e => setQuickNoteMode(e.target.value as 'new' | 'append')}
>
<option value="new">Create New Entry</option>
<option value="append">Append to Existing Entry</option>
</select>
</label>
{quickNoteMode === 'append' ? (
<label>
Append To
<select
value={quickNoteTargetId}
onChange={e => setQuickNoteTargetId(e.target.value)}
>
<option value="">Select an entry...</option>
{chatbotEntries.map(entry => (
<option key={entry.id} value={entry.id}>{entry.title}</option>
))}
</select>
</label>
) : (
<>
<label>
Type
<select
value={quickNoteType}
onChange={e => setQuickNoteType(e.target.value as ChatbotEntry['type'])}
>
<option value="qa">Q&amp;A</option>
<option value="topic">Topic Summary</option>
<option value="episode">Episode Notes</option>
</select>
</label>
<label>
Title
<input
type="text"
value={quickNoteTitle}
onChange={e => setQuickNoteTitle(e.target.value)}
placeholder="e.g. Titus 2:11-12 Grace Trains Us"
/>
</label>
</>
)}
<label>
Source Label <span className="admin-field-hint">(optional)</span>
<input
type="text"
value={quickNoteSourceLabel}
onChange={e => setQuickNoteSourceLabel(e.target.value)}
placeholder="e.g. Episode 8, Titus 2:11-12"
/>
</label>
<label>
Notes
<textarea
rows={5}
value={quickNoteContent}
onChange={e => setQuickNoteContent(e.target.value)}
placeholder="Paste your latest notes here..."
/>
</label>
<label>
Keywords <span className="admin-field-hint">(comma-separated, optional)</span>
<input
type="text"
value={quickNoteKeywords}
onChange={e => setQuickNoteKeywords(e.target.value)}
placeholder="e.g. titus, grace, discipleship"
/>
</label>
<label>
<input
type="checkbox"
checked={quickNotePriority}
onChange={e => setQuickNotePriority(e.target.checked)}
/>{' '}
Pin this as a priority answer
</label>
<div className="admin-chatbot-editor-btns">
<button
type="button"
className="btn-admin-reset"
onClick={populateQuickNoteFromRaw}
>
Suggest Title & Keywords
</button>
<button
type="button"
className="btn-admin-primary"
disabled={
chatbotSaving ||
!quickNoteContent.trim() ||
(quickNoteMode === 'new' && !quickNoteTitle.trim()) ||
(quickNoteMode === 'append' && !quickNoteTargetId)
}
onClick={saveQuickNote}
>
{chatbotSaving ? 'Saving…' : quickNoteMode === 'append' ? 'Append Notes' : 'Add Note Entry'}
</button>
</div>
</div>
{chatbotDraft && (
<div className="admin-chatbot-editor" ref={chatbotEditorRef}>
<h3>{chatbotDraft.id ? 'Edit Entry' : 'New Entry'}</h3>
<label>
Type
<select
value={chatbotDraft.type}
onChange={e => setChatbotDraft({ ...chatbotDraft, type: e.target.value as ChatbotEntry['type'] })}
>
<option value="qa">Q&amp;A</option>
<option value="topic">Topic Summary</option>
<option value="episode">Episode Notes</option>
</select>
</label>
<label>
{chatbotDraft.type === 'qa' ? 'Question' : 'Title'}
<input
type="text"
value={chatbotDraft.title}
onChange={e => setChatbotDraft({ ...chatbotDraft, title: e.target.value })}
placeholder={chatbotDraft.type === 'qa' ? 'e.g. What Bible translation do you use?' : 'e.g. The Sermon on the Mount'}
/>
</label>
<label>
{chatbotDraft.type === 'qa' ? 'Answer' : 'Content / Summary'}
<textarea
rows={5}
value={chatbotDraft.content}
onChange={e => setChatbotDraft({ ...chatbotDraft, content: e.target.value })}
placeholder="Write the answer or summary here..."
/>
</label>
<label>
Source Label <span className="admin-field-hint">(optional)</span>
<input
type="text"
value={chatbotDraft.sourceLabel ?? ''}
onChange={e => setChatbotDraft({ ...chatbotDraft, sourceLabel: e.target.value })}
placeholder="e.g. Episode 12, Titus 3:4-7"
/>
</label>
<label>
Keywords <span className="admin-field-hint">(comma-separated, optional)</span>
<input
type="text"
value={Array.isArray(chatbotDraft.keywords) ? chatbotDraft.keywords.join(', ') : chatbotDraft.keywords}
onChange={e => setChatbotDraft({ ...chatbotDraft, keywords: e.target.value as unknown as string[] })}
placeholder="e.g. bible, translation, ESV, study"
/>
</label>
<label>
<input
type="checkbox"
checked={chatbotDraft.priority === true}
onChange={e => setChatbotDraft({ ...chatbotDraft, priority: e.target.checked })}
/>{' '}
Pin this as a priority answer
</label>
<div className="admin-chatbot-editor-btns">
<button
type="button"
className="btn-admin-primary"
disabled={!chatbotDraft.title.trim() || !chatbotDraft.content.trim() || chatbotSaving}
onClick={() => saveEntry(chatbotDraft)}
>
{chatbotSaving ? 'Saving…' : 'Save Entry'}
</button>
<button type="button" className="btn-admin-reset" onClick={() => setChatbotDraft(null)}>
Cancel
</button>
</div>
</div>
)}
{chatbotEntries.length === 0 ? (
<p className="admin-empty-msg">No entries yet. Add your first one above.</p>
) : (
<div className="admin-chatbot-list">
{sortedChatbotEntries.map(entry => (
<div key={entry.id} className="admin-chatbot-card">
<div className="admin-chatbot-card-head">
<span className={`admin-badge admin-badge--${entry.type}`}>{TYPE_LABELS[entry.type]}</span>
<span className="admin-chatbot-title">{entry.title}</span>
{entry.priority === true && <span className="admin-badge admin-badge--topic">Priority</span>}
</div>
{entry.sourceLabel && <p className="admin-chatbot-keywords">Source: {entry.sourceLabel}</p>}
<p className="admin-chatbot-preview">{entry.content.slice(0, 180)}{entry.content.length > 180 ? '…' : ''}</p>
{entry.keywords.length > 0 && (
<p className="admin-chatbot-keywords">🏷 {entry.keywords.join(', ')}</p>
)}
<div className="admin-question-actions">
<button
type="button"
className="btn-admin-reset"
onClick={() => setChatbotDraft({ ...entry, keywords: entry.keywords })}
disabled={!!chatbotDraft}
>
Edit
</button>
<button
type="button"
className="btn-admin-remove"
onClick={() => deleteEntry(entry.id)}
disabled={chatbotSaving}
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</section>
)
})()}
</div>
</div>
)