Reorganize admin workflow and persist episode discussion questions

This commit is contained in:
nmemmert
2026-04-27 16:25:26 -04:00
parent 83743cc78b
commit b26103b7b1
7 changed files with 2291 additions and 602 deletions
+17 -18
View File
@@ -54,15 +54,20 @@ Admin auth:
- Set `ADMIN_PASSWORD` on the server/container to protect `/admin` and admin stats/maintenance endpoints. - Set `ADMIN_PASSWORD` on the server/container to protect `/admin` and admin stats/maintenance endpoints.
- Without `ADMIN_PASSWORD`, admin login is disabled until configured. - Without `ADMIN_PASSWORD`, admin login is disabled until configured.
- Optional: set `CACHE_PURGE_WEBHOOK_URL` to enable one-click cache purge from Admin Operations.
- Optional: set `DEPLOY_WEBHOOK_URL` to enable one-click deploy trigger from Admin Operations.
- Optional: set `BUILD_COMMIT`, `BUILD_NUMBER`, and `DEPLOYED_AT` to display deployment status in Admin.
The app will be available at `http://localhost:4173`. The app will be available at `http://localhost:4173`.
Persistent admin saves: Persistent admin saves:
- Admin updates are written to `data/admin-content.json`. - Admin updates are written to `data/admin-content.json`.
- Admin drafts are written to `data/admin-content-draft.json`.
- Built-in page hit stats are written to `data/hit-stats.json`. - Built-in page hit stats are written to `data/hit-stats.json`.
- Detailed visitor analytics are written to `data/visitor-stats.json`. - Detailed visitor analytics are written to `data/visitor-stats.json`.
- Backup snapshots are written to `data/backups/`. - Backup snapshots are written to `data/backups/`.
- Uploaded admin assets are stored in `data/uploads/` and served at `/uploads/*`.
- `docker-compose.yml` mounts `./data` into the container at `/app/data`. - `docker-compose.yml` mounts `./data` into the container at `/app/data`.
- This keeps all admin-managed data after container restarts/rebuilds/updates. - This keeps all admin-managed data after container restarts/rebuilds/updates.
@@ -75,18 +80,20 @@ Persistent admin saves:
## Routes ## Routes
- Portfolio home: `/` - Site home: `/`
- Project details: `/projects/:slug` - Start Here: `/start-here`
- Questions: `/questions`
- Privacy: `/privacy`
- Terms: `/terms`
- Admin editor: `/admin` - Admin editor: `/admin`
- Admin draft preview: `/preview`
## Admin editing ## Admin editing
- Open `/admin` to edit project card and featured-page content. - Open `/admin` to edit homepage content, links, archived studies, Q&A moderation, and analytics.
- Use **Global Settings** to update theme, home headers, and quick tips. - Use **Operations** to manage SEO metadata, sitemap paths, legal page text, redirect rules, and podcast highlights.
- Use **Project Settings** to update project-specific fields and README URL. - Use **Operations** to upload/delete hosted image assets for use in content and social cards.
- Use **Create new entry** to add a new project directly from Admin. - Use **Save Draft**, **Preview Draft**, and **Publish Draft** for a safer publish workflow.
- Enter a URL and click **Scan URL metadata** to auto-fill title, summary, domain, category, and icon when available.
- Click **Save project** to apply updates instantly.
- Saved edits are written to `data/admin-content.json` through the API server. - Saved edits are written to `data/admin-content.json` through the API server.
- Built-in stats in `/admin` include page hits plus visitor details (IP, country/state/county/city, returning visitors, and recent visitor log). - Built-in stats in `/admin` include page hits plus visitor details (IP, country/state/county/city, returning visitors, and recent visitor log).
- Site Stats in `/admin` includes a Bible Questions inbox sourced from contact form submissions marked as Bible Question. - Site Stats in `/admin` includes a Bible Questions inbox sourced from contact form submissions marked as Bible Question.
@@ -94,19 +101,11 @@ Persistent admin saves:
- Admin now includes maintenance actions: **Export JSON**, **Backup Now**, **Prune Old Data**, and **Clear Analytics**. - Admin now includes maintenance actions: **Export JSON**, **Backup Now**, **Prune Old Data**, and **Clear Analytics**.
- Admin also supports restoring from a backup snapshot from `/admin`. - Admin also supports restoring from a backup snapshot from `/admin`.
- The server creates startup + daily backup snapshots and retains recent backups automatically. - The server creates startup + daily backup snapshots and retains recent backups automatically.
- Use the **Theme** dropdown to switch between Sandstone, Ocean, Midnight, Forest, and Sunset. - Operations can trigger optional **Purge Cache** and **Trigger Deploy** webhooks.
- Use **Remove project** to delete the selected project from your local Admin data.
- Use **Reset project** or **Reset all** to restore defaults from `src/data/projects.ts`.
Scan notes:
- URL scanning is best-effort and depends on site accessibility.
- Some sites block direct scanning; Admin falls back to a proxy scan path when possible.
- Always review scanned fields before saving.
Deployment note: Deployment note:
- To keep Admin saves working on the internet, deploy with the Node API (`server.js`) and writable server storage for `data/admin-content.json`. - To keep Admin saves working on the internet, deploy with the Node API (`server.js`) and writable server storage for `data/`.
Titus study download gate: Titus study download gate:
+101
View File
@@ -0,0 +1,101 @@
{
"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": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
"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": [
{
"id": "sample-highlight-1",
"title": "Introduction to Titus",
"episodeNumber": "2",
"summary": "A practical walkthrough of context, structure, and application for this study.",
"url": "https://open.spotify.com/episode/3mpFfJQP6avAhzxu4BTKvR?si=cl_Dm_2vQraiboouaoINaw",
"embedUrl": "",
"showNotes": "",
"discussionQuestions": [
"1. What do you already know about Paul, Titus, or the island of Crete? And what — if anything — surprised you about the background of this letter?",
"2. The letters core theme is that sound doctrine leads to godly living. Where have you seen that connection — or its breakdown — in your own experience?",
"3. Paul trusted Titus with a hard assignment. Who has trusted you with something difficult — and what did that trust mean to you?"
]
}
],
"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."
]
}
},
"updatedAt": "2026-04-27T20:24:22.589Z"
}
+79 -2
View File
@@ -2,6 +2,17 @@
"siteContent": { "siteContent": {
"eyebrow": "A Journey Through Scripture", "eyebrow": "A Journey Through Scripture",
"heroTagline": "Exploring God's Word one verse at a time", "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.", "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.", "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.",
@@ -18,7 +29,73 @@
"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": [],
"customBlocks": [], "customBlocks": [],
"archivedSeries": [] "archivedSeries": [],
"redirects": [
{
"id": "spotify",
"path": "/spotify",
"target": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
"statusCode": 301
}, },
"updatedAt": "2026-04-16T12:13:53.095Z" {
"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": [
{
"id": "sample-highlight-1",
"title": "Introduction to Titus",
"episodeNumber": "2",
"summary": "A practical walkthrough of context, structure, and application for this study.",
"url": "https://open.spotify.com/episode/3mpFfJQP6avAhzxu4BTKvR?si=cl_Dm_2vQraiboouaoINaw",
"embedUrl": "",
"showNotes": "",
"discussionQuestions": [
"1. What do you already know about Paul, Titus, or the island of Crete? And what — if anything — surprised you about the background of this letter?",
"2. The letters core theme is that sound doctrine leads to godly living. Where have you seen that connection — or its breakdown — in your own experience?",
"3. Paul trusted Titus with a hard assignment. Who has trusted you with something difficult — and what did that trust mean to you?"
]
}
],
"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."
]
}
},
"updatedAt": "2026-04-27T20:24:19.789Z"
} }
+608 -12
View File
@@ -26,12 +26,14 @@ const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) const __dirname = path.dirname(__filename)
const DATA_DIR = path.join(__dirname, 'data') const DATA_DIR = path.join(__dirname, 'data')
const DATA_FILE = path.join(DATA_DIR, 'admin-content.json') const DATA_FILE = path.join(DATA_DIR, 'admin-content.json')
const DRAFT_DATA_FILE = path.join(DATA_DIR, 'admin-content-draft.json')
const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json') 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 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 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')
@@ -40,6 +42,312 @@ 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')
const TITUS_STUDY_DOWNLOAD_NAME = process.env.TITUS_STUDY_DOWNLOAD_NAME ?? 'A_Study_of_Titus.pdf' const TITUS_STUDY_DOWNLOAD_NAME = process.env.TITUS_STUDY_DOWNLOAD_NAME ?? 'A_Study_of_Titus.pdf'
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,
},
]
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'],
}
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.',
],
}
const DEFAULT_PODCAST_FEATURED_LINKS = []
const DEFAULT_PUBLISH_STATE = {
draftUpdatedAt: null,
publishedAt: null,
}
let cachedSiteContent = null
let cachedDraftSiteContent = null
let publishState = { ...DEFAULT_PUBLISH_STATE }
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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
async function loadSiteContentFile(filePath) {
const raw = await readFile(filePath, 'utf8')
const parsed = JSON.parse(raw)
const safeSiteContent = sanitizeSiteContent(parsed?.siteContent)
return {
...parsed,
siteContent: safeSiteContent,
}
}
async function refreshContentCaches() {
try {
const published = await loadSiteContentFile(DATA_FILE)
cachedSiteContent = published.siteContent
if (typeof published?.updatedAt === 'string') {
publishState.publishedAt = published.updatedAt
}
} catch {
cachedSiteContent = null
}
try {
const draft = await loadSiteContentFile(DRAFT_DATA_FILE)
cachedDraftSiteContent = draft.siteContent
if (typeof draft?.updatedAt === 'string') {
publishState.draftUpdatedAt = draft.updatedAt
}
} catch {
cachedDraftSiteContent = null
}
}
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}`
}
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)}" />`)
}
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()}`
}
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
}
async function listUploadedAssets() {
await mkdir(UPLOADS_DIR, { recursive: true })
const files = await readdir(UPLOADS_DIR)
const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif)$/i.test(name)).sort()
const withStats = await Promise.all(imageFiles.map(async filename => {
const info = await stat(path.join(UPLOADS_DIR, filename))
return {
filename,
url: `/uploads/${filename}`,
sizeBytes: info.size,
updatedAt: info.mtime.toISOString(),
}
}))
return withStats
}
async function invokeWebhook(url, action) {
if (!url) {
return { ok: false, message: `${action} webhook URL is not configured.` }
}
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action,
at: new Date().toISOString(),
source: 'siteforge-admin',
}),
})
if (!response.ok) {
return { ok: false, message: `${action} webhook failed with ${response.status}.` }
}
return { ok: true, message: `${action} webhook triggered.` }
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }
}
}
const EMPTY_HIT_STATS = { const EMPTY_HIT_STATS = {
totalHits: 0, totalHits: 0,
@@ -87,6 +395,8 @@ let questionsWritePromise = Promise.resolve()
let lastVisitorStatsWrite = { ok: true, at: null, error: null } let lastVisitorStatsWrite = { ok: true, at: null, error: null }
let lastHitStatsWrite = { ok: true, at: null, error: null } let lastHitStatsWrite = { ok: true, at: null, error: null }
let lastBackupStatus = { ok: true, at: null, error: null, file: null } let lastBackupStatus = { ok: true, at: null, error: null, file: null }
let lastCachePurgeStatus = { ok: true, at: null, error: null }
let lastDeployHookStatus = { ok: true, at: null, error: null }
const adminSessions = new Map() const adminSessions = new Map()
function sha256(value) { function sha256(value) {
@@ -561,6 +871,8 @@ async function createBackupSnapshot(reason = 'scheduled') {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
reason, reason,
adminContent: null, adminContent: null,
draftContent: null,
publishState,
hitStats, hitStats,
visitorStats, visitorStats,
contactSubmissions, contactSubmissions,
@@ -573,6 +885,13 @@ async function createBackupSnapshot(reason = 'scheduled') {
payload.adminContent = null payload.adminContent = null
} }
try {
const draftRaw = await readFile(DRAFT_DATA_FILE, 'utf8')
payload.draftContent = JSON.parse(draftRaw)
} catch {
payload.draftContent = null
}
await writeFile(backupPath, JSON.stringify(payload, null, 2), 'utf8') await writeFile(backupPath, JSON.stringify(payload, null, 2), 'utf8')
const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort() const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort()
@@ -682,6 +1001,18 @@ async function restoreFromBackup(filename) {
await writeFile(DATA_FILE, JSON.stringify(parsed.adminContent, null, 2), 'utf8') await writeFile(DATA_FILE, JSON.stringify(parsed.adminContent, null, 2), 'utf8')
} }
if (parsed?.draftContent && typeof parsed.draftContent === 'object') {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(DRAFT_DATA_FILE, JSON.stringify(parsed.draftContent, null, 2), 'utf8')
}
if (parsed?.publishState && typeof parsed.publishState === 'object') {
publishState = {
draftUpdatedAt: typeof parsed.publishState.draftUpdatedAt === 'string' ? parsed.publishState.draftUpdatedAt : null,
publishedAt: typeof parsed.publishState.publishedAt === 'string' ? parsed.publishState.publishedAt : null,
}
}
hitStats = sanitizeLoadedHitStats(parsed?.hitStats) hitStats = sanitizeLoadedHitStats(parsed?.hitStats)
visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats) visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats)
contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions) contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
@@ -691,6 +1022,7 @@ async function restoreFromBackup(filename) {
queueContactSubmissionsWrite() queueContactSubmissionsWrite()
await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise]) await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise])
await refreshContentCaches()
await createBackupSnapshot('post-restore') await createBackupSnapshot('post-restore')
} }
@@ -785,16 +1117,199 @@ const app = express()
app.use(express.json({ limit: '10mb' })) app.use(express.json({ limit: '10mb' }))
app.set('trust proxy', true) app.set('trust proxy', true)
app.get('/api/admin-content', async (_req, res) => { app.get('/api/admin-content', async (req, res) => {
const source = req.query?.source === 'draft' ? 'draft' : 'published'
if (source === 'draft' && !isValidAdminSession(req)) {
res.status(401).json({ message: 'Unauthorized' })
return
}
try { try {
const raw = await readFile(DATA_FILE, 'utf8') const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
const parsed = JSON.parse(raw)
res.json(parsed) res.json(parsed)
} catch { } catch {
if (source === 'draft') {
res.status(404).json({ message: 'No saved draft content file yet.' })
return
}
res.status(404).json({ message: 'No saved admin content file yet.' }) res.status(404).json({ message: 'No saved admin content file yet.' })
} }
}) })
app.get('/api/admin-content-state', requireAdminAuth, (_req, res) => {
res.json({
publishState,
hasDraft: Boolean(cachedDraftSiteContent),
hasPublished: Boolean(cachedSiteContent),
})
})
app.get('/api/site-config', async (_req, res) => {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
const siteContent = parsed.siteContent ?? {}
res.json({
seo: siteContent.seo ?? DEFAULT_SEO,
legal: siteContent.legal ?? DEFAULT_LEGAL,
redirects: siteContent.redirects ?? DEFAULT_REDIRECT_RULES,
podcastFeaturedLinks: siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS,
publishState,
updatedAt: parsed.updatedAt ?? null,
})
} catch {
res.json({
seo: DEFAULT_SEO,
legal: DEFAULT_LEGAL,
redirects: DEFAULT_REDIRECT_RULES,
podcastFeaturedLinks: DEFAULT_PODCAST_FEATURED_LINKS,
publishState,
updatedAt: null,
})
}
})
app.put('/api/admin-content-draft', requireAdminAuth, async (req, res) => {
try {
const { siteContent } = req.body ?? {}
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) {
res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' })
return
}
const safeSiteContent = sanitizeSiteContent(siteContent)
const updatedAt = new Date().toISOString()
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
DRAFT_DATA_FILE,
JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
'utf8',
)
cachedDraftSiteContent = safeSiteContent
publishState.draftUpdatedAt = updatedAt
res.json({ ok: true, updatedAt })
} catch {
res.status(500).json({ message: 'Failed to persist admin draft content.' })
}
})
app.post('/api/admin-content/publish', requireAdminAuth, async (_req, res) => {
try {
const source = cachedDraftSiteContent
? { siteContent: cachedDraftSiteContent, updatedAt: publishState.draftUpdatedAt ?? new Date().toISOString() }
: await loadSiteContentFile(DRAFT_DATA_FILE)
const publishedAt = new Date().toISOString()
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
DATA_FILE,
JSON.stringify({ siteContent: source.siteContent, updatedAt: publishedAt }, null, 2),
'utf8',
)
cachedSiteContent = source.siteContent
publishState.publishedAt = publishedAt
await createBackupSnapshot('post-publish')
res.json({ ok: true, publishedAt })
} catch {
res.status(500).json({ message: 'Failed to publish draft content.' })
}
})
app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => {
try {
const assets = await listUploadedAssets()
res.json({ assets })
} catch {
res.status(500).json({ message: 'Could not list uploaded assets.' })
}
})
app.post('/api/admin-assets', requireAdminAuth, async (req, res) => {
try {
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
const ext = inferImageExtensionFromDataUrl(dataUrl)
if (!ext) {
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, or GIF data URL.' })
return
}
const base64 = dataUrl.split(',')[1] ?? ''
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0 || buffer.length > (8 * 1024 * 1024)) {
res.status(400).json({ message: 'Upload must be between 1 byte and 8MB.' })
return
}
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
const finalName = `${baseName}-${Date.now()}${ext}`
await mkdir(UPLOADS_DIR, { recursive: true })
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } })
} catch {
res.status(500).json({ message: 'Upload failed.' })
}
})
app.delete('/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
}
await unlink(path.join(UPLOADS_DIR, filename))
res.json({ ok: true })
} catch {
res.status(404).json({ message: 'Asset not found.' })
}
})
app.get('/api/admin-ops/status', requireAdminAuth, (_req, res) => {
res.json({
buildCommit: process.env.BUILD_COMMIT ?? null,
buildNumber: process.env.BUILD_NUMBER ?? null,
deployedAt: process.env.DEPLOYED_AT ?? null,
cachePurge: lastCachePurgeStatus,
deployHook: lastDeployHookStatus,
})
})
app.post('/api/admin-ops/purge-cache', requireAdminAuth, async (_req, res) => {
const result = await invokeWebhook(process.env.CACHE_PURGE_WEBHOOK_URL ?? '', 'cache-purge')
lastCachePurgeStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }
if (!result.ok) {
res.status(400).json({ message: result.message })
return
}
res.json({ ok: true, message: result.message })
})
app.post('/api/admin-ops/deploy', requireAdminAuth, async (_req, res) => {
const result = await invokeWebhook(process.env.DEPLOY_WEBHOOK_URL ?? '', 'deploy')
lastDeployHookStatus = { ok: result.ok, at: new Date().toISOString(), error: result.ok ? null : result.message }
if (!result.ok) {
res.status(400).json({ message: result.message })
return
}
res.json({ ok: true, message: result.message })
})
// ── Chatbot knowledge base ────────────────────────────────────────────────── // ── Chatbot knowledge base ──────────────────────────────────────────────────
const MAX_CHATBOT_ENTRIES = 500 const MAX_CHATBOT_ENTRIES = 500
let chatbotEntries = [] let chatbotEntries = []
@@ -961,13 +1476,19 @@ app.put('/api/admin-content', requireAdminAuth, async (req, res) => {
return return
} }
const safeSiteContent = sanitizeSiteContent(siteContent)
const updatedAt = new Date().toISOString()
await mkdir(DATA_DIR, { recursive: true }) await mkdir(DATA_DIR, { recursive: true })
await writeFile( await writeFile(
DATA_FILE, DATA_FILE,
JSON.stringify({ siteContent, updatedAt: new Date().toISOString() }, null, 2), JSON.stringify({ siteContent: safeSiteContent, updatedAt }, null, 2),
'utf8', 'utf8',
) )
cachedSiteContent = safeSiteContent
publishState.publishedAt = updatedAt
res.json({ ok: true }) res.json({ ok: true })
} catch { } catch {
res.status(500).json({ message: 'Failed to persist admin content.' }) res.status(500).json({ message: 'Failed to persist admin content.' })
@@ -1011,6 +1532,8 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
hitStats: lastHitStatsWrite, hitStats: lastHitStatsWrite,
visitorStats: lastVisitorStatsWrite, visitorStats: lastVisitorStatsWrite,
backups: lastBackupStatus, backups: lastBackupStatus,
cachePurge: lastCachePurgeStatus,
deployHook: lastDeployHookStatus,
}, },
contactTotals: { contactTotals: {
totalSubmissions: contactSubmissions.length, totalSubmissions: contactSubmissions.length,
@@ -1021,6 +1544,7 @@ app.get('/api/admin-stats', requireAdminAuth, (_req, res) => {
app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => { app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
let adminContent = null let adminContent = null
let draftContent = null
try { try {
const raw = await readFile(DATA_FILE, 'utf8') const raw = await readFile(DATA_FILE, 'utf8')
adminContent = JSON.parse(raw) adminContent = JSON.parse(raw)
@@ -1028,9 +1552,18 @@ app.get('/api/admin-stats/export', requireAdminAuth, async (_req, res) => {
adminContent = null adminContent = null
} }
try {
const rawDraft = await readFile(DRAFT_DATA_FILE, 'utf8')
draftContent = JSON.parse(rawDraft)
} catch {
draftContent = null
}
res.json({ res.json({
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
adminContent, adminContent,
draftContent,
publishState,
hitStats, hitStats,
visitorStats, visitorStats,
contactSubmissions, contactSubmissions,
@@ -1470,34 +2003,97 @@ app.get('/api/episodes', async (_req, res) => {
} }
}) })
app.get('/spotify', (_req, res) => { app.get('/robots.txt', async (_req, res) => {
res.redirect(301, 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl') let content = cachedSiteContent
if (!content) {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
content = parsed.siteContent
} catch {
content = {}
}
}
const seo = content?.seo ?? DEFAULT_SEO
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
res.type('text/plain').send(
[
'User-agent: *',
'Allow: /',
`Sitemap: ${root}/sitemap.xml`,
].join('\n'),
)
}) })
app.get('/apple', (_req, res) => { app.get('/sitemap.xml', async (_req, res) => {
res.redirect(301, 'https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate') let content = cachedSiteContent
if (!content) {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
content = parsed.siteContent
} catch {
content = {}
}
}
const seo = content?.seo ?? DEFAULT_SEO
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
const paths = Array.isArray(seo.sitemapPaths) && seo.sitemapPaths.length > 0
? seo.sitemapPaths
: DEFAULT_SEO.sitemapPaths
const urls = paths
.map(item => normalizeSitemapPath(item))
.filter(Boolean)
.map(item => `${root}${item}`)
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...urls.map(url => ` <url><loc>${escapeXml(url)}</loc></url>`),
'</urlset>',
].join('\n')
res.type('application/xml').send(xml)
}) })
app.get('/amazon', (_req, res) => { app.use((req, res, next) => {
res.redirect(301, 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate') const rules = sanitizeRedirectRules(cachedSiteContent?.redirects)
const match = rules.find(rule => rule.path === req.path)
if (!match) {
next()
return
}
res.redirect(match.statusCode === 302 ? 302 : 301, match.target)
}) })
app.use('/images', express.static(DIST_IMAGES_DIR)) app.use('/images', express.static(DIST_IMAGES_DIR))
app.use('/images', express.static(PUBLIC_IMAGES_DIR)) app.use('/images', express.static(PUBLIC_IMAGES_DIR))
app.use('/uploads', express.static(UPLOADS_DIR))
app.use(express.static(DIST_DIR)) app.use(express.static(DIST_DIR))
app.use(async (_req, res) => { app.use(async (_req, res) => {
try { try {
const html = await readFile(INDEX_FILE, 'utf8') const html = await readFile(INDEX_FILE, 'utf8')
res.type('html').send(html) res.type('html').send(injectSeoIntoHtml(html, cachedSiteContent))
} catch { } catch {
res.status(503).send('Frontend build not found. Run "npm run build" first.') res.status(503).send('Frontend build not found. Run "npm run build" first.')
} }
}) })
const PORT = Number(process.env.PORT ?? 4173) const PORT = Number(process.env.PORT ?? 4173)
Promise.all([loadHitStatsFromDisk(), loadVisitorStatsFromDisk(), loadContactSubmissionsFromDisk(), loadQuestionsFromDisk(), loadChatbotFromDisk()]) Promise.all([
loadHitStatsFromDisk(),
loadVisitorStatsFromDisk(),
loadContactSubmissionsFromDisk(),
loadQuestionsFromDisk(),
loadChatbotFromDisk(),
refreshContentCaches(),
])
.catch(err => { .catch(err => {
console.error('[stats] failed to load persisted stats:', err) console.error('[stats] failed to load persisted stats:', err)
}) })
+628 -11
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } 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 } from './App' import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './App'
import { DEFAULTS } from './App' import { DEFAULTS } from './App'
interface Props { interface Props {
@@ -60,6 +61,26 @@ interface AdminStats {
} }
} }
interface AdminAsset {
filename: string
url: string
sizeBytes: number
updatedAt: string
}
interface PublishState {
draftUpdatedAt: string | null
publishedAt: string | null
}
interface OpsStatus {
buildCommit: string | null
buildNumber: string | null
deployedAt: string | null
cachePurge: { ok: boolean; at: string | null; error: string | null }
deployHook: { ok: boolean; at: string | null; error: string | null }
}
interface Question { interface Question {
id: string id: string
submittedAt: string submittedAt: string
@@ -71,7 +92,7 @@ interface Question {
isApproved: boolean isApproved: boolean
approvedAt: string | null approvedAt: string | null
} }
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries'> type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal'>
type MainContentSection = 'hero' | 'start-here' | 'about' | 'series' | 'share' type MainContentSection = 'hero' | 'start-here' | 'about' | 'series' | 'share'
@@ -174,14 +195,19 @@ 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' | 'stats' | 'questions' | 'brand'>('content') const [adminTab, setAdminTab] = useState<'content' | 'episodes' | 'settings' | 'publish' | 'analytics' | 'questions' | 'brand'>('content')
const [contentTab, setContentTab] = useState<'main' | '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('')
const [opsMsg, setOpsMsg] = useState('')
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([]) const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
const [selectedBackup, setSelectedBackup] = useState('') const [selectedBackup, setSelectedBackup] = useState('')
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null) const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
const [publishState, setPublishState] = useState<PublishState>({ draftUpdatedAt: null, publishedAt: null })
const [assets, setAssets] = useState<AdminAsset[]>([])
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
const [assetUploadPending, setAssetUploadPending] = useState(false)
const [questions, setQuestions] = useState<Question[]>([]) const [questions, setQuestions] = useState<Question[]>([])
const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({}) const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
@@ -234,6 +260,34 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
} }
}) })
.catch(() => {}) .catch(() => {})
fetch('/api/admin-content-state')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load publish state'))))
.then(data => {
const state = (data as { publishState?: PublishState }).publishState
if (state) {
setPublishState({
draftUpdatedAt: state.draftUpdatedAt ?? null,
publishedAt: state.publishedAt ?? null,
})
}
})
.catch(() => {})
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')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load operations status'))))
.then(data => {
setOpsStatus(data as OpsStatus)
})
.catch(() => {})
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -288,6 +342,256 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
} }
} }
async function reloadAssets() {
const r = await fetch('/api/admin-assets')
if (!r.ok) throw new Error('Could not refresh assets')
const data = await r.json() as { assets?: AdminAsset[] }
setAssets(Array.isArray(data.assets) ? data.assets : [])
}
async function reloadOpsStatus() {
const r = await fetch('/api/admin-ops/status')
if (!r.ok) throw new Error('Could not refresh ops status')
const data = await r.json() as OpsStatus
setOpsStatus(data)
}
async function handleSaveDraft() {
setStatus('saving')
setErrorMsg('')
try {
const res = await fetch('/api/admin-content-draft', {
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 ?? 'Draft save failed')
}
const data = await res.json() as { updatedAt?: string }
setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() }))
setStatus('saved')
setTimeout(() => setStatus('idle'), 3500)
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
setStatus('error')
}
}
async function handlePublishDraft() {
if (!confirm('Publish current changes to the live site now?')) return
setStatus('saving')
setErrorMsg('')
try {
// Always save the current form to draft first so publish never fails from a missing draft
const draftRes = await fetch('/api/admin-content-draft', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ siteContent: form }),
})
if (!draftRes.ok) {
const data = await draftRes.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Draft save failed')
}
const draftData = await draftRes.json() as { updatedAt?: string }
setPublishState(prev => ({ ...prev, draftUpdatedAt: draftData.updatedAt ?? new Date().toISOString() }))
const publishRes = await fetch('/api/admin-content/publish', { method: 'POST' })
if (!publishRes.ok) {
const data = await publishRes.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Publish failed')
}
const published = await publishRes.json() as { publishedAt?: string }
const latestRes = await fetch('/api/admin-content')
if (!latestRes.ok) throw new Error('Failed to refresh published content')
const latest = await latestRes.json() as { siteContent?: Partial<SiteContent> }
if (latest?.siteContent) {
const next = { ...DEFAULTS, ...latest.siteContent }
setForm(next)
onSave(next)
setLastSavedSnapshot(JSON.stringify(next))
}
setPublishState(prev => ({ ...prev, publishedAt: published.publishedAt ?? new Date().toISOString() }))
await reloadStats()
setStatus('saved')
setTimeout(() => setStatus('idle'), 3500)
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
setStatus('error')
}
}
function openDraftPreview() {
window.open('/preview', '_blank', 'noopener,noreferrer')
}
function updateSeoField(field: keyof SeoSettings, value: string | string[]) {
setForm(f => ({
...f,
seo: {
...f.seo,
[field]: value,
},
}))
}
function updateLegalField(field: keyof LegalSettings, value: string | string[]) {
setForm(f => ({
...f,
legal: {
...f.legal,
[field]: value,
},
}))
}
function addRedirectRule() {
setForm(f => ({
...f,
redirects: [
...(f.redirects ?? []),
{
id: Date.now().toString(36),
path: '/new-short-link',
target: 'https://',
statusCode: 301,
},
],
}))
}
function updateRedirectRule(id: string, field: keyof RedirectRule, value: string | number) {
setForm(f => ({
...f,
redirects: (f.redirects ?? []).map(rule => rule.id === id
? {
...rule,
[field]: field === 'statusCode' ? (Number(value) === 302 ? 302 : 301) : value,
}
: rule),
}))
}
function removeRedirectRule(id: string) {
setForm(f => ({ ...f, redirects: (f.redirects ?? []).filter(rule => rule.id !== id) }))
}
function addPodcastLink() {
setForm(f => ({
...f,
podcastFeaturedLinks: [
...(f.podcastFeaturedLinks ?? []),
{
id: Date.now().toString(36),
title: '',
episodeNumber: '',
summary: '',
url: '',
embedUrl: '',
showNotes: '',
discussionQuestions: [],
},
],
}))
}
function updatePodcastLink(id: string, field: keyof PodcastFeaturedLink, value: string) {
setForm(f => ({
...f,
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link => link.id === id ? { ...link, [field]: value } : link),
}))
}
function updatePodcastLinkQuestions(id: string, raw: string) {
const questions = raw.split('\n').map(q => q.trim()).filter(Boolean)
setForm(f => ({
...f,
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link =>
link.id === id ? { ...link, discussionQuestions: questions } : link
),
}))
}
function removePodcastLink(id: string) {
setForm(f => ({ ...f, podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).filter(link => link.id !== id) }))
}
async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
if (!file) return
setAssetUploadPending(true)
setOpsMsg('')
try {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
const res = await fetch('/api/admin-assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, dataUrl }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Upload failed')
}
await reloadAssets()
setOpsMsg('Asset uploaded successfully.')
} catch (err) {
setOpsMsg(err instanceof Error ? err.message : 'Upload failed.')
} finally {
setAssetUploadPending(false)
event.target.value = ''
}
}
async function handleDeleteAsset(filename: string) {
if (!confirm(`Delete asset ${filename}?`)) return
try {
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Delete failed')
await reloadAssets()
setOpsMsg('Asset deleted.')
} catch {
setOpsMsg('Asset delete failed.')
}
}
async function handlePurgeCache() {
try {
const res = await fetch('/api/admin-ops/purge-cache', { method: 'POST' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Cache purge failed')
}
await reloadOpsStatus()
setOpsMsg('Cache purge triggered.')
} catch (err) {
setOpsMsg(err instanceof Error ? err.message : 'Cache purge failed.')
}
}
async function handleDeployHook() {
try {
const res = await fetch('/api/admin-ops/deploy', { method: 'POST' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { message?: string }).message ?? 'Deploy hook failed')
}
await reloadOpsStatus()
setOpsMsg('Deploy hook triggered.')
} catch (err) {
setOpsMsg(err instanceof Error ? err.message : 'Deploy hook failed.')
}
}
function maskIp(ip: string) { function maskIp(ip: string) {
if (!ip || ip === 'unknown') return 'unknown' if (!ip || ip === 'unknown') return 'unknown'
if (ip.includes('.')) { if (ip.includes('.')) {
@@ -402,7 +706,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
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' | 'stats' | 'questions' | 'brand') { 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)
@@ -807,11 +1111,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<button <button
type="button" type="button"
role="tab" role="tab"
aria-selected={adminTab === 'stats'} aria-selected={adminTab === 'episodes'}
className={`admin-tab ${adminTab === 'stats' ? 'admin-tab--active' : ''}`} className={`admin-tab ${adminTab === 'episodes' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('stats')} onClick={() => handleAdminTabChange('episodes')}
> >
Site Stats Episodes
</button> </button>
<button <button
type="button" type="button"
@@ -822,6 +1126,33 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
> >
Questions ({questions.length}) Questions ({questions.length})
</button> </button>
<button
type="button"
role="tab"
aria-selected={adminTab === 'analytics'}
className={`admin-tab ${adminTab === 'analytics' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('analytics')}
>
Analytics
</button>
<button
type="button"
role="tab"
aria-selected={adminTab === 'settings'}
className={`admin-tab ${adminTab === 'settings' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('settings')}
>
Settings
</button>
<button
type="button"
role="tab"
aria-selected={adminTab === 'publish'}
className={`admin-tab ${adminTab === 'publish' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('publish')}
>
Publish
</button>
<button <button
type="button" type="button"
role="tab" role="tab"
@@ -829,7 +1160,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
className={`admin-tab ${adminTab === 'brand' ? 'admin-tab--active' : ''}`} className={`admin-tab ${adminTab === 'brand' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('brand')} onClick={() => handleAdminTabChange('brand')}
> >
Brand Kit Brand
</button> </button>
</div> </div>
@@ -894,8 +1225,294 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section> </section>
)} )}
{adminTab === 'stats' && ( {adminTab === 'publish' && (
<section className="admin-stats" aria-label="Site hit statistics"> <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' && (
<section className="admin-stats" aria-label="Episode highlights">
<div className="admin-stats-head">
<h2>Episode Highlights</h2>
<p>Featured episodes shown on the Episodes page. Fill in discussion questions, show notes, or an embed URL and the highlight automatically gets its own detail page at <code>/episodes/[id]</code>.</p>
</div>
{(form.podcastFeaturedLinks ?? []).length === 0 && (
<p className="admin-stats-note">No episode highlights yet. Add one below.</p>
)}
{(form.podcastFeaturedLinks ?? []).map(item => (
<div key={item.id} className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`podcast-ep-${item.id}`}>Episode Number</label>
<input id={`podcast-ep-${item.id}`} type="text" placeholder="e.g. 42" value={item.episodeNumber ?? ''} onChange={e => updatePodcastLink(item.id, 'episodeNumber', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-title-${item.id}`}>Title</label>
<input id={`podcast-title-${item.id}`} type="text" value={item.title} onChange={e => updatePodcastLink(item.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-summary-${item.id}`}>Summary</label>
<textarea id={`podcast-summary-${item.id}`} rows={2} value={item.summary} onChange={e => updatePodcastLink(item.id, 'summary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-url-${item.id}`}>Platform URL (external link)</label>
<input id={`podcast-url-${item.id}`} type="text" placeholder="https://open.spotify.com/..." value={item.url} onChange={e => updatePodcastLink(item.id, 'url', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-embed-${item.id}`}>Embed URL (optional enables in-page player)</label>
<input id={`podcast-embed-${item.id}`} type="text" placeholder="https://open.spotify.com/embed/episode/..." value={item.embedUrl ?? ''} onChange={e => updatePodcastLink(item.id, 'embedUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-notes-${item.id}`}>Show Notes</label>
<textarea id={`podcast-notes-${item.id}`} rows={4} placeholder="Key points, scripture references, timestamps..." value={item.showNotes ?? ''} onChange={e => updatePodcastLink(item.id, 'showNotes', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`podcast-dq-${item.id}`}>Discussion Questions (one per line)</label>
<textarea id={`podcast-dq-${item.id}`} rows={5} placeholder="What stood out to you in this passage?" value={(item.discussionQuestions ?? []).join('\n')} onChange={e => updatePodcastLinkQuestions(item.id, e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removePodcastLink(item.id)}>Remove</button>
</div>
))}
<button type="button" className="btn-admin-add" onClick={addPodcastLink}>+ Add Episode Highlight</button>
<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 === 'error' && <p className="admin-status admin-status--err"> {errorMsg}</p>}
</section>
)}
{adminTab === 'settings' && (
<section className="admin-stats" aria-label="Site settings">
<div className="admin-stats-head">
<h2>SEO Settings</h2>
<p>Control metadata, canonical URL, robots policy, and sitemap paths.</p>
</div>
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor="seo-title">SEO Title</label>
<input id="seo-title" type="text" value={form.seo?.title ?? ''} onChange={e => updateSeoField('title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="seo-description">SEO Description</label>
<textarea id="seo-description" rows={3} value={form.seo?.description ?? ''} onChange={e => updateSeoField('description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="seo-og-title">Open Graph Title</label>
<input id="seo-og-title" type="text" value={form.seo?.ogTitle ?? ''} onChange={e => updateSeoField('ogTitle', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="seo-og-description">Open Graph Description</label>
<textarea id="seo-og-description" rows={3} value={form.seo?.ogDescription ?? ''} onChange={e => updateSeoField('ogDescription', e.target.value)} />
</div>
<div className="admin-field">
<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)} />
</div>
<div className="admin-field">
<label htmlFor="seo-canonical">Canonical URL</label>
<input id="seo-canonical" type="text" value={form.seo?.canonicalUrl ?? ''} onChange={e => updateSeoField('canonicalUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="seo-robots">Robots Policy</label>
<input id="seo-robots" type="text" value={form.seo?.robotsPolicy ?? ''} onChange={e => updateSeoField('robotsPolicy', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="seo-sitemap">Sitemap Paths (one per line)</label>
<textarea
id="seo-sitemap"
rows={4}
value={(form.seo?.sitemapPaths ?? []).join('\n')}
onChange={e => updateSeoField('sitemapPaths', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))}
/>
</div>
</div>
</div>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Redirect Manager</h2>
<p>Maintain first-party short links like /spotify without code changes.</p>
</div>
{(form.redirects ?? []).map(rule => (
<div key={rule.id} className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`redirect-path-${rule.id}`}>Path</label>
<input id={`redirect-path-${rule.id}`} type="text" value={rule.path} onChange={e => updateRedirectRule(rule.id, 'path', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`redirect-target-${rule.id}`}>Target URL</label>
<input id={`redirect-target-${rule.id}`} type="text" value={rule.target} onChange={e => updateRedirectRule(rule.id, 'target', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`redirect-status-${rule.id}`}>Status</label>
<select id={`redirect-status-${rule.id}`} value={rule.statusCode} onChange={e => updateRedirectRule(rule.id, 'statusCode', Number(e.target.value))}>
<option value={301}>301 Permanent</option>
<option value={302}>302 Temporary</option>
</select>
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeRedirectRule(rule.id)}>Remove</button>
</div>
))}
<button type="button" className="btn-admin-add" onClick={addRedirectRule}>+ Add Redirect</button>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Legal Pages</h2>
<p>Edit Privacy and Terms pages from admin.</p>
</div>
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor="legal-privacy-title">Privacy Title</label>
<input id="legal-privacy-title" type="text" value={form.legal?.privacyTitle ?? ''} onChange={e => updateLegalField('privacyTitle', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="legal-privacy-body">Privacy Body (one paragraph per line)</label>
<textarea
id="legal-privacy-body"
rows={4}
value={(form.legal?.privacyBody ?? []).join('\n')}
onChange={e => updateLegalField('privacyBody', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))}
/>
</div>
<div className="admin-field">
<label htmlFor="legal-terms-title">Terms Title</label>
<input id="legal-terms-title" type="text" value={form.legal?.termsTitle ?? ''} onChange={e => updateLegalField('termsTitle', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor="legal-terms-body">Terms Body (one paragraph per line)</label>
<textarea
id="legal-terms-body"
rows={4}
value={(form.legal?.termsBody ?? []).join('\n')}
onChange={e => updateLegalField('termsBody', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))}
/>
</div>
</div>
</div>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Asset Manager</h2>
<p>Upload and reuse hosted images from this domain.</p>
</div>
<div className="admin-actions admin-actions--maintenance">
<label className="btn-admin-reset" style={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer' }}>
{assetUploadPending ? 'Uploading...' : 'Upload Image'}
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={handleAssetUpload} style={{ display: 'none' }} disabled={assetUploadPending} />
</label>
</div>
{assets.length === 0 ? (
<p className="admin-stats-note">No uploaded assets yet.</p>
) : (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table">
<thead>
<tr>
<th>Preview</th>
<th>URL</th>
<th>Size</th>
<th>Updated</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{assets.map(asset => (
<tr key={asset.filename}>
<td><img src={asset.url} alt={asset.filename} style={{ width: '68px', height: '68px', objectFit: 'cover', borderRadius: '8px' }} /></td>
<td>{asset.url}</td>
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
<td>{formatDate(asset.updatedAt)}</td>
<td><button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button></td>
</tr>
))}
</tbody>
</table>
</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>}
{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 === 'analytics' && (
<section className="admin-stats" aria-label="Site analytics">
<div className="admin-stats-head"> <div className="admin-stats-head">
<h2>Site Hit Stats</h2> <h2>Site Hit Stats</h2>
<p>Built-in page traffic and visitor intelligence from this server.</p> <p>Built-in page traffic and visitor intelligence from this server.</p>
+179 -87
View File
@@ -42,6 +42,20 @@
align-items: center; align-items: center;
} }
.header-menu-btn {
display: none;
border: 1px solid rgba(201, 168, 76, 0.42);
background: rgba(0, 0, 0, 0.15);
color: var(--brand-gold);
border-radius: 999px;
padding: 0.4rem 0.9rem;
font-family: var(--brand-font-body);
font-size: 0.88rem;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
}
.header-nav a { .header-nav a {
color: var(--brand-muted); color: var(--brand-muted);
text-decoration: none; text-decoration: none;
@@ -57,6 +71,10 @@
color: var(--brand-gold); color: var(--brand-gold);
} }
.header-nav-link--active {
color: var(--brand-gold) !important;
}
.header-cta { .header-cta {
color: var(--brand-gold) !important; color: var(--brand-gold) !important;
border: 1px solid rgba(201, 168, 76, 0.5) !important; border: 1px solid rgba(201, 168, 76, 0.5) !important;
@@ -749,104 +767,156 @@
font-size: 0.8rem; font-size: 0.8rem;
} }
/* ── QR / Share ── */ /* ── Home Jump Section ── */
.section-qr { .section-home-jump {
background: var(--brand-black); background: var(--brand-black);
padding: 5rem 0; border-top: 1px solid rgba(201, 168, 76, 0.16);
border-bottom: 1px solid rgba(201, 168, 76, 0.12);
padding: 4.5rem 0;
} }
.section-qr .section-inner { .home-jump-head {
max-width: 1080px; text-align: center;
margin-bottom: 1.25rem;
} }
.qr-inner { .home-jump-head .section-heading {
margin-bottom: 0;
}
.home-jump-grid {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4.5rem; gap: 1rem;
align-items: center;
} }
.qr-text { .home-jump-card {
background: linear-gradient(180deg, rgba(17, 17, 17, 0.92), rgba(11, 11, 11, 0.94)); display: block;
border: 1px solid rgba(201, 168, 76, 0.22); text-decoration: none;
border-radius: 16px; background: linear-gradient(180deg, rgba(20, 20, 18, 0.95), rgba(11, 11, 10, 0.95));
padding: 2rem 2.1rem; border: 1px solid rgba(201, 168, 76, 0.2);
border-radius: 14px;
padding: 1.1rem 1.15rem;
transition: transform 180ms, border-color 180ms, background 180ms;
} }
.qr-text h2 { .home-jump-card:hover {
transform: translateY(-2px);
border-color: rgba(201, 168, 76, 0.45);
background: linear-gradient(180deg, rgba(28, 27, 23, 0.98), rgba(14, 14, 12, 0.98));
}
.home-jump-card h3 {
margin: 0 0 0.35rem;
font-family: var(--brand-font-heading); font-family: var(--brand-font-heading);
font-size: clamp(1.6rem, 2.5vw, 2.2rem);
color: var(--brand-warm-white); color: var(--brand-warm-white);
margin: 0.5rem 0 1rem; font-size: 1.28rem;
font-weight: 700;
} }
.qr-text p { .home-jump-card p {
font-family: var(--brand-font-body);
font-weight: 300;
font-size: 1.1rem;
line-height: 1.65;
color: var(--brand-muted);
margin: 0 0 1.5rem;
padding: 0;
}
.share-show-wrap {
margin-top: 1rem;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.45rem;
}
.btn-share-show {
width: fit-content;
}
.share-feedback {
margin: 0; margin: 0;
font-family: var(--brand-font-body); font-family: var(--brand-font-body);
color: #cdb483; color: var(--brand-muted);
font-size: 0.9rem; line-height: 1.55;
font-size: 0.97rem;
} }
.qr-code-wrap { /* ── Episode Detail Page ── */
text-align: center; .section-episode-detail {
flex-shrink: 0; background: var(--brand-black);
background: rgba(16, 16, 16, 0.9); padding: 4rem 0 5rem;
border: 1px solid rgba(201, 168, 76, 0.22); min-height: 60vh;
border-radius: 16px;
padding: 1.35rem 1.2rem 1rem;
box-shadow: 0 10px 35px rgba(0, 0, 0, 0.38);
} }
.qr-frame { .episode-detail-back {
display: inline-block; display: inline-block;
padding: 16px 18px;
background: #ffffff;
border-radius: 24px;
position: relative;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
}
.qr-code {
display: block;
width: 180px;
height: 180px;
border-radius: 2px;
}
.scan-label {
font-family: var(--brand-font-body); font-family: var(--brand-font-body);
font-weight: 500; font-size: 0.92rem;
font-size: 0.72rem;
letter-spacing: 0.4em;
text-transform: uppercase;
color: var(--brand-gold); color: var(--brand-gold);
margin-top: 0.85rem; text-decoration: none;
text-align: center; margin-bottom: 1.75rem;
opacity: 0.8; letter-spacing: 0.02em;
padding: 0; transition: opacity 150ms;
}
.episode-detail-back:hover {
opacity: 0.75;
}
.episode-detail-number {
font-family: var(--brand-font-body);
font-size: 0.85rem;
color: var(--brand-gold);
text-transform: uppercase;
letter-spacing: 0.12em;
margin: 0 0 0.4rem;
}
.episode-detail-title {
font-family: var(--brand-font-heading);
font-size: clamp(1.6rem, 3.5vw, 2.4rem);
color: var(--brand-warm-white);
line-height: 1.2;
margin: 0 0 0.85rem;
}
.episode-detail-summary {
font-family: var(--brand-font-body);
color: var(--brand-muted);
font-size: 1.05rem;
line-height: 1.65;
margin: 0 0 2rem;
max-width: 680px;
}
.episode-detail-embed {
margin: 1.5rem 0 2rem;
border-radius: 10px;
overflow: hidden;
max-width: 700px;
}
.episode-detail-listen-btn {
display: inline-block;
margin: 0 0 2.5rem;
}
.episode-detail-show-notes,
.episode-detail-questions {
margin-top: 2.5rem;
padding-top: 2rem;
border-top: 1px solid rgba(201, 168, 76, 0.14);
max-width: 700px;
}
.episode-detail-section-heading {
font-family: var(--brand-font-heading);
font-size: 1.25rem;
color: var(--brand-gold);
margin: 0 0 1rem;
letter-spacing: 0.03em;
}
.episode-detail-show-notes p {
font-family: var(--brand-font-body);
color: var(--brand-muted);
line-height: 1.75;
font-size: 1rem;
margin: 0;
white-space: pre-wrap;
}
.episode-detail-questions-list {
padding-left: 1.4rem;
margin: 0;
}
.episode-detail-questions-list li {
font-family: var(--brand-font-body);
color: var(--brand-warm-white);
line-height: 1.7;
font-size: 1rem;
margin-bottom: 0.75rem;
} }
/* ── Contact ── */ /* ── Contact ── */
@@ -2658,15 +2728,8 @@
flex-direction: column; flex-direction: column;
} }
.qr-inner { .home-jump-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 2.5rem;
text-align: center;
}
.qr-text .btn-secondary {
font-size: 0.75rem;
word-break: break-all;
} }
.contact-inner { .contact-inner {
@@ -2726,7 +2789,7 @@
.section-about, .section-about,
.section-series, .section-series,
.section-guide, .section-guide,
.section-qr, .section-home-jump,
.section-chatbot-feature, .section-chatbot-feature,
.section-contact { .section-contact {
padding: 3.5rem 0; padding: 3.5rem 0;
@@ -2738,14 +2801,43 @@
.header-inner { .header-inner {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
position: relative;
}
.header-menu-btn {
display: inline-flex;
margin-left: auto;
} }
.header-nav { .header-nav {
gap: 1rem; display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 120;
flex-direction: column;
align-items: stretch;
gap: 0;
padding: 0.5rem 1rem 1rem;
background: rgba(10, 10, 8, 0.98);
border-bottom: 1px solid rgba(201, 168, 76, 0.2);
} }
.header-nav a:not(.header-cta) { .header-nav--open {
display: none; display: flex;
}
.header-nav .header-nav-link {
display: block;
padding: 0.8rem 0.25rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.12);
}
.header-nav .header-cta {
margin-top: 0.75rem;
justify-content: center;
text-align: center;
} }
.admin-top-tabs .admin-tab { .admin-top-tabs .admin-tab {
+595 -388
View File
File diff suppressed because it is too large Load Diff