Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 287fa72462 |
@@ -57,50 +57,6 @@ Admin auth:
|
|||||||
|
|
||||||
The app will be available at `http://localhost:4173`.
|
The app will be available at `http://localhost:4173`.
|
||||||
|
|
||||||
## Optional local LLM (Ollama) for grounded rewrites
|
|
||||||
|
|
||||||
You can keep deterministic retrieval as the source-of-truth and optionally rewrite responses with a local model.
|
|
||||||
|
|
||||||
1. Install and run Ollama on your host.
|
|
||||||
2. Pull a small model suited to older hardware, for example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ollama pull qwen2.5:3b-instruct
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Start the API with these environment variables:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CHATBOT_LLM_ENABLED=true
|
|
||||||
CHATBOT_LLM_BASE_URL=http://127.0.0.1:11434
|
|
||||||
CHATBOT_LLM_MODEL=qwen2.5:3b-instruct
|
|
||||||
CHATBOT_LLM_TIMEOUT_MS=25000
|
|
||||||
CHATBOT_LLM_NUM_CTX=2048
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Call the rewrite endpoint from your existing chat flow:
|
|
||||||
|
|
||||||
`POST /api/chatbot-grounded-rewrite`
|
|
||||||
|
|
||||||
Request payload shape:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"question": "Who was Titus?",
|
|
||||||
"draftAnswer": "Deterministic answer produced by current retrieval/synthesis.",
|
|
||||||
"sources": ["Episode 2 - Introduction to Titus"],
|
|
||||||
"contextChunks": [
|
|
||||||
{
|
|
||||||
"title": "Episode 2 - Introduction to Titus",
|
|
||||||
"sourceLabel": "Episode 2",
|
|
||||||
"content": "Titus was a Gentile..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If the endpoint fails or is disabled, keep your deterministic answer and existing fallback behavior.
|
|
||||||
|
|
||||||
Persistent admin saves:
|
Persistent admin saves:
|
||||||
|
|
||||||
- Admin updates are written to `data/admin-content.json`.
|
- Admin updates are written to `data/admin-content.json`.
|
||||||
|
|||||||
@@ -9,13 +9,16 @@
|
|||||||
"seriesLabel": "Now Playing",
|
"seriesLabel": "Now Playing",
|
||||||
"seriesTitle": "Study of Titus: Sound Doctrine",
|
"seriesTitle": "Study of Titus: Sound Doctrine",
|
||||||
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
||||||
|
"seriesImageUrl": "/images/titus-cover.png",
|
||||||
|
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
|
||||||
"studyGuideTitle": "Companion Study Guide",
|
"studyGuideTitle": "Companion Study Guide",
|
||||||
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
|
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
|
||||||
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
|
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
|
||||||
"shareHeading": "Know someone who'd love this?",
|
"shareHeading": "Help one more person hear the Word this week.",
|
||||||
"shareP": "Scan the QR code or share the link to introduce a friend to Verse by Verse with Nate.",
|
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
|
||||||
"customLinks": [],
|
"customLinks": [],
|
||||||
"customBlocks": []
|
"customBlocks": [],
|
||||||
|
"archivedSeries": []
|
||||||
},
|
},
|
||||||
"updatedAt": "2026-04-09T00:00:00.000Z"
|
"updatedAt": "2026-04-16T12:13:53.095Z"
|
||||||
}
|
}
|
||||||
+237
-440
File diff suppressed because one or more lines are too long
+98
-1
@@ -1 +1,98 @@
|
|||||||
[]
|
[
|
||||||
|
{
|
||||||
|
"id": "cb_001",
|
||||||
|
"firstName": "Nate",
|
||||||
|
"question": "What Bible translation do you use?",
|
||||||
|
"answer": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.",
|
||||||
|
"isApproved": true,
|
||||||
|
"topic": "Bible Study"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cb_002",
|
||||||
|
"firstName": "Nate",
|
||||||
|
"question": "How do you approach a difficult or confusing Bible passage?",
|
||||||
|
"answer": "I always start by reading the surrounding context — a confusing verse often makes perfect sense once you zoom out a chapter or two. Then I look at who the author was, who they were writing to, and what was happening historically. A good study Bible or a commentary like Matthew Henry's can be a huge help. Most importantly, pray for understanding before you dive in. The Holy Spirit is your best guide.",
|
||||||
|
"isApproved": true,
|
||||||
|
"topic": "Bible Study"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cb_003",
|
||||||
|
"firstName": "Nate",
|
||||||
|
"question": "How do I stay consistent with daily Bible reading?",
|
||||||
|
"answer": "Start small and protect that time like an appointment. Even 10 minutes in the morning before checking your phone can be transformative over a year. A reading plan helps a lot — there are great ones on apps like YouVersion that break the whole Bible into daily chunks so you never have to decide what to read. And give yourself grace on the days you miss; guilt is not a good motivator. Just pick back up where you left off.",
|
||||||
|
"isApproved": true,
|
||||||
|
"topic": "Bible Study"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cb_007",
|
||||||
|
"firstName": "Nate",
|
||||||
|
"question": "How do I submit a question to Nate?",
|
||||||
|
"answer": "You can submit a question using the contact form at the bottom of this page. Select 'Bible Question' as the message type. Nate reads every question personally and answers the best ones on the site's Q&A section or in a future episode. There's no guarantee every question gets a public response, but all are read and appreciated!",
|
||||||
|
"isApproved": true,
|
||||||
|
"topic": "Podcast"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cb_faith_family_001",
|
||||||
|
"firstName": "Nate",
|
||||||
|
"question": "How can I share my faith with skeptical family?",
|
||||||
|
"answer": "Lead with relationship, not argument. Skeptical family members usually don't need more information — they need to see the faith lived out genuinely. Consistency, love, and patience over time speaks louder than any debate win.\n\nPray specifically and persistently. This is the most underrated strategy. God moves hearts in ways arguments never can.",
|
||||||
|
"isApproved": true,
|
||||||
|
"topic": "Faith & Life"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cb_blessed_hope_001",
|
||||||
|
"firstName": "Nate",
|
||||||
|
"question": "What is the blessed hope?",
|
||||||
|
"answer": "The blessed hope is a phrase from Titus 2:13 — \"as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ.\" In Episode 9, Nate breaks down the Greek: makaria elpis. Makaria is the same word used in the Beatitudes — a divinely favored condition. And elpis in the New Testament is not wishful thinking. Biblical hope is a confident expectation of something certain. Not a wish. An anchor.\n\nThe blessed hope is simply this: Jesus is coming back. Not maybe. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today.",
|
||||||
|
"isApproved": true,
|
||||||
|
"topic": "Titus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cb_who_is_titus_001",
|
||||||
|
"firstName": "Nate",
|
||||||
|
"question": "Who was Titus?",
|
||||||
|
"answer": "Titus was a Gentile — not Jewish — who came to faith through Paul's ministry. Paul calls him \"my true child in our common faith\" (Titus 1:4). He was one of Paul's closest and most trusted co-workers. In 2 Corinthians 7, Paul describes being comforted by Titus arriving during a difficult season. In chapter 8, Titus helped organize a relief offering for believers in Jerusalem. In Galatians 2, he appears as proof that the gospel is for everyone.\n\nPaul left Titus on the island of Crete with a difficult assignment: appoint elders, correct false teaching, and help a young church find its footing in a challenging culture. The letter of Titus is essentially his briefing.",
|
||||||
|
"isApproved": true,
|
||||||
|
"topic": "Titus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cb_deny_works_001",
|
||||||
|
"firstName": "Nate",
|
||||||
|
"question": "What does it mean to deny God by your works?",
|
||||||
|
"answer": "Titus 1:16 says: \"They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed.\"\n\nIn Episode 6, Nate explains this using three precise words Paul chose. Detestable — morally repulsive before God, regardless of religious appearance. Disobedient — not persuadable, resistant to truth even when confronted with it. Unfit — a Greek metalworking term for metal tested and found counterfeit. Religious on the surface, but no real substance underneath.\n\nThe warning cuts both ways: a person can know the right words, attend the right services, hold the right opinions — and still live in a way that functionally says God doesn't matter. Theology and life are inseparable. What you actually believe will show itself in how you actually live.",
|
||||||
|
"isApproved": true,
|
||||||
|
"topic": "Titus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cb_grace_trains_001",
|
||||||
|
"firstName": "Nate",
|
||||||
|
"question": "What does grace actually train us to do?",
|
||||||
|
"answer": "Titus 2:11-12 says grace \"instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives.\"\n\nIn Episode 8, Nate points out that the Greek word for instructs is paideuō — the education of a child. Not classroom instruction but formation. Grace is the subject doing the teaching.\n\nGrace teaches two things: what to say no to (ungodliness and worldly passions) and what to say yes to — sensible (an ordered mind), upright (right in relationship with others), and godly (God genuinely at the center). Inward, outward, and upward.\n\nThe key insight: the pathway to saying no to sin is going deeper into grace, not trying harder. Grace properly understood reorients your desires — it doesn't just change the rules, it changes what you want. Grace is not a hall pass. It's a school you attend every day.",
|
||||||
|
"isApproved": true,
|
||||||
|
"topic": "Titus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cb_leadership_titus1_001",
|
||||||
|
"firstName": "Nate",
|
||||||
|
"question": "What does Titus 1 teach about church leadership?",
|
||||||
|
"answer": "Paul's first instruction in Titus was to appoint elders — because a church without mature, qualified leadership is vulnerable. From Episode 4 (Titus 1:5-9):\n\nThe elder's home is exhibit A. Before looking at preaching or public presence, look at the household. If a man cannot lead his own family well, there's no reason to believe he'll lead God's family well. Integrity can't be compartmentalized.\n\nPaul gives five things an elder must NOT be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy. And six things he must BE: hospitable, a lover of good, self-controlled, upright, holy, and disciplined in sound doctrine.\n\nThis is not a job description for a CEO. Nate frames it as a picture of Christlike character — authority that flows from below, from a posture of submission and service, not dominance.",
|
||||||
|
"isApproved": true,
|
||||||
|
"topic": "Titus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cb_titus347_summary_001",
|
||||||
|
"firstName": "Nate",
|
||||||
|
"question": "Give me a summary of Titus 3:4-7",
|
||||||
|
"answer": "Titus 3:4-7 is what Nate calls 'the gospel in one paragraph' — one of the most concentrated summaries of salvation anywhere in Paul's letters. Episode 12 breaks it down:\n\nAll three persons of the Trinity are present: God the Father is the initiating motivation — He is kind and full of love toward humanity. The Holy Spirit is the agent of new birth and ongoing renewal. Jesus Christ is the one through whom the Spirit is poured out, and verse 7 calls him 'our Savior.'\n\nThe hinge of the passage is verse 5: He saved us not by the righteous deeds we had done, but according to His mercy. Not partially. Not mostly. The basis is His mercy, not our merit — which means salvation rests on something that cannot change.\n\nVerse 7 gives us two results: justified (declared righteous in God's courtroom) and heirs (members of the family with a guaranteed inheritance). You are not just legally acquitted — you belong.",
|
||||||
|
"isApproved": true,
|
||||||
|
"topic": "Titus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cb_christians_politics_001",
|
||||||
|
"firstName": "Nate",
|
||||||
|
"question": "How should Christians engage with politics and public life?",
|
||||||
|
"answer": "Titus 3:1-2 is Paul's answer, covered in Episode 10. He tells Titus to remind believers to submit to rulers and authorities, be obedient, be ready for every good work, malign no one, and be peaceable, gentle, and considerate to everyone.\n\nNate's key insight: Paul is not saying agree with every government decision. He's saying your engagement with public life should be marked by something recognizable as different. Submission, readiness to do good, and a peaceable presence are not just civic virtues — they are missionary strategies. The way you navigate public life either opens doors for the gospel or closes them.\n\nThe word for peaceable in Greek is amachous — literally non-combative. Paul doesn't say malign no one except your political opponents. He says no one. This is not weakness. It is the confidence of people who know how the story ends and don't need to win every argument to prove it.",
|
||||||
|
"isApproved": true,
|
||||||
|
"topic": "Faith & Life"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import { execFileSync } from 'node:child_process'
|
|
||||||
import { randomUUID } from 'node:crypto'
|
|
||||||
import { promises as fs } from 'node:fs'
|
|
||||||
import path from 'node:path'
|
|
||||||
|
|
||||||
const ROOT = '/Users/nate.emmert/Documents/github/Siteforge'
|
|
||||||
const DOCS_DIR = path.join(ROOT, 'Verse by Verse with Nate Complete Series')
|
|
||||||
const CHATBOT_FILE = path.join(ROOT, 'data', 'chatbot-content.json')
|
|
||||||
|
|
||||||
const STOP_WORDS = new Set([
|
|
||||||
'the', 'and', 'for', 'that', 'with', 'this', 'from', 'your', 'you', 'are', 'but', 'not', 'have',
|
|
||||||
'has', 'was', 'were', 'his', 'her', 'our', 'their', 'into', 'about', 'what', 'when', 'where',
|
|
||||||
'which', 'will', 'just', 'they', 'them', 'then', 'than', 'how', 'why', 'can', 'all', 'through',
|
|
||||||
])
|
|
||||||
|
|
||||||
function parseEpisodeNumber(filePath) {
|
|
||||||
const match = path.basename(filePath).match(/Episode(\d+)/i)
|
|
||||||
return match ? Number(match[1]) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVariantRank(filePath) {
|
|
||||||
const name = path.basename(filePath).toLowerCase()
|
|
||||||
let score = 0
|
|
||||||
if (name.includes('expanded')) score += 30
|
|
||||||
if (name.includes('updated')) score += 20
|
|
||||||
if (!name.includes('expanded') && !name.includes('updated')) score += 10
|
|
||||||
if (filePath.includes(`${path.sep}Done${path.sep}Old${path.sep}`)) score -= 25
|
|
||||||
return score
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectDocxFiles(dir) {
|
|
||||||
const out = []
|
|
||||||
const items = await fs.readdir(dir, { withFileTypes: true })
|
|
||||||
for (const item of items) {
|
|
||||||
const fullPath = path.join(dir, item.name)
|
|
||||||
if (item.isDirectory()) {
|
|
||||||
out.push(...await collectDocxFiles(fullPath))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!item.isFile()) continue
|
|
||||||
if (!item.name.toLowerCase().endsWith('.docx')) continue
|
|
||||||
if (item.name.startsWith('~$')) continue
|
|
||||||
out.push(fullPath)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickBestPerEpisode(docxFiles) {
|
|
||||||
const byEpisode = new Map()
|
|
||||||
|
|
||||||
for (const filePath of docxFiles) {
|
|
||||||
const episode = parseEpisodeNumber(filePath)
|
|
||||||
if (!episode) continue
|
|
||||||
|
|
||||||
const current = byEpisode.get(episode)
|
|
||||||
const next = {
|
|
||||||
filePath,
|
|
||||||
episode,
|
|
||||||
rank: getVariantRank(filePath),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!current || next.rank > current.rank) {
|
|
||||||
byEpisode.set(episode, next)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...byEpisode.values()].sort((a, b) => a.episode - b.episode)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractDocText(filePath) {
|
|
||||||
const output = execFileSync('textutil', ['-convert', 'txt', '-stdout', filePath], { encoding: 'utf8' })
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeContent(text) {
|
|
||||||
const lines = text
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map(line => line.replace(/\s+/g, ' ').trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
const filtered = lines.filter(line => {
|
|
||||||
const upper = line.toUpperCase()
|
|
||||||
if (upper === 'VERSE BY VERSE WITH NATE') return false
|
|
||||||
if (upper === 'A JOURNEY THROUGH SCRIPTURE') return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
return filtered.join(' ').replace(/\s{2,}/g, ' ').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildKeywords(title, content, existingKeywords = []) {
|
|
||||||
const tokens = `${title} ${content.slice(0, 1600)}`
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9\s:-]/g, ' ')
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(token => token.length >= 3 && !STOP_WORDS.has(token))
|
|
||||||
|
|
||||||
const counts = new Map()
|
|
||||||
for (const token of tokens) {
|
|
||||||
counts.set(token, (counts.get(token) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const top = [...counts.entries()]
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 20)
|
|
||||||
.map(([token]) => token)
|
|
||||||
|
|
||||||
return [...new Set([...(existingKeywords ?? []), ...top])].slice(0, 25)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEpisodeFromTitle(title = '') {
|
|
||||||
const match = title.match(/Episode\s+(\d+)/i)
|
|
||||||
return match ? Number(match[1]) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEntryTitleFallback(episodeNumber, rawText, existingTitle) {
|
|
||||||
if (existingTitle && existingTitle.trim()) return existingTitle
|
|
||||||
|
|
||||||
const lineMatch = rawText.match(new RegExp(`EPISODE\\s+${episodeNumber}\\s*[—-]\\s*([^\\n]+)`, 'i'))
|
|
||||||
if (lineMatch) {
|
|
||||||
return `Episode ${episodeNumber} — ${lineMatch[1].trim()}`
|
|
||||||
}
|
|
||||||
return `Episode ${episodeNumber}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const raw = await fs.readFile(CHATBOT_FILE, 'utf8')
|
|
||||||
const entries = JSON.parse(raw)
|
|
||||||
|
|
||||||
const docxFiles = await collectDocxFiles(DOCS_DIR)
|
|
||||||
const selected = pickBestPerEpisode(docxFiles)
|
|
||||||
|
|
||||||
const existingByEpisode = new Map()
|
|
||||||
for (const entry of entries) {
|
|
||||||
const episode = getEpisodeFromTitle(entry.title)
|
|
||||||
if (episode) existingByEpisode.set(episode, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
let updated = 0
|
|
||||||
let added = 0
|
|
||||||
|
|
||||||
for (const item of selected) {
|
|
||||||
const rawText = extractDocText(item.filePath)
|
|
||||||
const content = normalizeContent(rawText)
|
|
||||||
if (!content) continue
|
|
||||||
|
|
||||||
const existing = existingByEpisode.get(item.episode)
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
existing.type = 'episode'
|
|
||||||
existing.title = getEntryTitleFallback(item.episode, rawText, existing.title)
|
|
||||||
existing.content = content
|
|
||||||
existing.keywords = buildKeywords(existing.title, content, existing.keywords)
|
|
||||||
existing.updatedAt = now
|
|
||||||
updated += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.push({
|
|
||||||
id: randomUUID(),
|
|
||||||
type: 'episode',
|
|
||||||
title: getEntryTitleFallback(item.episode, rawText, ''),
|
|
||||||
content,
|
|
||||||
keywords: buildKeywords(`Episode ${item.episode}`, content, []),
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
added += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.sort((a, b) => {
|
|
||||||
const aEp = getEpisodeFromTitle(a.title)
|
|
||||||
const bEp = getEpisodeFromTitle(b.title)
|
|
||||||
if (aEp && bEp) return aEp - bEp
|
|
||||||
if (aEp && !bEp) return 1
|
|
||||||
if (!aEp && bEp) return -1
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
await fs.writeFile(CHATBOT_FILE, `${JSON.stringify(entries, null, 2)}\n`)
|
|
||||||
|
|
||||||
console.log(`Episodes selected from docs: ${selected.length}`)
|
|
||||||
console.log(`Updated entries: ${updated}`)
|
|
||||||
console.log(`Added entries: ${added}`)
|
|
||||||
for (const item of selected) {
|
|
||||||
console.log(`- Episode ${item.episode}: ${path.relative(ROOT, item.filePath)}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch(error => {
|
|
||||||
console.error(error)
|
|
||||||
process.exitCode = 1
|
|
||||||
})
|
|
||||||
@@ -55,15 +55,6 @@ const BACKUP_RETENTION_DAYS = 30
|
|||||||
const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000
|
const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||||
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'change-me-admin-password'
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'change-me-admin-password'
|
||||||
const CHATBOT_LLM_ENABLED = process.env.CHATBOT_LLM_ENABLED === 'true'
|
|
||||||
const CHATBOT_LLM_BASE_URL = process.env.CHATBOT_LLM_BASE_URL ?? 'http://127.0.0.1:11434'
|
|
||||||
const CHATBOT_LLM_MODEL = process.env.CHATBOT_LLM_MODEL ?? 'qwen2.5:3b-instruct'
|
|
||||||
const CHATBOT_LLM_TIMEOUT_MS = Number(process.env.CHATBOT_LLM_TIMEOUT_MS) > 0
|
|
||||||
? Number(process.env.CHATBOT_LLM_TIMEOUT_MS)
|
|
||||||
: 25000
|
|
||||||
const CHATBOT_LLM_NUM_CTX = Number(process.env.CHATBOT_LLM_NUM_CTX) > 0
|
|
||||||
? Number(process.env.CHATBOT_LLM_NUM_CTX)
|
|
||||||
: 2048
|
|
||||||
|
|
||||||
const EMPTY_VISITOR_STATS = {
|
const EMPTY_VISITOR_STATS = {
|
||||||
totalVisits: 0,
|
totalVisits: 0,
|
||||||
@@ -89,32 +80,6 @@ let lastVisitorStatsWrite = { ok: true, at: null, error: null }
|
|||||||
let lastHitStatsWrite = { ok: true, at: null, error: null }
|
let lastHitStatsWrite = { ok: true, at: null, error: null }
|
||||||
let lastBackupStatus = { ok: true, at: null, error: null, file: null }
|
let lastBackupStatus = { ok: true, at: null, error: null, file: null }
|
||||||
const adminSessions = new Map()
|
const adminSessions = new Map()
|
||||||
const llmResponseCache = new Map()
|
|
||||||
const MAX_CACHE_SIZE = 500
|
|
||||||
const CACHE_TTL_MS = 60 * 60 * 1000
|
|
||||||
|
|
||||||
function getCacheKey(question, draftAnswer) {
|
|
||||||
return sha256(`${question}||${draftAnswer}`).slice(0, 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCachedResponse(cacheKey) {
|
|
||||||
const cached = llmResponseCache.get(cacheKey)
|
|
||||||
if (cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
|
||||||
return cached.text
|
|
||||||
}
|
|
||||||
if (cached) {
|
|
||||||
llmResponseCache.delete(cacheKey)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCachedResponse(cacheKey, text) {
|
|
||||||
if (llmResponseCache.size >= MAX_CACHE_SIZE) {
|
|
||||||
const firstKey = llmResponseCache.keys().next().value
|
|
||||||
if (firstKey) llmResponseCache.delete(firstKey)
|
|
||||||
}
|
|
||||||
llmResponseCache.set(cacheKey, { text, at: Date.now() })
|
|
||||||
}
|
|
||||||
|
|
||||||
function sha256(value) {
|
function sha256(value) {
|
||||||
return createHash('sha256').update(value).digest('hex')
|
return createHash('sha256').update(value).digest('hex')
|
||||||
@@ -819,188 +784,12 @@ async function refreshChatbotFromDiskIfChanged() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeChatbotLlmContext(chunks) {
|
|
||||||
if (!Array.isArray(chunks)) return []
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
.filter(chunk => chunk && typeof chunk.content === 'string')
|
|
||||||
.slice(0, 4)
|
|
||||||
.map((chunk, index) => {
|
|
||||||
const title = typeof chunk.title === 'string' ? chunk.title.trim().slice(0, 160) : `Context ${index + 1}`
|
|
||||||
const sourceLabel = typeof chunk.sourceLabel === 'string' && chunk.sourceLabel.trim()
|
|
||||||
? chunk.sourceLabel.trim().slice(0, 200)
|
|
||||||
: title
|
|
||||||
const content = chunk.content.trim().slice(0, 1400)
|
|
||||||
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
sourceLabel,
|
|
||||||
content,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(chunk => chunk.content.length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildGroundedRewritePrompt({ question, draftAnswer, sources, contextChunks }) {
|
|
||||||
const sourceList = Array.isArray(sources) && sources.length > 0
|
|
||||||
? sources.slice(0, 6).map(source => `- ${String(source).slice(0, 220)}`).join('\n')
|
|
||||||
: '- No explicit source labels provided'
|
|
||||||
|
|
||||||
const contextBlock = contextChunks.length > 0
|
|
||||||
? contextChunks
|
|
||||||
.map((chunk, index) => (
|
|
||||||
`Context ${index + 1}: ${chunk.sourceLabel}\nTitle: ${chunk.title}\nExcerpt: ${chunk.content}`
|
|
||||||
))
|
|
||||||
.join('\n\n')
|
|
||||||
: 'No context excerpts were provided.'
|
|
||||||
|
|
||||||
return [
|
|
||||||
'You are a Bible study assistant helping rewrite responses to be clearer and more pastoral.',
|
|
||||||
'',
|
|
||||||
'HARD RULES:',
|
|
||||||
'1) Use ONLY facts from DRAFT ANSWER and CONTEXT EXCERPTS. Never add new information.',
|
|
||||||
'2) If the draft is uncertain or incomplete, preserve that. Do not fill gaps or speculate.',
|
|
||||||
'3) Do not invent verses, names, historical details, or theological claims.',
|
|
||||||
'4) Maintain a warm, encouraging pastoral tone—like Nate teaching directly.',
|
|
||||||
'5) Keep sentences clear and direct. Avoid jargon unless biblical.',
|
|
||||||
'6) Return plain text only. Use paragraph breaks but no markdown formatting.',
|
|
||||||
'',
|
|
||||||
'GOALS:',
|
|
||||||
'- Help the reader understand Scripture better',
|
|
||||||
'- Stay faithful to Nate\'s teaching and tone',
|
|
||||||
'- Be encouraging but honest about limitations',
|
|
||||||
'',
|
|
||||||
`QUESTION:\n${question}`,
|
|
||||||
'',
|
|
||||||
`DRAFT ANSWER:\n${draftAnswer}`,
|
|
||||||
'',
|
|
||||||
`SOURCES:\n${sourceList}`,
|
|
||||||
'',
|
|
||||||
`CONTEXT EXCERPTS:\n${contextBlock}`,
|
|
||||||
].join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rewriteWithChatbotLlm({ question, draftAnswer, sources, contextChunks }) {
|
|
||||||
const controller = new AbortController()
|
|
||||||
const timeout = setTimeout(() => controller.abort(), CHATBOT_LLM_TIMEOUT_MS)
|
|
||||||
const cacheKey = getCacheKey(question, draftAnswer)
|
|
||||||
const cached = getCachedResponse(cacheKey)
|
|
||||||
if (cached) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
|
||||||
const prompt = buildGroundedRewritePrompt({
|
|
||||||
question,
|
|
||||||
draftAnswer,
|
|
||||||
sources,
|
|
||||||
contextChunks,
|
|
||||||
})
|
|
||||||
|
|
||||||
const response = await fetch(`${CHATBOT_LLM_BASE_URL.replace(/\/$/, '')}/api/chat`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
signal: controller.signal,
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: CHATBOT_LLM_MODEL,
|
|
||||||
stream: false,
|
|
||||||
options: {
|
|
||||||
temperature: 0.2,
|
|
||||||
num_ctx: CHATBOT_LLM_NUM_CTX,
|
|
||||||
},
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: 'Rewrite grounded answers faithfully. Never add information not present in the provided draft/context.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: prompt,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(`LLM request failed (${response.status}): ${text.slice(0, 220)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json()
|
|
||||||
const rewritten = typeof payload?.message?.content === 'string'
|
|
||||||
? payload.message.content.trim()
|
|
||||||
: ''
|
|
||||||
|
|
||||||
if (!rewritten) {
|
|
||||||
throw new Error('LLM returned an empty response')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = rewritten.slice(0, 3500)
|
|
||||||
setCachedResponse(cacheKey, result)
|
|
||||||
return result
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public: return all chatbot entries for client-side matching
|
// Public: return all chatbot entries for client-side matching
|
||||||
app.get('/api/chatbot-content', async (req, res) => {
|
app.get('/api/chatbot-content', async (req, res) => {
|
||||||
await refreshChatbotFromDiskIfChanged()
|
await refreshChatbotFromDiskIfChanged()
|
||||||
res.json(chatbotEntries)
|
res.json(chatbotEntries)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Optional: grounded rewrite endpoint for local Ollama usage.
|
|
||||||
app.post('/api/chatbot-grounded-rewrite', async (req, res) => {
|
|
||||||
if (!CHATBOT_LLM_ENABLED) {
|
|
||||||
res.status(503).json({
|
|
||||||
ok: false,
|
|
||||||
message: 'Chatbot LLM is disabled. Set CHATBOT_LLM_ENABLED=true to enable.',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const question = typeof req.body?.question === 'string' ? req.body.question.trim() : ''
|
|
||||||
const draftAnswer = typeof req.body?.draftAnswer === 'string' ? req.body.draftAnswer.trim() : ''
|
|
||||||
const sources = Array.isArray(req.body?.sources)
|
|
||||||
? req.body.sources.filter(source => typeof source === 'string').slice(0, 8)
|
|
||||||
: []
|
|
||||||
const contextChunks = normalizeChatbotLlmContext(req.body?.contextChunks)
|
|
||||||
|
|
||||||
if (!question) {
|
|
||||||
res.status(400).json({ ok: false, message: 'Missing required field: question' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!draftAnswer) {
|
|
||||||
res.status(400).json({ ok: false, message: 'Missing required field: draftAnswer' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rewrittenAnswer = await rewriteWithChatbotLlm({
|
|
||||||
question,
|
|
||||||
draftAnswer,
|
|
||||||
sources,
|
|
||||||
contextChunks,
|
|
||||||
})
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
ok: true,
|
|
||||||
text: rewrittenAnswer,
|
|
||||||
model: CHATBOT_LLM_MODEL,
|
|
||||||
grounded: true,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[chatbot-llm] rewrite failed:', err)
|
|
||||||
res.status(502).json({
|
|
||||||
ok: false,
|
|
||||||
message: 'LLM rewrite failed. Returning deterministic draft is recommended.',
|
|
||||||
error: String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Admin: get all entries
|
// Admin: get all entries
|
||||||
app.get('/api/admin/chatbot-content', async (req, res) => {
|
app.get('/api/admin/chatbot-content', async (req, res) => {
|
||||||
if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return }
|
if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Not authenticated.' }); return }
|
||||||
|
|||||||
+484
-458
File diff suppressed because it is too large
Load Diff
+386
-194
@@ -427,6 +427,143 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-archive-series {
|
||||||
|
background: #0b0b0b;
|
||||||
|
border-top: 1px solid rgba(200, 134, 10, 0.12);
|
||||||
|
padding: 4.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-series-rotator {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-rotate-btn {
|
||||||
|
width: 2.3rem;
|
||||||
|
height: 2.3rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(200, 134, 10, 0.4);
|
||||||
|
background: rgba(200, 134, 10, 0.08);
|
||||||
|
color: #c8860a;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-rotate-btn:hover {
|
||||||
|
background: rgba(200, 134, 10, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-series-list {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-series-track {
|
||||||
|
display: flex;
|
||||||
|
transition: transform 450ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-series-card {
|
||||||
|
min-width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
background: #121212;
|
||||||
|
border: 1px solid rgba(200, 134, 10, 0.18);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-series-art {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 28px rgba(200, 134, 10, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-series-copy h3 {
|
||||||
|
font-family: 'Playfair Display', Georgia, serif;
|
||||||
|
color: #f0e6d0;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: clamp(1.3rem, 2.2vw, 1.7rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-series-copy p {
|
||||||
|
font-family: 'Barlow Condensed', sans-serif;
|
||||||
|
color: #a89060;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-series-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-series-study-guide {
|
||||||
|
color: #b7a27a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-series-resources,
|
||||||
|
.archive-series-notes {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-series-resources h4,
|
||||||
|
.archive-series-note h4 {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
font-family: 'Barlow Condensed', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #c8860a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-series-notes {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-series-note {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(200, 134, 10, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-rotator-dots {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-rotator-dot {
|
||||||
|
width: 0.52rem;
|
||||||
|
height: 0.52rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(168, 144, 96, 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-rotator-dot--active {
|
||||||
|
background: #c8860a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-rotator-count {
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
text-align: center;
|
||||||
|
font-family: 'Barlow Condensed', sans-serif;
|
||||||
|
color: #a89060;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── QR / Share ── */
|
/* ── QR / Share ── */
|
||||||
.section-qr {
|
.section-qr {
|
||||||
background: #0a0a0a;
|
background: #0a0a0a;
|
||||||
@@ -1515,6 +1652,29 @@
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-archive-helper {
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 1px solid rgba(200, 134, 10, 0.18);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-archive-helper h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Playfair Display', Georgia, serif;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: #f0e6d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-archive-helper p {
|
||||||
|
margin: 0.55rem 0 0.9rem;
|
||||||
|
font-family: 'Barlow Condensed', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #a89060;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-field label {
|
.admin-field label {
|
||||||
font-family: 'Barlow Condensed', sans-serif;
|
font-family: 'Barlow Condensed', sans-serif;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -1675,6 +1835,119 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-content-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-summary-card {
|
||||||
|
background: #111;
|
||||||
|
border: 1px solid rgba(200, 134, 10, 0.16);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-summary-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Barlow Condensed', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #c8860a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-summary-card p {
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
font-family: 'Playfair Display', Georgia, serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #f0e6d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-archive-card {
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 1px solid rgba(200, 134, 10, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-archive-card-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-archive-card-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Playfair Display', Georgia, serif;
|
||||||
|
color: #f0e6d0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-archive-card-head p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-family: 'Barlow Condensed', sans-serif;
|
||||||
|
color: #a89060;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-archive-subsection {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(200, 134, 10, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-archive-subsection-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-archive-subsection-head h5 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Barlow Condensed', sans-serif;
|
||||||
|
color: #c8860a;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-archive-subsection-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-archive-subsection-actions select {
|
||||||
|
background: #111;
|
||||||
|
border: 1px solid rgba(200, 134, 10, 0.22);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #f0e6d0;
|
||||||
|
font-family: 'Barlow Condensed', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 300;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-archive-subsection-actions select:focus {
|
||||||
|
border-color: rgba(200, 134, 10, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-array-row--nested {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-admin-add {
|
.btn-admin-add {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #c8860a;
|
color: #c8860a;
|
||||||
@@ -1758,11 +2031,28 @@
|
|||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archive-series-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-series-rotator {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-rotate-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.series-art {
|
.series-art {
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archive-series-art {
|
||||||
|
max-width: 220px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.guide-inner {
|
.guide-inner {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
@@ -1773,6 +2063,14 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-content-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-archive-card-head {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.qr-inner {
|
.qr-inner {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 2.5rem;
|
gap: 2.5rem;
|
||||||
@@ -1859,8 +2157,45 @@
|
|||||||
border-top: 1px solid rgba(200, 134, 10, 0.15);
|
border-top: 1px solid rgba(200, 134, 10, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qa-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-topics {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-topic-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(200, 134, 10, 0.35);
|
||||||
|
color: #a89060;
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
border-radius: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: 'Barlow', sans-serif;
|
||||||
|
transition: background 0.18s, border-color 0.18s, color 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-topic-btn:hover {
|
||||||
|
background: rgba(200, 134, 10, 0.1);
|
||||||
|
border-color: rgba(200, 134, 10, 0.6);
|
||||||
|
color: #e8d8b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-topic-btn--active {
|
||||||
|
background: rgba(200, 134, 10, 0.18);
|
||||||
|
border-color: #c8860a;
|
||||||
|
color: #c8860a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.qa-search {
|
.qa-search {
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -1873,10 +2208,16 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-search label span {
|
.visually-hidden {
|
||||||
font-size: 0.95rem;
|
position: absolute;
|
||||||
font-weight: 600;
|
width: 1px;
|
||||||
color: #c8860a;
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0,0,0,0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-search input {
|
.qa-search input {
|
||||||
@@ -1895,91 +2236,79 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.qa-cards {
|
.qa-cards {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
gap: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 3D flip card ── */
|
|
||||||
.qa-card-scene {
|
|
||||||
perspective: 1000px;
|
|
||||||
height: 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qa-card-inner {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
transition: transform 0.6s cubic-bezier(0.45, 0, 0.55, 1);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qa-card-inner.flipped {
|
|
||||||
transform: rotateY(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qa-card-face {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
backface-visibility: hidden;
|
|
||||||
-webkit-backface-visibility: hidden;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1.75rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
border: 1px solid rgba(200, 134, 10, 0.25);
|
|
||||||
transition: border-color 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-card-inner:hover .qa-card-face {
|
/* ── Accordion card ── */
|
||||||
|
.qa-card-scene {
|
||||||
|
border: 1px solid rgba(200, 134, 10, 0.25);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgba(35, 30, 20, 0.85);
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-card-scene:hover {
|
||||||
border-color: rgba(200, 134, 10, 0.55);
|
border-color: rgba(200, 134, 10, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qa-card-inner {
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-card-face {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.qa-card-front {
|
.qa-card-front {
|
||||||
background: rgba(35, 30, 20, 0.85);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-card-back {
|
.qa-card-back {
|
||||||
background: rgba(20, 30, 20, 0.92);
|
border-top: 1px solid rgba(200, 134, 10, 0.2);
|
||||||
transform: rotateY(180deg);
|
background: rgba(20, 28, 20, 0.7);
|
||||||
overflow-y: auto;
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-card-inner.flipped .qa-card-back {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-face-label {
|
.qa-face-label {
|
||||||
font-size: 2rem;
|
font-size: 1.3rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #c8860a;
|
color: #c8860a;
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
width: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-question-text {
|
.qa-question-text {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1.08rem;
|
||||||
line-height: 1.55;
|
line-height: 1.6;
|
||||||
color: #e8d8b0;
|
color: #e8d8b0;
|
||||||
|
font-weight: 600;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-answer-text {
|
.qa-answer-text {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.95rem;
|
font-size: 1.04rem;
|
||||||
line-height: 1.6;
|
line-height: 1.75;
|
||||||
color: #c8d8b0;
|
color: #d4e8c8;
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-flip-hint {
|
.qa-flip-hint {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #a89060;
|
color: #a89060;
|
||||||
margin-top: auto;
|
margin-left: auto;
|
||||||
opacity: 0.75;
|
flex-shrink: 0;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2456,140 +2785,3 @@
|
|||||||
.chatbot-send:hover:not(:disabled) { background: #e8a91a; }
|
.chatbot-send:hover:not(:disabled) { background: #e8a91a; }
|
||||||
.chatbot-send:disabled { opacity: 0.35; cursor: default; }
|
.chatbot-send:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════
|
|
||||||
ADMIN — CHATBOT TAB
|
|
||||||
══════════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
.admin-chatbot {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-chatbot-actions {
|
|
||||||
margin: 1.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-admin-primary {
|
|
||||||
background: #c8860a;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.35rem;
|
|
||||||
padding: 0.55rem 1.25rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-admin-primary:hover:not(:disabled) { background: #e8a91a; }
|
|
||||||
.btn-admin-primary:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
|
|
||||||
.admin-chatbot-editor {
|
|
||||||
background: rgba(50, 50, 50, 0.5);
|
|
||||||
border: 1px solid rgba(200, 134, 10, 0.25);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-chatbot-editor h3 {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
color: #c8860a;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-chatbot-editor label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
color: #b0a080;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-chatbot-editor input,
|
|
||||||
.admin-chatbot-editor select,
|
|
||||||
.admin-chatbot-editor textarea {
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
border: 1px solid rgba(200, 134, 10, 0.3);
|
|
||||||
border-radius: 0.35rem;
|
|
||||||
color: #e8d8b0;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-chatbot-editor input:focus,
|
|
||||||
.admin-chatbot-editor select:focus,
|
|
||||||
.admin-chatbot-editor textarea:focus {
|
|
||||||
border-color: #c8860a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-chatbot-editor textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-field-hint {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: #7a6a50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-chatbot-editor-btns {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-chatbot-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-chatbot-card {
|
|
||||||
background: rgba(50, 50, 50, 0.45);
|
|
||||||
border: 1px solid rgba(200, 134, 10, 0.18);
|
|
||||||
border-radius: 0.45rem;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-chatbot-card-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.65rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-chatbot-title {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: #e8d8b0;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-badge--topic { background: rgba(30,100,180,0.3); color: #88aaff; border: 1px solid rgba(100,160,255,0.3); }
|
|
||||||
.admin-badge--episode { background: rgba(100,60,180,0.3); color: #cc99ff; border: 1px solid rgba(160,100,255,0.3); }
|
|
||||||
|
|
||||||
.admin-chatbot-preview {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #9a8a6a;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-chatbot-keywords {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: #7a6a50;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-empty-msg {
|
|
||||||
color: #7a6a50;
|
|
||||||
font-style: italic;
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|||||||
+284
-1614
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user