1 Commits

Author SHA1 Message Date
nmemmert 74c21f956e Add companion app 2026-04-20 14:58:57 -04:00
18 changed files with 2977 additions and 0 deletions
+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
}
}
}
});