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