3 Commits

Author SHA1 Message Date
nmemmert 74c21f956e Add companion app 2026-04-20 14:58:57 -04:00
nmemmert 0c328057c8 Polish hero UX and add admin brand kit panel 2026-04-16 10:31:35 -04:00
nmemmert 5562db992b Ignore generated runtime data files 2026-04-16 08:37:32 -04:00
24 changed files with 4108 additions and 446 deletions
+3
View File
@@ -18,6 +18,9 @@ dist-ssr
# Uncomment the line below if you do NOT want to track live data in git.
# data/admin-content.json
data/backups/
data/hit-stats.json
data/visitor-stats.json
data/contact-submissions.json
# Editor directories and files
.vscode/*
+42
View File
@@ -0,0 +1,42 @@
# Verse by Verse Companion App
This is a standalone companion app for your podcast.
It is intentionally separate from the main Siteforge app and has its own dependencies, scripts, and Vite config.
Episodes are loaded at runtime from your RSS feed:
- https://anchor.fm/s/11068d290/podcast/rss
This means new podcast episodes appear in the app without rebuilding the frontend.
## Run locally
```bash
cd companion-app
npm install
npm run dev
```
The app runs on `http://localhost:5181`.
`npm run dev` starts:
- Vite web app on `http://localhost:5181`
- RSS API on `http://localhost:4174`
## Production build
```bash
cd companion-app
npm run build
npm run start
```
`npm run start` serves the built app and RSS API from one Node process.
## Runtime API
- Endpoint: `/api/episodes`
- Source feed: Anchor RSS URL above
- Fallback: If RSS is unavailable, the app uses local fallback episodes from `src/data/podcastData.ts`.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Verse by Verse Companion</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2206
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "siteforge-companion-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:dev:api\" \"npm:dev:web\"",
"dev:web": "vite",
"dev:api": "NODE_ENV=development PORT=4174 node server.mjs",
"build": "tsc -b && vite build",
"preview": "vite preview",
"start": "NODE_ENV=production node server.mjs"
},
"dependencies": {
"express": "^5.1.0",
"fast-xml-parser": "^5.3.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"concurrently": "^9.2.1",
"typescript": "~5.9.3",
"vite": "^8.0.0"
}
}
+102
View File
@@ -0,0 +1,102 @@
import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { XMLParser } from 'fast-xml-parser';
const RSS_URL = 'https://anchor.fm/s/11068d290/podcast/rss';
const PORT = Number(process.env.PORT || 5181);
const isProd = process.env.NODE_ENV === 'production';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const parser = new XMLParser({
ignoreAttributes: false,
trimValues: true
});
function toArray(value) {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}
function cleanText(input) {
if (!input) return '';
return String(input)
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function normalizeItem(item, index) {
const title = cleanText(item.title) || `Episode ${index + 1}`;
const summary = cleanText(item['content:encoded'] || item.description) || 'New episode now available.';
const link = item.link || item.guid || RSS_URL;
const date = item.pubDate ? new Date(item.pubDate) : null;
const publishedAt = date && !Number.isNaN(date.getTime()) ? date.toISOString() : null;
return {
id: cleanText(item.guid) || link,
title,
summary,
scripture: '',
reflectionPrompt: '',
prayerFocus: '',
challenge: '',
link,
publishedAt
};
}
async function readFeedEpisodes() {
const response = await fetch(RSS_URL, {
headers: {
'User-Agent': 'Siteforge-Companion/1.0'
}
});
if (!response.ok) {
throw new Error(`RSS request failed with ${response.status}`);
}
const xml = await response.text();
const parsed = parser.parse(xml);
const items = toArray(parsed?.rss?.channel?.item);
return items.map(normalizeItem).slice(0, 24);
}
const app = express();
app.get('/api/episodes', async (_req, res) => {
try {
const episodes = await readFeedEpisodes();
res.json({
source: 'rss',
rssUrl: RSS_URL,
updatedAt: new Date().toISOString(),
episodes
});
} catch (error) {
res.status(502).json({
source: 'rss',
rssUrl: RSS_URL,
error: error instanceof Error ? error.message : 'Unable to load RSS feed',
episodes: []
});
}
});
if (isProd) {
const distDir = path.resolve(__dirname, 'dist');
app.use(express.static(distDir));
app.get('*', (_req, res) => {
res.sendFile(path.join(distDir, 'index.html'));
});
}
app.listen(PORT, () => {
const mode = isProd ? 'production' : 'development';
console.log(`Companion API listening on http://localhost:${PORT} (${mode})`);
});
+178
View File
@@ -0,0 +1,178 @@
.app-shell {
max-width: 1100px;
margin: 0 auto;
padding: 2rem 1rem 4rem;
}
.hero {
background: linear-gradient(140deg, rgba(47, 79, 47, 0.94), rgba(98, 121, 90, 0.92));
border: 1px solid rgba(244, 240, 231, 0.2);
border-radius: 24px;
color: var(--cream);
padding: 2rem;
box-shadow: 0 16px 32px rgba(22, 32, 19, 0.24);
animation: rise-in 500ms ease-out;
}
.kicker {
letter-spacing: 0.14em;
text-transform: uppercase;
font-size: 0.8rem;
margin-bottom: 0.75rem;
}
.hero h1 {
font-size: clamp(2rem, 6vw, 3.3rem);
margin-bottom: 0.8rem;
}
.hero-copy {
max-width: 42rem;
}
.series-line {
margin-top: 0.75rem;
margin-bottom: 0.35rem;
}
.hero-actions {
margin-top: 1.2rem;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.hero-actions a {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
border: none;
border-radius: 999px;
background: var(--sun);
color: #2c1a00;
padding: 0.7rem 1.1rem;
font-weight: 700;
cursor: pointer;
}
.hero-actions .ghost {
background: transparent;
color: var(--cream);
border: 1px solid rgba(244, 240, 231, 0.55);
}
.section-grid {
margin-top: 2.25rem;
display: grid;
gap: 1.15rem;
}
.section-grid h2 {
font-size: clamp(1.5rem, 4vw, 2.2rem);
}
.section-copy {
margin-top: 0.6rem;
max-width: 46rem;
}
.episode-grid,
.resource-grid {
display: grid;
gap: 0.95rem;
}
.episode-grid {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.resource-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.answer-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.95rem;
}
.episode-card,
.resource-card,
.answer-card {
background: var(--card);
border: 1px solid var(--edge);
border-radius: 18px;
padding: 1rem;
box-shadow: 0 6px 16px rgba(22, 32, 19, 0.08);
}
.episode-card {
display: grid;
gap: 0.65rem;
}
.episode-id {
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--olive);
}
.pill {
display: inline-flex;
width: fit-content;
font-size: 0.8rem;
border: 1px solid rgba(98, 121, 90, 0.45);
color: var(--deep-green);
border-radius: 999px;
padding: 0.2rem 0.55rem;
}
.published {
font-size: 0.84rem;
color: var(--olive);
}
.resource-card {
display: grid;
gap: 0.7rem;
}
.answer-card {
display: grid;
gap: 0.6rem;
}
.resource-link {
display: inline-flex;
width: fit-content;
padding: 0.4rem 0.7rem;
border-radius: 999px;
border: 1px solid rgba(47, 79, 47, 0.4);
color: var(--deep-green);
text-decoration: none;
font-weight: 700;
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 640px) {
.hero {
padding: 1.3rem;
}
.hero-actions a {
width: 100%;
}
}
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react';
import EpisodeCard from './components/EpisodeCard';
import ResourceCard from './components/ResourceCard';
import { fallbackEpisodes, quickAnswers, resources, showContent, type Episode } from './data/podcastData';
import './App.css';
type EpisodesApiResponse = {
episodes?: Episode[];
};
export default function App() {
const [episodes, setEpisodes] = useState<Episode[]>(fallbackEpisodes);
useEffect(() => {
let isMounted = true;
async function loadEpisodes() {
try {
const response = await fetch('/api/episodes', { cache: 'no-store' });
if (!response.ok) {
return;
}
const data = (await response.json()) as EpisodesApiResponse;
if (isMounted && Array.isArray(data.episodes) && data.episodes.length > 0) {
setEpisodes(data.episodes);
}
} catch {
// Keep local fallback episodes when network call fails.
}
}
loadEpisodes();
return () => {
isMounted = false;
};
}, []);
return (
<div className="app-shell">
<header className="hero">
<p className="kicker">{showContent.eyebrow}</p>
<h1>{showContent.title}</h1>
<p className="hero-copy">{showContent.hero}</p>
<p className="series-line">
<strong>{showContent.seriesLabel}:</strong> {showContent.seriesTitle}
</p>
<p className="hero-copy">{showContent.seriesDescription}</p>
<div className="hero-actions">
<a href={showContent.listenUrl} target="_blank" rel="noreferrer">
Listen on Spotify
</a>
<a href={showContent.studyGuideUrl} target="_blank" rel="noreferrer" className="ghost">
Get companion guide
</a>
</div>
</header>
<main>
<section className="section-grid">
<div>
<h2>Titus Companion Guides</h2>
<p className="section-copy">Episode-aligned prompts to help you move from hearing to applying the Word this week.</p>
</div>
<div className="episode-grid">
{episodes.map((episode) => (
<EpisodeCard key={episode.id} episode={episode} />
))}
</div>
</section>
<section className="section-grid answers">
<div>
<h2>Quick Answers</h2>
<p className="section-copy">Helpful guidance pulled from your existing Verse by Verse teaching content.</p>
</div>
<div className="answer-grid">
{quickAnswers.map((entry) => (
<article key={entry.question} className="answer-card">
<h3>{entry.question}</h3>
<p>{entry.answer}</p>
</article>
))}
</div>
</section>
<section className="section-grid resources">
<div>
<h2>Listen, Study, Share</h2>
<p className="section-copy">Direct links to your show and companion resources so listeners can take the next step.</p>
</div>
<div className="resource-grid">
{resources.map((resource) => (
<ResourceCard key={resource.title} resource={resource} />
))}
</div>
</section>
</main>
</div>
);
}
@@ -0,0 +1,45 @@
import type { Episode } from '../data/podcastData';
type EpisodeCardProps = {
episode: Episode;
};
export default function EpisodeCard({ episode }: EpisodeCardProps) {
const publishedLabel = episode.publishedAt
? new Date(episode.publishedAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
})
: null;
return (
<article className="episode-card">
<p className="episode-id">{episode.id.toUpperCase()}</p>
<h3>{episode.title}</h3>
{episode.scripture ? <p className="pill">{episode.scripture}</p> : null}
{publishedLabel ? <p className="published">Released {publishedLabel}</p> : null}
<p>{episode.summary}</p>
{episode.reflectionPrompt ? (
<p>
<strong>Reflect:</strong> {episode.reflectionPrompt}
</p>
) : null}
{episode.prayerFocus ? (
<p>
<strong>Prayer:</strong> {episode.prayerFocus}
</p>
) : null}
{episode.challenge ? (
<p>
<strong>Challenge:</strong> {episode.challenge}
</p>
) : null}
{episode.link ? (
<a className="resource-link" href={episode.link} target="_blank" rel="noreferrer">
Listen to episode
</a>
) : null}
</article>
);
}
@@ -0,0 +1,17 @@
import type { CompanionResource } from '../data/podcastData';
type ResourceCardProps = {
resource: CompanionResource;
};
export default function ResourceCard({ resource }: ResourceCardProps) {
return (
<article className="resource-card">
<h3>{resource.title}</h3>
<p>{resource.description}</p>
<a href={resource.href} className="resource-link" aria-label={resource.action} target="_blank" rel="noreferrer">
{resource.action}
</a>
</article>
);
}
+130
View File
@@ -0,0 +1,130 @@
export type Episode = {
id: string;
title: string;
scripture: string;
summary: string;
reflectionPrompt: string;
prayerFocus: string;
challenge: string;
link?: string;
publishedAt?: string | null;
};
export type CompanionResource = {
title: string;
description: string;
action: string;
href: string;
};
export type QuickAnswer = {
question: string;
answer: string;
};
export const showContent = {
eyebrow: 'A Journey Through Scripture',
title: 'Verse by Verse Companion',
hero:
'Walk through Titus with grounded teaching, reflection prompts, and practical next steps you can apply this week.',
seriesLabel: 'Now Studying',
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.",
listenUrl: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
studyGuideUrl: 'https://a.co/d/01sG2tOJ'
};
export const fallbackEpisodes: Episode[] = [
{
id: 'titus-08',
title: 'Episode 8 - Grace Is Not Just What Saves You - It Is What Trains You',
scripture: 'Titus 2:11-12',
summary:
'Grace is not only the starting point of salvation. Grace also teaches us to renounce ungodliness and live self-controlled lives.',
reflectionPrompt:
'Where have you treated grace as permission instead of formation?',
prayerFocus: 'Ask God for a teachable heart shaped by grace.',
challenge: 'Write one habit you will surrender and one habit you will build this week.'
},
{
id: 'titus-09',
title: 'Episode 9 - Living Between Two Appearings',
scripture: 'Titus 2:13-15',
summary:
'Believers live between Christs first and second appearing: rooted in finished grace while actively waiting in present obedience.',
reflectionPrompt:
'How would your priorities change if you lived this week with eternity in view?',
prayerFocus: 'Steadiness, hope, and urgency in daily obedience.',
challenge: 'Choose one daily moment to pray, Come Lord Jesus, and then act on one obedient next step.'
},
{
id: 'titus-12',
title: 'Episode 12 - The Gospel in One Paragraph',
scripture: 'Titus 3:4-7',
summary:
'Paul compresses the gospel into a rich summary: Gods kindness appeared, mercy saved us, and the Spirit renews us for new life.',
reflectionPrompt:
'Which phrase in Titus 3:4-7 most confronts self-reliance in your walk with God?',
prayerFocus: 'Gratitude for salvation by mercy, not by works.',
challenge: 'Share your testimony this week using before, but God, and now language.'
},
{
id: 'titus-13',
title: 'Episode 13 - What Happens After the Gospel Takes Root',
scripture: 'Titus 3:8-11',
summary:
'Sound doctrine grows visible fruit: believers devote themselves to good works and refuse divisive, unprofitable arguments.',
reflectionPrompt:
'Where are you spending energy on arguments instead of obedient good works?',
prayerFocus: 'Discernment to pursue what is excellent and profitable for others.',
challenge: 'Plan one concrete act of service this week and do it quietly.'
},
{
id: 'titus-14',
title: 'Episode 14 - Grace: Where It Starts and Where It Ends',
scripture: 'Titus 3:12-15',
summary:
'Pauls closing words show that grace is not abstract theology. It is a practical culture of partnership, provision, and perseverance.',
reflectionPrompt:
'Who in your church or circle needs practical support from you right now?',
prayerFocus: 'Faithfulness in ordinary, often unseen acts of care.',
challenge: 'Reach out to one person this week with specific encouragement and practical help.'
}
];
export const resources: CompanionResource[] = [
{
title: 'Listen to the Titus Series',
description: 'Stream the full Verse by Verse with Nate series currently focused on Titus.',
action: 'Open Spotify',
href: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl'
},
{
title: 'Official Companion Study Guide',
description: 'Go deeper in your study with the Verse by Verse companion guide on Amazon.',
action: 'View study guide',
href: 'https://a.co/d/01sG2tOJ'
},
{
title: 'Share the Show',
description: 'Invite one friend to listen this week and walk through Scripture together.',
action: 'Copy show link',
href: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl'
}
];
export const quickAnswers: QuickAnswer[] = [
{
question: 'What Bible translation does Nate teach from?',
answer: 'The primary teaching text is the BSB (Berean Standard Bible), with occasional comparisons to other translations.'
},
{
question: 'How should I approach a difficult passage?',
answer: 'Start with context, study author and audience, use trusted tools, and pray for understanding before drawing conclusions.'
},
{
question: 'How can I stay consistent with Bible reading?',
answer: 'Keep it simple and daily, even 10 minutes. Use a reading plan and focus on consistency over perfection.'
}
];
+44
View File
@@ -0,0 +1,44 @@
@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;600;800&family=Fraunces:opsz,wght@9..144,500;9..144,700&display=swap');
:root {
font-family: 'Bricolage Grotesque', sans-serif;
color: #162013;
background: #f4f0e7;
--ink: #162013;
--deep-green: #2f4f2f;
--olive: #62795a;
--cream: #f4f0e7;
--sun: #d38a39;
--card: #fffdf8;
--edge: rgba(22, 32, 19, 0.15);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 12% 8%, rgba(211, 138, 57, 0.22), transparent 34%),
radial-gradient(circle at 80% 12%, rgba(98, 121, 90, 0.22), transparent 36%),
linear-gradient(180deg, #f7f2ea 0%, #ece4d8 100%);
}
h1,
h2,
h3 {
margin: 0;
font-family: 'Fraunces', serif;
}
p {
margin: 0;
line-height: 1.5;
}
button,
a {
font: inherit;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/components/episodecard.tsx","./src/components/resourcecard.tsx","./src/data/podcastdata.ts"],"version":"5.9.3"}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.9.3"}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5181,
proxy: {
'/api': {
target: 'http://localhost:4174',
changeOrigin: true
}
}
}
});
+1 -1
View File
@@ -21,7 +21,7 @@
<link rel="canonical" href="https://necloud.us" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,700;0,900;1,700&family=Barlow+Condensed:wght@300;400;500;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600;1,700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
+262 -45
View File
@@ -73,30 +73,108 @@ interface Question {
}
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries'>
const FIELDS: { key: StringField; label: string; multiline?: boolean }[] = [
{ key: 'eyebrow', label: 'Hero Eyebrow Text' },
{ key: 'heroTagline', label: 'Hero Tagline' },
{ key: 'aboutShowHeading', label: 'About Show — Heading' },
{ key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true },
{ key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true },
{ key: 'aboutNate', label: 'About Nate', multiline: true },
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")' },
{ key: 'seriesTitle', label: 'Series Title' },
{ key: 'seriesDescription', label: 'Series Description', multiline: true },
{ key: 'seriesImageUrl', label: 'Series Cover Image URL' },
{ key: 'seriesListenUrl', label: 'Series Listen URL' },
{ key: 'studyGuideTitle', label: 'Study Guide Title' },
{ key: 'studyGuideDescription', label: 'Study Guide Description', multiline: true },
{ key: 'studyGuideUrl', label: 'Study Guide URL (Amazon link)' },
{ key: 'shareHeading', label: 'Share Section — Heading' },
{ key: 'shareP', label: 'Share Section — Paragraph', multiline: true },
type MainContentSection = 'hero' | 'start-here' | 'about' | 'series' | 'share'
const MAIN_CONTENT_SECTIONS: Array<{ id: MainContentSection; title: string; description: string }> = [
{
id: 'hero',
title: 'Hero Section',
description: 'Controls the top intro copy on the homepage.',
},
{
id: 'start-here',
title: 'Start Here Page',
description: 'Edit the separate Start Here page heading, intro, and step cards.',
},
{
id: 'about',
title: 'About Section',
description: 'Manage the main show description and Nate bio content.',
},
{
id: 'series',
title: 'Current Series',
description: 'Update the active study card and companion guide content.',
},
{
id: 'share',
title: 'Share Section',
description: 'Controls the copy shown near the QR/share area.',
},
]
const BRAND_KIT_SWATCHES = [
{ name: 'Rich Black', hex: '#0a0a08', role: 'Primary background' },
{ name: 'Soft Black', hex: '#0f0f0c', role: 'Secondary surfaces' },
{ name: 'Panel Black', hex: '#1a1a15', role: 'Cards and panels' },
{ name: 'Deep Brown', hex: '#2a2518', role: 'Borders and dividers' },
{ name: 'Antique Gold', hex: '#c9a84c', role: 'Primary accent' },
{ name: 'Light Gold', hex: '#e0c070', role: 'Hover and highlight' },
{ name: 'Warm White', hex: '#f0ead8', role: 'Primary text' },
{ name: 'Warm Gray', hex: '#7a7060', role: 'Secondary text' },
] as const
const BRAND_KIT_TYPE = [
{
label: 'Brand Font',
spec: 'Cormorant Garamond · 300/400/600/700 · italic supported',
sample: 'Verse by Verse with Nate',
},
{
label: 'Subtitle / Small Caps',
spec: 'Uppercase with tracking for labels, nav, and support text',
sample: 'A Journey Through Scripture',
},
{
label: 'Body / Quote',
spec: 'Cormorant Garamond light/italic for long-form text and pull copy',
sample: 'Verse by verse. Nugget by nugget.',
},
] as const
const BRAND_KIT_VERIFICATION = [
'Global font import updated to Cormorant Garamond.',
'Shared brand variables now define black, gold, border, warm white, and muted text colors.',
'Live site CSS now references the brand variables for core text and accent styling.',
'Admin surfaces inherit the same typography and palette tokens used by the public site.',
] as const
const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; section: MainContentSection }> = [
{ key: 'eyebrow', label: 'Hero Eyebrow Text', section: 'hero' },
{ key: 'heroTagline', label: 'Hero Tagline', section: 'hero' },
{ key: 'startHereHeading', label: 'Start Here — Heading', section: 'start-here' },
{ key: 'startHereIntro', label: 'Start Here — Intro', multiline: true, section: 'start-here' },
{ key: 'startHereStep1Title', label: 'Start Here — Step 1 Title', section: 'start-here' },
{ key: 'startHereStep1Body', label: 'Start Here — Step 1 Body', multiline: true, section: 'start-here' },
{ key: 'startHereStep1Cta', label: 'Start Here — Step 1 Button Text', section: 'start-here' },
{ key: 'startHereStep2Title', label: 'Start Here — Step 2 Title', section: 'start-here' },
{ key: 'startHereStep2Body', label: 'Start Here — Step 2 Body', multiline: true, section: 'start-here' },
{ key: 'startHereStep2Cta', label: 'Start Here — Step 2 Button Text', section: 'start-here' },
{ key: 'startHereStep3Title', label: 'Start Here — Step 3 Title', section: 'start-here' },
{ key: 'startHereStep3Body', label: 'Start Here — Step 3 Body', multiline: true, section: 'start-here' },
{ key: 'startHereStep3Cta', label: 'Start Here — Step 3 Button Text', section: 'start-here' },
{ key: 'aboutShowHeading', label: 'About Show — Heading', 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: 'aboutNate', label: 'About Nate', multiline: true, section: 'about' },
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' },
{ key: 'seriesTitle', label: 'Series Title', section: 'series' },
{ key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' },
{ key: 'seriesImageUrl', label: 'Series Cover Image URL', section: 'series' },
{ key: 'seriesListenUrl', label: 'Series Listen URL', section: 'series' },
{ key: 'studyGuideTitle', label: 'Study Guide Title', section: 'series' },
{ key: 'studyGuideDescription', label: 'Study Guide Description', multiline: true, section: 'series' },
{ key: 'studyGuideUrl', label: 'Study Guide URL (Amazon link)', section: 'series' },
{ key: 'shareHeading', label: 'Share Section — Heading', section: 'share' },
{ key: 'shareP', label: 'Share Section — Paragraph', multiline: true, section: 'share' },
]
export default function AdminPage({ content, onSave, onLogout }: Props) {
const [form, setForm] = useState<SiteContent>(content)
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content))
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [adminTab, setAdminTab] = useState<'content' | 'stats' | 'questions'>('content')
const [adminTab, setAdminTab] = useState<'content' | 'stats' | 'questions' | 'brand'>('content')
const [contentTab, setContentTab] = useState<'main' | 'custom'>('main')
const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
@@ -110,6 +188,25 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null)
const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({})
const isDirty = JSON.stringify(form) !== lastSavedSnapshot
useEffect(() => {
const nextSnapshot = JSON.stringify(content)
setForm(content)
setLastSavedSnapshot(nextSnapshot)
}, [content])
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty) return
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDirty])
useEffect(() => {
fetch('/api/admin-stats')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats'))))
@@ -300,6 +397,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setForm(f => ({ ...f, [key]: value }))
}
function confirmLeaveUnsavedChanges() {
if (!isDirty) return true
return confirm('You have unsaved changes. Leave this section without saving?')
}
function handleAdminTabChange(nextTab: 'content' | 'stats' | 'questions' | 'brand') {
if (nextTab === adminTab) return
if (!confirmLeaveUnsavedChanges()) return
setAdminTab(nextTab)
}
function handleContentTabChange(nextTab: 'main' | 'custom') {
if (nextTab === contentTab) return
if (!confirmLeaveUnsavedChanges()) return
setContentTab(nextTab)
}
function addLink() {
setForm(f => ({
...f,
@@ -581,6 +695,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
throw new Error((data as { message?: string }).message ?? 'Save failed')
}
onSave(form)
setLastSavedSnapshot(JSON.stringify(form))
setStatus('saved')
setTimeout(() => setStatus('idle'), 3500)
} catch (err) {
@@ -656,8 +771,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<h1>Site Admin</h1>
<p className="admin-sub">Verse by Verse with Nate</p>
<div className="admin-header-actions">
<Link to="/" className="admin-back"> Back to site</Link>
<button type="button" className="btn-admin-logout" onClick={() => void onLogout()}>
<Link
to="/"
className="admin-back"
onClick={e => {
if (!confirmLeaveUnsavedChanges()) e.preventDefault()
}}
>
Back to site
</Link>
<button
type="button"
className="btn-admin-logout"
onClick={() => {
if (!confirmLeaveUnsavedChanges()) return
void onLogout()
}}
>
Log Out
</button>
</div>
@@ -670,7 +800,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab"
aria-selected={adminTab === 'content'}
className={`admin-tab ${adminTab === 'content' ? 'admin-tab--active' : ''}`}
onClick={() => setAdminTab('content')}
onClick={() => handleAdminTabChange('content')}
>
Content
</button>
@@ -679,7 +809,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab"
aria-selected={adminTab === 'stats'}
className={`admin-tab ${adminTab === 'stats' ? 'admin-tab--active' : ''}`}
onClick={() => setAdminTab('stats')}
onClick={() => handleAdminTabChange('stats')}
>
Site Stats
</button>
@@ -688,12 +818,82 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab"
aria-selected={adminTab === 'questions'}
className={`admin-tab ${adminTab === 'questions' ? 'admin-tab--active' : ''}`}
onClick={() => setAdminTab('questions')}
onClick={() => handleAdminTabChange('questions')}
>
Questions ({questions.length})
</button>
<button
type="button"
role="tab"
aria-selected={adminTab === 'brand'}
className={`admin-tab ${adminTab === 'brand' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('brand')}
>
Brand Kit
</button>
</div>
{adminTab === 'brand' && (
<section className="admin-brand-kit" aria-label="Brand kit">
<div className="admin-stats-head">
<h2>Brand Kit</h2>
<p>Reference palette, typography, and implementation status for the live site.</p>
</div>
<div className="admin-brand-hero">
<img src="/images/podcast-art.jpeg" alt="Verse by Verse with Nate artwork" />
<div>
<p className="admin-brand-kicker">Verse by Verse with Nate</p>
<h3>A Journey Through Scripture</h3>
<p className="admin-brand-tagline">Verse by verse. Nugget by nugget.</p>
</div>
</div>
<div className="admin-brand-grid">
<article className="admin-brand-card">
<h3>Color Palette</h3>
<div className="admin-brand-swatches">
{BRAND_KIT_SWATCHES.map(swatch => (
<div key={swatch.hex} className="admin-brand-swatch">
<div className="admin-brand-swatch-block" style={{ background: swatch.hex }} />
<div className="admin-brand-swatch-copy">
<strong>{swatch.name}</strong>
<span>{swatch.hex}</span>
<p>{swatch.role}</p>
</div>
</div>
))}
</div>
</article>
<article className="admin-brand-card">
<h3>Typography</h3>
<div className="admin-brand-type-list">
{BRAND_KIT_TYPE.map(item => (
<div key={item.label} className="admin-brand-type-row">
<p className="admin-brand-type-label">{item.label}</p>
<p className="admin-brand-type-spec">{item.spec}</p>
<p className="admin-brand-type-sample">{item.sample}</p>
</div>
))}
</div>
</article>
</div>
<article className="admin-brand-card admin-brand-card--verify">
<h3>Verified On Site</h3>
<ul className="admin-brand-checklist">
{BRAND_KIT_VERIFICATION.map(item => (
<li key={item}>{item}</li>
))}
</ul>
<p className="admin-brand-note">
This admin panel reflects the live implementation now loaded from the shared font import and brand tokens.
</p>
</article>
</section>
)}
{adminTab === 'stats' && (
<section className="admin-stats" aria-label="Site hit statistics">
<div className="admin-stats-head">
@@ -967,7 +1167,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab"
aria-selected={contentTab === 'main'}
className={`admin-tab ${contentTab === 'main' ? 'admin-tab--active' : ''}`}
onClick={() => setContentTab('main')}
onClick={() => handleContentTabChange('main')}
>
Main Content
</button>
@@ -976,12 +1176,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab"
aria-selected={contentTab === 'custom'}
className={`admin-tab ${contentTab === 'custom' ? 'admin-tab--active' : ''}`}
onClick={() => setContentTab('custom')}
onClick={() => handleContentTabChange('custom')}
>
Custom Content
</button>
</div>
{isDirty && <p className="admin-status admin-status--warn">Unsaved changes</p>}
{contentTab === 'main' && (
<>
<div className="admin-archive-helper">
@@ -995,26 +1197,41 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</button>
</div>
{FIELDS.map(({ key, label, multiline }) => (
<div className="admin-field" key={key}>
<label htmlFor={`field-${key}`}>{label}</label>
{multiline ? (
<textarea
id={`field-${key}`}
value={form[key] as string}
onChange={e => handleChange(key, e.target.value)}
rows={4}
/>
) : (
<input
id={`field-${key}`}
type="text"
value={form[key] as string}
onChange={e => handleChange(key, e.target.value)}
/>
)}
</div>
))}
{MAIN_CONTENT_SECTIONS.map(section => {
const sectionFields = FIELDS.filter(field => field.section === section.id)
return (
<section className="admin-form-section" key={section.id}>
<div className="admin-section-header admin-section-header--compact">
<h3>{section.title}</h3>
<p>{section.description}</p>
</div>
<div className="admin-form-section-fields">
{sectionFields.map(({ key, label, multiline }) => (
<div className="admin-field" key={key}>
<label htmlFor={`field-${key}`}>{label}</label>
{multiline ? (
<textarea
id={`field-${key}`}
value={form[key] as string}
onChange={e => handleChange(key, e.target.value)}
rows={4}
/>
) : (
<input
id={`field-${key}`}
type="text"
value={form[key] as string}
onChange={e => handleChange(key, e.target.value)}
/>
)}
</div>
))}
</div>
</section>
)
})}
</>
)}
+689 -389
View File
File diff suppressed because it is too large Load Diff
+162 -8
View File
@@ -65,6 +65,17 @@ export interface ArchivedSeries {
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
@@ -87,6 +98,21 @@ export interface SiteContent {
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.',
@@ -193,6 +219,33 @@ function QASection() {
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">
@@ -265,6 +318,28 @@ function QASection() {
<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>
@@ -480,6 +555,7 @@ function ShareShowButton() {
function LandingPage({ content }: { content: SiteContent }) {
const archivedSeries = content.archivedSeries ?? []
const [seriesView, setSeriesView] = useState<'current' | 'archive'>('current')
const [activeArchivedIndex, setActiveArchivedIndex] = useState(0)
useEffect(() => {
@@ -496,6 +572,16 @@ function LandingPage({ content }: { content: SiteContent }) {
return () => window.clearInterval(intervalId)
}, [archivedSeries.length])
useEffect(() => {
if (window.location.hash === '#archives' && archivedSeries.length > 0) {
setSeriesView('archive')
return
}
if (window.location.hash === '#series') {
setSeriesView('current')
}
}, [archivedSeries.length])
return (
<div className="site">
{/* ── HEADER ── */}
@@ -503,10 +589,14 @@ function LandingPage({ content }: { content: SiteContent }) {
<div className="header-inner">
<span className="header-ornament"> &nbsp; &nbsp; </span>
<nav className="header-nav">
<Link to="/start-here">New Here</Link>
<a href="#listen">Listen</a>
<a href="#about">About</a>
<a href="#qa">Q&amp;A</a>
<a href="#series">Series</a>
{archivedSeries.length > 0 && (
<a href="#archives" onClick={() => setSeriesView('archive')}>Archived</a>
)}
<a href="#contact">Contact</a>
<a
href={SPOTIFY_SHOW_URL}
@@ -514,7 +604,7 @@ function LandingPage({ content }: { content: SiteContent }) {
rel="noreferrer"
className="header-cta"
>
Subscribe Free
Follow on Spotify
</a>
</nav>
</div>
@@ -532,11 +622,7 @@ function LandingPage({ content }: { content: SiteContent }) {
</div>
<div className="hero-text">
<p className="eyebrow">{content.eyebrow}</p>
<h1 className="hero-title">
<span className="verse-word">VERSE</span>
<span className="by-label">· by ·</span>
<span className="verse-word">VERSE</span>
</h1>
<h1 className="hero-title">Verse by Verse</h1>
<p className="with-nate">
<span className="word-with">with </span>Nate
</p>
@@ -627,6 +713,36 @@ function LandingPage({ content }: { content: SiteContent }) {
</section>
{/* ── SERIES ── */}
{archivedSeries.length > 0 && (
<section className="section-series-toggle" aria-label="Series view toggle">
<div className="section-inner">
<div className="series-toggle-wrap">
<button
type="button"
className={`series-toggle-btn ${seriesView === 'current' ? 'series-toggle-btn--active' : ''}`}
onClick={() => {
setSeriesView('current')
window.location.hash = 'series'
}}
>
Current Study
</button>
<button
type="button"
className={`series-toggle-btn ${seriesView === 'archive' ? 'series-toggle-btn--active' : ''}`}
onClick={() => {
setSeriesView('archive')
window.location.hash = 'archives'
}}
>
Archived Studies
</button>
</div>
</div>
</section>
)}
{seriesView === 'current' && (
<section className="section-series" id="series" aria-label="Current series">
<div className="section-inner">
<h2 className="section-heading">
@@ -658,9 +774,10 @@ function LandingPage({ content }: { content: SiteContent }) {
</div>
</div>
</section>
)}
{archivedSeries.length > 0 && (
<section className="section-archive-series" aria-label="Archived studies">
{archivedSeries.length > 0 && seriesView === 'archive' && (
<section className="section-archive-series" id="archives" aria-label="Archived studies">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament"></span> Archived Studies <span className="ornament"></span>
@@ -920,6 +1037,42 @@ function LandingPage({ content }: { content: SiteContent }) {
)
}
function StartHerePage({ content }: { content: SiteContent }) {
return (
<main className="thanks-page" aria-label="Start Here">
<div className="thanks-card" style={{ maxWidth: '980px', width: '100%' }}>
<p className="eyebrow">Verse by Verse with Nate</p>
<h1>{content.startHereHeading}</h1>
<p>
{content.startHereIntro}
</p>
<div className="start-grid" style={{ marginTop: '1.25rem' }}>
<article className="start-card">
<h3>{content.startHereStep1Title}</h3>
<p>{content.startHereStep1Body}</p>
<a href="/#listen" className="btn-secondary">{content.startHereStep1Cta}</a>
</article>
<article className="start-card">
<h3>{content.startHereStep2Title}</h3>
<p>{content.startHereStep2Body}</p>
<a href="/#qa" className="btn-secondary">{content.startHereStep2Cta}</a>
</article>
<article className="start-card">
<h3>{content.startHereStep3Title}</h3>
<p>{content.startHereStep3Body}</p>
<a href="/#contact" className="btn-primary">{content.startHereStep3Cta}</a>
</article>
</div>
<div style={{ marginTop: '1.4rem' }}>
<Link to="/" className="btn-secondary">Back to Site</Link>
</div>
</div>
</main>
)
}
function ThankYouPage() {
const navigate = useNavigate()
const [secondsLeft, setSecondsLeft] = useState(15)
@@ -1203,6 +1356,7 @@ export default function App() {
return (
<Routes>
<Route path="/" element={<LandingPage content={content} />} />
<Route path="/start-here" element={<StartHerePage content={content} />} />
<Route path="/questions" element={<QuestionsPage />} />
<Route path="/thanks" element={<ThankYouPage />} />
<Route path="/subscribe" element={<SubscribePage />} />
+14 -3
View File
@@ -3,10 +3,21 @@
}
:root {
font-family: 'Barlow Condensed', 'Trebuchet MS', sans-serif;
--brand-black: #0a0a08;
--brand-black-2: #0f0f0c;
--brand-black-3: #1a1a15;
--brand-border: #2a2518;
--brand-gold: #c9a84c;
--brand-gold-light: #e0c070;
--brand-gold-dark: #8a6e28;
--brand-warm-white: #f0ead8;
--brand-muted: #7a7060;
--brand-font-body: 'Cormorant Garamond', Georgia, serif;
--brand-font-heading: 'Cormorant Garamond', Georgia, serif;
font-family: var(--brand-font-body);
line-height: 1.5;
color: #f0e6d0;
background: #0a0a0a;
color: var(--brand-warm-white);
background: var(--brand-black);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;