Add episode transcripts, expand chatbot Q&A database to 28 entries, and build scripts
This commit is contained in:
+638
-43
@@ -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&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&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&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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user