Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a4e011d0e | |||
| 10bd6848ef | |||
| b26103b7b1 | |||
| 83743cc78b | |||
| ee153cf9ef | |||
| 88860bc42a | |||
| c5cc28b36b | |||
| 73b8299f1a | |||
| b4f1c1dbdb | |||
| 8a71aecb4e | |||
| cc4a4b4d63 |
@@ -32,3 +32,10 @@ data/contact-submissions.json
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# wrangler files
|
||||
.wrangler
|
||||
.dev.vars*
|
||||
!.dev.vars.example
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
Binary file not shown.
@@ -18,6 +18,7 @@ RUN npm ci --omit=dev
|
||||
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY server.js ./server.js
|
||||
COPY --from=build /app/A_Study_of_Titus.pdf ./A_Study_of_Titus.pdf
|
||||
|
||||
# Copy seed data to a separate directory so the entrypoint can seed /app/data
|
||||
# only when no live data exists yet — upgrades never overwrite existing data.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
@@ -54,15 +54,20 @@ Admin auth:
|
||||
|
||||
- Set `ADMIN_PASSWORD` on the server/container to protect `/admin` and admin stats/maintenance endpoints.
|
||||
- Without `ADMIN_PASSWORD`, admin login is disabled until configured.
|
||||
- Optional: set `CACHE_PURGE_WEBHOOK_URL` to enable one-click cache purge from Admin Operations.
|
||||
- Optional: set `DEPLOY_WEBHOOK_URL` to enable one-click deploy trigger from Admin Operations.
|
||||
- Optional: set `BUILD_COMMIT`, `BUILD_NUMBER`, and `DEPLOYED_AT` to display deployment status in Admin.
|
||||
|
||||
The app will be available at `http://localhost:4173`.
|
||||
|
||||
Persistent admin saves:
|
||||
|
||||
- Admin updates are written to `data/admin-content.json`.
|
||||
- Admin drafts are written to `data/admin-content-draft.json`.
|
||||
- Built-in page hit stats are written to `data/hit-stats.json`.
|
||||
- Detailed visitor analytics are written to `data/visitor-stats.json`.
|
||||
- Backup snapshots are written to `data/backups/`.
|
||||
- Uploaded admin assets are stored in `data/uploads/` and served at `/uploads/*`.
|
||||
- `docker-compose.yml` mounts `./data` into the container at `/app/data`.
|
||||
- This keeps all admin-managed data after container restarts/rebuilds/updates.
|
||||
|
||||
@@ -75,18 +80,20 @@ Persistent admin saves:
|
||||
|
||||
## Routes
|
||||
|
||||
- Portfolio home: `/`
|
||||
- Project details: `/projects/:slug`
|
||||
- Site home: `/`
|
||||
- Start Here: `/start-here`
|
||||
- Questions: `/questions`
|
||||
- Privacy: `/privacy`
|
||||
- Terms: `/terms`
|
||||
- Admin editor: `/admin`
|
||||
- Admin draft preview: `/preview`
|
||||
|
||||
## Admin editing
|
||||
|
||||
- Open `/admin` to edit project card and featured-page content.
|
||||
- Use **Global Settings** to update theme, home headers, and quick tips.
|
||||
- Use **Project Settings** to update project-specific fields and README URL.
|
||||
- Use **Create new entry** to add a new project directly from Admin.
|
||||
- Enter a URL and click **Scan URL metadata** to auto-fill title, summary, domain, category, and icon when available.
|
||||
- Click **Save project** to apply updates instantly.
|
||||
- Open `/admin` to edit homepage content, links, archived studies, Q&A moderation, and analytics.
|
||||
- Use **Operations** to manage SEO metadata, sitemap paths, legal page text, redirect rules, and podcast highlights.
|
||||
- Use **Operations** to upload/delete hosted image assets for use in content and social cards.
|
||||
- Use **Save Draft**, **Preview Draft**, and **Publish Draft** for a safer publish workflow.
|
||||
- Saved edits are written to `data/admin-content.json` through the API server.
|
||||
- Built-in stats in `/admin` include page hits plus visitor details (IP, country/state/county/city, returning visitors, and recent visitor log).
|
||||
- Site Stats in `/admin` includes a Bible Questions inbox sourced from contact form submissions marked as Bible Question.
|
||||
@@ -94,19 +101,19 @@ Persistent admin saves:
|
||||
- Admin now includes maintenance actions: **Export JSON**, **Backup Now**, **Prune Old Data**, and **Clear Analytics**.
|
||||
- Admin also supports restoring from a backup snapshot from `/admin`.
|
||||
- The server creates startup + daily backup snapshots and retains recent backups automatically.
|
||||
- Use the **Theme** dropdown to switch between Sandstone, Ocean, Midnight, Forest, and Sunset.
|
||||
- Use **Remove project** to delete the selected project from your local Admin data.
|
||||
- Use **Reset project** or **Reset all** to restore defaults from `src/data/projects.ts`.
|
||||
|
||||
Scan notes:
|
||||
|
||||
- URL scanning is best-effort and depends on site accessibility.
|
||||
- Some sites block direct scanning; Admin falls back to a proxy scan path when possible.
|
||||
- Always review scanned fields before saving.
|
||||
- Operations can trigger optional **Purge Cache** and **Trigger Deploy** webhooks.
|
||||
|
||||
Deployment note:
|
||||
|
||||
- To keep Admin saves working on the internet, deploy with the Node API (`server.js`) and writable server storage for `data/admin-content.json`.
|
||||
- To keep Admin saves working on the internet, deploy with the Node API (`server.js`) and writable server storage for `data/`.
|
||||
|
||||
Titus study download gate:
|
||||
|
||||
- The site now gates Titus study downloads behind a name/email form.
|
||||
- Default source file is `A_Study_of_Titus.pdf` in the project root.
|
||||
- Override source path with `TITUS_STUDY_FILE` (relative to project root or absolute).
|
||||
- Override downloaded filename with `TITUS_STUDY_DOWNLOAD_NAME`.
|
||||
- When the form checkbox is left enabled (default), contacts are synced to Resend using the same contact sync flow as the contact form.
|
||||
|
||||
Useful container commands:
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,12 +0,0 @@
|
||||
<!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
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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})`);
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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.'
|
||||
}
|
||||
];
|
||||
@@ -1,44 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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 +0,0 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/components/episodecard.tsx","./src/components/resourcecard.tsx","./src/data/podcastdata.ts"],"version":"5.9.3"}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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 +0,0 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"siteContent": {
|
||||
"eyebrow": "A Journey Through Scripture",
|
||||
"heroTagline": "Exploring God's Word one verse at a time",
|
||||
"startHereHeading": "New Here? Start Here",
|
||||
"startHereIntro": "If this is your first visit, this path helps you get grounded quickly and make the most of the site.",
|
||||
"startHereStep1Title": "Step 1: Listen to 3 Starter Episodes",
|
||||
"startHereStep1Body": "Start with the newest episode, one from the beginning of the current study, and one from the middle.",
|
||||
"startHereStep1Cta": "Open Episodes",
|
||||
"startHereStep2Title": "Step 2: Use Q&A to Go Deeper",
|
||||
"startHereStep2Body": "Browse by topic or search key words to find concise biblical answers and related follow-up questions.",
|
||||
"startHereStep2Cta": "Go to Q&A",
|
||||
"startHereStep3Title": "Step 3: Ask Nate Directly",
|
||||
"startHereStep3Body": "Use the contact form to submit your Bible question for future Q&A or an upcoming episode.",
|
||||
"startHereStep3Cta": "Submit a Question",
|
||||
"aboutShowHeading": "Depth. Clarity. Application.",
|
||||
"aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.",
|
||||
"aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
|
||||
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
||||
"seriesLabel": "Now Playing",
|
||||
"seriesTitle": "Study of Titus: Sound Doctrine",
|
||||
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
||||
"seriesImageUrl": "/images/titus-cover.png",
|
||||
"seriesListenUrl": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
|
||||
"studyGuideTitle": "Companion Study Guide",
|
||||
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
|
||||
"studyGuideUrl": "https://a.co/d/01sG2tOJ",
|
||||
"shareHeading": "Help one more person hear the Word this week.",
|
||||
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
|
||||
"customLinks": [],
|
||||
"customBlocks": [],
|
||||
"archivedSeries": [],
|
||||
"redirects": [
|
||||
{
|
||||
"id": "spotify",
|
||||
"path": "/spotify",
|
||||
"target": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
|
||||
"statusCode": 301
|
||||
},
|
||||
{
|
||||
"id": "apple",
|
||||
"path": "/apple",
|
||||
"target": "https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate",
|
||||
"statusCode": 301
|
||||
},
|
||||
{
|
||||
"id": "amazon",
|
||||
"path": "/amazon",
|
||||
"target": "https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate",
|
||||
"statusCode": 301
|
||||
}
|
||||
],
|
||||
"podcastFeaturedLinks": [
|
||||
{
|
||||
"id": "sample-highlight-1",
|
||||
"title": "Introduction to Titus",
|
||||
"episodeNumber": "2",
|
||||
"summary": "A practical walkthrough of context, structure, and application for this study.",
|
||||
"url": "https://open.spotify.com/episode/3mpFfJQP6avAhzxu4BTKvR?si=cl_Dm_2vQraiboouaoINaw",
|
||||
"embedUrl": "",
|
||||
"showNotes": "",
|
||||
"discussionQuestions": [
|
||||
"1. What do you already know about Paul, Titus, or the island of Crete? And what — if anything — surprised you about the background of this letter?",
|
||||
"2. The letter’s core theme is that sound doctrine leads to godly living. Where have you seen that connection — or its breakdown — in your own experience?",
|
||||
"3. Paul trusted Titus with a hard assignment. Who has trusted you with something difficult — and what did that trust mean to you?"
|
||||
]
|
||||
}
|
||||
],
|
||||
"seo": {
|
||||
"title": "Verse by Verse with Nate",
|
||||
"description": "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
||||
"ogTitle": "Verse by Verse with Nate",
|
||||
"ogDescription": "A Journey Through Scripture - verse by verse, nugget by nugget.",
|
||||
"ogImage": "/images/podcast-art.jpeg",
|
||||
"canonicalUrl": "https://versebyversewithnate.us/",
|
||||
"robotsPolicy": "index,follow",
|
||||
"sitemapPaths": [
|
||||
"/",
|
||||
"/start-here",
|
||||
"/questions",
|
||||
"/privacy",
|
||||
"/terms"
|
||||
]
|
||||
},
|
||||
"legal": {
|
||||
"privacyTitle": "Privacy Policy",
|
||||
"privacyBody": [
|
||||
"We respect your privacy and collect limited data to operate and improve this site.",
|
||||
"If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.",
|
||||
"Contact form details are used only to respond to your message and ministry communication requests."
|
||||
],
|
||||
"termsTitle": "Terms",
|
||||
"termsBody": [
|
||||
"Content on this site is for informational and ministry purposes.",
|
||||
"External links are provided for convenience and are subject to third-party policies.",
|
||||
"By using this site, you agree to lawful use and respectful communication."
|
||||
]
|
||||
}
|
||||
},
|
||||
"updatedAt": "2026-04-27T20:24:22.589Z"
|
||||
}
|
||||
+80
-3
@@ -2,10 +2,21 @@
|
||||
"siteContent": {
|
||||
"eyebrow": "A Journey Through Scripture",
|
||||
"heroTagline": "Exploring God's Word one verse at a time",
|
||||
"startHereHeading": "New Here? Start Here",
|
||||
"startHereIntro": "If this is your first visit, this path helps you get grounded quickly and make the most of the site.",
|
||||
"startHereStep1Title": "Step 1: Listen to 3 Starter Episodes",
|
||||
"startHereStep1Body": "Start with the newest episode, one from the beginning of the current study, and one from the middle.",
|
||||
"startHereStep1Cta": "Open Episodes",
|
||||
"startHereStep2Title": "Step 2: Use Q&A to Go Deeper",
|
||||
"startHereStep2Body": "Browse by topic or search key words to find concise biblical answers and related follow-up questions.",
|
||||
"startHereStep2Cta": "Go to Q&A",
|
||||
"startHereStep3Title": "Step 3: Ask Nate Directly",
|
||||
"startHereStep3Body": "Use the contact form to submit your Bible question for future Q&A or an upcoming episode.",
|
||||
"startHereStep3Cta": "Submit a Question",
|
||||
"aboutShowHeading": "Depth. Clarity. Application.",
|
||||
"aboutShowP1": "Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.",
|
||||
"aboutShowP2": "Whether you're in the car, at the gym, or just looking for something to anchor your day, each episode is designed to feed your faith with solid, practical teaching.",
|
||||
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He created Verse by Verse to share the joy of deep Scripture study in a format anyone can follow along with — no seminary required.",
|
||||
"aboutNate": "Nate Emmert is a husband, dad, and lifelong student of the Bible from Lynchburg, Va. He's not a pastor or a professor — just someone who fell in love with digging into Scripture and wanted to bring others along for the journey. He created Verse by Verse to make deep Bible study accessible to anyone, whether you've read the Bible your whole life or you're just getting started. No seminary required. No prior knowledge assumed. Just the Word, unpacked verse by verse.",
|
||||
"seriesLabel": "Now Playing",
|
||||
"seriesTitle": "Study of Titus: Sound Doctrine",
|
||||
"seriesDescription": "A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
|
||||
@@ -18,7 +29,73 @@
|
||||
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
|
||||
"customLinks": [],
|
||||
"customBlocks": [],
|
||||
"archivedSeries": []
|
||||
"archivedSeries": [],
|
||||
"redirects": [
|
||||
{
|
||||
"id": "spotify",
|
||||
"path": "/spotify",
|
||||
"target": "https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl",
|
||||
"statusCode": 301
|
||||
},
|
||||
"updatedAt": "2026-04-16T12:13:53.095Z"
|
||||
{
|
||||
"id": "apple",
|
||||
"path": "/apple",
|
||||
"target": "https://podcasts.apple.com/search?term=Verse+by+Verse+with+Nate",
|
||||
"statusCode": 301
|
||||
},
|
||||
{
|
||||
"id": "amazon",
|
||||
"path": "/amazon",
|
||||
"target": "https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate",
|
||||
"statusCode": 301
|
||||
}
|
||||
],
|
||||
"podcastFeaturedLinks": [
|
||||
{
|
||||
"id": "sample-highlight-1",
|
||||
"title": "Introduction to Titus",
|
||||
"episodeNumber": "2",
|
||||
"summary": "A practical walkthrough of context, structure, and application for this study.",
|
||||
"url": "https://open.spotify.com/episode/3mpFfJQP6avAhzxu4BTKvR?si=cl_Dm_2vQraiboouaoINaw",
|
||||
"embedUrl": "",
|
||||
"showNotes": "",
|
||||
"discussionQuestions": [
|
||||
"1. What do you already know about Paul, Titus, or the island of Crete? And what — if anything — surprised you about the background of this letter?",
|
||||
"2. The letter’s core theme is that sound doctrine leads to godly living. Where have you seen that connection — or its breakdown — in your own experience?",
|
||||
"3. Paul trusted Titus with a hard assignment. Who has trusted you with something difficult — and what did that trust mean to you?"
|
||||
]
|
||||
}
|
||||
],
|
||||
"seo": {
|
||||
"title": "Verse by Verse with Nate",
|
||||
"description": "Verse by Verse with Nate explores Scripture one verse at a time with practical Bible teaching.",
|
||||
"ogTitle": "Verse by Verse with Nate",
|
||||
"ogDescription": "A Journey Through Scripture - verse by verse, nugget by nugget.",
|
||||
"ogImage": "/images/podcast-art.jpeg",
|
||||
"canonicalUrl": "https://versebyversewithnate.us/",
|
||||
"robotsPolicy": "index,follow",
|
||||
"sitemapPaths": [
|
||||
"/",
|
||||
"/start-here",
|
||||
"/questions",
|
||||
"/privacy",
|
||||
"/terms"
|
||||
]
|
||||
},
|
||||
"legal": {
|
||||
"privacyTitle": "Privacy Policy",
|
||||
"privacyBody": [
|
||||
"We respect your privacy and collect limited data to operate and improve this site.",
|
||||
"If you consent to analytics cookies, we may store masked IP-based location signals and returning visitor activity.",
|
||||
"Contact form details are used only to respond to your message and ministry communication requests."
|
||||
],
|
||||
"termsTitle": "Terms",
|
||||
"termsBody": [
|
||||
"Content on this site is for informational and ministry purposes.",
|
||||
"External links are provided for convenience and are subject to third-party policies.",
|
||||
"By using this site, you agree to lawful use and respectful communication."
|
||||
]
|
||||
}
|
||||
},
|
||||
"updatedAt": "2026-04-27T20:24:19.789Z"
|
||||
}
|
||||
+4
-4
@@ -11,14 +11,14 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Verse by Verse with Nate" />
|
||||
<meta property="og:description" content="A verse-by-verse Scripture podcast exploring God's Word with depth, clarity, and practical application." />
|
||||
<meta property="og:image" content="https://necloud.us/images/banner.png" />
|
||||
<meta property="og:image" content="https://versebyversewithnate.us/images/banner.png" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:secure_url" content="https://necloud.us/images/banner.png" />
|
||||
<meta property="og:image:secure_url" content="https://versebyversewithnate.us/images/banner.png" />
|
||||
<meta property="og:image:alt" content="Verse by Verse with Nate podcast banner" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:url" content="https://necloud.us" />
|
||||
<meta property="og:url" content="https://versebyversewithnate.us" />
|
||||
<meta property="og:site_name" content="Verse by Verse with Nate" />
|
||||
<link rel="canonical" href="https://necloud.us" />
|
||||
<link rel="canonical" href="https://versebyversewithnate.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=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600;1,700&display=swap" rel="stylesheet" />
|
||||
|
||||
Generated
+1435
-1
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -10,8 +10,9 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"start": "node --env-file=.env server.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"chatbot:eval": "node scripts/evaluate-chatbot.mjs"
|
||||
"preview": "npm run build && wrangler dev",
|
||||
"chatbot:eval": "node scripts/evaluate-chatbot.mjs",
|
||||
"deploy": "npm run build && wrangler deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
@@ -23,6 +24,7 @@
|
||||
"resend": "^6.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.35.0",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
@@ -35,6 +37,7 @@
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0"
|
||||
"vite": "^8.0.0",
|
||||
"wrangler": "^4.87.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
@@ -0,0 +1,9 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Disallow: /admin
|
||||
Disallow: /api/
|
||||
Disallow: /thanks
|
||||
Disallow: /subscribe/thanks
|
||||
|
||||
Sitemap: https://versebyversewithnate.us/sitemap.xml
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/</loc>
|
||||
<lastmod>2026-04-27</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/start-here</loc>
|
||||
<lastmod>2026-04-27</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/questions</loc>
|
||||
<lastmod>2026-04-27</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/subscribe</loc>
|
||||
<lastmod>2026-04-27</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/privacy</loc>
|
||||
<lastmod>2026-04-27</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/terms</loc>
|
||||
<lastmod>2026-04-27</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
+628
-11
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote } from './App'
|
||||
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './App'
|
||||
import { DEFAULTS } from './App'
|
||||
|
||||
interface Props {
|
||||
@@ -60,6 +61,26 @@ interface AdminStats {
|
||||
}
|
||||
}
|
||||
|
||||
interface AdminAsset {
|
||||
filename: string
|
||||
url: string
|
||||
sizeBytes: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface PublishState {
|
||||
draftUpdatedAt: string | null
|
||||
publishedAt: string | null
|
||||
}
|
||||
|
||||
interface OpsStatus {
|
||||
buildCommit: string | null
|
||||
buildNumber: string | null
|
||||
deployedAt: string | null
|
||||
cachePurge: { ok: boolean; at: string | null; error: string | null }
|
||||
deployHook: { ok: boolean; at: string | null; error: string | null }
|
||||
}
|
||||
|
||||
interface Question {
|
||||
id: string
|
||||
submittedAt: string
|
||||
@@ -71,7 +92,7 @@ interface Question {
|
||||
isApproved: boolean
|
||||
approvedAt: string | null
|
||||
}
|
||||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries'>
|
||||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal'>
|
||||
|
||||
type MainContentSection = 'hero' | 'start-here' | 'about' | 'series' | 'share'
|
||||
|
||||
@@ -174,14 +195,19 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
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' | 'brand'>('content')
|
||||
const [adminTab, setAdminTab] = useState<'content' | 'episodes' | 'settings' | 'publish' | 'analytics' | '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')
|
||||
const [maintenanceMsg, setMaintenanceMsg] = useState('')
|
||||
const [opsMsg, setOpsMsg] = useState('')
|
||||
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
|
||||
const [selectedBackup, setSelectedBackup] = useState('')
|
||||
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
|
||||
const [publishState, setPublishState] = useState<PublishState>({ draftUpdatedAt: null, publishedAt: null })
|
||||
const [assets, setAssets] = useState<AdminAsset[]>([])
|
||||
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
|
||||
const [assetUploadPending, setAssetUploadPending] = useState(false)
|
||||
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
|
||||
@@ -234,6 +260,34 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
fetch('/api/admin-content-state')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load publish state'))))
|
||||
.then(data => {
|
||||
const state = (data as { publishState?: PublishState }).publishState
|
||||
if (state) {
|
||||
setPublishState({
|
||||
draftUpdatedAt: state.draftUpdatedAt ?? null,
|
||||
publishedAt: state.publishedAt ?? null,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
fetch('/api/admin-assets')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load assets'))))
|
||||
.then(data => {
|
||||
const list = Array.isArray((data as { assets?: unknown }).assets) ? (data as { assets: AdminAsset[] }).assets : []
|
||||
setAssets(list)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
fetch('/api/admin-ops/status')
|
||||
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load operations status'))))
|
||||
.then(data => {
|
||||
setOpsStatus(data as OpsStatus)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -288,6 +342,256 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadAssets() {
|
||||
const r = await fetch('/api/admin-assets')
|
||||
if (!r.ok) throw new Error('Could not refresh assets')
|
||||
const data = await r.json() as { assets?: AdminAsset[] }
|
||||
setAssets(Array.isArray(data.assets) ? data.assets : [])
|
||||
}
|
||||
|
||||
async function reloadOpsStatus() {
|
||||
const r = await fetch('/api/admin-ops/status')
|
||||
if (!r.ok) throw new Error('Could not refresh ops status')
|
||||
const data = await r.json() as OpsStatus
|
||||
setOpsStatus(data)
|
||||
}
|
||||
|
||||
async function handleSaveDraft() {
|
||||
setStatus('saving')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const res = await fetch('/api/admin-content-draft', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ siteContent: form }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { message?: string }).message ?? 'Draft save failed')
|
||||
}
|
||||
const data = await res.json() as { updatedAt?: string }
|
||||
setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() }))
|
||||
setStatus('saved')
|
||||
setTimeout(() => setStatus('idle'), 3500)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublishDraft() {
|
||||
if (!confirm('Publish current changes to the live site now?')) return
|
||||
setStatus('saving')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
// Always save the current form to draft first so publish never fails from a missing draft
|
||||
const draftRes = await fetch('/api/admin-content-draft', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ siteContent: form }),
|
||||
})
|
||||
if (!draftRes.ok) {
|
||||
const data = await draftRes.json().catch(() => ({}))
|
||||
throw new Error((data as { message?: string }).message ?? 'Draft save failed')
|
||||
}
|
||||
const draftData = await draftRes.json() as { updatedAt?: string }
|
||||
setPublishState(prev => ({ ...prev, draftUpdatedAt: draftData.updatedAt ?? new Date().toISOString() }))
|
||||
|
||||
const publishRes = await fetch('/api/admin-content/publish', { method: 'POST' })
|
||||
if (!publishRes.ok) {
|
||||
const data = await publishRes.json().catch(() => ({}))
|
||||
throw new Error((data as { message?: string }).message ?? 'Publish failed')
|
||||
}
|
||||
|
||||
const published = await publishRes.json() as { publishedAt?: string }
|
||||
const latestRes = await fetch('/api/admin-content')
|
||||
if (!latestRes.ok) throw new Error('Failed to refresh published content')
|
||||
const latest = await latestRes.json() as { siteContent?: Partial<SiteContent> }
|
||||
if (latest?.siteContent) {
|
||||
const next = { ...DEFAULTS, ...latest.siteContent }
|
||||
setForm(next)
|
||||
onSave(next)
|
||||
setLastSavedSnapshot(JSON.stringify(next))
|
||||
}
|
||||
|
||||
setPublishState(prev => ({ ...prev, publishedAt: published.publishedAt ?? new Date().toISOString() }))
|
||||
await reloadStats()
|
||||
setStatus('saved')
|
||||
setTimeout(() => setStatus('idle'), 3500)
|
||||
} catch (err) {
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
function openDraftPreview() {
|
||||
window.open('/preview', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function updateSeoField(field: keyof SeoSettings, value: string | string[]) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
seo: {
|
||||
...f.seo,
|
||||
[field]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function updateLegalField(field: keyof LegalSettings, value: string | string[]) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
legal: {
|
||||
...f.legal,
|
||||
[field]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function addRedirectRule() {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
redirects: [
|
||||
...(f.redirects ?? []),
|
||||
{
|
||||
id: Date.now().toString(36),
|
||||
path: '/new-short-link',
|
||||
target: 'https://',
|
||||
statusCode: 301,
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
function updateRedirectRule(id: string, field: keyof RedirectRule, value: string | number) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
redirects: (f.redirects ?? []).map(rule => rule.id === id
|
||||
? {
|
||||
...rule,
|
||||
[field]: field === 'statusCode' ? (Number(value) === 302 ? 302 : 301) : value,
|
||||
}
|
||||
: rule),
|
||||
}))
|
||||
}
|
||||
|
||||
function removeRedirectRule(id: string) {
|
||||
setForm(f => ({ ...f, redirects: (f.redirects ?? []).filter(rule => rule.id !== id) }))
|
||||
}
|
||||
|
||||
function addPodcastLink() {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
podcastFeaturedLinks: [
|
||||
...(f.podcastFeaturedLinks ?? []),
|
||||
{
|
||||
id: Date.now().toString(36),
|
||||
title: '',
|
||||
episodeNumber: '',
|
||||
summary: '',
|
||||
url: '',
|
||||
embedUrl: '',
|
||||
showNotes: '',
|
||||
discussionQuestions: [],
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
function updatePodcastLink(id: string, field: keyof PodcastFeaturedLink, value: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link => link.id === id ? { ...link, [field]: value } : link),
|
||||
}))
|
||||
}
|
||||
|
||||
function updatePodcastLinkQuestions(id: string, raw: string) {
|
||||
const questions = raw.split('\n').map(q => q.trim()).filter(Boolean)
|
||||
setForm(f => ({
|
||||
...f,
|
||||
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link =>
|
||||
link.id === id ? { ...link, discussionQuestions: questions } : link
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function removePodcastLink(id: string) {
|
||||
setForm(f => ({ ...f, podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).filter(link => link.id !== id) }))
|
||||
}
|
||||
|
||||
async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setAssetUploadPending(true)
|
||||
setOpsMsg('')
|
||||
try {
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(new Error('Failed to read file'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
|
||||
const res = await fetch('/api/admin-assets', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename: file.name, dataUrl }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { message?: string }).message ?? 'Upload failed')
|
||||
}
|
||||
await reloadAssets()
|
||||
setOpsMsg('Asset uploaded successfully.')
|
||||
} catch (err) {
|
||||
setOpsMsg(err instanceof Error ? err.message : 'Upload failed.')
|
||||
} finally {
|
||||
setAssetUploadPending(false)
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAsset(filename: string) {
|
||||
if (!confirm(`Delete asset ${filename}?`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error('Delete failed')
|
||||
await reloadAssets()
|
||||
setOpsMsg('Asset deleted.')
|
||||
} catch {
|
||||
setOpsMsg('Asset delete failed.')
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePurgeCache() {
|
||||
try {
|
||||
const res = await fetch('/api/admin-ops/purge-cache', { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { message?: string }).message ?? 'Cache purge failed')
|
||||
}
|
||||
await reloadOpsStatus()
|
||||
setOpsMsg('Cache purge triggered.')
|
||||
} catch (err) {
|
||||
setOpsMsg(err instanceof Error ? err.message : 'Cache purge failed.')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeployHook() {
|
||||
try {
|
||||
const res = await fetch('/api/admin-ops/deploy', { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { message?: string }).message ?? 'Deploy hook failed')
|
||||
}
|
||||
await reloadOpsStatus()
|
||||
setOpsMsg('Deploy hook triggered.')
|
||||
} catch (err) {
|
||||
setOpsMsg(err instanceof Error ? err.message : 'Deploy hook failed.')
|
||||
}
|
||||
}
|
||||
|
||||
function maskIp(ip: string) {
|
||||
if (!ip || ip === 'unknown') return 'unknown'
|
||||
if (ip.includes('.')) {
|
||||
@@ -402,7 +706,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
return confirm('You have unsaved changes. Leave this section without saving?')
|
||||
}
|
||||
|
||||
function handleAdminTabChange(nextTab: 'content' | 'stats' | 'questions' | 'brand') {
|
||||
function handleAdminTabChange(nextTab: 'content' | 'episodes' | 'settings' | 'publish' | 'analytics' | 'questions' | 'brand') {
|
||||
if (nextTab === adminTab) return
|
||||
if (!confirmLeaveUnsavedChanges()) return
|
||||
setAdminTab(nextTab)
|
||||
@@ -807,11 +1111,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={adminTab === 'stats'}
|
||||
className={`admin-tab ${adminTab === 'stats' ? 'admin-tab--active' : ''}`}
|
||||
onClick={() => handleAdminTabChange('stats')}
|
||||
aria-selected={adminTab === 'episodes'}
|
||||
className={`admin-tab ${adminTab === 'episodes' ? 'admin-tab--active' : ''}`}
|
||||
onClick={() => handleAdminTabChange('episodes')}
|
||||
>
|
||||
Site Stats
|
||||
Episodes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -822,6 +1126,33 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
>
|
||||
Questions ({questions.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={adminTab === 'analytics'}
|
||||
className={`admin-tab ${adminTab === 'analytics' ? 'admin-tab--active' : ''}`}
|
||||
onClick={() => handleAdminTabChange('analytics')}
|
||||
>
|
||||
Analytics
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={adminTab === 'settings'}
|
||||
className={`admin-tab ${adminTab === 'settings' ? 'admin-tab--active' : ''}`}
|
||||
onClick={() => handleAdminTabChange('settings')}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={adminTab === 'publish'}
|
||||
className={`admin-tab ${adminTab === 'publish' ? 'admin-tab--active' : ''}`}
|
||||
onClick={() => handleAdminTabChange('publish')}
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -829,7 +1160,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
className={`admin-tab ${adminTab === 'brand' ? 'admin-tab--active' : ''}`}
|
||||
onClick={() => handleAdminTabChange('brand')}
|
||||
>
|
||||
Brand Kit
|
||||
Brand
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -894,8 +1225,294 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{adminTab === 'stats' && (
|
||||
<section className="admin-stats" aria-label="Site hit statistics">
|
||||
{adminTab === 'publish' && (
|
||||
<section className="admin-stats" aria-label="Publishing and deployment">
|
||||
<div className="admin-stats-head">
|
||||
<h2>Publishing Workflow</h2>
|
||||
<p>Save drafts, preview before release, and publish when ready.</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-stats-grid">
|
||||
<article>
|
||||
<h3>Draft Updated</h3>
|
||||
<p>{formatDate(publishState.draftUpdatedAt)}</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Last Published</h3>
|
||||
<p>{formatDate(publishState.publishedAt)}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div className="admin-actions admin-actions--maintenance">
|
||||
<button type="button" className="btn-admin-save" onClick={handleSaveDraft}>Save Draft</button>
|
||||
<button type="button" className="btn-admin-reset" onClick={openDraftPreview}>Preview Draft</button>
|
||||
<button type="button" className="btn-admin-reset" onClick={handlePublishDraft}>Publish Draft</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Deployment and Cache</h2>
|
||||
<p>Trigger deployment hooks and cache purge hooks.</p>
|
||||
</div>
|
||||
<div className="admin-stats-grid">
|
||||
<article>
|
||||
<h3>Build Commit</h3>
|
||||
<p>{opsStatus?.buildCommit ?? 'Not available'}</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Build Number</h3>
|
||||
<p>{opsStatus?.buildNumber ?? 'Not available'}</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Deployed At</h3>
|
||||
<p>{formatDate(opsStatus?.deployedAt ?? null)}</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>Cache Purge</h3>
|
||||
<p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p>
|
||||
<p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p>
|
||||
</article>
|
||||
</div>
|
||||
<div className="admin-actions admin-actions--maintenance">
|
||||
<button type="button" className="btn-admin-reset" onClick={handlePurgeCache}>Purge Cache</button>
|
||||
<button type="button" className="btn-admin-reset" onClick={handleDeployHook}>Trigger Deploy</button>
|
||||
<button type="button" className="btn-admin-reset" onClick={() => { void reloadOpsStatus() }}>Refresh Status</button>
|
||||
</div>
|
||||
|
||||
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
|
||||
{status === 'error' && <p className="admin-status admin-status--err">✗ {errorMsg}</p>}
|
||||
{status === 'saved' && <p className="admin-status admin-status--ok">✓ Draft action completed.</p>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{adminTab === 'episodes' && (
|
||||
<section className="admin-stats" aria-label="Episode highlights">
|
||||
<div className="admin-stats-head">
|
||||
<h2>Episode Highlights</h2>
|
||||
<p>Featured episodes shown on the Episodes page. Fill in discussion questions, show notes, or an embed URL and the highlight automatically gets its own detail page at <code>/episodes/[id]</code>.</p>
|
||||
</div>
|
||||
{(form.podcastFeaturedLinks ?? []).length === 0 && (
|
||||
<p className="admin-stats-note">No episode highlights yet. Add one below.</p>
|
||||
)}
|
||||
{(form.podcastFeaturedLinks ?? []).map(item => (
|
||||
<div key={item.id} className="admin-array-row">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`podcast-ep-${item.id}`}>Episode Number</label>
|
||||
<input id={`podcast-ep-${item.id}`} type="text" placeholder="e.g. 42" value={item.episodeNumber ?? ''} onChange={e => updatePodcastLink(item.id, 'episodeNumber', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`podcast-title-${item.id}`}>Title</label>
|
||||
<input id={`podcast-title-${item.id}`} type="text" value={item.title} onChange={e => updatePodcastLink(item.id, 'title', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`podcast-summary-${item.id}`}>Summary</label>
|
||||
<textarea id={`podcast-summary-${item.id}`} rows={2} value={item.summary} onChange={e => updatePodcastLink(item.id, 'summary', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`podcast-url-${item.id}`}>Platform URL (external link)</label>
|
||||
<input id={`podcast-url-${item.id}`} type="text" placeholder="https://open.spotify.com/..." value={item.url} onChange={e => updatePodcastLink(item.id, 'url', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`podcast-embed-${item.id}`}>Embed URL (optional — enables in-page player)</label>
|
||||
<input id={`podcast-embed-${item.id}`} type="text" placeholder="https://open.spotify.com/embed/episode/..." value={item.embedUrl ?? ''} onChange={e => updatePodcastLink(item.id, 'embedUrl', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`podcast-notes-${item.id}`}>Show Notes</label>
|
||||
<textarea id={`podcast-notes-${item.id}`} rows={4} placeholder="Key points, scripture references, timestamps..." value={item.showNotes ?? ''} onChange={e => updatePodcastLink(item.id, 'showNotes', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`podcast-dq-${item.id}`}>Discussion Questions (one per line)</label>
|
||||
<textarea id={`podcast-dq-${item.id}`} rows={5} placeholder="What stood out to you in this passage?" value={(item.discussionQuestions ?? []).join('\n')} onChange={e => updatePodcastLinkQuestions(item.id, e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removePodcastLink(item.id)}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" onClick={addPodcastLink}>+ Add Episode Highlight</button>
|
||||
|
||||
<div className="admin-actions" style={{ marginTop: '2rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-save"
|
||||
onClick={handleSave}
|
||||
disabled={status === 'saving'}
|
||||
>
|
||||
{status === 'saving' ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
{status === 'saved' && <p className="admin-status admin-status--ok">✓ Changes saved.</p>}
|
||||
{status === 'error' && <p className="admin-status admin-status--err">✗ {errorMsg}</p>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{adminTab === 'settings' && (
|
||||
<section className="admin-stats" aria-label="Site settings">
|
||||
<div className="admin-stats-head">
|
||||
<h2>SEO Settings</h2>
|
||||
<p>Control metadata, canonical URL, robots policy, and sitemap paths.</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-array-row">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor="seo-title">SEO Title</label>
|
||||
<input id="seo-title" type="text" value={form.seo?.title ?? ''} onChange={e => updateSeoField('title', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="seo-description">SEO Description</label>
|
||||
<textarea id="seo-description" rows={3} value={form.seo?.description ?? ''} onChange={e => updateSeoField('description', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="seo-og-title">Open Graph Title</label>
|
||||
<input id="seo-og-title" type="text" value={form.seo?.ogTitle ?? ''} onChange={e => updateSeoField('ogTitle', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="seo-og-description">Open Graph Description</label>
|
||||
<textarea id="seo-og-description" rows={3} value={form.seo?.ogDescription ?? ''} onChange={e => updateSeoField('ogDescription', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="seo-og-image">Open Graph Image URL</label>
|
||||
<input id="seo-og-image" type="text" value={form.seo?.ogImage ?? ''} onChange={e => updateSeoField('ogImage', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="seo-canonical">Canonical URL</label>
|
||||
<input id="seo-canonical" type="text" value={form.seo?.canonicalUrl ?? ''} onChange={e => updateSeoField('canonicalUrl', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="seo-robots">Robots Policy</label>
|
||||
<input id="seo-robots" type="text" value={form.seo?.robotsPolicy ?? ''} onChange={e => updateSeoField('robotsPolicy', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="seo-sitemap">Sitemap Paths (one per line)</label>
|
||||
<textarea
|
||||
id="seo-sitemap"
|
||||
rows={4}
|
||||
value={(form.seo?.sitemapPaths ?? []).join('\n')}
|
||||
onChange={e => updateSeoField('sitemapPaths', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Redirect Manager</h2>
|
||||
<p>Maintain first-party short links like /spotify without code changes.</p>
|
||||
</div>
|
||||
{(form.redirects ?? []).map(rule => (
|
||||
<div key={rule.id} className="admin-array-row">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`redirect-path-${rule.id}`}>Path</label>
|
||||
<input id={`redirect-path-${rule.id}`} type="text" value={rule.path} onChange={e => updateRedirectRule(rule.id, 'path', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`redirect-target-${rule.id}`}>Target URL</label>
|
||||
<input id={`redirect-target-${rule.id}`} type="text" value={rule.target} onChange={e => updateRedirectRule(rule.id, 'target', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`redirect-status-${rule.id}`}>Status</label>
|
||||
<select id={`redirect-status-${rule.id}`} value={rule.statusCode} onChange={e => updateRedirectRule(rule.id, 'statusCode', Number(e.target.value))}>
|
||||
<option value={301}>301 Permanent</option>
|
||||
<option value={302}>302 Temporary</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeRedirectRule(rule.id)}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" onClick={addRedirectRule}>+ Add Redirect</button>
|
||||
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Legal Pages</h2>
|
||||
<p>Edit Privacy and Terms pages from admin.</p>
|
||||
</div>
|
||||
<div className="admin-array-row">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor="legal-privacy-title">Privacy Title</label>
|
||||
<input id="legal-privacy-title" type="text" value={form.legal?.privacyTitle ?? ''} onChange={e => updateLegalField('privacyTitle', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="legal-privacy-body">Privacy Body (one paragraph per line)</label>
|
||||
<textarea
|
||||
id="legal-privacy-body"
|
||||
rows={4}
|
||||
value={(form.legal?.privacyBody ?? []).join('\n')}
|
||||
onChange={e => updateLegalField('privacyBody', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="legal-terms-title">Terms Title</label>
|
||||
<input id="legal-terms-title" type="text" value={form.legal?.termsTitle ?? ''} onChange={e => updateLegalField('termsTitle', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="legal-terms-body">Terms Body (one paragraph per line)</label>
|
||||
<textarea
|
||||
id="legal-terms-body"
|
||||
rows={4}
|
||||
value={(form.legal?.termsBody ?? []).join('\n')}
|
||||
onChange={e => updateLegalField('termsBody', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-stats-head admin-stats-head--visitors">
|
||||
<h2>Asset Manager</h2>
|
||||
<p>Upload and reuse hosted images from this domain.</p>
|
||||
</div>
|
||||
<div className="admin-actions admin-actions--maintenance">
|
||||
<label className="btn-admin-reset" style={{ display: 'inline-flex', alignItems: 'center', cursor: 'pointer' }}>
|
||||
{assetUploadPending ? 'Uploading...' : 'Upload Image'}
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" onChange={handleAssetUpload} style={{ display: 'none' }} disabled={assetUploadPending} />
|
||||
</label>
|
||||
</div>
|
||||
{assets.length === 0 ? (
|
||||
<p className="admin-stats-note">No uploaded assets yet.</p>
|
||||
) : (
|
||||
<div className="admin-visits-table-scroll">
|
||||
<table className="admin-visits-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preview</th>
|
||||
<th>URL</th>
|
||||
<th>Size</th>
|
||||
<th>Updated</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assets.map(asset => (
|
||||
<tr key={asset.filename}>
|
||||
<td><img src={asset.url} alt={asset.filename} style={{ width: '68px', height: '68px', objectFit: 'cover', borderRadius: '8px' }} /></td>
|
||||
<td>{asset.url}</td>
|
||||
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
|
||||
<td>{formatDate(asset.updatedAt)}</td>
|
||||
<td><button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-actions" style={{ marginTop: '2rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-save"
|
||||
onClick={handleSave}
|
||||
disabled={status === 'saving'}
|
||||
>
|
||||
{status === 'saving' ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
|
||||
{status === 'saved' && <p className="admin-status admin-status--ok">✓ Changes saved.</p>}
|
||||
{status === 'error' && <p className="admin-status admin-status--err">✗ {errorMsg}</p>}
|
||||
</section>
|
||||
)}
|
||||
{adminTab === 'analytics' && (
|
||||
<section className="admin-stats" aria-label="Site analytics">
|
||||
<div className="admin-stats-head">
|
||||
<h2>Site Hit Stats</h2>
|
||||
<p>Built-in page traffic and visitor intelligence from this server.</p>
|
||||
|
||||
+625
-109
@@ -42,6 +42,20 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-menu-btn {
|
||||
display: none;
|
||||
border: 1px solid rgba(201, 168, 76, 0.42);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
color: var(--brand-gold);
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.88rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-nav a {
|
||||
color: var(--brand-muted);
|
||||
text-decoration: none;
|
||||
@@ -57,6 +71,10 @@
|
||||
color: var(--brand-gold);
|
||||
}
|
||||
|
||||
.header-nav-link--active {
|
||||
color: var(--brand-gold) !important;
|
||||
}
|
||||
|
||||
.header-cta {
|
||||
color: var(--brand-gold) !important;
|
||||
border: 1px solid rgba(201, 168, 76, 0.5) !important;
|
||||
@@ -96,11 +114,17 @@
|
||||
.art-glow {
|
||||
position: absolute;
|
||||
inset: -44%;
|
||||
background: radial-gradient(ellipse, rgba(201, 168, 76, 0.42) 0%, rgba(201, 168, 76, 0.16) 44%, transparent 72%);
|
||||
background: radial-gradient(
|
||||
ellipse,
|
||||
rgba(201, 168, 76, 0.58) 0%,
|
||||
rgba(201, 168, 76, 0.3) 34%,
|
||||
rgba(201, 168, 76, 0.1) 58%,
|
||||
transparent 78%
|
||||
);
|
||||
z-index: 0;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
filter: blur(12px);
|
||||
filter: blur(18px) saturate(112%);
|
||||
animation: glow-pulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -267,6 +291,119 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Episode List ── */
|
||||
.episode-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.episode-card {
|
||||
display: block;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||
border-left: 3px solid var(--brand-gold);
|
||||
border-radius: 10px;
|
||||
padding: 1.1rem 1.4rem;
|
||||
text-decoration: none;
|
||||
transition: background 180ms, border-color 180ms, transform 180ms;
|
||||
}
|
||||
|
||||
.episode-card:hover {
|
||||
background: rgba(201, 168, 76, 0.07);
|
||||
border-color: rgba(201, 168, 76, 0.45);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.episode-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.episode-number {
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-gold);
|
||||
}
|
||||
|
||||
.episode-date,
|
||||
.episode-duration {
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.78rem;
|
||||
color: var(--brand-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.episode-date::before {
|
||||
content: '·';
|
||||
margin-right: 0.85rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.episode-duration::before {
|
||||
content: '·';
|
||||
margin-right: 0.85rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.episode-title {
|
||||
font-family: var(--brand-font-heading);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand-warm-white);
|
||||
margin: 0 0 0.35rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.episode-desc {
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.55;
|
||||
color: var(--brand-muted);
|
||||
margin: 0 0 0.6rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.episode-listen-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--brand-gold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.episode-list-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.episode-skeleton {
|
||||
height: 100px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.platform-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -316,14 +453,25 @@
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
.section-about .section-inner {
|
||||
max-width: 1160px;
|
||||
}
|
||||
|
||||
.about-inner {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 4rem;
|
||||
grid-template-columns: minmax(340px, 420px) 1fr;
|
||||
gap: 4.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.about-photo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.about-photo img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
@@ -331,6 +479,75 @@
|
||||
0 0 0 1px rgba(201, 168, 76, 0.15);
|
||||
}
|
||||
|
||||
.about-photo-divider {
|
||||
width: 86%;
|
||||
height: 1px;
|
||||
margin: 0.3rem auto 0.45rem;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(201, 168, 76, 0),
|
||||
rgba(201, 168, 76, 0.6) 20%,
|
||||
rgba(201, 168, 76, 0.9) 50%,
|
||||
rgba(201, 168, 76, 0.6) 80%,
|
||||
rgba(201, 168, 76, 0)
|
||||
);
|
||||
}
|
||||
|
||||
.about-nate-copy {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.about-nate-copy .eyebrow {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.about-nate-rule {
|
||||
width: 170px;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--brand-gold), rgba(201, 168, 76, 0.18));
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.about-nate-copy p {
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 400;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.7;
|
||||
color: var(--brand-warm-white);
|
||||
margin: 0 0 1rem;
|
||||
padding: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.about-nate-cta {
|
||||
display: inline-block;
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-gold);
|
||||
text-decoration: none;
|
||||
margin-top: 0.35rem;
|
||||
padding-bottom: 0.1rem;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.45);
|
||||
transition: color 180ms ease, border-color 180ms ease;
|
||||
}
|
||||
|
||||
.about-nate-cta:hover {
|
||||
color: var(--brand-warm-white);
|
||||
border-bottom-color: rgba(246, 232, 186, 0.85);
|
||||
}
|
||||
|
||||
.about-verse-art {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 1.1rem;
|
||||
border-radius: 14px;
|
||||
box-shadow:
|
||||
0 12px 50px rgba(0, 0, 0, 0.55),
|
||||
0 0 0 1px rgba(201, 168, 76, 0.15);
|
||||
}
|
||||
|
||||
.about-text h2 {
|
||||
font-family: var(--brand-font-heading);
|
||||
font-size: clamp(1.8rem, 3vw, 2.4rem);
|
||||
@@ -550,87 +767,156 @@
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* ── QR / Share ── */
|
||||
.section-qr {
|
||||
/* ── Home Jump Section ── */
|
||||
.section-home-jump {
|
||||
background: var(--brand-black);
|
||||
padding: 5rem 0;
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.16);
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.12);
|
||||
padding: 4.5rem 0;
|
||||
}
|
||||
|
||||
.qr-inner {
|
||||
.home-jump-head {
|
||||
text-align: center;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.home-jump-head .section-heading {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.home-jump-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 4rem;
|
||||
align-items: center;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.qr-text h2 {
|
||||
.home-jump-card {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
background: linear-gradient(180deg, rgba(20, 20, 18, 0.95), rgba(11, 11, 10, 0.95));
|
||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||
border-radius: 14px;
|
||||
padding: 1.1rem 1.15rem;
|
||||
transition: transform 180ms, border-color 180ms, background 180ms;
|
||||
}
|
||||
|
||||
.home-jump-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(201, 168, 76, 0.45);
|
||||
background: linear-gradient(180deg, rgba(28, 27, 23, 0.98), rgba(14, 14, 12, 0.98));
|
||||
}
|
||||
|
||||
.home-jump-card h3 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-family: var(--brand-font-heading);
|
||||
font-size: clamp(1.6rem, 2.5vw, 2.2rem);
|
||||
color: var(--brand-warm-white);
|
||||
margin: 0.5rem 0 1rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.28rem;
|
||||
}
|
||||
|
||||
.qr-text p {
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 300;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.65;
|
||||
color: var(--brand-muted);
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.share-show-wrap {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.btn-share-show {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.share-feedback {
|
||||
.home-jump-card p {
|
||||
margin: 0;
|
||||
font-family: var(--brand-font-body);
|
||||
color: #cdb483;
|
||||
font-size: 0.9rem;
|
||||
color: var(--brand-muted);
|
||||
line-height: 1.55;
|
||||
font-size: 0.97rem;
|
||||
}
|
||||
|
||||
.qr-code-wrap {
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
/* ── Episode Detail Page ── */
|
||||
.section-episode-detail {
|
||||
background: var(--brand-black);
|
||||
padding: 4rem 0 5rem;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.qr-frame {
|
||||
.episode-detail-back {
|
||||
display: inline-block;
|
||||
padding: 12px;
|
||||
background: #f5eed8;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
display: block;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.scan-label {
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 500;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.4em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.92rem;
|
||||
color: var(--brand-gold);
|
||||
margin-top: 0.85rem;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.75rem;
|
||||
letter-spacing: 0.02em;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
|
||||
.episode-detail-back:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.episode-detail-number {
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.85rem;
|
||||
color: var(--brand-gold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
margin: 0 0 0.4rem;
|
||||
}
|
||||
|
||||
.episode-detail-title {
|
||||
font-family: var(--brand-font-heading);
|
||||
font-size: clamp(1.6rem, 3.5vw, 2.4rem);
|
||||
color: var(--brand-warm-white);
|
||||
line-height: 1.2;
|
||||
margin: 0 0 0.85rem;
|
||||
}
|
||||
|
||||
.episode-detail-summary {
|
||||
font-family: var(--brand-font-body);
|
||||
color: var(--brand-muted);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.65;
|
||||
margin: 0 0 2rem;
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.episode-detail-embed {
|
||||
margin: 1.5rem 0 2rem;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.episode-detail-listen-btn {
|
||||
display: inline-block;
|
||||
margin: 0 0 2.5rem;
|
||||
}
|
||||
|
||||
.episode-detail-show-notes,
|
||||
.episode-detail-questions {
|
||||
margin-top: 2.5rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.14);
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.episode-detail-section-heading {
|
||||
font-family: var(--brand-font-heading);
|
||||
font-size: 1.25rem;
|
||||
color: var(--brand-gold);
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.episode-detail-show-notes p {
|
||||
font-family: var(--brand-font-body);
|
||||
color: var(--brand-muted);
|
||||
line-height: 1.75;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.episode-detail-questions-list {
|
||||
padding-left: 1.4rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.episode-detail-questions-list li {
|
||||
font-family: var(--brand-font-body);
|
||||
color: var(--brand-warm-white);
|
||||
line-height: 1.7;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Contact ── */
|
||||
@@ -734,28 +1020,123 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.contact-copy .section-heading {
|
||||
text-align: left;
|
||||
margin-bottom: 1rem;
|
||||
.contact-card {
|
||||
background: #111111;
|
||||
border: 1px solid rgba(201, 168, 76, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 2.3rem;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.contact-copy p {
|
||||
.contact-card .eyebrow {
|
||||
margin: 0 0 0.45rem;
|
||||
}
|
||||
|
||||
.contact-card h2 {
|
||||
margin: 0 0 1.75rem;
|
||||
font-family: var(--brand-font-heading);
|
||||
color: var(--brand-warm-white);
|
||||
font-size: clamp(1.45rem, 2vw, 1.9rem);
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contact-profile {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.contact-profile-photo {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
object-position: center 22%;
|
||||
border: 2px solid var(--brand-gold);
|
||||
flex-shrink: 0;
|
||||
box-shadow:
|
||||
0 0 0 4px rgba(201, 168, 76, 0.16),
|
||||
0 10px 28px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.contact-profile-name {
|
||||
margin: 0 0 0.15rem;
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 300;
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.7;
|
||||
color: var(--brand-muted);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.contact-copy .contact-highlight {
|
||||
font-size: clamp(1.25rem, 2.2vw, 1.65rem);
|
||||
line-height: 1.35;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 600;
|
||||
color: var(--brand-warm-white);
|
||||
margin: 0 0 0.6rem;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.contact-profile-role {
|
||||
margin: 0;
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.95rem;
|
||||
color: var(--brand-gold);
|
||||
}
|
||||
|
||||
.contact-quote {
|
||||
border-left: 2px solid var(--brand-gold);
|
||||
padding-left: 1rem;
|
||||
margin: 0 0 1.75rem;
|
||||
}
|
||||
|
||||
.contact-quote p {
|
||||
margin: 0;
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 1.35rem;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
color: #d4c4a0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.contact-intro {
|
||||
margin: 0 0 1rem;
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
color: #9a9994;
|
||||
}
|
||||
|
||||
.contact-points {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.contact-point {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
color: #8f8e89;
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.contact-point svg {
|
||||
color: var(--brand-gold);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.contact-scripture {
|
||||
margin-top: 1.75rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #2c2116;
|
||||
}
|
||||
|
||||
.contact-scripture p {
|
||||
margin: 0;
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.88rem;
|
||||
font-style: italic;
|
||||
line-height: 1.6;
|
||||
color: #6d6b66;
|
||||
}
|
||||
|
||||
.contact-scripture-ref {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contact-form {
|
||||
@@ -783,8 +1164,8 @@
|
||||
.contact-form input,
|
||||
.contact-form textarea,
|
||||
.contact-form select {
|
||||
background: #0c0c0c;
|
||||
border: 1px solid rgba(201, 168, 76, 0.25);
|
||||
background: #090909;
|
||||
border: 1px solid rgba(201, 168, 76, 0.38);
|
||||
border-radius: 8px;
|
||||
color: var(--brand-warm-white);
|
||||
font-family: var(--brand-font-body);
|
||||
@@ -792,12 +1173,34 @@
|
||||
font-weight: 300;
|
||||
padding: 0.7rem 0.85rem;
|
||||
outline: none;
|
||||
transition: border-color 200ms;
|
||||
transition: border-color 200ms, box-shadow 200ms, background-color 200ms;
|
||||
}
|
||||
|
||||
.contact-form input:focus,
|
||||
.contact-form textarea:focus {
|
||||
border-color: rgba(201, 168, 76, 0.7);
|
||||
.contact-form textarea:focus,
|
||||
.contact-form select:focus {
|
||||
border-color: rgba(201, 168, 76, 0.85);
|
||||
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.16);
|
||||
}
|
||||
|
||||
.contact-form input::placeholder,
|
||||
.contact-form textarea::placeholder {
|
||||
color: rgba(214, 193, 140, 0.6);
|
||||
}
|
||||
|
||||
.contact-form select {
|
||||
appearance: none;
|
||||
background-image: linear-gradient(45deg, transparent 50%, rgba(201, 168, 76, 0.9) 50%),
|
||||
linear-gradient(135deg, rgba(201, 168, 76, 0.9) 50%, transparent 50%);
|
||||
background-position: calc(100% - 16px) calc(50% - 2px), calc(100% - 11px) calc(50% - 2px);
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.contact-form select option {
|
||||
background: #0b0b0b;
|
||||
color: var(--brand-warm-white);
|
||||
}
|
||||
|
||||
.contact-form textarea {
|
||||
@@ -1111,19 +1514,22 @@
|
||||
|
||||
.guide-inner {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
grid-template-columns: 160px 1fr;
|
||||
gap: 3rem;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.guide-icon {
|
||||
width: 100px;
|
||||
.guide-cover-art {
|
||||
flex-shrink: 0;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.guide-icon svg {
|
||||
.guide-cover-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.guide-text h2 {
|
||||
@@ -1144,6 +1550,84 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.guide-actions {
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.study-download-form {
|
||||
margin-top: 0.8rem;
|
||||
background: #121212;
|
||||
border: 1px solid rgba(201, 168, 76, 0.22);
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.study-download-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.study-download-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 500;
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-gold);
|
||||
}
|
||||
|
||||
.study-download-form input {
|
||||
background: #090909;
|
||||
border: 1px solid rgba(201, 168, 76, 0.38);
|
||||
border-radius: 8px;
|
||||
color: var(--brand-warm-white);
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 1rem;
|
||||
font-weight: 300;
|
||||
padding: 0.7rem 0.85rem;
|
||||
outline: none;
|
||||
transition: border-color 200ms, box-shadow 200ms, background-color 200ms;
|
||||
}
|
||||
|
||||
.study-download-form input:focus {
|
||||
border-color: rgba(201, 168, 76, 0.85);
|
||||
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.16);
|
||||
}
|
||||
|
||||
.study-download-form input::placeholder {
|
||||
color: rgba(214, 193, 140, 0.6);
|
||||
}
|
||||
|
||||
.contact-form input:-webkit-autofill,
|
||||
.contact-form textarea:-webkit-autofill,
|
||||
.contact-form select:-webkit-autofill,
|
||||
.study-download-form input:-webkit-autofill {
|
||||
-webkit-text-fill-color: var(--brand-warm-white);
|
||||
-webkit-box-shadow: 0 0 0 1000px #0a0a0a inset;
|
||||
box-shadow: 0 0 0 1000px #0a0a0a inset;
|
||||
transition: background-color 9999s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.study-download-form .btn-primary {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.study-download-success {
|
||||
font-family: var(--brand-font-body);
|
||||
font-size: 0.95rem;
|
||||
color: #97cd85;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Admin page ── */
|
||||
.admin-page {
|
||||
min-height: 100vh;
|
||||
@@ -2227,8 +2711,13 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.guide-icon {
|
||||
.guide-cover-art {
|
||||
margin: 0 auto;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.study-download-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-content-summary {
|
||||
@@ -2239,20 +2728,13 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.qr-inner {
|
||||
.home-jump-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-text .btn-secondary {
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.contact-inner {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.chatbot-feature-inner {
|
||||
@@ -2268,8 +2750,13 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.contact-copy .section-heading {
|
||||
text-align: center;
|
||||
.contact-card {
|
||||
max-width: 100%;
|
||||
padding: 1.45rem;
|
||||
}
|
||||
|
||||
.contact-quote p {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.header-ornament {
|
||||
@@ -2302,7 +2789,7 @@
|
||||
.section-about,
|
||||
.section-series,
|
||||
.section-guide,
|
||||
.section-qr,
|
||||
.section-home-jump,
|
||||
.section-chatbot-feature,
|
||||
.section-contact {
|
||||
padding: 3.5rem 0;
|
||||
@@ -2314,14 +2801,43 @@
|
||||
|
||||
.header-inner {
|
||||
padding: 0.75rem 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-menu-btn {
|
||||
display: inline-flex;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
gap: 1rem;
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 120;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
background: rgba(10, 10, 8, 0.98);
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.2);
|
||||
}
|
||||
|
||||
.header-nav a:not(.header-cta) {
|
||||
display: none;
|
||||
.header-nav--open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.header-nav .header-nav-link {
|
||||
display: block;
|
||||
padding: 0.8rem 0.25rem;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.12);
|
||||
}
|
||||
|
||||
.header-nav .header-cta {
|
||||
margin-top: 0.75rem;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-top-tabs .admin-tab {
|
||||
|
||||
+904
-471
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1,9 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
import { cloudflare } from "@cloudflare/vite-plugin";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), cloudflare()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:4173',
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "siteforge",
|
||||
"compatibility_date": "2026-05-04",
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"assets": {
|
||||
"not_found_handling": "single-page-application"
|
||||
},
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user