Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a4e011d0e |
@@ -32,3 +32,10 @@ data/contact-submissions.json
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# wrangler files
|
||||||
|
.wrangler
|
||||||
|
.dev.vars*
|
||||||
|
!.dev.vars.example
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|||||||
@@ -17,28 +17,17 @@
|
|||||||
"aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.",
|
"aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.",
|
||||||
"aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
|
"aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
|
||||||
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
||||||
"aboutPhotoUrl": "/images/nate-photo.jpeg",
|
|
||||||
"aboutVerseArtUrl": "/images/hebrews-4-12-verse-art.png",
|
|
||||||
"contactPhotoUrl": "/images/nate-contact-photo.png",
|
|
||||||
"seriesLabel": "Now Playing",
|
"seriesLabel": "Now Playing",
|
||||||
"seriesTitle": "Study of Titus: Sound Doctrine",
|
"seriesTitle": "Study of Titus: Sound Doctrine",
|
||||||
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
||||||
"seriesImageUrl": "/uploads/titus-series-1777568297943.png",
|
"seriesImageUrl": "/images/titus-cover.png",
|
||||||
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
|
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
|
||||||
"studyGuideTitle": "Companion Study Guide",
|
"studyGuideTitle": "Companion Study Guide",
|
||||||
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
|
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
|
||||||
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
|
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
|
||||||
"shareHeading": "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.",
|
||||||
"customLinks": [
|
"customLinks": [],
|
||||||
{
|
|
||||||
"id": "molso9ow",
|
|
||||||
"label": "test",
|
|
||||||
"url": "/uploads/discussion_questions_post-1777565348551.png",
|
|
||||||
"imageUrl": "/uploads/discussion_questions_post-1777565348551.png",
|
|
||||||
"placement": "resources"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"customBlocks": [],
|
"customBlocks": [],
|
||||||
"archivedSeries": [],
|
"archivedSeries": [],
|
||||||
"redirects": [
|
"redirects": [
|
||||||
@@ -108,5 +97,5 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"updatedAt": "2026-04-30T18:06:00.013Z"
|
"updatedAt": "2026-04-27T20:24:22.589Z"
|
||||||
}
|
}
|
||||||
+3
-14
@@ -17,28 +17,17 @@
|
|||||||
"aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.",
|
"aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.",
|
||||||
"aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
|
"aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
|
||||||
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
||||||
"aboutPhotoUrl": "/images/nate-photo.jpeg",
|
|
||||||
"aboutVerseArtUrl": "/images/hebrews-4-12-verse-art.png",
|
|
||||||
"contactPhotoUrl": "/images/nate-contact-photo.png",
|
|
||||||
"seriesLabel": "Now Playing",
|
"seriesLabel": "Now Playing",
|
||||||
"seriesTitle": "Study of Titus: Sound Doctrine",
|
"seriesTitle": "Study of Titus: Sound Doctrine",
|
||||||
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
||||||
"seriesImageUrl": "/uploads/titus-series-1777568297943.png",
|
"seriesImageUrl": "/images/titus-cover.png",
|
||||||
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
|
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
|
||||||
"studyGuideTitle": "Companion Study Guide",
|
"studyGuideTitle": "Companion Study Guide",
|
||||||
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
|
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
|
||||||
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
|
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
|
||||||
"shareHeading": "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.",
|
||||||
"customLinks": [
|
"customLinks": [],
|
||||||
{
|
|
||||||
"id": "molso9ow",
|
|
||||||
"label": "test",
|
|
||||||
"url": "/uploads/discussion_questions_post-1777565348551.png",
|
|
||||||
"imageUrl": "/uploads/discussion_questions_post-1777565348551.png",
|
|
||||||
"placement": "resources"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"customBlocks": [],
|
"customBlocks": [],
|
||||||
"archivedSeries": [],
|
"archivedSeries": [],
|
||||||
"redirects": [
|
"redirects": [
|
||||||
@@ -108,5 +97,5 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"updatedAt": "2026-04-30T18:06:00.017Z"
|
"updatedAt": "2026-04-27T20:24:19.789Z"
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB |
Generated
+1435
-1
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -10,8 +10,9 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"start": "node --env-file=.env server.js",
|
"start": "node --env-file=.env server.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "npm run build && wrangler dev",
|
||||||
"chatbot:eval": "node scripts/evaluate-chatbot.mjs"
|
"chatbot:eval": "node scripts/evaluate-chatbot.mjs",
|
||||||
|
"deploy": "npm run build && wrangler deploy"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"resend": "^6.10.0"
|
"resend": "^6.10.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@cloudflare/vite-plugin": "^1.35.0",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
@@ -35,6 +37,7 @@
|
|||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.56.1",
|
"typescript-eslint": "^8.56.1",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0",
|
||||||
|
"wrangler": "^4.87.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,31 +4,23 @@ import { createHash, randomUUID } from 'node:crypto'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { Resend } from 'resend'
|
import { Resend } from 'resend'
|
||||||
import {
|
|
||||||
sanitizeSiteContent,
|
function escapeHtml(value) {
|
||||||
escapeHtml,
|
return String(value)
|
||||||
escapeXml,
|
.replace(/&/g, '&')
|
||||||
buildAbsoluteUrl,
|
.replace(/</g, '<')
|
||||||
injectSeoIntoHtml,
|
.replace(/>/g, '>')
|
||||||
normalizeAssetBaseName,
|
.replace(/"/g, '"')
|
||||||
inferImageExtensionFromDataUrl,
|
.replace(/'/g, ''')
|
||||||
getClientIp,
|
}
|
||||||
hasVisitorConsent,
|
|
||||||
setConsentCookie,
|
function splitName(fullName) {
|
||||||
splitName,
|
const parts = fullName.trim().split(/\s+/).filter(Boolean)
|
||||||
parseCookies,
|
return {
|
||||||
} from './server/helpers.js'
|
firstName: parts[0] ?? '',
|
||||||
import {
|
lastName: parts.slice(1).join(' '),
|
||||||
isAdminPasswordConfigured,
|
}
|
||||||
isValidAdminSession,
|
}
|
||||||
requireAdminAuth,
|
|
||||||
setAdminSessionCookie,
|
|
||||||
clearAdminSessionCookie,
|
|
||||||
createAdminSession,
|
|
||||||
deleteAdminSession,
|
|
||||||
validateAdminPasswordSetup,
|
|
||||||
isAdminPasswordValid,
|
|
||||||
} from './server/auth.js'
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
@@ -39,16 +31,13 @@ const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json')
|
|||||||
const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json')
|
const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json')
|
||||||
const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json')
|
const CONTACT_SUBMISSIONS_FILE = path.join(DATA_DIR, 'contact-submissions.json')
|
||||||
const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json')
|
const QUESTIONS_FILE = path.join(DATA_DIR, 'questions.json')
|
||||||
const DRAFT_QUESTIONS_FILE = path.join(DATA_DIR, 'questions-draft.json')
|
|
||||||
const CHATBOT_FILE = path.join(DATA_DIR, 'chatbot-content.json')
|
const CHATBOT_FILE = path.join(DATA_DIR, 'chatbot-content.json')
|
||||||
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
|
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
|
||||||
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
||||||
const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
|
|
||||||
const DIST_DIR = path.join(__dirname, 'dist')
|
const DIST_DIR = path.join(__dirname, 'dist')
|
||||||
const INDEX_FILE = path.join(DIST_DIR, 'index.html')
|
const INDEX_FILE = path.join(DIST_DIR, 'index.html')
|
||||||
const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images')
|
const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images')
|
||||||
const PUBLIC_IMAGES_DIR = path.join(__dirname, 'public', 'images')
|
const PUBLIC_IMAGES_DIR = path.join(__dirname, 'public', 'images')
|
||||||
validateAdminPasswordSetup()
|
|
||||||
const TITUS_STUDY_FILE = process.env.TITUS_STUDY_FILE
|
const TITUS_STUDY_FILE = process.env.TITUS_STUDY_FILE
|
||||||
? path.resolve(__dirname, process.env.TITUS_STUDY_FILE)
|
? path.resolve(__dirname, process.env.TITUS_STUDY_FILE)
|
||||||
: path.join(__dirname, 'A_Study_of_Titus.pdf')
|
: path.join(__dirname, 'A_Study_of_Titus.pdf')
|
||||||
@@ -73,50 +62,6 @@ const DEFAULT_REDIRECT_RULES = [
|
|||||||
statusCode: 301,
|
statusCode: 301,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function normalizeRedirectPath(value) {
|
|
||||||
if (typeof value !== 'string') return ''
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!trimmed) return ''
|
|
||||||
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
|
||||||
const normalized = withSlash.replace(/\/+/g, '/')
|
|
||||||
if (normalized === '/') return ''
|
|
||||||
if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return ''
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeUrl(value) {
|
|
||||||
if (typeof value !== 'string') return ''
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!trimmed) return ''
|
|
||||||
if (trimmed.startsWith('/')) return trimmed
|
|
||||||
if (/^https?:\/\//i.test(trimmed)) return trimmed
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeRedirectRules(value) {
|
|
||||||
const source = Array.isArray(value) ? value : []
|
|
||||||
const seen = new Set()
|
|
||||||
const out = []
|
|
||||||
|
|
||||||
for (const item of source) {
|
|
||||||
const pathValue = normalizeRedirectPath(item?.path)
|
|
||||||
const target = sanitizeUrl(item?.target)
|
|
||||||
const statusCode = Number(item?.statusCode) === 302 ? 302 : 301
|
|
||||||
if (!pathValue || !target) continue
|
|
||||||
if (seen.has(pathValue)) continue
|
|
||||||
seen.add(pathValue)
|
|
||||||
out.push({
|
|
||||||
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
|
||||||
path: pathValue,
|
|
||||||
target,
|
|
||||||
statusCode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.length > 0 ? out : DEFAULT_REDIRECT_RULES
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SEO = {
|
const DEFAULT_SEO = {
|
||||||
title: 'Verse by Verse with Nate',
|
title: 'Verse by Verse with Nate',
|
||||||
description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.',
|
description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.',
|
||||||
@@ -150,8 +95,130 @@ const DEFAULT_PUBLISH_STATE = {
|
|||||||
let cachedSiteContent = null
|
let cachedSiteContent = null
|
||||||
let cachedDraftSiteContent = null
|
let cachedDraftSiteContent = null
|
||||||
let publishState = { ...DEFAULT_PUBLISH_STATE }
|
let publishState = { ...DEFAULT_PUBLISH_STATE }
|
||||||
let draftQuestions = null
|
|
||||||
let draftQuestionsWritePromise = Promise.resolve()
|
function sanitizeUrl(value) {
|
||||||
|
if (typeof value !== 'string') return ''
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
if (trimmed.startsWith('/')) return trimmed
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) return trimmed
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRedirectPath(value) {
|
||||||
|
if (typeof value !== 'string') return ''
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
||||||
|
const normalized = withSlash.replace(/\/+/g, '/')
|
||||||
|
if (normalized === '/') return ''
|
||||||
|
if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return ''
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSitemapPath(value) {
|
||||||
|
if (typeof value !== 'string') return ''
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
if (trimmed === '/') return '/'
|
||||||
|
return normalizeRedirectPath(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRedirectRules(value) {
|
||||||
|
const source = Array.isArray(value) ? value : []
|
||||||
|
const seen = new Set()
|
||||||
|
const out = []
|
||||||
|
|
||||||
|
for (const item of source) {
|
||||||
|
const pathValue = normalizeRedirectPath(item?.path)
|
||||||
|
const target = sanitizeUrl(item?.target)
|
||||||
|
const statusCode = Number(item?.statusCode) === 302 ? 302 : 301
|
||||||
|
if (!pathValue || !target) continue
|
||||||
|
if (seen.has(pathValue)) continue
|
||||||
|
seen.add(pathValue)
|
||||||
|
out.push({
|
||||||
|
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||||
|
path: pathValue,
|
||||||
|
target,
|
||||||
|
statusCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.length > 0 ? out : DEFAULT_REDIRECT_RULES
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFeaturedLinks(value) {
|
||||||
|
const source = Array.isArray(value) ? value : []
|
||||||
|
return source
|
||||||
|
.filter(item => item && typeof item === 'object')
|
||||||
|
.map(item => {
|
||||||
|
const discussionQuestions = Array.isArray(item.discussionQuestions)
|
||||||
|
? item.discussionQuestions
|
||||||
|
.filter(question => typeof question === 'string')
|
||||||
|
.map(question => question.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 30)
|
||||||
|
: []
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||||
|
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
|
||||||
|
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
|
||||||
|
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
|
||||||
|
url: sanitizeUrl(item.url),
|
||||||
|
embedUrl: sanitizeUrl(item.embedUrl),
|
||||||
|
showNotes: typeof item.showNotes === 'string' ? item.showNotes.trim().slice(0, 10000) : '',
|
||||||
|
discussionQuestions,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(item => item.title || item.summary || item.url || item.embedUrl || item.showNotes || item.discussionQuestions.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSiteContent(siteContent) {
|
||||||
|
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {}
|
||||||
|
|
||||||
|
const seo = siteContent.seo && typeof siteContent.seo === 'object' ? siteContent.seo : {}
|
||||||
|
const legal = siteContent.legal && typeof siteContent.legal === 'object' ? siteContent.legal : {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...siteContent,
|
||||||
|
redirects: sanitizeRedirectRules(siteContent.redirects),
|
||||||
|
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
|
||||||
|
seo: {
|
||||||
|
title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title,
|
||||||
|
description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description,
|
||||||
|
ogTitle: typeof seo.ogTitle === 'string' && seo.ogTitle.trim() ? seo.ogTitle.trim().slice(0, 120) : DEFAULT_SEO.ogTitle,
|
||||||
|
ogDescription: typeof seo.ogDescription === 'string' && seo.ogDescription.trim() ? seo.ogDescription.trim().slice(0, 240) : DEFAULT_SEO.ogDescription,
|
||||||
|
ogImage: sanitizeUrl(seo.ogImage) || DEFAULT_SEO.ogImage,
|
||||||
|
canonicalUrl: sanitizeUrl(seo.canonicalUrl) || DEFAULT_SEO.canonicalUrl,
|
||||||
|
robotsPolicy: typeof seo.robotsPolicy === 'string' && seo.robotsPolicy.trim() ? seo.robotsPolicy.trim() : DEFAULT_SEO.robotsPolicy,
|
||||||
|
sitemapPaths: Array.isArray(seo.sitemapPaths)
|
||||||
|
? seo.sitemapPaths
|
||||||
|
.map(pathItem => normalizeSitemapPath(pathItem))
|
||||||
|
.filter(Boolean)
|
||||||
|
: [...DEFAULT_SEO.sitemapPaths],
|
||||||
|
},
|
||||||
|
legal: {
|
||||||
|
privacyTitle: typeof legal.privacyTitle === 'string' && legal.privacyTitle.trim() ? legal.privacyTitle.trim().slice(0, 120) : DEFAULT_LEGAL.privacyTitle,
|
||||||
|
privacyBody: Array.isArray(legal.privacyBody) && legal.privacyBody.length > 0
|
||||||
|
? legal.privacyBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
|
||||||
|
: [...DEFAULT_LEGAL.privacyBody],
|
||||||
|
termsTitle: typeof legal.termsTitle === 'string' && legal.termsTitle.trim() ? legal.termsTitle.trim().slice(0, 120) : DEFAULT_LEGAL.termsTitle,
|
||||||
|
termsBody: Array.isArray(legal.termsBody) && legal.termsBody.length > 0
|
||||||
|
? legal.termsBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
|
||||||
|
: [...DEFAULT_LEGAL.termsBody],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSiteContentFile(filePath) {
|
async function loadSiteContentFile(filePath) {
|
||||||
const raw = await readFile(filePath, 'utf8')
|
const raw = await readFile(filePath, 'utf8')
|
||||||
@@ -185,50 +252,61 @@ async function refreshContentCaches() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDraftQuestionsFromDisk() {
|
function buildAbsoluteUrl(baseUrl, maybeRelativePath) {
|
||||||
return readFile(DRAFT_QUESTIONS_FILE, 'utf8')
|
const safeBase = typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : DEFAULT_SEO.canonicalUrl
|
||||||
.then(raw => {
|
const root = safeBase.endsWith('/') ? safeBase.slice(0, -1) : safeBase
|
||||||
const parsed = JSON.parse(raw)
|
if (typeof maybeRelativePath !== 'string' || !maybeRelativePath.trim()) return root
|
||||||
if (Array.isArray(parsed)) {
|
const value = maybeRelativePath.trim()
|
||||||
draftQuestions = parsed.slice(0, MAX_QUESTIONS)
|
if (/^https?:\/\//i.test(value)) return value
|
||||||
} else if (Array.isArray(parsed?.questions)) {
|
if (value.startsWith('/')) return `${root}${value}`
|
||||||
draftQuestions = parsed.questions.slice(0, MAX_QUESTIONS)
|
return `${root}/${value}`
|
||||||
} else {
|
|
||||||
draftQuestions = null
|
|
||||||
}
|
|
||||||
if (typeof parsed?.updatedAt === 'string') {
|
|
||||||
publishState.draftUpdatedAt = parsed.updatedAt
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
draftQuestions = null
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDraftQuestions() {
|
function injectSeoIntoHtml(html, siteContent) {
|
||||||
if (draftQuestions !== null) return
|
const seo = siteContent?.seo ?? DEFAULT_SEO
|
||||||
draftQuestions = questions.slice(0, MAX_QUESTIONS)
|
const title = seo.title || DEFAULT_SEO.title
|
||||||
|
const description = seo.description || DEFAULT_SEO.description
|
||||||
|
const ogTitle = seo.ogTitle || title
|
||||||
|
const ogDescription = seo.ogDescription || description
|
||||||
|
const canonical = buildAbsoluteUrl(seo.canonicalUrl || DEFAULT_SEO.canonicalUrl, '/')
|
||||||
|
const ogImage = buildAbsoluteUrl(canonical, seo.ogImage || DEFAULT_SEO.ogImage)
|
||||||
|
const robots = seo.robotsPolicy || DEFAULT_SEO.robotsPolicy
|
||||||
|
|
||||||
|
return html
|
||||||
|
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}</title>`)
|
||||||
|
.replace(/<meta name="description" content="[^"]*"\s*\/>/i, `<meta name="description" content="${escapeHtml(description)}" />`)
|
||||||
|
.replace(/<meta name="robots" content="[^"]*"\s*\/>/i, `<meta name="robots" content="${escapeHtml(robots)}" />`)
|
||||||
|
.replace(/<meta property="og:title" content="[^"]*"\s*\/>/i, `<meta property="og:title" content="${escapeHtml(ogTitle)}" />`)
|
||||||
|
.replace(/<meta property="og:description" content="[^"]*"\s*\/>/i, `<meta property="og:description" content="${escapeHtml(ogDescription)}" />`)
|
||||||
|
.replace(/<meta property="og:image" content="[^"]*"\s*\/>/i, `<meta property="og:image" content="${escapeHtml(ogImage)}" />`)
|
||||||
|
.replace(/<meta property="og:image:secure_url" content="[^"]*"\s*\/>/i, `<meta property="og:image:secure_url" content="${escapeHtml(ogImage)}" />`)
|
||||||
|
.replace(/<meta property="og:url" content="[^"]*"\s*\/>/i, `<meta property="og:url" content="${escapeHtml(canonical)}" />`)
|
||||||
|
.replace(/<link rel="canonical" href="[^"]*"\s*\/>/i, `<link rel="canonical" href="${escapeHtml(canonical)}" />`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readUploadsMetadata() {
|
function normalizeAssetBaseName(name) {
|
||||||
try {
|
if (typeof name !== 'string') return `upload-${Date.now()}`
|
||||||
const raw = await readFile(UPLOADS_META_FILE, 'utf8')
|
const cleaned = name
|
||||||
return JSON.parse(raw)
|
.toLowerCase()
|
||||||
} catch {
|
.replace(/[^a-z0-9._-]+/g, '-')
|
||||||
return {}
|
.replace(/-+/g, '-')
|
||||||
}
|
.replace(/^-|-$/g, '')
|
||||||
|
return cleaned || `upload-${Date.now()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeUploadsMetadata(metadata) {
|
function inferImageExtensionFromDataUrl(dataUrl) {
|
||||||
await mkdir(DATA_DIR, { recursive: true })
|
if (typeof dataUrl !== 'string') return null
|
||||||
await writeFile(UPLOADS_META_FILE, JSON.stringify(metadata, null, 2), 'utf8')
|
if (dataUrl.startsWith('data:image/png;base64,')) return '.png'
|
||||||
|
if (dataUrl.startsWith('data:image/jpeg;base64,')) return '.jpg'
|
||||||
|
if (dataUrl.startsWith('data:image/webp;base64,')) return '.webp'
|
||||||
|
if (dataUrl.startsWith('data:image/gif;base64,')) return '.gif'
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listUploadedAssets() {
|
async function listUploadedAssets() {
|
||||||
await mkdir(UPLOADS_DIR, { recursive: true })
|
await mkdir(UPLOADS_DIR, { recursive: true })
|
||||||
const files = await readdir(UPLOADS_DIR)
|
const files = await readdir(UPLOADS_DIR)
|
||||||
const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif)$/i.test(name)).sort()
|
const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif)$/i.test(name)).sort()
|
||||||
const metadata = await readUploadsMetadata()
|
|
||||||
|
|
||||||
const withStats = await Promise.all(imageFiles.map(async filename => {
|
const withStats = await Promise.all(imageFiles.map(async filename => {
|
||||||
const info = await stat(path.join(UPLOADS_DIR, filename))
|
const info = await stat(path.join(UPLOADS_DIR, filename))
|
||||||
@@ -237,7 +315,6 @@ async function listUploadedAssets() {
|
|||||||
url: `/uploads/${filename}`,
|
url: `/uploads/${filename}`,
|
||||||
sizeBytes: info.size,
|
sizeBytes: info.size,
|
||||||
updatedAt: info.mtime.toISOString(),
|
updatedAt: info.mtime.toISOString(),
|
||||||
tags: Array.isArray(metadata[filename]) ? metadata[filename].filter(tag => typeof tag === 'string') : [],
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -285,11 +362,13 @@ let hitStatsWritePromise = Promise.resolve()
|
|||||||
|
|
||||||
const VISITOR_COOKIE = 'vbn_vid'
|
const VISITOR_COOKIE = 'vbn_vid'
|
||||||
const CONSENT_COOKIE = 'vbn_analytics_consent'
|
const CONSENT_COOKIE = 'vbn_analytics_consent'
|
||||||
|
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
|
||||||
const MAX_RECENT_VISITS = 1000
|
const MAX_RECENT_VISITS = 1000
|
||||||
const VISITOR_RETENTION_DAYS_DEFAULT = 180
|
const VISITOR_RETENTION_DAYS_DEFAULT = 180
|
||||||
const BACKUP_RETENTION_DAYS = 30
|
const BACKUP_RETENTION_DAYS = 30
|
||||||
const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000
|
const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||||
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'change-me-admin-password'
|
||||||
|
|
||||||
const EMPTY_VISITOR_STATS = {
|
const EMPTY_VISITOR_STATS = {
|
||||||
totalVisits: 0,
|
totalVisits: 0,
|
||||||
@@ -318,6 +397,47 @@ let lastHitStatsWrite = { ok: true, at: null, error: null }
|
|||||||
let lastBackupStatus = { ok: true, at: null, error: null, file: null }
|
let lastBackupStatus = { ok: true, at: null, error: null, file: null }
|
||||||
let lastCachePurgeStatus = { ok: true, at: null, error: null }
|
let lastCachePurgeStatus = { ok: true, at: null, error: null }
|
||||||
let lastDeployHookStatus = { ok: true, at: null, error: null }
|
let lastDeployHookStatus = { ok: true, at: null, error: null }
|
||||||
|
const adminSessions = new Map()
|
||||||
|
|
||||||
|
function sha256(value) {
|
||||||
|
return createHash('sha256').update(value).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdminPasswordConfigured() {
|
||||||
|
return ADMIN_PASSWORD !== 'change-me-admin-password'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidAdminSession(req) {
|
||||||
|
const cookies = parseCookies(req.headers.cookie)
|
||||||
|
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
|
||||||
|
if (!sessionToken) return false
|
||||||
|
|
||||||
|
const expiresAt = adminSessions.get(sessionToken)
|
||||||
|
if (!expiresAt) return false
|
||||||
|
if (expiresAt <= Date.now()) {
|
||||||
|
adminSessions.delete(sessionToken)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAdminSessionCookie(res, token) {
|
||||||
|
res.append('Set-Cookie', `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAdminSessionCookie(res) {
|
||||||
|
res.append('Set-Cookie', `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAdminAuth(req, res, next) {
|
||||||
|
if (!isValidAdminSession(req)) {
|
||||||
|
res.status(401).json({ message: 'Unauthorized' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeIp(rawIp) {
|
function normalizeIp(rawIp) {
|
||||||
if (!rawIp) return 'unknown'
|
if (!rawIp) return 'unknown'
|
||||||
@@ -339,6 +459,45 @@ function normalizeIp(rawIp) {
|
|||||||
return ip || 'unknown'
|
return ip || 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getClientIp(req) {
|
||||||
|
const forwarded = req.headers['x-forwarded-for']
|
||||||
|
if (forwarded) {
|
||||||
|
return normalizeIp(forwarded)
|
||||||
|
}
|
||||||
|
return normalizeIp(req.ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookies(cookieHeader) {
|
||||||
|
if (!cookieHeader) return {}
|
||||||
|
|
||||||
|
return cookieHeader
|
||||||
|
.split(';')
|
||||||
|
.map(v => v.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.reduce((acc, part) => {
|
||||||
|
const idx = part.indexOf('=')
|
||||||
|
if (idx === -1) return acc
|
||||||
|
const key = part.slice(0, idx).trim()
|
||||||
|
const value = part.slice(idx + 1).trim()
|
||||||
|
try {
|
||||||
|
acc[key] = decodeURIComponent(value)
|
||||||
|
} catch {
|
||||||
|
acc[key] = value
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasVisitorConsent(req) {
|
||||||
|
const cookies = parseCookies(req.headers.cookie)
|
||||||
|
return cookies[CONSENT_COOKIE] === 'yes'
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConsentCookie(res, consent) {
|
||||||
|
const value = consent ? 'yes' : 'no'
|
||||||
|
res.append('Set-Cookie', `${CONSENT_COOKIE}=${value}; Max-Age=31536000; Path=/; SameSite=Lax`)
|
||||||
|
}
|
||||||
|
|
||||||
function isPrivateOrLocalIp(ip) {
|
function isPrivateOrLocalIp(ip) {
|
||||||
return (
|
return (
|
||||||
ip === '127.0.0.1'
|
ip === '127.0.0.1'
|
||||||
@@ -1055,17 +1214,6 @@ app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => {
|
|||||||
|
|
||||||
cachedSiteContent = source.siteContent
|
cachedSiteContent = source.siteContent
|
||||||
publishState.publishedAt = publishedAt
|
publishState.publishedAt = publishedAt
|
||||||
|
|
||||||
if (draftQuestions !== null) {
|
|
||||||
questions = draftQuestions.slice(0, MAX_QUESTIONS)
|
|
||||||
await mkdir(DATA_DIR, { recursive: true })
|
|
||||||
await writeFile(
|
|
||||||
QUESTIONS_FILE,
|
|
||||||
JSON.stringify({ questions, updatedAt: publishedAt }, null, 2),
|
|
||||||
'utf8',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await createBackupSnapshot('post-publish')
|
await createBackupSnapshot('post-publish')
|
||||||
|
|
||||||
res.json({ ok: true, publishedAt })
|
res.json({ ok: true, publishedAt })
|
||||||
@@ -1106,9 +1254,6 @@ app.post('/api/admin-assets', requireAdminAuth, async (req, res) => {
|
|||||||
|
|
||||||
await mkdir(UPLOADS_DIR, { recursive: true })
|
await mkdir(UPLOADS_DIR, { recursive: true })
|
||||||
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
|
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
|
||||||
const metadata = await readUploadsMetadata()
|
|
||||||
metadata[finalName] = []
|
|
||||||
await writeUploadsMetadata(metadata)
|
|
||||||
|
|
||||||
res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } })
|
res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1116,31 +1261,6 @@ app.post('/api/admin-assets', requireAdminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { filename } = req.params
|
|
||||||
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
|
|
||||||
res.status(400).json({ message: 'Invalid filename.' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const tags = Array.isArray(req.body?.tags)
|
|
||||||
? req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean)
|
|
||||||
: []
|
|
||||||
|
|
||||||
const filePath = path.join(UPLOADS_DIR, filename)
|
|
||||||
await stat(filePath)
|
|
||||||
|
|
||||||
const metadata = await readUploadsMetadata()
|
|
||||||
metadata[filename] = tags
|
|
||||||
await writeUploadsMetadata(metadata)
|
|
||||||
|
|
||||||
res.json({ ok: true, tags })
|
|
||||||
} catch {
|
|
||||||
res.status(404).json({ message: 'Asset not found.' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
|
app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { filename } = req.params
|
const { filename } = req.params
|
||||||
@@ -1150,9 +1270,6 @@ app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
await unlink(path.join(UPLOADS_DIR, filename))
|
await unlink(path.join(UPLOADS_DIR, filename))
|
||||||
const metadata = await readUploadsMetadata()
|
|
||||||
delete metadata[filename]
|
|
||||||
await writeUploadsMetadata(metadata)
|
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
} catch {
|
} catch {
|
||||||
res.status(404).json({ message: 'Asset not found.' })
|
res.status(404).json({ message: 'Asset not found.' })
|
||||||
@@ -1298,23 +1415,6 @@ function queueQuestionsWrite() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueDraftQuestionsWrite() {
|
|
||||||
if (draftQuestions === null) return
|
|
||||||
draftQuestionsWritePromise = draftQuestionsWritePromise
|
|
||||||
.then(async () => {
|
|
||||||
await mkdir(DATA_DIR, { recursive: true })
|
|
||||||
await writeFile(
|
|
||||||
DRAFT_QUESTIONS_FILE,
|
|
||||||
JSON.stringify({ questions: draftQuestions, updatedAt: new Date().toISOString() }, null, 2),
|
|
||||||
'utf8',
|
|
||||||
)
|
|
||||||
publishState.draftUpdatedAt = new Date().toISOString()
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('[draft-questions] failed to write draft questions:', err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadQuestionsFromDisk() {
|
function loadQuestionsFromDisk() {
|
||||||
return readFile(QUESTIONS_FILE, 'utf8')
|
return readFile(QUESTIONS_FILE, 'utf8')
|
||||||
.then(raw => {
|
.then(raw => {
|
||||||
@@ -1346,12 +1446,13 @@ app.post('/api/admin-auth/login', (req, res) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAdminPasswordValid(password)) {
|
if (sha256(password) !== sha256(ADMIN_PASSWORD)) {
|
||||||
res.status(401).json({ message: 'Invalid password.' })
|
res.status(401).json({ message: 'Invalid password.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionToken = createAdminSession()
|
const sessionToken = randomUUID()
|
||||||
|
adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS)
|
||||||
setAdminSessionCookie(res, sessionToken)
|
setAdminSessionCookie(res, sessionToken)
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
@@ -1359,7 +1460,9 @@ app.post('/api/admin-auth/login', (req, res) => {
|
|||||||
app.post('/api/admin-auth/logout', (req, res) => {
|
app.post('/api/admin-auth/logout', (req, res) => {
|
||||||
const cookies = parseCookies(req.headers.cookie)
|
const cookies = parseCookies(req.headers.cookie)
|
||||||
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
|
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
|
||||||
deleteAdminSession(sessionToken)
|
if (sessionToken) {
|
||||||
|
adminSessions.delete(sessionToken)
|
||||||
|
}
|
||||||
clearAdminSessionCookie(res)
|
clearAdminSessionCookie(res)
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
@@ -1625,70 +1728,6 @@ app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res)
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/api/resource-download', studyDownloadRateLimit, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { resourceId, firstName, lastName, email, subscribe, _honey } = req.body ?? {}
|
|
||||||
|
|
||||||
if (_honey) {
|
|
||||||
res.json({ ok: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resourceId || typeof resourceId !== 'string') {
|
|
||||||
res.status(400).json({ message: 'Resource ID is required.' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const published = await loadSiteContentFile(DATA_FILE)
|
|
||||||
const resource = Array.isArray(published?.siteContent?.customLinks)
|
|
||||||
? published.siteContent.customLinks.find(link => link.id === resourceId && link.placement === 'resources')
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
if (!resource || typeof resource.url !== 'string' || !resource.url.trim()) {
|
|
||||||
res.status(400).json({ message: 'Resource not found.' })
|
|
||||||
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' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
|
|
||||||
res.status(400).json({ message: 'A valid email address is required.' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedFirstName = firstName.trim()
|
|
||||||
const trimmedLastName = lastName.trim()
|
|
||||||
const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim()
|
|
||||||
const trimmedEmail = email.trim()
|
|
||||||
const wantsSubscribe = subscribe !== false
|
|
||||||
|
|
||||||
addContactSubmission({
|
|
||||||
name: trimmedName,
|
|
||||||
email: trimmedEmail,
|
|
||||||
message: `Requested resource download: ${resource.label ?? resource.url}`,
|
|
||||||
messageType: 'general',
|
|
||||||
subscribe: wantsSubscribe,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (wantsSubscribe) {
|
|
||||||
await syncContactToResend(trimmedName, trimmedEmail)
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ ok: true, downloadUrl: resource.url.trim() })
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[resource-download] request error:', err)
|
|
||||||
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/api/study-downloads/titus/file', async (req, res) => {
|
app.get('/api/study-downloads/titus/file', async (req, res) => {
|
||||||
const token = typeof req.query?.token === 'string' ? req.query.token : ''
|
const token = typeof req.query?.token === 'string' ? req.query.token : ''
|
||||||
if (!token || !consumeTitusDownloadToken(token)) {
|
if (!token || !consumeTitusDownloadToken(token)) {
|
||||||
@@ -1769,11 +1808,6 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
|
|||||||
}
|
}
|
||||||
questions.unshift(question)
|
questions.unshift(question)
|
||||||
questions = questions.slice(0, MAX_QUESTIONS)
|
questions = questions.slice(0, MAX_QUESTIONS)
|
||||||
if (draftQuestions !== null) {
|
|
||||||
draftQuestions.unshift(question)
|
|
||||||
draftQuestions = draftQuestions.slice(0, MAX_QUESTIONS)
|
|
||||||
queueDraftQuestionsWrite()
|
|
||||||
}
|
|
||||||
queueQuestionsWrite()
|
queueQuestionsWrite()
|
||||||
}
|
}
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
@@ -1840,7 +1874,7 @@ app.post('/api/contact', contactRateLimit, async (req, res) => {
|
|||||||
|
|
||||||
// Get all questions (for admin)
|
// Get all questions (for admin)
|
||||||
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
|
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
|
||||||
res.json({ questions: draftQuestions ?? questions })
|
res.json({ questions })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get only approved public questions (for homepage)
|
// Get only approved public questions (for homepage)
|
||||||
@@ -1859,8 +1893,7 @@ app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureDraftQuestions()
|
const question = questions.find(q => q.id === id)
|
||||||
const question = draftQuestions.find(q => q.id === id)
|
|
||||||
if (!question) {
|
if (!question) {
|
||||||
res.status(404).json({ message: 'Question not found.' })
|
res.status(404).json({ message: 'Question not found.' })
|
||||||
return
|
return
|
||||||
@@ -1868,7 +1901,7 @@ app.post('/api/admin-questions/:id/answer', requireAdminAuth, (req, res) => {
|
|||||||
|
|
||||||
question.answer = answer.trim()
|
question.answer = answer.trim()
|
||||||
question.answeredAt = new Date().toISOString()
|
question.answeredAt = new Date().toISOString()
|
||||||
queueDraftQuestionsWrite()
|
queueQuestionsWrite()
|
||||||
|
|
||||||
res.json({ ok: true, question })
|
res.json({ ok: true, question })
|
||||||
})
|
})
|
||||||
@@ -1878,8 +1911,7 @@ app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => {
|
|||||||
const { id } = req.params
|
const { id } = req.params
|
||||||
const { approved } = req.body ?? {}
|
const { approved } = req.body ?? {}
|
||||||
|
|
||||||
ensureDraftQuestions()
|
const question = questions.find(q => q.id === id)
|
||||||
const question = draftQuestions.find(q => q.id === id)
|
|
||||||
if (!question) {
|
if (!question) {
|
||||||
res.status(404).json({ message: 'Question not found.' })
|
res.status(404).json({ message: 'Question not found.' })
|
||||||
return
|
return
|
||||||
@@ -1887,7 +1919,7 @@ app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => {
|
|||||||
|
|
||||||
question.isApproved = approved === true
|
question.isApproved = approved === true
|
||||||
question.approvedAt = approved === true ? new Date().toISOString() : null
|
question.approvedAt = approved === true ? new Date().toISOString() : null
|
||||||
queueDraftQuestionsWrite()
|
queueQuestionsWrite()
|
||||||
|
|
||||||
res.json({ ok: true, question })
|
res.json({ ok: true, question })
|
||||||
})
|
})
|
||||||
@@ -1895,16 +1927,15 @@ app.post('/api/admin-questions/:id/approve', requireAdminAuth, (req, res) => {
|
|||||||
// Delete a question (admin)
|
// Delete a question (admin)
|
||||||
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()
|
const index = questions.findIndex(q => q.id === id)
|
||||||
const index = draftQuestions.findIndex(q => q.id === id)
|
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
res.status(404).json({ message: 'Question not found.' })
|
res.status(404).json({ message: 'Question not found.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
draftQuestions.splice(index, 1)
|
questions.splice(index, 1)
|
||||||
queueDraftQuestionsWrite()
|
queueQuestionsWrite()
|
||||||
|
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
@@ -2060,7 +2091,6 @@ Promise.all([
|
|||||||
loadVisitorStatsFromDisk(),
|
loadVisitorStatsFromDisk(),
|
||||||
loadContactSubmissionsFromDisk(),
|
loadContactSubmissionsFromDisk(),
|
||||||
loadQuestionsFromDisk(),
|
loadQuestionsFromDisk(),
|
||||||
loadDraftQuestionsFromDisk(),
|
|
||||||
loadChatbotFromDisk(),
|
loadChatbotFromDisk(),
|
||||||
refreshContentCaches(),
|
refreshContentCaches(),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto'
|
|
||||||
import { parseCookies } from './helpers.js'
|
|
||||||
|
|
||||||
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
|
|
||||||
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
|
||||||
const adminSessions = new Map()
|
|
||||||
|
|
||||||
function cookieFlags() {
|
|
||||||
return process.env.NODE_ENV === 'production' ? '; Secure' : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sha256(value) {
|
|
||||||
return createHash('sha256').update(String(value)).digest('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAdminPasswordConfigured() {
|
|
||||||
return Boolean(ADMIN_PASSWORD)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateAdminPasswordSetup() {
|
|
||||||
if (!isAdminPasswordConfigured() && process.env.NODE_ENV === 'production') {
|
|
||||||
throw new Error('ADMIN_PASSWORD is required in production.')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAdminPasswordConfigured()) {
|
|
||||||
console.warn('ADMIN_PASSWORD is not configured; admin routes will remain disabled until the environment is configured.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAdminPasswordValid(password) {
|
|
||||||
if (!isAdminPasswordConfigured()) return false
|
|
||||||
return sha256(password) === sha256(ADMIN_PASSWORD)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAdminSession() {
|
|
||||||
const token = randomUUID()
|
|
||||||
adminSessions.set(token, Date.now() + ADMIN_SESSION_TTL_MS)
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteAdminSession(token) {
|
|
||||||
if (token) {
|
|
||||||
adminSessions.delete(token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isValidAdminSession(req) {
|
|
||||||
if (!isAdminPasswordConfigured()) return false
|
|
||||||
|
|
||||||
const cookies = parseCookies(req.headers.cookie)
|
|
||||||
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
|
|
||||||
if (!sessionToken) return false
|
|
||||||
|
|
||||||
const expiresAt = adminSessions.get(sessionToken)
|
|
||||||
if (!expiresAt || expiresAt <= Date.now()) {
|
|
||||||
adminSessions.delete(sessionToken)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setAdminSessionCookie(res, token) {
|
|
||||||
res.append(
|
|
||||||
'Set-Cookie',
|
|
||||||
`${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearAdminSessionCookie(res) {
|
|
||||||
res.append(
|
|
||||||
'Set-Cookie',
|
|
||||||
`${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requireAdminAuth(req, res, next) {
|
|
||||||
if (!isValidAdminSession(req)) {
|
|
||||||
res.status(401).json({ message: 'Unauthorized' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto'
|
|
||||||
|
|
||||||
export const DEFAULT_REDIRECT_RULES = [
|
|
||||||
{
|
|
||||||
id: 'spotify',
|
|
||||||
path: '/spotify',
|
|
||||||
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
|
|
||||||
statusCode: 301,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'apple',
|
|
||||||
path: '/apple',
|
|
||||||
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
|
|
||||||
statusCode: 301,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'amazon',
|
|
||||||
path: '/amazon',
|
|
||||||
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
|
|
||||||
statusCode: 301,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const DEFAULT_SEO = {
|
|
||||||
title: 'Verse by Verse with Nate',
|
|
||||||
description: 'Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.',
|
|
||||||
ogTitle: 'Verse by Verse with Nate',
|
|
||||||
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
|
|
||||||
ogImage: '/images/podcast-art.jpeg',
|
|
||||||
canonicalUrl: 'https://versebyversewithnate.us/',
|
|
||||||
robotsPolicy: 'index,follow',
|
|
||||||
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_LEGAL = {
|
|
||||||
privacyTitle: 'Privacy Policy',
|
|
||||||
privacyBody: [
|
|
||||||
'We respect your privacy and collect limited data to operate and improve this site.',
|
|
||||||
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
|
|
||||||
'Contact form details are used only to respond to your message and ministry communication requests.',
|
|
||||||
],
|
|
||||||
termsTitle: 'Terms',
|
|
||||||
termsBody: [
|
|
||||||
'Content on this site is for informational and ministry purposes.',
|
|
||||||
'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.',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_PODCAST_FEATURED_LINKS = []
|
|
||||||
export const DEFAULT_PUBLISH_STATE = {
|
|
||||||
draftUpdatedAt: null,
|
|
||||||
publishedAt: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function escapeHtml(value) {
|
|
||||||
return String(value)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function splitName(fullName) {
|
|
||||||
const parts = String(fullName).trim().split(/\s+/).filter(Boolean)
|
|
||||||
return {
|
|
||||||
firstName: parts[0] ?? '',
|
|
||||||
lastName: parts.slice(1).join(' '),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeUrl(value) {
|
|
||||||
if (typeof value !== 'string') return ''
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!trimmed) return ''
|
|
||||||
if (trimmed.startsWith('/')) return trimmed
|
|
||||||
if (/^https?:\/\//i.test(trimmed)) return trimmed
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeRedirectPath(value) {
|
|
||||||
if (typeof value !== 'string') return ''
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!trimmed) return ''
|
|
||||||
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
|
|
||||||
const normalized = withSlash.replace(/\/+/g, '/')
|
|
||||||
if (normalized === '/') return ''
|
|
||||||
if (normalized.startsWith('/api/') || normalized.startsWith('/admin')) return ''
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeSitemapPath(value) {
|
|
||||||
if (typeof value !== 'string') return ''
|
|
||||||
const trimmed = value.trim()
|
|
||||||
if (!trimmed) return ''
|
|
||||||
if (trimmed === '/') return '/'
|
|
||||||
return normalizeRedirectPath(trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeRedirectRules(value) {
|
|
||||||
const source = Array.isArray(value) ? value : []
|
|
||||||
const seen = new Set()
|
|
||||||
const out = []
|
|
||||||
|
|
||||||
for (const item of source) {
|
|
||||||
const pathValue = normalizeRedirectPath(item?.path)
|
|
||||||
const target = sanitizeUrl(item?.target)
|
|
||||||
const statusCode = Number(item?.statusCode) === 302 ? 302 : 301
|
|
||||||
if (!pathValue || !target) continue
|
|
||||||
if (seen.has(pathValue)) continue
|
|
||||||
seen.add(pathValue)
|
|
||||||
out.push({
|
|
||||||
id: typeof item?.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
|
||||||
path: pathValue,
|
|
||||||
target,
|
|
||||||
statusCode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.length > 0 ? out : DEFAULT_REDIRECT_RULES
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeFeaturedLinks(value) {
|
|
||||||
const source = Array.isArray(value) ? value : []
|
|
||||||
return source
|
|
||||||
.filter(item => item && typeof item === 'object')
|
|
||||||
.map(item => {
|
|
||||||
const discussionQuestions = Array.isArray(item.discussionQuestions)
|
|
||||||
? item.discussionQuestions
|
|
||||||
.filter(question => typeof question === 'string')
|
|
||||||
.map(question => question.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 30)
|
|
||||||
: []
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
|
||||||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
|
|
||||||
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
|
|
||||||
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
|
|
||||||
url: sanitizeUrl(item.url),
|
|
||||||
embedUrl: sanitizeUrl(item.embedUrl),
|
|
||||||
showNotes: typeof item.showNotes === 'string' ? item.showNotes.trim().slice(0, 10000) : '',
|
|
||||||
discussionQuestions,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(
|
|
||||||
item =>
|
|
||||||
item.title ||
|
|
||||||
item.summary ||
|
|
||||||
item.url ||
|
|
||||||
item.embedUrl ||
|
|
||||||
item.showNotes ||
|
|
||||||
item.discussionQuestions.length > 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeCustomLinks(value) {
|
|
||||||
const source = Array.isArray(value) ? value : []
|
|
||||||
return source
|
|
||||||
.filter(item => item && typeof item === 'object')
|
|
||||||
.map(item => {
|
|
||||||
const placement =
|
|
||||||
item?.placement === 'platforms' || item?.placement === 'footer' || item?.placement === 'resources'
|
|
||||||
? item.placement
|
|
||||||
: 'footer'
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
|
||||||
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
|
|
||||||
url: sanitizeUrl(item.url),
|
|
||||||
imageUrl: sanitizeUrl(item.imageUrl),
|
|
||||||
placement,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(item => item.label && item.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeCustomBlocks(value) {
|
|
||||||
const source = Array.isArray(value) ? value : []
|
|
||||||
return source
|
|
||||||
.filter(item => item && typeof item === 'object')
|
|
||||||
.map(item => ({
|
|
||||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
|
||||||
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
|
|
||||||
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
|
|
||||||
}))
|
|
||||||
.filter(item => item.heading || item.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeArchivedSeriesResourceLinks(value) {
|
|
||||||
const source = Array.isArray(value) ? value : []
|
|
||||||
return source
|
|
||||||
.filter(item => item && typeof item === 'object')
|
|
||||||
.map(item => ({
|
|
||||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
|
||||||
label: typeof item.label === 'string' ? item.label.trim().slice(0, 120) : '',
|
|
||||||
url: sanitizeUrl(item.url),
|
|
||||||
}))
|
|
||||||
.filter(item => item.label && item.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeArchivedSeriesNotes(value) {
|
|
||||||
const source = Array.isArray(value) ? value : []
|
|
||||||
return source
|
|
||||||
.filter(item => item && typeof item === 'object')
|
|
||||||
.map(item => ({
|
|
||||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
|
||||||
heading: typeof item.heading === 'string' ? item.heading.trim().slice(0, 140) : '',
|
|
||||||
body: typeof item.body === 'string' ? item.body.trim().slice(0, 4000) : '',
|
|
||||||
}))
|
|
||||||
.filter(item => item.heading || item.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeArchivedSeries(value) {
|
|
||||||
const source = Array.isArray(value) ? value : []
|
|
||||||
return source
|
|
||||||
.filter(item => item && typeof item === 'object')
|
|
||||||
.map(item => ({
|
|
||||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
|
||||||
label: typeof item.label === 'string' ? item.label.trim().slice(0, 140) : '',
|
|
||||||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 200) : '',
|
|
||||||
description: typeof item.description === 'string' ? item.description.trim().slice(0, 1000) : '',
|
|
||||||
imageUrl: sanitizeUrl(item.imageUrl),
|
|
||||||
listenUrl: sanitizeUrl(item.listenUrl),
|
|
||||||
studyGuideTitle: typeof item.studyGuideTitle === 'string' ? item.studyGuideTitle.trim().slice(0, 140) : '',
|
|
||||||
studyGuideDescription: typeof item.studyGuideDescription === 'string' ? item.studyGuideDescription.trim().slice(0, 4000) : '',
|
|
||||||
studyGuideUrl: sanitizeUrl(item.studyGuideUrl),
|
|
||||||
resourceLinks: sanitizeArchivedSeriesResourceLinks(item.resourceLinks),
|
|
||||||
notes: sanitizeArchivedSeriesNotes(item.notes),
|
|
||||||
}))
|
|
||||||
.filter(
|
|
||||||
item =>
|
|
||||||
item.title ||
|
|
||||||
item.description ||
|
|
||||||
item.resourceLinks.length > 0 ||
|
|
||||||
item.notes.length > 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sanitizeSiteContent(siteContent) {
|
|
||||||
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) return {}
|
|
||||||
|
|
||||||
const seo = siteContent.seo && typeof siteContent.seo === 'object' ? siteContent.seo : {}
|
|
||||||
const legal = siteContent.legal && typeof siteContent.legal === 'object' ? siteContent.legal : {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...siteContent,
|
|
||||||
customLinks: sanitizeCustomLinks(siteContent.customLinks),
|
|
||||||
customBlocks: sanitizeCustomBlocks(siteContent.customBlocks),
|
|
||||||
archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries),
|
|
||||||
redirects: sanitizeRedirectRules(siteContent.redirects),
|
|
||||||
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
|
|
||||||
seo: {
|
|
||||||
title: typeof seo.title === 'string' && seo.title.trim() ? seo.title.trim().slice(0, 120) : DEFAULT_SEO.title,
|
|
||||||
description: typeof seo.description === 'string' && seo.description.trim() ? seo.description.trim().slice(0, 240) : DEFAULT_SEO.description,
|
|
||||||
ogTitle: typeof seo.ogTitle === 'string' && seo.ogTitle.trim() ? seo.ogTitle.trim().slice(0, 120) : DEFAULT_SEO.ogTitle,
|
|
||||||
ogDescription: typeof seo.ogDescription === 'string' && seo.ogDescription.trim() ? seo.ogDescription.trim().slice(0, 240) : DEFAULT_SEO.ogDescription,
|
|
||||||
ogImage: sanitizeUrl(seo.ogImage) || DEFAULT_SEO.ogImage,
|
|
||||||
canonicalUrl: sanitizeUrl(seo.canonicalUrl) || DEFAULT_SEO.canonicalUrl,
|
|
||||||
robotsPolicy: typeof seo.robotsPolicy === 'string' && seo.robotsPolicy.trim() ? seo.robotsPolicy.trim() : DEFAULT_SEO.robotsPolicy,
|
|
||||||
sitemapPaths: Array.isArray(seo.sitemapPaths)
|
|
||||||
? seo.sitemapPaths.map(pathItem => normalizeSitemapPath(pathItem)).filter(Boolean)
|
|
||||||
: [...DEFAULT_SEO.sitemapPaths],
|
|
||||||
},
|
|
||||||
legal: {
|
|
||||||
privacyTitle: typeof legal.privacyTitle === 'string' && legal.privacyTitle.trim() ? legal.privacyTitle.trim().slice(0, 120) : DEFAULT_LEGAL.privacyTitle,
|
|
||||||
privacyBody: Array.isArray(legal.privacyBody) && legal.privacyBody.length > 0
|
|
||||||
? legal.privacyBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
|
|
||||||
: [...DEFAULT_LEGAL.privacyBody],
|
|
||||||
termsTitle: typeof legal.termsTitle === 'string' && legal.termsTitle.trim() ? legal.termsTitle.trim().slice(0, 120) : DEFAULT_LEGAL.termsTitle,
|
|
||||||
termsBody: Array.isArray(legal.termsBody) && legal.termsBody.length > 0
|
|
||||||
? legal.termsBody.filter(line => typeof line === 'string').map(line => line.trim()).filter(Boolean).slice(0, 20)
|
|
||||||
: [...DEFAULT_LEGAL.termsBody],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function escapeXml(value) {
|
|
||||||
return String(value)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildAbsoluteUrl(baseUrl, maybeRelativePath) {
|
|
||||||
const safeBase = typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : DEFAULT_SEO.canonicalUrl
|
|
||||||
const root = safeBase.endsWith('/') ? safeBase.slice(0, -1) : safeBase
|
|
||||||
if (typeof maybeRelativePath !== 'string' || !maybeRelativePath.trim()) return root
|
|
||||||
const value = maybeRelativePath.trim()
|
|
||||||
if (/^https?:\/\//i.test(value)) return value
|
|
||||||
if (value.startsWith('/')) return `${root}${value}`
|
|
||||||
return `${root}/${value}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function injectSeoIntoHtml(html, siteContent) {
|
|
||||||
const seo = siteContent?.seo ?? DEFAULT_SEO
|
|
||||||
const title = seo.title || DEFAULT_SEO.title
|
|
||||||
const description = seo.description || DEFAULT_SEO.description
|
|
||||||
const ogTitle = seo.ogTitle || title
|
|
||||||
const ogDescription = seo.ogDescription || description
|
|
||||||
const canonical = buildAbsoluteUrl(seo.canonicalUrl || DEFAULT_SEO.canonicalUrl, '/')
|
|
||||||
const ogImage = buildAbsoluteUrl(canonical, seo.ogImage || DEFAULT_SEO.ogImage)
|
|
||||||
const robots = seo.robotsPolicy || DEFAULT_SEO.robotsPolicy
|
|
||||||
|
|
||||||
return html
|
|
||||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${escapeHtml(title)}</title>`)
|
|
||||||
.replace(/<meta name="description" content="[^"]*"\s*\/?>/i, `<meta name="description" content="${escapeHtml(description)}" />`)
|
|
||||||
.replace(/<meta name="robots" content="[^"]*"\s*\/?>/i, `<meta name="robots" content="${escapeHtml(robots)}" />`)
|
|
||||||
.replace(/<meta property="og:title" content="[^"]*"\s*\/?>/i, `<meta property="og:title" content="${escapeHtml(ogTitle)}" />`)
|
|
||||||
.replace(/<meta property="og:description" content="[^"]*"\s*\/?>/i, `<meta property="og:description" content="${escapeHtml(ogDescription)}" />`)
|
|
||||||
.replace(/<meta property="og:image" content="[^"]*"\s*\/?>/i, `<meta property="og:image" content="${escapeHtml(ogImage)}" />`)
|
|
||||||
.replace(/<meta property="og:image:secure_url" content="[^"]*"\s*\/?>/i, `<meta property="og:image:secure_url" content="${escapeHtml(ogImage)}" />`)
|
|
||||||
.replace(/<meta property="og:url" content="[^"]*"\s*\/?>/i, `<meta property="og:url" content="${escapeHtml(canonical)}" />`)
|
|
||||||
.replace(/<link rel="canonical" href="[^"]*"\s*\/?>/i, `<link rel="canonical" href="${escapeHtml(canonical)}" />`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeAssetBaseName(name) {
|
|
||||||
if (typeof name !== 'string') return `upload-${Date.now()}`
|
|
||||||
const cleaned = name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9._-]+/g, '-')
|
|
||||||
.replace(/-+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '')
|
|
||||||
return cleaned || `upload-${Date.now()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function inferImageExtensionFromDataUrl(dataUrl) {
|
|
||||||
if (typeof dataUrl !== 'string') return null
|
|
||||||
if (dataUrl.startsWith('data:image/png;base64,')) return '.png'
|
|
||||||
if (dataUrl.startsWith('data:image/jpeg;base64,')) return '.jpg'
|
|
||||||
if (dataUrl.startsWith('data:image/webp;base64,')) return '.webp'
|
|
||||||
if (dataUrl.startsWith('data:image/gif;base64,')) return '.gif'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeIp(rawIp) {
|
|
||||||
if (!rawIp) return 'unknown'
|
|
||||||
|
|
||||||
let ip = String(rawIp).trim()
|
|
||||||
|
|
||||||
if (ip.includes(',')) {
|
|
||||||
ip = ip.split(',')[0].trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ip.startsWith('::ffff:')) {
|
|
||||||
ip = ip.slice(7)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ip === '::1') {
|
|
||||||
ip = '127.0.0.1'
|
|
||||||
}
|
|
||||||
|
|
||||||
return ip || 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getClientIp(req) {
|
|
||||||
const forwarded = req.headers['x-forwarded-for']
|
|
||||||
if (forwarded) {
|
|
||||||
return normalizeIp(forwarded)
|
|
||||||
}
|
|
||||||
return normalizeIp(req.ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseCookies(cookieHeader) {
|
|
||||||
if (!cookieHeader) return {}
|
|
||||||
|
|
||||||
return cookieHeader
|
|
||||||
.split(';')
|
|
||||||
.map(v => v.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.reduce((acc, part) => {
|
|
||||||
const idx = part.indexOf('=')
|
|
||||||
if (idx === -1) return acc
|
|
||||||
const key = part.slice(0, idx).trim()
|
|
||||||
const value = part.slice(idx + 1).trim()
|
|
||||||
try {
|
|
||||||
acc[key] = decodeURIComponent(value)
|
|
||||||
} catch {
|
|
||||||
acc[key] = value
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasVisitorConsent(req) {
|
|
||||||
const cookies = parseCookies(req.headers.cookie)
|
|
||||||
return cookies['vbn_analytics_consent'] === 'yes'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setConsentCookie(res, consent) {
|
|
||||||
const value = consent ? 'yes' : 'no'
|
|
||||||
const secureFlag = process.env.NODE_ENV === 'production' ? '; Secure' : ''
|
|
||||||
res.append('Set-Cookie', `vbn_analytics_consent=${value}; Max-Age=31536000; Path=/; SameSite=Lax${secureFlag}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPrivateOrLocalIp(ip) {
|
|
||||||
return (
|
|
||||||
ip === '127.0.0.1' ||
|
|
||||||
ip === 'localhost' ||
|
|
||||||
ip.startsWith('10.') ||
|
|
||||||
ip.startsWith('192.168.') ||
|
|
||||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip) ||
|
|
||||||
ip.startsWith('fc') ||
|
|
||||||
ip.startsWith('fd') ||
|
|
||||||
ip.startsWith('fe80:') ||
|
|
||||||
ip === 'unknown'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+142
-281
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { ChangeEvent } from 'react'
|
import type { ChangeEvent } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
|
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './App'
|
||||||
import { DEFAULTS } from './content'
|
import { DEFAULTS } from './App'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: SiteContent
|
content: SiteContent
|
||||||
@@ -66,7 +66,6 @@ interface AdminAsset {
|
|||||||
url: string
|
url: string
|
||||||
sizeBytes: number
|
sizeBytes: number
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
tags?: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublishState {
|
interface PublishState {
|
||||||
@@ -95,7 +94,7 @@ interface Question {
|
|||||||
}
|
}
|
||||||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal'>
|
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal'>
|
||||||
|
|
||||||
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share'
|
type MainContentSection = 'hero' | 'start-here' | 'about' | 'series' | 'share'
|
||||||
|
|
||||||
const MAIN_CONTENT_SECTIONS: Array<{ id: MainContentSection; title: string; description: string }> = [
|
const MAIN_CONTENT_SECTIONS: Array<{ id: MainContentSection; title: string; description: string }> = [
|
||||||
{
|
{
|
||||||
@@ -111,12 +110,7 @@ const MAIN_CONTENT_SECTIONS: Array<{ id: MainContentSection; title: string; desc
|
|||||||
{
|
{
|
||||||
id: 'about',
|
id: 'about',
|
||||||
title: 'About Section',
|
title: 'About Section',
|
||||||
description: 'Manage the main show description, Nate bio, and about images.',
|
description: 'Manage the main show description and Nate bio content.',
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'contact',
|
|
||||||
title: 'Contact Section',
|
|
||||||
description: 'Manage the contact page profile image and contact copy.',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'series',
|
id: 'series',
|
||||||
@@ -184,9 +178,6 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
|
|||||||
{ key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true, section: 'about' },
|
{ key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true, section: 'about' },
|
||||||
{ key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true, section: 'about' },
|
{ key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true, section: 'about' },
|
||||||
{ key: 'aboutNate', label: 'About Nate', multiline: true, section: 'about' },
|
{ key: 'aboutNate', label: 'About Nate', multiline: true, section: 'about' },
|
||||||
{ key: 'aboutPhotoUrl', label: 'About Section Photo URL', section: 'about' },
|
|
||||||
{ key: 'aboutVerseArtUrl', label: 'About Verse Art Image URL', section: 'about' },
|
|
||||||
{ key: 'contactPhotoUrl', label: 'Contact Profile Photo URL', section: 'contact' },
|
|
||||||
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' },
|
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' },
|
||||||
{ key: 'seriesTitle', label: 'Series Title', section: 'series' },
|
{ key: 'seriesTitle', label: 'Series Title', section: 'series' },
|
||||||
{ key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' },
|
{ key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' },
|
||||||
@@ -204,8 +195,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content))
|
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content))
|
||||||
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||||
const [errorMsg, setErrorMsg] = useState('')
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
const [adminTab, setAdminTab] = useState<'content' | 'episodes' | 'settings' | 'analytics' | 'questions' | 'brand' | 'assets'>('content')
|
const [adminTab, setAdminTab] = useState<'content' | 'episodes' | 'settings' | 'publish' | 'analytics' | 'questions' | 'brand'>('content')
|
||||||
const [contentTab, setContentTab] = useState<'main' | 'resources' | 'custom'>('main')
|
const [contentTab, setContentTab] = useState<'main' | 'custom'>('main')
|
||||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||||
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||||
const [maintenanceMsg, setMaintenanceMsg] = useState('')
|
const [maintenanceMsg, setMaintenanceMsg] = useState('')
|
||||||
@@ -215,7 +206,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
|
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
|
||||||
const [publishState, setPublishState] = useState<PublishState>({ draftUpdatedAt: null, publishedAt: null })
|
const [publishState, setPublishState] = useState<PublishState>({ draftUpdatedAt: null, publishedAt: null })
|
||||||
const [assets, setAssets] = useState<AdminAsset[]>([])
|
const [assets, setAssets] = useState<AdminAsset[]>([])
|
||||||
const [assetTagEdits, setAssetTagEdits] = useState<Record<string, string>>({})
|
|
||||||
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
|
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
|
||||||
const [assetUploadPending, setAssetUploadPending] = useState(false)
|
const [assetUploadPending, setAssetUploadPending] = useState(false)
|
||||||
|
|
||||||
@@ -284,7 +274,13 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
|
||||||
void reloadAssets()
|
fetch('/api/admin-assets')
|
||||||
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load assets'))))
|
||||||
|
.then(data => {
|
||||||
|
const list = Array.isArray((data as { assets?: unknown }).assets) ? (data as { assets: AdminAsset[] }).assets : []
|
||||||
|
setAssets(list)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
fetch('/api/admin-ops/status')
|
fetch('/api/admin-ops/status')
|
||||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load operations status'))))
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load operations status'))))
|
||||||
@@ -350,12 +346,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
const r = await fetch('/api/admin-assets')
|
const r = await fetch('/api/admin-assets')
|
||||||
if (!r.ok) throw new Error('Could not refresh assets')
|
if (!r.ok) throw new Error('Could not refresh assets')
|
||||||
const data = await r.json() as { assets?: AdminAsset[] }
|
const data = await r.json() as { assets?: AdminAsset[] }
|
||||||
const incomingAssets = Array.isArray(data.assets) ? data.assets : []
|
setAssets(Array.isArray(data.assets) ? data.assets : [])
|
||||||
setAssets(incomingAssets)
|
|
||||||
setAssetTagEdits(incomingAssets.reduce<Record<string, string>>((memo, asset) => {
|
|
||||||
memo[asset.filename] = (asset.tags ?? []).join(', ')
|
|
||||||
return memo
|
|
||||||
}, {}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reloadOpsStatus() {
|
async function reloadOpsStatus() {
|
||||||
@@ -380,7 +371,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
}
|
}
|
||||||
const data = await res.json() as { updatedAt?: string }
|
const data = await res.json() as { updatedAt?: string }
|
||||||
setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() }))
|
setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() }))
|
||||||
setLastSavedSnapshot(JSON.stringify(form))
|
|
||||||
setStatus('saved')
|
setStatus('saved')
|
||||||
setTimeout(() => setStatus('idle'), 3500)
|
setTimeout(() => setStatus('idle'), 3500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -574,30 +564,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAssetTagChange(filename: string, value: string) {
|
|
||||||
setAssetTagEdits(prev => ({ ...prev, [filename]: value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveAssetTags(filename: string) {
|
|
||||||
const tagsText = assetTagEdits[filename] ?? ''
|
|
||||||
const tags = tagsText.split(',').map(tag => tag.trim()).filter(Boolean)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ tags }),
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
throw new Error((data as { message?: string }).message ?? 'Save failed')
|
|
||||||
}
|
|
||||||
await reloadAssets()
|
|
||||||
setOpsMsg('Asset tags saved.')
|
|
||||||
} catch (err) {
|
|
||||||
setOpsMsg(err instanceof Error ? err.message : 'Save failed.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePurgeCache() {
|
async function handlePurgeCache() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin-ops/purge-cache', { method: 'POST' })
|
const res = await fetch('/api/admin-ops/purge-cache', { method: 'POST' })
|
||||||
@@ -735,43 +701,18 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
setForm(f => ({ ...f, [key]: value }))
|
setForm(f => ({ ...f, [key]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderImageAssetSelector(value: string | null | undefined, onChange: (value: string) => void, fieldId: string) {
|
|
||||||
const assetOptions = [...assets.map(asset => ({ label: asset.filename, value: asset.url }))]
|
|
||||||
const currentValue = value?.trim() ?? ''
|
|
||||||
|
|
||||||
if (currentValue && !assetOptions.some(item => item.value === currentValue)) {
|
|
||||||
assetOptions.unshift({ label: `Current image (${currentValue})`, value: currentValue })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (assetOptions.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-field-asset-picker">
|
|
||||||
<label htmlFor={fieldId}>Choose an existing image</label>
|
|
||||||
<select id={fieldId} value={assetOptions.some(a => a.value === currentValue) ? currentValue : ''} onChange={e => onChange(e.target.value)}>
|
|
||||||
<option value="">Select image</option>
|
|
||||||
{assetOptions.map(option => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmLeaveUnsavedChanges() {
|
function confirmLeaveUnsavedChanges() {
|
||||||
if (!isDirty) return true
|
if (!isDirty) return true
|
||||||
return confirm('You have unsaved changes. Leave this section without saving?')
|
return confirm('You have unsaved changes. Leave this section without saving?')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAdminTabChange(nextTab: 'content' | 'episodes' | 'settings' | 'analytics' | 'questions' | 'brand' | 'assets') {
|
function handleAdminTabChange(nextTab: 'content' | 'episodes' | 'settings' | 'publish' | 'analytics' | 'questions' | 'brand') {
|
||||||
if (nextTab === adminTab) return
|
if (nextTab === adminTab) return
|
||||||
if (!confirmLeaveUnsavedChanges()) return
|
if (!confirmLeaveUnsavedChanges()) return
|
||||||
setAdminTab(nextTab)
|
setAdminTab(nextTab)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContentTabChange(nextTab: 'main' | 'resources' | 'custom') {
|
function handleContentTabChange(nextTab: 'main' | 'custom') {
|
||||||
if (nextTab === contentTab) return
|
if (nextTab === contentTab) return
|
||||||
if (!confirmLeaveUnsavedChanges()) return
|
if (!confirmLeaveUnsavedChanges()) return
|
||||||
setContentTab(nextTab)
|
setContentTab(nextTab)
|
||||||
@@ -782,22 +723,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
...f,
|
...f,
|
||||||
customLinks: [
|
customLinks: [
|
||||||
...(f.customLinks ?? []),
|
...(f.customLinks ?? []),
|
||||||
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'platforms' as const },
|
{ id: Date.now().toString(36), label: '', url: '', placement: 'platforms' as const },
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function addResource() {
|
function updateLink(id: string, field: keyof CustomLink, value: string) {
|
||||||
setForm(f => ({
|
|
||||||
...f,
|
|
||||||
customLinks: [
|
|
||||||
...(f.customLinks ?? []),
|
|
||||||
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'resources' as const },
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLink(id: string, field: keyof CustomLink, value: string | string[]) {
|
|
||||||
setForm(f => ({
|
setForm(f => ({
|
||||||
...f,
|
...f,
|
||||||
customLinks: (f.customLinks ?? []).map(l => l.id === id ? { ...l, [field]: value } : l),
|
customLinks: (f.customLinks ?? []).map(l => l.id === id ? { ...l, [field]: value } : l),
|
||||||
@@ -1054,6 +985,29 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
setContentTab('custom')
|
setContentTab('custom')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setStatus('saving')
|
||||||
|
setErrorMsg('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin-content', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ siteContent: form }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error((data as { message?: string }).message ?? 'Save failed')
|
||||||
|
}
|
||||||
|
onSave(form)
|
||||||
|
setLastSavedSnapshot(JSON.stringify(form))
|
||||||
|
setStatus('saved')
|
||||||
|
setTimeout(() => setStatus('idle'), 3500)
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
if (confirm('Reset all fields to defaults?')) {
|
if (confirm('Reset all fields to defaults?')) {
|
||||||
setForm(DEFAULTS)
|
setForm(DEFAULTS)
|
||||||
@@ -1193,11 +1147,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={adminTab === 'assets'}
|
aria-selected={adminTab === 'publish'}
|
||||||
className={`admin-tab ${adminTab === 'assets' ? 'admin-tab--active' : ''}`}
|
className={`admin-tab ${adminTab === 'publish' ? 'admin-tab--active' : ''}`}
|
||||||
onClick={() => handleAdminTabChange('assets')}
|
onClick={() => handleAdminTabChange('publish')}
|
||||||
>
|
>
|
||||||
Assets
|
Publish
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1209,25 +1163,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
Brand
|
Brand
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-action-toolbar">
|
|
||||||
<div className="admin-action-toolbar-meta">
|
|
||||||
{isDirty && <span className="admin-status admin-status--warn">Unsaved changes</span>}
|
|
||||||
<span>Draft saved: {formatDate(publishState.draftUpdatedAt)}</span>
|
|
||||||
<span>Last published: {formatDate(publishState.publishedAt)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="admin-actions admin-actions--toolbar">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-admin-save"
|
|
||||||
onClick={handleSaveDraft}
|
|
||||||
disabled={status === 'saving' || !isDirty}
|
|
||||||
>
|
|
||||||
{status === 'saving' ? 'Saving…' : 'Save Draft'}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn-admin-reset" onClick={openDraftPreview}>Preview Draft</button>
|
|
||||||
<button type="button" className="btn-admin-reset" onClick={handlePublishDraft}>Publish Draft</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{adminTab === 'brand' && (
|
{adminTab === 'brand' && (
|
||||||
<section className="admin-brand-kit" aria-label="Brand kit">
|
<section className="admin-brand-kit" aria-label="Brand kit">
|
||||||
@@ -1290,6 +1225,64 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{adminTab === 'publish' && (
|
||||||
|
<section className="admin-stats" aria-label="Publishing and deployment">
|
||||||
|
<div className="admin-stats-head">
|
||||||
|
<h2>Publishing Workflow</h2>
|
||||||
|
<p>Save drafts, preview before release, and publish when ready.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-stats-grid">
|
||||||
|
<article>
|
||||||
|
<h3>Draft Updated</h3>
|
||||||
|
<p>{formatDate(publishState.draftUpdatedAt)}</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Last Published</h3>
|
||||||
|
<p>{formatDate(publishState.publishedAt)}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-actions admin-actions--maintenance">
|
||||||
|
<button type="button" className="btn-admin-save" onClick={handleSaveDraft}>Save Draft</button>
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={openDraftPreview}>Preview Draft</button>
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={handlePublishDraft}>Publish Draft</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-stats-head admin-stats-head--visitors">
|
||||||
|
<h2>Deployment and Cache</h2>
|
||||||
|
<p>Trigger deployment hooks and cache purge hooks.</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-stats-grid">
|
||||||
|
<article>
|
||||||
|
<h3>Build Commit</h3>
|
||||||
|
<p>{opsStatus?.buildCommit ?? 'Not available'}</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Build Number</h3>
|
||||||
|
<p>{opsStatus?.buildNumber ?? 'Not available'}</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Deployed At</h3>
|
||||||
|
<p>{formatDate(opsStatus?.deployedAt ?? null)}</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Cache Purge</h3>
|
||||||
|
<p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p>
|
||||||
|
<p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div className="admin-actions admin-actions--maintenance">
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={handlePurgeCache}>Purge Cache</button>
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={handleDeployHook}>Trigger Deploy</button>
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={() => { void reloadOpsStatus() }}>Refresh Status</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
|
||||||
|
{status === 'error' && <p className="admin-status admin-status--err">✗ {errorMsg}</p>}
|
||||||
|
{status === 'saved' && <p className="admin-status admin-status--ok">✓ Draft action completed.</p>}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{adminTab === 'episodes' && (
|
{adminTab === 'episodes' && (
|
||||||
<section className="admin-stats" aria-label="Episode highlights">
|
<section className="admin-stats" aria-label="Episode highlights">
|
||||||
@@ -1337,7 +1330,16 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
))}
|
))}
|
||||||
<button type="button" className="btn-admin-add" onClick={addPodcastLink}>+ Add Episode Highlight</button>
|
<button type="button" className="btn-admin-add" onClick={addPodcastLink}>+ Add Episode Highlight</button>
|
||||||
|
|
||||||
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
|
<div className="admin-actions" style={{ marginTop: '2rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-admin-save"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={status === 'saving'}
|
||||||
|
>
|
||||||
|
{status === 'saving' ? 'Saving…' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{status === 'saved' && <p className="admin-status admin-status--ok">✓ Changes saved.</p>}
|
{status === 'saved' && <p className="admin-status admin-status--ok">✓ Changes saved.</p>}
|
||||||
{status === 'error' && <p className="admin-status admin-status--err">✗ {errorMsg}</p>}
|
{status === 'error' && <p className="admin-status admin-status--err">✗ {errorMsg}</p>}
|
||||||
</section>
|
</section>
|
||||||
@@ -1371,7 +1373,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor="seo-og-image">Open Graph Image URL</label>
|
<label htmlFor="seo-og-image">Open Graph Image URL</label>
|
||||||
<input id="seo-og-image" type="text" value={form.seo?.ogImage ?? ''} onChange={e => updateSeoField('ogImage', e.target.value)} />
|
<input id="seo-og-image" type="text" value={form.seo?.ogImage ?? ''} onChange={e => updateSeoField('ogImage', e.target.value)} />
|
||||||
{renderImageAssetSelector(form.seo?.ogImage, value => updateSeoField('ogImage', value), 'seo-og-image-asset')}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor="seo-canonical">Canonical URL</label>
|
<label htmlFor="seo-canonical">Canonical URL</label>
|
||||||
@@ -1456,39 +1457,28 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
|
<div className="admin-stats-head admin-stats-head--visitors">
|
||||||
{status === 'saved' && <p className="admin-status admin-status--ok">✓ Changes saved.</p>}
|
|
||||||
{status === 'error' && <p className="admin-status admin-status--err">✗ {errorMsg}</p>}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{adminTab === 'assets' && (
|
|
||||||
<section className="admin-stats" aria-label="Asset manager">
|
|
||||||
<div className="admin-stats-head">
|
|
||||||
<h2>Asset Manager</h2>
|
<h2>Asset Manager</h2>
|
||||||
<p>Upload, tag, and reuse hosted images from this domain.</p>
|
<p>Upload and reuse hosted images from this domain.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-actions admin-actions--maintenance">
|
<div className="admin-actions admin-actions--maintenance">
|
||||||
<label className="btn-admin-reset" style={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer' }}>
|
<label className="btn-admin-reset" style={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer' }}>
|
||||||
{assetUploadPending ? 'Uploading...' : 'Upload Image'}
|
{assetUploadPending ? 'Uploading...' : 'Upload Image'}
|
||||||
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={handleAssetUpload} style={{ display: 'none' }} disabled={assetUploadPending} />
|
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={handleAssetUpload} style={{ display: 'none' }} disabled={assetUploadPending} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{assets.length === 0 ? (
|
{assets.length === 0 ? (
|
||||||
<p className="admin-stats-note">No uploaded assets yet.</p>
|
<p className="admin-stats-note">No uploaded assets yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-visits-table-scroll">
|
<div className="admin-visits-table-scroll">
|
||||||
<table className="admin-visits-table admin-assets-table">
|
<table className="admin-visits-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Preview</th>
|
<th>Preview</th>
|
||||||
<th>URL</th>
|
<th>URL</th>
|
||||||
<th>Tags</th>
|
|
||||||
<th>Size</th>
|
<th>Size</th>
|
||||||
<th>Updated</th>
|
<th>Updated</th>
|
||||||
<th>Actions</th>
|
<th>Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -1496,20 +1486,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
<tr key={asset.filename}>
|
<tr key={asset.filename}>
|
||||||
<td><img src={asset.url} alt={asset.filename} style={{ width: '68px', height: '68px', objectFit: 'cover', borderRadius: '8px' }} /></td>
|
<td><img src={asset.url} alt={asset.filename} style={{ width: '68px', height: '68px', objectFit: 'cover', borderRadius: '8px' }} /></td>
|
||||||
<td>{asset.url}</td>
|
<td>{asset.url}</td>
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={assetTagEdits[asset.filename] ?? ''}
|
|
||||||
onChange={e => handleAssetTagChange(asset.filename, e.target.value)}
|
|
||||||
placeholder="comma separated tags"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
|
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
|
||||||
<td>{formatDate(asset.updatedAt)}</td>
|
<td>{formatDate(asset.updatedAt)}</td>
|
||||||
<td className="admin-assets-actions">
|
<td><button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button></td>
|
||||||
<button type="button" className="btn-admin-apply" onClick={() => handleSaveAssetTags(asset.filename)}>Save Tags</button>
|
|
||||||
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -1517,7 +1496,19 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="admin-actions" style={{ marginTop: '2rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-admin-save"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={status === 'saving'}
|
||||||
|
>
|
||||||
|
{status === 'saving' ? 'Saving…' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
|
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
|
||||||
|
{status === 'saved' && <p className="admin-status admin-status--ok">✓ Changes saved.</p>}
|
||||||
|
{status === 'error' && <p className="admin-status admin-status--err">✗ {errorMsg}</p>}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{adminTab === 'analytics' && (
|
{adminTab === 'analytics' && (
|
||||||
@@ -1740,31 +1731,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-stats-head admin-stats-head--visitors">
|
|
||||||
<h2>Deployment & Cache Status</h2>
|
|
||||||
<p>Current deployment metadata and cache purge health for the live app.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-stats-grid">
|
|
||||||
<article>
|
|
||||||
<h3>Build Commit</h3>
|
|
||||||
<p>{opsStatus?.buildCommit ?? 'Not available'}</p>
|
|
||||||
</article>
|
|
||||||
<article>
|
|
||||||
<h3>Build Number</h3>
|
|
||||||
<p>{opsStatus?.buildNumber ?? 'Not available'}</p>
|
|
||||||
</article>
|
|
||||||
<article>
|
|
||||||
<h3>Deployed At</h3>
|
|
||||||
<p>{formatDate(opsStatus?.deployedAt ?? null)}</p>
|
|
||||||
</article>
|
|
||||||
<article>
|
|
||||||
<h3>Cache Purge</h3>
|
|
||||||
<p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p>
|
|
||||||
<p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-actions admin-actions--maintenance">
|
<div className="admin-actions admin-actions--maintenance">
|
||||||
<button type="button" className="btn-admin-reset" onClick={handleExport}>Export JSON</button>
|
<button type="button" className="btn-admin-reset" onClick={handleExport}>Export JSON</button>
|
||||||
<button type="button" className="btn-admin-reset" onClick={handleBackupNow}>Backup Now</button>
|
<button type="button" className="btn-admin-reset" onClick={handleBackupNow}>Backup Now</button>
|
||||||
@@ -1772,12 +1738,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
<button type="button" className="btn-admin-remove" onClick={handleClear}>Clear Analytics</button>
|
<button type="button" className="btn-admin-remove" onClick={handleClear}>Clear Analytics</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-actions admin-actions--maintenance">
|
|
||||||
<button type="button" className="btn-admin-reset" onClick={handlePurgeCache}>Purge Cache</button>
|
|
||||||
<button type="button" className="btn-admin-reset" onClick={handleDeployHook}>Trigger Deploy</button>
|
|
||||||
<button type="button" className="btn-admin-reset" onClick={() => { void reloadOpsStatus() }}>Refresh Status</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-restore-row">
|
<div className="admin-restore-row">
|
||||||
<label htmlFor="restore-backup">Restore Backup</label>
|
<label htmlFor="restore-backup">Restore Backup</label>
|
||||||
<select
|
<select
|
||||||
@@ -1816,7 +1776,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
{adminTab === 'content' && (
|
{adminTab === 'content' && (
|
||||||
<form
|
<form
|
||||||
className="admin-form"
|
className="admin-form"
|
||||||
onSubmit={e => { e.preventDefault(); handleSaveDraft() }}
|
onSubmit={e => { e.preventDefault(); handleSave() }}
|
||||||
>
|
>
|
||||||
<div className="admin-tabs" role="tablist" aria-label="Content editor tabs">
|
<div className="admin-tabs" role="tablist" aria-label="Content editor tabs">
|
||||||
<button
|
<button
|
||||||
@@ -1828,15 +1788,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
>
|
>
|
||||||
Main Content
|
Main Content
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={contentTab === 'resources'}
|
|
||||||
className={`admin-tab ${contentTab === 'resources' ? 'admin-tab--active' : ''}`}
|
|
||||||
onClick={() => handleContentTabChange('resources')}
|
|
||||||
>
|
|
||||||
Resources
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -1885,15 +1836,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
|
||||||
<input
|
<input
|
||||||
id={`field-${key}`}
|
id={`field-${key}`}
|
||||||
type="text"
|
type="text"
|
||||||
value={form[key] as string}
|
value={form[key] as string}
|
||||||
onChange={e => handleChange(key, e.target.value)}
|
onChange={e => handleChange(key, e.target.value)}
|
||||||
/>
|
/>
|
||||||
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1904,84 +1852,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{contentTab === 'resources' && (
|
|
||||||
<>
|
|
||||||
<div className="admin-content-summary">
|
|
||||||
<div className="admin-summary-card">
|
|
||||||
<h3>Resource Links</h3>
|
|
||||||
<p>{(form.customLinks ?? []).filter(link => link.placement === 'resources').length}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-section-header">
|
|
||||||
<h3>More Resources</h3>
|
|
||||||
<p>Manage additional resources shown on the More Resources page.</p>
|
|
||||||
</div>
|
|
||||||
{(form.customLinks ?? []).filter(link => link.placement === 'resources').length === 0 && (
|
|
||||||
<p className="admin-stats-note">No resources yet.</p>
|
|
||||||
)}
|
|
||||||
{(form.customLinks ?? []).filter(link => link.placement === 'resources').map(link => (
|
|
||||||
<div key={link.id} className="admin-array-row">
|
|
||||||
<div className="admin-array-fields">
|
|
||||||
<div className="admin-field">
|
|
||||||
<label htmlFor={`resource-label-${link.id}`}>Label</label>
|
|
||||||
<input
|
|
||||||
id={`resource-label-${link.id}`}
|
|
||||||
type="text"
|
|
||||||
value={link.label}
|
|
||||||
placeholder="Resource title"
|
|
||||||
onChange={e => updateLink(link.id, 'label', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="admin-field">
|
|
||||||
<label htmlFor={`resource-url-${link.id}`}>URL</label>
|
|
||||||
<input
|
|
||||||
id={`resource-url-${link.id}`}
|
|
||||||
type="url"
|
|
||||||
value={link.url}
|
|
||||||
placeholder="https://..."
|
|
||||||
onChange={e => updateLink(link.id, 'url', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="admin-field">
|
|
||||||
<label htmlFor={`resource-image-${link.id}`}>Image URL</label>
|
|
||||||
<input
|
|
||||||
id={`resource-image-${link.id}`}
|
|
||||||
type="text"
|
|
||||||
value={link.imageUrl ?? ''}
|
|
||||||
placeholder="/uploads/example.png"
|
|
||||||
onChange={e => updateLink(link.id, 'imageUrl', e.target.value)}
|
|
||||||
/>
|
|
||||||
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `resource-image-${link.id}-asset`)}
|
|
||||||
</div>
|
|
||||||
<div className="admin-field">
|
|
||||||
<label htmlFor={`resource-tags-${link.id}`}>Tags</label>
|
|
||||||
<input
|
|
||||||
id={`resource-tags-${link.id}`}
|
|
||||||
type="text"
|
|
||||||
value={(link.tags ?? []).join(', ')}
|
|
||||||
placeholder="sermon, bible study, faith"
|
|
||||||
onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button type="button" className="btn-admin-add" onClick={addResource}>
|
|
||||||
+ Add Resource
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{contentTab === 'custom' && (
|
{contentTab === 'custom' && (
|
||||||
<>
|
<>
|
||||||
<div className="admin-content-summary">
|
<div className="admin-content-summary">
|
||||||
<div className="admin-summary-card">
|
<div className="admin-summary-card">
|
||||||
<h3>Custom Links</h3>
|
<h3>Custom Links</h3>
|
||||||
<p>{(form.customLinks ?? []).filter(link => link.placement !== 'resources').length}</p>
|
<p>{(form.customLinks ?? []).length}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-summary-card">
|
<div className="admin-summary-card">
|
||||||
<h3>Custom Blocks</h3>
|
<h3>Custom Blocks</h3>
|
||||||
@@ -1995,12 +1871,12 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
|
|
||||||
<div className="admin-section-header">
|
<div className="admin-section-header">
|
||||||
<h3>Custom Links</h3>
|
<h3>Custom Links</h3>
|
||||||
<p>Add links to show in the platform buttons row or footer navigation.</p>
|
<p>Add links to show in the platform buttons row, footer, or a dedicated "More Resources" section.</p>
|
||||||
</div>
|
</div>
|
||||||
{(form.customLinks ?? []).filter(link => link.placement !== 'resources').length === 0 && (
|
{(form.customLinks ?? []).length === 0 && (
|
||||||
<p className="admin-stats-note">No custom links yet.</p>
|
<p className="admin-stats-note">No custom links yet.</p>
|
||||||
)}
|
)}
|
||||||
{(form.customLinks ?? []).filter(link => link.placement !== 'resources').map(link => (
|
{(form.customLinks ?? []).map(link => (
|
||||||
<div key={link.id} className="admin-array-row">
|
<div key={link.id} className="admin-array-row">
|
||||||
<div className="admin-array-fields">
|
<div className="admin-array-fields">
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
@@ -2023,27 +1899,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
onChange={e => updateLink(link.id, 'url', e.target.value)}
|
onChange={e => updateLink(link.id, 'url', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field">
|
|
||||||
<label htmlFor={`link-image-${link.id}`}>Image URL</label>
|
|
||||||
<input
|
|
||||||
id={`link-image-${link.id}`}
|
|
||||||
type="text"
|
|
||||||
value={link.imageUrl ?? ''}
|
|
||||||
placeholder="/uploads/example.png"
|
|
||||||
onChange={e => updateLink(link.id, 'imageUrl', e.target.value)}
|
|
||||||
/>
|
|
||||||
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `link-image-${link.id}-asset`)}
|
|
||||||
</div>
|
|
||||||
<div className="admin-field">
|
|
||||||
<label htmlFor={`link-tags-${link.id}`}>Tags</label>
|
|
||||||
<input
|
|
||||||
id={`link-tags-${link.id}`}
|
|
||||||
type="text"
|
|
||||||
value={(link.tags ?? []).join(', ')}
|
|
||||||
placeholder="listen, podcast, study"
|
|
||||||
onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`link-placement-${link.id}`}>Show in</label>
|
<label htmlFor={`link-placement-${link.id}`}>Show in</label>
|
||||||
<select
|
<select
|
||||||
@@ -2165,7 +2020,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
placeholder="/images/titus-cover.png"
|
placeholder="/images/titus-cover.png"
|
||||||
onChange={e => updateArchivedSeries(series.id, 'imageUrl', e.target.value)}
|
onChange={e => updateArchivedSeries(series.id, 'imageUrl', e.target.value)}
|
||||||
/>
|
/>
|
||||||
{renderImageAssetSelector(series.imageUrl, value => updateArchivedSeries(series.id, 'imageUrl', value), `archive-image-${series.id}-asset`)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`archive-listen-${series.id}`}>Listen URL</label>
|
<label htmlFor={`archive-listen-${series.id}`}>Listen URL</label>
|
||||||
@@ -2330,6 +2184,13 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="admin-actions">
|
<div className="admin-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-admin-save"
|
||||||
|
disabled={status === 'saving'}
|
||||||
|
>
|
||||||
|
{status === 'saving' ? 'Saving…' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-admin-reset"
|
className="btn-admin-reset"
|
||||||
|
|||||||
+30
-221
@@ -1455,89 +1455,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resources-list {
|
.resources-list {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
flex-wrap: wrap;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-link {
|
.resource-link {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
min-height: 86px;
|
|
||||||
padding: 1rem 1.2rem;
|
|
||||||
font-family: var(--brand-font-body);
|
font-family: var(--brand-font-body);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--brand-warm-white);
|
|
||||||
text-decoration: none;
|
|
||||||
background: #111;
|
|
||||||
border: 1px solid rgba(201, 168, 76, 0.18);
|
|
||||||
border-radius: 18px;
|
|
||||||
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-link:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
background: rgba(201, 168, 76, 0.08);
|
|
||||||
border-color: rgba(201, 168, 76, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-link--with-image {
|
|
||||||
grid-template-columns: auto 1fr auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-download-card {
|
|
||||||
background: #111;
|
|
||||||
border: 1px solid rgba(201, 168, 76, 0.18);
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 1rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-download-header {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-download-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-link-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--brand-warm-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-link-action {
|
|
||||||
color: var(--brand-gold);
|
color: var(--brand-gold);
|
||||||
font-size: 0.95rem;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
border: 1px solid rgba(201, 168, 76, 0.3);
|
||||||
}
|
border-radius: 999px;
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
transition: background 200ms, color 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-link-image {
|
.resource-link:hover {
|
||||||
width: 72px;
|
background: rgba(201, 168, 76, 0.1);
|
||||||
height: 72px;
|
color: #d4b87a;
|
||||||
border-radius: 16px;
|
}
|
||||||
object-fit: cover;
|
|
||||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-link-tags {
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
color: rgba(201, 168, 76, 0.9);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Custom content blocks ── */
|
/* ── Custom content blocks ── */
|
||||||
.section-custom-block {
|
.section-custom-block {
|
||||||
@@ -1843,8 +1783,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-form-wrap {
|
.admin-form-wrap {
|
||||||
max-width: 1400px;
|
max-width: 760px;
|
||||||
width: min(100%, 1400px);
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 3rem 2rem 5rem;
|
padding: 3rem 2rem 5rem;
|
||||||
}
|
}
|
||||||
@@ -1856,31 +1795,6 @@
|
|||||||
margin-bottom: 1.1rem;
|
margin-bottom: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-action-toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-action-toolbar-meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
color: var(--brand-muted);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-actions--toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-top-tabs .admin-tab {
|
.admin-top-tabs .admin-tab {
|
||||||
flex: 1 1 180px;
|
flex: 1 1 180px;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
@@ -2197,28 +2111,6 @@
|
|||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-assets-table input[type="text"] {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.35rem 0.45rem;
|
|
||||||
border: 1px solid rgba(201, 168, 76, 0.18);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #090909;
|
|
||||||
color: var(--brand-warm-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-assets-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-link-tags {
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
color: var(--brand-gold);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-restore-row {
|
.admin-restore-row {
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2411,16 +2303,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-form-section-fields {
|
.admin-form-section-fields {
|
||||||
display: grid;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.admin-form-section-fields {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-form-section .admin-section-header {
|
.admin-form-section .admin-section-header {
|
||||||
border-top: none;
|
border-top: none;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
@@ -2537,61 +2424,21 @@
|
|||||||
/* ── Admin: select input ── */
|
/* ── Admin: select input ── */
|
||||||
.admin-field select {
|
.admin-field select {
|
||||||
background: #111;
|
background: #111;
|
||||||
border: 1px solid rgba(201, 168, 76, 0.35);
|
border: 1px solid rgba(201, 168, 76, 0.22);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--brand-warm-white);
|
color: var(--brand-warm-white);
|
||||||
font-family: var(--brand-font-body);
|
font-family: var(--brand-font-body);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
padding: 0.65rem 1rem 0.65rem 0.85rem;
|
padding: 0.65rem 0.85rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
appearance: auto;
|
appearance: none;
|
||||||
transition: border-color 200ms, background-image 200ms;
|
transition: border-color 200ms;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-image: linear-gradient(45deg, transparent 50%, rgba(255,255,255,0.9) 50%), linear-gradient(135deg, rgba(255,255,255,0.9) 50%, transparent 50%);
|
|
||||||
background-position: calc(100% - 0.75rem) calc(50% - 0.15rem), calc(100% - 0.4rem) calc(50% - 0.15rem);
|
|
||||||
background-size: 0.45rem 0.45rem, 0.45rem 0.45rem;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-field select:focus {
|
.admin-field select:focus {
|
||||||
border-color: rgba(201, 168, 76, 0.75);
|
border-color: rgba(201, 168, 76, 0.65);
|
||||||
}
|
|
||||||
|
|
||||||
.admin-field-asset-picker {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
padding: 0.85rem 0.9rem;
|
|
||||||
border: 1px solid rgba(201, 168, 76, 0.16);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-field-asset-picker label {
|
|
||||||
color: var(--brand-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-field-asset-picker label {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--brand-gold);
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-field-asset-picker select {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
background: #111;
|
|
||||||
border: 1px solid rgba(201, 168, 76, 0.45);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--brand-warm-white);
|
|
||||||
padding: 0.65rem 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-field-asset-picker select:focus {
|
|
||||||
border-color: rgba(201, 168, 76, 0.75);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Admin: section divider ── */
|
/* ── Admin: section divider ── */
|
||||||
@@ -2749,29 +2596,12 @@
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-to-top {
|
|
||||||
position: fixed;
|
|
||||||
right: 1.5rem;
|
|
||||||
bottom: 1.5rem;
|
|
||||||
z-index: 20;
|
|
||||||
background: rgba(12, 12, 12, 0.96);
|
|
||||||
color: var(--brand-gold);
|
|
||||||
border: 1px solid rgba(201, 168, 76, 0.45);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25);
|
|
||||||
font-family: var(--brand-font-body);
|
|
||||||
font-weight: 700;
|
|
||||||
transition: transform 0.25s ease, background 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-to-top:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
background: rgba(201, 168, 76, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-admin-add {
|
.btn-admin-add {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--brand-gold);
|
||||||
|
font-family: var(--brand-font-body);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
padding: 0.6rem 1.2rem;
|
padding: 0.6rem 1.2rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -2807,27 +2637,6 @@
|
|||||||
border-color: rgba(224, 92, 92, 0.4);
|
border-color: rgba(224, 92, 92, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-admin-apply {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--brand-gold);
|
|
||||||
font-family: var(--brand-font-body);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
padding: 0.5rem 0.85rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid rgba(201, 168, 76, 0.35);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-self: flex-start;
|
|
||||||
transition: background 200ms, border-color 200ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-admin-apply:hover {
|
|
||||||
background: rgba(201, 168, 76, 0.08);
|
|
||||||
border-color: rgba(201, 168, 76, 0.65);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
/* ── Responsive ── */
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
.hero {
|
.hero {
|
||||||
|
|||||||
+559
-140
@@ -1,11 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import AdminPage from './AdminPage'
|
import AdminPage from './AdminPage'
|
||||||
import QASection from './components/QASection'
|
|
||||||
import ContactForm from './components/ContactForm'
|
|
||||||
import { FacebookIcon, SpotifyIcon, YouTubeIcon, AmazonMusicIcon } from './icons'
|
|
||||||
import type { SiteContent } from './content'
|
|
||||||
import { DEFAULTS } from './content'
|
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
const SPOTIFY_SHOW_URL = '/spotify'
|
const SPOTIFY_SHOW_URL = '/spotify'
|
||||||
@@ -18,6 +13,546 @@ const AMAZON_MUSIC_URL = '/amazon'
|
|||||||
const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate'
|
const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate'
|
||||||
const CONSENT_KEY = 'vbn_analytics_consent_choice'
|
const CONSENT_KEY = 'vbn_analytics_consent_choice'
|
||||||
|
|
||||||
|
function FacebookIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.234 2.686.234v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomLink {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
placement: 'platforms' | 'footer' | 'resources'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomBlock {
|
||||||
|
id: string
|
||||||
|
heading: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchivedSeriesResourceLink {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchivedSeriesNote {
|
||||||
|
id: string
|
||||||
|
heading: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchivedSeries {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
imageUrl: string
|
||||||
|
listenUrl: string
|
||||||
|
studyGuideTitle: string
|
||||||
|
studyGuideDescription: string
|
||||||
|
studyGuideUrl: string
|
||||||
|
resourceLinks: ArchivedSeriesResourceLink[]
|
||||||
|
notes: ArchivedSeriesNote[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedirectRule {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
target: string
|
||||||
|
statusCode: 301 | 302
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PodcastFeaturedLink {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
episodeNumber?: string
|
||||||
|
summary: string
|
||||||
|
url: string
|
||||||
|
embedUrl?: string
|
||||||
|
showNotes?: string
|
||||||
|
discussionQuestions?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeoSettings {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
ogTitle: string
|
||||||
|
ogDescription: string
|
||||||
|
ogImage: string
|
||||||
|
canonicalUrl: string
|
||||||
|
robotsPolicy: string
|
||||||
|
sitemapPaths: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegalSettings {
|
||||||
|
privacyTitle: string
|
||||||
|
privacyBody: string[]
|
||||||
|
termsTitle: string
|
||||||
|
termsBody: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteContent {
|
||||||
|
eyebrow: string
|
||||||
|
heroTagline: string
|
||||||
|
startHereHeading: string
|
||||||
|
startHereIntro: string
|
||||||
|
startHereStep1Title: string
|
||||||
|
startHereStep1Body: string
|
||||||
|
startHereStep1Cta: string
|
||||||
|
startHereStep2Title: string
|
||||||
|
startHereStep2Body: string
|
||||||
|
startHereStep2Cta: string
|
||||||
|
startHereStep3Title: string
|
||||||
|
startHereStep3Body: string
|
||||||
|
startHereStep3Cta: string
|
||||||
|
aboutShowHeading: string
|
||||||
|
aboutShowP1: string
|
||||||
|
aboutShowP2: string
|
||||||
|
aboutNate: string
|
||||||
|
seriesLabel: string
|
||||||
|
seriesTitle: string
|
||||||
|
seriesDescription: string
|
||||||
|
seriesImageUrl: string
|
||||||
|
seriesListenUrl: string
|
||||||
|
studyGuideTitle: string
|
||||||
|
studyGuideDescription: string
|
||||||
|
studyGuideUrl: string
|
||||||
|
shareHeading: string
|
||||||
|
shareP: string
|
||||||
|
customLinks: CustomLink[]
|
||||||
|
customBlocks: CustomBlock[]
|
||||||
|
archivedSeries: ArchivedSeries[]
|
||||||
|
redirects: RedirectRule[]
|
||||||
|
podcastFeaturedLinks: PodcastFeaturedLink[]
|
||||||
|
seo: SeoSettings
|
||||||
|
legal: LegalSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULTS: SiteContent = {
|
||||||
|
eyebrow: 'A Journey Through Scripture',
|
||||||
|
heroTagline: "Exploring God's Word one verse at a time",
|
||||||
|
startHereHeading: 'New Here? Start Here',
|
||||||
|
startHereIntro:
|
||||||
|
'If this is your first visit, this path helps you get grounded quickly and make the most of the site.',
|
||||||
|
startHereStep1Title: 'Step 1: Listen to 3 Starter Episodes',
|
||||||
|
startHereStep1Body:
|
||||||
|
'Start with the newest episode, one from the beginning of the current study, and one from the middle.',
|
||||||
|
startHereStep1Cta: 'Open Episodes',
|
||||||
|
startHereStep2Title: 'Step 2: Use Q&A to Go Deeper',
|
||||||
|
startHereStep2Body:
|
||||||
|
'Browse by topic or search key words to find concise biblical answers and related follow-up questions.',
|
||||||
|
startHereStep2Cta: 'Go to Q&A',
|
||||||
|
startHereStep3Title: 'Step 3: Ask Nate Directly',
|
||||||
|
startHereStep3Body:
|
||||||
|
'Use the contact form to submit your Bible question for future Q&A or an upcoming episode.',
|
||||||
|
startHereStep3Cta: 'Submit a Question',
|
||||||
|
aboutShowHeading: 'Depth. Clarity. Application.',
|
||||||
|
aboutShowP1:
|
||||||
|
'Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.',
|
||||||
|
aboutShowP2:
|
||||||
|
"Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
|
||||||
|
aboutNate:
|
||||||
|
"Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
||||||
|
seriesLabel: 'Now Playing',
|
||||||
|
seriesTitle: 'Study of Titus: Sound Doctrine',
|
||||||
|
seriesDescription:
|
||||||
|
"A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
||||||
|
seriesImageUrl: '/images/titus-cover.png',
|
||||||
|
seriesListenUrl: SPOTIFY_SHOW_URL,
|
||||||
|
studyGuideTitle: 'Companion Study Guide',
|
||||||
|
studyGuideDescription:
|
||||||
|
'Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.',
|
||||||
|
studyGuideUrl: 'https://a.co/d/01sG2tOJ',
|
||||||
|
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.',
|
||||||
|
customLinks: [],
|
||||||
|
customBlocks: [],
|
||||||
|
archivedSeries: [],
|
||||||
|
redirects: [
|
||||||
|
{
|
||||||
|
id: 'spotify',
|
||||||
|
path: '/spotify',
|
||||||
|
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
|
||||||
|
statusCode: 301,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'apple',
|
||||||
|
path: '/apple',
|
||||||
|
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
|
||||||
|
statusCode: 301,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon',
|
||||||
|
path: '/amazon',
|
||||||
|
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
|
||||||
|
statusCode: 301,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
podcastFeaturedLinks: [],
|
||||||
|
seo: {
|
||||||
|
title: 'Verse by Verse with Nate',
|
||||||
|
description: "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
||||||
|
ogTitle: 'Verse by Verse with Nate',
|
||||||
|
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
|
||||||
|
ogImage: '/images/podcast-art.jpeg',
|
||||||
|
canonicalUrl: 'https://versebyversewithnate.us/',
|
||||||
|
robotsPolicy: 'index,follow',
|
||||||
|
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
|
||||||
|
},
|
||||||
|
legal: {
|
||||||
|
privacyTitle: 'Privacy Policy',
|
||||||
|
privacyBody: [
|
||||||
|
'We respect your privacy and collect limited data to operate and improve this site.',
|
||||||
|
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
|
||||||
|
'Contact form details are used only to respond to your message and ministry communication requests.',
|
||||||
|
],
|
||||||
|
termsTitle: 'Terms',
|
||||||
|
termsBody: [
|
||||||
|
'Content on this site is for informational and ministry purposes.',
|
||||||
|
'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.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpotifyIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function YouTubeIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AmazonMusicIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PublicQuestion {
|
||||||
|
id: string
|
||||||
|
firstName: string
|
||||||
|
question: string
|
||||||
|
answer: string
|
||||||
|
topic?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const QA_PAGE_SIZE = 6
|
||||||
|
|
||||||
|
function QASection() {
|
||||||
|
const [questions, setQuestions] = useState<PublicQuestion[]>([])
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
||||||
|
const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({})
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/questions')
|
||||||
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
|
||||||
|
.then(data => {
|
||||||
|
setQuestions((data as { questions: PublicQuestion[] }).questions ?? [])
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const topics = Array.from(new Set(questions.map(q => q.topic).filter(Boolean))) as string[]
|
||||||
|
|
||||||
|
const filteredQuestions = questions.filter(q => {
|
||||||
|
const matchesTopic = !selectedTopic || q.topic === selectedTopic
|
||||||
|
const matchesSearch = !searchQuery ||
|
||||||
|
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
q.answer.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
return matchesTopic && matchesSearch
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)
|
||||||
|
const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE)
|
||||||
|
|
||||||
|
const toggleExpanded = (id: string) => {
|
||||||
|
setExpanded(e => ({ ...e, [id]: !e[id] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setSearchQuery(value)
|
||||||
|
setPage(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTopic = (topic: string | null) => {
|
||||||
|
setSelectedTopic(topic)
|
||||||
|
setPage(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenizeForRelated = (text: string) =>
|
||||||
|
text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s]/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(token => token.length > 3)
|
||||||
|
|
||||||
|
const getRelatedQuestions = (current: PublicQuestion) => {
|
||||||
|
const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`))
|
||||||
|
|
||||||
|
const related = questions
|
||||||
|
.filter(candidate => candidate.id !== current.id)
|
||||||
|
.map(candidate => {
|
||||||
|
const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`)
|
||||||
|
const overlap = candidateTokens.filter(token => currentTokens.has(token)).length
|
||||||
|
const sameTopic = Boolean(current.topic && candidate.topic && current.topic === candidate.topic)
|
||||||
|
const score = overlap + (sameTopic ? 5 : 0)
|
||||||
|
return { candidate, score }
|
||||||
|
})
|
||||||
|
.filter(item => item.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(item => item.candidate)
|
||||||
|
|
||||||
|
return related
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="qa" className="section-qa" aria-label="Questions and answers">
|
||||||
|
<div className="section-inner">
|
||||||
|
<h2 className="section-heading">
|
||||||
|
<span className="ornament">✦</span> Questions & Answers <span className="ornament">✦</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="qa-no-results">Loading questions…</p>
|
||||||
|
) : questions.length === 0 ? (
|
||||||
|
<div className="qa-no-results">
|
||||||
|
<p>No questions have been answered yet. <a href="#contact">Submit yours below!</a></p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="qa-filters">
|
||||||
|
<div className="qa-topics">
|
||||||
|
<button
|
||||||
|
className={`qa-topic-btn${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
|
||||||
|
onClick={() => handleTopic(null)}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{topics.map(topic => (
|
||||||
|
<button
|
||||||
|
key={topic}
|
||||||
|
className={`qa-topic-btn${selectedTopic === topic ? ' qa-topic-btn--active' : ''}`}
|
||||||
|
onClick={() => handleTopic(topic)}
|
||||||
|
>
|
||||||
|
{topic}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="qa-search">
|
||||||
|
<label>
|
||||||
|
<span className="visually-hidden">Search Questions</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search questions…"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => handleSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredQuestions.length === 0 ? (
|
||||||
|
<div className="qa-no-results">
|
||||||
|
<p>No matching questions found. <a href="#contact">Submit your question</a></p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="qa-cards">
|
||||||
|
{pagedQuestions.map(question => (
|
||||||
|
<div key={question.id} className="qa-card-scene">
|
||||||
|
<div
|
||||||
|
className={`qa-card-inner ${expanded[question.id] ? 'flipped' : ''}`}
|
||||||
|
onClick={() => toggleExpanded(question.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-expanded={!!expanded[question.id]}
|
||||||
|
aria-label={question.question}
|
||||||
|
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && toggleExpanded(question.id)}
|
||||||
|
>
|
||||||
|
<div className="qa-card-face qa-card-front">
|
||||||
|
<span className="qa-face-label">Q</span>
|
||||||
|
<p className="qa-question-text">{question.question}</p>
|
||||||
|
<span className="qa-flip-hint">{expanded[question.id] ? '▲' : '▼'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="qa-card-face qa-card-back">
|
||||||
|
<span className="qa-face-label">A</span>
|
||||||
|
<p className="qa-answer-text">{question.answer}</p>
|
||||||
|
{getRelatedQuestions(question).length > 0 && (
|
||||||
|
<div className="qa-related-wrap">
|
||||||
|
<p className="qa-related-label">Related questions</p>
|
||||||
|
<div className="qa-related-list">
|
||||||
|
{getRelatedQuestions(question).map(related => (
|
||||||
|
<button
|
||||||
|
key={related.id}
|
||||||
|
type="button"
|
||||||
|
className="qa-related-btn"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleTopic(null)
|
||||||
|
handleSearch(related.question)
|
||||||
|
setExpanded({ [related.id]: true })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{related.question}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p style={{ margin: '0.75rem 0 0', fontSize: '0.8rem', color: '#a89060', fontStyle: 'italic' }}>— Answered by Nate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="qa-pagination">
|
||||||
|
<button
|
||||||
|
className="qa-page-btn"
|
||||||
|
onClick={() => setPage(p => p - 1)}
|
||||||
|
disabled={page === 0}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
‹ Prev
|
||||||
|
</button>
|
||||||
|
<span className="qa-page-info">
|
||||||
|
{page + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="qa-page-btn"
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
Next ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContactForm() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '', message: '', messageType: 'question' })
|
||||||
|
const [subscribe, setSubscribe] = useState(true)
|
||||||
|
const [honey, setHoney] = useState('')
|
||||||
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
|
||||||
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
|
|
||||||
|
function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
||||||
|
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
|
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setStatus('submitting')
|
||||||
|
setErrorMsg('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/contact', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...fields, subscribe, _honey: honey }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
setErrorMsg((data as { message?: string }).message ?? 'Something went wrong. Please try again.')
|
||||||
|
setStatus('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate('/thanks')
|
||||||
|
} catch {
|
||||||
|
setErrorMsg('Could not connect. Please try again later.')
|
||||||
|
setStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="contact-form" onSubmit={handleSubmit} noValidate>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="contact-honeypot"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-hidden="true"
|
||||||
|
value={honey}
|
||||||
|
onChange={e => setHoney(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
First Name
|
||||||
|
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Last Name
|
||||||
|
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Message Type
|
||||||
|
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
|
||||||
|
<option value="question">Bible Question</option>
|
||||||
|
<option value="testimony">Testimony</option>
|
||||||
|
<option value="topic">Topic Request</option>
|
||||||
|
<option value="general">General Message</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Message
|
||||||
|
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
|
||||||
|
</label>
|
||||||
|
<label className="contact-consent">
|
||||||
|
<input
|
||||||
|
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>
|
||||||
|
</label>
|
||||||
|
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
|
||||||
|
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
||||||
|
{status === 'submitting' ? 'Sending…' : 'Send Message'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function StudyDownloadForm() {
|
function StudyDownloadForm() {
|
||||||
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '' })
|
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '' })
|
||||||
const [subscribe, setSubscribe] = useState(true)
|
const [subscribe, setSubscribe] = useState(true)
|
||||||
@@ -101,89 +636,6 @@ function StudyDownloadForm() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourceDownloadForm({ resourceId, buttonText }: { resourceId: string; buttonText: string }) {
|
|
||||||
const [fields, setFields] = useState({ firstName: '', lastName: '', email: '' })
|
|
||||||
const [subscribe, setSubscribe] = useState(true)
|
|
||||||
const [honey, setHoney] = useState('')
|
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'error' | 'success'>('idle')
|
|
||||||
const [errorMsg, setErrorMsg] = useState('')
|
|
||||||
const [successMsg, setSuccessMsg] = useState('')
|
|
||||||
|
|
||||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setStatus('submitting')
|
|
||||||
setErrorMsg('')
|
|
||||||
setSuccessMsg('')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/resource-download', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ resourceId, ...fields, subscribe, _honey: honey }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({})) as { message?: string; downloadUrl?: string }
|
|
||||||
if (!res.ok || !data.downloadUrl) {
|
|
||||||
setErrorMsg(data.message ?? 'Could not process your request. Please try again.')
|
|
||||||
setStatus('error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus('success')
|
|
||||||
setSuccessMsg('Your download should start now. If not, use the link below.')
|
|
||||||
window.location.assign(data.downloadUrl)
|
|
||||||
} catch {
|
|
||||||
setErrorMsg('Could not connect. Please try again later.')
|
|
||||||
setStatus('error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="study-download-form" onSubmit={handleSubmit} noValidate>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="contact-honeypot"
|
|
||||||
tabIndex={-1}
|
|
||||||
autoComplete="off"
|
|
||||||
aria-hidden="true"
|
|
||||||
value={honey}
|
|
||||||
onChange={e => setHoney(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="study-download-grid">
|
|
||||||
<label>
|
|
||||||
First Name
|
|
||||||
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Last Name
|
|
||||||
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Email
|
|
||||||
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="contact-consent">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={subscribe}
|
|
||||||
onChange={e => setSubscribe(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>Subscribe me to updates from Verse by Verse with Nate.</span>
|
|
||||||
</label>
|
|
||||||
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
|
|
||||||
{status === 'success' && <p className="study-download-success">{successMsg}</p>}
|
|
||||||
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
|
||||||
{status === 'submitting' ? 'Preparing Download...' : buttonText}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AnalyticsConsentBanner() {
|
function AnalyticsConsentBanner() {
|
||||||
const [choice, setChoice] = useState<'unknown' | 'accepted' | 'declined'>(() => {
|
const [choice, setChoice] = useState<'unknown' | 'accepted' | 'declined'>(() => {
|
||||||
const saved = localStorage.getItem(CONSENT_KEY)
|
const saved = localStorage.getItem(CONSENT_KEY)
|
||||||
@@ -324,6 +776,11 @@ function LatestEpisodesList() {
|
|||||||
|
|
||||||
function SiteHeader() {
|
function SiteHeader() {
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
@@ -339,11 +796,11 @@ function SiteHeader() {
|
|||||||
{menuOpen ? 'Close' : 'Menu'}
|
{menuOpen ? 'Close' : 'Menu'}
|
||||||
</button>
|
</button>
|
||||||
<nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}>
|
<nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}>
|
||||||
<NavLink to="/" end className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Home</NavLink>
|
<NavLink to="/" end className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`}>Home</NavLink>
|
||||||
<NavLink to="/episodes" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Episodes</NavLink>
|
<NavLink to="/episodes" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`}>Episodes</NavLink>
|
||||||
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Resources</NavLink>
|
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`}>Resources</NavLink>
|
||||||
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
|
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`}>About</NavLink>
|
||||||
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact</NavLink>
|
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`}>Contact</NavLink>
|
||||||
<a
|
<a
|
||||||
href={SPOTIFY_SHOW_URL}
|
href={SPOTIFY_SHOW_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -435,7 +892,7 @@ function AboutSection({ content }: { content: SiteContent }) {
|
|||||||
<section className="section-about" aria-label="About the show">
|
<section className="section-about" aria-label="About the show">
|
||||||
<div className="section-inner about-inner">
|
<div className="section-inner about-inner">
|
||||||
<div className="about-photo">
|
<div className="about-photo">
|
||||||
<img src={content.aboutPhotoUrl || '/images/nate-photo.jpeg'} alt="Nate Emmert" />
|
<img src="/images/nate-photo.jpeg" alt="Nate Emmert" />
|
||||||
<div className="about-photo-divider" aria-hidden="true" />
|
<div className="about-photo-divider" aria-hidden="true" />
|
||||||
<div className="about-nate-copy">
|
<div className="about-nate-copy">
|
||||||
<p className="eyebrow">About Nate</p>
|
<p className="eyebrow">About Nate</p>
|
||||||
@@ -451,7 +908,7 @@ function AboutSection({ content }: { content: SiteContent }) {
|
|||||||
<p>{content.aboutShowP2}</p>
|
<p>{content.aboutShowP2}</p>
|
||||||
<div className="rule-divider" aria-hidden="true" />
|
<div className="rule-divider" aria-hidden="true" />
|
||||||
<img
|
<img
|
||||||
src={content.aboutVerseArtUrl || '/images/hebrews-4-12-verse-art.png'}
|
src="/images/hebrews-4-12-verse-art.png"
|
||||||
alt="Hebrews 4:12 verse artwork"
|
alt="Hebrews 4:12 verse artwork"
|
||||||
className="about-verse-art"
|
className="about-verse-art"
|
||||||
/>
|
/>
|
||||||
@@ -495,7 +952,7 @@ function StudyGuideSection({ content }: { content: SiteContent }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContactSection({ content }: { content: SiteContent }) {
|
function ContactSection() {
|
||||||
return (
|
return (
|
||||||
<section className="section-contact" id="contact" aria-label="Contact form">
|
<section className="section-contact" id="contact" aria-label="Contact form">
|
||||||
<div className="section-inner contact-inner">
|
<div className="section-inner contact-inner">
|
||||||
@@ -506,7 +963,7 @@ function ContactSection({ content }: { content: SiteContent }) {
|
|||||||
|
|
||||||
<div className="contact-profile">
|
<div className="contact-profile">
|
||||||
<img
|
<img
|
||||||
src={content.contactPhotoUrl || '/images/nate-contact-photo.png'}
|
src="/images/nate-contact-photo.png"
|
||||||
alt="Nate"
|
alt="Nate"
|
||||||
className="contact-profile-photo"
|
className="contact-profile-photo"
|
||||||
/>
|
/>
|
||||||
@@ -602,21 +1059,10 @@ function CustomResourcesSection({ content }: { content: SiteContent }) {
|
|||||||
<span className="ornament">✦</span>
|
<span className="ornament">✦</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="resources-list">
|
<div className="resources-list">
|
||||||
{resources.map(resource => (
|
{resources.map(l => (
|
||||||
<article key={resource.id} className="resource-download-card">
|
<a key={l.id} href={l.url} target="_blank" rel="noreferrer" className="resource-link">
|
||||||
<div className="resource-download-header">
|
{l.label} →
|
||||||
{resource.imageUrl && (
|
</a>
|
||||||
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" />
|
|
||||||
)}
|
|
||||||
<div className="resource-download-meta">
|
|
||||||
<span className="resource-link-label">{resource.label}</span>
|
|
||||||
{(resource.tags ?? []).length > 0 && (
|
|
||||||
<div className="resource-link-tags">{(resource.tags ?? []).join(', ')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ResourceDownloadForm resourceId={resource.id} buttonText={`Download ${resource.label}`} />
|
|
||||||
</article>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -903,7 +1349,7 @@ function ContactPage({ content }: { content: SiteContent }) {
|
|||||||
return (
|
return (
|
||||||
<div className="site">
|
<div className="site">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<ContactSection content={content} />
|
<ContactSection />
|
||||||
<SiteFooter content={content} />
|
<SiteFooter content={content} />
|
||||||
<AnalyticsConsentBanner />
|
<AnalyticsConsentBanner />
|
||||||
</div>
|
</div>
|
||||||
@@ -1343,7 +1789,6 @@ export default function App() {
|
|||||||
}, [content])
|
}, [content])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<LandingPage content={content} />} />
|
<Route path="/" element={<LandingPage content={content} />} />
|
||||||
<Route path="/start-here" element={<StartHerePage content={content} />} />
|
<Route path="/start-here" element={<StartHerePage content={content} />} />
|
||||||
@@ -1377,31 +1822,5 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
<BackToTopButton />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BackToTopButton() {
|
|
||||||
const [visible, setVisible] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onScroll = () => setVisible(window.scrollY > 320)
|
|
||||||
window.addEventListener('scroll', onScroll)
|
|
||||||
onScroll()
|
|
||||||
return () => window.removeEventListener('scroll', onScroll)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (!visible) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="back-to-top"
|
|
||||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
|
||||||
aria-label="Scroll back to top"
|
|
||||||
>
|
|
||||||
↑ Top
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import type { ChangeEvent } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
|
|
||||||
interface ContactFields {
|
|
||||||
firstName: string
|
|
||||||
lastName: string
|
|
||||||
email: string
|
|
||||||
message: string
|
|
||||||
messageType: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ContactForm() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [fields, setFields] = useState<ContactFields>({
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
email: '',
|
|
||||||
message: '',
|
|
||||||
messageType: 'question',
|
|
||||||
})
|
|
||||||
const [subscribe, setSubscribe] = useState(true)
|
|
||||||
const [honey, setHoney] = useState('')
|
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle')
|
|
||||||
const [errorMsg, setErrorMsg] = useState('')
|
|
||||||
|
|
||||||
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
|
|
||||||
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
|
|
||||||
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setStatus('submitting')
|
|
||||||
setErrorMsg('')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/contact', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ ...fields, subscribe, _honey: honey }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
setErrorMsg((data as { message?: string }).message ?? 'Something went wrong. Please try again.')
|
|
||||||
setStatus('error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate('/thanks')
|
|
||||||
} catch {
|
|
||||||
setErrorMsg('Could not connect. Please try again later.')
|
|
||||||
setStatus('error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="contact-form" onSubmit={handleSubmit} noValidate>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="contact-honeypot"
|
|
||||||
tabIndex={-1}
|
|
||||||
autoComplete="off"
|
|
||||||
aria-hidden="true"
|
|
||||||
value={honey}
|
|
||||||
onChange={e => setHoney(e.target.value)}
|
|
||||||
/>
|
|
||||||
<label>
|
|
||||||
First Name
|
|
||||||
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Last Name
|
|
||||||
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Email
|
|
||||||
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Message Type
|
|
||||||
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
|
|
||||||
<option value="question">Bible Question</option>
|
|
||||||
<option value="testimony">Testimony</option>
|
|
||||||
<option value="topic">Topic Request</option>
|
|
||||||
<option value="general">General Message</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Message
|
|
||||||
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
|
|
||||||
</label>
|
|
||||||
<label className="contact-consent">
|
|
||||||
<input
|
|
||||||
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>
|
|
||||||
</label>
|
|
||||||
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
|
|
||||||
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
|
||||||
{status === 'submitting' ? 'Sending…' : 'Send Message'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import type { KeyboardEvent } from 'react'
|
|
||||||
|
|
||||||
interface PublicQuestion {
|
|
||||||
id: string
|
|
||||||
firstName: string
|
|
||||||
question: string
|
|
||||||
answer: string
|
|
||||||
topic?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const QA_PAGE_SIZE = 6
|
|
||||||
|
|
||||||
function tokenizeForRelated(text: string) {
|
|
||||||
return text
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9\s]/g, ' ')
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter(token => token.length > 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function QASection() {
|
|
||||||
const [questions, setQuestions] = useState<PublicQuestion[]>([])
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
|
||||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
|
||||||
const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({})
|
|
||||||
const [page, setPage] = useState(0)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/questions')
|
|
||||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
|
|
||||||
.then(data => {
|
|
||||||
setQuestions((data as { questions: PublicQuestion[] }).questions ?? [])
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const topics = Array.from(new Set(questions.map(q => q.topic).filter(Boolean))) as string[]
|
|
||||||
|
|
||||||
const filteredQuestions = questions.filter(q => {
|
|
||||||
const matchesTopic = !selectedTopic || q.topic === selectedTopic
|
|
||||||
const matchesSearch =
|
|
||||||
!searchQuery ||
|
|
||||||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
q.answer.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
return matchesTopic && matchesSearch
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)
|
|
||||||
const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE)
|
|
||||||
|
|
||||||
const toggleExpanded = (id: string) => {
|
|
||||||
setExpanded(state => ({ ...state, [id]: !state[id] }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSearch = (value: string) => {
|
|
||||||
setSearchQuery(value)
|
|
||||||
setPage(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTopic = (topic: string | null) => {
|
|
||||||
setSelectedTopic(topic)
|
|
||||||
setPage(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRelatedQuestions = (current: PublicQuestion) => {
|
|
||||||
const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`))
|
|
||||||
|
|
||||||
return questions
|
|
||||||
.filter(candidate => candidate.id !== current.id)
|
|
||||||
.map(candidate => {
|
|
||||||
const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`)
|
|
||||||
const overlap = candidateTokens.filter(token => currentTokens.has(token)).length
|
|
||||||
const sameTopic = Boolean(current.topic && candidate.topic && current.topic === candidate.topic)
|
|
||||||
const score = overlap + (sameTopic ? 5 : 0)
|
|
||||||
return { candidate, score }
|
|
||||||
})
|
|
||||||
.filter(item => item.score > 0)
|
|
||||||
.sort((a, b) => b.score - a.score)
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(item => item.candidate)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="qa" className="section-qa" aria-label="Questions and answers">
|
|
||||||
<div className="section-inner">
|
|
||||||
<h2 className="section-heading">
|
|
||||||
<span className="ornament">✦</span> Questions & Answers <span className="ornament">✦</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p className="qa-no-results">Loading questions…</p>
|
|
||||||
) : questions.length === 0 ? (
|
|
||||||
<div className="qa-no-results">
|
|
||||||
<p>No questions have been answered yet. <a href="#contact">Submit yours below!</a></p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="qa-filters">
|
|
||||||
<div className="qa-topics">
|
|
||||||
<button
|
|
||||||
className={`qa-topic-btn${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
|
|
||||||
onClick={() => handleTopic(null)}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
{topics.map(topic => (
|
|
||||||
<button
|
|
||||||
key={topic}
|
|
||||||
className={`qa-topic-btn${selectedTopic === topic ? ' qa-topic-btn--active' : ''}`}
|
|
||||||
onClick={() => handleTopic(topic)}
|
|
||||||
>
|
|
||||||
{topic}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="qa-search">
|
|
||||||
<label>
|
|
||||||
<span className="visually-hidden">Search Questions</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search questions…"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={e => handleSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredQuestions.length === 0 ? (
|
|
||||||
<div className="qa-no-results">
|
|
||||||
<p>No matching questions found. <a href="#contact">Submit your question</a></p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="qa-cards">
|
|
||||||
{pagedQuestions.map(question => (
|
|
||||||
<div key={question.id} className="qa-card-scene">
|
|
||||||
<div
|
|
||||||
className={`qa-card-inner ${expanded[question.id] ? 'flipped' : ''}`}
|
|
||||||
onClick={() => toggleExpanded(question.id)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-expanded={!!expanded[question.id]}
|
|
||||||
aria-label={question.question}
|
|
||||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') toggleExpanded(question.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="qa-card-face qa-card-front">
|
|
||||||
<span className="qa-face-label">Q</span>
|
|
||||||
<p className="qa-question-text">{question.question}</p>
|
|
||||||
<span className="qa-flip-hint">{expanded[question.id] ? '▲' : '▼'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="qa-card-face qa-card-back">
|
|
||||||
<span className="qa-face-label">A</span>
|
|
||||||
<p className="qa-answer-text">{question.answer}</p>
|
|
||||||
{getRelatedQuestions(question).length > 0 && (
|
|
||||||
<div className="qa-related-wrap">
|
|
||||||
<p className="qa-related-label">Related questions</p>
|
|
||||||
<div className="qa-related-list">
|
|
||||||
{getRelatedQuestions(question).map(related => (
|
|
||||||
<button
|
|
||||||
key={related.id}
|
|
||||||
type="button"
|
|
||||||
className="qa-related-btn"
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleTopic(null)
|
|
||||||
handleSearch(related.question)
|
|
||||||
setExpanded({ [related.id]: true })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{related.question}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p style={{ margin: '0.75rem 0 0', fontSize: '0.8rem', color: '#a89060', fontStyle: 'italic' }}>
|
|
||||||
— Answered by Nate
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="qa-pagination">
|
|
||||||
<button
|
|
||||||
className="qa-page-btn"
|
|
||||||
onClick={() => setPage(p => p - 1)}
|
|
||||||
disabled={page === 0}
|
|
||||||
aria-label="Previous page"
|
|
||||||
>
|
|
||||||
‹ Prev
|
|
||||||
</button>
|
|
||||||
<span className="qa-page-info">
|
|
||||||
{page + 1} / {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="qa-page-btn"
|
|
||||||
onClick={() => setPage(p => p + 1)}
|
|
||||||
disabled={page >= totalPages - 1}
|
|
||||||
aria-label="Next page"
|
|
||||||
>
|
|
||||||
Next ›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-206
@@ -1,206 +0,0 @@
|
|||||||
export interface CustomLink {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
url: string
|
|
||||||
placement: 'platforms' | 'footer' | 'resources'
|
|
||||||
imageUrl?: string
|
|
||||||
tags?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CustomBlock {
|
|
||||||
id: string
|
|
||||||
heading: string
|
|
||||||
body: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ArchivedSeriesResourceLink {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ArchivedSeriesNote {
|
|
||||||
id: string
|
|
||||||
heading: string
|
|
||||||
body: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ArchivedSeries {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
imageUrl: string
|
|
||||||
listenUrl: string
|
|
||||||
studyGuideTitle: string
|
|
||||||
studyGuideDescription: string
|
|
||||||
studyGuideUrl: string
|
|
||||||
resourceLinks: ArchivedSeriesResourceLink[]
|
|
||||||
notes: ArchivedSeriesNote[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RedirectRule {
|
|
||||||
id: string
|
|
||||||
path: string
|
|
||||||
target: string
|
|
||||||
statusCode: 301 | 302
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PodcastFeaturedLink {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
episodeNumber?: string
|
|
||||||
summary: string
|
|
||||||
url: string
|
|
||||||
embedUrl?: string
|
|
||||||
showNotes?: string
|
|
||||||
discussionQuestions?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SeoSettings {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
ogTitle: string
|
|
||||||
ogDescription: string
|
|
||||||
ogImage: string
|
|
||||||
canonicalUrl: string
|
|
||||||
robotsPolicy: string
|
|
||||||
sitemapPaths: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LegalSettings {
|
|
||||||
privacyTitle: string
|
|
||||||
privacyBody: string[]
|
|
||||||
termsTitle: string
|
|
||||||
termsBody: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SiteContent {
|
|
||||||
eyebrow: string
|
|
||||||
heroTagline: string
|
|
||||||
startHereHeading: string
|
|
||||||
startHereIntro: string
|
|
||||||
startHereStep1Title: string
|
|
||||||
startHereStep1Body: string
|
|
||||||
startHereStep1Cta: string
|
|
||||||
startHereStep2Title: string
|
|
||||||
startHereStep2Body: string
|
|
||||||
startHereStep2Cta: string
|
|
||||||
startHereStep3Title: string
|
|
||||||
startHereStep3Body: string
|
|
||||||
startHereStep3Cta: string
|
|
||||||
aboutShowHeading: string
|
|
||||||
aboutShowP1: string
|
|
||||||
aboutShowP2: string
|
|
||||||
aboutNate: string
|
|
||||||
aboutPhotoUrl: string
|
|
||||||
aboutVerseArtUrl: string
|
|
||||||
contactPhotoUrl: string
|
|
||||||
seriesLabel: string
|
|
||||||
seriesTitle: string
|
|
||||||
seriesDescription: string
|
|
||||||
seriesImageUrl: string
|
|
||||||
seriesListenUrl: string
|
|
||||||
studyGuideTitle: string
|
|
||||||
studyGuideDescription: string
|
|
||||||
studyGuideUrl: string
|
|
||||||
shareHeading: string
|
|
||||||
shareP: string
|
|
||||||
customLinks: CustomLink[]
|
|
||||||
customBlocks: CustomBlock[]
|
|
||||||
archivedSeries: ArchivedSeries[]
|
|
||||||
redirects: RedirectRule[]
|
|
||||||
podcastFeaturedLinks: PodcastFeaturedLink[]
|
|
||||||
seo: SeoSettings
|
|
||||||
legal: LegalSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULTS: SiteContent = {
|
|
||||||
eyebrow: 'A Journey Through Scripture',
|
|
||||||
heroTagline: "Exploring God's Word one verse at a time",
|
|
||||||
startHereHeading: 'New Here? Start Here',
|
|
||||||
startHereIntro:
|
|
||||||
'If this is your first visit, this path helps you get grounded quickly and make the most of the site.',
|
|
||||||
startHereStep1Title: 'Step 1: Listen to 3 Starter Episodes',
|
|
||||||
startHereStep1Body:
|
|
||||||
'Start with the newest episode, one from the beginning of the current study, and one from the middle.',
|
|
||||||
startHereStep1Cta: 'Open Episodes',
|
|
||||||
startHereStep2Title: 'Step 2: Use Q&A to Go Deeper',
|
|
||||||
startHereStep2Body:
|
|
||||||
'Browse by topic or search key words to find concise biblical answers and related follow-up questions.',
|
|
||||||
startHereStep2Cta: 'Go to Q&A',
|
|
||||||
startHereStep3Title: 'Step 3: Ask Nate Directly',
|
|
||||||
startHereStep3Body:
|
|
||||||
'Use the contact form to submit your Bible question for future Q&A or an upcoming episode.',
|
|
||||||
startHereStep3Cta: 'Submit a Question',
|
|
||||||
aboutShowHeading: 'Depth. Clarity. Application.',
|
|
||||||
aboutShowP1:
|
|
||||||
'Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.',
|
|
||||||
aboutShowP2:
|
|
||||||
"Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
|
|
||||||
aboutNate:
|
|
||||||
"Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
|
||||||
aboutPhotoUrl: '/images/nate-photo.jpeg',
|
|
||||||
aboutVerseArtUrl: '/images/hebrews-4-12-verse-art.png',
|
|
||||||
contactPhotoUrl: '/images/nate-contact-photo.png',
|
|
||||||
seriesLabel: 'Now Playing',
|
|
||||||
seriesTitle: 'Study of Titus: Sound Doctrine',
|
|
||||||
seriesDescription:
|
|
||||||
"A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
|
||||||
seriesImageUrl: '/images/titus-cover.png',
|
|
||||||
seriesListenUrl: '/spotify',
|
|
||||||
studyGuideTitle: 'Companion Study Guide',
|
|
||||||
studyGuideDescription:
|
|
||||||
'Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.',
|
|
||||||
studyGuideUrl: 'https://a.co/d/01sG2tOJ',
|
|
||||||
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.',
|
|
||||||
customLinks: [],
|
|
||||||
customBlocks: [],
|
|
||||||
archivedSeries: [],
|
|
||||||
redirects: [
|
|
||||||
{
|
|
||||||
id: 'spotify',
|
|
||||||
path: '/spotify',
|
|
||||||
target: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
|
|
||||||
statusCode: 301,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'apple',
|
|
||||||
path: '/apple',
|
|
||||||
target: 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate',
|
|
||||||
statusCode: 301,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'amazon',
|
|
||||||
path: '/amazon',
|
|
||||||
target: 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate',
|
|
||||||
statusCode: 301,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
podcastFeaturedLinks: [],
|
|
||||||
seo: {
|
|
||||||
title: 'Verse by Verse with Nate',
|
|
||||||
description: "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
|
||||||
ogTitle: 'Verse by Verse with Nate',
|
|
||||||
ogDescription: 'A Journey Through Scripture - verse by verse, nugget by nugget.',
|
|
||||||
ogImage: '/images/podcast-art.jpeg',
|
|
||||||
canonicalUrl: 'https://versebyversewithnate.us/',
|
|
||||||
robotsPolicy: 'index,follow',
|
|
||||||
sitemapPaths: ['/', '/start-here', '/questions', '/privacy', '/terms'],
|
|
||||||
},
|
|
||||||
legal: {
|
|
||||||
privacyTitle: 'Privacy Policy',
|
|
||||||
privacyBody: [
|
|
||||||
'We respect your privacy and collect limited data to operate and improve this site.',
|
|
||||||
'If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.',
|
|
||||||
'Contact form details are used only to respond to your message and ministry communication requests.',
|
|
||||||
],
|
|
||||||
termsTitle: 'Terms',
|
|
||||||
termsBody: [
|
|
||||||
'Content on this site is for informational and ministry purposes.',
|
|
||||||
'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.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
export function FacebookIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
||||||
<path d="M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.234 2.686.234v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SpotifyIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
||||||
<path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function YouTubeIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
||||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AmazonMusicIcon() {
|
|
||||||
return (
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
||||||
<path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+3
-1
@@ -1,9 +1,11 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
import { cloudflare } from "@cloudflare/vite-plugin";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), cloudflare()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:4173',
|
'/api': 'http://localhost:4173',
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"name": "siteforge",
|
||||||
|
"compatibility_date": "2026-05-04",
|
||||||
|
"observability": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"assets": {
|
||||||
|
"not_found_handling": "single-page-application"
|
||||||
|
},
|
||||||
|
"compatibility_flags": [
|
||||||
|
"nodejs_compat"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user