Gospel page audio: narration script, ElevenLabs generator, player on /salvation, admin Gospel Page setting; v2.40.0
Publish Container / build (push) Successful in 17m6s
Publish Container / build (push) Successful in 17m6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
330c354d54
commit
ad1c3e35b1
8 files changed
+293
-3
No files matched your search
@@ -62,3 +62,6 @@ 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/
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.39.2",
|
||||
"version": "2.40.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -628,6 +628,21 @@
|
||||
@keyframes plea-glow { 0%, 100% { box-shadow: 0 0 0 4px rgba(184,134,11,0.3), 0 10px 30px rgba(0,0,0,0.5), 0 0 30px rgba(240,194,74,0.25); } 50% { box-shadow: 0 0 0 8px rgba(184,134,11,0.2), 0 10px 30px rgba(0,0,0,0.5), 0 0 55px rgba(240,194,74,0.55); } }
|
||||
@media (prefers-reduced-motion: reduce) { .plea-big { animation: none; } }
|
||||
@media (max-width: 600px) { .plea-big { display: block; padding: 1.15rem 0.6rem; font-size: 1.12rem; letter-spacing: 0.05em; white-space: nowrap; } .plea-big span { margin-left: 0.3rem; } }
|
||||
|
||||
.audio-card { margin: 1.5rem 0 0; padding: 1.1rem 1.3rem; background: linear-gradient(145deg, rgba(139,26,26,0.3), rgba(26,9,5,0.85)); border: 1px solid var(--gold); border-radius: 4px; color: var(--parchment); }
|
||||
.audio-card[hidden] { display: none; }
|
||||
.audio-top { display: flex; align-items: center; gap: 0.9rem; }
|
||||
.audio-icon { font-size: 1.8rem; }
|
||||
.audio-title { font-family: 'Cinzel', serif; font-weight: 700; font-size: 1.05rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--gold-light); }
|
||||
.audio-sub { font-size: 1rem; opacity: 0.9; font-style: italic; }
|
||||
.audio-controls { display: flex; align-items: center; gap: 0.7rem; margin-top: 0.9rem; }
|
||||
.audio-btn { font-family: 'Cinzel', serif; font-weight: 700; font-size: 0.8rem; letter-spacing: 0.05em; color: var(--parchment); background: transparent; border: 1px solid rgba(184,134,11,0.6); border-radius: 2px; padding: 0.55rem 0.7rem; cursor: pointer; white-space: nowrap; }
|
||||
.audio-btn:hover { background: rgba(184,134,11,0.18); }
|
||||
.audio-play { width: 2.9rem; height: 2.9rem; border-radius: 50%; padding: 0; font-size: 1rem; color: #1a0f05; background: var(--gold); border-color: var(--gold); }
|
||||
.audio-play:hover { background: var(--gold-light); }
|
||||
#audio-seek { flex: 1; min-width: 0; accent-color: var(--gold); height: 1.6rem; cursor: pointer; }
|
||||
.audio-time { font-family: 'Cinzel', serif; font-size: 0.8rem; min-width: 4.6rem; text-align: right; color: var(--parchment-dark); white-space: nowrap; }
|
||||
@media (max-width: 600px) { .audio-controls { flex-wrap: wrap; } #audio-seek { flex-basis: 100%; order: 5; } .audio-time { margin-left: auto; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -668,6 +683,24 @@
|
||||
</div>
|
||||
|
||||
<div class="panel" id="panel-case" role="tabpanel" aria-labelledby="tab-case">
|
||||
<div class="audio-card" id="audio-card" hidden>
|
||||
<div class="audio-top">
|
||||
<span class="audio-icon" aria-hidden="true">🎧</span>
|
||||
<div>
|
||||
<p class="audio-title">Listen to the case</p>
|
||||
<p class="audio-sub">The full case read aloud, about 10 minutes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<audio id="audio-el" preload="none"></audio>
|
||||
<div class="audio-controls">
|
||||
<button type="button" class="audio-btn audio-play" id="audio-play" aria-label="Play">▶</button>
|
||||
<button type="button" class="audio-btn" id="audio-back" aria-label="Back 15 seconds">↺ 15</button>
|
||||
<input type="range" id="audio-seek" min="0" max="100" value="0" step="0.1" aria-label="Seek">
|
||||
<span class="audio-time" id="audio-time">0:00</span>
|
||||
<button type="button" class="audio-btn audio-speed" id="audio-speed" aria-label="Playback speed">1×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="gospel-message" aria-labelledby="good-news">
|
||||
<div class="docket" aria-hidden="true">
|
||||
<span class="docket-scales">⚖</span>
|
||||
@@ -1616,6 +1649,36 @@ buildTimeline();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ── Audio narration (shown only when a URL is set in Admin → Gospel Page) ──
|
||||
fetch('/api/salvation-audio').then(function (r) { return r.ok ? r.json() : { url: '' }; }).then(function (d) {
|
||||
if (!d || !d.url) return;
|
||||
const card = document.getElementById('audio-card');
|
||||
const el = document.getElementById('audio-el');
|
||||
const playBtn = document.getElementById('audio-play');
|
||||
const seek = document.getElementById('audio-seek');
|
||||
const timeEl = document.getElementById('audio-time');
|
||||
const speedBtn = document.getElementById('audio-speed');
|
||||
el.src = d.url;
|
||||
card.hidden = false;
|
||||
const fmt = function (t) { if (!isFinite(t)) return '0:00'; const m = Math.floor(t / 60); const s = Math.floor(t % 60); return m + ':' + (s < 10 ? '0' : '') + s; };
|
||||
const paint = function () {
|
||||
seek.value = el.duration ? (el.currentTime / el.duration) * 100 : 0;
|
||||
timeEl.textContent = fmt(el.currentTime) + (el.duration ? ' / ' + fmt(el.duration) : '');
|
||||
};
|
||||
playBtn.addEventListener('click', function () { if (el.paused) el.play(); else el.pause(); });
|
||||
el.addEventListener('play', function () { playBtn.textContent = '❚❚'; playBtn.setAttribute('aria-label', 'Pause'); });
|
||||
el.addEventListener('pause', function () { playBtn.textContent = '▶'; playBtn.setAttribute('aria-label', 'Play'); });
|
||||
el.addEventListener('ended', function () { playBtn.textContent = '▶'; playBtn.setAttribute('aria-label', 'Play'); });
|
||||
el.addEventListener('timeupdate', paint);
|
||||
el.addEventListener('loadedmetadata', paint);
|
||||
seek.addEventListener('input', function () { if (el.duration) el.currentTime = (seek.value / 100) * el.duration; });
|
||||
document.getElementById('audio-back').addEventListener('click', function () { el.currentTime = Math.max(0, el.currentTime - 15); });
|
||||
const speeds = [1, 1.25, 1.5, 0.85];
|
||||
let si = 0;
|
||||
speedBtn.addEventListener('click', function () { si = (si + 1) % speeds.length; el.playbackRate = speeds[si]; speedBtn.textContent = speeds[si] + '×'; });
|
||||
}).catch(function () {});
|
||||
|
||||
// ── The plea ──
|
||||
const select = document.getElementById('plea-select');
|
||||
const guilty = document.getElementById('plea-guilty');
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Generates the /salvation audio from scripts/salvation/narration-case.txt using ElevenLabs.
|
||||
//
|
||||
// node --env-file=.env scripts/generate-salvation-audio.mjs --list-voices # find your voice id
|
||||
// node --env-file=.env scripts/generate-salvation-audio.mjs # generate the MP3
|
||||
//
|
||||
// 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 MP3 in Admin → Asset Manager, then paste its
|
||||
// URL 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())
|
||||
}
|
||||
|
||||
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 src = path.join(ROOT, 'scripts/salvation/narration-case.txt')
|
||||
const text = (await readFile(src, 'utf8')).trim()
|
||||
const chunks = chunkText(text)
|
||||
console.log(`\n${text.length.toLocaleString()} characters in ${chunks.length} parts (model ${model}).`)
|
||||
console.log('This uses about that many ElevenLabs credits.\n')
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
const outDir = path.join(ROOT, 'audio-out')
|
||||
await mkdir(outDir, { recursive: true })
|
||||
const out = path.join(outDir, 'salvation-case.mp3')
|
||||
await writeFile(out, Buffer.concat(parts))
|
||||
if (totalCost) console.log(`\nElevenLabs reported ${totalCost.toLocaleString()} credits used.`)
|
||||
console.log(`\nSaved ${out}\nListen to it before you upload it. Check the Scripture names and references.\n`)
|
||||
}
|
||||
|
||||
main().catch(err => fail(err?.message ?? String(err)))
|
||||
@@ -0,0 +1,75 @@
|
||||
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. Then come back and enter your plea.
|
||||
|
||||
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 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. If that is your plea, please be sure you understand what it means. It means telling the Judge, "I have kept your law perfectly." God's standard is not "better than most people." It is perfection. Ask yourself honestly: Have you ever told a lie, even a small one? Ever taken something that was not yours? Ever put something else ahead of God? Ever hated someone in your heart? If the answer is yes, then by God's own standard, that plea does not hold. And it means standing before the Judge with no Substitute. But that is not what God wants for you. He takes no pleasure in anyone's death, and the offer of pardon is open right now. Scripture says, "Now is the day of salvation."
|
||||
|
||||
If you are ready to plead guilty and receive the pardon, you can tell God in your own words, or you can pray something like this:
|
||||
|
||||
"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."
|
||||
|
||||
If you prayed that and meant it, then God has forgiven you. "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.
|
||||
|
||||
Here is what to do next. Tell a Christian friend or family member what you decided. Begin reading the Gospel of John, and keep going through the New Testament. Talk to God every day. Find a Bible-teaching church where you can grow, and be baptized as a public sign of your faith. And if you would like, let us know, and someone from our team will reach out to you.
|
||||
|
||||
Welcome to the family of God.
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Resend } from 'resend'
|
||||
import { escapeHtml } from '../helpers.js'
|
||||
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.
|
||||
@@ -226,6 +227,13 @@ function buildSalvationConfirmationEmail({ name }) {
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
// The /salvation page is static HTML, so it asks the server for the narration URL set in Admin → Gospel Page.
|
||||
app.get('/api/salvation-audio', (_req, res) => {
|
||||
const url = sanitizeUrl(state.cachedSiteContent?.salvationAudioUrl) || ''
|
||||
res.set('Cache-Control', 'public, max-age=300')
|
||||
res.json({ url })
|
||||
})
|
||||
|
||||
app.post('/api/salvation-response', rateLimit, async (req, res) => {
|
||||
const { name, email, message, _honey } = req.body ?? {}
|
||||
if (_honey) { res.json({ ok: true }); return }
|
||||
|
||||
+30
-1
@@ -736,7 +736,7 @@ type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | '
|
||||
type AdminView =
|
||||
| 'dashboard'
|
||||
| 'homepage:hero' | 'homepage:share' | 'homepage:prism' | 'homepage:where-to-next'
|
||||
| 'start-here'
|
||||
| 'start-here' | 'salvation'
|
||||
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
|
||||
| 'contact:profile' | 'contact:copy'
|
||||
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:workflow' | 'podcast:season-check' | 'podcast:rss'
|
||||
@@ -772,6 +772,7 @@ const ADMIN_NAV: NavGroup[] = [
|
||||
{ kind: 'leaf', view: 'homepage:where-to-next', label: 'Where to Next' },
|
||||
]},
|
||||
{ kind: 'leaf', view: 'start-here', label: 'Start Here' },
|
||||
{ kind: 'leaf', view: 'salvation', label: 'Gospel Page' },
|
||||
{ kind: 'parent', id: 'about', label: 'About', children: [
|
||||
{ kind: 'leaf', view: 'about:show-copy', label: 'Show Copy' },
|
||||
{ kind: 'leaf', view: 'about:bio', label: 'Nate Bio' },
|
||||
@@ -7021,6 +7022,34 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</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>An audio version of The Case, with a player at the top of the page. Leave this blank to hide the player.</p>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="field-salvationAudioUrl">Audio file URL (MP3)</label>
|
||||
<input id="field-salvationAudioUrl" type="text" placeholder="https://…/salvation-case.mp3" value={form.salvationAudioUrl ?? ''} onChange={e => handleChange('salvationAudioUrl', e.target.value)} />
|
||||
</div>
|
||||
{form.salvationAudioUrl?.trim() && (
|
||||
<div className="admin-field">
|
||||
<label>Preview</label>
|
||||
<audio controls preload="none" src={form.salvationAudioUrl.trim()} style={{ width: '100%' }} />
|
||||
</div>
|
||||
)}
|
||||
<p className="admin-stats-note">
|
||||
To make the file: put your ElevenLabs key and voice in your local <code>.env</code>, then run{' '}
|
||||
<code>node --env-file=.env scripts/generate-salvation-audio.mjs</code>. Upload the MP3 in Asset Manager and paste its URL here, then Save and Publish.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* LEGAL */}
|
||||
{adminView === 'legal' && (
|
||||
<section className="admin-panel-section" aria-label="Legal Pages">
|
||||
|
||||
@@ -285,6 +285,8 @@ export interface SiteContent {
|
||||
shareQrImageUrl: string
|
||||
// ── PRISM Video ──
|
||||
prismVideoUrl: string
|
||||
// Audio narration of the /salvation page (served to the static page via /api/salvation-audio)
|
||||
salvationAudioUrl: string
|
||||
prismEyebrow: string
|
||||
prismHeading: string
|
||||
prismIntro: string
|
||||
@@ -528,6 +530,7 @@ export const DEFAULTS: SiteContent = {
|
||||
shareP: 'Scan the QR code or text the show link to a friend who needs encouragement today.',
|
||||
shareQrImageUrl: '/images/qr-code.jpg',
|
||||
prismVideoUrl: 'https://files.versebyversewithnate.us/images/PRISM.mp4',
|
||||
salvationAudioUrl: '',
|
||||
prismEyebrow: 'Featured Video',
|
||||
prismHeading: 'PRISM',
|
||||
prismIntro:
|
||||
|
||||
Reference in new issue
Block a user