Add Verse By Verse: Nuggets sub-show with editable hosts and RSS feed
Publish Container / docker (push) Successful in 14m16s

New /nuggets page for the companion show, linked from the Episodes nav
dropdown. Hosts (name, role, bio, photo) show in a left column with the
episode list on the right — latest episode featured, rest below.
Episodes are pulled from an admin-configurable RSS feed URL (empty by
default, shows "coming soon" until set) rather than hardcoded like the
main show's feed, since the show doesn't have one yet.

Everything is editable in /admin under a new Nuggets section: Show
Settings (title, tagline, description, podcast art, RSS/platform URLs)
and Hosts (add freely; removing a host archives them instead of
deleting — kept on record and restorable, never lost).

Extracted the RSS item-parsing logic shared with the main show's feed
(server/routes/episodes.js) into server/rss-helpers.js so both feeds
use the same parser instead of duplicating it.

Also fixes the local Vite dev server not proxying /uploads/* to the
backend — it was silently serving the SPA shell (200, text/html)
instead of the actual file, so any admin-uploaded image (podcast art,
host photos, etc.) appeared broken in local dev only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-07 14:27:52 -04:00
1 parent 335a9cbe31
commit 31e79ad542
11 files changed
+573 -41

No files matched your search

+137 -4
View File
@@ -1,5 +1,6 @@
{
"siteContent": {
"podcastArtUrl": "/images/podcast-art.jpeg",
"eyebrow": "A Journey Through Scripture",
"heroTagline": "Exploring God's Word one verse at a time",
"heroBtnSpotify": "Listen on Spotify",
@@ -48,6 +49,8 @@
"studyGuideDownloadUrl": "",
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
"studyGuideAmazonButtonLabel": "Get it on Amazon",
"studyGuideAppleBooksUrl": "",
"studyGuideAppleBooksLabel": "Get it on Apple Books",
"studies": [
{
"id": "study-colossians",
@@ -85,6 +88,7 @@
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [
"Why does Paul begin with grace and peace?",
@@ -106,6 +110,7 @@
"What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-05-31T00:00:00Z"
@@ -124,6 +129,7 @@
"What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z"
@@ -142,6 +148,7 @@
"What stands out most about Christ’s supremacy?",
"Why is this passage central to the study?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z"
@@ -160,6 +167,7 @@
"How does Paul describe the believer’s former condition?",
"What does continuing in the faith look like?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z"
@@ -178,6 +186,7 @@
"What is the goal of Paul’s ministry?",
"Why is maturity such an important theme here?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z"
@@ -196,6 +205,7 @@
"What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z"
@@ -214,6 +224,7 @@
"What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z"
@@ -232,6 +243,7 @@
"Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z"
@@ -250,6 +262,7 @@
"What changes because of Christ’s work here?",
"How does victory over the powers encourage believers today?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z"
@@ -268,6 +281,7 @@
"What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z"
@@ -286,6 +300,7 @@
"How does Paul describe false spirituality?",
"Why is connection to Christ essential?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z"
@@ -304,6 +319,7 @@
"Why do human regulations fail to transform the heart?",
"What does real spiritual change require?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z"
@@ -322,6 +338,7 @@
"What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z"
@@ -340,6 +357,7 @@
"Which old patterns does Paul name?",
"Why is decisive change necessary?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z"
@@ -358,6 +376,7 @@
"What does renewal in Christ produce?",
"How does this reshape how believers see one another?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z"
@@ -376,6 +395,7 @@
"Which virtues are most needed in your context?",
"How does love bind these virtues together?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z"
@@ -394,6 +414,7 @@
"What does it look like for Christ’s peace to rule?",
"How can the word of Christ dwell richly in a church family?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z"
@@ -412,6 +433,7 @@
"How do these instructions reflect Christ’s lordship?",
"Which relationship is most challenging to apply today?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z"
@@ -430,6 +452,7 @@
"How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z"
@@ -448,6 +471,7 @@
"Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z"
@@ -466,6 +490,7 @@
"How are prayer and witness connected here?",
"What does gracious speech look like in real life?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z"
@@ -484,6 +509,7 @@
"Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z"
@@ -502,6 +528,7 @@
"What stands out about Paul’s coworkers?",
"How does shared ministry strengthen the church?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z"
@@ -520,6 +547,7 @@
"What final responsibility does Paul place on the church?",
"How does the closing reinforce the letter’s themes?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z"
@@ -565,6 +593,7 @@
"Why does Paul begin with grace and peace?",
"What tone does this create for the rest of the study?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [
"Why does Paul begin with grace and peace?",
@@ -586,6 +615,7 @@
"What signs of gospel growth does Paul mention?",
"How do faith, hope, and love work together here?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-05-31T00:00:00Z"
@@ -604,6 +634,7 @@
"What does Paul ask God to produce?",
"How does this prayer shape the goals of the study?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-07T00:00:00Z"
@@ -622,6 +653,7 @@
"What stands out most about Christ’s supremacy?",
"Why is this passage central to the study?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2026-12-21T00:00:00Z"
@@ -640,6 +672,7 @@
"How does Paul describe the believer’s former condition?",
"What does continuing in the faith look like?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-04T00:00:00Z"
@@ -658,6 +691,7 @@
"What is the goal of Paul’s ministry?",
"Why is maturity such an important theme here?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-01-18T00:00:00Z"
@@ -676,6 +710,7 @@
"What does Paul want the believers to experience?",
"Why does he emphasize encouragement and unity?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-01T00:00:00Z"
@@ -694,6 +729,7 @@
"What does it mean to continue in Christ?",
"What kinds of deception should believers watch for?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-02-15T00:00:00Z"
@@ -712,6 +748,7 @@
"Why is fullness in Christ such an important correction?",
"How does this guard against spiritual insecurity?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-01T00:00:00Z"
@@ -730,6 +767,7 @@
"What changes because of Christ’s work here?",
"How does victory over the powers encourage believers today?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-15T00:00:00Z"
@@ -748,6 +786,7 @@
"What do shadows and substance mean here?",
"Where do believers still feel pressure from external judgment?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-03-29T00:00:00Z"
@@ -766,6 +805,7 @@
"How does Paul describe false spirituality?",
"Why is connection to Christ essential?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-12T00:00:00Z"
@@ -784,6 +824,7 @@
"Why do human regulations fail to transform the heart?",
"What does real spiritual change require?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-04-26T00:00:00Z"
@@ -802,6 +843,7 @@
"What does it mean to seek the things above?",
"How does hidden life in Christ shape daily priorities?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-10T00:00:00Z"
@@ -820,6 +862,7 @@
"Which old patterns does Paul name?",
"Why is decisive change necessary?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-05-24T00:00:00Z"
@@ -838,6 +881,7 @@
"What does renewal in Christ produce?",
"How does this reshape how believers see one another?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-07T00:00:00Z"
@@ -856,6 +900,7 @@
"Which virtues are most needed in your context?",
"How does love bind these virtues together?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-06-21T00:00:00Z"
@@ -874,6 +919,7 @@
"What does it look like for Christ’s peace to rule?",
"How can the word of Christ dwell richly in a church family?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-05T00:00:00Z"
@@ -892,6 +938,7 @@
"How do these instructions reflect Christ’s lordship?",
"Which relationship is most challenging to apply today?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-07-19T00:00:00Z"
@@ -910,6 +957,7 @@
"How does serving the Lord reshape everyday work?",
"What does Paul say about fairness and accountability?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-02T00:00:00Z"
@@ -928,6 +976,7 @@
"Why does Paul remind masters of their heavenly Master?",
"What does fairness look like in this context?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-16T00:00:00Z"
@@ -946,6 +995,7 @@
"How are prayer and witness connected here?",
"What does gracious speech look like in real life?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-08-30T00:00:00Z"
@@ -964,6 +1014,7 @@
"Why do these messengers matter to the letter?",
"What does this tell us about trusted coworkers?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-13T00:00:00Z"
@@ -982,6 +1033,7 @@
"What stands out about Paul’s coworkers?",
"How does shared ministry strengthen the church?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-09-27T00:00:00Z"
@@ -1000,6 +1052,7 @@
"What final responsibility does Paul place on the church?",
"How does the closing reinforce the letter’s themes?"
],
"announcement": "",
"checkpointPrompt": "",
"checkpointQuestions": [],
"releasedAt": "2027-10-11T00:00:00Z"
@@ -1007,6 +1060,7 @@
],
"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.",
"shareQrImageUrl": "/images/qr-code.jpg",
"prismVideoUrl": "/images/PRISM.mp4",
"prismEyebrow": "Featured Video",
"prismHeading": "PRISM",
@@ -1064,6 +1118,8 @@
"platformAmazonUrl": "/amazon",
"platformFacebookUrl": "https://facebook.com/versebyversewithnate",
"platformCreatorUrl": "https://creators.spotify.com/pod/profile/nmemmert/",
"platformKofiUrl": "https://ko-fi.com/versebyversewithnate",
"platformKofiQrUrl": "/images/kofi-qr.png",
"welcomeEmailSubject": "Welcome to Verse by Verse with Nate",
"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.",
@@ -1082,8 +1138,52 @@
"welcomeEmailScripture": "For the grace of God has appeared, bringing salvation to all people.",
"welcomeEmailScriptureRef": "Titus 2:11 - BSB",
"welcomeEmailSignoff": "Grace and peace,\nNate",
"welcomeEmailSpotifyBtnLabel": "",
"welcomeEmailAppleBtnLabel": "",
"welcomeEmailStartHereLinkLabel": "",
"studyWelcomeEmailSubject": "",
"studyWelcomeEmailBody": "",
"studyWelcomeEmailCtaLabel": "",
"studyWelcomeEmailCtaPath": "",
"studyWelcomeEmailSignoff": "",
"studyDeletedEmailSubject": "",
"studyDeletedEmailBody": "",
"studyDeletedEmailCtaLabel": "",
"studyDeletedEmailCtaPath": "",
"studyDeletedEmailSignoff": "",
"studyReminderEmailSubjectPrefix": "",
"studyReminderEmailBody": "",
"studyReminderEmailCtaLabel": "",
"studyReminderEmailSignoff": "",
"studyReengagementDays": "7,14,30",
"studyReengagementT1Subject": "Your study is waiting for you",
"studyReengagementT1Body": "It's been a while — your progress is saved and your next lesson is ready whenever you are.",
"studyReengagementT1Cta": "Continue Studying",
"studyReengagementT2Subject": "Don't lose your momentum",
"studyReengagementT2Body": "Every note and completed lesson is still right there. A little each day adds up — come back and continue.",
"studyReengagementT2Cta": "Return to Your Study",
"studyReengagementT3Subject": "Your progress is still here",
"studyReengagementT3Body": "Your study progress is still intact and waiting. There's no deadline — come back whenever you're ready.",
"studyReengagementT3Cta": "Open Your Study",
"studyCertificateEmailSubject": "Your certificate is ready",
"studyCertificateEmailBody": "Congratulations — you have completed every lesson in this study. Your certificate is ready to view and share.",
"studyCertificateEmailCtaLabel": "View Certificate",
"studyCertificateEmailSignoff": "Grace and peace,\nVerse by Verse with Nate",
"studyWeeklyDigestEmailSubject": "Your weekly study digest",
"studyWeeklyDigestEmailBody": "Here are the lessons released this week in your study track. Open any to continue where you left off.",
"studyWeeklyDigestEmailSignoff": "Grace and peace,\nVerse by Verse with Nate",
"emailChangeSubject": "",
"emailChangeBody": "",
"emailChangeCtaLabel": "",
"twoFaOtpEmailSubject": "",
"twoFaOtpEmailBody": "",
"twoFaOtpEmailExpiry": "",
"qaAnsweredEmailSubject": "Your question has been answered — Verse by Verse with Nate",
"qaAnsweredEmailBody": "Nate has answered your question on Verse by Verse with Nate.",
"qaAnsweredEmailCtaLabel": "Read Full Answer →",
"qaAnsweredEmailSignoff": "Grace and peace,\nNate",
"headerFollowLabel": "Follow on Spotify",
"cookieBannerText": "We use optional analytics cookies to better understand how people find and engage with this content. By accepting, you allow us to measure how you interact with the site — no personal data is sold or shared.",
"cookieBannerText": "We use optional analytics cookies to measure visits and location trends for site improvement.",
"customLinks": [
{
"id": "mpvh1okr",
@@ -1093,6 +1193,8 @@
"description": "We know that attending a new church can be overwhelming. Whether you are visiting for the first time or looking for ways to get more connected to Thomas Road, we want to help you take your next step with Jesus. We invite you to stop by our Connect wall (located in the main lobby) or fill out our connect form below—we'd love to meet you, answer your questions, and even offer you a welcome gift.",
"amazonUrl": "",
"amazonLabel": "",
"appleBooksUrl": "",
"appleBooksLabel": "",
"tags": [],
"placement": "externalSites"
},
@@ -1104,12 +1206,13 @@
"description": "test test We know that attending a new church can be overwhelming. Whether you are visiting for the first time or looking for ways to get more connected to Thomas Road, we want to help you take your next step with Jesus. We invite you to stop by our Connect wall (located in the main lobby) or fill out our connect form below—we'd love to meet you, answer your questions, and even offer you a welcome gift. test test",
"amazonUrl": "",
"amazonLabel": "",
"appleBooksUrl": "",
"appleBooksLabel": "",
"tags": [],
"placement": "externalSites"
}
],
"customBlocks": [],
"archivedSeries": [],
"redirects": [
{
"id": "spotify",
@@ -1144,6 +1247,35 @@
]
}
],
"finishedBooks": [],
"testimonials": [],
"nuggets": {
"title": "Verse By Verse: Nuggets",
"tagline": "",
"description": "Thesting this",
"podcastArtUrl": "/uploads/verse-by-verse-nuggets-cover-art-wheat-verse-1788805272517.png",
"rssFeedUrl": "",
"spotifyUrl": "",
"appleUrl": "",
"hosts": [
{
"id": "mtrk7n5r",
"name": "Nate",
"role": "Host",
"bio": "Teset",
"imageUrl": "/uploads/titus-series-1780334718378.png",
"archived": false
},
{
"id": "mtrk83tg",
"name": "Josh",
"role": "Co-Host",
"bio": "sadgfdgd",
"imageUrl": "/uploads/titus-series-1780334718378.png",
"archived": false
}
]
},
"episodesSeoIntro": "",
"seo": {
"title": "Verse by Verse with Nate",
@@ -1174,7 +1306,8 @@
"External links are provided for convenience and are subject to third-party policies.",
"By using this site, you agree to lawful use and respectful communication."
]
}
},
"archivedSeries": []
},
"updatedAt": "2026-06-03T14:17:48.087Z"
"updatedAt": "2026-09-07T18:21:37.012Z"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

+2
View File
@@ -62,6 +62,7 @@ import { register as registerQuestions } from './server/routes/questions.js'
import { register as registerAnalytics } from './server/routes/analytics.js'
import { register as registerDownloads } from './server/routes/downloads.js'
import { register as registerEpisodes } from './server/routes/episodes.js'
import { register as registerNuggets } from './server/routes/nuggets.js'
import { register as registerFeed } from './server/routes/feed.js'
import { register as registerQrCodes } from './server/routes/qr-codes.js'
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
@@ -110,6 +111,7 @@ registerQuestions(app)
registerAnalytics(app)
registerDownloads(app)
registerEpisodes(app)
registerNuggets(app)
registerFeed(app)
registerQrCodes(app)
registerBiblePassage(app)
+1 -35
View File
@@ -1,4 +1,5 @@
import { sanitizeUrl } from '../study-helpers.js'
import { parseRssItems } from '../rss-helpers.js'
const RSS_FEED_URL = 'https://anchor.fm/s/11068d290/podcast/rss'
let episodesCache = null
@@ -6,41 +7,6 @@ let episodesCacheAt = 0
let rawXmlCache = null
const EPISODES_CACHE_TTL = 30 * 60 * 1000
function extractCdata(raw) {
const cdata = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(raw.trim())
return cdata ? cdata[1].trim() : raw.trim()
}
function parseRssItems(xml, limit = Infinity) {
const items = []
const itemRegex = /<item>([\s\S]*?)<\/item>/g
let match
while ((match = itemRegex.exec(xml)) !== null && items.length < limit) {
const block = match[1]
const titleRaw = /<title>([\s\S]*?)<\/title>/.exec(block)?.[1] ?? ''
const title = extractCdata(titleRaw)
if (!title) continue
const pubDate = (/<pubDate>([\s\S]*?)<\/pubDate>/.exec(block)?.[1] ?? '').trim()
const guidRaw = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(block)?.[1] ?? ''
const guid = extractCdata(guidRaw)
const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? ''
const link = guid.startsWith('http') ? guid : enclosureUrl
const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? ''
const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim()
const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim()
const season = (/<itunes:season>([\s\S]*?)<\/itunes:season>/.exec(block)?.[1] ?? '').trim()
items.push({
title, pubDate, link,
audioUrl: enclosureUrl,
description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''),
duration, episode, season,
})
}
return items
}
export async function fetchAllEpisodes() {
const now = Date.now()
if (episodesCache && (now - episodesCacheAt) < EPISODES_CACHE_TTL) {
+62
View File
@@ -0,0 +1,62 @@
import { parseRssItems } from '../rss-helpers.js'
import { state } from '../state.js'
let cache = null // { url, episodes }
let cacheAt = 0
const CACHE_TTL = 30 * 60 * 1000
function feedUrl() {
return state.cachedSiteContent?.nuggets?.rssFeedUrl?.trim() ?? ''
}
async function fetchNuggetsEpisodes() {
const url = feedUrl()
if (!url) { cache = null; return [] }
const now = Date.now()
if (cache && cache.url === url && (now - cacheAt) < CACHE_TTL) {
return cache.episodes
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
try {
const response = await fetch(url, { signal: controller.signal })
clearTimeout(timeout)
if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`)
const xml = await response.text()
const episodes = parseRssItems(xml)
cache = { url, episodes }
cacheAt = now
return episodes
} catch (err) {
clearTimeout(timeout)
// Serve stale cache for this same URL rather than a hard failure, if we have one.
if (cache && cache.url === url) return cache.episodes
throw err
}
}
export function register(app) {
app.get('/api/nuggets/episodes', async (_req, res) => {
const configured = Boolean(feedUrl())
try {
const episodes = await fetchNuggetsEpisodes()
res.json({ episodes: episodes.slice(0, 6), configured })
} catch (err) {
console.error('[nuggets/episodes] RSS fetch error:', err.message)
res.json({ episodes: [], configured })
}
})
app.get('/api/nuggets/episodes/all', async (_req, res) => {
const configured = Boolean(feedUrl())
try {
const episodes = await fetchNuggetsEpisodes()
res.json({ episodes, configured })
} catch (err) {
console.error('[nuggets/episodes/all] RSS fetch error:', err.message)
res.json({ episodes: [], configured })
}
})
}
+37
View File
@@ -0,0 +1,37 @@
// Shared podcast-RSS parsing, used by both the main show (episodes.js) and
// any sub-show fed by its own feed URL (nuggets.js).
export function extractCdata(raw) {
const cdata = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(raw.trim())
return cdata ? cdata[1].trim() : raw.trim()
}
export function parseRssItems(xml, limit = Infinity) {
const items = []
const itemRegex = /<item>([\s\S]*?)<\/item>/g
let match
while ((match = itemRegex.exec(xml)) !== null && items.length < limit) {
const block = match[1]
const titleRaw = /<title>([\s\S]*?)<\/title>/.exec(block)?.[1] ?? ''
const title = extractCdata(titleRaw)
if (!title) continue
const pubDate = (/<pubDate>([\s\S]*?)<\/pubDate>/.exec(block)?.[1] ?? '').trim()
const guidRaw = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(block)?.[1] ?? ''
const guid = extractCdata(guidRaw)
const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? ''
const link = guid.startsWith('http') ? guid : enclosureUrl
const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? ''
const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim()
const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim()
const season = (/<itunes:season>([\s\S]*?)<\/itunes:season>/.exec(block)?.[1] ?? '').trim()
items.push({
title, pubDate, link,
audioUrl: enclosureUrl,
description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''),
duration, episode, season,
})
}
return items
}
+144 -1
View File
@@ -4,7 +4,7 @@ import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable }
import { CSS } from '@dnd-kit/utilities'
import type { ChangeEvent } from 'react'
import { Link } from 'react-router-dom'
import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings, PodcastHost } from './content'
import { DEFAULTS } from './content'
import { AnalyticsPanel } from './components/AnalyticsPanel'
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
@@ -666,6 +666,7 @@ type AdminView =
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
| 'contact:profile' | 'contact:copy'
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
| 'nuggets:settings' | 'nuggets:hosts'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
@@ -717,6 +718,10 @@ const ADMIN_NAV: NavGroup[] = [
{ kind: 'leaf', view: 'podcast:scripts', label: 'Episode Scripts' },
{ kind: 'leaf', view: 'podcast:rss', label: 'RSS Feed' },
]},
{ kind: 'parent', id: 'nuggets', label: 'Nuggets', children: [
{ kind: 'leaf', view: 'nuggets:settings', label: 'Show Settings' },
{ kind: 'leaf', view: 'nuggets:hosts', label: 'Hosts' },
]},
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
{ kind: 'leaf', view: 'downloads', label: 'Resources' },
{ kind: 'leaf', view: 'custom-links', label: 'Custom Links' },
@@ -4574,6 +4579,144 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)
})()}
{/* NUGGETS — SHOW SETTINGS */}
{adminView === 'nuggets:settings' && (() => {
const nuggets = form.nuggets ?? DEFAULTS.nuggets
const updateNuggets = <K extends keyof typeof nuggets>(key: K, value: (typeof nuggets)[K]) =>
setForm(f => ({ ...f, nuggets: { ...(f.nuggets ?? DEFAULTS.nuggets), [key]: value } }))
return (
<section className="admin-panel-section" aria-label="Nuggets show settings">
<div className="admin-panel-head">
<h2>Nuggets — Show Settings</h2>
<p>Verse By Verse: Nuggets is a companion show. Visitors reach it at <code>/nuggets</code>.</p>
</div>
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor="nuggets-title">Show Title</label>
<input id="nuggets-title" type="text" value={nuggets.title} onChange={e => updateNuggets('title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="nuggets-tagline">Tagline</label>
<input id="nuggets-tagline" type="text" placeholder="Short verse-bite teaching, one nugget at a time." value={nuggets.tagline} onChange={e => updateNuggets('tagline', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="nuggets-description">Description</label>
<textarea id="nuggets-description" rows={4} value={nuggets.description} onChange={e => updateNuggets('description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="nuggets-art">Podcast Art</label>
<input id="nuggets-art" type="text" placeholder="/uploads/nuggets-art.png" value={nuggets.podcastArtUrl} onChange={e => updateNuggets('podcastArtUrl', e.target.value)} />
{renderImageAssetSelector(nuggets.podcastArtUrl, value => updateNuggets('podcastArtUrl', value), 'nuggets-art-asset')}
{renderImagePreview(nuggets.podcastArtUrl, `${nuggets.title} podcast art`)}
</div>
<div className="admin-field">
<label htmlFor="nuggets-rss">RSS Feed URL</label>
<input id="nuggets-rss" type="text" placeholder="https://anchor.fm/s/xxxxxxxx/podcast/rss" value={nuggets.rssFeedUrl} onChange={e => updateNuggets('rssFeedUrl', e.target.value)} />
<p className="admin-field-help">
{nuggets.rssFeedUrl.trim()
? 'The /nuggets page pulls its episode list from this feed (cached 30 minutes).'
: 'Leave blank until the show has a feed — the page will show "Episodes coming soon" until this is set.'}
</p>
</div>
<div className="admin-field">
<label htmlFor="nuggets-spotify">Spotify URL</label>
<input id="nuggets-spotify" type="text" placeholder="https://open.spotify.com/show/..." value={nuggets.spotifyUrl} onChange={e => updateNuggets('spotifyUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="nuggets-apple">Apple Podcasts URL</label>
<input id="nuggets-apple" type="text" placeholder="https://podcasts.apple.com/..." value={nuggets.appleUrl} onChange={e => updateNuggets('appleUrl', e.target.value)} />
</div>
</div>
{renderSaveStatus()}
</section>
)
})()}
{/* NUGGETS — HOSTS */}
{adminView === 'nuggets:hosts' && (() => {
const hosts = form.nuggets?.hosts ?? []
const activeHosts = hosts.filter(h => !h.archived)
const archivedHosts = hosts.filter(h => h.archived)
const updateHost = (id: string, patch: Partial<PodcastHost>) =>
setForm(f => ({
...f,
nuggets: { ...(f.nuggets ?? DEFAULTS.nuggets), hosts: (f.nuggets?.hosts ?? []).map(h => h.id === id ? { ...h, ...patch } : h) },
}))
return (
<section className="admin-panel-section" aria-label="Nuggets hosts">
<div className="admin-panel-head">
<h2>Nuggets — Hosts</h2>
<p>Hosts shown on the public Nuggets page. Removing a host archives them — they're kept on record, never deleted, and can be restored.</p>
</div>
{activeHosts.length === 0 && (
<p className="admin-stats-note">No active hosts yet. Add one below.</p>
)}
{activeHosts.map(host => (
<AdminCollapsibleCard key={host.id} title={host.name || 'Unnamed host'} subtitle={host.role || 'No role set'}>
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label>Name</label>
<input type="text" value={host.name} onChange={e => updateHost(host.id, { name: e.target.value })} />
</div>
<div className="admin-field">
<label>Role</label>
<input type="text" placeholder="Co-Host" value={host.role} onChange={e => updateHost(host.id, { role: e.target.value })} />
</div>
<div className="admin-field">
<label>Bio</label>
<textarea rows={3} value={host.bio} onChange={e => updateHost(host.id, { bio: e.target.value })} />
</div>
<div className="admin-field">
<label>Photo</label>
<input type="text" placeholder="/uploads/host-photo.jpg" value={host.imageUrl} onChange={e => updateHost(host.id, { imageUrl: e.target.value })} />
{renderImageAssetSelector(host.imageUrl, value => updateHost(host.id, { imageUrl: value }), `nuggets-host-img-${host.id}`)}
{renderImagePreview(host.imageUrl, host.name || 'Host photo')}
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => updateHost(host.id, { archived: true })}>Archive</button>
</div>
</AdminCollapsibleCard>
))}
<button
type="button"
className="btn-admin-add"
onClick={() => setForm(f => ({
...f,
nuggets: {
...(f.nuggets ?? DEFAULTS.nuggets),
hosts: [...(f.nuggets?.hosts ?? []), { id: Date.now().toString(36), name: '', role: '', bio: '', imageUrl: '', archived: false }],
},
}))}
>
+ Add Host
</button>
{archivedHosts.length > 0 && (
<div style={{ marginTop: '2.5rem' }}>
<div className="admin-panel-head">
<h3>Archived Hosts</h3>
<p>Hidden from the public site. Restore to bring a host back.</p>
</div>
{archivedHosts.map(host => (
<div key={host.id} className="admin-array-row" style={{ alignItems: 'center' }}>
<div className="admin-array-fields" style={{ flexDirection: 'row', alignItems: 'center', gap: '1rem' }}>
{host.imageUrl && <img src={host.imageUrl} alt={host.name} style={{ width: 36, height: 36, borderRadius: '50%', objectFit: 'cover' }} />}
<span>{host.name || 'Unnamed host'}</span>
{host.role && <span style={{ color: '#7a7060', fontSize: '0.85rem' }}>{host.role}</span>}
</div>
<button type="button" className="btn-admin-save" onClick={() => updateHost(host.id, { archived: false })}>Restore</button>
</div>
))}
</div>
)}
{renderSaveStatus()}
</section>
)
})()}
{/* STUDY COMMENTS */}
{adminView === 'study-comments' && (() => {
const filtered = studyComments.filter(c => {
+30
View File
@@ -9870,6 +9870,36 @@
margin: 0 0 0.6rem;
}
/* ── Nuggets (sub-show) page ─────────────────────────────────────────────── */
.section-nuggets-intro { padding: 1rem 1.5rem 0; }
.nuggets-intro-layout { display: flex; gap: 2rem; align-items: flex-start; flex-wrap: wrap; }
.nuggets-podcast-art { width: 180px; height: 180px; object-fit: cover; border-radius: 12px; flex-shrink: 0; border: 1px solid rgba(201, 168, 76, 0.18); }
.nuggets-intro-body { flex: 1; min-width: 240px; }
.nuggets-description { color: var(--brand-muted); line-height: 1.6; margin: 0 0 1.25rem; }
.nuggets-platform-links { display: flex; gap: 0.9rem; flex-wrap: wrap; }
.section-nuggets-body { padding: 2.5rem 1.5rem 4rem; }
.nuggets-body-layout { display: grid; grid-template-columns: minmax(240px, 300px) 1fr; gap: 2.5rem; align-items: start; }
.nuggets-body-col { min-width: 0; }
.nuggets-hosts-list { display: flex; flex-direction: column; gap: 1.25rem; margin-top: 1.5rem; }
.nuggets-host-card { display: flex; gap: 1rem; align-items: flex-start; background: rgba(26, 26, 21, 0.9); border: 1px solid rgba(201, 168, 76, 0.18); border-radius: 12px; padding: 1.1rem 1.25rem; }
.nuggets-host-card-img { width: 64px; height: 64px; object-fit: cover; border-radius: 50%; flex-shrink: 0; border: 2px solid rgba(201, 168, 76, 0.3); }
.nuggets-host-card-name { font-family: var(--brand-font-heading); font-size: 1.1rem; color: #e8d9b5; margin: 0; }
.nuggets-host-card-role { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--brand-gold); opacity: 0.85; margin: 0.2rem 0 0; }
.nuggets-host-card-bio { font-size: 0.85rem; color: var(--brand-muted); line-height: 1.5; margin: 0.5rem 0 0; }
.nuggets-episodes-note { color: var(--brand-muted); margin-top: 1.5rem; }
.nuggets-latest-episode { background: rgba(26, 26, 21, 0.9); border: 1px solid rgba(201, 168, 76, 0.25); border-radius: 12px; padding: 1.5rem 1.75rem; margin-top: 1.5rem; }
.nuggets-latest-episode-eyebrow { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--brand-gold); margin: 0 0 0.4rem; }
.nuggets-latest-episode-title { font-family: var(--brand-font-heading); font-size: 1.5rem; color: #e8d9b5; margin: 0 0 0.6rem; }
.nuggets-latest-episode-desc { color: var(--brand-muted); line-height: 1.55; margin: 0 0 1rem; }
.nuggets-episode-list { margin-top: 2rem; }
@media (max-width: 800px) {
.nuggets-body-layout { grid-template-columns: 1fr; }
}
.breadcrumb {
font-size: 0.85rem;
color: var(--brand-muted);
+125 -1
View File
@@ -383,7 +383,7 @@ function SiteHeader({ content }: { content: SiteContent }) {
const studiesDropdownRef = useRef<HTMLDivElement>(null)
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
const location = useLocation()
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished')
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished') || location.pathname.startsWith('/nuggets')
const studiesActive = location.pathname.startsWith('/study') || location.pathname === '/scripture'
useEffect(() => {
@@ -427,6 +427,7 @@ function SiteHeader({ content }: { content: SiteContent }) {
<div className="nav-dropdown-menu-inner">
<NavLink to="/episodes" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series</NavLink>
<NavLink to="/finished" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books</NavLink>
<NavLink to="/nuggets" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Nuggets</NavLink>
</div>
</div>
</div>
@@ -1863,6 +1864,128 @@ function EpisodesPage({ content }: { content: SiteContent }) {
)
}
interface NuggetsEpisode {
title: string
pubDate: string
link: string
audioUrl: string
description: string
duration: string
episode: string
season: string
}
function NuggetsPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
const nuggets = content.nuggets ?? DEFAULTS.nuggets
usePageMeta(
`${nuggets.title} | ${siteTitle}`,
nuggets.description || nuggets.tagline || `${nuggets.title} — a companion show from ${siteTitle}.`,
)
const [episodes, setEpisodes] = useState<NuggetsEpisode[]>([])
const [loading, setLoading] = useState(true)
const [configured, setConfigured] = useState(true)
useEffect(() => {
fetch('/api/nuggets/episodes/all')
.then(r => r.json())
.then((d: { episodes?: NuggetsEpisode[]; configured?: boolean }) => {
setEpisodes(Array.isArray(d.episodes) ? d.episodes : [])
setConfigured(d.configured !== false)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const [latest, ...rest] = episodes
const activeHosts = (nuggets.hosts ?? []).filter(h => !h.archived)
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Sub-Show" title={nuggets.title} desc={nuggets.tagline} />
{(nuggets.podcastArtUrl || nuggets.description || nuggets.spotifyUrl || nuggets.appleUrl) && (
<section className="section-nuggets-intro" aria-label={`About ${nuggets.title}`}>
<div className="section-inner nuggets-intro-layout">
{nuggets.podcastArtUrl && <img src={nuggets.podcastArtUrl} alt={nuggets.title} className="nuggets-podcast-art" />}
<div className="nuggets-intro-body">
{nuggets.description && <p className="nuggets-description">{nuggets.description}</p>}
{(nuggets.spotifyUrl || nuggets.appleUrl) && (
<div className="nuggets-platform-links">
{nuggets.spotifyUrl && <a href={nuggets.spotifyUrl} target="_blank" rel="noopener noreferrer" className="btn-primary">Listen on Spotify</a>}
{nuggets.appleUrl && <a href={nuggets.appleUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary">Listen on Apple Podcasts</a>}
</div>
)}
</div>
</div>
</section>
)}
<section className="section-nuggets-body" aria-label={`${nuggets.title} hosts and episodes`}>
<div className="section-inner nuggets-body-layout">
<div className="nuggets-body-col nuggets-body-hosts">
{activeHosts.length > 0 && (
<>
<p className="eyebrow">Meet the Hosts</p>
<div className="nuggets-hosts-list">
{activeHosts.map(host => (
<div key={host.id} className="nuggets-host-card">
{host.imageUrl && <img src={host.imageUrl} alt={host.name} className="nuggets-host-card-img" loading="lazy" />}
<h3 className="nuggets-host-card-name">{host.name}</h3>
{host.role && <p className="nuggets-host-card-role">{host.role}</p>}
{host.bio && <p className="nuggets-host-card-bio">{host.bio}</p>}
</div>
))}
</div>
</>
)}
</div>
<div className="nuggets-body-col nuggets-body-episodes">
<p className="eyebrow">Episodes</p>
{loading ? (
<p className="nuggets-episodes-note">Loading episodes…</p>
) : !configured ? (
<p className="nuggets-episodes-note">Episodes are coming soon — check back once the show launches.</p>
) : episodes.length === 0 ? (
<p className="nuggets-episodes-note">No episodes published yet.</p>
) : (
<>
<div className="nuggets-latest-episode">
<p className="nuggets-latest-episode-eyebrow">Latest Episode</p>
<h2 className="nuggets-latest-episode-title">{latest.title}</h2>
{latest.description && <p className="nuggets-latest-episode-desc">{latest.description}</p>}
{latest.audioUrl && <EpisodeAudioPlayer src={latest.audioUrl} title={latest.title} size="full" />}
</div>
{rest.length > 0 && (
<ol className="finished-series-playlist nuggets-episode-list">
{rest.map((ep, i) => (
<li key={ep.audioUrl || i} className="finished-series-episode">
<span className="finished-series-ep-num">{ep.episode || i + 2}</span>
<div className="finished-series-ep-body">
<p className="finished-series-ep-title">{ep.title}</p>
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
{ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
</div>
</li>
))}
</ol>
)}
</>
)}
</div>
</div>
</section>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
function BooksAndResourcesSection({ content }: { content: SiteContent }) {
const books = (content.customLinks ?? []).filter(link => link.placement === 'books')
const recommended = (content.customLinks ?? []).filter(link => link.placement === 'recommended')
@@ -3001,6 +3124,7 @@ export default function App() {
<Route path="/finished/:id" element={<FinishedSeriesPage content={content} />} />
<Route path="/episodes" element={<EpisodesPage content={content} />} />
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
<Route path="/nuggets" element={<NuggetsPage content={content} />} />
<Route path="/resources" element={<ResourcesPage content={content} />} />
<Route path="/downloads/:id" element={<DownloadDetailPage content={content} />} />
<Route path="/about" element={<AboutPage content={content} />} />
+34
View File
@@ -93,6 +93,28 @@ export interface FinishedBook {
season: number
}
export interface PodcastHost {
id: string
name: string
role: string
bio: string
imageUrl: string
// Removed hosts are archived, never deleted — kept out of public display but
// preserved so past-episode credits and history stay intact.
archived: boolean
}
export interface NuggetsShow {
title: string
tagline: string
description: string
podcastArtUrl: string
rssFeedUrl: string
spotifyUrl: string
appleUrl: string
hosts: PodcastHost[]
}
export interface SeoSettings {
title: string
description: string
@@ -298,6 +320,8 @@ export interface SiteContent {
finishedBooks: FinishedBook[]
// ── Testimonials ──
testimonials: Testimonial[]
// ── Nuggets (sub-show) ──
nuggets: NuggetsShow
}
export const DEFAULTS: SiteContent = {
@@ -525,6 +549,16 @@ export const DEFAULTS: SiteContent = {
podcastFeaturedLinks: [],
finishedBooks: [],
testimonials: [],
nuggets: {
title: 'Verse By Verse: Nuggets',
tagline: '',
description: '',
podcastArtUrl: '',
rssFeedUrl: '',
spotifyUrl: '',
appleUrl: '',
hosts: [],
},
episodesSeoIntro: 'Verse by Verse with Nate is an expository Bible teaching podcast where we go through Scripture one verse at a time. Each episode digs into the text carefully and practically, helping you understand what the Bible says, what it means, and how to live it out. New episodes released regularly — subscribe on Spotify or your favorite podcast platform.',
seo: {
title: 'Verse by Verse with Nate',
+1
View File
@@ -55,6 +55,7 @@ export default defineConfig({
server: {
proxy: {
'/api': 'http://localhost:4173',
'/uploads': 'http://localhost:4173',
},
},
})