Add companion app
This commit is contained in:
@@ -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`.
|
||||||
@@ -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>
|
||||||
Generated
+2206
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})`);
|
||||||
|
});
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user