Compare commits
57
Commits
69d445013a
...
main
No files matched your search
@@ -59,3 +59,9 @@ data/episode-plays.json
|
|||||||
# Episode workflow progress — lives only in the server data volume
|
# Episode workflow progress — lives only in the server data volume
|
||||||
data/podcast-workflow.json
|
data/podcast-workflow.json
|
||||||
data/nuggets-workflow.json
|
data/nuggets-workflow.json
|
||||||
|
|
||||||
|
# Live download counters — server data volume only
|
||||||
|
data/download-counts.json
|
||||||
|
|
||||||
|
# Generated audio (upload via Admin → Asset Manager, do not commit)
|
||||||
|
audio-out/
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"seriesImageUrl": "/images/titus-cover.png",
|
"seriesImageUrl": "/images/titus-cover.png",
|
||||||
"seriesListenUrl": "/spotify",
|
"seriesListenUrl": "/spotify",
|
||||||
"seriesListenBtnLabel": "Listen to Series",
|
"seriesListenBtnLabel": "Listen to Series",
|
||||||
"seriesDownloadsBtnLabel": "View Downloads",
|
"seriesDownloadsBtnLabel": "View Resources",
|
||||||
"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.",
|
||||||
"studyGuideDownloadUrl": "",
|
"studyGuideDownloadUrl": "",
|
||||||
@@ -1007,7 +1007,7 @@
|
|||||||
],
|
],
|
||||||
"shareHeading": "Help one more person hear the Word this week.",
|
"shareHeading": "Help one more person hear the Word this week.",
|
||||||
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
|
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
|
||||||
"prismVideoUrl": "/images/PRISM.mp4",
|
"prismVideoUrl": "https://files.versebyversewithnate.us/images/PRISM.mp4",
|
||||||
"prismEyebrow": "Featured Video",
|
"prismEyebrow": "Featured Video",
|
||||||
"prismHeading": "PRISM",
|
"prismHeading": "PRISM",
|
||||||
"prismIntro": "What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?",
|
"prismIntro": "What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?",
|
||||||
@@ -1030,7 +1030,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "downloads",
|
"id": "downloads",
|
||||||
"title": "Downloads",
|
"title": "Resources",
|
||||||
"description": "Main guide, extra downloads, and past study files.",
|
"description": "Main guide, extra downloads, and past study files.",
|
||||||
"path": "/resources"
|
"path": "/resources"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
"seriesImageUrl": "/images/titus-cover.png",
|
"seriesImageUrl": "/images/titus-cover.png",
|
||||||
"seriesListenUrl": "/spotify",
|
"seriesListenUrl": "/spotify",
|
||||||
"seriesListenBtnLabel": "Listen to Series",
|
"seriesListenBtnLabel": "Listen to Series",
|
||||||
"seriesDownloadsBtnLabel": "View Downloads",
|
"seriesDownloadsBtnLabel": "View Resources",
|
||||||
"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.",
|
||||||
"studyGuideDownloadUrl": "",
|
"studyGuideDownloadUrl": "",
|
||||||
@@ -1061,7 +1061,7 @@
|
|||||||
"shareHeading": "Help one more person hear the Word this week.",
|
"shareHeading": "Help one more person hear the Word this week.",
|
||||||
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
|
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
|
||||||
"shareQrImageUrl": "/images/qr-code.jpg",
|
"shareQrImageUrl": "/images/qr-code.jpg",
|
||||||
"prismVideoUrl": "/images/PRISM.mp4",
|
"prismVideoUrl": "https://files.versebyversewithnate.us/images/PRISM.mp4",
|
||||||
"prismEyebrow": "Featured Video",
|
"prismEyebrow": "Featured Video",
|
||||||
"prismHeading": "PRISM",
|
"prismHeading": "PRISM",
|
||||||
"prismIntro": "What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?",
|
"prismIntro": "What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?",
|
||||||
@@ -1084,7 +1084,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "downloads",
|
"id": "downloads",
|
||||||
"title": "Downloads",
|
"title": "Resources",
|
||||||
"description": "Main guide, extra downloads, and past study files.",
|
"description": "Main guide, extra downloads, and past study files.",
|
||||||
"path": "/resources"
|
"path": "/resources"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+1258
-1125
File diff suppressed because it is too large.
Load diff
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.18.0",
|
"version": "2.46.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"react-simple-maps": "^3.0.0",
|
"react-simple-maps": "^5.0.5",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"resend": "^6.10.0",
|
"resend": "^6.10.0",
|
||||||
"tar": "^7.5.19"
|
"tar": "^7.5.19"
|
||||||
|
|||||||
Binary file not shown.
+964
-15
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,148 @@
|
|||||||
|
// Generates the /salvation audio from the narration files in scripts/salvation/ using ElevenLabs.
|
||||||
|
// There are three tracks: the case, then the listener chooses Guilty or Not guilty.
|
||||||
|
//
|
||||||
|
// node --env-file=.env scripts/generate-salvation-audio.mjs --list-voices # find your voice id
|
||||||
|
// node --env-file=.env scripts/generate-salvation-audio.mjs --sample # ~40 seconds, to test the voice (cheap)
|
||||||
|
// node --env-file=.env scripts/generate-salvation-audio.mjs # generate all three MP3s
|
||||||
|
// node --env-file=.env scripts/generate-salvation-audio.mjs --only=guilty # just one: case | guilty | not-guilty
|
||||||
|
//
|
||||||
|
// Needs, in your local .env (never commit it):
|
||||||
|
// ELEVENLABS_API_KEY=...
|
||||||
|
// ELEVENLABS_VOICE_ID=... (from --list-voices, or your cloned voice)
|
||||||
|
// Optional:
|
||||||
|
// ELEVENLABS_MODEL=eleven_multilingual_v2
|
||||||
|
// ELEVENLABS_STABILITY=0.5 (lower = more expressive, higher = steadier)
|
||||||
|
// ELEVENLABS_SPEED=1
|
||||||
|
//
|
||||||
|
// Output goes to audio-out/ (git-ignored). Upload the MP3s in Admin → Asset Manager, then paste their
|
||||||
|
// URLs into Admin → Gospel Page.
|
||||||
|
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const API = 'https://api.elevenlabs.io/v1'
|
||||||
|
const apiKey = process.env.ELEVENLABS_API_KEY
|
||||||
|
const voiceId = process.env.ELEVENLABS_VOICE_ID
|
||||||
|
const model = process.env.ELEVENLABS_MODEL || 'eleven_multilingual_v2'
|
||||||
|
const MAX_CHARS = 4500
|
||||||
|
let totalCost = 0
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(`\n${message}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey) fail('ELEVENLABS_API_KEY is not set. Add it to your local .env file (not to chat or to git).')
|
||||||
|
|
||||||
|
async function listVoices() {
|
||||||
|
const res = await fetch(`${API}/voices`, { headers: { 'xi-api-key': apiKey } })
|
||||||
|
if (!res.ok) fail(`Could not list voices (${res.status}). Check the API key.`)
|
||||||
|
const { voices = [] } = await res.json()
|
||||||
|
console.log('\nYour voices (name: voice id)\n')
|
||||||
|
for (const v of voices) console.log(` ${v.name}${v.category ? ` [${v.category}]` : ''}: ${v.voice_id}`)
|
||||||
|
console.log('\nPut the one you want in .env as ELEVENLABS_VOICE_ID=...\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on paragraph boundaries so each request stays under MAX_CHARS and never cuts a sentence.
|
||||||
|
function chunkText(text) {
|
||||||
|
const paragraphs = text.split(/\n{2,}/).map(p => p.trim()).filter(Boolean)
|
||||||
|
const chunks = []
|
||||||
|
let current = ''
|
||||||
|
for (const p of paragraphs) {
|
||||||
|
if (current && current.length + p.length + 2 > MAX_CHARS) { chunks.push(current); current = '' }
|
||||||
|
current = current ? `${current}\n\n${p}` : p
|
||||||
|
}
|
||||||
|
if (current) chunks.push(current)
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speak(text, previous, next) {
|
||||||
|
const res = await fetch(`${API}/text-to-speech/${voiceId}?output_format=mp3_44100_128`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'xi-api-key': apiKey, 'Content-Type': 'application/json', Accept: 'audio/mpeg' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
text,
|
||||||
|
model_id: model,
|
||||||
|
voice_settings: {
|
||||||
|
stability: Number(process.env.ELEVENLABS_STABILITY ?? 0.5),
|
||||||
|
similarity_boost: 0.75,
|
||||||
|
style: 0,
|
||||||
|
use_speaker_boost: true,
|
||||||
|
speed: Number(process.env.ELEVENLABS_SPEED ?? 1),
|
||||||
|
},
|
||||||
|
...(previous ? { previous_text: previous.slice(-600) } : {}),
|
||||||
|
...(next ? { next_text: next.slice(0, 600) } : {}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(() => '')
|
||||||
|
fail(`ElevenLabs returned ${res.status}.\n${detail.slice(0, 400)}`)
|
||||||
|
}
|
||||||
|
const cost = res.headers.get('character-cost')
|
||||||
|
if (cost) totalCost += Number(cost) || 0
|
||||||
|
return Buffer.from(await res.arrayBuffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACKS = [
|
||||||
|
{ id: 'case', file: 'narration-1-case.txt', out: 'salvation-case.mp3' },
|
||||||
|
{ id: 'guilty', file: 'narration-2-guilty.txt', out: 'salvation-guilty.mp3' },
|
||||||
|
{ id: 'not-guilty', file: 'narration-3-not-guilty.txt', out: 'salvation-not-guilty.mp3' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function renderTrack(text, outPath) {
|
||||||
|
const chunks = chunkText(text)
|
||||||
|
const parts = []
|
||||||
|
for (let i = 0; i < chunks.length; i += 1) {
|
||||||
|
process.stdout.write(` part ${i + 1}/${chunks.length}… `)
|
||||||
|
parts.push(await speak(chunks[i], chunks[i - 1], chunks[i + 1]))
|
||||||
|
console.log('done')
|
||||||
|
}
|
||||||
|
await writeFile(outPath, Buffer.concat(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (process.argv.includes('--list-voices')) { await listVoices(); return }
|
||||||
|
if (!voiceId) fail('ELEVENLABS_VOICE_ID is not set. Run with --list-voices to find it, then add it to .env.')
|
||||||
|
|
||||||
|
const outDir = path.join(ROOT, 'audio-out')
|
||||||
|
await mkdir(outDir, { recursive: true })
|
||||||
|
|
||||||
|
if (process.argv.includes('--sample')) {
|
||||||
|
const full = (await readFile(path.join(ROOT, 'scripts/salvation', TRACKS[0].file), 'utf8')).trim()
|
||||||
|
// Whole paragraphs only, so the sample never stops mid-sentence (about 700 characters).
|
||||||
|
let sample = ''
|
||||||
|
for (const para of full.split(/\n{2,}/)) {
|
||||||
|
if (sample && sample.length + para.length > 750) break
|
||||||
|
sample = sample ? `${sample}\n\n${para}` : para
|
||||||
|
}
|
||||||
|
// Don't end on a lone heading like "The Accuser."
|
||||||
|
const lastBreak = sample.lastIndexOf('\n\n')
|
||||||
|
if (lastBreak > 0 && sample.length - lastBreak < 40) sample = sample.slice(0, lastBreak)
|
||||||
|
console.log(`\nSample: ${sample.length} characters (model ${model}).\n`)
|
||||||
|
const out = path.join(outDir, 'salvation-sample.mp3')
|
||||||
|
await renderTrack(sample, out)
|
||||||
|
if (totalCost) console.log(`\nElevenLabs reported ${totalCost.toLocaleString()} credits used.`)
|
||||||
|
console.log(`\nSaved ${out}\nIf the voice sounds right, run again without --sample.\n`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const only = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1]
|
||||||
|
const tracks = only ? TRACKS.filter(t => t.id === only) : TRACKS
|
||||||
|
if (!tracks.length) fail(`Unknown --only value "${only}". Use: case, guilty or not-guilty.`)
|
||||||
|
|
||||||
|
const texts = []
|
||||||
|
for (const t of tracks) texts.push((await readFile(path.join(ROOT, 'scripts/salvation', t.file), 'utf8')).trim())
|
||||||
|
const chars = texts.reduce((n, t) => n + t.length, 0)
|
||||||
|
console.log(`\n${tracks.length} track(s), ${chars.toLocaleString()} characters in total (model ${model}).`)
|
||||||
|
console.log('This uses about that many ElevenLabs credits.\n')
|
||||||
|
|
||||||
|
for (const [i, t] of tracks.entries()) {
|
||||||
|
console.log(` ${t.id} (${texts[i].length.toLocaleString()} characters)`)
|
||||||
|
await renderTrack(texts[i], path.join(outDir, t.out))
|
||||||
|
}
|
||||||
|
if (totalCost) console.log(`\nElevenLabs reported ${totalCost.toLocaleString()} credits used.`)
|
||||||
|
console.log(`\nSaved to ${outDir}:\n${tracks.map(t => ` ${t.out}`).join('\n')}\nListen to each one before you upload it. Check the Scripture names and references.\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => fail(err?.message ?? String(err)))
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
The Gospel, in a Courtroom.
|
||||||
|
|
||||||
|
Imagine a courtroom. Not a courtroom on earth, but the highest court there is: the Court of Heaven. God is the Judge. Satan is the accuser. The charges are your sins, and you are the one on trial. But before the verdict is read, a Substitute has stepped forward to take your place. Let me walk you through the case.
|
||||||
|
|
||||||
|
The Judge.
|
||||||
|
|
||||||
|
God is perfectly just. He will not overlook wrongdoing. Nothing is hidden from him, and he judges rightly. In the book of Genesis, chapter eighteen, verse twenty-five, Scripture asks: "Will not the Judge of all the earth do right?" And in Acts, chapter seventeen, verse thirty-one: "For He has set a day when He will judge the world with justice by the Man He has appointed."
|
||||||
|
|
||||||
|
The Accuser.
|
||||||
|
|
||||||
|
Every courtroom has an accuser, and ours is Satan. His very name means "adversary." Scripture calls him the accuser of the brothers, who brings charges against us before God. He does not have to invent anything. He only has to point to what we have actually done. Revelation, chapter twelve, verse ten, calls him "the accuser of our brothers, who accuses them before our God day and night." And First Peter, chapter five, verse eight, warns: "Your adversary the devil prowls around like a roaring lion, looking for someone to devour."
|
||||||
|
|
||||||
|
The Charges.
|
||||||
|
|
||||||
|
So what are the charges? The charges are the sins you have committed.
|
||||||
|
|
||||||
|
Sin is breaking God's law. That includes the wrong things we knowingly do, the good things we fail to do, and the things we say and think in secret.
|
||||||
|
|
||||||
|
It also includes breaking his law without knowing it. Not knowing the law has never made anyone innocent. God's standard is written on every conscience, so that no one is without excuse. Even sin done unknowingly still leaves a person guilty.
|
||||||
|
|
||||||
|
And God does not grade on a curve. Stumbling at one point means you have broken the whole law. We have all sinned. That includes you, and it includes me.
|
||||||
|
|
||||||
|
First John, chapter three, verse four: "Everyone who practices sin practices lawlessness; indeed, sin is lawlessness." James, chapter two, verse ten: "For whoever keeps the whole law but stumbles at just one point is guilty of breaking all of it." Romans, chapter three, verse ten: "There is no one righteous, not even one." And Romans, chapter three, verse twenty-three: "For all have sinned and fall short of the glory of God."
|
||||||
|
|
||||||
|
The Verdict.
|
||||||
|
|
||||||
|
The evidence is clear, and there is no defense. We cannot argue our way out, and our good deeds cannot cancel our guilt. The sentence for sin is death: separation from the God who made us. Romans, chapter three, verse nineteen: "...so that every mouth may be silenced and the whole world held accountable to God." And Romans, chapter six, verse twenty-three: "For the wages of sin is death..."
|
||||||
|
|
||||||
|
But that is not the end of the story.
|
||||||
|
|
||||||
|
The Substitute.
|
||||||
|
|
||||||
|
Then God himself stepped forward to take your place.
|
||||||
|
|
||||||
|
Jesus is God. The eternal Son took on flesh and was born as a baby in Bethlehem, and he came for one purpose: to take the place of sinners like you and me. He did not come to be served. He came to give his life, and the cross was always where the road was heading.
|
||||||
|
|
||||||
|
He lived the perfect life you could not live. On the cross he took the sentence that belonged to you, and he rose again on the third day to prove the payment was accepted.
|
||||||
|
|
||||||
|
John, chapter one, verse fourteen: "The Word became flesh and dwelt among us." Matthew, chapter one, verse twenty-one: "She will bear a Son, and you are to name Him Jesus, because He will save His people from their sins." Mark, chapter ten, verse forty-five: "For even the Son of Man did not come to be served, but to serve, and to give His life as a ransom for many." First Peter, chapter three, verse eighteen: "For Christ also suffered once for sins, the righteous for the unrighteous, to bring you to God." Second Corinthians, chapter five, verse twenty-one: "God made Him who knew no sin to be sin for us, so that in Him we might become the righteousness of God." And Romans, chapter five, verse eight: "But God proves His love for us in this: While we were still sinners, Christ died for us."
|
||||||
|
|
||||||
|
The Pardon.
|
||||||
|
|
||||||
|
The pardon is a gift. It cannot be earned or bought. You receive it by turning from your sin and trusting Jesus alone. When you do, God declares you forgiven and counts you righteous because of what Christ has done.
|
||||||
|
|
||||||
|
Ephesians, chapter two, verses eight and nine: "For by grace you have been saved through faith, and this is not from yourselves, but it is the gift of God, not by works, so that no one can boast." And Romans, chapter ten, verse nine: "If you confess with your mouth that Jesus is Lord, and believe in your heart that God raised Him from the dead, you will be saved."
|
||||||
|
|
||||||
|
The Result.
|
||||||
|
|
||||||
|
Case closed. The accuser has nothing left to say. Our sins were real charges, but they have been answered, because the penalty has been paid in full by the Substitute. The one who trusts in Christ is not waiting nervously for a verdict. The verdict has already been given in their favor.
|
||||||
|
|
||||||
|
Romans, chapter eight, verse one: "Therefore, there is now no condemnation for those who are in Christ Jesus." And verses thirty-three and thirty-four: "Who can bring an accusation against those whom God has chosen? It is God who justifies. Who then can condemn? Christ Jesus, who died, more than that, was raised to life, is at the right hand of God, and He is interceding for us."
|
||||||
|
|
||||||
|
Before you plead.
|
||||||
|
|
||||||
|
Before you plead, read what Jesus did for you. Everything you have just heard tells you that the Substitute took your place. The record shows you how: the night he was arrested, the trials where he was declared innocent and condemned anyway, the long road to the cross, and the empty tomb on the third day.
|
||||||
|
|
||||||
|
Please do not skip it. It is the story of what Jesus did for you, told by four witnesses and woven into one account. Take your time and read it slowly. You can pause this audio at any time, read the record, and then come back and press play. When you are ready, continue.
|
||||||
|
|
||||||
|
How do you plead?
|
||||||
|
|
||||||
|
The court is not asking whether you are a good person. It is asking you to be honest about the charges, and to decide where you will stand: on your own record, or on what Jesus has done in your place.
|
||||||
|
|
||||||
|
You have two choices.
|
||||||
|
|
||||||
|
You can plead guilty. That means: I admit it. I have broken God's law, whether I knew it or not, and I cannot save myself. I am not asking the court to find me innocent. I am asking for the pardon Jesus paid for with his life. And here is what happens: the Judge declares you forgiven, and counts you righteous because of Christ. The accuser has nothing left to say.
|
||||||
|
|
||||||
|
Or you can plead not guilty. That means: I stand on my own record. I believe I have kept God's law well enough to be found innocent, and I do not need anyone to take my place. That plea has a cost, and I will explain it if you choose it.
|
||||||
|
|
||||||
|
Now look at the two buttons below the player. When you are ready, press the one that matches your plea, and I will walk you through what happens next.
|
||||||
|
|
||||||
|
Take a moment. Be honest with God, and honest with yourself.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
You have pleaded guilty. That is the beginning of the good news.
|
||||||
|
|
||||||
|
You are not earning anything by saying this. You are receiving a gift. Romans, chapter six, verse twenty-three: "For the wages of sin is death, but the gift of God is eternal life in Christ Jesus our Lord." Here is what to do next.
|
||||||
|
|
||||||
|
First, tell God. Words do not save you. Trusting Jesus does. But you can say it to him in your own words, or you can pray this prayer. If you mean it, pray it out loud with me.
|
||||||
|
|
||||||
|
"Lord God, I plead guilty. I have sinned against you, and I cannot save myself. I believe Jesus Christ took my place on the cross, died for my sins, and rose again. I turn from my sin, and I trust him alone. Thank you for the pardon I could never earn. Make me new, and help me follow you. Amen."
|
||||||
|
|
||||||
|
Second, believe it is finished. If you meant that prayer, God has forgiven you. Romans, chapter eight, verse one: "Therefore, there is now no condemnation for those who are in Christ Jesus." Your feelings may go up and down, but the verdict does not change.
|
||||||
|
|
||||||
|
Third, tell someone. Tell a Christian friend or family member what you decided.
|
||||||
|
|
||||||
|
Fourth, start reading. Begin with the Gospel of John, then keep going through the New Testament, and talk to God every day.
|
||||||
|
|
||||||
|
Fifth, find a church. Join a Bible-teaching church where you can grow, and be baptized as a public sign of your faith. Acts, chapter two, verse thirty-eight.
|
||||||
|
|
||||||
|
And if you prayed that prayer and meant it, we would love to rejoice with you. Press the button below that says, Let us know. You can leave your name and your email, and someone from our team will reach out to you personally. It is completely optional.
|
||||||
|
|
||||||
|
Welcome to the family of God.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
You have pleaded not guilty. Please be sure you understand what that means.
|
||||||
|
|
||||||
|
Pleading not guilty is not just saying, "I am not a bad person." It is telling the Judge, "I have kept your law perfectly, and I need no one to take my place."
|
||||||
|
|
||||||
|
God's standard is not "better than most people." It is perfection. Matthew, chapter five, verse forty-eight: "Be perfect, therefore, as your heavenly Father is perfect." And breaking that standard at even one point makes a person guilty of breaking all of it. James, chapter two, verse ten: "For whoever keeps the whole law but stumbles at just one point is guilty of breaking all of it." That includes the things done without knowing they were wrong.
|
||||||
|
|
||||||
|
So test your plea honestly.
|
||||||
|
|
||||||
|
Have you ever told a lie, even a small one?
|
||||||
|
|
||||||
|
Have you ever taken something that was not yours?
|
||||||
|
|
||||||
|
Have you ever used God's name carelessly, or put something else ahead of him: money, a person, yourself?
|
||||||
|
|
||||||
|
Have you ever hated someone in your heart, or looked at someone with lust? Jesus said those count too. Matthew, chapter five.
|
||||||
|
|
||||||
|
If the answer to any of those is yes, then by God's own standard, the plea does not hold. First John, chapter one, verse eight: "If we say that we have no sin, we are deceiving ourselves, and the truth is not in us."
|
||||||
|
|
||||||
|
And here is what a not guilty plea costs. It means standing before the Judge on your own record, with no Substitute. The sentence for sin stands. Romans, chapter six, verse twenty-three. But that is not what God wants for you. He says he takes no pleasure in anyone's death. Ezekiel, chapter thirty-three, verse eleven. And the offer of pardon is open right now. Second Corinthians, chapter six, verse two: "Now is the day of salvation."
|
||||||
|
|
||||||
|
If you are not sure, that is okay. Questions and doubts are fine. Asking honestly is not the same as refusing.
|
||||||
|
|
||||||
|
If you would like to change your plea, press the button below that says, Change my plea to guilty. You can also read the record of what Jesus did for you, or ask us a question. Whatever you decide, thank you for listening honestly. The offer of the pardon is still open.
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
loadStudySessionsFromDisk,
|
loadStudySessionsFromDisk,
|
||||||
queueStudySessionsWrite,
|
queueStudySessionsWrite,
|
||||||
loadNewsletterIssuesFromDisk,
|
loadNewsletterIssuesFromDisk,
|
||||||
|
loadNuggetsTagsFromDisk,
|
||||||
createBackupSnapshot,
|
createBackupSnapshot,
|
||||||
refreshContentCaches,
|
refreshContentCaches,
|
||||||
queueHitStatsWrite,
|
queueHitStatsWrite,
|
||||||
@@ -38,10 +39,12 @@ import {
|
|||||||
sanitizeUserAgent,
|
sanitizeUserAgent,
|
||||||
shouldCountHit,
|
shouldCountHit,
|
||||||
recordHit,
|
recordHit,
|
||||||
|
isKnownSitePath,
|
||||||
scheduleStudyReminders,
|
scheduleStudyReminders,
|
||||||
scheduleReengagementEmails,
|
scheduleReengagementEmails,
|
||||||
scheduleWeeklyDigestEmails,
|
scheduleWeeklyDigestEmails,
|
||||||
backfillCompletedStudies,
|
backfillCompletedStudies,
|
||||||
|
backfillReminderDefault,
|
||||||
} from './server/study-helpers.js'
|
} from './server/study-helpers.js'
|
||||||
import { recordVisitor } from './server/routes/analytics.js'
|
import { recordVisitor } from './server/routes/analytics.js'
|
||||||
|
|
||||||
@@ -67,12 +70,15 @@ import { register as registerNuggets } from './server/routes/nuggets.js'
|
|||||||
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
|
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
|
||||||
import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js'
|
import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js'
|
||||||
import { register as registerPodcastWorkflow } from './server/routes/podcast-workflow.js'
|
import { register as registerPodcastWorkflow } from './server/routes/podcast-workflow.js'
|
||||||
|
import { register as registerSeasonCheck } from './server/season-check.js'
|
||||||
|
import { warmFeedSnapshot } from './server/feed-release.js'
|
||||||
import { preferIpv4ForSeafile } from './server/seafile-dns.js'
|
import { preferIpv4ForSeafile } from './server/seafile-dns.js'
|
||||||
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
|
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
|
||||||
import { register as registerFeed } from './server/routes/feed.js'
|
import { register as registerFeed } from './server/routes/feed.js'
|
||||||
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
||||||
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
|
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
|
||||||
import { register as registerNewsletter } from './server/routes/newsletter.js'
|
import { register as registerNewsletter } from './server/routes/newsletter.js'
|
||||||
|
import { register as registerSalvation } from './server/routes/salvation.js'
|
||||||
import { register as registerPublic } from './server/routes/public.js'
|
import { register as registerPublic } from './server/routes/public.js'
|
||||||
|
|
||||||
preferIpv4ForSeafile()
|
preferIpv4ForSeafile()
|
||||||
@@ -94,12 +100,12 @@ app.use((_req, res, next) => {
|
|||||||
'Content-Security-Policy',
|
'Content-Security-Policy',
|
||||||
[
|
[
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"script-src 'self' 'unsafe-inline' https://www.truthforlife.org https://ajax.googleapis.com",
|
"script-src 'self' 'unsafe-inline' https://www.truthforlife.org https://ajax.googleapis.com https://storage.ko-fi.com https://static.cloudflareinsights.com",
|
||||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://www.truthforlife.org",
|
||||||
"font-src 'self' https://fonts.gstatic.com",
|
"font-src 'self' https://fonts.gstatic.com",
|
||||||
"img-src 'self' data: https:",
|
"img-src 'self' data: https:",
|
||||||
"media-src 'self' https:",
|
"media-src 'self' https:",
|
||||||
"frame-src https:",
|
"frame-src 'self' https:",
|
||||||
"connect-src 'self' https:",
|
"connect-src 'self' https:",
|
||||||
].join('; '),
|
].join('; '),
|
||||||
)
|
)
|
||||||
@@ -128,16 +134,19 @@ registerNuggets(app)
|
|||||||
registerNuggetsHostUpload(app)
|
registerNuggetsHostUpload(app)
|
||||||
registerNuggetsWorkflow(app)
|
registerNuggetsWorkflow(app)
|
||||||
registerPodcastWorkflow(app)
|
registerPodcastWorkflow(app)
|
||||||
|
registerSeasonCheck(app)
|
||||||
registerFeed(app)
|
registerFeed(app)
|
||||||
registerQrCodes(app)
|
registerQrCodes(app)
|
||||||
registerBiblePassage(app)
|
registerBiblePassage(app)
|
||||||
registerNewsletter(app)
|
registerNewsletter(app)
|
||||||
|
registerSalvation(app)
|
||||||
|
|
||||||
// Hit-counting middleware (must come before public routes)
|
// Hit-counting middleware (must come before public routes)
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (shouldCountHit(req)) {
|
if (shouldCountHit(req)) {
|
||||||
const ua = sanitizeUserAgent(req.get('user-agent'))
|
const ua = sanitizeUserAgent(req.get('user-agent'))
|
||||||
const botDetection = detectBot(ua)
|
const knownPath = isKnownSitePath(req.path)
|
||||||
|
const botDetection = knownPath ? detectBot(ua) : { isBot: true, reason: 'unknown-path' }
|
||||||
recordHit(req.path, botDetection.isBot, botDetection.reason)
|
recordHit(req.path, botDetection.isBot, botDetection.reason)
|
||||||
queueHitStatsWrite()
|
queueHitStatsWrite()
|
||||||
if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) {
|
if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) {
|
||||||
@@ -191,6 +200,7 @@ Promise.all([
|
|||||||
loadWebhooksFromDisk(),
|
loadWebhooksFromDisk(),
|
||||||
loadStudySessionsFromDisk(),
|
loadStudySessionsFromDisk(),
|
||||||
loadNewsletterIssuesFromDisk(),
|
loadNewsletterIssuesFromDisk(),
|
||||||
|
loadNuggetsTagsFromDisk(),
|
||||||
refreshContentCaches(),
|
refreshContentCaches(),
|
||||||
])
|
])
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
@@ -201,6 +211,11 @@ Promise.all([
|
|||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error('[study] completed-study backfill failed:', err)
|
console.error('[study] completed-study backfill failed:', err)
|
||||||
})
|
})
|
||||||
|
.then(() => backfillReminderDefault())
|
||||||
|
.then(updated => { if (updated > 0) console.log(`[study] turned lesson reminders on for ${updated} existing accounts`) })
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[study] reminder-default backfill failed:', err)
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
createBackupSnapshot('startup').catch(() => {})
|
createBackupSnapshot('startup').catch(() => {})
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
@@ -208,6 +223,7 @@ Promise.all([
|
|||||||
}, BACKUP_INTERVAL_MS)
|
}, BACKUP_INTERVAL_MS)
|
||||||
|
|
||||||
startNuggetsSeafileWatcher()
|
startNuggetsSeafileWatcher()
|
||||||
|
warmFeedSnapshot()
|
||||||
|
|
||||||
// Purge expired study sessions every hour
|
// Purge expired study sessions every hour
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
+4
-2
@@ -73,6 +73,7 @@ export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
|
|||||||
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
|
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
|
||||||
export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json')
|
export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json')
|
||||||
export const NEWSLETTER_ISSUES_FILE = path.join(DATA_DIR, 'newsletter-issues.json')
|
export const NEWSLETTER_ISSUES_FILE = path.join(DATA_DIR, 'newsletter-issues.json')
|
||||||
|
export const NUGGETS_TAGS_FILE = path.join(DATA_DIR, 'nuggets-tags.json')
|
||||||
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||||
|
|
||||||
// Seafile integration for Nuggets episode uploads
|
// Seafile integration for Nuggets episode uploads
|
||||||
@@ -121,8 +122,8 @@ export const MAX_STUDY_COMMENTS = 10000
|
|||||||
export const MAX_STUDY_COMMENT_LENGTH = 2000
|
export const MAX_STUDY_COMMENT_LENGTH = 2000
|
||||||
export const MAX_STUDY_USERS = 5000
|
export const MAX_STUDY_USERS = 5000
|
||||||
export const MAX_STUDY_ENROLLMENTS_PER_USER = 100
|
export const MAX_STUDY_ENROLLMENTS_PER_USER = 100
|
||||||
export const MAX_STUDY_NOTES_PER_USER = 500
|
export const MAX_STUDY_NOTES_PER_USER = 3000
|
||||||
export const MAX_STUDY_NOTE_LENGTH = 12000
|
export const MAX_STUDY_NOTE_LENGTH = 200000
|
||||||
export const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
|
export const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
|
||||||
export const EMAIL_VERIFY_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
export const EMAIL_VERIFY_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
export const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000
|
export const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000
|
||||||
@@ -171,6 +172,7 @@ export const EMPTY_HIT_STATS = {
|
|||||||
byPath: {},
|
byPath: {},
|
||||||
byPathReal: {},
|
byPathReal: {},
|
||||||
byPathBot: {},
|
byPathBot: {},
|
||||||
|
byPathUnknown: {},
|
||||||
byDay: {},
|
byDay: {},
|
||||||
byDayReal: {},
|
byDayReal: {},
|
||||||
byDayBot: {},
|
byDayBot: {},
|
||||||
|
|||||||
+31
-1
@@ -37,6 +37,7 @@ import {
|
|||||||
WEBHOOKS_FILE,
|
WEBHOOKS_FILE,
|
||||||
STUDY_SESSIONS_FILE,
|
STUDY_SESSIONS_FILE,
|
||||||
NEWSLETTER_ISSUES_FILE,
|
NEWSLETTER_ISSUES_FILE,
|
||||||
|
NUGGETS_TAGS_FILE,
|
||||||
EMPTY_HIT_STATS,
|
EMPTY_HIT_STATS,
|
||||||
EMPTY_VISITOR_STATS,
|
EMPTY_VISITOR_STATS,
|
||||||
DEFAULT_REPLY_TEMPLATES,
|
DEFAULT_REPLY_TEMPLATES,
|
||||||
@@ -166,6 +167,7 @@ export function loadHitStatsFromDisk() {
|
|||||||
byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {},
|
byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {},
|
||||||
byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {},
|
byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {},
|
||||||
byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {},
|
byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {},
|
||||||
|
byPathUnknown: parsed?.byPathUnknown && typeof parsed.byPathUnknown === 'object' ? parsed.byPathUnknown : {},
|
||||||
byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {},
|
byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {},
|
||||||
byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {},
|
byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {},
|
||||||
byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {},
|
byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {},
|
||||||
@@ -1165,7 +1167,7 @@ export async function restoreFromBackup(filename) {
|
|||||||
// (These are defined here rather than study-helpers to avoid circular imports)
|
// (These are defined here rather than study-helpers to avoid circular imports)
|
||||||
|
|
||||||
export function normalizeMessageType(value) {
|
export function normalizeMessageType(value) {
|
||||||
if (value === 'question' || value === 'testimony' || value === 'topic') return value
|
if (value === 'question' || value === 'testimony' || value === 'topic' || value === 'gospel' || value === 'community') return value
|
||||||
return 'general'
|
return 'general'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1249,6 +1251,7 @@ function sanitizeLoadedHitStats(value) {
|
|||||||
byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {},
|
byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {},
|
||||||
byPathReal: value?.byPathReal && typeof value.byPathReal === 'object' ? value.byPathReal : {},
|
byPathReal: value?.byPathReal && typeof value.byPathReal === 'object' ? value.byPathReal : {},
|
||||||
byPathBot: value?.byPathBot && typeof value.byPathBot === 'object' ? value.byPathBot : {},
|
byPathBot: value?.byPathBot && typeof value.byPathBot === 'object' ? value.byPathBot : {},
|
||||||
|
byPathUnknown: value?.byPathUnknown && typeof value.byPathUnknown === 'object' ? value.byPathUnknown : {},
|
||||||
byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {},
|
byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {},
|
||||||
byDayReal: value?.byDayReal && typeof value.byDayReal === 'object' ? value.byDayReal : {},
|
byDayReal: value?.byDayReal && typeof value.byDayReal === 'object' ? value.byDayReal : {},
|
||||||
byDayBot: value?.byDayBot && typeof value.byDayBot === 'object' ? value.byDayBot : {},
|
byDayBot: value?.byDayBot && typeof value.byDayBot === 'object' ? value.byDayBot : {},
|
||||||
@@ -1394,6 +1397,8 @@ export function sanitizeStudyUsers(value) {
|
|||||||
studyRemindersEnabled,
|
studyRemindersEnabled,
|
||||||
enrolledStudySlugs,
|
enrolledStudySlugs,
|
||||||
completedStudySlugs,
|
completedStudySlugs,
|
||||||
|
// Only the admin panel can set this (see PATCH /api/admin/study-users/:id)
|
||||||
|
adminShortcut: item?.adminShortcut === true,
|
||||||
currentStreak: Number.isFinite(item?.currentStreak) && item.currentStreak > 0 ? Math.floor(item.currentStreak) : 0,
|
currentStreak: Number.isFinite(item?.currentStreak) && item.currentStreak > 0 ? Math.floor(item.currentStreak) : 0,
|
||||||
longestStreak: Number.isFinite(item?.longestStreak) && item.longestStreak > 0 ? Math.floor(item.longestStreak) : 0,
|
longestStreak: Number.isFinite(item?.longestStreak) && item.longestStreak > 0 ? Math.floor(item.longestStreak) : 0,
|
||||||
lastStudiedDate: typeof item?.lastStudiedDate === 'string' ? item.lastStudiedDate : null,
|
lastStudiedDate: typeof item?.lastStudiedDate === 'string' ? item.lastStudiedDate : null,
|
||||||
@@ -1416,6 +1421,7 @@ export function sanitizeStudyUsers(value) {
|
|||||||
remindersPausedUntil: typeof item?.remindersPausedUntil === 'string' ? item.remindersPausedUntil : null,
|
remindersPausedUntil: typeof item?.remindersPausedUntil === 'string' ? item.remindersPausedUntil : null,
|
||||||
reminderFrequency: item?.reminderFrequency === 'weekly' ? 'weekly' : 'immediate',
|
reminderFrequency: item?.reminderFrequency === 'weekly' ? 'weekly' : 'immediate',
|
||||||
weeklyDigestSentAt: typeof item?.weeklyDigestSentAt === 'string' ? item.weeklyDigestSentAt : null,
|
weeklyDigestSentAt: typeof item?.weeklyDigestSentAt === 'string' ? item.weeklyDigestSentAt : null,
|
||||||
|
remindersDefaultedAt: typeof item?.remindersDefaultedAt === 'string' ? item.remindersDefaultedAt : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out.slice(0, MAX_STUDY_USERS)
|
return out.slice(0, MAX_STUDY_USERS)
|
||||||
@@ -1586,3 +1592,27 @@ export function loadNewsletterIssuesFromDisk() {
|
|||||||
state.newsletterIssues = []
|
state.newsletterIssues = []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Nuggets Episode Tags ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function queueNuggetsTagsWrite() {
|
||||||
|
state.nuggetsEpisodeTagsWritePromise = state.nuggetsEpisodeTagsWritePromise
|
||||||
|
.then(async () => {
|
||||||
|
await mkdir(DATA_DIR, { recursive: true })
|
||||||
|
await writeFile(NUGGETS_TAGS_FILE, JSON.stringify({ tags: state.nuggetsEpisodeTags }, null, 2), 'utf8')
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[nuggets-tags] failed to write:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadNuggetsTagsFromDisk() {
|
||||||
|
return readFile(NUGGETS_TAGS_FILE, 'utf8')
|
||||||
|
.then(raw => {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
state.nuggetsEpisodeTags = parsed?.tags && typeof parsed.tags === 'object' ? parsed.tags : {}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
state.nuggetsEpisodeTags = {}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { fetchAllEpisodes } from './routes/episodes.js'
|
||||||
|
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
|
||||||
|
|
||||||
|
// The Spotify (Anchor) RSS feed only lists episodes that are live, so a season + episode number
|
||||||
|
// showing up there means the episode has been published. That drives two things on the public site:
|
||||||
|
// - highlights marked "hold until released" stay hidden until their episode is in the feed
|
||||||
|
// - highlights and study lessons with no audio of their own pick it up from the matching feed episode
|
||||||
|
|
||||||
|
const SNAPSHOT_TTL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
let snapshot = { items: [], at: 0 }
|
||||||
|
let inflight = null
|
||||||
|
|
||||||
|
function refreshSnapshot() {
|
||||||
|
if (!inflight) {
|
||||||
|
inflight = fetchAllEpisodes()
|
||||||
|
.then(items => { snapshot = { items, at: Date.now() } })
|
||||||
|
// Keep serving the last good copy; retry on the next TTL window
|
||||||
|
.catch(() => { snapshot = { items: snapshot.items, at: Date.now() } })
|
||||||
|
.finally(() => { inflight = null })
|
||||||
|
}
|
||||||
|
return inflight
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFeedIndex() {
|
||||||
|
if (!snapshot.at) await refreshSnapshot()
|
||||||
|
else if (Date.now() - snapshot.at > SNAPSHOT_TTL_MS) refreshSnapshot()
|
||||||
|
|
||||||
|
const index = new Map()
|
||||||
|
for (const item of snapshot.items) {
|
||||||
|
if (item.season && /^\d+$/.test(item.episode)) index.set(`${item.season}-${item.episode}`, item)
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
export function warmFeedSnapshot() {
|
||||||
|
return refreshSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a copy of the published site content with the feed applied. Run it before
|
||||||
|
// filterSiteContentByReleaseDate so audio is never added to a lesson that is still locked.
|
||||||
|
export async function applyFeedRelease(content) {
|
||||||
|
if (!content || typeof content !== 'object' || Array.isArray(content)) return content
|
||||||
|
|
||||||
|
const links = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
||||||
|
const studies = Array.isArray(content.studies) ? content.studies : []
|
||||||
|
if (links.length === 0 && studies.length === 0) return content
|
||||||
|
|
||||||
|
const [index, wf] = await Promise.all([getFeedIndex(), loadPodcastWorkflow()])
|
||||||
|
const seasonA = laneSeasonOf('A', wf, content)
|
||||||
|
const seasonB = laneSeasonOf('B', wf, content)
|
||||||
|
|
||||||
|
const nextLinks = links.flatMap(link => {
|
||||||
|
// General highlights aren't tied to a series, so there's no season to match against
|
||||||
|
if (!link || link.lane === 'general') return [link]
|
||||||
|
const season = link.lane === 'second' ? seasonB : seasonA
|
||||||
|
const item = season && link.episodeNumber ? index.get(`${season}-${String(link.episodeNumber).trim()}`) : null
|
||||||
|
if (!item) return link.holdUntilReleased ? [] : [link]
|
||||||
|
// Highlights with no audio of their own pick it up from the matching feed episode
|
||||||
|
return [{
|
||||||
|
...link,
|
||||||
|
url: link.url || item.link || '',
|
||||||
|
embedUrl: link.embedUrl?.trim() ? link.embedUrl : (item.audioUrl || link.embedUrl),
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
const studyLane = study => {
|
||||||
|
if (study?.lane === 'A' || study?.lane === 'B') return study.lane
|
||||||
|
if (study?.slug && study.slug === content.seriesStudySlug) return 'A'
|
||||||
|
if (study?.slug && study.slug === content.secondSeries?.studySlug) return 'B'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStudies = studies.map(study => {
|
||||||
|
const lane = studyLane(study)
|
||||||
|
const season = lane === 'A' ? seasonA : lane === 'B' ? seasonB : ''
|
||||||
|
if (!season || !Array.isArray(study.sections)) return study
|
||||||
|
return {
|
||||||
|
...study,
|
||||||
|
sections: study.sections.map(section => {
|
||||||
|
if (!section || section.audioEmbedUrl?.trim() || !section.episodeNumber) return section
|
||||||
|
const item = index.get(`${season}-${String(section.episodeNumber).trim()}`)
|
||||||
|
return item?.audioUrl ? { ...section, audioEmbedUrl: item.audioUrl } : section
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...content, podcastFeaturedLinks: nextLinks, studies: nextStudies }
|
||||||
|
}
|
||||||
@@ -142,6 +142,7 @@ export function sanitizeFeaturedLinks(value) {
|
|||||||
return {
|
return {
|
||||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||||
lane: item.lane === 'second' || item.lane === 'general' ? item.lane : 'primary',
|
lane: item.lane === 'second' || item.lane === 'general' ? item.lane : 'primary',
|
||||||
|
holdUntilReleased: item.holdUntilReleased === true,
|
||||||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
|
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
|
||||||
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
|
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
|
||||||
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
|
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
|
||||||
@@ -340,9 +341,11 @@ function sanitizeStudies(value) {
|
|||||||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
|
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
|
||||||
description: typeof item.description === 'string' ? item.description.trim().slice(0, 800) : '',
|
description: typeof item.description === 'string' ? item.description.trim().slice(0, 800) : '',
|
||||||
homepageEyebrow: typeof item.homepageEyebrow === 'string' ? item.homepageEyebrow.trim().slice(0, 80) : '',
|
homepageEyebrow: typeof item.homepageEyebrow === 'string' ? item.homepageEyebrow.trim().slice(0, 80) : '',
|
||||||
|
imageUrl: sanitizeUrl(item.imageUrl) || '',
|
||||||
showOnHomepage: item.showOnHomepage === true,
|
showOnHomepage: item.showOnHomepage === true,
|
||||||
showNewTag: item.showNewTag === true,
|
showNewTag: item.showNewTag === true,
|
||||||
newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '',
|
newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '',
|
||||||
|
guideSource: typeof item.guideSource === 'string' && /^(none|primary|second|book:[A-Za-z0-9_-]{1,80})$/.test(item.guideSource.trim()) ? item.guideSource.trim() : '',
|
||||||
status: item.status === 'planned' ? 'planned' : (item.status === 'complete' ? 'complete' : 'active'),
|
status: item.status === 'planned' ? 'planned' : (item.status === 'complete' ? 'complete' : 'active'),
|
||||||
lane: item.lane === 'A' || item.lane === 'B' ? item.lane : '',
|
lane: item.lane === 'A' || item.lane === 'B' ? item.lane : '',
|
||||||
startsAt: sanitizeIsoDate(item.startsAt),
|
startsAt: sanitizeIsoDate(item.startsAt),
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ export function register(app) {
|
|||||||
currentStreak: user.currentStreak ?? 0,
|
currentStreak: user.currentStreak ?? 0,
|
||||||
longestStreak: user.longestStreak ?? 0,
|
longestStreak: user.longestStreak ?? 0,
|
||||||
reengagementSentAt: user.reengagementSentAt ?? {},
|
reengagementSentAt: user.reengagementSentAt ?? {},
|
||||||
|
adminShortcut: user.adminShortcut === true,
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -282,12 +283,16 @@ export function register(app) {
|
|||||||
const user = state.studyUsers.find(u => u.id === req.params.id)
|
const user = state.studyUsers.find(u => u.id === req.params.id)
|
||||||
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
|
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
|
||||||
|
|
||||||
const { displayName, newPassword, addEnrollment, removeEnrollment } = req.body ?? {}
|
const { displayName, newPassword, addEnrollment, removeEnrollment, adminShortcut } = req.body ?? {}
|
||||||
|
|
||||||
if (typeof displayName === 'string') {
|
if (typeof displayName === 'string') {
|
||||||
user.displayName = displayName.trim().slice(0, 80)
|
user.displayName = displayName.trim().slice(0, 80)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof adminShortcut === 'boolean') {
|
||||||
|
user.adminShortcut = adminShortcut
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof newPassword === 'string') {
|
if (typeof newPassword === 'string') {
|
||||||
if (newPassword.length < 8 || newPassword.length > 200) {
|
if (newPassword.length < 8 || newPassword.length > 200) {
|
||||||
res.status(400).json({ message: 'Password must be 8–200 characters.' }); return
|
res.status(400).json({ message: 'Password must be 8–200 characters.' }); return
|
||||||
@@ -314,7 +319,7 @@ export function register(app) {
|
|||||||
|
|
||||||
user.updatedAt = new Date().toISOString()
|
user.updatedAt = new Date().toISOString()
|
||||||
queueStudyUsersWrite()
|
queueStudyUsersWrite()
|
||||||
res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs })
|
res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs, adminShortcut: user.adminShortcut === true })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.delete('/api/admin/study-users/:id', requireAdminAuth, async (req, res) => {
|
app.delete('/api/admin/study-users/:id', requireAdminAuth, async (req, res) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { applyFeedRelease } from '../feed-release.js'
|
||||||
import { mkdir, readFile } from 'node:fs/promises'
|
import { mkdir, readFile } from 'node:fs/promises'
|
||||||
import { writeFile } from '../atomic-write.js'
|
import { writeFile } from '../atomic-write.js'
|
||||||
import { assertPublicHttpsUrl } from '../url-safety.js'
|
import { assertPublicHttpsUrl } from '../url-safety.js'
|
||||||
@@ -249,7 +250,7 @@ export function register(app) {
|
|||||||
try {
|
try {
|
||||||
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
|
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
|
||||||
if (source === 'published') {
|
if (source === 'published') {
|
||||||
const safeSiteContent = filterSiteContentByReleaseDate(parsed.siteContent)
|
const safeSiteContent = filterSiteContentByReleaseDate(await applyFeedRelease(parsed.siteContent))
|
||||||
res.json({ ...parsed, siteContent: safeSiteContent })
|
res.json({ ...parsed, siteContent: safeSiteContent })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -279,7 +280,7 @@ export function register(app) {
|
|||||||
app.get('/api/site-config', async (_req, res) => {
|
app.get('/api/site-config', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const parsed = await loadSiteContentFile(DATA_FILE)
|
const parsed = await loadSiteContentFile(DATA_FILE)
|
||||||
const siteContent = parsed.siteContent ?? {}
|
const siteContent = await applyFeedRelease(parsed.siteContent ?? {})
|
||||||
res.json({
|
res.json({
|
||||||
seo: siteContent.seo ?? DEFAULT_SEO,
|
seo: siteContent.seo ?? DEFAULT_SEO,
|
||||||
legal: siteContent.legal ?? DEFAULT_LEGAL,
|
legal: siteContent.legal ?? DEFAULT_LEGAL,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Resend } from 'resend'
|
import { Resend } from 'resend'
|
||||||
import { readdir } from 'node:fs/promises'
|
import { readdir } from 'node:fs/promises'
|
||||||
import { requireAdminAuth } from '../auth.js'
|
import { requireAdminAuth } from '../auth.js'
|
||||||
|
import { buildSeasonCheck } from '../season-check.js'
|
||||||
|
import { countPendingUploads } from './nuggets-workflow.js'
|
||||||
import { state } from '../state.js'
|
import { state } from '../state.js'
|
||||||
import { getStorageStatus, loadUserProgress } from '../data.js'
|
import { getStorageStatus, loadUserProgress } from '../data.js'
|
||||||
import { getStudyCatalog } from '../study-helpers.js'
|
import { getStudyCatalog } from '../study-helpers.js'
|
||||||
@@ -151,6 +153,7 @@ async function buildNuggets() {
|
|||||||
const weekAgo = Date.now() - 7 * DAY_MS
|
const weekAgo = Date.now() - 7 * DAY_MS
|
||||||
return {
|
return {
|
||||||
uploadsThisWeek: entries.filter(e => new Date(e.uploadedAt).getTime() >= weekAgo).length,
|
uploadsThisWeek: entries.filter(e => new Date(e.uploadedAt).getTime() >= weekAgo).length,
|
||||||
|
pending: await countPendingUploads(),
|
||||||
latest: entries.slice(0, 3).map(e => ({ hostName: e.hostName, episodeName: e.episodeName, uploadedAt: e.uploadedAt })),
|
latest: entries.slice(0, 3).map(e => ({ hostName: e.hostName, episodeName: e.episodeName, uploadedAt: e.uploadedAt })),
|
||||||
entries: entries.slice(0, 10),
|
entries: entries.slice(0, 10),
|
||||||
}
|
}
|
||||||
@@ -219,13 +222,23 @@ export function register(app) {
|
|||||||
buildStudyProgress(), buildLatestEpisode(), buildNuggets(), buildHealth(), buildLastBroadcast(),
|
buildStudyProgress(), buildLatestEpisode(), buildNuggets(), buildHealth(), buildLastBroadcast(),
|
||||||
])
|
])
|
||||||
const comments = state.studyComments ?? []
|
const comments = state.studyComments ?? []
|
||||||
|
const seasonCheck = await buildSeasonCheck()
|
||||||
|
.then(c => ({
|
||||||
|
total: c.total,
|
||||||
|
errors: c.errors,
|
||||||
|
warnings: c.warnings,
|
||||||
|
general: c.general.length,
|
||||||
|
lanes: c.lanes.map(l => ({ lane: l.lane, title: l.title, season: l.season, errors: l.errors, warnings: l.warnings })),
|
||||||
|
}))
|
||||||
|
.catch(() => null)
|
||||||
res.json({
|
res.json({
|
||||||
|
seasonCheck,
|
||||||
trends: buildTrends(),
|
trends: buildTrends(),
|
||||||
traffic: buildTopTraffic(),
|
traffic: buildTopTraffic(),
|
||||||
reengagement: buildReengagement(),
|
reengagement: buildReengagement(),
|
||||||
studyProgress,
|
studyProgress,
|
||||||
latestEpisode,
|
latestEpisode,
|
||||||
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, latest: nuggets.latest },
|
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, pending: nuggets.pending, latest: nuggets.latest },
|
||||||
health,
|
health,
|
||||||
lastBroadcast,
|
lastBroadcast,
|
||||||
comments: {
|
comments: {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
buildLastNDaysStats,
|
buildLastNDaysStats,
|
||||||
shouldCountHit,
|
shouldCountHit,
|
||||||
recordHit,
|
recordHit,
|
||||||
|
isKnownSitePath,
|
||||||
getStudyCatalog,
|
getStudyCatalog,
|
||||||
} from '../study-helpers.js'
|
} from '../study-helpers.js'
|
||||||
|
|
||||||
@@ -260,6 +261,7 @@ export function register(app) {
|
|||||||
const ip = getClientIp(req)
|
const ip = getClientIp(req)
|
||||||
if (!pageviewAllowed(ip)) { res.json({ ok: false, reason: 'rate-limited' }); return }
|
if (!pageviewAllowed(ip)) { res.json({ ok: false, reason: 'rate-limited' }); return }
|
||||||
const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/'
|
const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/'
|
||||||
|
if (!isKnownSitePath(rawPath)) { res.json({ ok: false, reason: 'unknown-path' }); return }
|
||||||
const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : ''
|
const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : ''
|
||||||
recordHit(rawPath, false)
|
recordHit(rawPath, false)
|
||||||
queueHitStatsWrite()
|
queueHitStatsWrite()
|
||||||
@@ -391,6 +393,13 @@ export function register(app) {
|
|||||||
firstHitAt: state.hitStats.firstHitAt,
|
firstHitAt: state.hitStats.firstHitAt,
|
||||||
lastHitAt: state.hitStats.lastHitAt,
|
lastHitAt: state.hitStats.lastHitAt,
|
||||||
topPaths, topPathsReal, topPathsBot,
|
topPaths, topPathsReal, topPathsBot,
|
||||||
|
topUnknownPaths: Object.entries(state.hitStats.byPathUnknown ?? {})
|
||||||
|
.sort((a, b) => b[1] - a[1]).slice(0, 25).map(([path, hits]) => ({ path, hits })),
|
||||||
|
uniqueVisitors7d: new Set(
|
||||||
|
state.visitorStats.recentVisits
|
||||||
|
.filter(v => Date.now() - new Date(v.at).getTime() <= 7 * 24 * 60 * 60 * 1000)
|
||||||
|
.map(v => v.visitorId),
|
||||||
|
).size,
|
||||||
last7Days, last7DaysReal, last7DaysBot,
|
last7Days, last7DaysReal, last7DaysBot,
|
||||||
last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal,
|
last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal,
|
||||||
botReasons,
|
botReasons,
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ BIBLE_BOOKS.forEach((b, index) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function parseReference(reference) {
|
function parseReference(reference) {
|
||||||
const stripped = reference.replace(/^[a-z]+/i, '')
|
// Accept a leading book name ("Titus 1:1-2", "1 John 2:3"), en/em dashes and verse-part letters ("1:13a")
|
||||||
|
const stripped = reference
|
||||||
|
.replace(/[–—]/g, '-')
|
||||||
|
.replace(/(\d)[a-z]\b/gi, '$1')
|
||||||
|
.replace(/^\s*(?:[1-3]\s*)?[a-z][a-z .]*?\s*(?=\d+:)/i, '')
|
||||||
|
.trim()
|
||||||
const match = stripped.match(/^(\d+):(\d+)(?:-(\d+))?$/)
|
const match = stripped.match(/^(\d+):(\d+)(?:-(\d+))?$/)
|
||||||
if (!match) return null
|
if (!match) return null
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -222,6 +222,81 @@ export function register(app) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Collects a visitor's details, then hands back the Amazon / Apple Books link for a guide or book.
|
||||||
|
// The destination always comes from the saved site content, never from the request.
|
||||||
|
app.post('/api/store-link', studyDownloadRateLimit, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { source, store, firstName, lastName, email, subscribe, _honey } = req.body ?? {}
|
||||||
|
if (_honey) { res.json({ ok: true }); return }
|
||||||
|
|
||||||
|
if (typeof source !== 'string' || !source || (store !== 'amazon' && store !== 'apple')) {
|
||||||
|
res.status(400).json({ message: 'That link is not available.' }); return
|
||||||
|
}
|
||||||
|
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
|
||||||
|
res.status(400).json({ message: 'First name is required.' }); return
|
||||||
|
}
|
||||||
|
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
|
||||||
|
res.status(400).json({ message: 'Last name is required.' }); return
|
||||||
|
}
|
||||||
|
if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) {
|
||||||
|
res.status(400).json({ message: 'A valid email address is required.' }); return
|
||||||
|
}
|
||||||
|
|
||||||
|
const published = await loadSiteContentFile(DATA_FILE)
|
||||||
|
const content = published?.siteContent ?? {}
|
||||||
|
const pick = (amazon, apple) => (store === 'amazon' ? amazon : apple)
|
||||||
|
let label = ''
|
||||||
|
let url = ''
|
||||||
|
|
||||||
|
if (source === 'page:primary-guide') {
|
||||||
|
label = content.studyGuideTitle
|
||||||
|
url = pick(content.studyGuideUrl, content.studyGuideAppleBooksUrl)
|
||||||
|
} else if (source === 'page:second-guide') {
|
||||||
|
const guide = content.secondSeries?.guide
|
||||||
|
label = guide?.title
|
||||||
|
url = pick(guide?.amazonUrl, guide?.appleBooksUrl)
|
||||||
|
} else if (source.startsWith('page:book-guide--')) {
|
||||||
|
const id = source.slice('page:book-guide--'.length)
|
||||||
|
const guide = (Array.isArray(content.finishedBooks) ? content.finishedBooks : []).find(b => b?.id === id)?.guide
|
||||||
|
label = guide?.title
|
||||||
|
url = pick(guide?.amazonUrl, guide?.appleBooksUrl)
|
||||||
|
} else if (source.startsWith('page:custom--')) {
|
||||||
|
const id = source.slice('page:custom--'.length)
|
||||||
|
const link = (content.customLinks ?? []).find(l => l?.id === id && l.placement === 'resources')
|
||||||
|
label = link?.label
|
||||||
|
url = pick(link?.amazonUrl, link?.appleBooksUrl)
|
||||||
|
} else if (source.startsWith('book:')) {
|
||||||
|
const id = source.slice('book:'.length)
|
||||||
|
const book = (content.customLinks ?? []).find(l => l?.id === id && l.placement === 'books')
|
||||||
|
label = book?.label
|
||||||
|
url = pick(book?.amazonUrl || book?.url, book?.appleBooksUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = sanitizeUrl(url)
|
||||||
|
if (!destination) { res.status(404).json({ message: 'That link is not available.' }); return }
|
||||||
|
|
||||||
|
const name = `${firstName.trim()} ${lastName.trim()}`.trim()
|
||||||
|
const trimmedEmail = email.trim()
|
||||||
|
const wantsSubscribe = subscribe !== false
|
||||||
|
const storeName = store === 'amazon' ? 'Amazon' : 'Apple Books'
|
||||||
|
|
||||||
|
addContactSubmission({
|
||||||
|
name,
|
||||||
|
email: trimmedEmail,
|
||||||
|
message: `Requested ${storeName} link: ${label || source}`,
|
||||||
|
messageType: 'general',
|
||||||
|
subscribe: wantsSubscribe,
|
||||||
|
})
|
||||||
|
if (wantsSubscribe) await syncContactToResend(name, trimmedEmail)
|
||||||
|
incrementDownloadCount(`store:${source}:${store}`)
|
||||||
|
|
||||||
|
res.json({ ok: true, url: destination })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[store-link] request error:', err)
|
||||||
|
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => {
|
app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => {
|
||||||
res.json({ counts: state.downloadCounts })
|
res.json({ counts: state.downloadCounts })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const RSS_FEED_URL = 'https://anchor.fm/s/11068d290/podcast/rss'
|
|||||||
let episodesCache = null
|
let episodesCache = null
|
||||||
let episodesCacheAt = 0
|
let episodesCacheAt = 0
|
||||||
let rawXmlCache = null
|
let rawXmlCache = null
|
||||||
const EPISODES_CACHE_TTL = 30 * 60 * 1000
|
const EPISODES_CACHE_TTL = 10 * 60 * 1000
|
||||||
|
|
||||||
export async function fetchAllEpisodes() {
|
export async function fetchAllEpisodes() {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|||||||
@@ -88,15 +88,22 @@ table[align="left"] { float: none !important; margin-left: auto !important; marg
|
|||||||
</style>
|
</style>
|
||||||
</head>`
|
</head>`
|
||||||
|
|
||||||
|
const topBar = `
|
||||||
|
<div style="background:#050505;border-bottom:1px solid rgba(184,151,42,0.25);padding:14px 20px;font-family:Georgia,serif">
|
||||||
|
<div style="max-width:640px;margin:0 auto;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
||||||
|
<a href="${BASE}/" style="color:#e8d9b5;text-decoration:none;font-size:15px;letter-spacing:0.04em">Verse by Verse with Nate</a>
|
||||||
|
<a href="${BASE}/newsletter" style="color:#B8972A;text-decoration:none;font-size:12px;letter-spacing:0.14em;text-transform:uppercase">← All issues</a>
|
||||||
|
</div></div>`
|
||||||
|
|
||||||
const ctaBlock = `
|
const ctaBlock = `
|
||||||
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
|
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
|
||||||
<tbody><tr><td align="center" style="padding:32px 20px 48px;background-color:#080807">
|
<tbody><tr><td align="center" style="padding:40px 20px 56px;background-color:#080807;border-top:1px solid rgba(184,151,42,0.2)">
|
||||||
<table width="580" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:580px;width:100%">
|
<table width="580" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:580px;width:100%">
|
||||||
<tbody><tr><td align="center" style="padding:0">
|
<tbody><tr><td align="center" style="padding:0;font-family:Georgia,serif">
|
||||||
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:13px;color:#6b6044">
|
<p style="margin:0 0 8px;font-size:12px;letter-spacing:0.18em;text-transform:uppercase;color:#B8972A">Stay in the Word</p>
|
||||||
<a href="${BASE}/newsletter" style="color:#6b6044;text-decoration:none">← Newsletter Archive</a>
|
<p style="margin:0 0 22px;font-size:22px;line-height:1.3;color:#f0e9cc">Get the next issue in your inbox</p>
|
||||||
</p>
|
<a href="${BASE}/subscribe" style="display:inline-block;padding:13px 34px;font-size:12px;letter-spacing:0.16em;text-transform:uppercase;color:#0c0c08;background:#B8972A;text-decoration:none;border-radius:4px;font-weight:bold">Subscribe by Email</a>
|
||||||
<a href="${BASE}/subscribe" style="display:inline-block;padding:12px 32px;font-family:Georgia,serif;font-size:12px;letter-spacing:0.16em;text-transform:uppercase;color:#B8972A;border:1px solid #B8972A;text-decoration:none">Subscribe by Email</a>
|
<p style="margin:26px 0 0;font-size:13px"><a href="${BASE}/newsletter" style="color:#8a7e55;text-decoration:none">← Back to the newsletter archive</a></p>
|
||||||
</td></tr></tbody></table>
|
</td></tr></tbody></table>
|
||||||
</td></tr></tbody></table>
|
</td></tr></tbody></table>
|
||||||
</body>`
|
</body>`
|
||||||
@@ -104,6 +111,7 @@ table[align="left"] { float: none !important; margin-left: auto !important; marg
|
|||||||
let html = stripUnsubscribeLink(issue.html)
|
let html = stripUnsubscribeLink(issue.html)
|
||||||
html = html.replace(/<title>[\s\S]*?<\/title>/i, '')
|
html = html.replace(/<title>[\s\S]*?<\/title>/i, '')
|
||||||
html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, metaBlock) : html.replace(/<head[^>]*>/i, m => `${m}${metaBlock.replace('</head>', '')}`)
|
html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, metaBlock) : html.replace(/<head[^>]*>/i, m => `${m}${metaBlock.replace('</head>', '')}`)
|
||||||
|
html = html.replace(/<body[^>]*>/i, m => `${m}${topBar}`)
|
||||||
html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, ctaBlock) : `${html}${ctaBlock}`
|
html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, ctaBlock) : `${html}${ctaBlock}`
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const AUDIO_TYPES = {
|
|||||||
|
|
||||||
export const WORKFLOW_STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify']
|
export const WORKFLOW_STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify']
|
||||||
|
|
||||||
async function loadWorkflow() {
|
export async function loadWorkflow() {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
|
return JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -34,7 +34,7 @@ async function loadWorkflow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryId(entry) {
|
export function entryId(entry) {
|
||||||
return createHash('sha1').update(`${entry.uploadedAt}|${entry.filename}`).digest('hex').slice(0, 16)
|
return createHash('sha1').update(`${entry.uploadedAt}|${entry.filename}`).digest('hex').slice(0, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +51,15 @@ function seafileConfigured() {
|
|||||||
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
|
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Portal uploads whose workflow still has steps left to do (any age, so an old one keeps showing).
|
||||||
|
export async function countPendingUploads() {
|
||||||
|
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
|
||||||
|
return history.entries.filter(entry => {
|
||||||
|
const steps = wf.items[entryId(entry)]?.steps ?? {}
|
||||||
|
return !WORKFLOW_STEPS.every(step => steps[step])
|
||||||
|
}).length
|
||||||
|
}
|
||||||
|
|
||||||
export function register(app) {
|
export function register(app) {
|
||||||
app.get('/api/admin/nuggets-workflow', requireAdminAuth, async (_req, res) => {
|
app.get('/api/admin/nuggets-workflow', requireAdminAuth, async (_req, res) => {
|
||||||
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
|
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { parseRssItems } from '../rss-helpers.js'
|
import { parseRssItems } from '../rss-helpers.js'
|
||||||
import { state } from '../state.js'
|
import { state } from '../state.js'
|
||||||
|
import { requireAdminAuth } from '../auth.js'
|
||||||
|
import { queueNuggetsTagsWrite } from '../data.js'
|
||||||
|
|
||||||
let cache = null // { url, episodes }
|
let cache = null // { url, episodes }
|
||||||
let cacheAt = 0
|
let cacheAt = 0
|
||||||
@@ -37,12 +39,47 @@ export async function fetchNuggetsEpisodes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withTags(episodes) {
|
||||||
|
return episodes.map(ep => ({ ...ep, tags: state.nuggetsEpisodeTags[ep.audioUrl] ?? [] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanTags(input) {
|
||||||
|
const list = Array.isArray(input) ? input : []
|
||||||
|
const seen = new Set()
|
||||||
|
const out = []
|
||||||
|
for (const t of list) {
|
||||||
|
const tag = String(t ?? '').trim().replace(/\s+/g, ' ').slice(0, 40)
|
||||||
|
const key = tag.toLowerCase()
|
||||||
|
if (tag && !seen.has(key)) { seen.add(key); out.push(tag) }
|
||||||
|
}
|
||||||
|
return out.slice(0, 12)
|
||||||
|
}
|
||||||
|
|
||||||
export function register(app) {
|
export function register(app) {
|
||||||
|
app.get('/api/admin/nuggets/episode-tags', requireAdminAuth, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const episodes = await fetchNuggetsEpisodes()
|
||||||
|
res.json({ episodes: withTags(episodes).map(({ title, pubDate, audioUrl, episode, tags }) => ({ title, pubDate, audioUrl, episode, tags })) })
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ message: 'Could not load episodes from the feed.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/admin/nuggets/episode-tags', requireAdminAuth, (req, res) => {
|
||||||
|
const audioUrl = String(req.body?.audioUrl ?? '')
|
||||||
|
if (!audioUrl) { res.status(400).json({ message: 'audioUrl is required.' }); return }
|
||||||
|
const tags = cleanTags(req.body?.tags)
|
||||||
|
if (tags.length) state.nuggetsEpisodeTags[audioUrl] = tags
|
||||||
|
else delete state.nuggetsEpisodeTags[audioUrl]
|
||||||
|
queueNuggetsTagsWrite()
|
||||||
|
res.json({ ok: true, tags })
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/nuggets/episodes', async (_req, res) => {
|
app.get('/api/nuggets/episodes', async (_req, res) => {
|
||||||
const configured = Boolean(feedUrl())
|
const configured = Boolean(feedUrl())
|
||||||
try {
|
try {
|
||||||
const episodes = await fetchNuggetsEpisodes()
|
const episodes = await fetchNuggetsEpisodes()
|
||||||
res.json({ episodes: episodes.slice(0, 6), configured })
|
res.json({ episodes: withTags(episodes.slice(0, 6)), configured })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[nuggets/episodes] RSS fetch error:', err.message)
|
console.error('[nuggets/episodes] RSS fetch error:', err.message)
|
||||||
res.json({ episodes: [], configured })
|
res.json({ episodes: [], configured })
|
||||||
@@ -53,7 +90,7 @@ export function register(app) {
|
|||||||
const configured = Boolean(feedUrl())
|
const configured = Boolean(feedUrl())
|
||||||
try {
|
try {
|
||||||
const episodes = await fetchNuggetsEpisodes()
|
const episodes = await fetchNuggetsEpisodes()
|
||||||
res.json({ episodes, configured })
|
res.json({ episodes: withTags(episodes), configured })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[nuggets/episodes/all] RSS fetch error:', err.message)
|
console.error('[nuggets/episodes/all] RSS fetch error:', err.message)
|
||||||
res.json({ episodes: [], configured })
|
res.json({ episodes: [], configured })
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const WORKFLOW_FILE = path.join(DATA_DIR, 'podcast-workflow.json')
|
|||||||
|
|
||||||
const STEPS = ['readScript', 'record', 'edit', 'upload']
|
const STEPS = ['readScript', 'record', 'edit', 'upload']
|
||||||
|
|
||||||
async function load() {
|
export async function loadPodcastWorkflow() {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
|
const data = JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
|
||||||
return { planned: Array.isArray(data.planned) ? data.planned : [], items: data.items ?? {}, laneOverrides: data.laneOverrides ?? {}, laneSeasons: data.laneSeasons ?? {} }
|
return { planned: Array.isArray(data.planned) ? data.planned : [], items: data.items ?? {}, laneOverrides: data.laneOverrides ?? {}, laneSeasons: data.laneSeasons ?? {} }
|
||||||
@@ -30,10 +30,9 @@ function emptyState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A lane's season comes from the workflow page's own setting, falling back to Current Series.
|
// A lane's season comes from the workflow page's own setting, falling back to Current Series.
|
||||||
function laneSeasonOf(lane, wf) {
|
export function laneSeasonOf(lane, wf, content = state.cachedSiteContent ?? {}) {
|
||||||
const saved = wf.laneSeasons?.[lane]
|
const saved = wf.laneSeasons?.[lane]
|
||||||
if (/^\d+$/.test(String(saved ?? ''))) return String(saved)
|
if (/^\d+$/.test(String(saved ?? ''))) return String(saved)
|
||||||
const content = state.cachedSiteContent ?? {}
|
|
||||||
const fallback = lane === 'A' ? content.seriesSeason : content.secondSeries?.season
|
const fallback = lane === 'A' ? content.seriesSeason : content.secondSeries?.season
|
||||||
return fallback ? String(fallback) : ''
|
return fallback ? String(fallback) : ''
|
||||||
}
|
}
|
||||||
@@ -55,7 +54,7 @@ function toIsoDate(pubDate) {
|
|||||||
|
|
||||||
export function register(app) {
|
export function register(app) {
|
||||||
app.get('/api/admin/podcast-workflow', requireAdminAuth, async (_req, res) => {
|
app.get('/api/admin/podcast-workflow', requireAdminAuth, async (_req, res) => {
|
||||||
const wf = await load()
|
const wf = await loadPodcastWorkflow()
|
||||||
let feed = []
|
let feed = []
|
||||||
let feedOk = true
|
let feedOk = true
|
||||||
try {
|
try {
|
||||||
@@ -119,7 +118,7 @@ export function register(app) {
|
|||||||
res.status(400).json({ message: 'Season and episode number must be whole numbers.' })
|
res.status(400).json({ message: 'Season and episode number must be whole numbers.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const wf = await load()
|
const wf = await loadPodcastWorkflow()
|
||||||
const key = scriptKey(season, episodeNumber)
|
const key = scriptKey(season, episodeNumber)
|
||||||
if (wf.planned.some(p => plannedKey(p) === key)) {
|
if (wf.planned.some(p => plannedKey(p) === key)) {
|
||||||
res.status(409).json({ message: `Season ${season} episode ${episodeNumber} has already been started.` })
|
res.status(409).json({ message: `Season ${season} episode ${episodeNumber} has already been started.` })
|
||||||
@@ -131,7 +130,7 @@ export function register(app) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.delete('/api/admin/podcast-workflow/planned/:key', requireAdminAuth, async (req, res) => {
|
app.delete('/api/admin/podcast-workflow/planned/:key', requireAdminAuth, async (req, res) => {
|
||||||
const wf = await load()
|
const wf = await loadPodcastWorkflow()
|
||||||
wf.planned = wf.planned.filter(p => plannedKey(p) !== req.params.key)
|
wf.planned = wf.planned.filter(p => plannedKey(p) !== req.params.key)
|
||||||
delete wf.items[req.params.key]
|
delete wf.items[req.params.key]
|
||||||
delete wf.laneOverrides[req.params.key]
|
delete wf.laneOverrides[req.params.key]
|
||||||
@@ -141,7 +140,7 @@ export function register(app) {
|
|||||||
|
|
||||||
// Set the season number each lane looks for ('' goes back to the Current Series value).
|
// Set the season number each lane looks for ('' goes back to the Current Series value).
|
||||||
app.put('/api/admin/podcast-workflow-lane-seasons', requireAdminAuth, async (req, res) => {
|
app.put('/api/admin/podcast-workflow-lane-seasons', requireAdminAuth, async (req, res) => {
|
||||||
const wf = await load()
|
const wf = await loadPodcastWorkflow()
|
||||||
for (const lane of ['A', 'B']) {
|
for (const lane of ['A', 'B']) {
|
||||||
const v = req.body?.[lane]
|
const v = req.body?.[lane]
|
||||||
if (v === undefined) continue
|
if (v === undefined) continue
|
||||||
@@ -161,7 +160,7 @@ export function register(app) {
|
|||||||
res.status(400).json({ message: 'Lane must be A, B, other or auto.' })
|
res.status(400).json({ message: 'Lane must be A, B, other or auto.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const wf = await load()
|
const wf = await loadPodcastWorkflow()
|
||||||
if (lane === 'auto') delete wf.laneOverrides[req.params.key]
|
if (lane === 'auto') delete wf.laneOverrides[req.params.key]
|
||||||
else wf.laneOverrides[req.params.key] = lane
|
else wf.laneOverrides[req.params.key] = lane
|
||||||
await save(wf)
|
await save(wf)
|
||||||
@@ -170,7 +169,7 @@ export function register(app) {
|
|||||||
|
|
||||||
app.put('/api/admin/podcast-workflow/:key', requireAdminAuth, async (req, res) => {
|
app.put('/api/admin/podcast-workflow/:key', requireAdminAuth, async (req, res) => {
|
||||||
const { key } = req.params
|
const { key } = req.params
|
||||||
const wf = await load()
|
const wf = await loadPodcastWorkflow()
|
||||||
const prev = { ...emptyState(), ...(wf.items[key] ?? {}) }
|
const prev = { ...emptyState(), ...(wf.items[key] ?? {}) }
|
||||||
const next = { ...prev, steps: { ...prev.steps } }
|
const next = { ...prev, steps: { ...prev.steps } }
|
||||||
const body = req.body ?? {}
|
const body = req.body ?? {}
|
||||||
|
|||||||
+13
-5
@@ -1,4 +1,5 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
import { applyFeedRelease } from '../feed-release.js'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js'
|
import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js'
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
} from '../config.js'
|
} from '../config.js'
|
||||||
import { state } from '../state.js'
|
import { state } from '../state.js'
|
||||||
import { loadSiteContentFile } from '../data.js'
|
import { loadSiteContentFile } from '../data.js'
|
||||||
import { sanitizeRedirectRules } from '../study-helpers.js'
|
import { sanitizeRedirectRules, isKnownSitePath } from '../study-helpers.js'
|
||||||
import { fetchAllEpisodes } from './episodes.js'
|
import { fetchAllEpisodes } from './episodes.js'
|
||||||
import { fetchNuggetsEpisodes } from './nuggets.js'
|
import { fetchNuggetsEpisodes } from './nuggets.js'
|
||||||
import { BIBLE_BOOKS } from '../../shared/bible-books.js'
|
import { BIBLE_BOOKS } from '../../shared/bible-books.js'
|
||||||
@@ -59,13 +60,14 @@ export function register(app) {
|
|||||||
content = parsed.siteContent
|
content = parsed.siteContent
|
||||||
} catch { content = {} }
|
} catch { content = {} }
|
||||||
}
|
}
|
||||||
|
content = await applyFeedRelease(content)
|
||||||
|
|
||||||
const seo = content?.seo ?? DEFAULT_SEO
|
const seo = content?.seo ?? DEFAULT_SEO
|
||||||
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
|
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
|
||||||
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
|
||||||
|
|
||||||
res.type('text/plain').send(
|
res.type('text/plain').send(
|
||||||
['User-agent: *', 'Allow: /', 'Disallow: /salvation', `Sitemap: ${root}/sitemap.xml`].join('\n'),
|
['User-agent: *', 'Allow: /', `Sitemap: ${root}/sitemap.xml`].join('\n'),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -109,6 +111,8 @@ export function register(app) {
|
|||||||
for (const ep of highlights) {
|
for (const ep of highlights) {
|
||||||
if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`)
|
if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`)
|
||||||
}
|
}
|
||||||
|
// The standalone gospel page
|
||||||
|
if (!urls.includes(`${root}/salvation`)) urls.push(`${root}/salvation`)
|
||||||
// Newsletter archive + published issues
|
// Newsletter archive + published issues
|
||||||
urls.push(`${root}/newsletter`)
|
urls.push(`${root}/newsletter`)
|
||||||
for (const issue of state.newsletterIssues) {
|
for (const issue of state.newsletterIssues) {
|
||||||
@@ -151,7 +155,8 @@ export function register(app) {
|
|||||||
if (!id || !/^[\w-]{1,120}$/.test(id)) {
|
if (!id || !/^[\w-]{1,120}$/.test(id)) {
|
||||||
res.redirect(302, '/questions'); return
|
res.redirect(302, '/questions'); return
|
||||||
}
|
}
|
||||||
const sourceQuestions = state.questions
|
// Same source as /api/questions, so a freshly published answer's share link works straight away
|
||||||
|
const sourceQuestions = state.draftQuestions ?? state.questions
|
||||||
const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer)
|
const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer)
|
||||||
if (!question) {
|
if (!question) {
|
||||||
res.redirect(302, '/questions'); return
|
res.redirect(302, '/questions'); return
|
||||||
@@ -196,7 +201,7 @@ export function register(app) {
|
|||||||
|
|
||||||
// Scripture index — every passage covered across released study lessons and podcast episodes
|
// Scripture index — every passage covered across released study lessons and podcast episodes
|
||||||
app.get('/api/public/scripture-index', async (_req, res) => {
|
app.get('/api/public/scripture-index', async (_req, res) => {
|
||||||
const content = state.cachedSiteContent
|
const content = await applyFeedRelease(state.cachedSiteContent)
|
||||||
const allStudies = Array.isArray(content?.studies) ? content.studies : []
|
const allStudies = Array.isArray(content?.studies) ? content.studies : []
|
||||||
const allEpisodes = [
|
const allEpisodes = [
|
||||||
...(Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []),
|
...(Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []),
|
||||||
@@ -212,7 +217,8 @@ export function register(app) {
|
|||||||
if (!sec.id || !sec.reference || !sec.releasedAt) continue
|
if (!sec.id || !sec.reference || !sec.releasedAt) continue
|
||||||
if (new Date(sec.releasedAt) > cutoff) continue
|
if (new Date(sec.releasedAt) > cutoff) continue
|
||||||
entries.push({
|
entries.push({
|
||||||
reference: `${bookName} ${sec.reference}`,
|
// Section references often already include the book ("Titus 1:1-2"); only prepend when they don't
|
||||||
|
reference: /^\s*(?:[1-3]\s*)?[A-Za-z]/.test(sec.reference) ? sec.reference.trim() : `${bookName} ${sec.reference}`,
|
||||||
sourceType: 'study',
|
sourceType: 'study',
|
||||||
sourceTitle: s.title || s.slug,
|
sourceTitle: s.title || s.slug,
|
||||||
sectionTitle: sec.title || '',
|
sectionTitle: sec.title || '',
|
||||||
@@ -269,6 +275,8 @@ export function register(app) {
|
|||||||
app.use(async (_req, res) => {
|
app.use(async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const html = await readFile(INDEX_FILE, 'utf8')
|
const html = await readFile(INDEX_FILE, 'utf8')
|
||||||
|
// Unknown URLs still render the app's "Page not found" screen, but with a real 404 status
|
||||||
|
if (!isKnownSitePath(_req.path)) res.status(404)
|
||||||
res.type('html').send(injectSeoIntoHtml(html, state.cachedSiteContent))
|
res.type('html').send(injectSeoIntoHtml(html, state.cachedSiteContent))
|
||||||
} catch {
|
} catch {
|
||||||
res.status(503).send('Frontend build not found. Run "npm run build" first.')
|
res.status(503).send('Frontend build not found. Run "npm run build" first.')
|
||||||
|
|||||||
+102
-25
@@ -20,6 +20,51 @@ const upvoteRateLimiter = rateLimit({
|
|||||||
const upvotedBy = new Set()
|
const upvotedBy = new Set()
|
||||||
const UPVOTED_BY_MAX = 50000
|
const UPVOTED_BY_MAX = 50000
|
||||||
|
|
||||||
|
// Only these fields are ever sent to the public; the stored record also holds the asker's email.
|
||||||
|
function toPublicQuestion(q) {
|
||||||
|
return {
|
||||||
|
id: q.id,
|
||||||
|
firstName: q.firstName,
|
||||||
|
question: q.question,
|
||||||
|
answer: q.answer,
|
||||||
|
topic: q.topic || '',
|
||||||
|
reference: q.reference || '',
|
||||||
|
submittedAt: q.submittedAt,
|
||||||
|
answeredAt: q.answeredAt,
|
||||||
|
upvotes: q.upvotes ?? 0,
|
||||||
|
pinned: q.pinned === true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = (value, max) => (typeof value === 'string' ? value.trim().slice(0, max) : '')
|
||||||
|
|
||||||
|
// Email the asker once when their answer goes live (if they asked to be told).
|
||||||
|
async function notifyAsker(question) {
|
||||||
|
if (!question.notifyOnAnswer || !question.email || !question.answer || question.notifiedAt) return
|
||||||
|
try {
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
const from = getResendFromAddress()
|
||||||
|
const replyTo = getResendReplyToAddress()
|
||||||
|
const baseUrl = getCanonicalBaseUrl()
|
||||||
|
const questionUrl = buildAbsoluteUrl(baseUrl, `/questions#qa-${question.id}`)
|
||||||
|
const template = buildQuestionAnsweredEmailTemplate({
|
||||||
|
firstName: question.firstName,
|
||||||
|
question: question.question,
|
||||||
|
answer: question.answer,
|
||||||
|
questionUrl,
|
||||||
|
})
|
||||||
|
await sendResendEmailWithRetry({
|
||||||
|
resend,
|
||||||
|
payload: { from, replyTo, to: question.email, subject: template.subject, text: template.text, html: template.html },
|
||||||
|
context: `notify-on-answer:${question.id}`,
|
||||||
|
})
|
||||||
|
question.notifiedAt = new Date().toISOString()
|
||||||
|
queueDraftQuestionsWrite()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[notify-on-answer] Failed to send notification email:', err?.message ?? err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensureDraftQuestions() {
|
function ensureDraftQuestions() {
|
||||||
if (state.draftQuestions !== null) return
|
if (state.draftQuestions !== null) return
|
||||||
state.draftQuestions = state.questions.slice(0, MAX_QUESTIONS)
|
state.draftQuestions = state.questions.slice(0, MAX_QUESTIONS)
|
||||||
@@ -31,12 +76,14 @@ export function register(app) {
|
|||||||
const publicQuestions = sourceQuestions
|
const publicQuestions = sourceQuestions
|
||||||
.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
|
.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// Pinned questions always float to top
|
// Pinned first (they are also the ones featured on the home page), then newest answer first
|
||||||
if (a.pinned && !b.pinned) return -1
|
if (a.pinned && !b.pinned) return -1
|
||||||
if (!a.pinned && b.pinned) return 1
|
if (!a.pinned && b.pinned) return 1
|
||||||
return 0
|
const at = new Date(a.answeredAt ?? a.submittedAt ?? 0).getTime() || 0
|
||||||
|
const bt = new Date(b.answeredAt ?? b.submittedAt ?? 0).getTime() || 0
|
||||||
|
return bt - at
|
||||||
})
|
})
|
||||||
res.json({ questions: publicQuestions })
|
res.json({ questions: publicQuestions.map(toPublicQuestion) })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
|
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
|
||||||
@@ -75,6 +122,8 @@ export function register(app) {
|
|||||||
answeredAt: answerText ? now : null,
|
answeredAt: answerText ? now : null,
|
||||||
isApproved: approveNow,
|
isApproved: approveNow,
|
||||||
approvedAt: approveNow ? now : null,
|
approvedAt: approveNow ? now : null,
|
||||||
|
topic: clean(req.body?.topic, 60),
|
||||||
|
reference: clean(req.body?.reference, 80),
|
||||||
}
|
}
|
||||||
|
|
||||||
state.draftQuestions.unshift(created)
|
state.draftQuestions.unshift(created)
|
||||||
@@ -119,32 +168,60 @@ export function register(app) {
|
|||||||
question.approvedAt = approved === true ? new Date().toISOString() : null
|
question.approvedAt = approved === true ? new Date().toISOString() : null
|
||||||
queueDraftQuestionsWrite()
|
queueDraftQuestionsWrite()
|
||||||
|
|
||||||
if (approved === true && question.notifyOnAnswer && question.email && question.answer) {
|
if (approved === true) await notifyAsker(question)
|
||||||
try {
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
|
||||||
const from = getResendFromAddress()
|
|
||||||
const replyTo = getResendReplyToAddress()
|
|
||||||
const baseUrl = getCanonicalBaseUrl()
|
|
||||||
const questionUrl = buildAbsoluteUrl(baseUrl, `/questions#qa-${question.id}`)
|
|
||||||
const template = buildQuestionAnsweredEmailTemplate({
|
|
||||||
firstName: question.firstName,
|
|
||||||
question: question.question,
|
|
||||||
answer: question.answer,
|
|
||||||
questionUrl,
|
|
||||||
})
|
|
||||||
await sendResendEmailWithRetry({
|
|
||||||
resend,
|
|
||||||
payload: { from, replyTo, to: question.email, subject: template.subject, text: template.text, html: template.html },
|
|
||||||
context: `notify-on-answer:${question.id}`,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[notify-on-answer] Failed to send notification email:', err?.message ?? err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ ok: true, question })
|
res.json({ ok: true, question })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Edit any part of a question (typo fixes, answer, topic, scripture reference)
|
||||||
|
app.patch('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
|
||||||
|
ensureDraftQuestions()
|
||||||
|
const question = state.draftQuestions.find(q => q.id === req.params.id)
|
||||||
|
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
|
||||||
|
|
||||||
|
const body = req.body ?? {}
|
||||||
|
if (typeof body.question === 'string') {
|
||||||
|
const text = body.question.trim()
|
||||||
|
if (text.length < 5 || text.length > 3000) { res.status(400).json({ message: 'Question must be between 5 and 3000 characters.' }); return }
|
||||||
|
question.question = text
|
||||||
|
}
|
||||||
|
if (typeof body.answer === 'string') {
|
||||||
|
const text = body.answer.trim()
|
||||||
|
if (text.length > 5000) { res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return }
|
||||||
|
if (text !== (question.answer ?? '')) question.answeredAt = text ? new Date().toISOString() : null
|
||||||
|
question.answer = text
|
||||||
|
}
|
||||||
|
if (typeof body.firstName === 'string' && body.firstName.trim()) question.firstName = clean(body.firstName, 100)
|
||||||
|
if (typeof body.topic === 'string') question.topic = clean(body.topic, 60)
|
||||||
|
if (typeof body.reference === 'string') question.reference = clean(body.reference, 80)
|
||||||
|
queueDraftQuestionsWrite()
|
||||||
|
res.json({ ok: true, question })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save the answer, make it public and email the asker (once) — all in one step
|
||||||
|
app.post('/api/admin-questions/:id/publish', requireAdminAuth, async (req, res) => {
|
||||||
|
ensureDraftQuestions()
|
||||||
|
const question = state.draftQuestions.find(q => q.id === req.params.id)
|
||||||
|
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
|
||||||
|
|
||||||
|
const body = req.body ?? {}
|
||||||
|
if (typeof body.answer === 'string') {
|
||||||
|
const text = body.answer.trim()
|
||||||
|
if (text.length > 5000) { res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return }
|
||||||
|
if (text !== (question.answer ?? '')) question.answeredAt = text ? new Date().toISOString() : null
|
||||||
|
question.answer = text
|
||||||
|
}
|
||||||
|
if (typeof body.topic === 'string') question.topic = clean(body.topic, 60)
|
||||||
|
if (typeof body.reference === 'string') question.reference = clean(body.reference, 80)
|
||||||
|
if (!question.answer?.trim()) { res.status(400).json({ message: 'Write an answer before publishing.' }); return }
|
||||||
|
|
||||||
|
question.isApproved = true
|
||||||
|
question.approvedAt = new Date().toISOString()
|
||||||
|
queueDraftQuestionsWrite()
|
||||||
|
await notifyAsker(question)
|
||||||
|
res.json({ ok: true, question })
|
||||||
|
})
|
||||||
|
|
||||||
app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
|
app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
|
||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
ensureDraftQuestions()
|
ensureDraftQuestions()
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { Resend } from 'resend'
|
||||||
|
import { escapeHtml, sanitizeUrl } from '../helpers.js'
|
||||||
|
import { state } from '../state.js'
|
||||||
|
import { getResendFromAddress, getResendInboxAddress, getResendReplyToAddress, getCanonicalBaseUrl, sendResendEmailWithRetry } from '../email.js'
|
||||||
|
|
||||||
|
// A visitor who finished the /salvation page can tell us they prayed to receive Christ.
|
||||||
|
// Nothing is stored on the server: the response is emailed once to the site inbox and that is all.
|
||||||
|
const hits = new Map()
|
||||||
|
// One confirmation per address per hour, so the form can't be used to mail someone repeatedly.
|
||||||
|
const confirmedAt = new Map()
|
||||||
|
const CONFIRM_COOLDOWN_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
function mayConfirm(address) {
|
||||||
|
const key = address.toLowerCase()
|
||||||
|
const now = Date.now()
|
||||||
|
const last = confirmedAt.get(key)
|
||||||
|
if (typeof last === 'number' && now - last < CONFIRM_COOLDOWN_MS) return false
|
||||||
|
confirmedAt.set(key, now)
|
||||||
|
if (confirmedAt.size > 2000) {
|
||||||
|
for (const [k, v] of confirmedAt) { if (now - v > CONFIRM_COOLDOWN_MS) confirmedAt.delete(k) }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateLimit(req, res, next) {
|
||||||
|
const ip = req.ip ?? 'unknown'
|
||||||
|
const now = Date.now()
|
||||||
|
const windowMs = 10 * 60 * 1000
|
||||||
|
const entry = hits.get(ip) ?? { count: 0, start: now }
|
||||||
|
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
|
||||||
|
entry.count += 1
|
||||||
|
hits.set(ip, entry)
|
||||||
|
if (hits.size > 2000) {
|
||||||
|
for (const [k, v] of hits) { if (now - v.start > windowMs) hits.delete(k) }
|
||||||
|
}
|
||||||
|
if (entry.count > 3) {
|
||||||
|
res.status(429).json({ message: 'Please wait a few minutes before trying again.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean(value, max) {
|
||||||
|
return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ').slice(0, max) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// The notification the site owner receives. Styled to match the other admin emails
|
||||||
|
// (dark header, gold accent, cream cards) and written to be acted on, not just read.
|
||||||
|
function buildSalvationEmail({ name, email, message, when }) {
|
||||||
|
const who = name || 'Someone'
|
||||||
|
const subject = name ? `A new decision for Christ: ${name}` : 'A new decision for Christ on your salvation page'
|
||||||
|
const nameLine = name || 'Name not given'
|
||||||
|
const safeName = escapeHtml(nameLine)
|
||||||
|
const safeEmail = escapeHtml(email)
|
||||||
|
const safeWhen = escapeHtml(when)
|
||||||
|
|
||||||
|
const followUp = email
|
||||||
|
? [
|
||||||
|
'Reply to this email within a day or two. A short, warm note is plenty.',
|
||||||
|
'Congratulate them, and ask them to tell you their story.',
|
||||||
|
'Point them to the Gospel of John, a Bible-teaching church, and baptism.',
|
||||||
|
'Invite them to Start Here at versebyversewithnate.us/start-here.',
|
||||||
|
'Pray for them by name.',
|
||||||
|
]
|
||||||
|
: ['They did not leave an email address, so there is no way to reply.', 'Pray for them. God knows exactly who they are.']
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
`A VERDICT HAS COME IN`,
|
||||||
|
``,
|
||||||
|
`${who} pleaded guilty and accepted Christ's pardon on the "The Gospel, in a Courtroom" page and says they prayed to receive Jesus as their Savior.`,
|
||||||
|
``,
|
||||||
|
`"There is joy in the presence of the angels of God over one sinner who repents." (Luke 15:10)`,
|
||||||
|
``,
|
||||||
|
`DETAILS`,
|
||||||
|
`Name: ${nameLine}`,
|
||||||
|
`Email: ${email || 'Not given'}`,
|
||||||
|
`When: ${when}`,
|
||||||
|
``,
|
||||||
|
`THEIR MESSAGE`,
|
||||||
|
message || '(They did not leave a message.)',
|
||||||
|
``,
|
||||||
|
`SUGGESTED FOLLOW-UP`,
|
||||||
|
...followUp.map((t, i) => `${i + 1}. ${t}`),
|
||||||
|
``,
|
||||||
|
`This email is the only record of this response. Nothing is stored on the website, so keep it somewhere safe.`,
|
||||||
|
email ? `Reply directly to this email to reach ${who}.` : '',
|
||||||
|
].filter(l => l !== '').join('\n')
|
||||||
|
|
||||||
|
const row = (label, value, last = false) =>
|
||||||
|
`<tr><td style="width:96px;padding:11px 0;font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#8a6d35;${last ? '' : 'border-bottom:1px solid #efe4cc;'}">${label}</td>` +
|
||||||
|
`<td style="padding:11px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;${last ? '' : 'border-bottom:1px solid #efe4cc;'}">${value}</td></tr>`
|
||||||
|
|
||||||
|
const steps = followUp
|
||||||
|
.map(t => `<li style="margin:0 0 8px;font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#201a10;">${escapeHtml(t)}</li>`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const messageBlock = message
|
||||||
|
? `<tr><td style="padding:0 28px 22px;">
|
||||||
|
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 8px;">Their message</div>
|
||||||
|
<div style="background:#fbf7ef;border-left:4px solid #c8860a;border-radius:4px;padding:16px 18px;font-family:Georgia,serif;font-size:16px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(message)}</div>
|
||||||
|
</td></tr>`
|
||||||
|
: `<tr><td style="padding:0 28px 22px;font-family:Arial,sans-serif;font-size:14px;color:#735a2b;font-style:italic;">They did not leave a message.</td></tr>`
|
||||||
|
|
||||||
|
const replyButton = email
|
||||||
|
? `<tr><td align="center" style="padding:0 28px 26px;">
|
||||||
|
<a href="mailto:${safeEmail}?subject=${encodeURIComponent('Welcome to the family of God')}" style="display:inline-block;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:14px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;text-decoration:none;padding:14px 30px;border-radius:4px;">Reply to ${escapeHtml(who)}</a>
|
||||||
|
</td></tr>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const html = `<!doctype html><html><body style="margin:0;padding:0;background:#f5f1e8;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background:#f5f1e8;"><tr><td align="center" style="padding:28px 14px;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:640px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
|
||||||
|
<tr><td style="background:#111111;padding:30px 28px 26px;text-align:center;border-bottom:4px solid #c8860a;">
|
||||||
|
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.34em;text-transform:uppercase;color:#c8860a;">The Court of Heaven</div>
|
||||||
|
<div style="font-size:40px;line-height:1;margin:14px 0 6px;color:#c8860a;">⚖</div>
|
||||||
|
<h1 style="margin:0;font-family:Georgia,serif;font-size:30px;line-height:1.2;color:#f4ead5;">A Verdict Has Come In</h1>
|
||||||
|
<p style="margin:10px 0 0;font-family:Georgia,serif;font-style:italic;font-size:17px;color:#d9c9a3;">${escapeHtml(who)} pleaded guilty and accepted the pardon.</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:28px 28px 8px;">
|
||||||
|
<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;">Someone just worked through <em>The Gospel, in a Courtroom</em> on your website, pleaded guilty, and says they prayed to receive Jesus as their Savior.</p>
|
||||||
|
<p style="margin:0 0 20px;font-family:Georgia,serif;font-style:italic;font-size:16px;line-height:1.6;color:#57452b;border-left:3px solid #c8860a;padding-left:14px;">“There is joy in the presence of the angels of God over one sinner who repents.” <span style="font-style:normal;font-size:13px;color:#8a6d35;">Luke 15:10</span></p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:0 28px 22px;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="border-top:2px solid #c8860a;">
|
||||||
|
${row('Name', safeName)}
|
||||||
|
${row('Email', email ? `<a href="mailto:${safeEmail}" style="color:#8f5f05;text-decoration:none;">${safeEmail}</a>` : '<span style="color:#735a2b;font-style:italic;">Not given</span>')}
|
||||||
|
${row('When', safeWhen, true)}
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
${messageBlock}
|
||||||
|
${replyButton}
|
||||||
|
<tr><td style="padding:0 28px 26px;">
|
||||||
|
<div style="background:#f7f2e5;border:1px solid #e8dcc1;border-radius:10px;padding:18px 22px;">
|
||||||
|
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 12px;">Suggested follow-up</div>
|
||||||
|
<ol style="margin:0;padding-left:20px;">${steps}</ol>
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:16px 28px;">
|
||||||
|
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#735a2b;"><strong>Keep this email.</strong> It is the only record of this response. Nothing is stored on the website. ${email ? 'Replying to this message goes straight to them.' : ''}</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr></table></body></html>`
|
||||||
|
|
||||||
|
return { subject, text, html }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The warm, one-time confirmation the visitor receives at the address they gave.
|
||||||
|
function buildSalvationConfirmationEmail({ name }) {
|
||||||
|
const base = getCanonicalBaseUrl().replace(/\/$/, '')
|
||||||
|
const greeting = name ? `Hi ${name},` : 'Hi friend,'
|
||||||
|
const subject = 'We received your message. Welcome to the family of God'
|
||||||
|
const steps = [
|
||||||
|
['Read the Gospel of John.', 'Start there, then keep going through the New Testament.'],
|
||||||
|
['Talk to God every day.', 'Prayer is simply talking with him, in your own words.'],
|
||||||
|
['Tell someone.', 'Share your decision with a Christian friend or family member.'],
|
||||||
|
['Find a church.', 'Join a Bible-teaching church, and be baptized as a public sign of your faith (Acts 2:38).'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
greeting,
|
||||||
|
'',
|
||||||
|
'Thank you for letting us know that you prayed to receive Jesus. We are rejoicing with you, and your message has been received.',
|
||||||
|
'Someone from our team will reach out to you personally soon.',
|
||||||
|
'',
|
||||||
|
'"Therefore, there is now no condemnation for those who are in Christ Jesus." (Romans 8:1)',
|
||||||
|
'You are no longer a defendant. You are a child of the Judge.',
|
||||||
|
'',
|
||||||
|
'WHAT TO DO NEXT',
|
||||||
|
...steps.map(([h, b], i) => `${i + 1}. ${h} ${b}`),
|
||||||
|
'',
|
||||||
|
`A good path through the podcast and studies: ${base}/start-here`,
|
||||||
|
`Questions? Ask here: ${base}/contact?source=salvation`,
|
||||||
|
'',
|
||||||
|
'Grace and peace,',
|
||||||
|
'Verse by Verse with Nate',
|
||||||
|
'',
|
||||||
|
`You are receiving this one-time email because this address was entered on ${base}/salvation. If that was not you, you can safely ignore it.`,
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const stepRows = steps
|
||||||
|
.map(([h, b], i) =>
|
||||||
|
`<tr><td valign="top" style="width:34px;padding:0 0 14px;"><div style="width:26px;height:26px;border-radius:13px;background:#c8860a;color:#111;font-family:Arial,sans-serif;font-size:13px;font-weight:700;line-height:26px;text-align:center;">${i + 1}</div></td>` +
|
||||||
|
`<td style="padding:2px 0 14px;font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#201a10;"><strong>${escapeHtml(h)}</strong> ${escapeHtml(b)}</td></tr>`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const html = `<!doctype html><html><body style="margin:0;padding:0;background:#f5f1e8;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background:#f5f1e8;"><tr><td align="center" style="padding:28px 14px;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:600px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
|
||||||
|
<tr><td style="background:#111111;padding:30px 28px 26px;text-align:center;border-bottom:4px solid #c8860a;">
|
||||||
|
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.34em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
|
||||||
|
<div style="width:54px;height:54px;border-radius:27px;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:30px;font-weight:700;line-height:54px;margin:16px auto 10px;">✓</div>
|
||||||
|
<h1 style="margin:0;font-family:Georgia,serif;font-size:28px;line-height:1.25;color:#f4ead5;">Your message was received</h1>
|
||||||
|
<p style="margin:10px 0 0;font-family:Georgia,serif;font-style:italic;font-size:17px;color:#d9c9a3;">Welcome to the family of God.</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:30px 28px 6px;">
|
||||||
|
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:18px;line-height:1.6;color:#201a10;">${escapeHtml(greeting)}</p>
|
||||||
|
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;">Thank you for letting us know that you prayed to receive Jesus. We are rejoicing with you, and your message has been received.</p>
|
||||||
|
<p style="margin:0 0 22px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;"><strong>Someone from our team will reach out to you personally soon.</strong></p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:0 28px 26px;">
|
||||||
|
<div style="background:#fbf7ef;border-left:4px solid #c8860a;border-radius:4px;padding:18px 20px;">
|
||||||
|
<p style="margin:0 0 8px;font-family:Georgia,serif;font-style:italic;font-size:17px;line-height:1.6;color:#201a10;">“Therefore, there is now no condemnation for those who are in Christ Jesus.” <span style="font-style:normal;font-size:13px;color:#8a6d35;">Romans 8:1</span></p>
|
||||||
|
<p style="margin:0;font-family:Georgia,serif;font-size:15px;line-height:1.6;color:#57452b;">You are no longer a defendant. You are a child of the Judge.</p>
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:0 28px 8px;">
|
||||||
|
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 14px;">What to do next</div>
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation">${stepRows}</table>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td align="center" style="padding:6px 28px 8px;">
|
||||||
|
<a href="${escapeHtml(base)}/start-here" style="display:inline-block;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:14px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;text-decoration:none;padding:14px 28px;border-radius:4px;">Start Here</a>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td align="center" style="padding:6px 28px 26px;font-family:Arial,sans-serif;font-size:14px;color:#57452b;">
|
||||||
|
Have a question? <a href="${escapeHtml(base)}/contact?source=salvation" style="color:#8f5f05;">Ask us here</a>, or just reply to this email.
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:0 28px 28px;">
|
||||||
|
<p style="margin:0;font-family:Georgia,serif;font-size:16px;line-height:1.6;color:#201a10;">Grace and peace,<br><strong>Verse by Verse with Nate</strong></p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:16px 28px;">
|
||||||
|
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#735a2b;">You are receiving this one-time email because this address was entered at ${escapeHtml(base)}/salvation. If that was not you, you can safely ignore it.</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr></table></body></html>`
|
||||||
|
|
||||||
|
return { subject, text, html }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(app) {
|
||||||
|
// The /salvation page is static HTML, so it asks the server for the narration URLs set in Admin → Gospel Page.
|
||||||
|
app.get('/api/salvation-audio', (_req, res) => {
|
||||||
|
const c = state.cachedSiteContent ?? {}
|
||||||
|
res.set('Cache-Control', 'public, max-age=300')
|
||||||
|
res.json({
|
||||||
|
url: sanitizeUrl(c.salvationAudioUrl) || '',
|
||||||
|
guiltyUrl: sanitizeUrl(c.salvationAudioGuiltyUrl) || '',
|
||||||
|
notGuiltyUrl: sanitizeUrl(c.salvationAudioNotGuiltyUrl) || '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/salvation-response', rateLimit, async (req, res) => {
|
||||||
|
const { name, email, message, _honey } = req.body ?? {}
|
||||||
|
if (_honey) { res.json({ ok: true }); return }
|
||||||
|
|
||||||
|
const safeName = clean(name, 100)
|
||||||
|
const safeEmail = clean(email, 200)
|
||||||
|
const safeMessage = typeof message === 'string' ? message.trim().slice(0, 2000) : ''
|
||||||
|
if (safeEmail && !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(safeEmail)) {
|
||||||
|
res.status(400).json({ message: 'That email address does not look right. You can also leave it blank.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.RESEND_API_KEY) {
|
||||||
|
console.error('[salvation] RESEND_API_KEY env var not set')
|
||||||
|
res.status(503).json({ message: 'We could not send this right now.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const when = new Date().toLocaleString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'America/New_York', timeZoneName: 'short' })
|
||||||
|
const { subject, text, html } = buildSalvationEmail({ name: safeName, email: safeEmail, message: safeMessage, when })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
await sendResendEmailWithRetry({
|
||||||
|
resend,
|
||||||
|
context: 'salvation-response',
|
||||||
|
payload: {
|
||||||
|
from: getResendFromAddress(),
|
||||||
|
to: [getResendInboxAddress()],
|
||||||
|
...(safeEmail ? { replyTo: safeEmail } : {}),
|
||||||
|
subject,
|
||||||
|
tags: [{ name: 'flow', value: 'salvation-response' }],
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Best effort: a failed or skipped confirmation must never turn a delivered decision into an error.
|
||||||
|
let confirmationSent = false
|
||||||
|
if (safeEmail && mayConfirm(safeEmail)) {
|
||||||
|
try {
|
||||||
|
const confirmation = buildSalvationConfirmationEmail({ name: safeName })
|
||||||
|
await sendResendEmailWithRetry({
|
||||||
|
resend,
|
||||||
|
context: 'salvation-confirmation',
|
||||||
|
payload: {
|
||||||
|
from: getResendFromAddress(),
|
||||||
|
to: [safeEmail],
|
||||||
|
replyTo: getResendReplyToAddress(),
|
||||||
|
subject: confirmation.subject,
|
||||||
|
tags: [{ name: 'flow', value: 'salvation-confirmation' }],
|
||||||
|
text: confirmation.text,
|
||||||
|
html: confirmation.html,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
confirmationSent = true
|
||||||
|
} catch (confirmErr) {
|
||||||
|
console.error('[salvation] confirmation email failed:', confirmErr?.message ?? confirmErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({ ok: true, confirmationSent })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[salvation] email failed:', err?.message ?? err)
|
||||||
|
res.status(502).json({ message: 'We could not send this right now.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { noteEntriesToText } from '../../shared/note-format.js'
|
||||||
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
||||||
import rateLimit from 'express-rate-limit'
|
import rateLimit from 'express-rate-limit'
|
||||||
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
|
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
|
||||||
@@ -87,7 +88,7 @@ export function register(app) {
|
|||||||
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
|
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
|
||||||
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
|
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
|
||||||
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
|
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
|
||||||
studyEntries[studySlug][sectionId].noteText = noteText.trim()
|
studyEntries[studySlug][sectionId].noteText = noteEntriesToText(noteText) || noteText.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
|
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export function register(app) {
|
|||||||
subscribeNewsletter: user?.subscribeNewsletter !== false,
|
subscribeNewsletter: user?.subscribeNewsletter !== false,
|
||||||
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
studyRemindersEnabled: user?.studyRemindersEnabled === true,
|
||||||
avatarUrl: user ? getStudyAvatarUrl(user) : '',
|
avatarUrl: user ? getStudyAvatarUrl(user) : '',
|
||||||
|
// Shows an Admin link in the account menu. Only the admin panel can turn this on, and the link
|
||||||
|
// still lands on the admin sign-in, so it grants no access by itself.
|
||||||
|
adminShortcut: user?.adminShortcut === true,
|
||||||
// Studies the student can open: enrolled ones plus any they have finished
|
// Studies the student can open: enrolled ones plus any they have finished
|
||||||
enrolledStudySlugs: user ? getAccessibleStudySlugs(user) : [],
|
enrolledStudySlugs: user ? getAccessibleStudySlugs(user) : [],
|
||||||
completedStudySlugs: Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : [],
|
completedStudySlugs: Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : [],
|
||||||
@@ -114,7 +117,8 @@ export function register(app) {
|
|||||||
passwordHash: hashStudyPassword(password),
|
passwordHash: hashStudyPassword(password),
|
||||||
displayName,
|
displayName,
|
||||||
subscribeNewsletter: subscribe,
|
subscribeNewsletter: subscribe,
|
||||||
studyRemindersEnabled: false,
|
studyRemindersEnabled: true,
|
||||||
|
remindersDefaultedAt: now,
|
||||||
pendingEmailChange: null,
|
pendingEmailChange: null,
|
||||||
emailVerified: false,
|
emailVerified: false,
|
||||||
pendingEmailVerification: {
|
pendingEmailVerification: {
|
||||||
|
|||||||
@@ -147,7 +147,11 @@ export function register(app) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const rawNote = typeof req.body?.note === 'string' ? req.body.note : ''
|
const rawNote = typeof req.body?.note === 'string' ? req.body.note : ''
|
||||||
const note = rawNote.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
|
const note = rawNote.trim()
|
||||||
|
if (note.length > MAX_STUDY_NOTE_LENGTH) {
|
||||||
|
res.status(413).json({ message: 'This lesson\'s notes are too long to save. Please trim some text.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
const notes = await loadUserNotes(user.id)
|
const notes = await loadUserNotes(user.id)
|
||||||
|
|
||||||
if (!note) {
|
if (!note) {
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { state } from './state.js'
|
||||||
|
import { requireAdminAuth } from './auth.js'
|
||||||
|
import { scriptKey } from './episode-script-keys.js'
|
||||||
|
import { getFeedIndex } from './feed-release.js'
|
||||||
|
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
|
||||||
|
|
||||||
|
// One report per lane (A = primary series, B = second series) listing what is set up, what is
|
||||||
|
// missing, and an episode-by-episode grid. Reads the working draft so problems show up before
|
||||||
|
// publishing. Read-only: it never changes anything.
|
||||||
|
|
||||||
|
const err = (message, view) => ({ severity: 'error', message, view })
|
||||||
|
const warn = (message, view) => ({ severity: 'warn', message, view })
|
||||||
|
|
||||||
|
function laneConfig(content, lane) {
|
||||||
|
if (lane === 'A') {
|
||||||
|
return {
|
||||||
|
title: String(content.seriesTitle ?? '').trim(),
|
||||||
|
studySlug: String(content.seriesStudySlug ?? '').trim(),
|
||||||
|
highlightLane: 'primary',
|
||||||
|
description: String(content.seriesDescription ?? '').trim(),
|
||||||
|
imageUrl: String(content.seriesImageUrl ?? '').trim(),
|
||||||
|
listenUrl: String(content.seriesListenUrl ?? '').trim(),
|
||||||
|
hasGuide: Boolean(String(content.studyGuideTitle ?? '').trim() && (content.studyGuideDownloadUrl || content.studyGuideUrl || content.studyGuideAppleBooksUrl)),
|
||||||
|
transition: content.scheduledSeriesTransition,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const second = content.secondSeries ?? {}
|
||||||
|
return {
|
||||||
|
title: String(second.title ?? '').trim(),
|
||||||
|
studySlug: String(second.studySlug ?? '').trim(),
|
||||||
|
highlightLane: 'second',
|
||||||
|
description: String(second.description ?? '').trim(),
|
||||||
|
imageUrl: String(second.imageUrl ?? '').trim(),
|
||||||
|
listenUrl: String(second.listenUrl ?? '').trim(),
|
||||||
|
hasGuide: Boolean(String(second.guide?.title ?? '').trim() && (second.guide?.downloadUrl || second.guide?.amazonUrl || second.guide?.appleBooksUrl)),
|
||||||
|
transition: content.scheduledSecondSeriesTransition,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildSeasonCheck() {
|
||||||
|
const content = state.cachedDraftSiteContent ?? state.cachedSiteContent ?? {}
|
||||||
|
const [wf, index] = await Promise.all([loadPodcastWorkflow(), getFeedIndex()])
|
||||||
|
const seasons = { A: laneSeasonOf('A', wf, content), B: laneSeasonOf('B', wf, content) }
|
||||||
|
|
||||||
|
const studies = Array.isArray(content.studies) ? content.studies : []
|
||||||
|
const highlights = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
||||||
|
const planned = new Set(wf.planned.map(p => scriptKey(p.season, p.episodeNumber)))
|
||||||
|
|
||||||
|
const lanes = []
|
||||||
|
for (const lane of ['A', 'B']) {
|
||||||
|
const cfg = laneConfig(content, lane)
|
||||||
|
// Lane B only exists once a second series has been set up
|
||||||
|
if (lane === 'B' && !cfg.title && !cfg.studySlug && !seasons.B) continue
|
||||||
|
|
||||||
|
const season = seasons[lane]
|
||||||
|
const problems = []
|
||||||
|
if (!cfg.title) problems.push(warn(`Lane ${lane} has no series title.`, 'podcast:current-series'))
|
||||||
|
if (!season) problems.push(err(`Lane ${lane} has no season number. Highlights and lesson audio can't match the feed without it.`, 'podcast:workflow'))
|
||||||
|
if (season && lane === 'B' && season === seasons.A) problems.push(err(`Lanes A and B both use season ${season}.`, 'podcast:workflow'))
|
||||||
|
|
||||||
|
const study = studies.find(s => s?.lane === lane) ?? studies.find(s => s?.slug && s.slug === cfg.studySlug)
|
||||||
|
if (!study) problems.push(warn(`No study is linked to Lane ${lane}. Set the study's lane, or link it in Current Series.`, 'colossians-study'))
|
||||||
|
else if (study.lane !== lane) problems.push(warn(`"${study.title}" is linked to Lane ${lane} in Current Series but the study's own lane isn't set to ${lane}.`, 'colossians-study'))
|
||||||
|
|
||||||
|
const sections = Array.isArray(study?.sections) ? study.sections : []
|
||||||
|
const laneHighlights = highlights.filter(h => (h.lane ?? 'primary') === cfg.highlightLane)
|
||||||
|
|
||||||
|
const noNumber = sections.filter(s => !String(s.episodeNumber ?? '').trim())
|
||||||
|
if (study && noNumber.length > 0) {
|
||||||
|
problems.push(warn(`${noNumber.length} lesson${noNumber.length === 1 ? '' : 's'} in "${study.title}" ${noNumber.length === 1 ? 'has' : 'have'} no episode number, so ${noNumber.length === 1 ? 'it' : 'they'} can't pick up audio.`, 'colossians-study'))
|
||||||
|
}
|
||||||
|
const noDate = sections.filter(s => String(s.episodeNumber ?? '').trim() && !s.releasedAt)
|
||||||
|
if (study && noDate.length > 0) {
|
||||||
|
problems.push(warn(`${noDate.length} lesson${noDate.length === 1 ? '' : 's'} with an episode number ${noDate.length === 1 ? 'has' : 'have'} no release date.`, 'colossians-study'))
|
||||||
|
}
|
||||||
|
const noHighlightNumber = laneHighlights.filter(h => !String(h.episodeNumber ?? '').trim())
|
||||||
|
if (noHighlightNumber.length > 0) {
|
||||||
|
problems.push(warn(`${noHighlightNumber.length} highlight${noHighlightNumber.length === 1 ? '' : 's'} in Lane ${lane} ${noHighlightNumber.length === 1 ? 'has' : 'have'} no episode number.`, 'podcast:highlights'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Series card
|
||||||
|
if (!cfg.imageUrl) problems.push(warn(`Lane ${lane} has no cover image.`, 'podcast:current-series'))
|
||||||
|
if (!cfg.description) problems.push(warn(`Lane ${lane} has no series description.`, 'podcast:current-series'))
|
||||||
|
if (!cfg.listenUrl) problems.push(warn(`Lane ${lane} has no listen link.`, 'podcast:current-series'))
|
||||||
|
if (!cfg.hasGuide) problems.push(warn(`Lane ${lane} has no companion study guide (title plus a download or purchase link).`, 'podcast:current-series'))
|
||||||
|
|
||||||
|
// Handover to the next season
|
||||||
|
const t = cfg.transition
|
||||||
|
if (t) {
|
||||||
|
if (!String(t.newTitle ?? '').trim() || !t.newSeason) {
|
||||||
|
problems.push(warn(`The next-season handover for Lane ${lane} is scheduled but is missing its title or season number.`, 'podcast:current-series'))
|
||||||
|
} else if (season && String(t.newSeason) === season) {
|
||||||
|
problems.push(warn(`The next-season handover for Lane ${lane} uses the same season number (${season}) as the current one.`, 'podcast:current-series'))
|
||||||
|
}
|
||||||
|
} else if (study && sections.length > 0 && sections.every(x => x.releasedAt && new Date(x.releasedAt) <= new Date())) {
|
||||||
|
problems.push(warn(`Every lesson in "${study.title}" is released, but no next season is scheduled for Lane ${lane}.`, 'podcast:current-series'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Episode numbers to show: every number any part of the season mentions, plus 1..lesson count
|
||||||
|
const numbers = new Set()
|
||||||
|
for (let n = 1; n <= sections.length; n++) numbers.add(n)
|
||||||
|
const addNumber = v => { const n = Number(v); if (Number.isInteger(n) && n > 0) numbers.add(n) }
|
||||||
|
sections.forEach(s => addNumber(s.episodeNumber))
|
||||||
|
laneHighlights.forEach(h => addNumber(h.episodeNumber))
|
||||||
|
for (const [key, item] of index) if (season && key.startsWith(`${season}-`)) addNumber(item.episode)
|
||||||
|
for (const p of wf.planned) if (season && String(p.season) === season) addNumber(p.episodeNumber)
|
||||||
|
for (const key of Object.keys(state.episodeScripts ?? {})) if (season && key.startsWith(`${season}-`)) addNumber(key.split('-')[1])
|
||||||
|
|
||||||
|
const episodes = [...numbers].sort((a, b) => a - b).map(n => {
|
||||||
|
const num = String(n)
|
||||||
|
const feedItem = season ? index.get(`${season}-${num}`) : undefined
|
||||||
|
const released = Boolean(feedItem)
|
||||||
|
const lessons = sections.filter(s => String(s.episodeNumber ?? '').trim() === num)
|
||||||
|
const hl = laneHighlights.filter(h => String(h.episodeNumber ?? '').trim() === num)
|
||||||
|
const lessonHasAudio = lessons.some(s => s.audioEmbedUrl?.trim() || (released && feedItem?.audioUrl))
|
||||||
|
|
||||||
|
if (lessons.length > 1) problems.push(warn(`Episode ${num}: ${lessons.length} lessons share this episode number.`, 'colossians-study'))
|
||||||
|
if (hl.length > 1) problems.push(warn(`Episode ${num}: ${hl.length} highlights share this episode number.`, 'podcast:highlights'))
|
||||||
|
if (season && !released && hl.some(h => !h.holdUntilReleased && h.lane !== 'general')) {
|
||||||
|
problems.push(warn(`Episode ${num}: the highlight will show on the site before the episode is out. Turn on "Publish when the episode is released".`, 'podcast:highlights'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
number: n,
|
||||||
|
released,
|
||||||
|
releaseDate: feedItem?.pubDate ? new Date(feedItem.pubDate).toISOString().slice(0, 10) : '',
|
||||||
|
workflowStarted: season ? planned.has(scriptKey(season, num)) : false,
|
||||||
|
script: season ? Boolean(state.episodeScripts?.[scriptKey(season, num)]) : false,
|
||||||
|
lesson: lessons.length > 0,
|
||||||
|
lessonAudio: lessons.length > 0 ? lessonHasAudio : null,
|
||||||
|
highlight: hl.length > 0,
|
||||||
|
highlightHeld: hl.length > 0 ? hl.every(h => h.holdUntilReleased === true) : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
lanes.push({
|
||||||
|
lane,
|
||||||
|
title: cfg.title,
|
||||||
|
season,
|
||||||
|
studyTitle: study?.title ?? '',
|
||||||
|
problems,
|
||||||
|
errors: problems.filter(p => p.severity === 'error').length,
|
||||||
|
warnings: problems.filter(p => p.severity === 'warn').length,
|
||||||
|
episodes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scripts are filed by season + episode, so one with no season can't be matched to an episode
|
||||||
|
const general = []
|
||||||
|
const unseasoned = Object.entries(state.episodeScripts ?? {}).filter(([, entry]) => !String(entry?.season ?? '').trim())
|
||||||
|
if (unseasoned.length > 0) {
|
||||||
|
general.push(warn(`${unseasoned.length} episode script${unseasoned.length === 1 ? ' has' : 's have'} no season number, so ${unseasoned.length === 1 ? 'it' : 'they'} can't be matched to an episode. Use the season backfill in Episode Scripts.`, 'podcast:scripts'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = [...lanes.flatMap(l => l.problems), ...general]
|
||||||
|
return {
|
||||||
|
lanes,
|
||||||
|
general,
|
||||||
|
total: all.length,
|
||||||
|
errors: all.filter(p => p.severity === 'error').length,
|
||||||
|
warnings: all.filter(p => p.severity === 'warn').length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(app) {
|
||||||
|
app.get('/api/admin/season-check', requireAdminAuth, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await buildSeasonCheck())
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[season-check] failed:', err)
|
||||||
|
res.status(500).json({ message: 'Could not build the season check.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -96,6 +96,9 @@ export const state = {
|
|||||||
// newsletterIssues: Array<{ id, resendBroadcastId, slug, title, subject, description, html, sentAt, publishedAt, public }>
|
// newsletterIssues: Array<{ id, resendBroadcastId, slug, title, subject, description, html, sentAt, publishedAt, public }>
|
||||||
newsletterIssues: [],
|
newsletterIssues: [],
|
||||||
newsletterIssuesWritePromise: Promise.resolve(),
|
newsletterIssuesWritePromise: Promise.resolve(),
|
||||||
|
// nuggetsEpisodeTags: { [audioUrl]: string[] } — admin-assigned tags for Nuggets RSS episodes
|
||||||
|
nuggetsEpisodeTags: {},
|
||||||
|
nuggetsEpisodeTagsWritePromise: Promise.resolve(),
|
||||||
|
|
||||||
|
|
||||||
auditLog: [],
|
auditLog: [],
|
||||||
|
|||||||
@@ -476,6 +476,21 @@ export function normalizeHitPath(pathname) {
|
|||||||
return pathname
|
return pathname
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Top-level URL segments the SPA / static pages actually serve. Anything else is a probe or
|
||||||
|
// scanner hit, which must not count as a real visit or add a new key to the per-path stats.
|
||||||
|
const KNOWN_SITE_SEGMENTS = new Set([
|
||||||
|
'start-here', 'study', 'finished', 'episodes', 'nuggets', 'resources', 'downloads', 'about', 'contact',
|
||||||
|
'questions', 'newsletter', 'scripture', 'thanks', 'subscribe', 'certificate', 'host', 'admin', 'preview',
|
||||||
|
'privacy', 'terms', 'salvation', 'qr',
|
||||||
|
])
|
||||||
|
|
||||||
|
const MAX_UNKNOWN_PATHS = 300
|
||||||
|
|
||||||
|
export function isKnownSitePath(pathname) {
|
||||||
|
const first = String(pathname ?? '/').split('/')[1] ?? ''
|
||||||
|
return first === '' || KNOWN_SITE_SEGMENTS.has(first)
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldCountHit(req) {
|
export function shouldCountHit(req) {
|
||||||
if (req.method !== 'GET') return false
|
if (req.method !== 'GET') return false
|
||||||
if (req.path.startsWith('/api/')) return false
|
if (req.path.startsWith('/api/')) return false
|
||||||
@@ -580,6 +595,21 @@ export function recordHit(pathname, isBot = false, botReason = null) {
|
|||||||
state.hitStats.lastHitAt = nowIso
|
state.hitStats.lastHitAt = nowIso
|
||||||
state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso
|
state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso
|
||||||
|
|
||||||
|
if (isBot && botReason === 'unknown-path') {
|
||||||
|
// Probe/scanner URLs: keep a bounded list for the admin view instead of one stats key per URL
|
||||||
|
const unknown = (state.hitStats.byPathUnknown ??= {})
|
||||||
|
const key = safePath.slice(0, 120)
|
||||||
|
if (key in unknown || Object.keys(unknown).length < MAX_UNKNOWN_PATHS) unknown[key] = (unknown[key] ?? 0) + 1
|
||||||
|
else unknown['(other)'] = (unknown['(other)'] ?? 0) + 1
|
||||||
|
state.hitStats.botHits += 1
|
||||||
|
state.hitStats.byPathBot['(unknown)'] = (state.hitStats.byPathBot['(unknown)'] ?? 0) + 1
|
||||||
|
state.hitStats.byDayBot[dayKey] = (state.hitStats.byDayBot[dayKey] ?? 0) + 1
|
||||||
|
state.hitStats.botReasons[botReason] = (state.hitStats.botReasons[botReason] ?? 0) + 1
|
||||||
|
state.hitStats.byPath['(unknown)'] = (state.hitStats.byPath['(unknown)'] ?? 0) + 1
|
||||||
|
state.hitStats.byDay[dayKey] = (state.hitStats.byDay[dayKey] ?? 0) + 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isBot) {
|
if (isBot) {
|
||||||
state.hitStats.botHits += 1
|
state.hitStats.botHits += 1
|
||||||
state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1
|
state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1
|
||||||
@@ -706,6 +736,23 @@ export function extractResendMessageId(result) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-off pass: lesson reminders are now on by default, so switch them on for accounts that
|
||||||
|
// predate the default. Each account is marked as it is handled, so a later opt-out sticks.
|
||||||
|
export async function backfillReminderDefault() {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
let updated = 0
|
||||||
|
for (const user of state.studyUsers) {
|
||||||
|
if (user.remindersDefaultedAt) continue
|
||||||
|
if (user.studyRemindersEnabled !== true) {
|
||||||
|
user.studyRemindersEnabled = true
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
user.remindersDefaultedAt = now
|
||||||
|
}
|
||||||
|
if (state.studyUsers.some(user => user.remindersDefaultedAt === now)) queueStudyUsersWrite()
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
// ── Study reminder scheduler ───────────────────────────────────────────────
|
// ── Study reminder scheduler ───────────────────────────────────────────────
|
||||||
|
|
||||||
export async function scheduleStudyReminders(sendStudyReminderEmail) {
|
export async function scheduleStudyReminders(sendStudyReminderEmail) {
|
||||||
|
|||||||
+32
-1
@@ -67,7 +67,7 @@ export const BIBLE_BOOKS = [
|
|||||||
{ id: 'TH2', name: '2 Thessalonians', aliases: ['2 thess'] },
|
{ id: 'TH2', name: '2 Thessalonians', aliases: ['2 thess'] },
|
||||||
{ id: 'TI1', name: '1 Timothy', aliases: ['1 tim'] },
|
{ id: 'TI1', name: '1 Timothy', aliases: ['1 tim'] },
|
||||||
{ id: 'TI2', name: '2 Timothy', aliases: ['2 tim'] },
|
{ id: 'TI2', name: '2 Timothy', aliases: ['2 tim'] },
|
||||||
{ id: 'TIT', name: 'Titus' },
|
{ id: 'TIT', name: 'Titus', aliases: ['tit'] },
|
||||||
{ id: 'PHM', name: 'Philemon', aliases: ['philem'] },
|
{ id: 'PHM', name: 'Philemon', aliases: ['philem'] },
|
||||||
{ id: 'HEB', name: 'Hebrews', aliases: ['heb'] },
|
{ id: 'HEB', name: 'Hebrews', aliases: ['heb'] },
|
||||||
{ id: 'JAM', name: 'James', aliases: ['jas'] },
|
{ id: 'JAM', name: 'James', aliases: ['jas'] },
|
||||||
@@ -85,3 +85,34 @@ export function bookNumberFor(usfmId) {
|
|||||||
const index = BIBLE_BOOKS.findIndex(b => b.id === usfmId)
|
const index = BIBLE_BOOKS.findIndex(b => b.id === usfmId)
|
||||||
return index === -1 ? null : index + 1
|
return index === -1 ? null : index + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let referenceBookLookup = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks a passage reference the way the Scripture Index and passage fetch read it.
|
||||||
|
* `bookHint` is the study's Bible Book field (or slug) and is used when the reference
|
||||||
|
* has no book of its own ("1:1-2"). Returns null when it will work, otherwise a short
|
||||||
|
* message explaining what to change.
|
||||||
|
*/
|
||||||
|
export function checkPassageReference(reference, bookHint) {
|
||||||
|
if (!referenceBookLookup) {
|
||||||
|
referenceBookLookup = new Set()
|
||||||
|
for (const b of BIBLE_BOOKS) {
|
||||||
|
for (const key of [b.name.toLowerCase(), ...(b.aliases ?? [])]) {
|
||||||
|
referenceBookLookup.add(key)
|
||||||
|
referenceBookLookup.add(key.replace(/\s+/g, ''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const clean = String(reference ?? '').replace(/[–—]/g, '-').replace(/(\d)[a-z]\b/gi, '$1').trim()
|
||||||
|
if (!clean) return null
|
||||||
|
const m = clean.match(/^(?:(.*?)\s+)?(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/)
|
||||||
|
if (!m) return 'Use the form 1:1-2 (chapter:verse, optional range). It will be listed under "Other references" in the Scripture Index until fixed.'
|
||||||
|
const book = (m[1] ?? String(bookHint ?? '')).toLowerCase().replace(/\.\s*/g, ' ').trim()
|
||||||
|
if (!book || !referenceBookLookup.has(book)) {
|
||||||
|
return m[1]
|
||||||
|
? `"${m[1]}" is not a recognized Bible book name.`
|
||||||
|
: 'Set this study\'s "Bible Book" field (e.g. titus) so this reference can be matched to a book.'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
export type NoteAnchorId = 'scripture' | 'commentary' | 'questions' | 'general'
|
||||||
|
export interface NoteEntry {
|
||||||
|
id: string
|
||||||
|
anchor: NoteAnchorId
|
||||||
|
html: string
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
export const NOTE_ANCHORS: NoteAnchorId[]
|
||||||
|
export const NOTE_ANCHOR_LABELS: Record<NoteAnchorId, string>
|
||||||
|
export function newNoteId(): string
|
||||||
|
export function parseNoteEntries(raw: string): NoteEntry[]
|
||||||
|
export function serializeNoteEntries(entries: NoteEntry[]): string
|
||||||
|
export function noteHtmlToText(html: string): string
|
||||||
|
export function noteEntriesToText(raw: string): string
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Lesson note storage format, shared by the client and the server.
|
||||||
|
//
|
||||||
|
// Each lesson's notes are stored as one string. Current format (v2):
|
||||||
|
// {"v":2,"entries":[{"id","anchor","html","updatedAt"}]}
|
||||||
|
// Legacy format (v1) was a JSON object of { anchor: html }, and older still was
|
||||||
|
// plain text. Both are still read and are upgraded to v2 on the next save.
|
||||||
|
|
||||||
|
export const NOTE_ANCHORS = ['scripture', 'commentary', 'questions', 'general']
|
||||||
|
|
||||||
|
export const NOTE_ANCHOR_LABELS = {
|
||||||
|
scripture: 'Scripture Text',
|
||||||
|
commentary: 'Commentary',
|
||||||
|
questions: 'Reflection Questions',
|
||||||
|
general: 'General Notes',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newNoteId() {
|
||||||
|
return `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNoteEntries(raw) {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return []
|
||||||
|
let parsed = null
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
// Legacy plain-text note.
|
||||||
|
return [{ id: newNoteId(), anchor: 'general', html: raw, updatedAt: null }]
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return [{ id: newNoteId(), anchor: 'general', html: raw, updatedAt: null }]
|
||||||
|
}
|
||||||
|
if (parsed.v === 2 && Array.isArray(parsed.entries)) {
|
||||||
|
return parsed.entries
|
||||||
|
.filter(e => e && typeof e.html === 'string' && e.html.trim())
|
||||||
|
.map(e => ({
|
||||||
|
id: typeof e.id === 'string' && e.id ? e.id : newNoteId(),
|
||||||
|
anchor: NOTE_ANCHORS.includes(e.anchor) ? e.anchor : 'general',
|
||||||
|
html: e.html,
|
||||||
|
updatedAt: typeof e.updatedAt === 'string' ? e.updatedAt : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return Object.entries(parsed)
|
||||||
|
.filter(([, html]) => typeof html === 'string' && html.trim())
|
||||||
|
.map(([anchor, html]) => ({
|
||||||
|
id: `legacy-${anchor}`,
|
||||||
|
anchor: NOTE_ANCHORS.includes(anchor) ? anchor : 'general',
|
||||||
|
html,
|
||||||
|
updatedAt: null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeNoteEntries(entries) {
|
||||||
|
const kept = entries.filter(e => e.html && e.html.trim())
|
||||||
|
if (kept.length === 0) return ''
|
||||||
|
return JSON.stringify({ v: 2, entries: kept })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noteHtmlToText(html) {
|
||||||
|
return String(html)
|
||||||
|
.replace(/<\/(p|li|h[1-6]|blockquote)>/gi, '\n')
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<[^>]*>/g, '')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain-text rendering of a whole lesson's notes (used by the Word export).
|
||||||
|
export function noteEntriesToText(raw) {
|
||||||
|
return parseNoteEntries(raw)
|
||||||
|
.map(e => `${NOTE_ANCHOR_LABELS[e.anchor]}\n${noteHtmlToText(e.html)}`)
|
||||||
|
.join('\n\n')
|
||||||
|
}
|
||||||
+374
-387
@@ -1,3 +1,4 @@
|
|||||||
|
import { checkPassageReference } from '../shared/bible-books.js'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
|
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
|
||||||
@@ -9,6 +10,7 @@ import { DEFAULTS } from './content'
|
|||||||
import { AnalyticsPanel } from './components/AnalyticsPanel'
|
import { AnalyticsPanel } from './components/AnalyticsPanel'
|
||||||
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
|
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
|
||||||
import { AdminDashboardExtras } from './components/AdminDashboardExtras'
|
import { AdminDashboardExtras } from './components/AdminDashboardExtras'
|
||||||
|
import { QuestionsAdmin, type AdminQuestion } from './components/QuestionsAdmin'
|
||||||
import { useAutosave } from './hooks/useAutosave'
|
import { useAutosave } from './hooks/useAutosave'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import InboxPanel, { type InboxSubmission } from './InboxPanel'
|
import InboxPanel, { type InboxSubmission } from './InboxPanel'
|
||||||
@@ -119,6 +121,7 @@ interface StudyUserRecord {
|
|||||||
reminderFrequency: 'immediate' | 'weekly'
|
reminderFrequency: 'immediate' | 'weekly'
|
||||||
remindersPausedUntil: string | null
|
remindersPausedUntil: string | null
|
||||||
reengagementSentAt: Record<string, string>
|
reengagementSentAt: Record<string, string>
|
||||||
|
adminShortcut?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
||||||
@@ -144,7 +147,7 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
|||||||
.catch(() => setLoading(false))
|
.catch(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function patchUser(id: string, body: Record<string, string>) {
|
async function patchUser(id: string, body: Record<string, string | boolean>) {
|
||||||
const res = await fetch(`/api/admin/study-users/${id}`, {
|
const res = await fetch(`/api/admin/study-users/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -164,6 +167,14 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
|||||||
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
|
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleToggleAdminShortcut(user: StudyUserRecord, on: boolean) {
|
||||||
|
try {
|
||||||
|
await patchUser(user.id, { adminShortcut: on })
|
||||||
|
setUsers(prev => prev.map(u => (u.id === user.id ? { ...u, adminShortcut: on } : u)))
|
||||||
|
flashMsg(user.id, on ? 'Admin shortcut turned on.' : 'Admin shortcut turned off.')
|
||||||
|
} catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') }
|
||||||
|
}
|
||||||
|
|
||||||
async function handleResetPassword(user: StudyUserRecord) {
|
async function handleResetPassword(user: StudyUserRecord) {
|
||||||
const pw = newPassword[user.id] ?? ''
|
const pw = newPassword[user.id] ?? ''
|
||||||
if (pw.length < 8) { flashMsg(user.id, 'Password must be at least 8 characters.'); return }
|
if (pw.length < 8) { flashMsg(user.id, 'Password must be at least 8 characters.'); return }
|
||||||
@@ -264,6 +275,7 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
|||||||
{user.displayName && <span className="admin-study-user-email">{user.username}</span>}
|
{user.displayName && <span className="admin-study-user-email">{user.username}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-study-user-header-right">
|
<div className="admin-study-user-header-right">
|
||||||
|
{user.adminShortcut && <span className="admin-study-user-pill">Admin shortcut</span>}
|
||||||
<span className="admin-study-user-pill">{user.enrolledStudies.length} enrolled</span>
|
<span className="admin-study-user-pill">{user.enrolledStudies.length} enrolled</span>
|
||||||
<span className="admin-study-user-pill">{user.noteCount} notes</span>
|
<span className="admin-study-user-pill">{user.noteCount} notes</span>
|
||||||
{(user.currentStreak ?? 0) > 0 && <span className="admin-study-user-pill">{user.currentStreak} 🔥</span>}
|
{(user.currentStreak ?? 0) > 0 && <span className="admin-study-user-pill">{user.currentStreak} 🔥</span>}
|
||||||
@@ -303,6 +315,16 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Admin shortcut */}
|
||||||
|
<div className="admin-study-user-section">
|
||||||
|
<h4>Admin Shortcut</h4>
|
||||||
|
<label style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={user.adminShortcut === true} onChange={e => handleToggleAdminShortcut(user, e.target.checked)} />
|
||||||
|
Show an "Admin" link in this account's menu on the site
|
||||||
|
</label>
|
||||||
|
<p className="admin-stats-note" style={{ marginTop: '0.25rem' }}>Only accounts with this turned on see the link. It only takes them to the admin sign-in page; signing in still needs the admin password and code.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Password Reset */}
|
{/* Password Reset */}
|
||||||
<div className="admin-study-user-section">
|
<div className="admin-study-user-section">
|
||||||
<h4>Reset Password</h4>
|
<h4>Reset Password</h4>
|
||||||
@@ -595,6 +617,8 @@ export interface AdminStats {
|
|||||||
topPaths: Array<{ path: string; hits: number }>
|
topPaths: Array<{ path: string; hits: number }>
|
||||||
topPathsReal: Array<{ path: string; hits: number }>
|
topPathsReal: Array<{ path: string; hits: number }>
|
||||||
topPathsBot: Array<{ path: string; hits: number }>
|
topPathsBot: Array<{ path: string; hits: number }>
|
||||||
|
topUnknownPaths?: Array<{ path: string; hits: number }>
|
||||||
|
uniqueVisitors7d?: number
|
||||||
last7Days: Array<{ day: string; hits: number }>
|
last7Days: Array<{ day: string; hits: number }>
|
||||||
last7DaysReal: Array<{ day: string; hits: number }>
|
last7DaysReal: Array<{ day: string; hits: number }>
|
||||||
last7DaysBot: Array<{ day: string; hits: number }>
|
last7DaysBot: Array<{ day: string; hits: number }>
|
||||||
@@ -685,19 +709,7 @@ interface OpsStatus {
|
|||||||
deployHook: { ok: boolean; at: string | null; error: string | null }
|
deployHook: { ok: boolean; at: string | null; error: string | null }
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Question {
|
type Question = AdminQuestion
|
||||||
id: string
|
|
||||||
submittedAt: string
|
|
||||||
firstName: string
|
|
||||||
email: string
|
|
||||||
question: string
|
|
||||||
answer: string
|
|
||||||
answeredAt: string | null
|
|
||||||
isApproved: boolean
|
|
||||||
approvedAt: string | null
|
|
||||||
upvotes?: number
|
|
||||||
pinned?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type ContactSubmission = InboxSubmission
|
type ContactSubmission = InboxSubmission
|
||||||
|
|
||||||
@@ -727,11 +739,11 @@ type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | '
|
|||||||
type AdminView =
|
type AdminView =
|
||||||
| 'dashboard'
|
| 'dashboard'
|
||||||
| 'homepage:hero' | 'homepage:share' | 'homepage:prism' | 'homepage:where-to-next'
|
| 'homepage:hero' | 'homepage:share' | 'homepage:prism' | 'homepage:where-to-next'
|
||||||
| 'start-here'
|
| 'start-here' | 'salvation'
|
||||||
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
|
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
|
||||||
| 'contact:profile' | 'contact:copy'
|
| 'contact:profile' | 'contact:copy'
|
||||||
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:workflow' | 'podcast:rss'
|
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:workflow' | 'podcast:season-check' | 'podcast:rss'
|
||||||
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow'
|
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow' | 'nuggets:tags'
|
||||||
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||||
| 'inbox'
|
| 'inbox'
|
||||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||||
@@ -763,6 +775,7 @@ const ADMIN_NAV: NavGroup[] = [
|
|||||||
{ kind: 'leaf', view: 'homepage:where-to-next', label: 'Where to Next' },
|
{ kind: 'leaf', view: 'homepage:where-to-next', label: 'Where to Next' },
|
||||||
]},
|
]},
|
||||||
{ kind: 'leaf', view: 'start-here', label: 'Start Here' },
|
{ kind: 'leaf', view: 'start-here', label: 'Start Here' },
|
||||||
|
{ kind: 'leaf', view: 'salvation', label: 'Gospel Page' },
|
||||||
{ kind: 'parent', id: 'about', label: 'About', children: [
|
{ kind: 'parent', id: 'about', label: 'About', children: [
|
||||||
{ kind: 'leaf', view: 'about:show-copy', label: 'Show Copy' },
|
{ kind: 'leaf', view: 'about:show-copy', label: 'Show Copy' },
|
||||||
{ kind: 'leaf', view: 'about:bio', label: 'Nate Bio' },
|
{ kind: 'leaf', view: 'about:bio', label: 'Nate Bio' },
|
||||||
@@ -778,6 +791,7 @@ const ADMIN_NAV: NavGroup[] = [
|
|||||||
group: 'Content',
|
group: 'Content',
|
||||||
items: [
|
items: [
|
||||||
{ kind: 'parent', id: 'podcast', label: 'Podcast Hub', children: [
|
{ kind: 'parent', id: 'podcast', label: 'Podcast Hub', children: [
|
||||||
|
{ kind: 'leaf', view: 'podcast:season-check', label: 'Season Check' },
|
||||||
{ kind: 'leaf', view: 'podcast:current-series', label: 'Current Series' },
|
{ kind: 'leaf', view: 'podcast:current-series', label: 'Current Series' },
|
||||||
{ kind: 'leaf', view: 'podcast:highlights', label: 'Ep. Highlights' },
|
{ kind: 'leaf', view: 'podcast:highlights', label: 'Ep. Highlights' },
|
||||||
{ kind: 'leaf', view: 'podcast:finished-books', label: 'Finished Books' },
|
{ kind: 'leaf', view: 'podcast:finished-books', label: 'Finished Books' },
|
||||||
@@ -792,6 +806,7 @@ const ADMIN_NAV: NavGroup[] = [
|
|||||||
{ kind: 'leaf', view: 'nuggets:announcement', label: 'Announcement' },
|
{ kind: 'leaf', view: 'nuggets:announcement', label: 'Announcement' },
|
||||||
{ kind: 'leaf', view: 'nuggets:guidelines', label: 'Recording Guidelines' },
|
{ kind: 'leaf', view: 'nuggets:guidelines', label: 'Recording Guidelines' },
|
||||||
{ kind: 'leaf', view: 'nuggets:workflow', label: 'Episode Workflow' },
|
{ kind: 'leaf', view: 'nuggets:workflow', label: 'Episode Workflow' },
|
||||||
|
{ kind: 'leaf', view: 'nuggets:tags', label: 'Episode Tags' },
|
||||||
{ kind: 'leaf', view: 'nuggets:history', label: 'Upload History' },
|
{ kind: 'leaf', view: 'nuggets:history', label: 'Upload History' },
|
||||||
]},
|
]},
|
||||||
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
|
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
|
||||||
@@ -869,6 +884,7 @@ const ADMIN_SECTION_LINKS: Partial<Record<AdminView, AdminSectionLink[]>> = {
|
|||||||
{ id: 'global-header', label: 'Header' },
|
{ id: 'global-header', label: 'Header' },
|
||||||
{ id: 'global-platforms', label: 'Platform URLs' },
|
{ id: 'global-platforms', label: 'Platform URLs' },
|
||||||
{ id: 'global-banner', label: 'Analytics Banner' },
|
{ id: 'global-banner', label: 'Analytics Banner' },
|
||||||
|
{ id: 'global-verse', label: 'Verse of the Week' },
|
||||||
{ id: 'global-welcome-email', label: 'Welcome Email' },
|
{ id: 'global-welcome-email', label: 'Welcome Email' },
|
||||||
],
|
],
|
||||||
'start-here': [
|
'start-here': [
|
||||||
@@ -1012,6 +1028,7 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
|
|||||||
{ key: 'platformCreatorUrl', label: 'Platform — Creator Profile URL', section: 'global' },
|
{ key: 'platformCreatorUrl', label: 'Platform — Creator Profile URL', section: 'global' },
|
||||||
{ key: 'platformKofiUrl', label: 'Platform — Ko-fi URL', section: 'global' },
|
{ key: 'platformKofiUrl', label: 'Platform — Ko-fi URL', section: 'global' },
|
||||||
{ key: 'platformKofiQrUrl', label: 'Platform — Ko-fi QR Code Image URL', section: 'global' },
|
{ key: 'platformKofiQrUrl', label: 'Platform — Ko-fi QR Code Image URL', section: 'global' },
|
||||||
|
{ key: 'verseOfTheWeek', label: 'Scripture Index — Verse of the Week (optional)', section: 'global' },
|
||||||
{ key: 'welcomeEmailSubject', label: 'Welcome Email — Subject', section: 'global' },
|
{ key: 'welcomeEmailSubject', label: 'Welcome Email — Subject', section: 'global' },
|
||||||
{ key: 'welcomeEmailGreetingPrefix', label: 'Welcome Email — Greeting Prefix', section: 'global' },
|
{ key: 'welcomeEmailGreetingPrefix', label: 'Welcome Email — Greeting Prefix', section: 'global' },
|
||||||
{ key: 'welcomeEmailIntro', label: 'Welcome Email — Intro Paragraph', multiline: true, section: 'global' },
|
{ key: 'welcomeEmailIntro', label: 'Welcome Email — Intro Paragraph', multiline: true, section: 'global' },
|
||||||
@@ -1617,6 +1634,115 @@ function NuggetsHistoryAdmin() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Nuggets Episode Tags admin ─────────────────────────────────────────────
|
||||||
|
interface NuggetsTagEpisode {
|
||||||
|
title: string
|
||||||
|
pubDate: string
|
||||||
|
audioUrl: string
|
||||||
|
episode: string
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function NuggetsTagsAdmin() {
|
||||||
|
const [episodes, setEpisodes] = useState<NuggetsTagEpisode[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, string>>({})
|
||||||
|
const [filter, setFilter] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/admin/nuggets/episode-tags')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
||||||
|
.then(d => setEpisodes(Array.isArray(d.episodes) ? d.episodes : []))
|
||||||
|
.catch(() => setError('Could not load episodes. Check the RSS feed URL in Show Settings.'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const allTags = Array.from(new Set(episodes.flatMap(e => e.tags))).sort((a, b) => a.localeCompare(b))
|
||||||
|
|
||||||
|
async function saveTags(audioUrl: string, tags: string[]) {
|
||||||
|
const prev = episodes
|
||||||
|
setEpisodes(eps => eps.map(e => e.audioUrl === audioUrl ? { ...e, tags } : e))
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/admin/nuggets/episode-tags', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ audioUrl, tags }),
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error()
|
||||||
|
const d = await r.json()
|
||||||
|
setEpisodes(eps => eps.map(e => e.audioUrl === audioUrl ? { ...e, tags: d.tags ?? tags } : e))
|
||||||
|
} catch {
|
||||||
|
setEpisodes(prev)
|
||||||
|
setError('Could not save tags. Please try again.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTags(ep: NuggetsTagEpisode) {
|
||||||
|
const raw = (drafts[ep.audioUrl] ?? '').split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
if (!raw.length) return
|
||||||
|
setDrafts(d => ({ ...d, [ep.audioUrl]: '' }))
|
||||||
|
saveTags(ep.audioUrl, [...ep.tags, ...raw.filter(t => !ep.tags.some(x => x.toLowerCase() === t.toLowerCase()))])
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = filter.trim()
|
||||||
|
? episodes.filter(e => `${e.title} ${e.tags.join(' ')}`.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
: episodes
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-panel-section" aria-label="Nuggets episode tags">
|
||||||
|
<div className="admin-panel-head">
|
||||||
|
<h2>Nuggets — Episode Tags</h2>
|
||||||
|
<p>Tag episodes by topic or book. Visitors can filter the Nuggets page by these tags. Type a tag and press Enter (or separate several with commas).</p>
|
||||||
|
</div>
|
||||||
|
{error && <p className="admin-stats-note" role="alert">{error}</p>}
|
||||||
|
{loading ? <p className="admin-stats-note">Loading…</p> : (
|
||||||
|
<>
|
||||||
|
<datalist id="nuggets-tag-suggestions">
|
||||||
|
{allTags.map(t => <option key={t} value={t} />)}
|
||||||
|
</datalist>
|
||||||
|
{episodes.length > 0 && (
|
||||||
|
<div className="admin-field" style={{ maxWidth: 320, marginBottom: 12 }}>
|
||||||
|
<input type="text" placeholder="Filter by title or tag…" value={filter} onChange={e => setFilter(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<p className="admin-stats-note">{episodes.length === 0 ? 'No episodes found in the feed yet.' : 'No results match that filter.'}</p>
|
||||||
|
) : visible.map(ep => (
|
||||||
|
<div key={ep.audioUrl} style={{ padding: '10px 0', borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 6 }}>
|
||||||
|
{ep.episode ? `Ep. ${ep.episode} — ` : ''}{ep.title}
|
||||||
|
<span style={{ fontWeight: 400, opacity: 0.6, marginLeft: 8, fontSize: '0.8rem' }}>
|
||||||
|
{Number.isNaN(new Date(ep.pubDate).getTime()) ? '' : new Date(ep.pubDate).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||||
|
{ep.tags.map(t => (
|
||||||
|
<span key={t} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, background: 'rgba(201,168,76,0.15)', borderRadius: 999, padding: '2px 10px', fontSize: '0.82rem' }}>
|
||||||
|
{t}
|
||||||
|
<button type="button" aria-label={`Remove tag ${t}`} onClick={() => saveTags(ep.audioUrl, ep.tags.filter(x => x !== t))} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 0, fontSize: '1rem', lineHeight: 1 }}>×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
list="nuggets-tag-suggestions"
|
||||||
|
placeholder="Add tag…"
|
||||||
|
aria-label={`Add tag to ${ep.title}`}
|
||||||
|
value={drafts[ep.audioUrl] ?? ''}
|
||||||
|
onChange={e => setDrafts(d => ({ ...d, [ep.audioUrl]: e.target.value }))}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addTags(ep) } }}
|
||||||
|
onBlur={() => addTags(ep)}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Nuggets Episode Workflow admin ─────────────────────────────────────────
|
// ── Nuggets Episode Workflow admin ─────────────────────────────────────────
|
||||||
type WorkflowStepKey = 'listen' | 'coldOpen' | 'recordColdOpen' | 'edit' | 'spotify'
|
type WorkflowStepKey = 'listen' | 'coldOpen' | 'recordColdOpen' | 'edit' | 'spotify'
|
||||||
|
|
||||||
@@ -2122,6 +2248,127 @@ function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string }; lane
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Season Check (everything a season needs, per lane) ─────────────────────
|
||||||
|
interface SeasonCheckProblem { severity: 'error' | 'warn'; message: string; view: string }
|
||||||
|
interface SeasonCheckEpisode {
|
||||||
|
number: number
|
||||||
|
released: boolean
|
||||||
|
releaseDate: string
|
||||||
|
workflowStarted: boolean
|
||||||
|
script: boolean
|
||||||
|
lesson: boolean
|
||||||
|
lessonAudio: boolean | null
|
||||||
|
highlight: boolean
|
||||||
|
highlightHeld: boolean | null
|
||||||
|
}
|
||||||
|
interface SeasonCheckLane {
|
||||||
|
lane: 'A' | 'B'
|
||||||
|
title: string
|
||||||
|
season: string
|
||||||
|
studyTitle: string
|
||||||
|
problems: SeasonCheckProblem[]
|
||||||
|
errors: number
|
||||||
|
warnings: number
|
||||||
|
episodes: SeasonCheckEpisode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeasonCheckAdmin({ onNavigate }: { onNavigate: (view: AdminView) => void }) {
|
||||||
|
const [report, setReport] = useState<{ lanes: SeasonCheckLane[]; general: SeasonCheckProblem[]; total: number } | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchReport = () =>
|
||||||
|
fetch('/api/admin/season-check')
|
||||||
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('failed'))))
|
||||||
|
.then(d => { setReport(d); setError('') })
|
||||||
|
.catch(() => setError('Could not run the season check.'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
useEffect(() => { fetchReport() }, [])
|
||||||
|
|
||||||
|
const yes = <span style={{ color: '#4a9c5d' }} aria-label="yes">✓</span>
|
||||||
|
const no = <span style={{ opacity: 0.35 }} aria-label="no">–</span>
|
||||||
|
const cell = { padding: '4px 8px', textAlign: 'center' as const }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-panel-section" aria-label="Season check">
|
||||||
|
<div className="admin-panel-head">
|
||||||
|
<h2>Podcast — Season Check</h2>
|
||||||
|
<p>Everything a season needs, per lane. This reads your saved draft, so it catches gaps before you publish. Nothing here changes anything.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn-admin-ghost" onClick={() => { setLoading(true); fetchReport() }} disabled={loading}>{loading ? 'Checking…' : 'Check again'}</button>
|
||||||
|
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
|
||||||
|
{report && report.lanes.length === 0 && <p className="admin-stats-note">No lanes are set up yet.</p>}
|
||||||
|
{report && report.general.length > 0 && (
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<h3 style={{ marginBottom: 4 }}>All lanes</h3>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '8px 0 12px' }}>
|
||||||
|
{report.general.map((p, i) => (
|
||||||
|
<li key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '5px 0' }}>
|
||||||
|
<span style={{ color: p.severity === 'error' ? '#c0392b' : '#b8860b' }}>{p.severity === 'error' ? '●' : '▲'}</span>
|
||||||
|
<span style={{ flex: 1 }}>{p.message}</span>
|
||||||
|
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate(p.view as AdminView)}>Fix →</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{report?.lanes.map(l => (
|
||||||
|
<div key={l.lane} style={{ marginTop: 20 }}>
|
||||||
|
<h3 style={{ marginBottom: 4 }}>
|
||||||
|
Lane {l.lane}{l.title ? ` — ${l.title}` : ''}
|
||||||
|
</h3>
|
||||||
|
<p className="admin-stats-note" style={{ marginTop: 0 }}>
|
||||||
|
{l.season ? `Season ${l.season}` : 'No season number'}{l.studyTitle ? ` · Study: ${l.studyTitle}` : ' · No study linked'}
|
||||||
|
</p>
|
||||||
|
{l.problems.length === 0 ? (
|
||||||
|
<p style={{ color: '#4a9c5d' }}>✓ Nothing to fix.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '8px 0 12px' }}>
|
||||||
|
{l.problems.map((p, i) => (
|
||||||
|
<li key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '5px 0' }}>
|
||||||
|
<span style={{ color: p.severity === 'error' ? '#c0392b' : '#b8860b' }}>{p.severity === 'error' ? '●' : '▲'}</span>
|
||||||
|
<span style={{ flex: 1 }}>{p.message}</span>
|
||||||
|
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate(p.view as AdminView)}>Fix →</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{l.episodes.length > 0 && (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid var(--color-border, #e5e0d5)' }}>
|
||||||
|
<th style={{ ...cell, textAlign: 'left' }}>Ep</th>
|
||||||
|
<th style={cell}>Workflow</th>
|
||||||
|
<th style={cell}>Script</th>
|
||||||
|
<th style={cell}>Lesson</th>
|
||||||
|
<th style={cell}>Lesson audio</th>
|
||||||
|
<th style={cell}>Highlight</th>
|
||||||
|
<th style={cell}>Live on Spotify</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{l.episodes.map(e => (
|
||||||
|
<tr key={e.number} style={{ borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
|
||||||
|
<td style={{ ...cell, textAlign: 'left' }}><strong>{e.number}</strong></td>
|
||||||
|
<td style={cell}>{e.released ? 'Released' : e.workflowStarted ? 'In progress' : no}</td>
|
||||||
|
<td style={cell}>{e.script ? yes : no}</td>
|
||||||
|
<td style={cell}>{e.lesson ? yes : no}</td>
|
||||||
|
<td style={cell}>{e.lessonAudio === null ? no : e.lessonAudio ? yes : no}</td>
|
||||||
|
<td style={cell}>{e.highlight ? (e.highlightHeld ? 'Waits for release' : yes) : no}</td>
|
||||||
|
<td style={cell}>{e.released ? <>{yes} {e.releaseDate}</> : no}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminPage({ content, onSave, onLogout }: Props) {
|
export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||||
const normalizedContent = normalizeSiteContentForAdmin(content)
|
const normalizedContent = normalizeSiteContentForAdmin(content)
|
||||||
const [form, setForm] = useState<SiteContent>(normalizedContent)
|
const [form, setForm] = useState<SiteContent>(normalizedContent)
|
||||||
@@ -2163,12 +2410,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
|
|
||||||
const [questions, setQuestions] = useState<Question[]>([])
|
const [questions, setQuestions] = useState<Question[]>([])
|
||||||
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
|
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
|
||||||
const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
|
|
||||||
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null)
|
|
||||||
const [questionSearch, setQuestionSearch] = useState('')
|
|
||||||
const [questionFilter, setQuestionFilter] = useState<'all' | 'pending' | 'approved' | 'answered' | 'unanswered'>('all')
|
|
||||||
const [questionPage, setQuestionPage] = useState(0)
|
|
||||||
const [selectedQuestionIds, setSelectedQuestionIds] = useState<Set<string>>(new Set())
|
|
||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||||
const [navSearch, setNavSearch] = useState('')
|
const [navSearch, setNavSearch] = useState('')
|
||||||
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
|
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
|
||||||
@@ -2186,6 +2427,13 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
const [commentsLoaded, setCommentsLoaded] = useState(false)
|
const [commentsLoaded, setCommentsLoaded] = useState(false)
|
||||||
const [commentFilter, setCommentFilter] = useState<'all' | 'pending' | 'approved'>('all')
|
const [commentFilter, setCommentFilter] = useState<'all' | 'pending' | 'approved'>('all')
|
||||||
const [commentSearch, setCommentSearch] = useState('')
|
const [commentSearch, setCommentSearch] = useState('')
|
||||||
|
// Load moderation queue as soon as the section is opened; a failed load leaves the manual button available
|
||||||
|
const commentsAutoTried = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (adminView !== 'study-comments' || commentsLoaded || commentsAutoTried.current) return
|
||||||
|
commentsAutoTried.current = true
|
||||||
|
void loadStudyComments()
|
||||||
|
}, [adminView, commentsLoaded])
|
||||||
|
|
||||||
// Episode scripts state
|
// Episode scripts state
|
||||||
const [scriptsList, setScriptsList] = useState<Array<{ key: string; episodeNumber: string; title: string; filename: string; season: string | null; uploadedAt: string | null; wordCount: number }>>([])
|
const [scriptsList, setScriptsList] = useState<Array<{ key: string; episodeNumber: string; title: string; filename: string; season: string | null; uploadedAt: string | null; wordCount: number }>>([])
|
||||||
@@ -2260,15 +2508,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
const [nlPublishingId, setNlPublishingId] = useState<string | null>(null)
|
const [nlPublishingId, setNlPublishingId] = useState<string | null>(null)
|
||||||
|
|
||||||
const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||||
const [manualQuestion, setManualQuestion] = useState({
|
|
||||||
firstName: '',
|
|
||||||
email: '',
|
|
||||||
question: '',
|
|
||||||
answer: '',
|
|
||||||
approve: false,
|
|
||||||
})
|
|
||||||
const [manualQuestionStatus, setManualQuestionStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
|
||||||
const [manualQuestionMsg, setManualQuestionMsg] = useState('')
|
|
||||||
const [previewOpen, setPreviewOpen] = useState(false)
|
const [previewOpen, setPreviewOpen] = useState(false)
|
||||||
const previewIframeRef = useRef<HTMLIFrameElement>(null)
|
const previewIframeRef = useRef<HTMLIFrameElement>(null)
|
||||||
const dragSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
|
const dragSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
|
||||||
@@ -2522,10 +2761,6 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setQuestionPage(0)
|
|
||||||
}, [questionSearch, questionFilter])
|
|
||||||
|
|
||||||
async function reloadBackups() {
|
async function reloadBackups() {
|
||||||
const r = await fetch('/api/admin-stats/backups')
|
const r = await fetch('/api/admin-stats/backups')
|
||||||
if (!r.ok) throw new Error('Could not refresh backups')
|
if (!r.ok) throw new Error('Could not refresh backups')
|
||||||
@@ -2762,6 +2997,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
{
|
{
|
||||||
id: Date.now().toString(36),
|
id: Date.now().toString(36),
|
||||||
lane: 'primary' as const,
|
lane: 'primary' as const,
|
||||||
|
holdUntilReleased: true,
|
||||||
title: '',
|
title: '',
|
||||||
episodeNumber: '',
|
episodeNumber: '',
|
||||||
summary: '',
|
summary: '',
|
||||||
@@ -2778,7 +3014,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePodcastLink(id: string, field: keyof PodcastFeaturedLink, value: string) {
|
function updatePodcastLink(id: string, field: keyof PodcastFeaturedLink, value: string | boolean) {
|
||||||
setForm(f => ({
|
setForm(f => ({
|
||||||
...f,
|
...f,
|
||||||
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link => link.id === id ? { ...link, [field]: value } : link),
|
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link => link.id === id ? { ...link, [field]: value } : link),
|
||||||
@@ -3422,63 +3658,6 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
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 handlePinQuestion(questionId: string, pin: boolean) {
|
|
||||||
try {
|
|
||||||
const method = pin ? 'POST' : 'DELETE'
|
|
||||||
const res = await fetch(`/api/admin-questions/${questionId}/pin`, { method })
|
|
||||||
if (!res.ok) throw new Error('Failed to update pin')
|
|
||||||
const data = await res.json().catch(() => ({})) as { question?: Question }
|
|
||||||
if (data.question) {
|
|
||||||
setQuestions(items => items.map(q => q.id === questionId ? { ...q, pinned: data.question!.pinned } : q))
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadWebhooksAdmin() {
|
async function loadWebhooksAdmin() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin-webhooks')
|
const res = await fetch('/api/admin-webhooks')
|
||||||
@@ -3729,102 +3908,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
} catch { alert('Failed to delete script.') }
|
} catch { alert('Failed to delete script.') }
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
|
||||||
setSelectedQuestionIds(prev => { const next = new Set(prev); next.delete(questionId); return next })
|
|
||||||
} catch {
|
|
||||||
alert('Failed to delete question')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBulkDeleteQuestions() {
|
|
||||||
if (selectedQuestionIds.size === 0) return
|
|
||||||
if (!confirm(`Delete ${selectedQuestionIds.size} question${selectedQuestionIds.size === 1 ? '' : 's'} permanently?`)) return
|
|
||||||
const ids = Array.from(selectedQuestionIds)
|
|
||||||
let deletedCount = 0
|
|
||||||
for (const id of ids) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/admin-questions/${id}`, { method: 'DELETE' })
|
|
||||||
if (res.ok) {
|
|
||||||
deletedCount++
|
|
||||||
setQuestions(qs => qs.filter(q => q.id !== id))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// continue with remaining
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSelectedQuestionIds(new Set())
|
|
||||||
if (deletedCount < ids.length) alert(`Deleted ${deletedCount} of ${ids.length} questions.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateManualQuestion() {
|
|
||||||
if (!manualQuestion.firstName.trim() || !manualQuestion.question.trim()) {
|
|
||||||
setManualQuestionStatus('error')
|
|
||||||
setManualQuestionMsg('First name and question are required.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setManualQuestionStatus('saving')
|
|
||||||
setManualQuestionMsg('')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin-questions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
firstName: manualQuestion.firstName,
|
|
||||||
email: manualQuestion.email,
|
|
||||||
question: manualQuestion.question,
|
|
||||||
answer: manualQuestion.answer,
|
|
||||||
approve: manualQuestion.approve,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({})) as { question?: Question; message?: string }
|
|
||||||
if (!res.ok || !data.question) {
|
|
||||||
throw new Error(data.message ?? 'Failed to add question.')
|
|
||||||
}
|
|
||||||
|
|
||||||
setQuestions(items => [data.question as Question, ...items])
|
|
||||||
setManualQuestion({ firstName: '', email: '', question: '', answer: '', approve: false })
|
|
||||||
setManualQuestionStatus('saved')
|
|
||||||
setManualQuestionMsg('Question added to draft Q&A list.')
|
|
||||||
} catch (err) {
|
|
||||||
setManualQuestionStatus('error')
|
|
||||||
setManualQuestionMsg(err instanceof Error ? err.message : 'Failed to add question.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources')
|
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources')
|
||||||
|
|
||||||
const filteredAdminQuestions = questions.filter(question => {
|
|
||||||
const search = questionSearch.trim().toLowerCase()
|
|
||||||
const matchesSearch = !search
|
|
||||||
|| question.firstName.toLowerCase().includes(search)
|
|
||||||
|| question.question.toLowerCase().includes(search)
|
|
||||||
|| question.answer.toLowerCase().includes(search)
|
|
||||||
|
|
||||||
const matchesFilter = (
|
|
||||||
questionFilter === 'all'
|
|
||||||
|| (questionFilter === 'pending' && !question.isApproved)
|
|
||||||
|| (questionFilter === 'approved' && question.isApproved)
|
|
||||||
|| (questionFilter === 'answered' && Boolean(question.answer?.trim()))
|
|
||||||
|| (questionFilter === 'unanswered' && !question.answer?.trim())
|
|
||||||
)
|
|
||||||
|
|
||||||
return matchesSearch && matchesFilter
|
|
||||||
})
|
|
||||||
|
|
||||||
const QUESTION_PAGE_SIZE = 20
|
|
||||||
const totalQuestionPages = Math.max(1, Math.ceil(filteredAdminQuestions.length / QUESTION_PAGE_SIZE))
|
|
||||||
const visibleAdminQuestions = filteredAdminQuestions.slice(
|
|
||||||
questionPage * QUESTION_PAGE_SIZE,
|
|
||||||
(questionPage + 1) * QUESTION_PAGE_SIZE,
|
|
||||||
)
|
|
||||||
function renderSaveStatus() {
|
function renderSaveStatus() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -4046,7 +4131,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
<div className="admin-dashboard-card-value">{unansweredCount}</div>
|
<div className="admin-dashboard-card-value">{unansweredCount}</div>
|
||||||
<div className="admin-dashboard-card-label">Unanswered Questions</div>
|
<div className="admin-dashboard-card-label">Unanswered Questions</div>
|
||||||
<div className="admin-dashboard-card-sub">{answeredCount} answered · {approvedCount} approved of {questions.length}</div>
|
<div className="admin-dashboard-card-sub">{answeredCount} answered · {approvedCount} approved of {questions.length}</div>
|
||||||
{unansweredCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => { setQuestionFilter('unanswered'); navigateTo('questions') }}>Answer Now →</button>}
|
{unansweredCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('questions')}>Answer Now →</button>}
|
||||||
</div>
|
</div>
|
||||||
<div className={`admin-dashboard-card${unreadEmailCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
|
<div className={`admin-dashboard-card${unreadEmailCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
|
||||||
<div className="admin-dashboard-card-value">{unreadEmailCount}</div>
|
<div className="admin-dashboard-card-value">{unreadEmailCount}</div>
|
||||||
@@ -4057,7 +4142,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
<div className="admin-dashboard-card">
|
<div className="admin-dashboard-card">
|
||||||
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
|
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
|
||||||
<div className="admin-dashboard-card-label">Real Visits (7 Days)</div>
|
<div className="admin-dashboard-card-label">Real Visits (7 Days)</div>
|
||||||
<div className="admin-dashboard-card-sub">{thisWeekHits.toLocaleString()} total · {stats?.visitors?.uniqueVisitors?.toLocaleString() ?? '—'} unique all time</div>
|
<div className="admin-dashboard-card-sub">{thisWeekHits.toLocaleString()} total · {stats?.uniqueVisitors7d?.toLocaleString() ?? '—'} unique this week · {stats?.visitors?.uniqueVisitors?.toLocaleString() ?? '—'} all time (consented)</div>
|
||||||
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('analytics')}>Full Analytics →</button>
|
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('analytics')}>Full Analytics →</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-dashboard-card">
|
<div className="admin-dashboard-card">
|
||||||
@@ -5151,6 +5236,22 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
</select>
|
</select>
|
||||||
<p className="admin-field-help">Groups this highlight under its series on the Episodes page. When a series ends, its highlights move onto that book's Finished Books page. General highlights stay.</p>
|
<p className="admin-field-help">Groups this highlight under its series on the Episodes page. When a series ends, its highlights move onto that book's Finished Books page. General highlights stay.</p>
|
||||||
</div>
|
</div>
|
||||||
|
{activeHighlight.lane !== 'general' && (
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`podcast-hold-${activeHighlight.id}`}>
|
||||||
|
<input
|
||||||
|
id={`podcast-hold-${activeHighlight.id}`}
|
||||||
|
type="checkbox"
|
||||||
|
checked={activeHighlight.holdUntilReleased === true}
|
||||||
|
onChange={e => updatePodcastLink(activeHighlight.id, 'holdUntilReleased', e.target.checked)}
|
||||||
|
/>{' '}
|
||||||
|
Publish when the episode is released on Spotify
|
||||||
|
</label>
|
||||||
|
<p className="admin-field-help">
|
||||||
|
Hidden on the site until this lane's season number and the episode number below appear in your Spotify feed (checked every few minutes). Needs an episode number. Set each lane's season number on the Episode Workflow page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`podcast-ep-${activeHighlight.id}`}>Episode Number</label>
|
<label htmlFor={`podcast-ep-${activeHighlight.id}`}>Episode Number</label>
|
||||||
<input id={`podcast-ep-${activeHighlight.id}`} type="text" placeholder="e.g. 42" value={activeHighlight.episodeNumber ?? ''} onChange={e => updatePodcastLink(activeHighlight.id, 'episodeNumber', e.target.value)} />
|
<input id={`podcast-ep-${activeHighlight.id}`} type="text" placeholder="e.g. 42" value={activeHighlight.episodeNumber ?? ''} onChange={e => updatePodcastLink(activeHighlight.id, 'episodeNumber', e.target.value)} />
|
||||||
@@ -5823,6 +5924,20 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
<h2>Books</h2>
|
<h2>Books</h2>
|
||||||
<p>Amazon books shown in the Reading List section on the Resources page. Add a cover image, description, and Amazon link for each book.</p>
|
<p>Amazon books shown in the Reading List section on the Resources page. Add a cover image, description, and Amazon link for each book.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-field" style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<label htmlFor="gate-store-links" style={{ display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
id="gate-store-links"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.gateStoreLinks !== false}
|
||||||
|
onChange={e => setForm(f => ({ ...f, gateStoreLinks: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Ask for name and email before sending visitors to Amazon or Apple Books
|
||||||
|
</label>
|
||||||
|
<p className="admin-field-help">
|
||||||
|
Applies to the Amazon and Apple Books buttons on the Reading List and on the study guide and download pages. Each visitor is added to your Inbox as a contact, and subscribed if they leave the box ticked. Turn it off to send visitors straight to the store.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{(form.customLinks ?? []).filter(l => l.placement === 'books').length === 0 && <p className="admin-stats-note">No books yet.</p>}
|
{(form.customLinks ?? []).filter(l => l.placement === 'books').length === 0 && <p className="admin-stats-note">No books yet.</p>}
|
||||||
{(form.customLinks ?? []).filter(l => l.placement === 'books').map(book => (
|
{(form.customLinks ?? []).filter(l => l.placement === 'books').map(book => (
|
||||||
<div key={book.id} className="admin-array-row">
|
<div key={book.id} className="admin-array-row">
|
||||||
@@ -6009,6 +6124,13 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
<label htmlFor={`study-description-${activeStudy.id}`}>Description</label>
|
<label htmlFor={`study-description-${activeStudy.id}`}>Description</label>
|
||||||
<textarea id={`study-description-${activeStudy.id}`} rows={3} value={activeStudy.description} placeholder="Brief description for the study hub card" onChange={e => updateStudyProgram(activeStudy.id, 'description', e.target.value)} />
|
<textarea id={`study-description-${activeStudy.id}`} rows={3} value={activeStudy.description} placeholder="Brief description for the study hub card" onChange={e => updateStudyProgram(activeStudy.id, 'description', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`study-image-${activeStudy.id}`}>Cover Image URL</label>
|
||||||
|
<input id={`study-image-${activeStudy.id}`} type="text" value={activeStudy.imageUrl ?? ''} placeholder="/uploads/study-cover.jpg" onChange={e => updateStudyProgram(activeStudy.id, 'imageUrl', e.target.value)} />
|
||||||
|
{renderImageAssetSelector(activeStudy.imageUrl ?? '', value => updateStudyProgram(activeStudy.id, 'imageUrl', value), `study-image-${activeStudy.id}-asset`)}
|
||||||
|
{renderImagePreview(activeStudy.imageUrl ?? '', `${activeStudy.title || 'Study'} cover`)}
|
||||||
|
<p className="admin-field-help">Shown as the backdrop of this study’s featured card on the homepage. A wide or square image works best.</p>
|
||||||
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`study-home-eyebrow-${activeStudy.id}`}>Homepage Card Eyebrow</label>
|
<label htmlFor={`study-home-eyebrow-${activeStudy.id}`}>Homepage Card Eyebrow</label>
|
||||||
<input id={`study-home-eyebrow-${activeStudy.id}`} type="text" value={activeStudy.homepageEyebrow ?? ''} placeholder="New Study" onChange={e => updateStudyProgram(activeStudy.id, 'homepageEyebrow', e.target.value)} />
|
<input id={`study-home-eyebrow-${activeStudy.id}`} type="text" value={activeStudy.homepageEyebrow ?? ''} placeholder="New Study" onChange={e => updateStudyProgram(activeStudy.id, 'homepageEyebrow', e.target.value)} />
|
||||||
@@ -6021,6 +6143,19 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
<label htmlFor={`study-chapters-${activeStudy.id}`}>Number of Chapters</label>
|
<label htmlFor={`study-chapters-${activeStudy.id}`}>Number of Chapters</label>
|
||||||
<input id={`study-chapters-${activeStudy.id}`} type="number" min="1" max="999" value={activeStudy.numberOfChapters} placeholder="4" onChange={e => updateStudyProgram(activeStudy.id, 'numberOfChapters', Number(e.target.value) || 1)} />
|
<input id={`study-chapters-${activeStudy.id}`} type="number" min="1" max="999" value={activeStudy.numberOfChapters} placeholder="4" onChange={e => updateStudyProgram(activeStudy.id, 'numberOfChapters', Number(e.target.value) || 1)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`study-guide-source-${activeStudy.id}`}>Study Guide Download</label>
|
||||||
|
<select id={`study-guide-source-${activeStudy.id}`} value={activeStudy.guideSource ?? ''} onChange={e => updateStudyProgram(activeStudy.id, 'guideSource', e.target.value)}>
|
||||||
|
<option value="">Automatic (guide of the series or book linked to this study)</option>
|
||||||
|
<option value="none">None — don’t show a guide button</option>
|
||||||
|
{form.studyGuideTitle && <option value="primary">Current series guide: {form.studyGuideTitle}</option>}
|
||||||
|
{form.secondSeries?.guide?.title && <option value="second">Second series guide: {form.secondSeries.guide.title}</option>}
|
||||||
|
{(form.finishedBooks ?? []).filter(b => b.guide?.title).map(b => (
|
||||||
|
<option key={b.id} value={`book:${b.id}`}>{b.title}: {b.guide?.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="admin-field-help">Adds a “Download Study Guide” button to this study’s page. Visitors enter their name and email to get the file.</p>
|
||||||
|
</div>
|
||||||
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||||
<input id={`study-home-feature-${activeStudy.id}`} type="checkbox" checked={activeStudy.showOnHomepage === true} onChange={e => updateStudyProgram(activeStudy.id, 'showOnHomepage', e.target.checked)} />
|
<input id={`study-home-feature-${activeStudy.id}`} type="checkbox" checked={activeStudy.showOnHomepage === true} onChange={e => updateStudyProgram(activeStudy.id, 'showOnHomepage', e.target.checked)} />
|
||||||
<label htmlFor={`study-home-feature-${activeStudy.id}`}>Feature this study card on homepage</label>
|
<label htmlFor={`study-home-feature-${activeStudy.id}`}>Feature this study card on homepage</label>
|
||||||
@@ -6171,6 +6306,10 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`sl-reference-${activeLesson.id}`}>Passage Reference</label>
|
<label htmlFor={`sl-reference-${activeLesson.id}`}>Passage Reference</label>
|
||||||
<input id={`sl-reference-${activeLesson.id}`} type="text" value={activeLesson.reference} placeholder="1:1-2" onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'reference', e.target.value)} />
|
<input id={`sl-reference-${activeLesson.id}`} type="text" value={activeLesson.reference} placeholder="1:1-2" onChange={e => updateStudySection(activeStudy.id, activeLesson.id, 'reference', e.target.value)} />
|
||||||
|
{(() => {
|
||||||
|
const refProblem = checkPassageReference(activeLesson.reference, activeStudy.bibleBook?.trim() || activeStudy.slug)
|
||||||
|
return refProblem ? <p className="admin-field-warning" role="alert" style={{ color: '#e0a030', fontSize: '0.85rem', margin: '0.35rem 0 0' }}>⚠ {refProblem}</p> : null
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`sl-title-${activeLesson.id}`}>Section Title</label>
|
<label htmlFor={`sl-title-${activeLesson.id}`}>Section Title</label>
|
||||||
@@ -6185,6 +6324,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
<option value={activeLesson.audioEmbedUrl}>{activeLesson.audioEmbedUrl}</option>
|
<option value={activeLesson.audioEmbedUrl}>{activeLesson.audioEmbedUrl}</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
|
<p className="admin-field-help">Leave this as "No audio" to fill it in automatically: once this lane's season number and the Episode # below appear in your Spotify feed, the lesson uses that episode's audio.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`sl-released-${activeLesson.id}`}>Release Date (optional)</label>
|
<label htmlFor={`sl-released-${activeLesson.id}`}>Release Date (optional)</label>
|
||||||
@@ -6298,6 +6438,19 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
</>}
|
||||||
|
{tabOf('global', 'global-footer') === 'global-verse' && <>
|
||||||
|
<div className="admin-panel-subhead" id="global-verse"><h3>Verse of the Week</h3></div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="field-verseOfTheWeek">Verse of the Week (optional)</label>
|
||||||
|
<input id="field-verseOfTheWeek" type="text" placeholder="Titus 2:11-12" value={(form.verseOfTheWeek as string) ?? ''} onChange={e => handleChange('verseOfTheWeek', e.target.value)} />
|
||||||
|
<p className="admin-field-hint">Shown at the top of the Scripture Index. Leave blank and the site picks a different covered passage each week automatically. Use a full reference with the book name.</p>
|
||||||
|
{(() => {
|
||||||
|
const vowProblem = checkPassageReference(String(form.verseOfTheWeek ?? ''), '')
|
||||||
|
return vowProblem ? <p className="admin-field-warning" role="alert" style={{ color: '#e0a030', fontSize: '0.85rem', margin: '0.35rem 0 0' }}>⚠ {vowProblem}</p> : null
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
</>}
|
</>}
|
||||||
{tabOf('global', 'global-footer') === 'global-welcome-email' && <>
|
{tabOf('global', 'global-footer') === 'global-welcome-email' && <>
|
||||||
<div className="admin-panel-subhead" id="global-welcome-email"><h3>Welcome Email</h3></div>
|
<div className="admin-panel-subhead" id="global-welcome-email"><h3>Welcome Email</h3></div>
|
||||||
@@ -6316,6 +6469,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* EPISODE SCRIPTS */}
|
{/* EPISODE SCRIPTS */}
|
||||||
|
{adminView === 'podcast:season-check' && <SeasonCheckAdmin onNavigate={setAdminView} />}
|
||||||
{adminView === 'podcast:workflow' && <PodcastWorkflowAdmin laneA={{ title: form.seriesTitle }} laneB={{ title: form.secondSeries?.title ?? '' }} />}
|
{adminView === 'podcast:workflow' && <PodcastWorkflowAdmin laneA={{ title: form.seriesTitle }} laneB={{ title: form.secondSeries?.title ?? '' }} />}
|
||||||
{adminView === 'podcast:scripts' && (
|
{adminView === 'podcast:scripts' && (
|
||||||
<section className="admin-panel-section">
|
<section className="admin-panel-section">
|
||||||
@@ -6612,6 +6766,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
{/* NUGGETS UPLOAD HISTORY */}
|
{/* NUGGETS UPLOAD HISTORY */}
|
||||||
{adminView === 'nuggets:workflow' && <NuggetsWorkflowAdmin />}
|
{adminView === 'nuggets:workflow' && <NuggetsWorkflowAdmin />}
|
||||||
{adminView === 'nuggets:history' && <NuggetsHistoryAdmin />}
|
{adminView === 'nuggets:history' && <NuggetsHistoryAdmin />}
|
||||||
|
{adminView === 'nuggets:tags' && <NuggetsTagsAdmin />}
|
||||||
|
|
||||||
{/* STUDY COMMENTS */}
|
{/* STUDY COMMENTS */}
|
||||||
{adminView === 'study-comments' && (() => {
|
{adminView === 'study-comments' && (() => {
|
||||||
@@ -6699,203 +6854,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* QUESTIONS */}
|
{/* QUESTIONS */}
|
||||||
{adminView === 'questions' && (
|
{adminView === 'questions' && <QuestionsAdmin questions={questions} onChange={setQuestions} />}
|
||||||
<section className="admin-panel-section" aria-label="Q&A Management">
|
|
||||||
<div className="admin-panel-head">
|
|
||||||
<h2>Bible Questions & Answers</h2>
|
|
||||||
<p>Manage submitted questions, add manual questions, provide answers, and approve for public display.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-actions admin-actions--maintenance" style={{ alignItems: 'center' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={questionSearch}
|
|
||||||
onChange={e => setQuestionSearch(e.target.value)}
|
|
||||||
placeholder="Search by name, question, or answer..."
|
|
||||||
style={{ minWidth: '320px', maxWidth: '520px', width: '100%' }}
|
|
||||||
/>
|
|
||||||
<button type="button" className={`btn-admin-reset${questionFilter === 'all' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('all')}>All</button>
|
|
||||||
<button type="button" className={`btn-admin-reset${questionFilter === 'pending' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('pending')}>Pending</button>
|
|
||||||
<button type="button" className={`btn-admin-reset${questionFilter === 'approved' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('approved')}>Approved</button>
|
|
||||||
<button type="button" className={`btn-admin-reset${questionFilter === 'answered' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('answered')}>Answered</button>
|
|
||||||
<button type="button" className={`btn-admin-reset${questionFilter === 'unanswered' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('unanswered')}>Unanswered</button>
|
|
||||||
</div>
|
|
||||||
<p className="admin-stats-note">Showing {filteredAdminQuestions.length} of {questions.length} questions.</p>
|
|
||||||
|
|
||||||
{selectedQuestionIds.size > 0 && (
|
|
||||||
<div className="admin-bulk-toolbar">
|
|
||||||
<span>{selectedQuestionIds.size} selected</span>
|
|
||||||
<button type="button" className="btn-admin-remove" onClick={handleBulkDeleteQuestions}>Delete Selected</button>
|
|
||||||
<button type="button" className="btn-admin-reset" onClick={() => setSelectedQuestionIds(new Set())}>Deselect All</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="admin-visits-table-wrap">
|
|
||||||
<h3>Add Question Manually</h3>
|
|
||||||
<div className="admin-array-row">
|
|
||||||
<div className="admin-array-fields">
|
|
||||||
<div className="admin-field">
|
|
||||||
<label htmlFor="manual-question-first-name">First Name</label>
|
|
||||||
<input
|
|
||||||
id="manual-question-first-name"
|
|
||||||
type="text"
|
|
||||||
value={manualQuestion.firstName}
|
|
||||||
onChange={e => setManualQuestion(curr => ({ ...curr, firstName: e.target.value }))}
|
|
||||||
placeholder="Nate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="admin-field">
|
|
||||||
<label htmlFor="manual-question-email">Email (optional)</label>
|
|
||||||
<input
|
|
||||||
id="manual-question-email"
|
|
||||||
type="email"
|
|
||||||
value={manualQuestion.email}
|
|
||||||
onChange={e => setManualQuestion(curr => ({ ...curr, email: e.target.value }))}
|
|
||||||
placeholder="optional@email.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="admin-field">
|
|
||||||
<label htmlFor="manual-question-question">Question</label>
|
|
||||||
<textarea
|
|
||||||
id="manual-question-question"
|
|
||||||
rows={3}
|
|
||||||
value={manualQuestion.question}
|
|
||||||
onChange={e => setManualQuestion(curr => ({ ...curr, question: e.target.value }))}
|
|
||||||
placeholder="Type the question you want to add to the Q&A list..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="admin-field">
|
|
||||||
<label htmlFor="manual-question-answer">Answer (optional)</label>
|
|
||||||
<textarea
|
|
||||||
id="manual-question-answer"
|
|
||||||
rows={3}
|
|
||||||
value={manualQuestion.answer}
|
|
||||||
onChange={e => setManualQuestion(curr => ({ ...curr, answer: e.target.value }))}
|
|
||||||
placeholder="Optional: add an answer now"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="admin-field">
|
|
||||||
<label htmlFor="manual-question-approve">Approve Immediately</label>
|
|
||||||
<select
|
|
||||||
id="manual-question-approve"
|
|
||||||
value={manualQuestion.approve ? 'yes' : 'no'}
|
|
||||||
onChange={e => setManualQuestion(curr => ({ ...curr, approve: e.target.value === 'yes' }))}
|
|
||||||
>
|
|
||||||
<option value="no">No</option>
|
|
||||||
<option value="yes">Yes</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-admin-save"
|
|
||||||
onClick={handleCreateManualQuestion}
|
|
||||||
disabled={manualQuestionStatus === 'saving'}
|
|
||||||
>
|
|
||||||
{manualQuestionStatus === 'saving' ? 'Adding…' : 'Add Question'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{manualQuestionMsg && (
|
|
||||||
<p className={`admin-status ${manualQuestionStatus === 'error' ? 'admin-status--err' : 'admin-status--ok'}`}>
|
|
||||||
{manualQuestionMsg}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{questions.length === 0 ? (
|
|
||||||
<p className="admin-stats-note">No questions submitted yet.</p>
|
|
||||||
) : filteredAdminQuestions.length === 0 ? (
|
|
||||||
<p className="admin-stats-note">No questions match this filter.</p>
|
|
||||||
) : (
|
|
||||||
<div className="admin-questions-list">
|
|
||||||
{visibleAdminQuestions.map(question => (
|
|
||||||
<div key={question.id} className={`admin-question-card${selectedQuestionIds.has(question.id) ? ' admin-question-card--selected' : ''}`}>
|
|
||||||
<div className="admin-question-header">
|
|
||||||
<label className="admin-question-checkbox" title="Select question">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedQuestionIds.has(question.id)}
|
|
||||||
onChange={e => {
|
|
||||||
setSelectedQuestionIds(prev => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (e.target.checked) next.add(question.id)
|
|
||||||
else next.delete(question.id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<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-${question.pinned ? 'remove' : 'reset'}`} onClick={() => handlePinQuestion(question.id, !question.pinned)}>
|
|
||||||
{question.pinned ? '📌 Unpin' : '📌 Pin'}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteQuestion(question.id)}>Delete</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredAdminQuestions.length > QUESTION_PAGE_SIZE && (
|
|
||||||
<div className="qa-pagination" style={{ marginTop: '1rem', justifyContent: 'flex-start' }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="qa-page-btn"
|
|
||||||
onClick={() => setQuestionPage(p => Math.max(0, p - 1))}
|
|
||||||
disabled={questionPage === 0}
|
|
||||||
>
|
|
||||||
Prev
|
|
||||||
</button>
|
|
||||||
<span className="qa-page-info">Page {questionPage + 1} / {totalQuestionPages}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="qa-page-btn"
|
|
||||||
onClick={() => setQuestionPage(p => Math.min(totalQuestionPages - 1, p + 1))}
|
|
||||||
disabled={questionPage >= totalQuestionPages - 1}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* STUDY USERS */}
|
{/* STUDY USERS */}
|
||||||
{adminView === 'study-users' && <StudyUsersPanel studies={normalizeStudies(form)} />}
|
{adminView === 'study-users' && <StudyUsersPanel studies={normalizeStudies(form)} />}
|
||||||
@@ -7092,6 +7051,34 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* GOSPEL PAGE (/salvation) */}
|
||||||
|
{adminView === 'salvation' && (
|
||||||
|
<section className="admin-panel-section" aria-label="Gospel page">
|
||||||
|
<div className="admin-panel-head">
|
||||||
|
<h2>Gospel Page</h2>
|
||||||
|
<p>Settings for <a href="/salvation" target="_blank" rel="noopener noreferrer">/salvation</a>, The Gospel in a Courtroom.</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-panel-subhead">
|
||||||
|
<h3>Audio narration</h3>
|
||||||
|
<p>A choose-your-path audio version. Listeners hear The Case, then choose Guilty or Not guilty and hear the matching track. Leave the first field blank to hide the player. If the two plea tracks are blank, the plea buttons just select the plea on the page.</p>
|
||||||
|
</div>
|
||||||
|
{([
|
||||||
|
['salvationAudioUrl', '1. The Case (MP3)'],
|
||||||
|
['salvationAudioGuiltyUrl', '2. Guilty: next steps and prayer (MP3)'],
|
||||||
|
['salvationAudioNotGuiltyUrl', '3. Not guilty: what it means (MP3)'],
|
||||||
|
] as const).map(([key, label]) => (
|
||||||
|
<div className="admin-field" key={key}>
|
||||||
|
<label htmlFor={`field-${key}`}>{label}</label>
|
||||||
|
<input id={`field-${key}`} type="text" placeholder="https://…/salvation-case.mp3" value={form[key] ?? ''} onChange={e => handleChange(key, e.target.value)} />
|
||||||
|
{form[key]?.trim() && <audio controls preload="none" src={form[key].trim()} style={{ width: '100%', marginTop: 8 }} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="admin-stats-note">
|
||||||
|
To make the files: put your ElevenLabs key and voice in your local <code>.env</code>, try <code>node --env-file=.env scripts/generate-salvation-audio.mjs --sample</code> first, then run it without <code>--sample</code> to make all three. Upload the MP3s in Asset Manager and paste their URLs here, then Save and Publish.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* LEGAL */}
|
{/* LEGAL */}
|
||||||
{adminView === 'legal' && (
|
{adminView === 'legal' && (
|
||||||
<section className="admin-panel-section" aria-label="Legal Pages">
|
<section className="admin-panel-section" aria-label="Legal Pages">
|
||||||
|
|||||||
+1504
-1587
File diff suppressed because it is too large.
Load diff
+1262
-837
File diff suppressed because it is too large.
Load diff
@@ -28,6 +28,7 @@ const TYPE_LABEL: Record<string, string> = {
|
|||||||
community: 'Community Question',
|
community: 'Community Question',
|
||||||
testimony: 'Testimony',
|
testimony: 'Testimony',
|
||||||
topic: 'Topic Request',
|
topic: 'Topic Request',
|
||||||
|
gospel: 'Gospel Courtroom Question',
|
||||||
general: 'General Message',
|
general: 'General Message',
|
||||||
}
|
}
|
||||||
const KIND_LABEL: Record<Kind, string> = { email: 'Email', newsletter: 'Newsletter signup', download: 'Download request', message: 'Contact form' }
|
const KIND_LABEL: Record<Kind, string> = { email: 'Email', newsletter: 'Newsletter signup', download: 'Download request', message: 'Contact form' }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ interface Trend { thisWeek: number; lastWeek: number; series: number[] }
|
|||||||
interface ResendStatus { available: boolean; reason?: string; broadcast?: { id: string; name: string; subject: string; sentAt: string | null; dashboardUrl: string } | null }
|
interface ResendStatus { available: boolean; reason?: string; broadcast?: { id: string; name: string; subject: string; sentAt: string | null; dashboardUrl: string } | null }
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
|
seasonCheck: { total: number; errors: number; warnings: number; general: number; lanes: { lane: string; title: string; season: string; errors: number; warnings: number }[] } | null
|
||||||
trends: { visits: Trend; subscribers: Trend; studySignups: Trend }
|
trends: { visits: Trend; subscribers: Trend; studySignups: Trend }
|
||||||
traffic: { topPages: { path: string; hits: number }[]; topReferrers: { referrer: string; count: number }[] }
|
traffic: { topPages: { path: string; hits: number }[]; topReferrers: { referrer: string; count: number }[] }
|
||||||
reengagement: { t1: number; t2: number; t3: number; recentlySent: number; remindersEnabled: number }
|
reengagement: { t1: number; t2: number; t3: number; recentlySent: number; remindersEnabled: number }
|
||||||
@@ -13,7 +14,7 @@ export interface DashboardData {
|
|||||||
lastActivityAt: string | null
|
lastActivityAt: string | null
|
||||||
}
|
}
|
||||||
latestEpisode: { available: boolean; title?: string; episode?: string; season?: string; publishedAt?: string | null; daysAgo?: number | null; stale?: boolean; staleAfterDays?: number }
|
latestEpisode: { available: boolean; title?: string; episode?: string; season?: string; publishedAt?: string | null; daysAgo?: number | null; stale?: boolean; staleAfterDays?: number }
|
||||||
nuggets: { uploadsThisWeek: number; latest: { hostName: string; episodeName: string; uploadedAt: string }[] }
|
nuggets: { uploadsThisWeek: number; pending: number; latest: { hostName: string; episodeName: string; uploadedAt: string }[] }
|
||||||
health: {
|
health: {
|
||||||
resendConfigured: boolean
|
resendConfigured: boolean
|
||||||
lastBackup: { ok: boolean; at: string | null; error: string | null; file: string | null }
|
lastBackup: { ok: boolean; at: string | null; error: string | null; file: string | null }
|
||||||
@@ -90,8 +91,9 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN
|
|||||||
|
|
||||||
const attention: { key: string; label: string; tone: 'alert' | 'ok'; action?: () => void; actionLabel?: string }[] = []
|
const attention: { key: string; label: string; tone: 'alert' | 'ok'; action?: () => void; actionLabel?: string }[] = []
|
||||||
if (data) {
|
if (data) {
|
||||||
|
if (data.seasonCheck && data.seasonCheck.total > 0) attention.push({ key: 'season-check', label: `Season check: ${data.seasonCheck.total} thing${data.seasonCheck.total === 1 ? '' : 's'} to fix`, tone: 'alert', action: () => onNavigate('podcast:season-check'), actionLabel: 'Open Season Check' })
|
||||||
if (data.comments.pending > 0) attention.push({ key: 'comments', label: `${data.comments.pending} study comment${data.comments.pending === 1 ? '' : 's'} awaiting approval`, tone: 'alert', action: () => onNavigate('study-comments'), actionLabel: 'Review' })
|
if (data.comments.pending > 0) attention.push({ key: 'comments', label: `${data.comments.pending} study comment${data.comments.pending === 1 ? '' : 's'} awaiting approval`, tone: 'alert', action: () => onNavigate('study-comments'), actionLabel: 'Review' })
|
||||||
if (data.nuggets.uploadsThisWeek > 0) attention.push({ key: 'nuggets', label: `${data.nuggets.uploadsThisWeek} Nuggets upload${data.nuggets.uploadsThisWeek === 1 ? '' : 's'} in the last 7 days`, tone: 'alert', action: () => onNavigate('nuggets:history'), actionLabel: 'View uploads' })
|
if (data.nuggets.pending > 0) attention.push({ key: 'nuggets', label: `${data.nuggets.pending} Nuggets upload${data.nuggets.pending === 1 ? '' : 's'} waiting in the workflow`, tone: 'alert', action: () => onNavigate('nuggets:workflow'), actionLabel: 'Open workflow' })
|
||||||
if (data.latestEpisode.available && data.latestEpisode.stale) attention.push({ key: 'feed', label: `Podcast feed has no new episode in ${data.latestEpisode.daysAgo} days`, tone: 'alert', action: () => onNavigate('podcast:rss'), actionLabel: 'Check feed' })
|
if (data.latestEpisode.available && data.latestEpisode.stale) attention.push({ key: 'feed', label: `Podcast feed has no new episode in ${data.latestEpisode.daysAgo} days`, tone: 'alert', action: () => onNavigate('podcast:rss'), actionLabel: 'Check feed' })
|
||||||
if (!data.health.lastBackup.ok) attention.push({ key: 'backup', label: 'Last data backup failed', tone: 'alert' })
|
if (!data.health.lastBackup.ok) attention.push({ key: 'backup', label: 'Last data backup failed', tone: 'alert' })
|
||||||
if (data.health.dataDirWritable === false) attention.push({ key: 'disk', label: 'Data directory is not writable', tone: 'alert' })
|
if (data.health.dataDirWritable === false) attention.push({ key: 'disk', label: 'Data directory is not writable', tone: 'alert' })
|
||||||
@@ -137,6 +139,30 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{data.seasonCheck && data.seasonCheck.lanes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 className="admin-dashboard-section-title">Season check</h3>
|
||||||
|
<div className="admin-dashboard-grid">
|
||||||
|
{data.seasonCheck.lanes.map(l => {
|
||||||
|
const issues = l.errors + l.warnings
|
||||||
|
return (
|
||||||
|
<div key={l.lane} className="admin-dashboard-card">
|
||||||
|
<div className="admin-dashboard-card-value">{issues === 0 ? '✓' : issues}</div>
|
||||||
|
<div className="admin-dashboard-card-label">Lane {l.lane}{l.title ? ` — ${l.title}` : ''}</div>
|
||||||
|
<div className="admin-dashboard-card-sub">
|
||||||
|
{l.season ? `Season ${l.season}` : 'No season number set'} · {issues === 0 ? 'all set' : `${issues} to fix`}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="admin-dashboard-card-action" onClick={() => onNavigate('podcast:season-check')}>Season Check →</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{data.seasonCheck.general > 0 && (
|
||||||
|
<p className="admin-stats-note">Plus {data.seasonCheck.general} thing{data.seasonCheck.general === 1 ? '' : 's'} that apply to every lane (such as scripts with no season number).</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<h3 className="admin-dashboard-section-title">Trends</h3>
|
<h3 className="admin-dashboard-section-title">Trends</h3>
|
||||||
<div className="admin-dashboard-grid">
|
<div className="admin-dashboard-grid">
|
||||||
<TrendCard label="Real visits (7 days)" trend={data.trends.visits} onClick={() => onNavigate('analytics')} actionLabel="Analytics" />
|
<TrendCard label="Real visits (7 days)" trend={data.trends.visits} onClick={() => onNavigate('analytics')} actionLabel="Analytics" />
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ export function AnalyticsPanel({
|
|||||||
<article>
|
<article>
|
||||||
<h3>Unique Visitors</h3>
|
<h3>Unique Visitors</h3>
|
||||||
<p>{stats.visitors.uniqueVisitors.toLocaleString()}</p>
|
<p>{stats.visitors.uniqueVisitors.toLocaleString()}</p>
|
||||||
<small>Tracked with consent</small>
|
<small>{stats.uniqueVisitors7d != null ? `${stats.uniqueVisitors7d.toLocaleString()} this week · ` : ''}Tracked with consent</small>
|
||||||
</article>
|
</article>
|
||||||
<article>
|
<article>
|
||||||
<h3>Returning Rate</h3>
|
<h3>Returning Rate</h3>
|
||||||
@@ -399,6 +399,23 @@ export function AnalyticsPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
|
||||||
|
<div className="admin-analytics-card admin-analytics-card--wide">
|
||||||
|
<h3 className="admin-analytics-card-title">Probed URLs (not real pages)</h3>
|
||||||
|
{(stats.topUnknownPaths ?? []).length === 0 ? (
|
||||||
|
<p className="admin-stats-note">No probe traffic recorded yet. Requests for URLs that are not site pages (like /.env or /wp-login) are listed here and counted as bots.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="admin-bot-reason-list">
|
||||||
|
{(stats.topUnknownPaths ?? []).map(row => (
|
||||||
|
<li key={row.path} className="admin-bot-reason-item">
|
||||||
|
<span style={{ wordBreak: 'break-all' }}>{row.path}</span>
|
||||||
|
<strong>{row.hits.toLocaleString()}</strong>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
|
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
|
||||||
<div className="admin-analytics-card admin-analytics-card--wide">
|
<div className="admin-analytics-card admin-analytics-card--wide">
|
||||||
<h3 className="admin-analytics-card-title">Top Pages — Real vs Bot ({rangeLabel})</h3>
|
<h3 className="admin-analytics-card-title">Top Pages — Real vs Bot ({rangeLabel})</h3>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ChangeEvent } from 'react'
|
|||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,}$/
|
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,}$/
|
||||||
|
const MAX_MESSAGE = 3000
|
||||||
|
|
||||||
interface ContactFields {
|
interface ContactFields {
|
||||||
firstName: string
|
firstName: string
|
||||||
@@ -12,6 +13,15 @@ interface ContactFields {
|
|||||||
messageType: string
|
messageType: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TYPES: { value: string; label: string; hint: string; placeholder: string }[] = [
|
||||||
|
{ value: 'question', label: 'Bible question', hint: 'Ask anything about Scripture. Answers are posted on the Q&A page.', placeholder: 'What would you like to know?' },
|
||||||
|
{ value: 'gospel', label: 'Gospel courtroom', hint: 'A question about the gospel courtroom: the Judge, the accuser, the charges, the Substitute, the pardon or your plea. It goes to Nate privately and is not posted publicly.', placeholder: 'What would you like to ask about the courtroom, the verdict or the pardon?' },
|
||||||
|
{ value: 'community', label: 'Community question', hint: 'A question about a study discussion or the community.', placeholder: 'What’s on your mind?' },
|
||||||
|
{ value: 'testimony', label: 'Testimony', hint: 'Share what God is doing in your life. Every one is read.', placeholder: 'Tell your story…' },
|
||||||
|
{ value: 'topic', label: 'Topic request', hint: 'Suggest a passage or topic for a future episode.', placeholder: 'Which passage or topic?' },
|
||||||
|
{ value: 'general', label: 'General message', hint: 'Anything else you’d like to say.', placeholder: 'Write your message…' },
|
||||||
|
]
|
||||||
|
|
||||||
export default function ContactForm() {
|
export default function ContactForm() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
@@ -23,7 +33,7 @@ export default function ContactForm() {
|
|||||||
lastName: '',
|
lastName: '',
|
||||||
email: '',
|
email: '',
|
||||||
message: '',
|
message: '',
|
||||||
messageType: source === 'community' ? 'community' : 'question',
|
messageType: source === 'community' ? 'community' : source === 'salvation' ? 'gospel' : 'question',
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -42,6 +52,8 @@ export default function ContactForm() {
|
|||||||
const [errorMsg, setErrorMsg] = useState('')
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
const [fieldErrors, setFieldErrors] = useState<{ email?: string }>({})
|
const [fieldErrors, setFieldErrors] = useState<{ email?: string }>({})
|
||||||
|
|
||||||
|
const activeType = TYPES.find(t => t.value === fields.messageType) ?? TYPES[0]
|
||||||
|
|
||||||
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||||
const { name, value } = e.target
|
const { name, value } = e.target
|
||||||
setFields(f => ({ ...f, [name]: value }))
|
setFields(f => ({ ...f, [name]: value }))
|
||||||
@@ -59,10 +71,6 @@ export default function ContactForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
|
|
||||||
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setStatus('submitting')
|
setStatus('submitting')
|
||||||
@@ -90,7 +98,7 @@ export default function ContactForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="contact-form" onSubmit={handleSubmit} noValidate>
|
<form className="cx-form" onSubmit={handleSubmit} noValidate>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="contact-honeypot"
|
className="contact-honeypot"
|
||||||
@@ -100,14 +108,37 @@ export default function ContactForm() {
|
|||||||
value={honey}
|
value={honey}
|
||||||
onChange={e => setHoney(e.target.value)}
|
onChange={e => setHoney(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<fieldset className="cx-types">
|
||||||
|
<legend>What is this about?</legend>
|
||||||
|
<div className="cx-type-row" role="radiogroup">
|
||||||
|
{TYPES.map(t => (
|
||||||
|
<label key={t.value} className="cx-type">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="messageType"
|
||||||
|
value={t.value}
|
||||||
|
checked={fields.messageType === t.value}
|
||||||
|
onChange={() => setFields(f => ({ ...f, messageType: t.value }))}
|
||||||
|
/>
|
||||||
|
<span>{t.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="cx-hint" aria-live="polite">{activeType.hint}</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="cx-row">
|
||||||
<label>
|
<label>
|
||||||
First Name
|
First name
|
||||||
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
|
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Last Name
|
Last name
|
||||||
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
|
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Email
|
Email
|
||||||
<input
|
<input
|
||||||
@@ -122,41 +153,37 @@ export default function ContactForm() {
|
|||||||
/>
|
/>
|
||||||
{fieldErrors.email && <span id="contact-email-error" className="study-signup-field-error">{fieldErrors.email}</span>}
|
{fieldErrors.email && <span id="contact-email-error" className="study-signup-field-error">{fieldErrors.email}</span>}
|
||||||
</label>
|
</label>
|
||||||
<label>
|
|
||||||
Message Type
|
|
||||||
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
|
|
||||||
<option value="question">Bible Question</option>
|
|
||||||
<option value="community">Community Question</option>
|
|
||||||
<option value="testimony">Testimony</option>
|
|
||||||
<option value="topic">Topic Request</option>
|
|
||||||
<option value="general">General Message</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
Message
|
Message
|
||||||
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
|
<textarea
|
||||||
</label>
|
name="message"
|
||||||
{fields.messageType === 'question' && (
|
rows={6}
|
||||||
<label className="contact-consent">
|
required
|
||||||
<input
|
maxLength={MAX_MESSAGE}
|
||||||
type="checkbox"
|
placeholder={activeType.placeholder}
|
||||||
checked={notifyOnAnswer}
|
value={fields.message}
|
||||||
onChange={e => setNotifyOnAnswer(e.target.checked)}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
|
<span className="cx-count">{fields.message.length} / {MAX_MESSAGE}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="cx-checks">
|
||||||
|
{fields.messageType === 'question' && (
|
||||||
|
<label className="cx-check">
|
||||||
|
<input type="checkbox" checked={notifyOnAnswer} onChange={e => setNotifyOnAnswer(e.target.checked)} />
|
||||||
<span>Email me when this question is answered.</span>
|
<span>Email me when this question is answered.</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<label className="contact-consent">
|
<label className="cx-check">
|
||||||
<input
|
<input type="checkbox" checked={subscribe} onChange={e => setSubscribe(e.target.checked)} />
|
||||||
type="checkbox"
|
|
||||||
checked={subscribe}
|
|
||||||
onChange={e => setSubscribe(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
|
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
|
||||||
</label>
|
</label>
|
||||||
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
|
</div>
|
||||||
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
|
||||||
{status === 'submitting' ? 'Sending…' : 'Send Message'}
|
{status === 'error' && <p className="contact-error" role="alert">{errorMsg}</p>}
|
||||||
|
<button type="submit" className="btn-primary cx-submit" disabled={status === 'submitting'}>
|
||||||
|
{status === 'submitting' ? 'Sending…' : 'Send message'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
interface AccountStatus {
|
||||||
|
authenticated: boolean
|
||||||
|
username: string
|
||||||
|
displayName?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
adminShortcut?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared across header instances so moving between pages doesn't flash the signed-out icon
|
||||||
|
let cachedStatus: AccountStatus | null = null
|
||||||
|
|
||||||
|
function PersonIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="8" r="4" />
|
||||||
|
<path d="M4 20c0-3.6 3.6-6 8-6s8 2.4 8 6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeaderAccount() {
|
||||||
|
const [status, setStatus] = useState<AccountStatus | null>(cachedStatus)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch('/api/study-auth/status')
|
||||||
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('status failed'))))
|
||||||
|
.then((data: AccountStatus) => {
|
||||||
|
cachedStatus = data
|
||||||
|
if (!cancelled) setStatus(data)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onDown = (e: MouseEvent | TouchEvent) => {
|
||||||
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||||
|
document.addEventListener('mousedown', onDown)
|
||||||
|
document.addEventListener('touchstart', onDown)
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown)
|
||||||
|
document.removeEventListener('touchstart', onDown)
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
try { await fetch('/api/study-auth/logout', { method: 'POST' }) } catch { /* reload below shows the real state */ }
|
||||||
|
cachedStatus = null
|
||||||
|
window.location.assign('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed out (or still checking): one tap goes to the sign-in page
|
||||||
|
if (!status?.authenticated) {
|
||||||
|
return (
|
||||||
|
<Link to="/study/account" className="header-account" aria-label="Sign in or create an account" title="Sign in">
|
||||||
|
<PersonIcon />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = status.displayName?.trim() || status.username
|
||||||
|
const initial = (name || '?').charAt(0).toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="header-account-wrap" ref={wrapRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="header-account header-account--in"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={`Account menu for ${name}`}
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
>
|
||||||
|
{status.avatarUrl ? <img src={status.avatarUrl} alt="" className="header-account-avatar" /> : <span aria-hidden="true">{initial}</span>}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="header-account-menu" role="menu">
|
||||||
|
<p className="header-account-name">Signed in as <strong>{name}</strong></p>
|
||||||
|
<Link to="/study/account" role="menuitem" className="header-account-item" onClick={() => setOpen(false)}>My Account</Link>
|
||||||
|
{status.adminShortcut && (
|
||||||
|
<Link to="/admin" role="menuitem" className="header-account-item" onClick={() => setOpen(false)}>Admin</Link>
|
||||||
|
)}
|
||||||
|
<button type="button" role="menuitem" className="header-account-item" onClick={signOut}>Sign out</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,14 +5,17 @@ import { useEffect } from 'react'
|
|||||||
interface NoteCardProps {
|
interface NoteCardProps {
|
||||||
anchorLabel: string
|
anchorLabel: string
|
||||||
html: string
|
html: string
|
||||||
|
updatedAt?: string | null
|
||||||
|
onDelete?: () => void
|
||||||
autoSaveMsg?: string
|
autoSaveMsg?: string
|
||||||
onChange: (html: string) => void
|
onChange: (html: string) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }: NoteCardProps) {
|
export function NoteCard({ anchorLabel, html, updatedAt, onDelete, autoSaveMsg, onChange, onClose }: NoteCardProps) {
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions: [StarterKit],
|
extensions: [StarterKit],
|
||||||
|
shouldRerenderOnTransaction: true,
|
||||||
content: html || '<p></p>',
|
content: html || '<p></p>',
|
||||||
onUpdate({ editor }) {
|
onUpdate({ editor }) {
|
||||||
const next = editor.getHTML()
|
const next = editor.getHTML()
|
||||||
@@ -32,6 +35,8 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
|
|||||||
|
|
||||||
if (!editor) return null
|
if (!editor) return null
|
||||||
|
|
||||||
|
const wordCount = editor.getText().split(/\s+/).filter(Boolean).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="note-card">
|
<div className="note-card">
|
||||||
<div className="note-card-header">
|
<div className="note-card-header">
|
||||||
@@ -71,6 +76,27 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
|
|||||||
aria-label="Numbered list"
|
aria-label="Numbered list"
|
||||||
title="Numbered list"
|
title="Numbered list"
|
||||||
>1.</button>
|
>1.</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`note-tool${editor.isActive('heading', { level: 3 }) ? ' note-tool--active' : ''}`}
|
||||||
|
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleHeading({ level: 3 }).run() }}
|
||||||
|
aria-label="Heading"
|
||||||
|
title="Heading"
|
||||||
|
>H</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`note-tool${editor.isActive('blockquote') ? ' note-tool--active' : ''}`}
|
||||||
|
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleBlockquote().run() }}
|
||||||
|
aria-label="Quote"
|
||||||
|
title="Quote"
|
||||||
|
>❝</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`note-tool${editor.isActive('strike') ? ' note-tool--active' : ''}`}
|
||||||
|
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleStrike().run() }}
|
||||||
|
aria-label="Strikethrough"
|
||||||
|
title="Strikethrough"
|
||||||
|
><s>S</s></button>
|
||||||
<div className="note-tool-divider" />
|
<div className="note-tool-divider" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -91,6 +117,19 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditorContent editor={editor} className="note-card-editor" />
|
<EditorContent editor={editor} className="note-card-editor" />
|
||||||
|
<div className="note-card-footer">
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="note-card-delete"
|
||||||
|
onClick={() => { if (window.confirm('Delete this note?')) onDelete() }}
|
||||||
|
>Delete note</button>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{updatedAt ? `Edited ${new Date(updatedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} · ` : ''}
|
||||||
|
{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+215
-1043
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,363 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
export interface AdminQuestion {
|
||||||
|
id: string
|
||||||
|
submittedAt: string
|
||||||
|
firstName: string
|
||||||
|
email: string
|
||||||
|
question: string
|
||||||
|
answer: string
|
||||||
|
answeredAt: string | null
|
||||||
|
isApproved: boolean
|
||||||
|
approvedAt: string | null
|
||||||
|
topic?: string
|
||||||
|
reference?: string
|
||||||
|
notifyOnAnswer?: boolean
|
||||||
|
notifiedAt?: string
|
||||||
|
upvotes?: number
|
||||||
|
pinned?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'new' | 'ready' | 'published' | 'all'
|
||||||
|
|
||||||
|
const hasAnswer = (q: AdminQuestion) => Boolean(q.answer?.trim())
|
||||||
|
const statusOf = (q: AdminQuestion): Exclude<Tab, 'all'> => (!hasAnswer(q) ? 'new' : q.isApproved ? 'published' : 'ready')
|
||||||
|
|
||||||
|
const TAB_LABELS: Record<Tab, string> = {
|
||||||
|
new: 'Needs answer',
|
||||||
|
ready: 'Ready to publish',
|
||||||
|
published: 'Published',
|
||||||
|
all: 'All',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined) {
|
||||||
|
if (!value) return ''
|
||||||
|
const d = new Date(value)
|
||||||
|
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(url: string, method: string, body?: unknown) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
const data = await res.json().catch(() => ({})) as { question?: AdminQuestion; message?: string }
|
||||||
|
if (!res.ok) throw new Error(data.message ?? 'Something went wrong.')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
questions: AdminQuestion[]
|
||||||
|
onChange: (questions: AdminQuestion[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuestionsAdmin({ questions, onChange }: Props) {
|
||||||
|
const counts = useMemo(() => ({
|
||||||
|
new: questions.filter(q => statusOf(q) === 'new').length,
|
||||||
|
ready: questions.filter(q => statusOf(q) === 'ready').length,
|
||||||
|
published: questions.filter(q => statusOf(q) === 'published').length,
|
||||||
|
all: questions.length,
|
||||||
|
}), [questions])
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<Tab>(counts.new > 0 ? 'new' : 'all')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [selectedId, setSelectedId] = useState('')
|
||||||
|
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
// Edits live in a draft for the open question until saved
|
||||||
|
const [draft, setDraft] = useState<{ id: string; question: string; answer: string; topic: string; reference: string } | null>(null)
|
||||||
|
|
||||||
|
const topics = useMemo(() => {
|
||||||
|
const tally = new Map<string, number>()
|
||||||
|
for (const q of questions) if (q.topic?.trim()) tally.set(q.topic.trim(), (tally.get(q.topic.trim()) ?? 0) + 1)
|
||||||
|
return [...tally.entries()].sort((a, b) => b[1] - a[1]).map(([t]) => t)
|
||||||
|
}, [questions])
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const needle = search.trim().toLowerCase()
|
||||||
|
return questions
|
||||||
|
.filter(q => tab === 'all' || statusOf(q) === tab)
|
||||||
|
.filter(q => !needle || [q.firstName, q.question, q.answer, q.topic ?? '', q.reference ?? ''].some(v => v.toLowerCase().includes(needle)))
|
||||||
|
}, [questions, tab, search])
|
||||||
|
|
||||||
|
const selected = questions.find(q => q.id === selectedId) ?? null
|
||||||
|
const current = selected && draft?.id === selected.id
|
||||||
|
? draft
|
||||||
|
: selected
|
||||||
|
? { id: selected.id, question: selected.question, answer: selected.answer ?? '', topic: selected.topic ?? '', reference: selected.reference ?? '' }
|
||||||
|
: null
|
||||||
|
const dirty = Boolean(selected && current && (
|
||||||
|
current.question !== selected.question || current.answer !== (selected.answer ?? '') ||
|
||||||
|
current.topic !== (selected.topic ?? '') || current.reference !== (selected.reference ?? '')
|
||||||
|
))
|
||||||
|
|
||||||
|
const open = (id: string) => {
|
||||||
|
if (dirty && !confirm('You have unsaved changes to this question. Discard them?')) return
|
||||||
|
setSelectedId(id)
|
||||||
|
setDraft(null)
|
||||||
|
setMessage('')
|
||||||
|
}
|
||||||
|
const edit = (patch: Partial<NonNullable<typeof draft>>) => {
|
||||||
|
if (!selected || !current) return
|
||||||
|
setDraft({ ...current, ...patch })
|
||||||
|
}
|
||||||
|
const replace = (q: AdminQuestion) => onChange(questions.map(item => (item.id === q.id ? { ...item, ...q } : item)))
|
||||||
|
|
||||||
|
// After publishing, move on to the next question waiting in the same queue
|
||||||
|
const advance = (fromId: string) => {
|
||||||
|
const list = visible.filter(q => q.id !== fromId)
|
||||||
|
const idx = visible.findIndex(q => q.id === fromId)
|
||||||
|
const next = list[Math.min(idx, list.length - 1)]
|
||||||
|
setSelectedId(next?.id ?? '')
|
||||||
|
setDraft(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = async (label: string, fn: () => Promise<void>) => {
|
||||||
|
setBusy(true)
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
} catch (err) {
|
||||||
|
setMessage(err instanceof Error ? err.message : `Could not ${label}.`)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveFields = () => run('save', async () => {
|
||||||
|
if (!selected || !current) return
|
||||||
|
const d = await call(`/api/admin-questions/${selected.id}`, 'PATCH', current)
|
||||||
|
if (d.question) replace(d.question)
|
||||||
|
setDraft(null)
|
||||||
|
setMessage('Saved.')
|
||||||
|
})
|
||||||
|
|
||||||
|
const publish = () => run('publish', async () => {
|
||||||
|
if (!selected || !current) return
|
||||||
|
if (current.question !== selected.question) await call(`/api/admin-questions/${selected.id}`, 'PATCH', { question: current.question })
|
||||||
|
const d = await call(`/api/admin-questions/${selected.id}/publish`, 'POST', { answer: current.answer, topic: current.topic, reference: current.reference })
|
||||||
|
if (d.question) {
|
||||||
|
const id = selected.id
|
||||||
|
replace(d.question)
|
||||||
|
advance(tab === 'published' || tab === 'all' ? '' : id)
|
||||||
|
setMessage(selected.notifyOnAnswer && selected.email && !selected.notifiedAt ? 'Published, and the asker was emailed.' : 'Published.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const unpublish = () => run('unpublish', async () => {
|
||||||
|
if (!selected) return
|
||||||
|
const d = await call(`/api/admin-questions/${selected.id}/approve`, 'POST', { approved: false })
|
||||||
|
if (d.question) replace(d.question)
|
||||||
|
setMessage('Taken off the site.')
|
||||||
|
})
|
||||||
|
|
||||||
|
const togglePin = () => run('pin', async () => {
|
||||||
|
if (!selected) return
|
||||||
|
const d = await call(`/api/admin-questions/${selected.id}/pin`, selected.pinned ? 'DELETE' : 'POST')
|
||||||
|
if (d.question) replace(d.question)
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = (ids: string[]) => {
|
||||||
|
if (ids.length === 0) return
|
||||||
|
if (!confirm(`Delete ${ids.length === 1 ? 'this question' : `${ids.length} questions`} permanently?`)) return
|
||||||
|
return run('delete', async () => {
|
||||||
|
const gone: string[] = []
|
||||||
|
for (const id of ids) {
|
||||||
|
try { await call(`/api/admin-questions/${id}`, 'DELETE'); gone.push(id) } catch { /* keep going */ }
|
||||||
|
}
|
||||||
|
onChange(questions.filter(q => !gone.includes(q.id)))
|
||||||
|
setChecked(new Set())
|
||||||
|
if (gone.includes(selectedId)) { setSelectedId(''); setDraft(null) }
|
||||||
|
if (gone.length < ids.length) setMessage(`Deleted ${gone.length} of ${ids.length}.`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const [manual, setManual] = useState({ firstName: '', email: '', question: '', answer: '', topic: '', reference: '', publish: false })
|
||||||
|
const addManual = () => run('add the question', async () => {
|
||||||
|
const d = await call('/api/admin-questions', 'POST', { ...manual, approve: manual.publish && Boolean(manual.answer.trim()) })
|
||||||
|
if (d.question) {
|
||||||
|
onChange([d.question, ...questions])
|
||||||
|
setSelectedId(d.question.id)
|
||||||
|
setDraft(null)
|
||||||
|
}
|
||||||
|
setManual({ firstName: '', email: '', question: '', answer: '', topic: '', reference: '', publish: false })
|
||||||
|
setAdding(false)
|
||||||
|
setMessage('Question added.')
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = selected ? statusOf(selected) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-panel-section" aria-label="Questions and answers">
|
||||||
|
<div className="admin-panel-head">
|
||||||
|
<h2>Bible Questions & Answers</h2>
|
||||||
|
<p>Work through questions from the top: write an answer, set a topic, and publish. Published answers appear on the public Q&A page right away. Pinned questions show first there and are featured on the home page.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12, alignItems: 'center' }}>
|
||||||
|
{(Object.keys(TAB_LABELS) as Tab[]).map(t => (
|
||||||
|
<button key={t} type="button" className={tab === t ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => setTab(t)}>
|
||||||
|
{TAB_LABELS[t]} ({counts[t]})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn-admin-add" style={{ marginLeft: 'auto' }} onClick={() => setAdding(a => !a)}>
|
||||||
|
{adding ? 'Cancel' : '+ Add question'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && <p className="admin-stats-note" role="status">{message}</p>}
|
||||||
|
|
||||||
|
{adding && (
|
||||||
|
<div style={{ border: '1px solid var(--color-border, rgba(255,255,255,0.14))', borderRadius: 6, padding: 12, marginBottom: 16 }}>
|
||||||
|
<h3 style={{ marginTop: 0 }}>Add a question</h3>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
|
||||||
|
<label htmlFor="qa-new-name">First name</label>
|
||||||
|
<input id="qa-new-name" type="text" value={manual.firstName} onChange={e => setManual({ ...manual, firstName: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field" style={{ flex: 1, minWidth: 200 }}>
|
||||||
|
<label htmlFor="qa-new-email">Email (optional)</label>
|
||||||
|
<input id="qa-new-email" type="email" value={manual.email} onChange={e => setManual({ ...manual, email: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="qa-new-question">Question</label>
|
||||||
|
<textarea id="qa-new-question" rows={3} value={manual.question} onChange={e => setManual({ ...manual, question: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="qa-new-answer">Answer (optional)</label>
|
||||||
|
<textarea id="qa-new-answer" rows={4} value={manual.answer} onChange={e => setManual({ ...manual, answer: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
|
||||||
|
<label htmlFor="qa-new-topic">Topic</label>
|
||||||
|
<input id="qa-new-topic" type="text" list="qa-topic-options" value={manual.topic} onChange={e => setManual({ ...manual, topic: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
|
||||||
|
<label htmlFor="qa-new-ref">Scripture reference</label>
|
||||||
|
<input id="qa-new-ref" type="text" placeholder="e.g. Titus 1:5-9" value={manual.reference} onChange={e => setManual({ ...manual, reference: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label style={{ display: 'block', margin: '8px 0' }}>
|
||||||
|
<input type="checkbox" checked={manual.publish} onChange={e => setManual({ ...manual, publish: e.target.checked })} /> Publish right away (needs an answer)
|
||||||
|
</label>
|
||||||
|
<button type="button" className="btn-admin-save" onClick={addManual} disabled={busy || !manual.firstName.trim() || manual.question.trim().length < 5}>Add question</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<datalist id="qa-topic-options">{topics.map(t => <option key={t} value={t} />)}</datalist>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(240px, 340px) 1fr', gap: 20, alignItems: 'start' }}>
|
||||||
|
<div>
|
||||||
|
<div className="admin-field" style={{ marginBottom: 8 }}>
|
||||||
|
<input type="search" placeholder="Search questions, answers, topics…" value={search} onChange={e => setSearch(e.target.value)} aria-label="Search questions" />
|
||||||
|
</div>
|
||||||
|
{checked.size > 0 && (
|
||||||
|
<div className="admin-bulk-toolbar" style={{ marginBottom: 8 }}>
|
||||||
|
<span>{checked.size} selected</span>
|
||||||
|
<button type="button" className="btn-admin-remove" onClick={() => remove([...checked])}>Delete</button>
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={() => setChecked(new Set())}>Clear</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visible.length === 0 && <p className="admin-stats-note">{tab === 'new' ? 'Nothing waiting for an answer. 🎉' : 'No questions here.'}</p>}
|
||||||
|
{visible.map(q => (
|
||||||
|
<div
|
||||||
|
key={q.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex', gap: 8, alignItems: 'flex-start', padding: '8px 10px', marginBottom: 6, borderRadius: 6,
|
||||||
|
border: `1px solid ${q.id === selectedId ? '#8a6d2f' : 'var(--color-border, rgba(255,255,255,0.14))'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`Select question from ${q.firstName}`}
|
||||||
|
checked={checked.has(q.id)}
|
||||||
|
onChange={e => setChecked(prev => { const next = new Set(prev); if (e.target.checked) next.add(q.id); else next.delete(q.id); return next })}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => open(q.id)}
|
||||||
|
style={{ flex: 1, textAlign: 'left', background: 'transparent', border: 0, padding: 0, cursor: 'pointer', color: 'inherit', font: 'inherit' }}
|
||||||
|
>
|
||||||
|
<strong>{q.pinned ? '📌 ' : ''}{q.firstName || 'Someone'}</strong>
|
||||||
|
<span style={{ fontSize: '0.76rem', opacity: 0.65 }}> · {formatDate(q.submittedAt)}</span>
|
||||||
|
<div style={{ fontSize: '0.84rem', opacity: 0.85, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{q.question}</div>
|
||||||
|
<div style={{ fontSize: '0.74rem', opacity: 0.7, marginTop: 2 }}>
|
||||||
|
{tab === 'all' && <>{TAB_LABELS[statusOf(q)]} · </>}
|
||||||
|
{q.topic ? `${q.topic} · ` : ''}{q.notifyOnAnswer && !q.notifiedAt && !q.isApproved ? '✉ wants an email · ' : ''}{(q.upvotes ?? 0) > 0 ? `▲ ${q.upvotes}` : ''}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{!selected || !current ? (
|
||||||
|
<p className="admin-stats-note">Select a question to answer it.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '0 0 8px', fontSize: '0.86rem' }}>
|
||||||
|
From <strong>{selected.firstName || 'Someone'}</strong>
|
||||||
|
{selected.email && <> · <a href={`mailto:${selected.email}`}>{selected.email}</a></>}
|
||||||
|
{' '}· {formatDate(selected.submittedAt)} · <em>{TAB_LABELS[status!]}</em>
|
||||||
|
</p>
|
||||||
|
{selected.notifyOnAnswer && selected.email && (
|
||||||
|
<p className="admin-stats-note" style={{ marginTop: 0 }}>
|
||||||
|
{selected.notifiedAt ? `They were emailed on ${formatDate(selected.notifiedAt)}.` : 'They asked to be emailed when this is answered. Publishing sends it.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="qa-edit-question">Question</label>
|
||||||
|
<textarea id="qa-edit-question" rows={3} value={current.question} onChange={e => edit({ question: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor="qa-edit-answer">Answer</label>
|
||||||
|
<textarea id="qa-edit-answer" rows={9} value={current.answer} onChange={e => edit({ answer: e.target.value })} />
|
||||||
|
<p className="admin-field-help">{current.answer.length} / 5000</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
<div className="admin-field" style={{ flex: 1, minWidth: 180 }}>
|
||||||
|
<label htmlFor="qa-edit-topic">Topic</label>
|
||||||
|
<input id="qa-edit-topic" type="text" list="qa-topic-options" value={current.topic} onChange={e => edit({ topic: e.target.value })} placeholder="e.g. Salvation" />
|
||||||
|
</div>
|
||||||
|
<div className="admin-field" style={{ flex: 1, minWidth: 180 }}>
|
||||||
|
<label htmlFor="qa-edit-ref">Scripture reference</label>
|
||||||
|
<input id="qa-edit-ref" type="text" value={current.reference} onChange={e => edit({ reference: e.target.value })} placeholder="e.g. Titus 1:5-9" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{topics.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', margin: '0 0 12px' }}>
|
||||||
|
{topics.slice(0, 8).map(t => (
|
||||||
|
<button key={t} type="button" className={current.topic === t ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => edit({ topic: t })}>{t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
{status === 'published' ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn-admin-save" onClick={saveFields} disabled={busy || !dirty}>Save changes</button>
|
||||||
|
<button type="button" className="btn-admin-ghost" onClick={unpublish} disabled={busy}>Unpublish</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn-admin-save" onClick={publish} disabled={busy || !current.answer.trim()}>Save & publish</button>
|
||||||
|
<button type="button" className="btn-admin-ghost" onClick={saveFields} disabled={busy || !dirty}>Save draft</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn-admin-ghost" onClick={togglePin} disabled={busy} title="Pinned questions show first on the Q&A page and are featured on the home page">{selected.pinned ? 'Unpin' : 'Pin & feature'}</button>
|
||||||
|
<button type="button" className="btn-admin-remove" onClick={() => remove([selected.id])} disabled={busy}>Delete</button>
|
||||||
|
{dirty && <span className="admin-stats-note">Unsaved changes</span>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import type { SiteContent } from '../content'
|
||||||
|
import { StoreLink } from './StoreLink'
|
||||||
|
|
||||||
|
type TabKey = 'downloads' | 'books' | 'recommended'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: SiteContent
|
||||||
|
downloadPath: (resourceId: string) => string
|
||||||
|
// Shown when there are no downloads, books or recommended resources at all
|
||||||
|
fallback: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// A description that stays short in the grid; long ones get a Read more toggle
|
||||||
|
function ClampedText({ text, className, lines = 3, threshold = 150 }: { text: string; className: string; lines?: number; threshold?: number }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const long = text.length > threshold
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p
|
||||||
|
className={`${className}${long && !open ? ' res-clamped' : ''}`}
|
||||||
|
style={long && !open ? { WebkitLineClamp: lines } : undefined}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
{long && (
|
||||||
|
<button type="button" className="res-more" onClick={() => setOpen(o => !o)} aria-expanded={open}>
|
||||||
|
{open ? 'Show less' : 'Read more'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourcesHub({ content, downloadPath, fallback }: Props) {
|
||||||
|
const links = content.customLinks ?? []
|
||||||
|
// Ask for name and email before sending visitors to Amazon / Apple Books (on unless switched off in the admin)
|
||||||
|
const gated = content.gateStoreLinks !== false
|
||||||
|
const resources = links.filter(l => l.placement === 'resources')
|
||||||
|
const books = links.filter(l => l.placement === 'books')
|
||||||
|
const recommended = links.filter(l => l.placement === 'recommended')
|
||||||
|
|
||||||
|
const featured: { key: string; to: string; title: string; description: string; image: string; alt: string }[] = []
|
||||||
|
if (content.studyGuideTitle) {
|
||||||
|
featured.push({
|
||||||
|
key: 'primary',
|
||||||
|
to: '/downloads/primary-guide',
|
||||||
|
title: content.studyGuideTitle,
|
||||||
|
description: content.studyGuideDescription ?? '',
|
||||||
|
image: content.seriesImageUrl || '/images/titus-cover.png',
|
||||||
|
alt: content.seriesTitle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (content.secondSeries?.guide?.title) {
|
||||||
|
featured.push({
|
||||||
|
key: 'second',
|
||||||
|
to: '/downloads/second-guide',
|
||||||
|
title: content.secondSeries.guide.title,
|
||||||
|
description: content.secondSeries.guide.description ?? '',
|
||||||
|
image: content.secondSeries.imageUrl ?? '',
|
||||||
|
alt: content.secondSeries.title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts: Record<TabKey, number> = {
|
||||||
|
downloads: featured.length + resources.length,
|
||||||
|
books: books.length,
|
||||||
|
recommended: recommended.length,
|
||||||
|
}
|
||||||
|
const available = (Object.keys(counts) as TabKey[]).filter(k => counts[k] > 0)
|
||||||
|
|
||||||
|
const fromHash = (): TabKey | null => {
|
||||||
|
const h = typeof window !== 'undefined' ? window.location.hash.replace('#', '') : ''
|
||||||
|
return h === 'downloads' || h === 'books' || h === 'recommended' ? h : null
|
||||||
|
}
|
||||||
|
const [picked, setPicked] = useState<TabKey | null>(fromHash)
|
||||||
|
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Links like /resources#books, and the browser's back/forward buttons, switch the tab too
|
||||||
|
useEffect(() => {
|
||||||
|
const onHash = () => setPicked(fromHash())
|
||||||
|
window.addEventListener('hashchange', onHash)
|
||||||
|
return () => window.removeEventListener('hashchange', onHash)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (available.length === 0) return <>{fallback}</>
|
||||||
|
|
||||||
|
const tab: TabKey = picked && counts[picked] > 0 ? picked : available[0]
|
||||||
|
const choose = (k: TabKey) => {
|
||||||
|
setPicked(k)
|
||||||
|
try { window.history.replaceState(null, '', `#${k}`) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
|
||||||
|
const shownResources = resources.filter(r => !activeTag || (r.tags ?? []).includes(activeTag))
|
||||||
|
const labels: Record<TabKey, string> = { downloads: 'Downloads', books: 'Reading List', recommended: 'Recommended' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="res-hub" aria-label="Resources">
|
||||||
|
<div className="section-inner">
|
||||||
|
{available.length > 1 && (
|
||||||
|
<div className="res-tabs" role="tablist" aria-label="Resource types">
|
||||||
|
{available.map(k => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`res-tab-${k}`}
|
||||||
|
aria-selected={tab === k}
|
||||||
|
aria-controls="res-panel"
|
||||||
|
className={`res-tab${tab === k ? ' res-tab--on' : ''}`}
|
||||||
|
onClick={() => choose(k)}
|
||||||
|
>
|
||||||
|
{labels[k]} <span>{counts[k]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div id="res-panel" role="tabpanel" aria-labelledby={`res-tab-${tab}`}>
|
||||||
|
{tab === 'downloads' && (
|
||||||
|
<>
|
||||||
|
{featured.length > 0 && (
|
||||||
|
<div className="res-featured">
|
||||||
|
{featured.map(f => (
|
||||||
|
<Link key={f.key} to={f.to} className="res-feature">
|
||||||
|
{f.image && <img src={f.image} alt={f.alt} className="res-feature-img" loading="lazy" />}
|
||||||
|
<div className="res-feature-body">
|
||||||
|
<span className="res-badge">Featured</span>
|
||||||
|
<span className="res-title">{f.title}</span>
|
||||||
|
{f.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 2 }}>{f.description}</p>}
|
||||||
|
<span className="res-action">Download free guide →</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="res-tags" role="group" aria-label="Filter by topic">
|
||||||
|
<button type="button" className={`res-chip${activeTag === null ? ' res-chip--on' : ''}`} onClick={() => setActiveTag(null)}>All topics</button>
|
||||||
|
{tags.map(t => (
|
||||||
|
<button key={t} type="button" className={`res-chip${activeTag === t ? ' res-chip--on' : ''}`} onClick={() => setActiveTag(prev => (prev === t ? null : t))}>{t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shownResources.length > 0 ? (
|
||||||
|
<div className="res-grid">
|
||||||
|
{shownResources.map(r => (
|
||||||
|
<Link key={r.id} to={downloadPath(r.id)} className="res-card">
|
||||||
|
{r.imageUrl && <img src={r.imageUrl} alt={r.label} className="res-card-img" loading="lazy" />}
|
||||||
|
<div className="res-card-body">
|
||||||
|
<span className="res-title">{r.label}</span>
|
||||||
|
{r.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 3 }}>{r.description}</p>}
|
||||||
|
{(r.tags ?? []).length > 0 && <span className="res-card-tags">{(r.tags ?? []).join(' · ')}</span>}
|
||||||
|
<span className="res-action">Open download →</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
resources.length > 0 && <p className="res-empty">No downloads tagged "{activeTag}".</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'books' && (
|
||||||
|
<div className="res-grid">
|
||||||
|
{books.map(book => (
|
||||||
|
<div key={book.id} className="res-book">
|
||||||
|
{book.imageUrl && <img src={book.imageUrl} alt={book.label} className="res-book-cover" loading="lazy" />}
|
||||||
|
<div className="res-book-body">
|
||||||
|
<span className="res-title">{book.label}</span>
|
||||||
|
{book.description && <ClampedText text={book.description} className="res-desc" lines={3} />}
|
||||||
|
<div className="res-book-actions">
|
||||||
|
{(book.amazonUrl || book.url) && (
|
||||||
|
<StoreLink
|
||||||
|
source={`book:${book.id}`}
|
||||||
|
store="amazon"
|
||||||
|
href={book.amazonUrl || book.url}
|
||||||
|
gated={gated}
|
||||||
|
className="btn-primary res-btn"
|
||||||
|
>
|
||||||
|
{book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')}
|
||||||
|
</StoreLink>
|
||||||
|
)}
|
||||||
|
{book.appleBooksUrl && (
|
||||||
|
<StoreLink
|
||||||
|
source={`book:${book.id}`}
|
||||||
|
store="apple"
|
||||||
|
href={book.appleBooksUrl}
|
||||||
|
gated={gated}
|
||||||
|
className="btn-secondary res-btn"
|
||||||
|
>
|
||||||
|
{book.appleBooksLabel || 'Apple Books'}
|
||||||
|
</StoreLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'recommended' && (
|
||||||
|
<div className="res-grid">
|
||||||
|
{recommended.map(item => (
|
||||||
|
<a key={item.id} href={item.url} target="_blank" rel="noopener noreferrer" className="res-card">
|
||||||
|
{item.imageUrl && <img src={item.imageUrl} alt={item.label} className="res-card-img" loading="lazy" />}
|
||||||
|
<div className="res-card-body">
|
||||||
|
<span className="res-title">{item.label}</span>
|
||||||
|
{item.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 3 }}>{item.description}</p>}
|
||||||
|
<span className="res-action">Visit →</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
const SAVED_KEY = 'vbn-store-link-contact'
|
||||||
|
|
||||||
|
interface Saved { firstName: string; lastName: string; email: string }
|
||||||
|
|
||||||
|
function readSaved(): Saved {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SAVED_KEY)
|
||||||
|
if (raw) {
|
||||||
|
const d = JSON.parse(raw) as Partial<Saved>
|
||||||
|
return { firstName: d.firstName ?? '', lastName: d.lastName ?? '', email: d.email ?? '' }
|
||||||
|
}
|
||||||
|
} catch { /* private mode or bad data */ }
|
||||||
|
return { firstName: '', lastName: '', email: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// Which guide or book this link belongs to (the server finds the real address from this)
|
||||||
|
source: string
|
||||||
|
store: 'amazon' | 'apple'
|
||||||
|
// The address used when the sign-up step is switched off
|
||||||
|
href: string
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
gated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// A button that asks for name and email, then sends the visitor on to Amazon or Apple Books.
|
||||||
|
// With the gate switched off it is just a normal link.
|
||||||
|
export function StoreLink({ source, store, href, className, children, gated }: Props) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [fields, setFields] = useState<Saved>({ firstName: '', lastName: '', email: '' })
|
||||||
|
const [subscribe, setSubscribe] = useState(true)
|
||||||
|
const [honey, setHoney] = useState('')
|
||||||
|
const [status, setStatus] = useState<'idle' | 'sending' | 'error'>('idle')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const firstRef = useRef<HTMLInputElement>(null)
|
||||||
|
const storeName = store === 'amazon' ? 'Amazon' : 'Apple Books'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
const t = window.setTimeout(() => firstRef.current?.focus(), 30)
|
||||||
|
return () => { document.removeEventListener('keydown', onKey); window.clearTimeout(t) }
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!gated) {
|
||||||
|
return <a href={href} target="_blank" rel="noopener noreferrer" className={className}>{children}</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
setFields(readSaved())
|
||||||
|
setStatus('idle')
|
||||||
|
setError('')
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setStatus('sending')
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/store-link', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ source, store, ...fields, subscribe, _honey: honey }),
|
||||||
|
})
|
||||||
|
const data = (await res.json().catch(() => ({}))) as { url?: string; message?: string }
|
||||||
|
if (!res.ok || !data.url) {
|
||||||
|
setError(data.message ?? 'Something went wrong. Please try again.')
|
||||||
|
setStatus('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try { localStorage.setItem(SAVED_KEY, JSON.stringify(fields)) } catch { /* ignore */ }
|
||||||
|
window.location.assign(data.url)
|
||||||
|
} catch {
|
||||||
|
setError('Could not connect. Please try again later.')
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = (name: keyof Saved) => (e: React.ChangeEvent<HTMLInputElement>) => setFields(f => ({ ...f, [name]: e.target.value }))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" className={className} onClick={openDialog}>{children}</button>
|
||||||
|
{open && (
|
||||||
|
<div className="sl-overlay" onMouseDown={e => { if (e.target === e.currentTarget) setOpen(false) }}>
|
||||||
|
<div className="sl-dialog" role="dialog" aria-modal="true" aria-labelledby={`sl-title-${source}-${store}`}>
|
||||||
|
<h3 id={`sl-title-${source}-${store}`}>One quick step</h3>
|
||||||
|
<p className="sl-lead">Tell us where to send updates, then we'll take you straight to {storeName}.</p>
|
||||||
|
<form onSubmit={submit} noValidate>
|
||||||
|
<input type="text" className="contact-honeypot" tabIndex={-1} autoComplete="off" aria-hidden="true" value={honey} onChange={e => setHoney(e.target.value)} />
|
||||||
|
<div className="sl-row">
|
||||||
|
<label>
|
||||||
|
First name
|
||||||
|
<input ref={firstRef} type="text" required autoComplete="given-name" value={fields.firstName} onChange={set('firstName')} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Last name
|
||||||
|
<input type="text" required autoComplete="family-name" value={fields.lastName} onChange={set('lastName')} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input type="email" required autoComplete="email" value={fields.email} onChange={set('email')} />
|
||||||
|
</label>
|
||||||
|
<label className="sl-check">
|
||||||
|
<input type="checkbox" checked={subscribe} onChange={e => setSubscribe(e.target.checked)} />
|
||||||
|
<span>Subscribe me to updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
|
||||||
|
</label>
|
||||||
|
{status === 'error' && <p className="sl-error" role="alert">{error}</p>}
|
||||||
|
<div className="sl-actions">
|
||||||
|
<button type="submit" className="btn-primary" disabled={status === 'sending'}>
|
||||||
|
{status === 'sending' ? 'One moment…' : `Continue to ${storeName}`}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-secondary" onClick={() => setOpen(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// Turns scripture references ("Titus 1:5-9") in answer text into hover/tap verse popups and URLs into links.
|
||||||
|
|
||||||
|
function escapeRegExp(value: string) {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderHighlightedText(text: string, query: string) {
|
||||||
|
const trimmed = query.trim()
|
||||||
|
if (!trimmed) return text
|
||||||
|
|
||||||
|
const regex = new RegExp(`(${escapeRegExp(trimmed)})`, 'ig')
|
||||||
|
const parts = text.split(regex)
|
||||||
|
|
||||||
|
return parts.map((part, index) => {
|
||||||
|
if (part.toLowerCase() === trimmed.toLowerCase()) {
|
||||||
|
return <mark key={`mark-${index}`} className="qa-highlight">{part}</mark>
|
||||||
|
}
|
||||||
|
return <span key={`plain-${index}`}>{part}</span>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTextWithLinks(text: string, highlightQuery = '') {
|
||||||
|
|
||||||
|
const BOOK_ID_MAP: Record<string, string> = {
|
||||||
|
gen: 'GEN', genesis: 'GEN', exo: 'EXO', exodus: 'EXO', lev: 'LEV', leviticus: 'LEV',
|
||||||
|
num: 'NUM', numbers: 'NUM', deut: 'DEU', deuteronomy: 'DEU', josh: 'JOS', joshua: 'JOS',
|
||||||
|
judg: 'JDG', judges: 'JDG', ruth: 'RUT', '1 sam': 'SA1', '2 sam': 'SA2',
|
||||||
|
'1 kgs': 'KI1', '1 kings': 'KI1', '2 kgs': 'KI2', '2 kings': 'KI2',
|
||||||
|
'1 chr': 'CH1', '1 chron': 'CH1', '1 chronicles': 'CH1', '2 chr': 'CH2', '2 chron': 'CH2', '2 chronicles': 'CH2',
|
||||||
|
ezra: 'EZR', neh: 'NEH', nehemiah: 'NEH', esth: 'EST', esther: 'EST',
|
||||||
|
job: 'JOB', ps: 'PSA', psalms: 'PSA', psalm: 'PSA', prov: 'PRO', proverbs: 'PRO',
|
||||||
|
eccl: 'ECC', ecclesiastes: 'ECC', song: 'SNG', 'song of sol': 'SNG', 'song of solomon': 'SNG',
|
||||||
|
isa: 'ISA', isaiah: 'ISA', jer: 'JER', jeremiah: 'JER', lam: 'LAM', lamentations: 'LAM',
|
||||||
|
ezek: 'EZK', ezekiel: 'EZK', dan: 'DAN', daniel: 'DAN', hos: 'HOS', hosea: 'HOS',
|
||||||
|
joel: 'JOL', amos: 'AMO', obad: 'OBA', obadiah: 'OBA', jonah: 'JNA', mic: 'MIC', micah: 'MIC',
|
||||||
|
nah: 'NAH', nahum: 'NAH', hab: 'HAB', habakkuk: 'HAB', zeph: 'ZEP', zephaniah: 'ZEP',
|
||||||
|
hag: 'HAG', haggai: 'HAG', zech: 'ZEC', zechariah: 'ZEC', mal: 'MAL', malachi: 'MAL',
|
||||||
|
matt: 'MAT', matthew: 'MAT', mark: 'MRK', luke: 'LUK', john: 'JHN', acts: 'ACT',
|
||||||
|
rom: 'ROM', romans: 'ROM', '1 cor': 'CO1', '1 corinthians': 'CO1', '2 cor': 'CO2', '2 corinthians': 'CO2',
|
||||||
|
gal: 'GAL', galatians: 'GAL', eph: 'EPH', ephesians: 'EPH', phil: 'PHP', philippians: 'PHP',
|
||||||
|
col: 'COL', colossians: 'COL', '1 thess': 'TH1', '1 thessalonians': 'TH1', '2 thess': 'TH2', '2 thessalonians': 'TH2',
|
||||||
|
'1 tim': 'TI1', '1 timothy': 'TI1', '2 tim': 'TI2', '2 timothy': 'TI2',
|
||||||
|
titus: 'TIT', philem: 'PHM', philemon: 'PHM', heb: 'HEB', hebrews: 'HEB',
|
||||||
|
jas: 'JAM', james: 'JAM', '1 pet': 'PE1', '1 peter': 'PE1', '2 pet': 'PE2', '2 peter': 'PE2',
|
||||||
|
'1 john': 'JO1', '2 john': 'JO2', '3 john': 'JO3', jude: 'JDE', rev: 'REV', revelation: 'REV',
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScriptureRef(refText: string): { bookId: string; chapter: number; verseStart: number; verseEnd: number } | null {
|
||||||
|
const match = refText.match(/^(.*?)\s+(\d+):(\d+)(?:-(\d+))?$/)
|
||||||
|
if (!match) return null
|
||||||
|
const [, bookRaw, chapterStr, verseStartStr, verseEndStr] = match
|
||||||
|
const bookKey = bookRaw.toLowerCase().replace(/\.\s*/g, ' ').trim()
|
||||||
|
const bookId = BOOK_ID_MAP[bookKey]
|
||||||
|
if (!bookId) return null
|
||||||
|
return {
|
||||||
|
bookId,
|
||||||
|
chapter: parseInt(chapterStr, 10),
|
||||||
|
verseStart: parseInt(verseStartStr, 10),
|
||||||
|
verseEnd: verseEndStr ? parseInt(verseEndStr, 10) : parseInt(verseStartStr, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the text by both scripture refs and URLs
|
||||||
|
const combined = /(https?:\/\/[^\s]+)|\b((?:(?:1|2|3)\s)?(?:Gen(?:esis)?|Exo(?:dus)?|Lev(?:iticus)?|Num(?:bers)?|Deut(?:eronomy)?|Josh(?:ua)?|Judg(?:es)?|Ruth|1\s?Sam|2\s?Sam|1\s?Kgs?|2\s?Kgs?|1\s?Chr(?:on)?|2\s?Chr(?:on)?|Ezra|Neh(?:emiah)?|Esth(?:er)?|Job|Ps(?:alms?)?|Prov(?:erbs)?|Eccl(?:esiastes)?|Song(?:\s?of\s?Sol(?:omon)?)?|Isa(?:iah)?|Jer(?:emiah)?|Lam(?:entations)?|Ezek(?:iel)?|Dan(?:iel)?|Hos(?:ea)?|Joel|Amos|Obad(?:iah)?|Jonah|Mic(?:ah)?|Nah(?:um)?|Hab(?:akkuk)?|Zeph(?:aniah)?|Hag(?:gai)?|Zech(?:ariah)?|Mal(?:achi)?|Matt(?:hew)?|Mark|Luke|John|Acts|Rom(?:ans)?|1\s?Cor(?:inthians)?|2\s?Cor(?:inthians)?|Gal(?:atians)?|Eph(?:esians)?|Phil(?:ippians)?|Col(?:ossians)?|1\s?Thess|2\s?Thess|1\s?Tim(?:othy)?|2\s?Tim(?:othy)?|Titus|Philem(?:on)?|Heb(?:rews)?|Jas(?:mes)?|1\s?Pet(?:er)?|2\s?Pet(?:er)?|1\s?John|2\s?John|3\s?John|Jude|Rev(?:elation)?)\.?\s+\d+:\d+(?:-\d+)?)\b/gi
|
||||||
|
|
||||||
|
const parts: string[] = []
|
||||||
|
let lastIndex = 0
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
|
||||||
|
// reset lastIndex for combined
|
||||||
|
combined.lastIndex = 0
|
||||||
|
while ((m = combined.exec(text)) !== null) {
|
||||||
|
if (m.index > lastIndex) parts.push(text.slice(lastIndex, m.index))
|
||||||
|
parts.push(m[0])
|
||||||
|
lastIndex = m.index + m[0].length
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) parts.push(text.slice(lastIndex))
|
||||||
|
|
||||||
|
return parts.map((part, index) => {
|
||||||
|
if (/^https?:\/\//i.test(part)) {
|
||||||
|
const safeHref = part.replace(/[),.;!?]+$/g, '')
|
||||||
|
const trailing = part.slice(safeHref.length)
|
||||||
|
return (
|
||||||
|
<span key={`link-${index}`}>
|
||||||
|
<a href={safeHref} target="_blank" rel="noopener noreferrer">{safeHref}</a>
|
||||||
|
{trailing}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseScriptureRef(part.replace(/\.$/, ''))
|
||||||
|
if (parsed) {
|
||||||
|
return <ScriptureTooltip key={`scripture-${index}`} refText={part} bookId={parsed.bookId} chapter={parsed.chapter} verseStart={parsed.verseStart} verseEnd={parsed.verseEnd} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span key={`text-${index}`}>{renderHighlightedText(part, highlightQuery)}</span>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerseData {
|
||||||
|
verse: number
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps helloao API book IDs → bible.com book codes
|
||||||
|
const BIBLE_COM_BOOK_IDS: Record<string, string> = {
|
||||||
|
GEN: 'GEN', EXO: 'EXO', LEV: 'LEV', NUM: 'NUM', DEU: 'DEU', JOS: 'JOS', JDG: 'JDG', RUT: 'RUT',
|
||||||
|
SA1: '1SA', SA2: '2SA', KI1: '1KI', KI2: '2KI', CH1: '1CH', CH2: '2CH',
|
||||||
|
EZR: 'EZR', NEH: 'NEH', EST: 'EST', JOB: 'JOB', PSA: 'PSA', PRO: 'PRO', ECC: 'ECC', SNG: 'SNG',
|
||||||
|
ISA: 'ISA', JER: 'JER', LAM: 'LAM', EZK: 'EZK', DAN: 'DAN', HOS: 'HOS', JOL: 'JOL', AMO: 'AMO',
|
||||||
|
OBA: 'OBA', JNA: 'JON', MIC: 'MIC', NAH: 'NAH', HAB: 'HAB', ZEP: 'ZEP', HAG: 'HAG', ZEC: 'ZEC', MAL: 'MAL',
|
||||||
|
MAT: 'MAT', MRK: 'MRK', LUK: 'LUK', JHN: 'JHN', ACT: 'ACT', ROM: 'ROM',
|
||||||
|
CO1: '1CO', CO2: '2CO', GAL: 'GAL', EPH: 'EPH', PHP: 'PHP', COL: 'COL',
|
||||||
|
TH1: '1TH', TH2: '2TH', TI1: '1TI', TI2: '2TI', TIT: 'TIT', PHM: 'PHM', HEB: 'HEB',
|
||||||
|
JAM: 'JAS', PE1: '1PE', PE2: '2PE', JO1: '1JN', JO2: '2JN', JO3: '3JN', JDE: 'JUD', REV: 'REV',
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScriptureTooltip({ refText, bookId, chapter, verseStart, verseEnd }: {
|
||||||
|
refText: string
|
||||||
|
bookId: string
|
||||||
|
chapter: number
|
||||||
|
verseStart: number
|
||||||
|
verseEnd: number
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [verses, setVerses] = useState<VerseData[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||||
|
const wrapRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const btnRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
if (verses.length > 0) return
|
||||||
|
setLoading(true)
|
||||||
|
setError(false)
|
||||||
|
fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`)
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) throw new Error('Not found')
|
||||||
|
return r.json() as Promise<{ chapter: { content: Array<{ type: string; number?: number; content?: Array<{ text?: string; poem?: number } | string> }> } }>
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
const content = data?.chapter?.content ?? []
|
||||||
|
const found: VerseData[] = []
|
||||||
|
for (const item of content) {
|
||||||
|
if (item.type === 'verse' && item.number != null && item.number >= verseStart && item.number <= verseEnd) {
|
||||||
|
const text = (item.content ?? [])
|
||||||
|
.map(c => (typeof c === 'string' ? c : (c.text ?? '')))
|
||||||
|
.join('')
|
||||||
|
.trim()
|
||||||
|
if (text) found.push({ verse: item.number, value: text })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVerses(found)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError(true)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [open, bookId, chapter, verseStart, verseEnd, verses.length])
|
||||||
|
|
||||||
|
// Compute fixed position from the trigger button so the popup escapes overflow:hidden parents
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !btnRef.current) { setPos(null); return }
|
||||||
|
const rect = btnRef.current.getBoundingClientRect()
|
||||||
|
const POPUP_W = 360
|
||||||
|
const left = Math.max(8, Math.min(rect.left, window.innerWidth - POPUP_W - 12))
|
||||||
|
setPos({ top: rect.bottom + 8, left })
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
function handleDismiss() { setOpen(false) }
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
window.addEventListener('scroll', handleDismiss, { passive: true, capture: true })
|
||||||
|
window.addEventListener('resize', handleDismiss)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
window.removeEventListener('scroll', handleDismiss, { capture: true })
|
||||||
|
window.removeEventListener('resize', handleDismiss)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={wrapRef} className="scripture-ref-wrap">
|
||||||
|
<button
|
||||||
|
ref={btnRef}
|
||||||
|
type="button"
|
||||||
|
className="scripture-ref"
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
title={`View ${refText}`}
|
||||||
|
>
|
||||||
|
{refText}
|
||||||
|
</button>
|
||||||
|
{open && pos && (
|
||||||
|
<span
|
||||||
|
className="scripture-popup"
|
||||||
|
role="tooltip"
|
||||||
|
style={{ position: 'fixed', top: pos.top, left: pos.left, bottom: 'auto' }}
|
||||||
|
>
|
||||||
|
<span className="scripture-popup-header">
|
||||||
|
<strong>{refText}</strong>
|
||||||
|
<button type="button" className="scripture-popup-close" onClick={() => setOpen(false)} aria-label="Close">✕</button>
|
||||||
|
</span>
|
||||||
|
{loading && <span className="scripture-popup-body">Loading…</span>}
|
||||||
|
{error && <span className="scripture-popup-body scripture-popup-error">Could not load verse.</span>}
|
||||||
|
{!loading && !error && verses.length === 0 && <span className="scripture-popup-body">Verse not found.</span>}
|
||||||
|
{!loading && !error && verses.map(v => (
|
||||||
|
<span key={v.verse} className="scripture-popup-body">
|
||||||
|
{verseStart !== verseEnd && <sup>{v.verse} </sup>}{v.value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="scripture-popup-footer">
|
||||||
|
<a
|
||||||
|
href={`https://www.bible.com/bible/3034/${BIBLE_COM_BOOK_IDS[bookId] ?? bookId}.${chapter}.BSB`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="scripture-popup-link"
|
||||||
|
>
|
||||||
|
Read on Bible.com ↗
|
||||||
|
</a>
|
||||||
|
<span className="scripture-popup-attribution">Berean Standard Bible</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function AnswerText({ text, highlight = '' }: { text: string; highlight?: string }) {
|
||||||
|
return <>{renderTextWithLinks(text, highlight)}</>
|
||||||
|
}
|
||||||
+58
-1
@@ -11,9 +11,14 @@ export interface StudyProgram {
|
|||||||
description: string
|
description: string
|
||||||
bibleBook?: string
|
bibleBook?: string
|
||||||
homepageEyebrow?: string
|
homepageEyebrow?: string
|
||||||
|
// Cover image for the study, used as the backdrop of the featured card on the homepage
|
||||||
|
imageUrl?: string
|
||||||
showOnHomepage?: boolean
|
showOnHomepage?: boolean
|
||||||
showNewTag?: boolean
|
showNewTag?: boolean
|
||||||
newTagLabel?: string
|
newTagLabel?: string
|
||||||
|
// Which companion guide the study offers for download: '' = automatic (the guide of the series or
|
||||||
|
// finished book linked to this study), 'none', 'primary', 'second', or 'book:<finished book id>'
|
||||||
|
guideSource?: string
|
||||||
// 'complete' = finished and self-paced (still open for enrollment). A study whose lessons
|
// 'complete' = finished and self-paced (still open for enrollment). A study whose lessons
|
||||||
// have all been released is treated as complete even if left as 'active'.
|
// have all been released is treated as complete even if left as 'active'.
|
||||||
status: 'active' | 'planned' | 'complete'
|
status: 'active' | 'planned' | 'complete'
|
||||||
@@ -71,6 +76,9 @@ export interface PodcastFeaturedLink {
|
|||||||
// Which series this highlight belongs to: Lane A (primary), Lane B (second), or general.
|
// Which series this highlight belongs to: Lane A (primary), Lane B (second), or general.
|
||||||
// Missing = primary (older highlights were all about the one running series).
|
// Missing = primary (older highlights were all about the one running series).
|
||||||
lane?: 'primary' | 'second' | 'general'
|
lane?: 'primary' | 'second' | 'general'
|
||||||
|
// When true the highlight stays hidden on the public site until its season + episode number
|
||||||
|
// appear in the Spotify feed (the lane's season number is set on the Episode Workflow page).
|
||||||
|
holdUntilReleased?: boolean
|
||||||
title: string
|
title: string
|
||||||
episodeNumber?: string
|
episodeNumber?: string
|
||||||
summary: string
|
summary: string
|
||||||
@@ -277,6 +285,10 @@ export interface SiteContent {
|
|||||||
shareQrImageUrl: string
|
shareQrImageUrl: string
|
||||||
// ── PRISM Video ──
|
// ── PRISM Video ──
|
||||||
prismVideoUrl: string
|
prismVideoUrl: string
|
||||||
|
// Audio narration of the /salvation page (served to the static page via /api/salvation-audio)
|
||||||
|
salvationAudioUrl: string
|
||||||
|
salvationAudioGuiltyUrl: string
|
||||||
|
salvationAudioNotGuiltyUrl: string
|
||||||
prismEyebrow: string
|
prismEyebrow: string
|
||||||
prismHeading: string
|
prismHeading: string
|
||||||
prismIntro: string
|
prismIntro: string
|
||||||
@@ -307,6 +319,7 @@ export interface SiteContent {
|
|||||||
platformCreatorUrl: string
|
platformCreatorUrl: string
|
||||||
platformKofiUrl?: string
|
platformKofiUrl?: string
|
||||||
platformKofiQrUrl?: string
|
platformKofiQrUrl?: string
|
||||||
|
verseOfTheWeek?: string
|
||||||
// ── Welcome Email ──
|
// ── Welcome Email ──
|
||||||
welcomeEmailSubject: string
|
welcomeEmailSubject: string
|
||||||
welcomeEmailGreetingPrefix: string
|
welcomeEmailGreetingPrefix: string
|
||||||
@@ -393,6 +406,8 @@ export interface SiteContent {
|
|||||||
qaAnsweredEmailSignoff: string
|
qaAnsweredEmailSignoff: string
|
||||||
// ── Header ──
|
// ── Header ──
|
||||||
headerFollowLabel: string
|
headerFollowLabel: string
|
||||||
|
// Ask for name and email before sending visitors to Amazon or Apple Books (default on)
|
||||||
|
gateStoreLinks?: boolean
|
||||||
// ── Cookie banner ──
|
// ── Cookie banner ──
|
||||||
cookieBannerText: string
|
cookieBannerText: string
|
||||||
// ── Arrays ──
|
// ── Arrays ──
|
||||||
@@ -517,7 +532,10 @@ export const DEFAULTS: SiteContent = {
|
|||||||
shareHeading: 'Help one more person hear the Word this week.',
|
shareHeading: 'Help one more person hear the Word this week.',
|
||||||
shareP: 'Scan the QR code or text the show link to a friend who needs encouragement today.',
|
shareP: 'Scan the QR code or text the show link to a friend who needs encouragement today.',
|
||||||
shareQrImageUrl: '/images/qr-code.jpg',
|
shareQrImageUrl: '/images/qr-code.jpg',
|
||||||
prismVideoUrl: '/images/PRISM.mp4',
|
prismVideoUrl: 'https://files.versebyversewithnate.us/images/PRISM.mp4',
|
||||||
|
salvationAudioUrl: '',
|
||||||
|
salvationAudioGuiltyUrl: '',
|
||||||
|
salvationAudioNotGuiltyUrl: '',
|
||||||
prismEyebrow: 'Featured Video',
|
prismEyebrow: 'Featured Video',
|
||||||
prismHeading: 'PRISM',
|
prismHeading: 'PRISM',
|
||||||
prismIntro:
|
prismIntro:
|
||||||
@@ -554,6 +572,7 @@ export const DEFAULTS: SiteContent = {
|
|||||||
platformCreatorUrl: 'https://creators.spotify.com/pod/profile/nmemmert/',
|
platformCreatorUrl: 'https://creators.spotify.com/pod/profile/nmemmert/',
|
||||||
platformKofiUrl: 'https://ko-fi.com/versebyversewithnate',
|
platformKofiUrl: 'https://ko-fi.com/versebyversewithnate',
|
||||||
platformKofiQrUrl: '/images/kofi-qr.png',
|
platformKofiQrUrl: '/images/kofi-qr.png',
|
||||||
|
verseOfTheWeek: '',
|
||||||
welcomeEmailSubject: 'Welcome to Verse by Verse with Nate',
|
welcomeEmailSubject: 'Welcome to Verse by Verse with Nate',
|
||||||
welcomeEmailGreetingPrefix: 'Glad you\'re here',
|
welcomeEmailGreetingPrefix: 'Glad you\'re here',
|
||||||
welcomeEmailIntro: 'Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.',
|
welcomeEmailIntro: 'Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.',
|
||||||
@@ -695,3 +714,41 @@ export function getAllHighlights(content: Pick<SiteContent, 'podcastFeaturedLink
|
|||||||
...(content.finishedBooks ?? []).flatMap(book => book.highlights ?? []),
|
...(content.finishedBooks ?? []).flatMap(book => book.highlights ?? []),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StudyGuideLink {
|
||||||
|
// Page id under /downloads/ (primary-guide, second-guide, book-guide--<id>)
|
||||||
|
pageId: string
|
||||||
|
title: string
|
||||||
|
// Direct file link, when the guide has one; otherwise the button opens the guide's page (store links)
|
||||||
|
downloadUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// The companion guide a study offers, either chosen explicitly in admin or found through the
|
||||||
|
// series / finished book that is linked to the study.
|
||||||
|
export function getStudyGuideLink(
|
||||||
|
content: Pick<SiteContent, 'seriesStudySlug' | 'studyGuideTitle' | 'studyGuideUrl' | 'studyGuideDownloadUrl' | 'studyGuideAppleBooksUrl' | 'secondSeries' | 'finishedBooks'>,
|
||||||
|
study: Pick<StudyProgram, 'slug' | 'guideSource'>,
|
||||||
|
): StudyGuideLink | null {
|
||||||
|
const source = study.guideSource ?? ''
|
||||||
|
if (source === 'none') return null
|
||||||
|
|
||||||
|
const primary = (): StudyGuideLink | null =>
|
||||||
|
content.studyGuideTitle && (content.studyGuideUrl || content.studyGuideDownloadUrl || content.studyGuideAppleBooksUrl)
|
||||||
|
? { pageId: 'primary-guide', title: content.studyGuideTitle, downloadUrl: content.studyGuideDownloadUrl || undefined }
|
||||||
|
: null
|
||||||
|
const second = (): StudyGuideLink | null =>
|
||||||
|
content.secondSeries?.guide?.title ? { pageId: 'second-guide', title: content.secondSeries.guide.title, downloadUrl: content.secondSeries.guide.downloadUrl || undefined } : null
|
||||||
|
const book = (id: string): StudyGuideLink | null => {
|
||||||
|
const found = (content.finishedBooks ?? []).find(b => b.id === id)
|
||||||
|
return found?.guide?.title ? { pageId: `book-guide--${found.id}`, title: found.guide.title, downloadUrl: found.guide.downloadUrl || undefined } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source === 'primary') return primary()
|
||||||
|
if (source === 'second') return second()
|
||||||
|
if (source.startsWith('book:')) return book(source.slice('book:'.length))
|
||||||
|
|
||||||
|
if (content.seriesStudySlug && content.seriesStudySlug === study.slug) return primary()
|
||||||
|
if (content.secondSeries?.studySlug && content.secondSeries.studySlug === study.slug) return second()
|
||||||
|
const linkedBook = (content.finishedBooks ?? []).find(b => b.studySlug === study.slug && b.guide?.title)
|
||||||
|
return linkedBook ? book(linkedBook.id) : null
|
||||||
|
}
|
||||||
+373
-217
@@ -2,12 +2,15 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
|
|||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import type { SiteContent, StudyProgram, StudySection } from './content'
|
import type { SiteContent, StudyProgram, StudySection } from './content'
|
||||||
|
import { getStudyGuideLink } from './content'
|
||||||
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './legacyColossiansData'
|
import { DEFAULT_COLOSSIANS_STUDY_SECTIONS } from './legacyColossiansData'
|
||||||
import { usePageMeta } from './hooks/usePageMeta'
|
import { usePageMeta } from './hooks/usePageMeta'
|
||||||
import { Breadcrumbs } from './components/Breadcrumbs'
|
import { Breadcrumbs } from './components/Breadcrumbs'
|
||||||
import { StudyCertificate } from './components/StudyCertificate'
|
import { StudyCertificate } from './components/StudyCertificate'
|
||||||
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
|
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
|
||||||
import { NoteCard } from './components/NoteCard'
|
import { NoteCard } from './components/NoteCard'
|
||||||
|
import { NOTE_ANCHORS, NOTE_ANCHOR_LABELS, newNoteId, parseNoteEntries, serializeNoteEntries, noteHtmlToText } from '../shared/note-format.js'
|
||||||
|
import type { NoteAnchorId, NoteEntry } from '../shared/note-format.js'
|
||||||
import { StudySectionComments } from './components/StudySectionComments'
|
import { StudySectionComments } from './components/StudySectionComments'
|
||||||
|
|
||||||
type Props = { content: SiteContent }
|
type Props = { content: SiteContent }
|
||||||
@@ -239,31 +242,16 @@ function getReleaseDateDisplay(section: StudySection): string {
|
|||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFutureDate(iso: string) {
|
||||||
|
return new Date(iso).getTime() > Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
function getNoteId(studySlug: string, sectionId: string) {
|
function getNoteId(studySlug: string, sectionId: string) {
|
||||||
return `${studySlug}--${sectionId}`
|
return `${studySlug}--${sectionId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NoteAnchorId = 'scripture' | 'commentary' | 'questions' | 'general'
|
const ANCHOR_LABELS: Record<string, string> = NOTE_ANCHOR_LABELS
|
||||||
|
|
||||||
const ANCHOR_LABELS: Record<string, string> = {
|
|
||||||
scripture: 'Scripture Text',
|
|
||||||
commentary: 'Commentary',
|
|
||||||
questions: 'Reflection Questions',
|
|
||||||
general: 'General Notes',
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseNoteMap(raw: string): Record<string, string> {
|
|
||||||
if (!raw?.trim()) return {}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
||||||
return parsed as Record<string, string>
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Not valid JSON — treat the raw string as legacy plain-text notes.
|
|
||||||
}
|
|
||||||
return { general: raw }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisplayName(auth: StudyAuthState) {
|
function getDisplayName(auth: StudyAuthState) {
|
||||||
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
|
return auth.displayName?.trim() || auth.username.split('@')[0] || 'student'
|
||||||
@@ -1019,7 +1007,7 @@ export function StudyLandingPage({ content }: Props) {
|
|||||||
<ul className="study-track-facts">
|
<ul className="study-track-facts">
|
||||||
{variant === 'planned' ? (
|
{variant === 'planned' ? (
|
||||||
<li>
|
<li>
|
||||||
{study.startsAt && new Date(study.startsAt).getTime() > Date.now()
|
{study.startsAt && isFutureDate(study.startsAt)
|
||||||
? `Starts ${new Date(study.startsAt).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}`
|
? `Starts ${new Date(study.startsAt).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}`
|
||||||
: 'Preparing lesson structure and media'}
|
: 'Preparing lesson structure and media'}
|
||||||
</li>
|
</li>
|
||||||
@@ -1268,7 +1256,7 @@ export function StudySignupPage() {
|
|||||||
<p className="eyebrow">Free Student Account</p>
|
<p className="eyebrow">Free Student Account</p>
|
||||||
<h2 className="auth-aside-title">Make every lesson your own.</h2>
|
<h2 className="auth-aside-title">Make every lesson your own.</h2>
|
||||||
<ul className="auth-benefits">
|
<ul className="auth-benefits">
|
||||||
<li><span aria-hidden="true">📝</span><div><strong>Notes on every lesson</strong><p>Up to 500 notes across all tracks, each holding about 2,000 words.</p></div></li>
|
<li><span aria-hidden="true">📝</span><div><strong>Notes on every lesson</strong><p>Up to 3,000 lesson notes across all tracks, each holding tens of thousands of words, with rich formatting.</p></div></li>
|
||||||
<li><span aria-hidden="true">✅</span><div><strong>Track your progress</strong><p>Pick up exactly where you left off, on any device.</p></div></li>
|
<li><span aria-hidden="true">✅</span><div><strong>Track your progress</strong><p>Pick up exactly where you left off, on any device.</p></div></li>
|
||||||
<li><span aria-hidden="true">🔒</span><div><strong>Private to you</strong><p>Your notes are tied to your account. Nobody else can read them.</p></div></li>
|
<li><span aria-hidden="true">🔒</span><div><strong>Private to you</strong><p>Your notes are tied to your account. Nobody else can read them.</p></div></li>
|
||||||
<li><span aria-hidden="true">⚡</span><div><strong>Free, no ads</strong><p>Just an email and password. You stay signed in for 30 days.</p></div></li>
|
<li><span aria-hidden="true">⚡</span><div><strong>Free, no ads</strong><p>Just an email and password. You stay signed in for 30 days.</p></div></li>
|
||||||
@@ -1500,8 +1488,9 @@ export function StudyResetPasswordPage() {
|
|||||||
}, [token, newPassword, confirmPassword])
|
}, [token, newPassword, confirmPassword])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Reset password">
|
<main className="auth-page" aria-label="Reset password">
|
||||||
<div className="thanks-card study-signup-page-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Free Student Account</p>
|
<p className="eyebrow">Free Student Account</p>
|
||||||
<h1>Reset Your Password</h1>
|
<h1>Reset Your Password</h1>
|
||||||
|
|
||||||
@@ -1548,6 +1537,7 @@ export function StudyResetPasswordPage() {
|
|||||||
<Link to="/study/signup" className="btn-secondary">Back to Sign In</Link>
|
<Link to="/study/signup" className="btn-secondary">Back to Sign In</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1573,18 +1563,15 @@ export function StudyVerifyEmailPage() {
|
|||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Verify email">
|
<main className="auth-page" aria-label="Verify email">
|
||||||
<div className="thanks-card study-signup-page-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
|
{status === 'ok' && <span className="cp-icon" aria-hidden="true">✓</span>}
|
||||||
<p className="eyebrow">Free Student Account</p>
|
<p className="eyebrow">Free Student Account</p>
|
||||||
<h1>Verify Your Email</h1>
|
<h1>{status === 'ok' ? 'Email Verified' : status === 'error' ? 'Verification Problem' : 'Verifying Your Email'}</h1>
|
||||||
|
|
||||||
{status === 'checking' && <p style={{ color: '#b09e79' }}>Verifying…</p>}
|
{status === 'checking' && <p>Checking your link…</p>}
|
||||||
{status === 'ok' && (
|
{status === 'ok' && <p className="study-signup-success">Your email address is verified. You’re all set.</p>}
|
||||||
<>
|
|
||||||
<p className="study-signup-success">Your email address is verified.</p>
|
|
||||||
<p style={{ color: '#b09e79', marginBottom: '1.5rem', fontSize: '0.95rem' }}>Thanks for confirming — you're all set.</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === 'error' && <p className="study-signup-error">{message}</p>}
|
{status === 'error' && <p className="study-signup-error">{message}</p>}
|
||||||
|
|
||||||
<div className="study-signup-actions" style={{ marginTop: '1.5rem' }}>
|
<div className="study-signup-actions" style={{ marginTop: '1.5rem' }}>
|
||||||
@@ -1592,6 +1579,7 @@ export function StudyVerifyEmailPage() {
|
|||||||
<Link to="/study" className="btn-secondary">Back to Studies</Link>
|
<Link to="/study" className="btn-secondary">Back to Studies</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1659,13 +1647,17 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!study) {
|
if (!study) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Study not found">
|
<main className="auth-page" aria-label="Study not found">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Study Hub</p>
|
<p className="eyebrow">Study Hub</p>
|
||||||
<h1>Study not found</h1>
|
<h1>Study not found</h1>
|
||||||
<p>The study you requested is not available yet.</p>
|
<p>The study you requested is not available yet.</p>
|
||||||
|
<div className="study-signup-actions">
|
||||||
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1704,105 +1696,91 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const studyGuide = getStudyGuideLink(content, study)
|
||||||
|
|
||||||
|
const pct = sections.length > 0 ? Math.round((completedCount / sections.length) * 100) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="study-index-page" aria-label={`${study.title} study`}>
|
<main className="study-index-page" aria-label={`${study.title} study`}>
|
||||||
<section className="section-study-course-hero">
|
<section className="si-hero">
|
||||||
<div className="section-inner study-course-hero-inner">
|
<div className="section-inner si-hero-inner">
|
||||||
|
<div className="si-hero-main">
|
||||||
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title }]} />
|
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title }]} />
|
||||||
<p className="eyebrow">Online Bible Class</p>
|
<p className="eyebrow">Online Bible Class</p>
|
||||||
<h1>{study.title}</h1>
|
<h1 className="si-title">{study.title}</h1>
|
||||||
<p className="study-course-hero-copy">{study.description}</p>
|
<p className="si-desc">{study.description}</p>
|
||||||
<div className="study-course-meta">
|
<div className="si-chips">
|
||||||
<span>{releasedSections.length} of {sections.length} lessons available</span>
|
<span>{releasedSections.length} of {sections.length} lessons available</span>
|
||||||
<span>Text + commentary + discussion</span>
|
<span>Commentary + discussion</span>
|
||||||
<span>Student notes enabled</span>
|
<span>Personal notes</span>
|
||||||
{isSelfPaced && <span>Finished study — go at your own pace</span>}
|
{isSelfPaced && <span>Self-paced</span>}
|
||||||
</div>
|
</div>
|
||||||
{allLessonsDone && (
|
<div className="si-actions">
|
||||||
<p className="study-completed-banner" role="status">🎉 You've completed this study. Your lessons, notes and certificate are yours to keep.</p>
|
{enrolled && resumeSection && <Link to={`/study/${study.slug}/${resumeSection.id}`} className="btn-primary">{allLessonsDone ? 'Review lessons' : hasStarted ? 'Resume where you left off' : 'Start class'}</Link>}
|
||||||
)}
|
{!auth.authenticated && <Link to="/study/signup" className="btn-primary">Sign in to enroll</Link>}
|
||||||
{enrolled && !progressLoading && sections.length > 0 && (
|
|
||||||
<div className="study-progress" role="group" aria-label="Your progress">
|
|
||||||
<div className="study-progress-label">
|
|
||||||
<span>Your progress</span>
|
|
||||||
<strong>{completedCount} of {sections.length} lessons completed</strong>
|
|
||||||
</div>
|
|
||||||
<div className="study-progress-track" role="progressbar" aria-valuemin={0} aria-valuemax={sections.length} aria-valuenow={completedCount}>
|
|
||||||
<div className="study-progress-fill" style={{ width: `${Math.round((completedCount / sections.length) * 100)}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="study-course-hero-actions">
|
|
||||||
{enrolled && resumeSection && <Link to={`/study/${study.slug}/${resumeSection.id}`} className="btn-primary">{allLessonsDone ? 'Review Lessons' : hasStarted ? 'Resume Where You Left Off' : 'Start Class'}</Link>}
|
|
||||||
{!auth.authenticated && <Link to="/study/signup" className="btn-primary">Sign In to Enroll</Link>}
|
|
||||||
{auth.authenticated && !enrolled && (
|
{auth.authenticated && !enrolled && (
|
||||||
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
||||||
{enrolling ? 'Enrolling...' : 'Enroll in This Study'}
|
{enrolling ? 'Enrolling…' : 'Enroll in this study'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{allLessonsDone && <Link to="/study/account" className="btn-secondary">🏅 My Certificate</Link>}
|
{enrolled && <Link to={`/study/${study.slug}/notes`} className="btn-secondary">My notes</Link>}
|
||||||
{enrolled && <Link to={`/study/${study.slug}/notes`} className="btn-secondary">My Notes</Link>}
|
|
||||||
{enrolled && studyStatus !== 'planned' && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}
|
{enrolled && studyStatus !== 'planned' && <Link to={`/study/${study.slug}/community`} className="btn-secondary">Community</Link>}
|
||||||
{auth.authenticated && <Link to="/study/account" className="btn-secondary">My Account</Link>}
|
{studyGuide && (studyGuide.downloadUrl
|
||||||
|
? <a href={studyGuide.downloadUrl} target="_blank" rel="noopener noreferrer" download className="btn-secondary">Study guide</a>
|
||||||
|
: <Link to={`/downloads/${studyGuide.pageId}`} className="btn-secondary">Study guide</Link>)}
|
||||||
|
{allLessonsDone && <Link to="/study/account" className="btn-secondary">My certificate</Link>}
|
||||||
</div>
|
</div>
|
||||||
{enrollMessage && <p className="study-note-status" style={{ marginTop: '0.75rem' }}>{enrollMessage}</p>}
|
{enrollMessage && <p className="study-note-status" role="status">{enrollMessage}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enrolled && !progressLoading && sections.length > 0 && (
|
||||||
|
<aside className="si-progress" aria-label="Your progress">
|
||||||
|
<div className="si-ring" style={{ ['--pct' as string]: `${pct}%` }}>
|
||||||
|
<span>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="si-progress-big">{completedCount} of {sections.length}</p>
|
||||||
|
<p className="si-progress-sub">{allLessonsDone ? 'Study complete 🎉' : 'lessons completed'}</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{auth.checked && auth.authenticated && !enrolled && (
|
{auth.checked && auth.authenticated && !enrolled && (
|
||||||
<section className="section-study-module" aria-label="Enrollment required">
|
<section className="section-inner si-notice" aria-label="Enrollment required">
|
||||||
<div className="section-inner">
|
<strong>Enroll to open the lessons.</strong>
|
||||||
<article className="study-module-row study-module-row--disabled" aria-disabled="true">
|
<span>Enrollment is free and lets you save notes and track progress.</span>
|
||||||
<div className="study-module-row-left">
|
|
||||||
<p className="study-module-lesson">Enrollment Required</p>
|
|
||||||
<h3>Enroll before entering lessons</h3>
|
|
||||||
<p>You must enroll in this study before opening lessons or notes.</p>
|
|
||||||
<div style={{ marginTop: '0.75rem' }}>
|
|
||||||
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
||||||
{enrolling ? 'Enrolling...' : `Enroll in ${study.title}`}
|
{enrolling ? 'Enrolling…' : `Enroll in ${study.title}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sections.length === 0 && (
|
{sections.length === 0 && (
|
||||||
<section className="section-study-module">
|
<section className="section-inner si-notice">
|
||||||
<div className="section-inner">
|
<strong>Lessons are being prepared.</strong>
|
||||||
<article className="study-module-row study-module-row--disabled" aria-disabled="true">
|
<span>Check back soon.</span>
|
||||||
<div className="study-module-row-left">
|
|
||||||
<p className="study-module-lesson">Planned</p>
|
|
||||||
<h3>Lessons are being prepared</h3>
|
|
||||||
<p>Use the admin Studies editor to add section lessons and publish when ready.</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sections.length > 0 && (
|
{sections.length > 0 && (
|
||||||
<section className="section-study-module" aria-label="Course lessons">
|
<section className="si-lessons" aria-label="Course lessons">
|
||||||
<div className="section-inner">
|
<div className="section-inner">
|
||||||
<div className="study-module-header">
|
|
||||||
<p>Course Lessons</p>
|
|
||||||
<h2>{study.title}</h2>
|
|
||||||
</div>
|
|
||||||
{chaptersWithLessons.map(({ chapter }) => {
|
{chaptersWithLessons.map(({ chapter }) => {
|
||||||
const chapterSections = sections.filter(item => item.chapter === chapter)
|
const chapterSections = sections.filter(item => item.chapter === chapter)
|
||||||
const chapterDone = chapterSections.filter(item => studyProgress.completedSectionIds.includes(item.id)).length
|
const chapterDone = chapterSections.filter(item => studyProgress.completedSectionIds.includes(item.id)).length
|
||||||
return (
|
return (
|
||||||
<details className="study-chapter" key={chapter} open={chaptersWithLessons.length === 1 || chapter === resumeChapter}>
|
<details className="si-chapter" key={chapter} open={chaptersWithLessons.length === 1 || chapter === resumeChapter}>
|
||||||
<summary className="study-chapter-head">
|
<summary className="si-chapter-head">
|
||||||
<h3>Chapter {chapter}</h3>
|
<h2>Chapter {chapter}</h2>
|
||||||
<span>
|
<span>
|
||||||
{enrolled && !progressLoading
|
{enrolled && !progressLoading
|
||||||
? `${chapterDone} of ${chapterSections.length} completed`
|
? `${chapterDone} of ${chapterSections.length} completed`
|
||||||
: `${chapterSections.length} ${chapterSections.length === 1 ? 'lesson' : 'lessons'}`}
|
: `${chapterSections.length} ${chapterSections.length === 1 ? 'lesson' : 'lessons'}`}
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="study-module-list">
|
<div className="si-list">
|
||||||
{chapterSections.map(section => {
|
{chapterSections.map(section => {
|
||||||
const sectionIdx = sections.indexOf(section)
|
const sectionIdx = sections.indexOf(section)
|
||||||
const lessonNumber = sectionIdx + 1
|
const lessonNumber = sectionIdx + 1
|
||||||
@@ -1813,34 +1791,30 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
|||||||
const completed = studyProgress.completedSectionIds.includes(section.id)
|
const completed = studyProgress.completedSectionIds.includes(section.id)
|
||||||
const lockedByProgress = enrolled && !isSelfPaced && !coming && sectionIdx > 0 && !prevCompleted && !completed
|
const lockedByProgress = enrolled && !isSelfPaced && !coming && sectionIdx > 0 && !prevCompleted && !completed
|
||||||
const isDisabled = coming || !enrolled || lockedByProgress
|
const isDisabled = coming || !enrolled || lockedByProgress
|
||||||
|
const done = completed && enrolled
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link key={section.id} to={isDisabled ? '#' : `/study/${study.slug}/${section.id}`} className={`study-module-row${isDisabled ? ' study-module-row--disabled' : ''}${coming ? ' study-module-row--coming' : ''}${completed && enrolled ? ' study-module-row--done' : ''}`} aria-disabled={isDisabled || undefined} onClick={e => isDisabled && e.preventDefault()}>
|
<Link
|
||||||
<div className="study-module-row-left">
|
key={section.id}
|
||||||
<div className="study-lesson-meta">
|
to={isDisabled ? '#' : `/study/${study.slug}/${section.id}`}
|
||||||
<p className="study-module-lesson">Lesson {lessonNumber}</p>
|
className={`si-row${isDisabled ? ' si-row--disabled' : ''}${done ? ' si-row--done' : ''}`}
|
||||||
|
aria-disabled={isDisabled || undefined}
|
||||||
|
onClick={e => isDisabled && e.preventDefault()}
|
||||||
|
>
|
||||||
|
<span className="si-num" aria-hidden="true">{done ? '✓' : lessonNumber}</span>
|
||||||
|
<div className="si-row-body">
|
||||||
|
<div className="si-row-top">
|
||||||
|
<h3>{section.title}</h3>
|
||||||
{isNew && <span className="lesson-badge lesson-badge--new">New</span>}
|
{isNew && <span className="lesson-badge lesson-badge--new">New</span>}
|
||||||
{coming && <span className="lesson-badge lesson-badge--coming">Coming {getReleaseDateDisplay(section)}</span>}
|
{coming && <span className="lesson-badge lesson-badge--coming">Coming {getReleaseDateDisplay(section)}</span>}
|
||||||
{!coming && !enrolled && <span className="lesson-badge lesson-badge--enroll">Enroll to open</span>}
|
{!coming && !enrolled && <span className="lesson-badge lesson-badge--enroll">Enroll to open</span>}
|
||||||
{lockedByProgress && <span className="lesson-badge lesson-badge--locked">Finish the previous lesson first</span>}
|
{lockedByProgress && <span className="lesson-badge lesson-badge--locked">Finish the previous lesson first</span>}
|
||||||
{enrolled && !lockedByProgress && completed && (
|
|
||||||
<span className="lesson-badge lesson-badge--done">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true">
|
|
||||||
<circle cx="6.5" cy="6.5" r="6" stroke="currentColor" strokeWidth="1.2" />
|
|
||||||
<path d="M3.5 6.5l2 2 3.5-3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
Completed
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<h3>{section.title}</h3>
|
{section.reference && <p className="si-ref">{section.reference}</p>}
|
||||||
<p>{section.summary}</p>
|
{section.summary && <p className="si-summary">{section.summary}</p>}
|
||||||
</div>
|
<p className="si-focus"><span>Focus:</span> {getPrimaryQuestion(section)}</p>
|
||||||
<div className="study-module-row-right">
|
|
||||||
<p className="study-section-reference">{section.reference}</p>
|
|
||||||
<p className="study-module-focus">Focus question</p>
|
|
||||||
<p>{getPrimaryQuestion(section)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{!isDisabled && <span className="si-arrow" aria-hidden="true">→</span>}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -1875,8 +1849,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
const [enrollBusy, setEnrollBusy] = useState(false)
|
const [enrollBusy, setEnrollBusy] = useState(false)
|
||||||
const [enrollMessage, setEnrollMessage] = useState('')
|
const [enrollMessage, setEnrollMessage] = useState('')
|
||||||
|
|
||||||
const [noteMap, setNoteMap] = useState<Record<string, string>>({})
|
const [noteEntries, setNoteEntries] = useState<NoteEntry[]>([])
|
||||||
const [openAnchor, setOpenAnchor] = useState<NoteAnchorId | null>(null)
|
const [activeNoteId, setActiveNoteId] = useState<string | null>(null)
|
||||||
const [noteLoading, setNoteLoading] = useState(false)
|
const [noteLoading, setNoteLoading] = useState(false)
|
||||||
const [noteMessage, setNoteMessage] = useState('')
|
const [noteMessage, setNoteMessage] = useState('')
|
||||||
const noteMapDirtyRef = useRef(false)
|
const noteMapDirtyRef = useRef(false)
|
||||||
@@ -1895,12 +1869,13 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
const [bsbPassage, setBsbPassage] = useState<string | null>(null)
|
const [bsbPassage, setBsbPassage] = useState<string | null>(null)
|
||||||
const [bsbPassageLoading, setBsbPassageLoading] = useState(false)
|
const [bsbPassageLoading, setBsbPassageLoading] = useState(false)
|
||||||
|
|
||||||
const savedNoteCount = Object.values(noteMap).filter(v => v?.trim()).length
|
const activeEntry = noteEntries.find(e => e.id === activeNoteId) ?? null
|
||||||
const lessonAudioEmbedUrl = useMemo(() => {
|
const openAnchor: NoteAnchorId | null = activeEntry?.anchor ?? null
|
||||||
const trimmed = section?.audioEmbedUrl?.trim() ?? ''
|
const savedEntries = noteEntries.filter(e => e.html.trim())
|
||||||
if (!trimmed) return ''
|
const savedNoteCount = savedEntries.length
|
||||||
return /^https?:\/\//i.test(trimmed) ? trimmed : ''
|
const hasNoteOn = (anchor: NoteAnchorId) => savedEntries.some(e => e.anchor === anchor)
|
||||||
}, [section?.audioEmbedUrl])
|
const audioEmbedTrimmed = section?.audioEmbedUrl?.trim() ?? ''
|
||||||
|
const lessonAudioEmbedUrl = /^https?:\/\//i.test(audioEmbedTrimmed) ? audioEmbedTrimmed : ''
|
||||||
|
|
||||||
const currentStudySlug = study?.slug ?? 'colossians'
|
const currentStudySlug = study?.slug ?? 'colossians'
|
||||||
const noteId = section?.id ? getNoteId(currentStudySlug, section.id) : ''
|
const noteId = section?.id ? getNoteId(currentStudySlug, section.id) : ''
|
||||||
@@ -1981,7 +1956,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth.authenticated || !noteId || !isEnrolled) {
|
if (!auth.authenticated || !noteId || !isEnrolled) {
|
||||||
setNoteMap({})
|
setNoteEntries([])
|
||||||
|
setActiveNoteId(null)
|
||||||
noteMapDirtyRef.current = false
|
noteMapDirtyRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1993,7 +1969,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
readJson<{ note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`)
|
readJson<{ note: string }>(`/api/study-notes/${encodeURIComponent(noteId)}`)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setNoteMap(parseNoteMap(data.note ?? ''))
|
setNoteEntries(parseNoteEntries(data.note ?? ''))
|
||||||
|
setActiveNoteId(null)
|
||||||
noteMapDirtyRef.current = false
|
noteMapDirtyRef.current = false
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
@@ -2037,16 +2014,39 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateNote(anchor: NoteAnchorId, html: string) {
|
function updateNoteEntry(id: string, html: string) {
|
||||||
noteMapDirtyRef.current = true
|
noteMapDirtyRef.current = true
|
||||||
setNoteMap(prev => ({ ...prev, [anchor]: html }))
|
setNoteEntries(prev => prev.map(e => e.id === id ? { ...e, html, updatedAt: new Date().toISOString() } : e))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNoteEntry(anchor: NoteAnchorId, html = '') {
|
||||||
|
const entry: NoteEntry = { id: newNoteId(), anchor, html, updatedAt: html ? new Date().toISOString() : null }
|
||||||
|
if (html) noteMapDirtyRef.current = true
|
||||||
|
setNoteEntries(prev => [...prev, entry])
|
||||||
|
setActiveNoteId(entry.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNoteEntry(id: string) {
|
||||||
|
noteMapDirtyRef.current = true
|
||||||
|
setNoteEntries(prev => prev.filter(e => e.id !== id))
|
||||||
|
setActiveNoteId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAnchorNote(anchor: NoteAnchorId) {
|
||||||
|
if (openAnchor === anchor) {
|
||||||
|
setActiveNoteId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const existing = [...noteEntries].reverse().find(e => e.anchor === anchor)
|
||||||
|
if (existing) setActiveNoteId(existing.id)
|
||||||
|
else addNoteEntry(anchor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openAnchor) return
|
if (!openAnchor) return
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') setOpenAnchor(null)
|
if (e.key === 'Escape') setActiveNoteId(null)
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKeyDown)
|
document.addEventListener('keydown', onKeyDown)
|
||||||
return () => document.removeEventListener('keydown', onKeyDown)
|
return () => document.removeEventListener('keydown', onKeyDown)
|
||||||
@@ -2062,16 +2062,18 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
await readJson<{ ok: boolean }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
|
await readJson<{ ok: boolean }>(`/api/study-notes/${encodeURIComponent(noteId)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ note: JSON.stringify(noteMap) }),
|
body: JSON.stringify({ note: serializeNoteEntries(noteEntries) }),
|
||||||
})
|
})
|
||||||
|
setNoteMessage('')
|
||||||
setAutoSaveMsg('Auto-saved.')
|
setAutoSaveMsg('Auto-saved.')
|
||||||
setTimeout(() => setAutoSaveMsg(''), 3000)
|
setTimeout(() => setAutoSaveMsg(''), 3000)
|
||||||
} catch {
|
} catch (err) {
|
||||||
noteMapDirtyRef.current = true
|
noteMapDirtyRef.current = true
|
||||||
|
setNoteMessage(err instanceof Error ? err.message : 'Unable to save your note.')
|
||||||
}
|
}
|
||||||
}, 2000)
|
}, 2000)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [noteMap, noteId, auth.authenticated, noteLoading])
|
}, [noteEntries, noteId, auth.authenticated, noteLoading])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth.authenticated || !isEnrolled || !study || !section?.id) {
|
if (!auth.authenticated || !isEnrolled || !study || !section?.id) {
|
||||||
@@ -2122,12 +2124,14 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
|
|
||||||
function appendSelectedTextToNotes(selectedText: string, anchor: NoteAnchorId = 'general') {
|
function appendSelectedTextToNotes(selectedText: string, anchor: NoteAnchorId = 'general') {
|
||||||
if (!selectedText.trim()) return
|
if (!selectedText.trim()) return
|
||||||
const existing = noteMap[anchor] ?? ''
|
const safe = selectedText.trim().replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
const appended = existing
|
const latest = [...noteEntries].reverse().find(e => e.anchor === anchor)
|
||||||
? `${existing}<p>${selectedText.trim()}</p>`
|
if (latest) {
|
||||||
: `<p>${selectedText.trim()}</p>`
|
updateNoteEntry(latest.id, `${latest.html}<p>${safe}</p>`)
|
||||||
updateNote(anchor, appended)
|
setActiveNoteId(latest.id)
|
||||||
setOpenAnchor(anchor)
|
} else {
|
||||||
|
addNoteEntry(anchor, `<p>${safe}</p>`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLessonContentContextMenu(e: React.MouseEvent<HTMLDivElement>) {
|
function handleLessonContentContextMenu(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
@@ -2202,12 +2206,14 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!study || !section) {
|
if (!study || !section) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Section not found">
|
<main className="auth-page" aria-label="Section not found">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Study Hub</p>
|
<p className="eyebrow">Study Hub</p>
|
||||||
<h1>Section not found</h1>
|
<h1>Section not found</h1>
|
||||||
<p>The section you requested is not available yet.</p>
|
<p>The section you requested is not available yet.</p>
|
||||||
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
<div className="study-signup-actions"><Link to="/study" className="btn-primary">Back to Studies</Link></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -2216,12 +2222,14 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
if (!sectionIsReleased) {
|
if (!sectionIsReleased) {
|
||||||
const releaseDateDisplay = getReleaseDateDisplay(section)
|
const releaseDateDisplay = getReleaseDateDisplay(section)
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Lesson not yet available">
|
<main className="auth-page" aria-label="Lesson not yet available">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">{study.title}</p>
|
<p className="eyebrow">{study.title}</p>
|
||||||
<h1>Lesson Coming Soon</h1>
|
<h1>Lesson Coming Soon</h1>
|
||||||
<p>{releaseDateDisplay ? `This lesson will be available on ${releaseDateDisplay}.` : 'This lesson will be available soon.'}</p>
|
<p>{releaseDateDisplay ? `This lesson will be available on ${releaseDateDisplay}.` : 'This lesson will be available soon.'}</p>
|
||||||
<Link to={`/study/${study.slug}`} className="btn-primary">Back to Lessons</Link>
|
<div className="study-signup-actions"><Link to={`/study/${study.slug}`} className="btn-primary">Back to Lessons</Link></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -2229,11 +2237,13 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!auth.checked) {
|
if (!auth.checked) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Loading">
|
<main className="auth-page" aria-label="Loading">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">{study.title}</p>
|
<p className="eyebrow">{study.title}</p>
|
||||||
<p>Loading lesson...</p>
|
<p>Loading lesson...</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2314,17 +2324,24 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
<section className="section-study-classroom" onContextMenu={handleLessonContentContextMenu}>
|
<section className="section-study-classroom" onContextMenu={handleLessonContentContextMenu}>
|
||||||
<div className="section-inner study-classroom-shell">
|
<div className="section-inner study-classroom-shell">
|
||||||
<div className="study-classroom-main">
|
<div className="study-classroom-main">
|
||||||
|
<header className="sl-head">
|
||||||
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: `Lesson ${lessonNumber}` }]} />
|
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Studies', href: '/study' }, { label: study.title, href: `/study/${study.slug}` }, { label: `Lesson ${lessonNumber}` }]} />
|
||||||
<Link to={`/study/${study.slug}`} className="study-detail-back">Back to {study.title}</Link>
|
<div className="sl-head-meta">
|
||||||
<p className="study-lesson-label">Lesson {lessonNumber} of {sections.length}</p>
|
<span className="sl-lesson-pill">Lesson {lessonNumber} of {sections.length}</span>
|
||||||
|
{section.reference && <span className="sl-ref">{section.reference}</span>}
|
||||||
|
{lessonCompleted && <span className="sl-done">✓ Completed</span>}
|
||||||
|
</div>
|
||||||
|
<h1 className="sl-title">{section.title}</h1>
|
||||||
|
{section.summary && <p className="sl-summary">{section.summary}</p>}
|
||||||
{sections.length > 0 && (
|
{sections.length > 0 && (
|
||||||
<div className="study-lesson-progress-bar" role="progressbar" aria-valuenow={completedSectionIds.length} aria-valuemax={sections.length} aria-label={`${completedSectionIds.length} of ${sections.length} lessons completed`}>
|
<div className="sl-progress" aria-label={`${completedSectionIds.length} of ${sections.length} lessons completed`}>
|
||||||
|
<div className="study-lesson-progress-bar" role="progressbar" aria-valuenow={completedSectionIds.length} aria-valuemax={sections.length}>
|
||||||
<div className="study-lesson-progress-fill" style={{ width: `${Math.round((completedSectionIds.length / sections.length) * 100)}%` }} />
|
<div className="study-lesson-progress-fill" style={{ width: `${Math.round((completedSectionIds.length / sections.length) * 100)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
<span>{completedSectionIds.length} of {sections.length} lessons completed in {study.title}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<h1>{section.title}</h1>
|
</header>
|
||||||
<p className="study-detail-reference">{section.reference}</p>
|
|
||||||
<p className="study-detail-summary">{section.summary}</p>
|
|
||||||
|
|
||||||
{lessonAudioEmbedUrl && (
|
{lessonAudioEmbedUrl && (
|
||||||
<article className="study-class-block">
|
<article className="study-class-block">
|
||||||
@@ -2351,10 +2368,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
{isEnrolled && (
|
{isEnrolled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`note-anchor-btn${noteMap['scripture']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'scripture' ? ' note-anchor-btn--open' : ''}`}
|
className={`note-anchor-btn${hasNoteOn('scripture') ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'scripture' ? ' note-anchor-btn--open' : ''}`}
|
||||||
onClick={() => setOpenAnchor(curr => curr === 'scripture' ? null : 'scripture')}
|
onClick={() => toggleAnchorNote('scripture')}
|
||||||
aria-pressed={openAnchor === 'scripture'}
|
aria-pressed={openAnchor === 'scripture'}
|
||||||
>{noteMap['scripture']?.trim() ? '✎ Note' : '+ Note'}</button>
|
>{hasNoteOn('scripture') ? '✎ Note' : '+ Note'}</button>
|
||||||
)}
|
)}
|
||||||
<h2>Scripture Text</h2>
|
<h2>Scripture Text</h2>
|
||||||
{bsbPassageLoading
|
{bsbPassageLoading
|
||||||
@@ -2374,10 +2391,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
{isEnrolled && (
|
{isEnrolled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`note-anchor-btn${noteMap['commentary']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'commentary' ? ' note-anchor-btn--open' : ''}`}
|
className={`note-anchor-btn${hasNoteOn('commentary') ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'commentary' ? ' note-anchor-btn--open' : ''}`}
|
||||||
onClick={() => setOpenAnchor(curr => curr === 'commentary' ? null : 'commentary')}
|
onClick={() => toggleAnchorNote('commentary')}
|
||||||
aria-pressed={openAnchor === 'commentary'}
|
aria-pressed={openAnchor === 'commentary'}
|
||||||
>{noteMap['commentary']?.trim() ? '✎ Note' : '+ Note'}</button>
|
>{hasNoteOn('commentary') ? '✎ Note' : '+ Note'}</button>
|
||||||
)}
|
)}
|
||||||
<h2>Instructor Commentary</h2>
|
<h2>Instructor Commentary</h2>
|
||||||
{section.commentary.split(/\n{2,}/).map((para, i) => (
|
{section.commentary.split(/\n{2,}/).map((para, i) => (
|
||||||
@@ -2393,10 +2410,10 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
{isEnrolled && (
|
{isEnrolled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`note-anchor-btn${noteMap['questions']?.trim() ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'questions' ? ' note-anchor-btn--open' : ''}`}
|
className={`note-anchor-btn${hasNoteOn('questions') ? ' note-anchor-btn--has-note' : ''}${openAnchor === 'questions' ? ' note-anchor-btn--open' : ''}`}
|
||||||
onClick={() => setOpenAnchor(curr => curr === 'questions' ? null : 'questions')}
|
onClick={() => toggleAnchorNote('questions')}
|
||||||
aria-pressed={openAnchor === 'questions'}
|
aria-pressed={openAnchor === 'questions'}
|
||||||
>{noteMap['questions']?.trim() ? '✎ Note' : '+ Note'}</button>
|
>{hasNoteOn('questions') ? '✎ Note' : '+ Note'}</button>
|
||||||
)}
|
)}
|
||||||
<h2>Reflection Questions</h2>
|
<h2>Reflection Questions</h2>
|
||||||
<ol className="study-detail-list study-detail-list--questions">
|
<ol className="study-detail-list study-detail-list--questions">
|
||||||
@@ -2538,6 +2555,31 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
<StudySectionComments studySlug={currentStudySlug} sectionId={section.id} isEnrolled={isEnrolled} />
|
<StudySectionComments studySlug={currentStudySlug} sectionId={section.id} isEnrolled={isEnrolled} />
|
||||||
</article>}
|
</article>}
|
||||||
|
|
||||||
|
<nav className="sl-pager" aria-label="Lesson navigation">
|
||||||
|
{previousSection ? (
|
||||||
|
<Link to={`/study/${study.slug}/${previousSection.id}`} className="sl-pager-link">
|
||||||
|
<span>← Previous lesson</span>
|
||||||
|
<strong>{previousSection.title}</strong>
|
||||||
|
</Link>
|
||||||
|
) : <span />}
|
||||||
|
{nextSection && isSectionReleased(nextSection) && lessonCompleted ? (
|
||||||
|
<Link to={`/study/${study.slug}/${nextSection.id}`} className="sl-pager-link sl-pager-link--next">
|
||||||
|
<span>Next lesson →</span>
|
||||||
|
<strong>{nextSection.title}</strong>
|
||||||
|
</Link>
|
||||||
|
) : nextSection ? (
|
||||||
|
<div className="sl-pager-link sl-pager-link--next sl-pager-link--wait">
|
||||||
|
<span>Next lesson</span>
|
||||||
|
<strong>{!isSectionReleased(nextSection) ? `Available ${getReleaseDateDisplay(nextSection)}` : 'Complete this lesson to continue'}</strong>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link to={`/study/${study.slug}`} className="sl-pager-link sl-pager-link--next">
|
||||||
|
<span>Finished</span>
|
||||||
|
<strong>Back to all lessons →</strong>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
|
<aside className="study-classroom-sidebar" aria-label="Lesson tools">
|
||||||
@@ -2593,21 +2635,53 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
<>
|
<>
|
||||||
{noteLoading ? (
|
{noteLoading ? (
|
||||||
<p style={{ color: '#7a7060', fontSize: '0.85rem' }}>Loading notes…</p>
|
<p style={{ color: '#7a7060', fontSize: '0.85rem' }}>Loading notes…</p>
|
||||||
) : openAnchor ? (
|
|
||||||
<NoteCard
|
|
||||||
anchorLabel={ANCHOR_LABELS[openAnchor] ?? 'Notes'}
|
|
||||||
html={noteMap[openAnchor] ?? ''}
|
|
||||||
autoSaveMsg={autoSaveMsg}
|
|
||||||
onChange={html => updateNote(openAnchor as NoteAnchorId, html)}
|
|
||||||
onClose={() => setOpenAnchor(null)}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{savedEntries.length > 0 && (
|
||||||
|
<ul className="note-entry-list" aria-label="Notes on this lesson">
|
||||||
|
{savedEntries.map(entry => (
|
||||||
|
<li key={entry.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`note-entry${entry.id === activeNoteId ? ' note-entry--active' : ''}`}
|
||||||
|
onClick={() => setActiveNoteId(entry.id === activeNoteId ? null : entry.id)}
|
||||||
|
>
|
||||||
|
<span className="note-entry-label">{ANCHOR_LABELS[entry.anchor]}{entry.updatedAt ? ` · ${formatRelativeTime(entry.updatedAt)}` : ''}</span>
|
||||||
|
<span className="note-entry-snippet">{noteHtmlToText(entry.html).slice(0, 70)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{activeEntry && (
|
||||||
|
<NoteCard
|
||||||
|
key={activeEntry.id}
|
||||||
|
anchorLabel={ANCHOR_LABELS[activeEntry.anchor] ?? 'Notes'}
|
||||||
|
html={activeEntry.html}
|
||||||
|
updatedAt={activeEntry.updatedAt}
|
||||||
|
autoSaveMsg={autoSaveMsg}
|
||||||
|
onChange={html => updateNoteEntry(activeEntry.id, html)}
|
||||||
|
onDelete={() => deleteNoteEntry(activeEntry.id)}
|
||||||
|
onClose={() => setActiveNoteId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isEnrolled && (
|
||||||
|
<div className="note-add-row">
|
||||||
|
<span>Add a note on:</span>
|
||||||
|
{NOTE_ANCHORS.map(anchor => (
|
||||||
|
<button key={anchor} type="button" className="note-add-chip" onClick={() => addNoteEntry(anchor)}>
|
||||||
|
+ {ANCHOR_LABELS[anchor]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!activeEntry && savedEntries.length === 0 && (
|
||||||
<p style={{ fontSize: '0.85rem', color: '#5a5040', lineHeight: 1.55 }}>
|
<p style={{ fontSize: '0.85rem', color: '#5a5040', lineHeight: 1.55 }}>
|
||||||
{savedNoteCount > 0
|
Click the Note button on Scripture, Commentary, or Reflection Questions, or use the buttons above, to write notes here.
|
||||||
? `${savedNoteCount} note${savedNoteCount !== 1 ? 's' : ''} saved. Click a section's Note button to edit.`
|
|
||||||
: 'Click the Note button on Scripture, Commentary, or Reflection Questions to write notes here.'}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{autoSaveMsg && <p style={{ fontSize: '0.82rem', color: '#7a9c6a', marginTop: '0.35rem' }}>{autoSaveMsg}</p>}
|
{autoSaveMsg && <p style={{ fontSize: '0.82rem', color: '#7a9c6a', marginTop: '0.35rem' }}>{autoSaveMsg}</p>}
|
||||||
{noteMessage && <p className="study-note-status" style={{ marginTop: '0.35rem' }}>{noteMessage}</p>}
|
{noteMessage && <p className="study-note-status" style={{ marginTop: '0.35rem' }}>{noteMessage}</p>}
|
||||||
<Link to="/study/account" style={{ display: 'block', fontSize: '0.82rem', color: '#7a7060', marginTop: '0.75rem' }}>My Account →</Link>
|
<Link to="/study/account" style={{ display: 'block', fontSize: '0.82rem', color: '#7a7060', marginTop: '0.75rem' }}>My Account →</Link>
|
||||||
@@ -2702,6 +2776,7 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
|||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [activeNoteTab, setActiveNoteTab] = useState('')
|
const [activeNoteTab, setActiveNoteTab] = useState('')
|
||||||
const [noteQuery, setNoteQuery] = useState('')
|
const [noteQuery, setNoteQuery] = useState('')
|
||||||
|
const [noteSort, setNoteSort] = useState<'lesson' | 'recent'>('lesson')
|
||||||
|
|
||||||
usePageMeta(
|
usePageMeta(
|
||||||
study ? `My Notes | ${study.title}` : 'My Study Notes',
|
study ? `My Notes | ${study.title}` : 'My Study Notes',
|
||||||
@@ -2754,12 +2829,14 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!study) {
|
if (!study) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Study not found">
|
<main className="auth-page" aria-label="Study not found">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Study Hub</p>
|
<p className="eyebrow">Study Hub</p>
|
||||||
<h1>Study not found</h1>
|
<h1>Study not found</h1>
|
||||||
<p>The study you requested is not available yet.</p>
|
<p>The study you requested is not available yet.</p>
|
||||||
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
<div className="study-signup-actions"><Link to="/study" className="btn-primary">Back to Studies</Link></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -2781,8 +2858,10 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
const stripHtml = (html: string) => DOMPurify.sanitize(html, { ALLOWED_TAGS: [] }).replace(/\s+/g, ' ').trim()
|
const stripHtml = (html: string) => DOMPurify.sanitize(html, { ALLOWED_TAGS: [] }).replace(/\s+/g, ' ').trim()
|
||||||
const noteText = (raw: string) => Object.values(parseNoteMap(raw)).map(value => stripHtml(value ?? '')).filter(Boolean).join(' · ')
|
const noteText = (raw: string) => parseNoteEntries(raw).map(e => stripHtml(e.html)).filter(Boolean).join(' · ')
|
||||||
|
const lastEdited = (raw: string) => parseNoteEntries(raw).reduce((max, e) => (e.updatedAt && e.updatedAt > max ? e.updatedAt : max), '')
|
||||||
const sorted = [...entries].sort((a, b) => {
|
const sorted = [...entries].sort((a, b) => {
|
||||||
|
if (noteSort === 'recent') return lastEdited(b.note).localeCompare(lastEdited(a.note))
|
||||||
const aNum = a.section ? getLessonNumber(study.sections, a.section.id) : 0
|
const aNum = a.section ? getLessonNumber(study.sections, a.section.id) : 0
|
||||||
const bNum = b.section ? getLessonNumber(study.sections, b.section.id) : 0
|
const bNum = b.section ? getLessonNumber(study.sections, b.section.id) : 0
|
||||||
return aNum - bNum
|
return aNum - bNum
|
||||||
@@ -2793,6 +2872,34 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
|||||||
: sorted
|
: sorted
|
||||||
const active = visible.find(item => item.sectionId === activeNoteTab) ?? visible[0]
|
const active = visible.find(item => item.sectionId === activeNoteTab) ?? visible[0]
|
||||||
|
|
||||||
|
const otherStudyMatches = queryText
|
||||||
|
? enrolledStudies
|
||||||
|
.filter(s2 => s2.slug !== study.slug)
|
||||||
|
.flatMap(s2 => {
|
||||||
|
const p2 = `${s2.slug}--`
|
||||||
|
return Object.entries(notes)
|
||||||
|
.filter(([key]) => key.startsWith(p2))
|
||||||
|
.map(([key, note]) => ({ study: s2, section: s2.sections.find(sec => sec.id === key.slice(p2.length)), note }))
|
||||||
|
.filter(item => item.section && `${item.section.title} ${item.section.reference ?? ''} ${noteText(item.note)}`.toLowerCase().includes(queryText))
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
function downloadStudyNotesMarkdown() {
|
||||||
|
const body = sorted.map(({ section, sectionId, note }) => {
|
||||||
|
const blocks = parseNoteEntries(note)
|
||||||
|
.map(e => `### ${ANCHOR_LABELS[e.anchor]}${e.updatedAt ? ` (${new Date(e.updatedAt).toLocaleDateString('en-US')})` : ''}\n\n${stripHtml(e.html)}`)
|
||||||
|
.join('\n\n')
|
||||||
|
return `## ${section?.title ?? sectionId}${section?.reference ? ` (${section.reference})` : ''}\n\n${blocks}`
|
||||||
|
}).join('\n\n---\n\n')
|
||||||
|
const blob = new Blob([`# ${study!.title} — My Notes\n\n${body}\n`], { type: 'text/markdown' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${study!.slug}-notes-${new Date().toISOString().slice(0, 10)}.md`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="notes-page" aria-label="My study notes">
|
<main className="notes-page" aria-label="My study notes">
|
||||||
<div className="notes-shell">
|
<div className="notes-shell">
|
||||||
@@ -2866,8 +2973,12 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
|||||||
value={noteQuery}
|
value={noteQuery}
|
||||||
onChange={e => setNoteQuery(e.target.value)}
|
onChange={e => setNoteQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<div className="notes-sort" role="group" aria-label="Sort notes">
|
||||||
|
<button type="button" className={noteSort === 'lesson' ? 'notes-sort-btn notes-sort-btn--active' : 'notes-sort-btn'} onClick={() => setNoteSort('lesson')}>Lesson order</button>
|
||||||
|
<button type="button" className={noteSort === 'recent' ? 'notes-sort-btn notes-sort-btn--active' : 'notes-sort-btn'} onClick={() => setNoteSort('recent')}>Recently edited</button>
|
||||||
|
</div>
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
<p className="notes-empty-text">No notes match “{noteQuery.trim()}”.</p>
|
<p className="notes-empty-text">No notes match “{noteQuery.trim()}” in this study.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="notes-list" role="tablist" aria-label="Lesson notes">
|
<ul className="notes-list" role="tablist" aria-label="Lesson notes">
|
||||||
{visible.map(({ sectionId, section, note }) => {
|
{visible.map(({ sectionId, section, note }) => {
|
||||||
@@ -2885,7 +2996,7 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
|||||||
<span className="notes-item-number" aria-hidden="true">{lessonNumber || '•'}</span>
|
<span className="notes-item-number" aria-hidden="true">{lessonNumber || '•'}</span>
|
||||||
<span className="notes-item-body">
|
<span className="notes-item-body">
|
||||||
<strong>{section?.title ?? 'Saved note'}</strong>
|
<strong>{section?.title ?? 'Saved note'}</strong>
|
||||||
<small>{section?.reference ?? ''}</small>
|
<small>{section?.reference ?? ''}{lastEdited(note) ? ` · edited ${formatRelativeTime(lastEdited(note))}` : ''}</small>
|
||||||
<span className="notes-item-snippet">{noteText(note).slice(0, 110)}</span>
|
<span className="notes-item-snippet">{noteText(note).slice(0, 110)}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -2894,20 +3005,55 @@ export function ColossiansStudyNotesPage({ content }: Props) {
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
{otherStudyMatches.length > 0 && (
|
||||||
|
<div className="notes-other-matches">
|
||||||
|
<p className="notes-reader-label">Also in your other studies</p>
|
||||||
|
<ul className="notes-list">
|
||||||
|
{otherStudyMatches.map(({ study: s2, section, note }) => (
|
||||||
|
<li key={`${s2.slug}-${section!.id}`}>
|
||||||
|
<Link className="notes-item" to={`/study/${s2.slug}/notes`}>
|
||||||
|
<span className="notes-item-body">
|
||||||
|
<strong>{section!.title}</strong>
|
||||||
|
<small>{s2.title}</small>
|
||||||
|
<span className="notes-item-snippet">{noteText(note).slice(0, 110)}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn-secondary notes-export-btn" onClick={downloadStudyNotesMarkdown}>Download notes (.md)</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{active && (
|
{active && (
|
||||||
<article className="notes-reader" aria-label="Note">
|
<article className="notes-reader" aria-label="Note">
|
||||||
<p className="notes-reader-label">{active.section ? `Lesson ${getLessonNumber(study.sections, active.section.id)}` : 'Saved note'}{active.section?.reference ? ` · ${active.section.reference}` : ''}</p>
|
<p className="notes-reader-label">{active.section ? `Lesson ${getLessonNumber(study.sections, active.section.id)}` : 'Saved note'}{active.section?.reference ? ` · ${active.section.reference}` : ''}</p>
|
||||||
<h2>{active.section?.title ?? active.sectionId}</h2>
|
<h2>{active.section?.title ?? active.sectionId}</h2>
|
||||||
|
<p className="notes-reader-meta">
|
||||||
|
{noteText(active.note).split(/\s+/).filter(Boolean).length.toLocaleString()} words
|
||||||
|
{' · '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="notes-copy-btn"
|
||||||
|
onClick={() => {
|
||||||
|
const text = parseNoteEntries(active.note)
|
||||||
|
.map(e => `${ANCHOR_LABELS[e.anchor]}\n${stripHtml(e.html)}`)
|
||||||
|
.join('\n\n')
|
||||||
|
navigator.clipboard?.writeText(text).then(() => setMessage('Note copied to clipboard.'), () => setMessage('Unable to copy this note.'))
|
||||||
|
}}
|
||||||
|
>Copy note</button>
|
||||||
|
</p>
|
||||||
{(() => {
|
{(() => {
|
||||||
const anchorMap = parseNoteMap(active.note)
|
const blocks = parseNoteEntries(active.note)
|
||||||
const blocks = Object.entries(anchorMap).filter(([, html]) => html?.trim())
|
|
||||||
if (blocks.length === 0) return <p className="notes-empty-text">No notes saved for this lesson yet.</p>
|
if (blocks.length === 0) return <p className="notes-empty-text">No notes saved for this lesson yet.</p>
|
||||||
return blocks.map(([key, html]) => (
|
return blocks.map(entry => (
|
||||||
<section className="notes-block" key={key}>
|
<section className="notes-block" key={entry.id}>
|
||||||
<h3>{ANCHOR_LABELS[key] ?? key}</h3>
|
<h3>
|
||||||
<div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />
|
{ANCHOR_LABELS[entry.anchor]}
|
||||||
|
{entry.updatedAt && <small className="notes-block-date"> · edited {new Date(entry.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</small>}
|
||||||
|
</h3>
|
||||||
|
<div className="study-note-rich-content" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(entry.html) }} />
|
||||||
</section>
|
</section>
|
||||||
))
|
))
|
||||||
})()}
|
})()}
|
||||||
@@ -4127,12 +4273,14 @@ export function StudyQuizPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!study || !section) {
|
if (!study || !section) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Discussion questions not found">
|
<main className="auth-page" aria-label="Discussion questions not found">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Reflection Questions</p>
|
<p className="eyebrow">Reflection Questions</p>
|
||||||
<h1>Discussion questions not found</h1>
|
<h1>Discussion questions not found</h1>
|
||||||
<p>The reflection questions you requested are not available.</p>
|
<p>The reflection questions you requested are not available.</p>
|
||||||
<Link to="/study" className="btn-primary">Back to Studies</Link>
|
<div className="study-signup-actions"><Link to="/study" className="btn-primary">Back to Studies</Link></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -4144,20 +4292,24 @@ export function StudyQuizPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!auth.checked) {
|
if (!auth.checked) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Loading reflection questions">
|
<main className="auth-page" aria-label="Loading reflection questions">
|
||||||
<div className="thanks-card"><p>Loading reflection questions...</p></div>
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center"><p>Loading reflection questions...</p></div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!auth.authenticated) {
|
if (!auth.authenticated) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Sign in required">
|
<main className="auth-page" aria-label="Sign in required">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Reflection Questions</p>
|
<p className="eyebrow">Reflection Questions</p>
|
||||||
<h1>Sign In Required</h1>
|
<h1>Sign In Required</h1>
|
||||||
<p>You need to sign in before you can view and save your discussion answers.</p>
|
<p>You need to sign in before you can view and save your discussion answers.</p>
|
||||||
<Link to="/study/signup" className="btn-primary">Sign In</Link>
|
<div className="study-signup-actions"><Link to="/study/signup" className="btn-primary">Sign In</Link></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -4165,12 +4317,14 @@ export function StudyQuizPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!isEnrolled) {
|
if (!isEnrolled) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Enrollment required">
|
<main className="auth-page" aria-label="Enrollment required">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Reflection Questions</p>
|
<p className="eyebrow">Reflection Questions</p>
|
||||||
<h1>Enroll to Access Reflection Questions</h1>
|
<h1>Enroll to Access Reflection Questions</h1>
|
||||||
<p>Please enroll in {study.title} to open the reflection questions for this lesson.</p>
|
<p>Please enroll in {study.title} to open the reflection questions for this lesson.</p>
|
||||||
<Link to={`/study/${study.slug}`} className="btn-primary">Go to Study</Link>
|
<div className="study-signup-actions"><Link to={`/study/${study.slug}`} className="btn-primary">Go to Study</Link></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -4306,20 +4460,24 @@ export function StudyDiscussionHubPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!auth.checked) {
|
if (!auth.checked) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Loading">
|
<main className="auth-page" aria-label="Loading">
|
||||||
<div className="thanks-card"><p>Loading...</p></div>
|
<div className="auth-shell auth-shell--single"><div className="auth-card auth-card--center"><p>Loading…</p></div></div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!auth.authenticated) {
|
if (!auth.authenticated) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Sign in required">
|
<main className="auth-page" aria-label="Sign in required">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Study Discussion</p>
|
<p className="eyebrow">Study Discussion</p>
|
||||||
<h1>Sign In Required</h1>
|
<h1>Sign In Required</h1>
|
||||||
<p>Create a free student account to see and join the conversation with other students.</p>
|
<p>Create a free student account to see and join the conversation with other students.</p>
|
||||||
<Link to="/study/signup" className="btn-primary" style={{ marginTop: '1rem', display: 'inline-block' }}>Sign In</Link>
|
<div className="study-signup-actions">
|
||||||
|
<Link to="/study/signup" className="btn-primary">Create Account or Sign In</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -4338,22 +4496,16 @@ export function StudyDiscussionHubPage({ content }: Props) {
|
|||||||
{openDiscussionStudies.length === 0 ? (
|
{openDiscussionStudies.length === 0 ? (
|
||||||
<p className="study-detail-copy">There are no studies open for discussion yet. Check back when the next one starts.</p>
|
<p className="study-detail-copy">There are no studies open for discussion yet. Check back when the next one starts.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="study-module-list">
|
<div className="sdh-grid">
|
||||||
{openDiscussionStudies.map(study => {
|
{openDiscussionStudies.map(study => {
|
||||||
const enrolled = isEnrolledInStudy(auth, study.slug)
|
const enrolled = isEnrolledInStudy(auth, study.slug)
|
||||||
return (
|
return (
|
||||||
<article key={study.slug} className="study-module-row">
|
<Link key={study.slug} to={`/study/${study.slug}/community`} className="sdh-card">
|
||||||
<div className="study-module-row-left">
|
<span className={`sdh-badge${enrolled ? ' sdh-badge--on' : ''}`}>{enrolled ? 'Enrolled' : 'Open to all students'}</span>
|
||||||
<p className="study-module-lesson">{enrolled ? 'Enrolled' : 'Study track'}</p>
|
|
||||||
<h3>{study.title}</h3>
|
<h3>{study.title}</h3>
|
||||||
<p>{study.description || 'Join the discussion for this study track.'}</p>
|
<p>{study.description || 'Join the discussion for this study track.'}</p>
|
||||||
</div>
|
<span className="sdh-go">{enrolled ? 'Open discussion →' : 'View discussion →'}</span>
|
||||||
<div className="study-module-row-right" style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
|
|
||||||
<Link to={`/study/${study.slug}/community`} className="btn-primary">
|
|
||||||
{enrolled ? 'Open Discussion' : 'View Discussion'}
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -4383,20 +4535,24 @@ export function StudyCommunityPage({ content }: Props) {
|
|||||||
|
|
||||||
if (!auth.checked) {
|
if (!auth.checked) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Loading">
|
<main className="auth-page" aria-label="Loading">
|
||||||
<div className="thanks-card"><p>Loading...</p></div>
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center"><p>Loading...</p></div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!auth.authenticated) {
|
if (!auth.authenticated) {
|
||||||
return (
|
return (
|
||||||
<main className="thanks-page" aria-label="Sign in required">
|
<main className="auth-page" aria-label="Sign in required">
|
||||||
<div className="thanks-card">
|
<div className="auth-shell auth-shell--single">
|
||||||
|
<div className="auth-card auth-card--center">
|
||||||
<p className="eyebrow">Study Discussion</p>
|
<p className="eyebrow">Study Discussion</p>
|
||||||
<h1>Sign In Required</h1>
|
<h1>Sign In Required</h1>
|
||||||
<p>Create a free student account to see and join the conversation.</p>
|
<p>Create a free student account to see and join the conversation.</p>
|
||||||
<Link to="/study/signup" className="btn-primary" style={{ marginTop: '1rem', display: 'inline-block' }}>Sign In</Link>
|
<div className="study-signup-actions"><Link to="/study/signup" className="btn-primary">Sign In</Link></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { isKnownSitePath } from '../server/study-helpers.js'
|
||||||
|
|
||||||
|
test('real site pages are known paths', () => {
|
||||||
|
for (const p of ['/', '/study', '/study/titus/titus-abc', '/episodes/123', '/salvation', '/scripture', '/privacy']) {
|
||||||
|
assert.equal(isKnownSitePath(p), true, p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scanner and probe paths are not known paths', () => {
|
||||||
|
for (const p of ['/.env', '/.git/config', '/wp-login', '/actuator/health', '/cgi-bin/x', '/random-path-123']) {
|
||||||
|
assert.equal(isKnownSitePath(p), false, p)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { checkPassageReference } from '../shared/bible-books.js'
|
||||||
|
|
||||||
|
test('passage reference check accepts what the index can read and flags what it cannot', () => {
|
||||||
|
assert.equal(checkPassageReference('Titus 1:1-2', ''), null)
|
||||||
|
assert.equal(checkPassageReference('1:10–13a', 'titus'), null)
|
||||||
|
assert.match(checkPassageReference('1:1-2', ''), /Bible Book/)
|
||||||
|
assert.match(checkPassageReference('Foo 1:1', 'titus'), /not a recognized/)
|
||||||
|
assert.match(checkPassageReference('chapter one', 'titus'), /Use the form/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { parseNoteEntries, serializeNoteEntries, noteEntriesToText } from '../shared/note-format.js'
|
||||||
|
|
||||||
|
test('legacy anchor map upgrades to entries and round-trips as v2', () => {
|
||||||
|
const entries = parseNoteEntries(JSON.stringify({ scripture: '<p>a</p>', general: '', commentary: '<p>b</p>' }))
|
||||||
|
assert.deepEqual(entries.map(e => e.anchor), ['scripture', 'commentary'])
|
||||||
|
const again = parseNoteEntries(serializeNoteEntries(entries))
|
||||||
|
assert.equal(again.length, 2)
|
||||||
|
assert.equal(JSON.parse(serializeNoteEntries(entries)).v, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('plain-text legacy notes become a general entry', () => {
|
||||||
|
const entries = parseNoteEntries('just some text')
|
||||||
|
assert.equal(entries.length, 1)
|
||||||
|
assert.equal(entries[0].anchor, 'general')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('multiple entries per anchor are kept and empty ones dropped', () => {
|
||||||
|
const raw = serializeNoteEntries([
|
||||||
|
{ id: '1', anchor: 'general', html: '<p>one</p>', updatedAt: '2026-01-01T00:00:00.000Z' },
|
||||||
|
{ id: '2', anchor: 'general', html: '<p>two</p>', updatedAt: null },
|
||||||
|
{ id: '3', anchor: 'general', html: ' ', updatedAt: null },
|
||||||
|
])
|
||||||
|
assert.equal(parseNoteEntries(raw).length, 2)
|
||||||
|
assert.equal(serializeNoteEntries([]), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('plain-text export strips markup', () => {
|
||||||
|
const text = noteEntriesToText(serializeNoteEntries([{ id: '1', anchor: 'scripture', html: '<p>Hi & bye</p>', updatedAt: null }]))
|
||||||
|
assert.equal(text, 'Scripture Text\nHi & bye')
|
||||||
|
})
|
||||||
+3
-8
@@ -25,10 +25,10 @@ export default defineConfig({
|
|||||||
navigateFallback: '/index.html',
|
navigateFallback: '/index.html',
|
||||||
// Allowlist-only approach: SW only serves index.html for known SPA routes.
|
// Allowlist-only approach: SW only serves index.html for known SPA routes.
|
||||||
// Everything else (admin-configured redirects, /spotify, /apple, /amazon,
|
// Everything else (admin-configured redirects, /spotify, /apple, /amazon,
|
||||||
// feed.xml, robots.txt, etc.) goes to the server so server-side redirects work.
|
// feed.xml, robots.txt, the static /salvation page, etc.) goes to the server so server-side redirects work.
|
||||||
navigateFallbackAllowlist: [
|
navigateFallbackAllowlist: [
|
||||||
/^\/$/, // exact root
|
/^\/$/, // exact root
|
||||||
/^\/(start-here|about|contact|resources|thanks|preview|privacy|terms|salvation)(\/.*)?$/,
|
/^\/(start-here|about|contact|resources|thanks|preview|privacy|terms)(\/.*)?$/,
|
||||||
/^\/(study|studys|episodes|finished|downloads|subscribe|certificate)(\/.*)?$/,
|
/^\/(study|studys|episodes|finished|downloads|subscribe|certificate)(\/.*)?$/,
|
||||||
/^\/(questions|admin|email|contacts|calendar)(\/.*)?$/,
|
/^\/(questions|admin|email|contacts|calendar)(\/.*)?$/,
|
||||||
],
|
],
|
||||||
@@ -39,7 +39,7 @@ export default defineConfig({
|
|||||||
options: { cacheName: 'api-content', expiration: { maxAgeSeconds: 3600 } },
|
options: { cacheName: 'api-content', expiration: { maxAgeSeconds: 3600 } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
urlPattern: /\/images\//,
|
urlPattern: /\/images\/.*\.(?!mp4|webm|mov)[a-z0-9]+(\?.*)?$/i,
|
||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
options: { cacheName: 'images', expiration: { maxEntries: 60, maxAgeSeconds: 86400 * 30 } },
|
options: { cacheName: 'images', expiration: { maxEntries: 60, maxAgeSeconds: 86400 * 30 } },
|
||||||
},
|
},
|
||||||
@@ -47,11 +47,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'react-simple-maps': 'react-simple-maps/dist/index.es.js',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:4173',
|
'/api': 'http://localhost:4173',
|
||||||
|
|||||||
Reference in new issue
Block a user