Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74c21f956e |
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "siteforge",
|
||||
"runtimeExecutable": "node",
|
||||
"runtimeArgs": ["--env-file=.env", "server.js"],
|
||||
"port": 4173,
|
||||
"autoPort": false
|
||||
},
|
||||
{
|
||||
"name": "siteforge-vite",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173,
|
||||
"autoPort": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
|
||||
@@ -21,8 +21,3 @@ RESEND_SEGMENT_ID=
|
||||
# During testing you can leave this as-is (uses Resend's shared domain).
|
||||
# For production: verify your own domain at resend.com/domains and change this.
|
||||
RESEND_FROM=Verse by Verse with Nate <hello@versebyversewithnate.us>
|
||||
|
||||
# Shared secret for the Cloudflare Email Worker webhook.
|
||||
# Must match the WEBHOOK_SECRET variable set in the Cloudflare Worker settings.
|
||||
# Generate with: openssl rand -hex 32
|
||||
INBOUND_EMAIL_SECRET=
|
||||
|
||||
@@ -13,8 +13,6 @@ permissions:
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -26,28 +24,28 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
run: npm ci
|
||||
|
||||
- name: Run production build
|
||||
run: npm run build
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.necloud.us
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.necloud.us/nate/siteforge
|
||||
images: ghcr.io/nmemmert/siteforge
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha
|
||||
@@ -58,8 +56,5 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
COMMIT_SHA=${{ github.sha }}
|
||||
|
||||
@@ -21,12 +21,6 @@ data/backups/
|
||||
data/hit-stats.json
|
||||
data/visitor-stats.json
|
||||
data/contact-submissions.json
|
||||
data/totp-secret.json
|
||||
data/uploads-meta.json
|
||||
|
||||
# Local environment secrets — never commit
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Install prod deps on the native builder platform to avoid QEMU npm crashes
|
||||
# during cross-platform builds. All deps are pure-JS so this is safe.
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS deps
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev --legacy-peer-deps
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
@@ -11,16 +13,11 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=4173
|
||||
|
||||
ARG COMMIT_SHA=unknown
|
||||
ENV COMMIT_SHA=${COMMIT_SHA}
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
COPY package.json ./package.json
|
||||
COPY dist ./dist
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY server.js ./server.js
|
||||
COPY server/ ./server/
|
||||
COPY 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.
|
||||
|
||||
|
Before Width: | Height: | Size: 127 KiB |
@@ -1,259 +1,129 @@
|
||||
# Siteforge
|
||||
|
||||
A podcast and ministry website for **Verse by Verse with Nate** — a verse-by-verse Bible teaching podcast. Built with React + TypeScript (Vite) on the frontend and Express.js on the backend, deployed as a Docker container via GitHub Actions.
|
||||
This project is a React + TypeScript portfolio app built with Vite.
|
||||
|
||||
## Features
|
||||
It is designed to showcase two categories of work:
|
||||
|
||||
- **Podcast episodes** — episode listing, detail pages, downloadable resources, finished series archive
|
||||
- **Study system** — per-user enrollment in structured Bible studies with sections, notes, progress tracking, section quizzes, community posts/comments, and completion certificates
|
||||
- **Study accounts** — signup/login, email verification, avatar upload, email change with re-verification
|
||||
- **Two-factor authentication** — TOTP authenticator app + email OTP backup, recovery codes
|
||||
- **Contact form** — submissions stored server-side, admin reply with templates, inbound email webhook (Resend)
|
||||
- **Q&A** — public question submission, admin moderation, pinned answers, shareable question links
|
||||
- **Email integration** — welcome emails, admin notifications, admin replies, study reminder emails (via Resend)
|
||||
- **Admin panel** — content management with draft/publish workflow, episode scripts, podcast checklist, QR code management, subscriber management, asset uploads
|
||||
- **Analytics** — page views, audio plays, download tracking, geo-based visitor stats (country/state/city), consent-based cookie opt-in
|
||||
- **Backup system** — automatic JSON snapshots with restore, full `tar.gz` data export/import for server migration
|
||||
- **Version display** — app version and git commit shown in admin sidebar; `/api/version` endpoint
|
||||
- Websites
|
||||
- Apps
|
||||
|
||||
---
|
||||
The app now supports:
|
||||
|
||||
## Tech Stack
|
||||
- Dynamic cards generated from a project data source
|
||||
- Built vs Hosted filters
|
||||
- A dedicated About page route for each project
|
||||
- An admin editor route to update cards and About pages
|
||||
- A per-project README summary block on each project page
|
||||
- Embedded Spotify podcast player on the home section
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Frontend | React 19, TypeScript, Vite, React Router v7 |
|
||||
| Backend | Express 5, Node.js 22 |
|
||||
| Email | Resend |
|
||||
| TOTP | otplib |
|
||||
| QR Codes | qrcode |
|
||||
| Rich text | TipTap |
|
||||
| Charts | Chart.js + react-chartjs-2, react-simple-maps |
|
||||
| Documents | docx, mammoth |
|
||||
| Container | Docker (GHCR), GitHub Actions CI |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
siteforge/
|
||||
├── src/ # React frontend
|
||||
│ ├── App.tsx # Root + all client routes
|
||||
│ ├── App.css
|
||||
│ └── ... # Page and component files
|
||||
├── server/ # Express backend
|
||||
│ ├── config.js # Constants, env vars, APP_VERSION, GIT_COMMIT
|
||||
│ ├── data.js # In-memory state + JSON persistence
|
||||
│ ├── helpers.js # Shared utilities (IP, rate limiting, etc.)
|
||||
│ ├── study-helpers.js # Study email scheduling, cert generation
|
||||
│ └── routes/ # Route handlers (one file per domain)
|
||||
├── data/ # Seed data (JSON); live data at /app/data in container
|
||||
├── server.js # Entry point — mounts all routers
|
||||
├── Dockerfile
|
||||
├── entrypoint.sh # Seeds /app/data on first run, never overwrites live data
|
||||
├── docker-compose.yml
|
||||
└── .github/workflows/docker-publish.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `SESSION_SECRET` | Yes | Secret for Express session signing |
|
||||
| `ADMIN_PASSWORD_HASH` | Yes | bcrypt hash of the admin password |
|
||||
| `RESEND_API_KEY` | Yes | Resend API key for sending email |
|
||||
| `RESEND_FROM` | Yes | Sender address (e.g. `Nate <nate@example.com>`) |
|
||||
| `RESEND_TO` | Yes | Address that receives contact form notifications |
|
||||
| `RESEND_REPLY_TO` | No | Reply-to address on outbound emails |
|
||||
| `RESEND_WEBHOOK_TOKEN` | No | Validates inbound Resend webhook requests |
|
||||
| `INBOUND_EMAIL_SECRET` | No | Validates inbound email route requests |
|
||||
| `RESEND_CONTACTS_API_KEY` | No | Resend Contacts API key for subscriber sync |
|
||||
| `RESEND_SEGMENT_ID` | No | Resend segment ID for subscriber list |
|
||||
| `COMMIT_SHA` | No | Git SHA baked in at build time by CI (shown in admin UI) |
|
||||
| `TRUST_PROXY_HOPS` | No | Number of proxy hops to trust (default `1`) — set correctly for accurate client IPs behind a reverse proxy |
|
||||
| `ALLOW_INSECURE_COOKIES` | No | Set to `true` to allow session cookies over HTTP (for local/non-HTTPS access) |
|
||||
| `TITUS_STUDY_FILE` | No | Path to the Titus study PDF (defaults to bundled file) |
|
||||
| `TITUS_STUDY_DOWNLOAD_NAME` | No | Filename shown to users downloading the Titus PDF |
|
||||
| `PORT` | No | HTTP port (default `4173`) |
|
||||
| `CACHE_PURGE_WEBHOOK_URL` | No | Webhook URL for one-click cache purge from Admin Operations |
|
||||
| `DEPLOY_WEBHOOK_URL` | No | Webhook URL for one-click deploy trigger from Admin Operations |
|
||||
|
||||
---
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
| Path | Page |
|
||||
|---|---|
|
||||
| `/` | Landing page |
|
||||
| `/start-here` | Start here / intro page |
|
||||
| `/episodes` | Episode listing |
|
||||
| `/episodes/:id` | Episode detail |
|
||||
| `/finished` | Finished series listing |
|
||||
| `/finished/:id` | Finished series detail |
|
||||
| `/resources` | Resources listing |
|
||||
| `/downloads/:id` | Download detail |
|
||||
| `/about` | About page |
|
||||
| `/contact` | Contact form |
|
||||
| `/questions` | Q&A listing |
|
||||
| `/subscribe` | Email subscribe |
|
||||
| `/subscribe/thanks` | Subscribe confirmation |
|
||||
| `/study` | Study landing page |
|
||||
| `/study/signup` | Study account signup |
|
||||
| `/study/account` | Study account dashboard |
|
||||
| `/study/:studySlug` | Study index |
|
||||
| `/study/:studySlug/community` | Study community posts |
|
||||
| `/study/:studySlug/notes` | Study notes |
|
||||
| `/study/:studySlug/:sectionId` | Study section |
|
||||
| `/study/:studySlug/:sectionId/quiz` | Section quiz |
|
||||
| `/certificate/:token` | Public completion certificate |
|
||||
| `/privacy` | Privacy policy |
|
||||
| `/thanks` | Contact thank-you |
|
||||
| `/admin` | Admin panel (requires password + TOTP) |
|
||||
| `/preview` | Content draft preview |
|
||||
|
||||
---
|
||||
|
||||
## Key API Endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
|---|---|
|
||||
| `GET /api/version` | Returns `{ version, commit }` |
|
||||
| `GET /api/content` | Public site content (CMS data) |
|
||||
| `POST /api/contact` | Submit contact form |
|
||||
| `POST /api/analytics/pageview` | Record page view |
|
||||
| `POST /api/analytics/play` | Record audio play |
|
||||
| `GET /api/episode-audio` | Episode audio proxy |
|
||||
| `GET /api/admin-auth/status` | Admin session status + version info |
|
||||
| `POST /api/admin-auth/login` | Admin login |
|
||||
| `POST /api/admin-auth/totp-verify` | Complete TOTP 2FA |
|
||||
| `POST /api/admin-auth/totp-setup-init` | Begin TOTP enrollment |
|
||||
| `POST /api/admin-auth/totp-setup-confirm` | Confirm TOTP enrollment |
|
||||
| `GET /api/admin-stats` | Hit/visitor analytics |
|
||||
| `GET /api/admin-stats/backup` | Trigger stats snapshot |
|
||||
| `GET /api/admin-backup/export` | Download full `tar.gz` data backup |
|
||||
| `POST /api/admin-backup/import` | Restore from `tar.gz` backup |
|
||||
| `GET /api/admin/study-users` | List study accounts |
|
||||
| `GET /api/admin-study-certificates` | Study completion certificates |
|
||||
| `GET /api/admin-contact-submissions` | Contact form submissions |
|
||||
| `POST /api/admin-contact-submissions/:id/reply` | Reply to a submission |
|
||||
| `GET /api/admin/qr-codes` | QR code management |
|
||||
| `GET /api/admin-episode-scripts/:episodeNumber` | Episode script |
|
||||
| `GET /api/admin-podcast-checklist` | Podcast checklist state |
|
||||
| `GET /api/admin-subscribers` | Subscriber list |
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start frontend dev server only (Vite, http://localhost:5173)
|
||||
npm run dev
|
||||
|
||||
# Start backend API server only (reads .env, http://localhost:4173)
|
||||
npm run api
|
||||
|
||||
# Start both concurrently
|
||||
npm run dev:full
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
|
||||
# Start production server (reads .env)
|
||||
npm start
|
||||
```
|
||||
|
||||
Create a `.env` file at the project root with the required environment variables before running locally. The server uses `--env-file=.env`.
|
||||
`dev:full` starts both:
|
||||
|
||||
---
|
||||
- Vite frontend (`http://localhost:5173`)
|
||||
- Admin content API (`http://localhost:4173`)
|
||||
|
||||
## Docker / Deployment
|
||||
|
||||
Images are built and pushed to `ghcr.io/nmemmert/siteforge` automatically on every push to `main` or when a `v*` tag is pushed. The git commit SHA is baked into the image as `COMMIT_SHA` at build time and displayed in the admin sidebar.
|
||||
|
||||
### First run
|
||||
|
||||
On first boot, `entrypoint.sh` copies seed data from the bundled `data-seed/` into `/app/data/` if that directory is empty. Subsequent restarts skip this step, so live data is never overwritten by an upgrade.
|
||||
|
||||
### docker-compose.yml
|
||||
|
||||
A `docker-compose.yml` is included at the project root. Mount `/app/data` to a persistent volume or host path and supply all required environment variables.
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /path/to/your/data:/app/data
|
||||
```
|
||||
|
||||
### Useful commands
|
||||
## Build for production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Run in container
|
||||
|
||||
Pull image directly:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/nmemmert/siteforge:latest
|
||||
docker run -d --name siteforge-app -p 4173:4173 -v ./data:/app/data ghcr.io/nmemmert/siteforge:latest
|
||||
```
|
||||
|
||||
Run with Docker Compose:
|
||||
|
||||
```bash
|
||||
# Pull latest image and restart
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
# View logs
|
||||
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.
|
||||
|
||||
The app will be available at `http://localhost:4173`.
|
||||
|
||||
Persistent admin saves:
|
||||
|
||||
- Admin updates are written to `data/admin-content.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/`.
|
||||
- `docker-compose.yml` mounts `./data` into the container at `/app/data`.
|
||||
- This keeps all admin-managed data after container restarts/rebuilds/updates.
|
||||
|
||||
## Where to edit content
|
||||
|
||||
- Project domain data and ownership: `src/data/projects.ts`
|
||||
- Portfolio rendering logic: `src/App.tsx`
|
||||
- Portfolio UI styles: `src/App.css`
|
||||
- Global theme variables: `src/index.css`
|
||||
|
||||
## Routes
|
||||
|
||||
- Portfolio home: `/`
|
||||
- Project details: `/projects/:slug`
|
||||
- Admin editor: `/admin`
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- Analytics cookies are consent-based. Visitors can accept or decline tracking from the site banner.
|
||||
- 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.
|
||||
|
||||
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`.
|
||||
|
||||
Useful container commands:
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
|
||||
# Stop
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
Update to latest image:
|
||||
|
||||
## Persistent Data
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
All server-managed data lives under `/app/data` (or `./data` locally):
|
||||
## Notes
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `admin-content.json` | Published CMS content |
|
||||
| `admin-content-draft.json` | Draft content (unpublished) |
|
||||
| `hit-stats.json` | Page hit analytics |
|
||||
| `visitor-stats.json` | Detailed visitor analytics |
|
||||
| `contact-submissions.json` | Contact form submissions |
|
||||
| `study-users.json` | Study account records |
|
||||
| `study-reminders.json` | Scheduled study reminder state |
|
||||
| `qr-codes.json` | QR code registry |
|
||||
| `episode-scripts/` | Per-episode script files |
|
||||
| `backups/` | Automatic JSON snapshot backups |
|
||||
| `uploads/` | Admin-uploaded assets (served at `/uploads/*`) |
|
||||
|
||||
---
|
||||
|
||||
## Admin Access
|
||||
|
||||
1. Navigate to `/admin`
|
||||
2. Enter the admin password (hashed value in `ADMIN_PASSWORD_HASH`)
|
||||
3. Complete TOTP 2FA, or use an email OTP if no TOTP app is enrolled
|
||||
4. On first use, enroll a TOTP authenticator app from Admin → Settings
|
||||
|
||||
Recovery codes are generated during TOTP setup and can be regenerated from Admin → Settings.
|
||||
|
||||
### Admin capabilities
|
||||
|
||||
- **Content** — edit and publish site content with draft/preview workflow
|
||||
- **Episode scripts** — write and manage per-episode scripts; export to `.docx`
|
||||
- **Podcast checklist** — track per-episode production steps
|
||||
- **QR codes** — create, manage, and track scan counts for QR codes
|
||||
- **Questions** — moderate submitted Q&A, pin answers, reply
|
||||
- **Contact** — view submissions, reply with templates, check email health
|
||||
- **Study users** — view enrollments, progress, certificates, manually enroll/unenroll
|
||||
- **Study comments** — moderate community post comments
|
||||
- **Subscribers** — view and export subscriber list
|
||||
- **Assets** — upload and delete hosted images
|
||||
- **Stats** — page hits, visitor geo analytics, download stats; export CSV
|
||||
- **Backup** — snapshot now, restore from snapshot, export/import full `tar.gz`
|
||||
- **Operations** — purge cache, trigger deploy, manage SEO/sitemap/redirects
|
||||
|
||||
---
|
||||
|
||||
## Versioning
|
||||
|
||||
The app version is read from `package.json` at startup (`APP_VERSION`). The git commit is baked in via `COMMIT_SHA` at Docker build time (set automatically by CI). Both are visible in the admin sidebar and at `GET /api/version`.
|
||||
|
||||
Bump `package.json` before every commit pushed to `main`: patch for fixes, minor for features, major for breaking changes.
|
||||
- The current project cards are sample placeholders.
|
||||
- Most metadata was scanned from live pages.
|
||||
- Skywatch currently uses a manual fallback description due to a `401` response during automated scan.
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* Cloudflare Email Worker — versebyversewithnate.us
|
||||
*
|
||||
* Receives inbound emails to hello@ and nate@, forwards to Gmail,
|
||||
* and POSTs a parsed copy to the site admin inbox.
|
||||
*
|
||||
* CLOUDFLARE SETUP:
|
||||
* 1. Go to Email > Email Routing > Routes in your Cloudflare dashboard
|
||||
* 2. Add two "Custom address" rules:
|
||||
* hello@versebyversewithnate.us → Send to Worker → this worker
|
||||
* nate@versebyversewithnate.us → Send to Worker → this worker
|
||||
* 3. Make sure nmemmert@gmail.com is listed as a verified destination address
|
||||
* (Email Routing > Destination addresses)
|
||||
*
|
||||
* WORKER ENVIRONMENT VARIABLES (Workers & Pages > this worker > Settings > Variables):
|
||||
* GMAIL_FORWARD_ADDRESS = nmemmert@gmail.com
|
||||
* WEBHOOK_URL = https://versebyversewithnate.us/api/inbound-email
|
||||
* WEBHOOK_SECRET = <a long random string — must match INBOUND_EMAIL_SECRET on the server>
|
||||
*
|
||||
* SERVER ENVIRONMENT VARIABLE (set in your server host / .env):
|
||||
* INBOUND_EMAIL_SECRET = <same long random string as WEBHOOK_SECRET above>
|
||||
*/
|
||||
|
||||
export default {
|
||||
async email(message, env, ctx) {
|
||||
const forwardPromise = message.forward(env.GMAIL_FORWARD_ADDRESS)
|
||||
|
||||
const rawEmail = await streamToText(message.raw)
|
||||
|
||||
const parsed = parseEmail(rawEmail, message)
|
||||
|
||||
const webhookPromise = fetch(env.WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Secret': env.WEBHOOK_SECRET,
|
||||
},
|
||||
body: JSON.stringify(parsed),
|
||||
}).catch(err => console.error('[email-worker] webhook failed:', err.message))
|
||||
|
||||
await Promise.all([forwardPromise, webhookPromise])
|
||||
},
|
||||
}
|
||||
|
||||
async function streamToText(stream) {
|
||||
const reader = stream.getReader()
|
||||
const chunks = []
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
}
|
||||
const bytes = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0))
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
function parseEmail(raw, message) {
|
||||
const { headers, body } = splitHeadersAndBody(raw)
|
||||
|
||||
const subject = decodeHeaderValue(headers['subject'] ?? '(no subject)')
|
||||
const from = message.from ?? headers['from'] ?? ''
|
||||
const to = message.to ?? headers['to'] ?? ''
|
||||
const date = headers['date'] ?? new Date().toISOString()
|
||||
const messageId = headers['message-id'] ?? ''
|
||||
|
||||
const { plainText, htmlBody } = extractBodies(headers['content-type'] ?? '', body)
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
body: plainText,
|
||||
htmlBody: htmlBody || null,
|
||||
date,
|
||||
messageId,
|
||||
source: 'inbound-email',
|
||||
}
|
||||
}
|
||||
|
||||
// Splits an RFC 5322 message (or MIME part) into its unfolded header map and raw body string.
|
||||
function splitHeadersAndBody(raw) {
|
||||
const match = /\r?\n\r?\n/.exec(raw)
|
||||
const headerBlock = match ? raw.slice(0, match.index) : raw
|
||||
const body = match ? raw.slice(match.index + match[0].length) : ''
|
||||
// RFC 2822 header folding: a line starting with SP/TAB continues the previous header line.
|
||||
const unfolded = headerBlock.replace(/\r?\n[ \t]+/g, ' ')
|
||||
const headers = {}
|
||||
for (const line of unfolded.split(/\r?\n/)) {
|
||||
const colon = line.indexOf(':')
|
||||
if (colon <= 0) continue
|
||||
const key = line.slice(0, colon).trim().toLowerCase()
|
||||
const val = line.slice(colon + 1).trim()
|
||||
if (!headers[key]) headers[key] = val
|
||||
}
|
||||
return { headers, body }
|
||||
}
|
||||
|
||||
// Recursively walks a (possibly multipart) MIME body and returns the first text/plain and text/html parts found.
|
||||
function extractBodies(topContentType, topBody) {
|
||||
let plainText = null
|
||||
let htmlBody = null
|
||||
|
||||
function visit(contentType, body, transferEncoding) {
|
||||
const type = (contentType.split(';')[0] || 'text/plain').trim().toLowerCase()
|
||||
|
||||
if (type.startsWith('multipart/')) {
|
||||
const boundary = getBoundary(contentType)
|
||||
if (!boundary) return
|
||||
for (const part of splitOnBoundary(body, boundary)) {
|
||||
const { headers, body: partBody } = splitHeadersAndBody(part)
|
||||
visit(headers['content-type'] ?? 'text/plain', partBody, headers['content-transfer-encoding'] ?? '')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const decoded = decodeBody(body, transferEncoding)
|
||||
if (type === 'text/html' && htmlBody === null) htmlBody = decoded.trim()
|
||||
if (type === 'text/plain' && plainText === null) plainText = decoded.trim()
|
||||
}
|
||||
|
||||
visit(topContentType || 'text/plain', topBody, '')
|
||||
|
||||
if (plainText === null && htmlBody !== null) {
|
||||
plainText = htmlBody.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s{3,}/g, '\n\n').trim()
|
||||
}
|
||||
if (plainText === null) plainText = ''
|
||||
|
||||
return { plainText, htmlBody }
|
||||
}
|
||||
|
||||
function getBoundary(contentType) {
|
||||
const match = /boundary\s*=\s*"([^"]+)"|boundary\s*=\s*([^;\s]+)/i.exec(contentType)
|
||||
if (!match) return null
|
||||
return match[1] ?? match[2]
|
||||
}
|
||||
|
||||
// Splits a multipart body on its boundary markers, ignoring the preamble/epilogue.
|
||||
function splitOnBoundary(body, boundary) {
|
||||
const escaped = boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const re = new RegExp(`(?:^|\\r?\\n)--${escaped}(--)?(?:\\r?\\n|$)`, 'g')
|
||||
const parts = []
|
||||
let lastIndex = 0
|
||||
let started = false
|
||||
let match
|
||||
while ((match = re.exec(body)) !== null) {
|
||||
if (started) parts.push(body.slice(lastIndex, match.index))
|
||||
started = true
|
||||
lastIndex = match.index + match[0].length
|
||||
if (match[1]) break // final boundary: "--boundary--"
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
function decodeBody(text, transferEncoding) {
|
||||
const enc = (transferEncoding || '').trim().toLowerCase()
|
||||
if (enc === 'base64') {
|
||||
try {
|
||||
const binary = atob(text.replace(/\s+/g, ''))
|
||||
return new TextDecoder('utf-8').decode(Uint8Array.from(binary, c => c.charCodeAt(0)))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
if (enc === 'quoted-printable') {
|
||||
return decodeEmailBody(text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function decodeEmailBody(text) {
|
||||
// Handle quoted-printable encoding (=XX hex sequences and soft line breaks).
|
||||
// Decode into raw bytes first, then run through TextDecoder so multi-byte
|
||||
// UTF-8 sequences split across multiple =XX escapes recombine correctly.
|
||||
const unfolded = text.replace(/=\r?\n/g, '')
|
||||
const bytes = []
|
||||
for (let i = 0; i < unfolded.length; i++) {
|
||||
if (unfolded[i] === '=' && /^[0-9A-Fa-f]{2}$/.test(unfolded.slice(i + 1, i + 3))) {
|
||||
bytes.push(parseInt(unfolded.slice(i + 1, i + 3), 16))
|
||||
i += 2
|
||||
} else {
|
||||
bytes.push(unfolded.charCodeAt(i))
|
||||
}
|
||||
}
|
||||
return new TextDecoder('utf-8').decode(Uint8Array.from(bytes))
|
||||
}
|
||||
|
||||
function decodeHeaderValue(value) {
|
||||
// Handle encoded words: =?UTF-8?B?...?= or =?UTF-8?Q?...?=
|
||||
return value.replace(/=\?([^?]+)\?([BQ])\?([^?]*)\?=/gi, (_, charset, encoding, encoded) => {
|
||||
try {
|
||||
if (encoding.toUpperCase() === 'B') {
|
||||
const binary = atob(encoded)
|
||||
return new TextDecoder(charset).decode(Uint8Array.from(binary, c => c.charCodeAt(0)))
|
||||
}
|
||||
if (encoding.toUpperCase() === 'Q') {
|
||||
return encoded.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||
}
|
||||
} catch {
|
||||
return encoded
|
||||
}
|
||||
return encoded
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Verse by Verse Companion App
|
||||
|
||||
This is a standalone companion app for your podcast.
|
||||
|
||||
It is intentionally separate from the main Siteforge app and has its own dependencies, scripts, and Vite config.
|
||||
|
||||
Episodes are loaded at runtime from your RSS feed:
|
||||
|
||||
- https://anchor.fm/s/11068d290/podcast/rss
|
||||
|
||||
This means new podcast episodes appear in the app without rebuilding the frontend.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
cd companion-app
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The app runs on `http://localhost:5181`.
|
||||
|
||||
`npm run dev` starts:
|
||||
|
||||
- Vite web app on `http://localhost:5181`
|
||||
- RSS API on `http://localhost:4174`
|
||||
|
||||
## Production build
|
||||
|
||||
```bash
|
||||
cd companion-app
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
`npm run start` serves the built app and RSS API from one Node process.
|
||||
|
||||
## Runtime API
|
||||
|
||||
- Endpoint: `/api/episodes`
|
||||
- Source feed: Anchor RSS URL above
|
||||
- Fallback: If RSS is unavailable, the app uses local fallback episodes from `src/data/podcastData.ts`.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Verse by Verse Companion</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "siteforge-companion-app",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:dev:api\" \"npm:dev:web\"",
|
||||
"dev:web": "vite",
|
||||
"dev:api": "NODE_ENV=development PORT=4174 node server.mjs",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "NODE_ENV=production node server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"fast-xml-parser": "^5.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import express from 'express';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
const RSS_URL = 'https://anchor.fm/s/11068d290/podcast/rss';
|
||||
const PORT = Number(process.env.PORT || 5181);
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
trimValues: true
|
||||
});
|
||||
|
||||
function toArray(value) {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function cleanText(input) {
|
||||
if (!input) return '';
|
||||
return String(input)
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeItem(item, index) {
|
||||
const title = cleanText(item.title) || `Episode ${index + 1}`;
|
||||
const summary = cleanText(item['content:encoded'] || item.description) || 'New episode now available.';
|
||||
const link = item.link || item.guid || RSS_URL;
|
||||
|
||||
const date = item.pubDate ? new Date(item.pubDate) : null;
|
||||
const publishedAt = date && !Number.isNaN(date.getTime()) ? date.toISOString() : null;
|
||||
|
||||
return {
|
||||
id: cleanText(item.guid) || link,
|
||||
title,
|
||||
summary,
|
||||
scripture: '',
|
||||
reflectionPrompt: '',
|
||||
prayerFocus: '',
|
||||
challenge: '',
|
||||
link,
|
||||
publishedAt
|
||||
};
|
||||
}
|
||||
|
||||
async function readFeedEpisodes() {
|
||||
const response = await fetch(RSS_URL, {
|
||||
headers: {
|
||||
'User-Agent': 'Siteforge-Companion/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`RSS request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const parsed = parser.parse(xml);
|
||||
const items = toArray(parsed?.rss?.channel?.item);
|
||||
return items.map(normalizeItem).slice(0, 24);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
|
||||
app.get('/api/episodes', async (_req, res) => {
|
||||
try {
|
||||
const episodes = await readFeedEpisodes();
|
||||
res.json({
|
||||
source: 'rss',
|
||||
rssUrl: RSS_URL,
|
||||
updatedAt: new Date().toISOString(),
|
||||
episodes
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(502).json({
|
||||
source: 'rss',
|
||||
rssUrl: RSS_URL,
|
||||
error: error instanceof Error ? error.message : 'Unable to load RSS feed',
|
||||
episodes: []
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (isProd) {
|
||||
const distDir = path.resolve(__dirname, 'dist');
|
||||
app.use(express.static(distDir));
|
||||
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(distDir, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
const mode = isProd ? 'production' : 'development';
|
||||
console.log(`Companion API listening on http://localhost:${PORT} (${mode})`);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
.app-shell {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(140deg, rgba(47, 79, 47, 0.94), rgba(98, 121, 90, 0.92));
|
||||
border: 1px solid rgba(244, 240, 231, 0.2);
|
||||
border-radius: 24px;
|
||||
color: var(--cream);
|
||||
padding: 2rem;
|
||||
box-shadow: 0 16px 32px rgba(22, 32, 19, 0.24);
|
||||
animation: rise-in 500ms ease-out;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(2rem, 6vw, 3.3rem);
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.series-line {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
margin-top: 1.2rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hero-actions a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--sun);
|
||||
color: #2c1a00;
|
||||
padding: 0.7rem 1.1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero-actions .ghost {
|
||||
background: transparent;
|
||||
color: var(--cream);
|
||||
border: 1px solid rgba(244, 240, 231, 0.55);
|
||||
}
|
||||
|
||||
.section-grid {
|
||||
margin-top: 2.25rem;
|
||||
display: grid;
|
||||
gap: 1.15rem;
|
||||
}
|
||||
|
||||
.section-grid h2 {
|
||||
font-size: clamp(1.5rem, 4vw, 2.2rem);
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
margin-top: 0.6rem;
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.episode-grid,
|
||||
.resource-grid {
|
||||
display: grid;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.episode-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.resource-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.answer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.episode-card,
|
||||
.resource-card,
|
||||
.answer-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 18px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 6px 16px rgba(22, 32, 19, 0.08);
|
||||
}
|
||||
|
||||
.episode-card {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.episode-id {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--olive);
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid rgba(98, 121, 90, 0.45);
|
||||
color: var(--deep-green);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
}
|
||||
|
||||
.published {
|
||||
font-size: 0.84rem;
|
||||
color: var(--olive);
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.answer-card {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.resource-link {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(47, 79, 47, 0.4);
|
||||
color: var(--deep-green);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@keyframes rise-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero {
|
||||
padding: 1.3rem;
|
||||
}
|
||||
|
||||
.hero-actions a {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import EpisodeCard from './components/EpisodeCard';
|
||||
import ResourceCard from './components/ResourceCard';
|
||||
import { fallbackEpisodes, quickAnswers, resources, showContent, type Episode } from './data/podcastData';
|
||||
import './App.css';
|
||||
|
||||
type EpisodesApiResponse = {
|
||||
episodes?: Episode[];
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [episodes, setEpisodes] = useState<Episode[]>(fallbackEpisodes);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadEpisodes() {
|
||||
try {
|
||||
const response = await fetch('/api/episodes', { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as EpisodesApiResponse;
|
||||
if (isMounted && Array.isArray(data.episodes) && data.episodes.length > 0) {
|
||||
setEpisodes(data.episodes);
|
||||
}
|
||||
} catch {
|
||||
// Keep local fallback episodes when network call fails.
|
||||
}
|
||||
}
|
||||
|
||||
loadEpisodes();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="hero">
|
||||
<p className="kicker">{showContent.eyebrow}</p>
|
||||
<h1>{showContent.title}</h1>
|
||||
<p className="hero-copy">{showContent.hero}</p>
|
||||
<p className="series-line">
|
||||
<strong>{showContent.seriesLabel}:</strong> {showContent.seriesTitle}
|
||||
</p>
|
||||
<p className="hero-copy">{showContent.seriesDescription}</p>
|
||||
<div className="hero-actions">
|
||||
<a href={showContent.listenUrl} target="_blank" rel="noreferrer">
|
||||
Listen on Spotify
|
||||
</a>
|
||||
<a href={showContent.studyGuideUrl} target="_blank" rel="noreferrer" className="ghost">
|
||||
Get companion guide
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section className="section-grid">
|
||||
<div>
|
||||
<h2>Titus Companion Guides</h2>
|
||||
<p className="section-copy">Episode-aligned prompts to help you move from hearing to applying the Word this week.</p>
|
||||
</div>
|
||||
<div className="episode-grid">
|
||||
{episodes.map((episode) => (
|
||||
<EpisodeCard key={episode.id} episode={episode} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-grid answers">
|
||||
<div>
|
||||
<h2>Quick Answers</h2>
|
||||
<p className="section-copy">Helpful guidance pulled from your existing Verse by Verse teaching content.</p>
|
||||
</div>
|
||||
<div className="answer-grid">
|
||||
{quickAnswers.map((entry) => (
|
||||
<article key={entry.question} className="answer-card">
|
||||
<h3>{entry.question}</h3>
|
||||
<p>{entry.answer}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-grid resources">
|
||||
<div>
|
||||
<h2>Listen, Study, Share</h2>
|
||||
<p className="section-copy">Direct links to your show and companion resources so listeners can take the next step.</p>
|
||||
</div>
|
||||
<div className="resource-grid">
|
||||
{resources.map((resource) => (
|
||||
<ResourceCard key={resource.title} resource={resource} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Episode } from '../data/podcastData';
|
||||
|
||||
type EpisodeCardProps = {
|
||||
episode: Episode;
|
||||
};
|
||||
|
||||
export default function EpisodeCard({ episode }: EpisodeCardProps) {
|
||||
const publishedLabel = episode.publishedAt
|
||||
? new Date(episode.publishedAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<article className="episode-card">
|
||||
<p className="episode-id">{episode.id.toUpperCase()}</p>
|
||||
<h3>{episode.title}</h3>
|
||||
{episode.scripture ? <p className="pill">{episode.scripture}</p> : null}
|
||||
{publishedLabel ? <p className="published">Released {publishedLabel}</p> : null}
|
||||
<p>{episode.summary}</p>
|
||||
{episode.reflectionPrompt ? (
|
||||
<p>
|
||||
<strong>Reflect:</strong> {episode.reflectionPrompt}
|
||||
</p>
|
||||
) : null}
|
||||
{episode.prayerFocus ? (
|
||||
<p>
|
||||
<strong>Prayer:</strong> {episode.prayerFocus}
|
||||
</p>
|
||||
) : null}
|
||||
{episode.challenge ? (
|
||||
<p>
|
||||
<strong>Challenge:</strong> {episode.challenge}
|
||||
</p>
|
||||
) : null}
|
||||
{episode.link ? (
|
||||
<a className="resource-link" href={episode.link} target="_blank" rel="noreferrer">
|
||||
Listen to episode
|
||||
</a>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { CompanionResource } from '../data/podcastData';
|
||||
|
||||
type ResourceCardProps = {
|
||||
resource: CompanionResource;
|
||||
};
|
||||
|
||||
export default function ResourceCard({ resource }: ResourceCardProps) {
|
||||
return (
|
||||
<article className="resource-card">
|
||||
<h3>{resource.title}</h3>
|
||||
<p>{resource.description}</p>
|
||||
<a href={resource.href} className="resource-link" aria-label={resource.action} target="_blank" rel="noreferrer">
|
||||
{resource.action}
|
||||
</a>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
export type Episode = {
|
||||
id: string;
|
||||
title: string;
|
||||
scripture: string;
|
||||
summary: string;
|
||||
reflectionPrompt: string;
|
||||
prayerFocus: string;
|
||||
challenge: string;
|
||||
link?: string;
|
||||
publishedAt?: string | null;
|
||||
};
|
||||
|
||||
export type CompanionResource = {
|
||||
title: string;
|
||||
description: string;
|
||||
action: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type QuickAnswer = {
|
||||
question: string;
|
||||
answer: string;
|
||||
};
|
||||
|
||||
export const showContent = {
|
||||
eyebrow: 'A Journey Through Scripture',
|
||||
title: 'Verse by Verse Companion',
|
||||
hero:
|
||||
'Walk through Titus with grounded teaching, reflection prompts, and practical next steps you can apply this week.',
|
||||
seriesLabel: 'Now Studying',
|
||||
seriesTitle: 'Study of Titus: Sound Doctrine',
|
||||
seriesDescription:
|
||||
"A deep-dive into Paul's letter to Titus, unpacking what it means to build a church and a life on sound doctrine.",
|
||||
listenUrl: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl',
|
||||
studyGuideUrl: 'https://a.co/d/01sG2tOJ'
|
||||
};
|
||||
|
||||
export const fallbackEpisodes: Episode[] = [
|
||||
{
|
||||
id: 'titus-08',
|
||||
title: 'Episode 8 - Grace Is Not Just What Saves You - It Is What Trains You',
|
||||
scripture: 'Titus 2:11-12',
|
||||
summary:
|
||||
'Grace is not only the starting point of salvation. Grace also teaches us to renounce ungodliness and live self-controlled lives.',
|
||||
reflectionPrompt:
|
||||
'Where have you treated grace as permission instead of formation?',
|
||||
prayerFocus: 'Ask God for a teachable heart shaped by grace.',
|
||||
challenge: 'Write one habit you will surrender and one habit you will build this week.'
|
||||
},
|
||||
{
|
||||
id: 'titus-09',
|
||||
title: 'Episode 9 - Living Between Two Appearings',
|
||||
scripture: 'Titus 2:13-15',
|
||||
summary:
|
||||
'Believers live between Christs first and second appearing: rooted in finished grace while actively waiting in present obedience.',
|
||||
reflectionPrompt:
|
||||
'How would your priorities change if you lived this week with eternity in view?',
|
||||
prayerFocus: 'Steadiness, hope, and urgency in daily obedience.',
|
||||
challenge: 'Choose one daily moment to pray, Come Lord Jesus, and then act on one obedient next step.'
|
||||
},
|
||||
{
|
||||
id: 'titus-12',
|
||||
title: 'Episode 12 - The Gospel in One Paragraph',
|
||||
scripture: 'Titus 3:4-7',
|
||||
summary:
|
||||
'Paul compresses the gospel into a rich summary: Gods kindness appeared, mercy saved us, and the Spirit renews us for new life.',
|
||||
reflectionPrompt:
|
||||
'Which phrase in Titus 3:4-7 most confronts self-reliance in your walk with God?',
|
||||
prayerFocus: 'Gratitude for salvation by mercy, not by works.',
|
||||
challenge: 'Share your testimony this week using before, but God, and now language.'
|
||||
},
|
||||
{
|
||||
id: 'titus-13',
|
||||
title: 'Episode 13 - What Happens After the Gospel Takes Root',
|
||||
scripture: 'Titus 3:8-11',
|
||||
summary:
|
||||
'Sound doctrine grows visible fruit: believers devote themselves to good works and refuse divisive, unprofitable arguments.',
|
||||
reflectionPrompt:
|
||||
'Where are you spending energy on arguments instead of obedient good works?',
|
||||
prayerFocus: 'Discernment to pursue what is excellent and profitable for others.',
|
||||
challenge: 'Plan one concrete act of service this week and do it quietly.'
|
||||
},
|
||||
{
|
||||
id: 'titus-14',
|
||||
title: 'Episode 14 - Grace: Where It Starts and Where It Ends',
|
||||
scripture: 'Titus 3:12-15',
|
||||
summary:
|
||||
'Pauls closing words show that grace is not abstract theology. It is a practical culture of partnership, provision, and perseverance.',
|
||||
reflectionPrompt:
|
||||
'Who in your church or circle needs practical support from you right now?',
|
||||
prayerFocus: 'Faithfulness in ordinary, often unseen acts of care.',
|
||||
challenge: 'Reach out to one person this week with specific encouragement and practical help.'
|
||||
}
|
||||
];
|
||||
|
||||
export const resources: CompanionResource[] = [
|
||||
{
|
||||
title: 'Listen to the Titus Series',
|
||||
description: 'Stream the full Verse by Verse with Nate series currently focused on Titus.',
|
||||
action: 'Open Spotify',
|
||||
href: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl'
|
||||
},
|
||||
{
|
||||
title: 'Official Companion Study Guide',
|
||||
description: 'Go deeper in your study with the Verse by Verse companion guide on Amazon.',
|
||||
action: 'View study guide',
|
||||
href: 'https://a.co/d/01sG2tOJ'
|
||||
},
|
||||
{
|
||||
title: 'Share the Show',
|
||||
description: 'Invite one friend to listen this week and walk through Scripture together.',
|
||||
action: 'Copy show link',
|
||||
href: 'https://open.spotify.com/show/0Gq1TzoJOdReSZ1gYQi8Xl'
|
||||
}
|
||||
];
|
||||
|
||||
export const quickAnswers: QuickAnswer[] = [
|
||||
{
|
||||
question: 'What Bible translation does Nate teach from?',
|
||||
answer: 'The primary teaching text is the BSB (Berean Standard Bible), with occasional comparisons to other translations.'
|
||||
},
|
||||
{
|
||||
question: 'How should I approach a difficult passage?',
|
||||
answer: 'Start with context, study author and audience, use trusted tools, and pray for understanding before drawing conclusions.'
|
||||
},
|
||||
{
|
||||
question: 'How can I stay consistent with Bible reading?',
|
||||
answer: 'Keep it simple and daily, even 10 minutes. Use a reading plan and focus on consistency over perfection.'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,44 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;600;800&family=Fraunces:opsz,wght@9..144,500;9..144,700&display=swap');
|
||||
|
||||
:root {
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
color: #162013;
|
||||
background: #f4f0e7;
|
||||
--ink: #162013;
|
||||
--deep-green: #2f4f2f;
|
||||
--olive: #62795a;
|
||||
--cream: #f4f0e7;
|
||||
--sun: #d38a39;
|
||||
--card: #fffdf8;
|
||||
--edge: rgba(22, 32, 19, 0.15);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 12% 8%, rgba(211, 138, 57, 0.22), transparent 34%),
|
||||
radial-gradient(circle at 80% 12%, rgba(98, 121, 90, 0.22), transparent 36%),
|
||||
linear-gradient(180deg, #f7f2ea 0%, #ece4d8 100%);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-family: 'Fraunces', serif;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/components/episodecard.tsx","./src/components/resourcecard.tsx","./src/data/podcastdata.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5181,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4174',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "da3f5f52-f0a3-4a36-9f52-7938b82e899a",
|
||||
"submissionId": "0c2e3dc4-5ee3-42e9-a81b-172bedc16713",
|
||||
"toEmail": "hello@versebyversewithnate.us",
|
||||
"toName": "NoAdmin Signup",
|
||||
"fromEmail": "hello@versebyversewithnate.us",
|
||||
"subject": "Thanks for reaching out to Verse by Verse with Nate",
|
||||
"preview": "Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally. I just wanted to say thank you for your email.\n\n~Nate",
|
||||
"sentAt": "2026-05-07T12:54:06.683Z"
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-05-07T12:54:06.685Z"
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"scrollDepth": {
|
||||
"/episodes": {
|
||||
"25": 4,
|
||||
"50": 4,
|
||||
"75": 2,
|
||||
"90": 0
|
||||
}
|
||||
},
|
||||
"timeOnPage": {
|
||||
"/": {
|
||||
"totalSeconds": 51,
|
||||
"count": 4
|
||||
},
|
||||
"/episodes": {
|
||||
"totalSeconds": 318967,
|
||||
"count": 305
|
||||
},
|
||||
"/episodes/movn6tk3": {
|
||||
"totalSeconds": 445,
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"outboundClicks": {},
|
||||
"linkClicks": {},
|
||||
"utmSources": {},
|
||||
"searchQueries": {},
|
||||
"notFound": {},
|
||||
"audioEvents": {
|
||||
"Living Between Two Appearings": {
|
||||
"pauses": 0,
|
||||
"completions": 0,
|
||||
"totalListenSeconds": 43
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
[
|
||||
{
|
||||
"id": "cb_001",
|
||||
"type": "qa",
|
||||
"title": "What Bible translation do you use?",
|
||||
"content": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.",
|
||||
"keywords": [
|
||||
"bible",
|
||||
"translation",
|
||||
"bsb",
|
||||
"berean standard bible",
|
||||
"version",
|
||||
"podcast",
|
||||
"teaching"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_002",
|
||||
"type": "qa",
|
||||
"title": "How do you approach a difficult or confusing Bible passage?",
|
||||
"content": "I always start by reading the surrounding context — a confusing verse often makes perfect sense once you zoom out a chapter or two. Then I look at who the author was, who they were writing to, and what was happening historically. A good study Bible or a commentary like Matthew Henry's can be a huge help. Most importantly, pray for understanding before you dive in. The Holy Spirit is your best guide.",
|
||||
"keywords": [
|
||||
"difficult",
|
||||
"confusing",
|
||||
"passage",
|
||||
"context",
|
||||
"commentary",
|
||||
"study",
|
||||
"interpretation",
|
||||
"hard"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_003",
|
||||
"type": "qa",
|
||||
"title": "How do I stay consistent with daily Bible reading?",
|
||||
"content": "Start small and protect that time like an appointment. Even 10 minutes in the morning before checking your phone can be transformative over a year. A reading plan helps a lot — there are great ones on apps like YouVersion that break the whole Bible into daily chunks so you never have to decide what to read. And give yourself grace on the days you miss; guilt is not a good motivator. Just pick back up where you left off.",
|
||||
"keywords": [
|
||||
"daily",
|
||||
"reading",
|
||||
"consistent",
|
||||
"habit",
|
||||
"bible",
|
||||
"plan",
|
||||
"youversion",
|
||||
"devotional",
|
||||
"routine",
|
||||
"morning"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_005",
|
||||
"type": "topic",
|
||||
"title": "About the Verse by Verse with Nate Podcast",
|
||||
"content": "Verse by Verse with Nate is a podcast dedicated to walking through books of the Bible chapter by chapter and verse by verse. The goal is to help everyday listeners develop a deep, contextual understanding of Scripture — not just surface-level summaries, but the meaning behind the words. New episodes are released regularly. You can listen on Spotify, Apple Podcasts, and other major platforms.",
|
||||
"keywords": [
|
||||
"podcast",
|
||||
"about",
|
||||
"verse by verse",
|
||||
"nate",
|
||||
"listen",
|
||||
"show",
|
||||
"episodes",
|
||||
"what is"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_006",
|
||||
"type": "topic",
|
||||
"title": "How to listen to the podcast",
|
||||
"content": "You can listen to Verse by Verse with Nate on Spotify or Apple Podcasts. Just search 'Verse by Verse with Nate' in your favorite podcast app. New episodes drop regularly — subscribing will make sure you never miss one. If you'd like to leave a review, that helps more people find the show!",
|
||||
"keywords": [
|
||||
"listen",
|
||||
"subscribe",
|
||||
"spotify",
|
||||
"apple podcasts",
|
||||
"find",
|
||||
"where",
|
||||
"app",
|
||||
"review"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_007",
|
||||
"type": "qa",
|
||||
"title": "How do I submit a question to Nate?",
|
||||
"content": "You can submit a question using the contact form at the bottom of this page. Select 'Bible Question' as the message type. Nate reads every question personally and answers the best ones on the site's Q&A section or in a future episode. There's no guarantee every question gets a public response, but all are read and appreciated!",
|
||||
"keywords": [
|
||||
"submit",
|
||||
"question",
|
||||
"contact",
|
||||
"ask",
|
||||
"send",
|
||||
"form",
|
||||
"email"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_008",
|
||||
"type": "topic",
|
||||
"title": "Prayer and quiet time",
|
||||
"content": "Prayer is simply talking to God — it doesn't have to be formal or long. A quiet time usually involves reading a passage of Scripture, reflecting on what it means for your life today, and spending a few minutes in prayer. You must make sure that you stay in context of what Scripture says and not put your own thoughts or words into what the Bible is saying. Many people find journaling helpful. The key is consistency, not perfection. Even five minutes a day done faithfully is far better than an hour once a week.",
|
||||
"keywords": [
|
||||
"prayer",
|
||||
"quiet time",
|
||||
"devotional",
|
||||
"pray",
|
||||
"journal",
|
||||
"meditate",
|
||||
"reflect",
|
||||
"morning",
|
||||
"time with god"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "167fa083-5438-44a5-ac72-6dd379e937f5",
|
||||
"type": "episode",
|
||||
"title": "Episode 2 — Introduction to Titus (Background & Overview)",
|
||||
"content": "WHO WAS PAUL? Let’s start at the beginning. The letter opens in verse 1 with the words: Paul, a servant of God and an apostle of Jesus Christ for the faith of God’s elect and their knowledge of the truth that leads to godliness, Now, if you’ve spent any time in the New Testament, you’ve heard of Paul. But let me give you the short version, because I think it matters here. Paul — originally named Saul — was a man who, by his own description, was a Hebrew of Hebrews. He was a Pharisee. He was educated, zealous, and deeply committed to the law of Moses. And for a season of his life, that zeal led him to do something he would carry with him for the rest of his days: he persecuted the early church. He approved of the stoning of Stephen. He dragged men and women out of their homes and had them thrown into prison. And then — on a road to Damascus — everything changed. He met the risen Jesus. And the man who was once the church’s greatest enemy became its most tireless ambassador. Paul went on to plant churches across the Roman world. He traveled through modern-day Turkey, Greece, Macedonia. He wrote letters that would become a third of our New Testament. He was beaten, shipwrecked, imprisoned — and through all of it, he kept going. Why does this matter for Titus? Because the man writing this letter is not writing from a place of comfort or theory. Paul understood, in the most personal way possible, what it meant to be completely transformed by grace. That’s going to come through on every page. WHO WAS TITUS? Now, who is Titus? Titus doesn’t get as much attention as, say, Timothy — but he should. He’s one of Paul’s closest and most trusted co-workers, and the more you look at him, the more impressive he becomes. Here’s what we know: Titus was a Gentile. He wasn’t Jewish. He came to faith through Paul’s ministry — which is why Paul, in verse 4 of this very letter, calls him: To Titus, my true child in our common faith: Grace and peace from God the Father and Christ Jesus our Savior. That phrase is loaded. Paul is saying: you and I share the same gospel. You are my son in the faith. If you want to understand Titus better, look at a few other places in Paul’s letters. In 2 Corinthians chapter 7, Paul describes being comforted by the arrival of Titus during a particularly difficult season. In chapter 8, he sends Titus to help organize a relief offering for struggling believers in Jerusalem. And in Galatians chapter 2, Titus shows up as something of a test case — proof, in human form, that the gospel is for everyone. This is a man Paul trusted with hard things. And that’s important — because what he’s being asked to do in Crete is very hard. WHERE IS CRETE? Which brings us to the island. Crete. If you look at a map of the Mediterranean, you’ll find it in the southern Aegean Sea — a large island stretching about 160 miles from east to west. It was a well-known place in the ancient world. It had a long history, a busy coastline, and a reputation. And not necessarily a good one. Paul actually quotes a Cretan poet in this very letter — we’ll get to that in chapter 1. Crete had a reputation for dishonesty, for moral looseness, for people who were, frankly, difficult to work with. The cultural environment was a challenging one for a young church trying to take root. There were also false teachers mixing Jewish influences with the faith in unhealthy ways. Paul is going to address that head-on. So picture Titus: a Gentile pastor, on a difficult island, in a church still finding its footing, surrounded by cultural pressures and false teachers. This letter is his assignment briefing. WHY WAS THE LETTER WRITTEN? In verse 5, Paul gets right to the point: The reason I left you in Cr",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 2",
|
||||
"titus 2:11",
|
||||
"introduction",
|
||||
"background",
|
||||
"overview",
|
||||
"crete",
|
||||
"letter"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.315Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.272Z"
|
||||
},
|
||||
{
|
||||
"id": "881279c7-3ca4-436d-a584-19cc0a5a9bf3",
|
||||
"type": "episode",
|
||||
"title": "Episode 3 — Who Is Paul, and Why Does This Letter Exist? (Titus 1:1– 4)",
|
||||
"content": "SERVANT AND APOSTLE Let’s start by reading Titus Chapter 1 Verses 1-4 I am reading from The Berean Standard Bible ( BSB) . 1 Paul, a servant of God and an apostle of Jesus Christ for the faith of God’s elect and their knowledge of the truth that leads to godliness, 2 in the hope of eternal life, which God, who cannot lie, promised before time began. 3 In His own time He has made His word evident in the proclamation entrusted to me by the command of God our Savior. 4 To Titus, my true child in our common faith: Grace and peace from God the Father and Christ Jesus our Savior. Paul opens with two words to describe himself: servant and apostle. The order matters, and the pairing is intentional. Apostle is a word of authority — a commissioned representative sent with the full weight of the sender’s backing behind them. Paul is saying: I speak on behalf of Jesus Christ. What I write carries weight. But before he says apostle, he says servant. In the Greek, the word is doulos (doo'- los ) — bondservant – a person who chooses to be a servant one who belongs entirely to another. Paul is saying: before anything else, I am owned. Everything I do flows from submission to the God I serve. This combination — servant and apostle — is a picture of what healthy spiritual authority looks like. It is never raw power. It is authority that comes from below — from a posture of submission — exercised in service, not dominance. Paul is modeling for Titus, right here in the opening line, what it looks like to lead. You don’t choose between humility and authority. You hold both. At the same time. TRUTH THAT LEADS TO GODLINESS In verse 1, Paul describes his mission as being for “the faith of God’s elect and their knowledge of the truth that leads to godliness.” That last phrase is the key: truth that leads to godliness. Paul is not interested in truth for its own sake — collecting correct theological propositions the way some people collect rare books, impressive on the shelf but never actually read. For Paul, truth has a direction. Truth moves. Truth produces something. What does it produce? Godliness. A life shaped by what it believes. This is the big theme of the entire letter — sound doctrine and transformed living are not separate categories. They are inseparable. The truth you know should be shaping the way you live. If it isn’t — if your theology is just mental furniture — something has gone wrong. BEFORE TIME BEGAN Now we get to one of the most stunning phrases in the entire letter. Verse 2: …in the hope of eternal life, which God, who cannot lie, promised before time began. Before time began. Let that land for a second. Paul is saying that the plan of salvation was not an afterthought. It was not Plan B. It was not God improvising a rescue after the fall. It was a promise made before the universe existed. Before the first atom, the first star, the first breath — God had already determined to save a people for Himself. And notice what Paul says about the God who made that promise: He cannot lie. Not He does not lie. He cannot lie. It is outside His nature. It is impossible. That means the promise of eternal life is not hanging on your faithfulness, or your feelings, or whether you had a good week. It is hanging on the character of a God who is constitutionally incapable of breaking His word. For Titus, laboring on a difficult island with a struggling church and real opposition — this is the ground he stands on. Not his own strength. The unchangeable promise of an undeceivable God. ENTRUSTED BY COMMAND Verse 3 adds one more layer to Paul’s self-understanding: …In His own time He has made His word evident in the proclamation entrusted to me by the",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 3",
|
||||
"titus 1:1– 4",
|
||||
"titus 1:1",
|
||||
"paul",
|
||||
"does",
|
||||
"this",
|
||||
"letter",
|
||||
"exist"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.347Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "e0ad268f-2826-41a5-b078-90f90aeda21b",
|
||||
"type": "episode",
|
||||
"title": "Episode 4 — What Does a Faithful Leader Look Like? (Titus 1:5–9)",
|
||||
"content": "WHY ELDERS FIRST? Remember where we are in the letter. Paul has just finished his opening and the very first practical instruction he gives Titus is this: The reason I left you in Crete was that you would set in order what was unfinished and appoint elders in every town, as I directed you. Appoint elders. That’s the first thing. Because a church without mature, qualified leadership is vulnerable. Think of it this way: Paul had planted the gospel in Crete, and people had come to faith. But a collection of new believers without shepherds is like a flock without a shepherd — it can survive for a season, but it will eventually scatter or get picked off. Leadership is not bureaucracy. In Paul’s vision, it’s protection. It’s structure that allows the gospel to take root across generations. Titus’s first assignment is to build the infrastructure that makes everything else possible. VERSE 6: THE HOUSEHOLD QUALIFICATION Before Paul moves to the broader character portrait in verses 7 and 8, he pauses in verse 6 to say something about the elder’s household. It’s easy to read past it. Don’t. An elder must be blameless, the husband of but one wife, having children who are believers and who are not open to accusation of indiscretion or insubordination. The elder’s home is exhibit A. His marriage, his children, the atmosphere of his household — these are not private matters separate from his fitness to lead. They are the primary evidence of it. Think about what Paul is saying. Before Titus looks at a man’s preaching or his public presence, he should look at his home. Is his marriage marked by faithfulness? Are his children — the people who know him best, who cannot be impressed or deceived — living in a way that reflects what he believes? Paul’s logic is plain: if a man cannot lead his own household well, there is no reason to believe he will lead God’s household well. The church is not a stage where a person performs. It is a family — and family life requires the same qualities at home and at the pulpit. Integrity cannot be compart - mentalized. This isn’t a demand for perfection — Paul is not saying an elder must have flawless children. He says they must not be open to accusation of wild living or insubordination. The standard is a household genuinely ordered by the same faith the elder proclaims. ELDER AND OVERSEER: TWO WORDS, ONE ROLE In verses 5 through 7, Paul uses two words interchangeably: elder and overseer. Elder — presbyteros — carries the idea of maturity and respect. It speaks to someone the community looks to for wisdom. Overseer — episkopos — is more functional. It means one who watches over, who takes responsibility for the welfare of others. It emphasizes the work of the role. Together they give us the full picture. The person leading a local church is someone who is mature in faith AND active in oversight. Not just respected but responsible. Not just experienced but engaged. THE FIVE ‘NOTS’ AND THE POSITIVES In verses 7 and 8, Paul gives Titus a remarkably specific list. Let’s read them: As God’s steward, an overseer must be above reproach—not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy for money. Five things an elder must not be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy for money. Then in verse 8, six things he must be: Hospitable — someone whose home and life are genuinely open to others. A lover of good — someone drawn toward what is right and true and beautiful. Self-controlled — someone who governs their own desires and impulses. Upright — someone whose dealings with others are fair and straight. Holy — someon",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 4",
|
||||
"titus 1:5–9",
|
||||
"titus 1:5",
|
||||
"what",
|
||||
"does",
|
||||
"faithful",
|
||||
"leader",
|
||||
"look",
|
||||
"like"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.369Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "56626549-7e1e-4564-8c79-2d21b49eb911",
|
||||
"type": "episode",
|
||||
"title": "Episode 5 — The Danger of Empty Words (Titus 1:10–13a)",
|
||||
"content": "WHO WERE THE CIRCUMCISION GROUP? Paul identifies the primary troublemakers in verse 10: For many are rebellious and full of empty talk and deception, especially those of the circumcision, “Those of the circumcision” refers to some of the Jewish Christians — or people who were influenced by Jewish tradition — they were insisting that Gentile believers needed to follow the Mosaic law in addition to having faith in Christ. The specific issue was circumcision — but it stood for a whole system. The argument was essentially: Jesus is necessary, but not sufficient. You also need the law, Jewish customs, certain food regulations, certain rituals. For Paul, this was not a secondary debate. It was a direct attack on the gospel itself. The moment you add requirements to grace, you have changed what grace is. Paul’s entire ministry was grounded in one truth: salvation comes through Christ alone. Jesus’ life, death, and resurrection are fully sufficient. Nothing needs to be added. Faith in Jesus is all that is required to be reconciled to God. THREE GREEK WORDS In verse 10, Paul uses three Greek words to describe these teachers, and each one is precise. ἀνυπότακτος ( any - potak - tos ) — rebellious. Literally not submitted, not under authority. These people have placed themselves outside accountability. ματαιολόγος ( mata - io - logos ) — full of empty talk. Combines mata - ios (empty, vain) with logos (word). Religious noise without substance. φρεναπάτης ( phren - a - patēs ) — deception. Literally one who deceives the mind. Not just wrong content — distorting the way a person thinks from the inside. Rebellious. Empty talkers. Mind-deceivers. These aren’t accidental mistakes . These are people actively leading others in the wrong direction. Paul wants Titus to see them clearly. UNDERMINING ENTIRE HOUSEHOLDS Verse 11 tells us the scale of the damage: …who must be silenced. For the sake of dishonorable gain, they undermine entire households and teach things they should not. Whole families ! In the first-century world, the household was the basic unit of society. Churches met in homes. Faith was practiced in families. When a false teacher reshaped what a household believed, the damage spread through every relationship in that home. And notice the motive Paul names: dishonorable gain. These teachers weren’t doing this out of sincere conviction. They were doing it for money. For influence. For the advantages that came from building a following. That’s the pastoral warning. Not everyone who teaches with confidence is teaching the truth. In the Garden of Eden, Satan spoke convincingly while distorting what God had said. And not everyone who sounds sincere is free from self-interest. WHY PAUL QUOTES EPIMENIDES In verse 12, Paul quotes a Cretan poet — Epimenides — who said: “Cretans are always liars, evil beasts, lazy gluttons.” And then in verse 13 Paul adds: This testimony is true. Therefore rebuke them sternly, so that they will be sound in the faith This has confused readers for centuries. Is Paul endorsing ethnic stereotyping? No. He is engaged in ministry that takes context into account. He’s saying to Titus: the culture you’re working in has certain well-known tendencies. O ne of their own poets — said so. This isn’t about ethnicity; it’s about a specific cultural pattern of dishonesty that the false teachers were both shaped by and exploiting. Paul isn’t writing off Crete. He’s telling Titus to go in with eyes open. Know where you are. Know what you’re working against. REBUKE AS AN ACT OF LOVE Verse 13 also contains an instruction that modern ears often s",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 5",
|
||||
"titus 1:10",
|
||||
"danger",
|
||||
"empty",
|
||||
"words",
|
||||
"false",
|
||||
"teacher",
|
||||
"titus 1:13"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.396Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "dd6b3af4-44a5-43f1-9d69-ab12c63dd46d",
|
||||
"type": "episode",
|
||||
"title": "Episode 6 — Words That Deny What We Claim to Believe (Titus 1:13b–16)",
|
||||
"content": "A DEFILED CONSCIENCE AND A DISTORTED WORLD Verse 15 contains one of the most psychologically precise statements in all of Paul’s letters: To the pure, all things are pure; but to the defiled and unbelieving, nothing is pure. Indeed, both their minds and their consciences are defiled. A person with a clean conscience — someone whose heart has been washed by the gospel — can engage with the world from a place of trust and clarity. But a person whose conscience is defiled — corrupted by sin, self-deception, or bad doctrine — can’t see anything clearly. Everything gets filtered through that corruption. Even what is pure looks suspect to them. This is why false teaching is so damaging at the root level. It doesn’t just change what people believe — it changes how they see. Think about what that looks like practically. A person shaped by bad doctrine doesn’t just hold wrong opinions — they become suspicious of grace, hostile toward correction, and unable to receive what is genuinely good. They see generosity and call it ingenuousness . They see freedom and call it license. The very gifts of the gospel become things they can’t trust, because the lens they’re looking through has been cracked. That’s the devastation Paul is describing — not just theological error, but a person who has lost the ability to see clearly. JEWISH MYTHS AND COMMANDS OF MEN Verse 14 tells us specifically what these false teachers were promoting — Jewish myths and the commands of people who have rejected the truth. Jewish myths likely refers to speculative expansions of Old Testament stories. Commands of men refers to human-made religious rules elevated to the level of divine requirement. And here’s why Paul sees this as a gospel issue: when you add human requirements to the gospel, you shift the basis of acceptance before God from what Christ has done to what you do. The result is a Christianity more anxious, more performance-driven, more focused on the maintenance of rules than on the freedom of grace. We should be honest that this isn’t only a first-century problem. The “commands of men” show up in modern dress all the time — in churches that quietly teach that real Christians hold certain political views, in communities where your worth is measured by how often you volunteer, how much you give, or whether you use the right vocabulary. None of those things are bad in themselves. But the moment they become the measure of your standing before God, the gospel has been displaced. Paul’s warning is just as sharp now as it was in Crete. DETESTABLE, DISOBEDIENT, UNFIT Verse 16 ends with a devastating summary: They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed. Three words. Detestable. Disobedient. Unfit for any good deed. The first word Paul uses - Detestable - describes something morally repulsive — something unacceptable before God . It’s the same kind of word the Old Testament used for things that were considered abominations. In other words, these teachers may look religious on the outside, but in God’s sight their teaching and conduct are deeply offensive. The second word - Disobedient - describes someone who is not persuadable — someone resistant to truth . Not merely confused, but unwilling to submit when confronted with what is right. They have heard the truth, but they refuse to bend their lives to it. The third word – is a phrase in English but is one word in Greek - Unfit for any good deed - This comes from the world of metalworking . It refers to metal that has been tested , examined and discovered to be counterfeit — rejected because it lacks real substance. That’s the picture Paul is painting: religious appearance on the surface, but wh",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 6",
|
||||
"titus 1:13",
|
||||
"deny",
|
||||
"claim",
|
||||
"believe",
|
||||
"titus 1:16"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.417Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "1559c4af-3913-4a0d-9ac3-a59172adabc5",
|
||||
"type": "episode",
|
||||
"title": "Episode 7 — Sound Doctrine for Every Stage of Life (Titus 2:1–10)",
|
||||
"content": "THE PIVOT FROM WARNING TO VISION Chapter 1 was about threats — the things that were wrong, the people causing damage, the patterns that needed to be stopped. It was a necessary chapter. You can’t build well if you haven’t cleared the ground. Chapter 2 is about vision. Not what are we against, but what are we for. Not just what does sound doctrine forbid, but what does it produce? And what it produces is a community that looks different. Every generation investing in the next. Older believers modeling what long faithfulness looks like. Younger believers learning to bring their appetites and ambitions under the direction of the gospel. Workers whose ordinary faithfulness makes the gospel attractive. This is not a vision of individual spiritual achievement. It is a vision of intergenerational community shaped by grace. OLDER MEN Verse 2 describes what Paul wants Titus to cultivate in older men: Older men are to be temperate, dignified, self-controlled, and sound in faith, love, and perseverance. Four qualities of character, and three qualities of soul. Temperate. Dignified. Self-controlled. These describe a man who has learned — through years of walking with God and being humbled by life — to govern himself. And then: sound in faith, love, and perseverance. Faith that hasn’t grown cynical. Love that hasn’t grown cold. Perseverance that hasn’t given up. The older man who embodies these qualities is not just personally virtuous — he is a witness. He shows the younger generation that it is possible to follow Jesus for decades and still be full of faith, still warm in love, still standing. That’s a gift no sermon can replace. OLDER WOMEN AND YOUNGER WOMEN Verses 3 through 5 describe older women and their specific role in the life of younger women: Older women, likewise, are to be reverent in their behavior, not slanderers or addicted to much wine, but teachers of good. In this way they can train the young women to love their husbands and children, to be self-controlled, pure, managers of their households, kind, and submissive to their own husbands, so that the word of God will not be discredited. The older women Paul describes are not passive. They are reverent — a word used elsewhere for temple service, suggesting a life of sacred intentionality. They are not slanderers. They are not addicted to much wine. They are self-governing. And their primary ministry is to teach younger women. The Greek word for train here — sophronizō — is about forming wisdom and good judgment in someone else. This is not advice-giving. This is mentorship. Investment. One life poured into another. And the reason Paul gives for all of it is stunning: so that the word of God will not be discredited. The way the women in this congregation live their ordinary lives either adorns the gospel or brings it into disrepute. Their faithfulness is a form of witness. YOUNGER MEN AND TITUS HIMSELF The section on younger men is the shortest in the passage. Verse 6: “In the same way, urge the younger men to be self-controlled.” That’s it. One charge. And then Paul immediately turns to Titus in verse 7: In everything, show yourself to be an example by doing good works. In your teaching show integrity, dignity, The connection is important. The best thing Titus can do for the younger men is not give them a list. It’s show them a life. Paul uses the word typos — a pattern, a mold. Titus himself is to be the shape that younger men are formed around. Self-control is not a naturally occurring quality in young men. It has to be learned. And it is learned most effectively not from a lecture but from a model. ADORNING THE GOSPEL [This is the capstone i",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 7",
|
||||
"titus 2:1–10",
|
||||
"titus 2:1",
|
||||
"sound",
|
||||
"doctrine",
|
||||
"every",
|
||||
"stage",
|
||||
"life"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.441Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "6be7a644-9276-4797-a7bb-1422b170846c",
|
||||
"type": "episode",
|
||||
"title": "Episode 8 — Grace Is Not Just What Saves You — It’s What Trains You (Titus 2:11–12)",
|
||||
"content": "GRACE AS TEACHER: THE GRAMMAR MATTERS Let’s read verses 11 and 12 carefully: For the grace of God has appeared, bringing salvation to everyone. It instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives in the present age, Now — look at the grammar of verse 12. The subject of the sentence is ‘it.’ And ‘it’ refers back to grace. Grace instructs us. Grace is the subject. Grace is doing the teaching. The Greek word Paul uses is paideuō — the word for the education of a child. Not classroom instruction. Formation. The kind of learning that shapes not just what you know but who you are. Grace is the tutor. And the curriculum it’s teaching has two parts: what to say no to, and what to say yes to. WHAT GRACE TEACHES US TO SAY NO TO Grace teaches us to renounce two things: ungodliness and worldly passions. Ungodliness — asebeia — is the opposite of reverence. A life where God is not actually at the center. Where decisions are made without reference to Him. Worldly passions — desires shaped by the age we live in rather than the kingdom we’ve been brought into. And here’s what I want you to notice: Paul doesn’t say that moral effort teaches us to renounce these things. He says grace teaches us. The pathway to saying no to ungodliness is going deeper into grace — understanding more fully what you have been given. Grace, properly understood, reorients your desires. It doesn’t just change the rules. It changes what you want. WHAT GRACE TEACHES US TO SAY YES TO On the other side, grace teaches three orientations: sensible, upright, and godly. Sensible — sōphrōn — a sound, well-ordered mind. Not repressed. Not uptight. But governed. A person whose interior life is ordered enough that they can engage with the world without being swept away by it. Upright — dikaios — right in relationship with others. Fair. Honest. The gospel changes how we treat people because it has changed how we understand our own standing before God. Godly — eusebōs — living with genuine reverence. A life where God is actually at the center, not just in stated belief, but in the practical orientation of each day. Inward (sensible), outward (upright), and upward (godly). Grace reshapes the whole person. THE KEY WORD: CHARIS The Greek word for grace is charis — χάρις. It appears in the opening greeting (1:4) and in the closing benediction (3:15), bookending the entire letter. Charis means unmerited favor. A gift freely given with no precondition of worthiness. But Paul uses it here as a teacher — it initiates AND sustains. The grace that started the work in you is the same grace that is continuing it. This is why trying harder without grace fails. Moral effort without a gospel foundation is white-knuckle religion. And this is also why grace without obedience is incomplete. Grace is so powerful that it actually produces change. If it’s not producing anything in your life, Paul would want to ask whether you’ve actually encountered it. DISCUSSION QUESTIONS Three questions to carry this week: 1. In what ways is grace a teacher? How does understanding what God has freely given change your motivation for how you live? 2. What’s the difference between obeying out of fear or duty versus obeying as a response to grace? What does that difference feel like from the inside? 3. Where do you find it hardest to say no to ungodliness — and what role does grace actually play there, in your honest experience? ✦ CLOSING Grace is not a hall pass for how you live. It is a school you attend every day. The same grace that declared you forgiven is act",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 8",
|
||||
"titus 2:11–12",
|
||||
"titus 2:11",
|
||||
"grace",
|
||||
"just",
|
||||
"what",
|
||||
"saves",
|
||||
"trains"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.461Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "4e6ec41f-b083-45b9-9581-0dc91f092f66",
|
||||
"type": "episode",
|
||||
"title": "Episode 9 — Living Between Two Appearings (Titus 2:13–15)",
|
||||
"content": "THE THREE TIMEFRAMES Verse 11: “For the grace of God has appeared.” Past tense. The first appearing. The foundation. Verse 12: “…to live sensible, upright, and godly lives in the present age.” Present tense. The current moment. Verse 13: …as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ. Future tense. We are waiting actively, with anticipation, with our behavior shaped by what we know is coming. Past event. Present calling. Future hope. You can’t live the present well without both the foundation behind you and the horizon ahead of you. THE BLESSED HOPE Verse 13 uses a phrase that has become so familiar it has almost lost its force: the blessed hope. The Greek is makaria elpis. Makaria is the same word used in the Beatitudes — blessed, in a divinely favored condition. And elpis — hope — in the New Testament is not the way we use the word in everyday conversation. We say ‘I hope it doesn’t rain’ — meaning we’re uncertain, we’re wishing. Biblical hope is a confident expectation of something certain. Not a wish. An anchor. The blessed hope is: Jesus is coming back. Not maybe. Not eventually, probably. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today. THE GOSPEL IN ONE SENTENCE Verse 14 does something remarkable. In a single sentence, Paul gives us the full scope of what Christ’s death accomplished: He gave Himself for us to redeem us from all lawlessness and to purify for Himself a people for His own possession, zealous for good deeds. Two verbs. Redeem and purify. Redeem — lytroō — a word from the slave market and from Exodus. To pay a price to set someone free. What were we freed from? All lawlessness. Purify — katharizō — from the temple. To cleanse, to make acceptable. We weren’t just released from prison — we were made clean. Belonging. And look at the end: ‘a people for His own possession, zealous for good deeds.’ The word zelotes — eager, passionate, on fire. Not reluctant. Not dragging their feet. People who are genuinely excited about doing good because they understand what they’ve been rescued for. SPEAK WITH AUTHORITY Paul closes the chapter with a charge to Titus: Speak these things as you encourage and rebuke with all authority. Let no one despise you. Speak these things. All of it — the grace, the training, the blessed hope, the gospel in one sentence. Don’t soften it. Don’t apologize for it. With all authority. Not Titus’s own authority — the authority of the message itself. The gospel doesn’t need Titus’s permission to be true. His job is to represent it faithfully and let it carry its own weight. DISCUSSION QUESTIONS Three questions for this week: 1. How does actively anticipating the return of Christ affect the way you make decisions and treat people today? Does the blessed hope feel like a real anchor in your life? 2. What does it mean to you personally that Christ ‘gave Himself’ — that it was intentional, not accidental? 3. Do you experience your Christian life as something you belong to — a people claimed by God — or mostly as something you have to maintain on your own? ✦ CLOSING We are living between two great appearings of grace. The first established the foundation. The second will complete what was begun. The in-between space is not a waiting room. It’s a mission field. We don’t pace and wait. We live — sensible, upright, godly, zealous for good deeds — shaped by what we know is coming. Next ep",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 9",
|
||||
"titus 2:13–15",
|
||||
"titus 2:13",
|
||||
"living",
|
||||
"between",
|
||||
"appearings"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.484Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "41233c63-d2b4-4fd3-9054-1afb11c37287",
|
||||
"type": "episode",
|
||||
"title": "Episode 10 — How the Gospel Forms a Community (Titus 3:1–2)",
|
||||
"content": "WHY PAUL OPENS CHAPTER 3 WITH CIVIC SUBMISSION Paul is not saying: agree with every government decision. He is saying: your engagement with public life should be marked by something recognizable as different. Your submission, your readiness to do good, your peaceable presence — these are not just civic virtues. They are missionary strategies. The way the community in Crete navigates public life is either going to open doors for the gospel or close them. THREE ACTIVE ORIENTATIONS Verse 1: Remind the believers to submit to rulers and authorities, to be obedient and ready for every good work, Submit to rulers and authorities — a posture of respect toward legitimate governing structures. The believer’s default is cooperation rather than defiance. Be obedient — follow through. Actually do what is asked. People whose lives don’t create unnecessary friction with the society around them. Be ready for every good work — active, not passive. Readiness implies preparation and eagerness. The believer’s posture is not just ‘don’t cause problems.’ It’s ‘be looking for opportunities to do good.’ THE POSTURE OF BELIEVERS IN A CONFLICT-SATURATED CULTURE Verse 2 adds four more qualities: …to malign no one, and to be peaceable and gentle, showing full consideration to everyone. Malign no one. In a culture built on callouts, political attack cycles, and the steady background noise of contempt — this is genuinely countercultural. Paul doesn’t say: malign no one except your political opponents. He says no one. Peaceable — amachous — literally non-combative. Alpha (not) + machē (battle). People who are not itching for a fight, not framing every interaction as a conflict to be won. Gentle — epieikēs — gracious, not insisting on the full weight of one’s rights. Showing full consideration to everyone — not just the likable, the agreeable, the theologically correct. For all people. This is not weakness. This is the confidence of people who know how the story ends and don’t need to win every argument to prove it. DISCUSSION QUESTIONS Three questions for this week: 1. How should the gospel shape the way Christians engage with politics, authority, and public life? Where do you see the church getting this right — and where do you see it getting it wrong? 2. What does it look like to be gentle and considerate to everyone in the age of social media? What would need to change in your online presence to reflect the posture Paul describes here? 3. Where do you find it hardest to reflect the peaceable posture Paul describes? What situation or kind of person most tests your gentleness? ✦ CLOSING The gospel sends us into the world not as culture warriors, but as witnesses. The posture Paul calls for — gentle, peaceable, considerate, ready for every good work — is the confidence of people who know who they belong to and what the future holds. The confidence of people who know the King doesn’t look like aggression. It looks like peace. Next episode, before Paul gives us the gospel again in its fullest form, he gives us the diagnosis. Verse 3 is one of the most honest passages in Titus — and Paul includes himself in it. I’m Nate, and this is Verse by Verse with Nate: A Journey Through Scripture. Until next time — keep going. — End of Episode 10 —",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 10",
|
||||
"titus 3:1–2",
|
||||
"titus 3:1",
|
||||
"gospel",
|
||||
"forms",
|
||||
"community"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.506Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "6d0def21-46cb-497e-ac20-8e09c5368a08",
|
||||
"type": "episode",
|
||||
"title": "Episode 11 — Remember What You Were (Titus 3:3)",
|
||||
"content": "THE ‘WE TOO WERE’ Let’s read the verse: For at one time we too were foolish, disobedient, misled, and enslaved to all sorts of desires and pleasures—living in malice and envy, being hated and hating one another. We ourselves. This is not rhetoric. This is autobiography. Paul is giving instructions on how to correct others — how to rebuke false teachers, how to call the congregation to a higher standard. And right here he stops and says: before you do any of that, remember what you were. This is the ground of Christian humility. Every time a believer looks at someone who is not yet a Christian — someone whose life is being destroyed by these very patterns — the right response is not contempt. It is recognition. I was there. I was enslaved to those same kinds of things. I didn’t save myself. I was saved. SEVEN DESCRIPTORS Paul gives us seven words for what we were before grace. Each one is precise. Foolish — anoētos — without understanding. Not unintelligent. Without the wisdom that comes from knowing God. Disobedient — apeitheis — not persuadable, resistant to truth. Misled — planōmenoi — wandering, deceived, off course. Confidently heading in the wrong direction. Enslaved to all sorts of desires and pleasures — douleuontes — serving as slaves. Not choosing. Owned by desires. Living in malice and envy — ordinary corrosive stuff — wanting what others have, being glad when they fail. Being hated and hating one another — the natural end state of a life shaped by malice and envy. Seven words for what we were. And this is not a description of the worst people in the world. This is a description of all of us, before grace. HONEST MEMORY AS A PASTORAL GIFT Why does Paul make Titus — and us — look at this? Because the people Titus is ministering to include people who are still in this condition. And it would be easy — especially for a young pastor who has been well-formed by the gospel — to look at them with spiritual impatience. To treat their condition as a problem to be solved rather than a darkness to be illuminated with compassion. Paul is cutting that off at the root. You were there. You did not extricate yourself. You were found. There is a kind of humility that only grows from honest memory. The person who genuinely remembers what they were before grace is not capable of looking down on someone who doesn’t yet have it. They are too aware of the distance grace had to travel to reach them. DISCUSSION QUESTIONS Three questions to sit with this week: 1. Why is Paul’s autobiographical ‘we’ so important here? How does remembering your own ‘before’ picture shape your attitude toward people who don’t yet know the gospel? 2. Which of the seven descriptors — foolish, disobedient, misled, enslaved, malicious, envious, hated and hating — do you most need to keep in view to stay humble? 3. How does honest self-awareness about what we were become a gift for the way we love others? Where have you seen that in practice? ✦ CLOSING Humility toward others grows from memory. We don’t look down on where people are — because we remember where we were. And we remember that we didn’t save ourselves. Grace came to us. Grace found us in the dark and brought us out. That memory is not a wound. It’s a gift. Next episode, we get the gospel in its fullest form — Titus 3:4 through 7 is one of the most concentrated and beautiful gospel summaries anywhere in the New Testament. I’m Nate, and this is Verse by Verse with Nate: A Journey Through Scripture. Until next time — keep going. — End of Episode 11 —",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 11",
|
||||
"titus 3:3",
|
||||
"titus 3:4",
|
||||
"remember",
|
||||
"what",
|
||||
"were"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.532Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "b68c5659-cc7e-42d2-ab24-a623b7404058",
|
||||
"type": "episode",
|
||||
"title": "Episode 12 — The Gospel in One Paragraph (Titus 3:4–7)",
|
||||
"content": "SALVATION IS TRINITARIAN All three persons of the Trinity are present and active in our salvation. This is not accidental — Paul is showing us that the gospel is not a one-person operation. God the Father — verse 4 speaks of ‘the kindness of God our Savior and His love for mankind.’ The initiating motivation for salvation is the character of the Father. He is kind. He is full of love toward humanity. Our rescue begins in the heart of God. The Holy Spirit — verse 5 speaks of ‘the washing of new birth and renewal by the Holy Spirit.’ The Spirit is the agent of the new birth — the one who applies the work of Christ to the individual soul. Jesus Christ — verse 6 names Him explicitly as the one through whom the Spirit is poured out, and verse 7 calls Him ‘our Savior.’ Father, Son, Spirit — each person fully present, each playing a distinct role, all working together in the single act of saving a person. This is not just theology. It is the shape of your own story. NOT BY OUR DEEDS: MERCY, NOT MERIT Verse 5 contains one of the most explicit denials of works-based salvation in the New Testament: But when the kindness of God our Savior and His love for mankind appeared, He saved us, not by the righteous deeds we had done, but according to His mercy, through the washing of new birth and renewal by the Holy Spirit. Not by the righteous deeds we had done. Not partially. Not mostly. The basis of salvation is not our righteousness. It is His mercy. The theological term for this is monergism — salvation as a single-agent work. God does the saving. We receive it. There was nothing in the raw material that deserved rescue. The rescue happened anyway, because of mercy. And why does this matter so much? Because if salvation is even one percent based on our merit, it is unstable. But if salvation is entirely based on God’s mercy — on the character of a God who cannot lie and who loved humanity enough to appear as a human and die — then it rests on something that cannot change. NEW BIRTH AND RENEWAL Verse 5 gives us two words for what the Spirit does in us: the washing of new birth and renewal. Washing of new birth — palingenesia — literally ‘again-genesis.’ A new beginning. A second creation. This is the moment the Spirit brings a spiritually dead person to life. A definitive event. A before-and-after. Renewal by the Holy Spirit — anakainōsis — ongoing work. Continuous present tense in the Greek. The Spirit who brought you to life at regeneration is also continually renewing you. Both are needed. New birth without renewal produces a static faith. Renewal without new birth produces endless effort without a genuine starting point. Both are the Spirit’s work. Your part is to cooperate with what He is already doing. JUSTIFIED AND HEIRS Verse 7 adds two more categories: This is the Spirit He poured out on us abundantly through Jesus Christ our Savior, so that, having been justified by His grace, we would become heirs with the hope of eternal life. Justified — dikaiōthentes — a courtroom term. To be declared righteous. Not to be made righteous in our behavior — but declared righteous in our standing. The verdict rendered over you in the courtroom of God is: not guilty. Accepted. Righteous in Christ. Heirs — klēronómoi — relational. An heir belongs to a family. Has a place at the table. Has a guaranteed share in the inheritance. You are not just legally acquitted. You are a member of the family. The eternal life promised before time began is not a prize you might win. It is an inheritance secured for you. DISCUSSION QUESTIONS Three questions for this week: 1. Why is it so hard for people — including long-time Christians — to fully accept that salvation is en",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 12",
|
||||
"titus 3:4–7",
|
||||
"titus 3:4",
|
||||
"gospel",
|
||||
"paragraph"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.554Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "c8db1a86-346f-4bad-ba4f-30c29d95feb3",
|
||||
"type": "episode",
|
||||
"title": "Episode 13 — What Happens After the Gospel Takes Root (Titus 3:8–11)",
|
||||
"content": "THE FAITHFUL SAYING Verse 8: This saying is trustworthy. And I want you to emphasize these things, so that those who have believed God will take care to devote themselves to good deeds. These things are excellent and profitable for the people. Pistos ho logos — this saying is trustworthy. Paul uses this formula in his letters to Timothy and Titus to mark something as having the highest doctrinal weight. What he just said in verses 4 through 7 is the trustworthy, reliable, foundational word. Bank on it. Build on it. And then immediately: the expected outcome. Those who have believed God will take care to devote themselves to good deeds. The test of the gospel having taken root is not doctrinal sophistication. It is practical devotion to good works. PROFITABLE VS. POINTLESS AND WORTHLESS Verse 9: But avoid foolish controversies, genealogies, arguments, and quarrels about the law, because these things are pointless and worthless. Profitable — ōphélima. Pointless and worthless — anōphelēs and mataioi. The contrast is total. Good works produce something. They are excellent — kalá — beautiful, genuinely valuable. They benefit people. They demonstrate the gospel in tangible ways. Foolish controversies, on the other hand, produce nothing. They generate heat but no light. They create factions but no fruit. The test Paul applies is not: is this interesting? Is this theologically sophisticated? The test is: does engaging with this produce anything? Does it make people more devoted to good works? SOUND DOCTRINE PRODUCES GOOD WORKS Let me name the thread Paul has been pulling through this entire letter, because we’re near the end and it is clearer here than anywhere else. From chapter 1, verse 1: truth that leads to godliness. Elders who hold firmly to sound doctrine. False teachers whose lives deny what they claim to believe. Chapter 2: grace that instructs us to live sensible, upright, godly lives. Ordinary faithfulness that adorns the gospel. Chapter 3: people who have believed God devoting themselves to good deeds. The thread is this: sound doctrine and transformed living are not separate categories. Doctrine is the seed. Good works are the fruit. You cannot have genuine doctrine without eventual fruit, and you cannot sustain genuine good works without doctrinal roots. THE DIVISIVE PERSON: TWO WARNINGS, THEN REJECTION Verses 10 and 11: Reject a divisive man after a first and second admonition, knowing that such a man is corrupt and sinful; he is self-condemned. Two warnings. Not immediate removal. Not endless patience. Two warnings. One warning is not enough — it doesn’t give the person a genuine chance to respond. The second warning is confirmation that the first was heard and not responded to. After two warnings with no change — have nothing more to do with him. And Paul’s assessment: self-condemned. The verdict is not coming from the outside. The person who persists in division after two clear warnings has essentially condemned themselves by their own choices. The community is simply recognizing what is already true. This is not cruelty. It is honesty. And it protects the people who are being divided. DISCUSSION QUESTIONS Three questions for this week: 1. Why is good theology supposed to produce good deeds — and what happens in a person or a church when doctrine and life become disconnected from each other? 2. What kinds of theological discussions do you think fall into Paul’s ‘pointless and worthless’ category today? How do you tell the difference between a discussion worth having and one that just generates heat? 3. Why does Paul prescribe two warnings rather than immediate removal or endless patience for divisive people? What does that process protect? ✦ CLOSING [Grounde",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 13",
|
||||
"titus 3:8–11",
|
||||
"titus 3:8",
|
||||
"what",
|
||||
"happens",
|
||||
"after",
|
||||
"gospel",
|
||||
"takes",
|
||||
"root"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.582Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "14ced988-1382-4c8d-a29e-b0f531580af1",
|
||||
"type": "episode",
|
||||
"title": "Episode 14 — Grace: Where It Starts and Where It Ends (Titus 3:12–15)",
|
||||
"content": "REAL PEOPLE IN A REAL COMMUNITY Verses 12 through 14 name four people: Artemas, Tychicus, Zenas the lawyer, and Apollos. Tychicus appears in several of Paul’s letters as a trusted messenger. Apollos is mentioned in Acts and 1 Corinthians as a gifted teacher. Zenas is only mentioned here. Artemas we know nothing about beyond this verse. Why does Paul name them? Because the gospel travels through people. Not as an abstract idea floating through the air, but carried by specific men and women who have been changed by it and who are willing to take it somewhere difficult at personal cost. Titus is not alone on Crete. He is part of a network. The church in the first century was a community of people in motion — bound together by a shared gospel and a shared Lord. That is still how it works. DEVOTED TO GOOD WORKS TO MEET PRESSING NEEDS Verse 14: And our people must also learn to devote themselves to good works in order to meet the pressing needs of others, so that they will not be unfruitful. Two things worth noticing. First: let our people learn. Devotion to good works has to be cultivated, practiced, formed over time. It doesn’t just happen because you believe the right things. Second: to meet the pressing needs of others. The word is anagkaias — necessary, pressing, urgent. Paul is not describing optional generosity. He is describing a community that has made itself available for the real needs that arise around it. And the consequence of not doing this: be unfruitful. The gospel that has been received and believed and proclaimed — if it doesn’t produce this kind of practical, neighbor-serving fruit — something is missing. GRACE AS BOOKEND Let’s look at the two times grace appears at the frame of this letter. Titus 1:4 — the opening: “Grace and peace from God the Father and Christ Jesus our Savior.” In the opening, grace is the origin. It comes from God and from Christ. It is something received, given from outside, descending as pure gift. Titus 3:15 — the closing: “Grace be with all of you.” In the closing, grace is the blessing. It is what Paul wants them to be with — not wealth, not safety, not resolved problems. Grace. Everything that happens between those two bookends flows from the first grace and points toward the second. The elders who lead well are stewards of grace. The community formed generation by generation is shaped by grace. The gospel paragraph is the concentrated statement of grace. The good works that follow are the fruit of grace. Grace is not a footnote in Titus. It is the frame. It is the air the letter breathes. THE LETTER’S ONE COHERENT VISION Let me take you through the structure of the letter one final time. Chapter 1 — Leadership. Establish qualified elders. The elder who holds firmly to sound doctrine is the church’s first line of protection. Character is not the entry point for leadership — it is the definition of it. Chapter 2 — Community Formation. Every generation — older men, older women, younger women, younger men, enslaved people — is addressed. Grace instructs. Grace trains. Grace produces a particular kind of life in every stage and every situation. Chapter 3 — Civic and Social Life. Engage with submission, with gentleness, with readiness to do good. Remember what you were. The same grace that found you is the grace you are carrying to others. Three chapters. One vision. Sound doctrine and godly living are inseparable. What you believe should shape how you live. And the whole thing is held together by the grace that started it and the grace that will complete it. DISCUSSION QUESTIONS Three final questions — take your tim",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"episode 14",
|
||||
"titus 3:12",
|
||||
"titus 1:4",
|
||||
"titus 3:15",
|
||||
"grace",
|
||||
"starts",
|
||||
"ends",
|
||||
"review"
|
||||
],
|
||||
"createdAt": "2026-04-13T13:11:07.605Z",
|
||||
"updatedAt": "2026-04-13T14:06:04.273Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_faith_family_001",
|
||||
"type": "qa",
|
||||
"title": "How can I share my faith with skeptical family?",
|
||||
"content": "Lead with relationship, not argument. Skeptical family members usually don't need more information — they need to see the faith lived out genuinely. Consistency, love, and patience over time speaks louder than any debate win.\n\nPray specifically and persistently. This is the most underrated strategy. God moves hearts in ways arguments never can.",
|
||||
"keywords": [
|
||||
"faith",
|
||||
"family",
|
||||
"skeptical",
|
||||
"share",
|
||||
"relationship",
|
||||
"evangelism",
|
||||
"prayer",
|
||||
"witness",
|
||||
"unbeliever",
|
||||
"argument",
|
||||
"patience",
|
||||
"love"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_blessed_hope_001",
|
||||
"type": "qa",
|
||||
"title": "What is the blessed hope?",
|
||||
"content": "The blessed hope is a phrase from Titus 2:13 — \"as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ.\" In Episode 9, Nate breaks down the Greek: makaria elpis. Makaria is the same word used in the Beatitudes — a divinely favored condition. And elpis in the New Testament is not wishful thinking. Biblical hope is a confident expectation of something certain. Not a wish. An anchor.\n\nThe blessed hope is simply this: Jesus is coming back. Not maybe. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today.",
|
||||
"keywords": [
|
||||
"blessed hope",
|
||||
"titus 2:13",
|
||||
"second coming",
|
||||
"return of Christ",
|
||||
"hope",
|
||||
"appearing",
|
||||
"makaria",
|
||||
"elpis",
|
||||
"anchor",
|
||||
"future",
|
||||
"episode 9"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_who_is_titus_001",
|
||||
"type": "qa",
|
||||
"title": "Who was Titus?",
|
||||
"content": "Titus was a Gentile — not Jewish — who came to faith through Paul's ministry. Paul calls him \"my true child in our common faith\" (Titus 1:4). He was one of Paul's closest and most trusted co-workers. In 2 Corinthians 7, Paul describes being comforted by Titus arriving during a difficult season. In chapter 8, Titus helped organize a relief offering for believers in Jerusalem. In Galatians 2, he appears as proof that the gospel is for everyone.\n\nPaul left Titus on the island of Crete with a difficult assignment: appoint elders, correct false teaching, and help a young church find its footing in a challenging culture. The letter of Titus is essentially his briefing.",
|
||||
"keywords": [
|
||||
"titus",
|
||||
"who is titus",
|
||||
"gentile",
|
||||
"paul",
|
||||
"co-worker",
|
||||
"crete",
|
||||
"new testament",
|
||||
"biography",
|
||||
"history",
|
||||
"faith"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_deny_works_001",
|
||||
"type": "qa",
|
||||
"title": "What does it mean to deny God by your works?",
|
||||
"content": "Titus 1:16 says: \"They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed.\"\n\nIn Episode 6, Nate explains this using three precise words Paul chose. Detestable — morally repulsive before God, regardless of religious appearance. Disobedient — not persuadable, resistant to truth even when confronted with it. Unfit — a Greek metalworking term for metal tested and found counterfeit. Religious on the surface, but no real substance underneath.\n\nThe warning cuts both ways: a person can know the right words, attend the right services, hold the right opinions — and still live in a way that functionally says God doesn't matter. Theology and life are inseparable. What you actually believe will show itself in how you actually live.",
|
||||
"keywords": [
|
||||
"deny god",
|
||||
"works",
|
||||
"titus 1:16",
|
||||
"detestable",
|
||||
"disobedient",
|
||||
"unfit",
|
||||
"profess",
|
||||
"actions",
|
||||
"hypocrisy",
|
||||
"false teaching",
|
||||
"episode 6"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_grace_trains_001",
|
||||
"type": "qa",
|
||||
"title": "What does grace actually train us to do?",
|
||||
"content": "Titus 2:11-12 says grace \"instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives.\"\n\nIn Episode 8, Nate points out that the Greek word for instructs is paideuō — the education of a child. Not classroom instruction but formation. Grace is the subject doing the teaching.\n\nGrace teaches two things: what to say no to (ungodliness and worldly passions) and what to say yes to — sensible (an ordered mind), upright (right in relationship with others), and godly (God genuinely at the center). Inward, outward, and upward.\n\nThe key insight: the pathway to saying no to sin is going deeper into grace, not trying harder. Grace properly understood reorients your desires — it doesn't just change the rules, it changes what you want. Grace is not a hall pass. It's a school you attend every day.",
|
||||
"keywords": [
|
||||
"grace",
|
||||
"trains",
|
||||
"titus 2:11",
|
||||
"titus 2:12",
|
||||
"paideuō",
|
||||
"ungodliness",
|
||||
"sensible",
|
||||
"upright",
|
||||
"godly",
|
||||
"obedience",
|
||||
"formation",
|
||||
"episode 8"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_leadership_titus1_001",
|
||||
"type": "qa",
|
||||
"title": "What does Titus 1 teach about church leadership?",
|
||||
"content": "Paul's first instruction in Titus was to appoint elders — because a church without mature, qualified leadership is vulnerable. From Episode 4 (Titus 1:5-9):\n\nThe elder's home is exhibit A. Before looking at preaching or public presence, look at the household. If a man cannot lead his own family well, there's no reason to believe he'll lead God's family well. Integrity can't be compartmentalized.\n\nPaul gives five things an elder must NOT be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy. And six things he must BE: hospitable, a lover of good, self-controlled, upright, holy, and disciplined in sound doctrine.\n\nThis is not a job description for a CEO. Nate frames it as a picture of Christlike character — authority that flows from below, from a posture of submission and service, not dominance.",
|
||||
"keywords": [
|
||||
"leadership",
|
||||
"elder",
|
||||
"overseer",
|
||||
"titus 1:5",
|
||||
"titus 1:6",
|
||||
"titus 1:7",
|
||||
"titus 1:9",
|
||||
"church",
|
||||
"pastor",
|
||||
"qualify",
|
||||
"character",
|
||||
"household",
|
||||
"episode 4"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_titus347_summary_001",
|
||||
"type": "qa",
|
||||
"title": "Give me a summary of Titus 3:4-7",
|
||||
"content": "Titus 3:4-7 is what Nate calls 'the gospel in one paragraph' — one of the most concentrated summaries of salvation anywhere in Paul's letters. Episode 12 breaks it down:\n\nAll three persons of the Trinity are present: God the Father is the initiating motivation — He is kind and full of love toward humanity. The Holy Spirit is the agent of new birth and ongoing renewal. Jesus Christ is the one through whom the Spirit is poured out, and verse 7 calls him 'our Savior.'\n\nThe hinge of the passage is verse 5: He saved us not by the righteous deeds we had done, but according to His mercy. Not partially. Not mostly. The basis is His mercy, not our merit — which means salvation rests on something that cannot change.\n\nVerse 7 gives us two results: justified (declared righteous in God's courtroom) and heirs (members of the family with a guaranteed inheritance). You are not just legally acquitted — you belong.",
|
||||
"keywords": [
|
||||
"titus 3:4",
|
||||
"titus 3:4-7",
|
||||
"titus 3:7",
|
||||
"summary",
|
||||
"gospel",
|
||||
"mercy",
|
||||
"salvation",
|
||||
"trinity",
|
||||
"justified",
|
||||
"heirs",
|
||||
"new birth",
|
||||
"renewal",
|
||||
"holy spirit",
|
||||
"episode 12"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_christians_politics_001",
|
||||
"type": "qa",
|
||||
"title": "How should Christians engage with politics and public life?",
|
||||
"content": "Titus 3:1-2 is Paul's answer, covered in Episode 10. He tells Titus to remind believers to submit to rulers and authorities, be obedient, be ready for every good work, malign no one, and be peaceable, gentle, and considerate to everyone.\n\nNate's key insight: Paul is not saying agree with every government decision. He's saying your engagement with public life should be marked by something recognizable as different. Submission, readiness to do good, and a peaceable presence are not just civic virtues — they are missionary strategies. The way you navigate public life either opens doors for the gospel or closes them.\n\nThe word for peaceable in Greek is amachous — literally non-combative. Paul doesn't say malign no one except your political opponents. He says no one. This is not weakness. It is the confidence of people who know how the story ends and don't need to win every argument to prove it.",
|
||||
"keywords": [
|
||||
"politics",
|
||||
"government",
|
||||
"Christians",
|
||||
"public life",
|
||||
"civic",
|
||||
"titus 3:1",
|
||||
"titus 3:2",
|
||||
"submit",
|
||||
"peaceable",
|
||||
"gentle",
|
||||
"social media",
|
||||
"culture",
|
||||
"episode 10"
|
||||
],
|
||||
"createdAt": "2026-04-13T00:00:00.000Z",
|
||||
"updatedAt": "2026-04-13T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
[
|
||||
{ "query": "who was titus", "expectedEntryId": "cb_who_is_titus_001", "expectedTopK": 3 },
|
||||
{ "query": "who is paul", "expectedEntryId": "881279c7-3ca4-436d-a584-19cc0a5a9bf3", "expectedTopK": 3 },
|
||||
{ "query": "tell me about paul in titus", "expectedEntryId": "881279c7-3ca4-436d-a584-19cc0a5a9bf3", "expectedTopK": 4 },
|
||||
{ "query": "who was saul before his conversion", "expectedEntryId": "167fa083-5438-44a5-ac72-6dd379e937f5", "expectedTopK": 4 },
|
||||
{ "query": "who was titus and why was he in crete", "expectedEntryId": "cb_who_is_titus_001", "expectedTopK": 12 },
|
||||
{ "query": "what bible translation does nate use", "expectedEntryId": "cb_001", "expectedTopK": 1 },
|
||||
{ "query": "does the podcast use bsb", "expectedEntryId": "cb_001", "expectedTopK": 2 },
|
||||
{ "query": "which bible version do you teach from", "expectedEntryId": "cb_001", "expectedTopK": 2 },
|
||||
{ "query": "how can i stay consistent with daily bible reading", "expectedEntryId": "cb_003", "expectedTopK": 2 },
|
||||
{ "query": "help me build a daily reading habit", "expectedEntryId": "cb_003", "expectedTopK": 3 },
|
||||
{ "query": "i keep missing devotion time what should i do", "expectedEntryId": "cb_003", "expectedTopK": 4 },
|
||||
{ "query": "how should christians engage with politics", "expectedEntryId": "cb_christians_politics_001", "expectedTopK": 2 },
|
||||
{ "query": "what does titus say about public life", "expectedEntryId": "cb_christians_politics_001", "expectedTopK": 3 },
|
||||
{ "query": "how can i be peaceable online", "expectedEntryId": "cb_christians_politics_001", "expectedTopK": 4 },
|
||||
{ "query": "give me a summary of titus 3:4-7", "expectedEntryId": "cb_titus347_summary_001", "expectedTopK": 1 },
|
||||
{ "query": "summarize titus 3 4 through 7", "expectedEntryId": "cb_titus347_summary_001", "expectedTopK": 12 },
|
||||
{ "query": "what does titus 3:4-7 teach about salvation", "expectedEntryId": "b68c5659-cc7e-42d2-ab24-a623b7404058", "expectedTopK": 2 },
|
||||
{ "query": "what is the blessed hope", "expectedEntryId": "cb_blessed_hope_001", "expectedTopK": 2 },
|
||||
{ "query": "define blessed hope from titus 2:13", "expectedEntryId": "cb_blessed_hope_001", "expectedTopK": 2 },
|
||||
{ "query": "what does makaria elpis mean", "expectedEntryId": "cb_blessed_hope_001", "expectedTopK": 3 },
|
||||
{ "query": "what does grace train us to do", "expectedEntryId": "cb_grace_trains_001", "expectedTopK": 2 },
|
||||
{ "query": "how does grace teach us to say no to ungodliness", "expectedEntryId": "cb_grace_trains_001", "expectedTopK": 3 },
|
||||
{ "query": "what is paideuo in titus 2", "expectedEntryId": "6be7a644-9276-4797-a7bb-1422b170846c", "expectedTopK": 12 },
|
||||
{ "query": "how do i submit a question to nate", "expectedEntryId": "cb_007", "expectedTopK": 1 },
|
||||
{ "query": "where can i send bible questions", "expectedEntryId": "cb_007", "expectedTopK": 12 },
|
||||
{ "query": "how do i contact nate", "expectedEntryId": "cb_007", "expectedTopK": 2 },
|
||||
{ "query": "what does titus 1 teach about church leadership", "expectedEntryId": "cb_leadership_titus1_001", "expectedTopK": 2 },
|
||||
{ "query": "elder qualifications in titus 1", "expectedEntryId": "cb_leadership_titus1_001", "expectedTopK": 12 },
|
||||
{ "query": "what should an overseer be like", "expectedEntryId": "cb_leadership_titus1_001", "expectedTopK": 3 },
|
||||
{ "query": "what did paul say about elders", "expectedEntryId": "e0ad268f-2826-41a5-b078-90f90aeda21b", "expectedTopK": 4 },
|
||||
{ "query": "episode on faithful leaders", "expectedEntryId": "e0ad268f-2826-41a5-b078-90f90aeda21b", "expectedTopK": 3 },
|
||||
{ "query": "titus 1:5-9 overview", "expectedEntryId": "e0ad268f-2826-41a5-b078-90f90aeda21b", "expectedTopK": 12 },
|
||||
{ "query": "what are false teachers doing in titus", "expectedEntryId": "56626549-7e1e-4564-8c79-2d21b49eb911", "expectedTopK": 3 },
|
||||
{ "query": "what is the circumcision group in titus", "expectedEntryId": "56626549-7e1e-4564-8c79-2d21b49eb911", "expectedTopK": 3 },
|
||||
{ "query": "empty talk and deception in titus 1", "expectedEntryId": "56626549-7e1e-4564-8c79-2d21b49eb911", "expectedTopK": 4 },
|
||||
{ "query": "what does it mean to deny god by your works", "expectedEntryId": "cb_deny_works_001", "expectedTopK": 2 },
|
||||
{ "query": "they profess to know god but deny him by actions", "expectedEntryId": "cb_deny_works_001", "expectedTopK": 3 },
|
||||
{ "query": "detestable disobedient unfit meaning", "expectedEntryId": "cb_deny_works_001", "expectedTopK": 4 },
|
||||
{ "query": "episode about grace training us", "expectedEntryId": "6be7a644-9276-4797-a7bb-1422b170846c", "expectedTopK": 2 },
|
||||
{ "query": "titus 2:11-12 episode", "expectedEntryId": "6be7a644-9276-4797-a7bb-1422b170846c", "expectedTopK": 12 },
|
||||
{ "query": "how should i share faith with skeptical family", "expectedEntryId": "cb_faith_family_001", "expectedTopK": 2 },
|
||||
{ "query": "evangelize skeptical relatives", "expectedEntryId": "cb_faith_family_001", "expectedTopK": 4 },
|
||||
{ "query": "where can i listen to the podcast", "expectedEntryId": "cb_006", "expectedTopK": 2 },
|
||||
{ "query": "how do i find verse by verse with nate on apple podcasts", "expectedEntryId": "cb_006", "expectedTopK": 3 },
|
||||
{ "query": "what is verse by verse with nate podcast", "expectedEntryId": "cb_005", "expectedTopK": 2 },
|
||||
{ "query": "tell me about the podcast", "expectedEntryId": "cb_005", "expectedTopK": 2 },
|
||||
{ "query": "how do i approach a difficult passage", "expectedEntryId": "cb_002", "expectedTopK": 2 },
|
||||
{ "query": "what should i do with confusing bible verses", "expectedEntryId": "cb_002", "expectedTopK": 3 },
|
||||
{ "query": "prayer and quiet time advice", "expectedEntryId": "cb_008", "expectedTopK": 2 },
|
||||
{ "query": "how to start a quiet time", "expectedEntryId": "cb_008", "expectedTopK": 3 },
|
||||
{ "query": "what is episode 9 about", "expectedEntryId": "4e6ec41f-b083-45b9-9581-0dc91f092f66", "expectedTopK": 12 },
|
||||
{ "query": "living between two appearings", "expectedEntryId": "4e6ec41f-b083-45b9-9581-0dc91f092f66", "expectedTopK": 2 },
|
||||
{ "query": "what is episode 12", "expectedEntryId": "b68c5659-cc7e-42d2-ab24-a623b7404058", "expectedTopK": 2 },
|
||||
{ "query": "the gospel in one paragraph", "expectedEntryId": "b68c5659-cc7e-42d2-ab24-a623b7404058", "expectedTopK": 2 },
|
||||
{ "query": "what is episode 14 about", "expectedEntryId": "14ced988-1382-4c8d-a29e-b0f531580af1", "expectedTopK": 3 },
|
||||
{ "query": "grace where it starts and where it ends", "expectedEntryId": "14ced988-1382-4c8d-a29e-b0f531580af1", "expectedTopK": 3 }
|
||||
]
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"Living Between Two Appearings": {
|
||||
"total": 1,
|
||||
"byDay": {
|
||||
"2026-07-22": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,681 +0,0 @@
|
||||
{
|
||||
"checklist": {
|
||||
"tasks": [
|
||||
{
|
||||
"id": "verify_script",
|
||||
"label": "Verify Script",
|
||||
"phase": "pre"
|
||||
},
|
||||
{
|
||||
"id": "read_script",
|
||||
"label": "Read Script",
|
||||
"phase": "pre"
|
||||
},
|
||||
{
|
||||
"id": "record",
|
||||
"label": "Record",
|
||||
"phase": "pre"
|
||||
},
|
||||
{
|
||||
"id": "edit",
|
||||
"label": "Edit",
|
||||
"phase": "pre"
|
||||
},
|
||||
{
|
||||
"id": "mix",
|
||||
"label": "Mix",
|
||||
"phase": "pre"
|
||||
},
|
||||
{
|
||||
"id": "video_script",
|
||||
"label": "Run Video Conversion Script",
|
||||
"phase": "pre"
|
||||
},
|
||||
{
|
||||
"id": "post_spotify",
|
||||
"label": "Post on Spotify",
|
||||
"phase": "pre"
|
||||
},
|
||||
{
|
||||
"id": "update_website",
|
||||
"label": "Update Website",
|
||||
"phase": "post"
|
||||
},
|
||||
{
|
||||
"id": "send_email",
|
||||
"label": "Send Email",
|
||||
"phase": "post"
|
||||
}
|
||||
],
|
||||
"episodes": [
|
||||
{
|
||||
"id": "titus-11",
|
||||
"series": "Titus",
|
||||
"episodeNumber": 11,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "titus-12",
|
||||
"series": "Titus",
|
||||
"episodeNumber": 12,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "titus-13",
|
||||
"series": "Titus",
|
||||
"episodeNumber": 13,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "titus-14",
|
||||
"series": "Titus",
|
||||
"episodeNumber": 14,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "titus-15",
|
||||
"series": "Titus",
|
||||
"episodeNumber": 15,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-1",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 1,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-2",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 2,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-3",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 3,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-4",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 4,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-5",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 5,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-6",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 6,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-7",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 7,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-8",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 8,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-9",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 9,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-10",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 10,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-11",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 11,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-12",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 12,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-13",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 13,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-14",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 14,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-15",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 15,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-16",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 16,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-17",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 17,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-18",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 18,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-19",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 19,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-20",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 20,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-21",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 21,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-22",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 22,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-23",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 23,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-24",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 24,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-25",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 25,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-26",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 26,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "colossians-27",
|
||||
"series": "Colossians",
|
||||
"episodeNumber": 27,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "episode-mpb66z1c",
|
||||
"series": "Titus -10",
|
||||
"episodeNumber": 0,
|
||||
"title": "",
|
||||
"datePublished": "",
|
||||
"expanded": false,
|
||||
"tasks": {
|
||||
"verify_script": false,
|
||||
"read_script": false,
|
||||
"record": false,
|
||||
"edit": false,
|
||||
"mix": false,
|
||||
"video_script": false,
|
||||
"post_spotify": false,
|
||||
"update_website": false,
|
||||
"send_email": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"updatedAt": "2026-05-18T12:18:35.130Z"
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"id": "cb_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What Bible translation do you use?",
|
||||
"answer": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.",
|
||||
"isApproved": true,
|
||||
"topic": "Bible Study",
|
||||
"answeredAt": "2026-05-07T13:36:16.859Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_002",
|
||||
"firstName": "Nate",
|
||||
"question": "How do you approach a difficult or confusing Bible passage?",
|
||||
"answer": "I always start by reading the surrounding context — a confusing verse often makes perfect sense once you zoom out a chapter or two. Then I look at who the author was, who they were writing to, and what was happening historically. A good study Bible or a commentary like Matthew Henry's can be a huge help. Most importantly, pray for understanding before you dive in. The Holy Spirit is your best guide.",
|
||||
"isApproved": true,
|
||||
"topic": "Bible Study"
|
||||
},
|
||||
{
|
||||
"id": "cb_003",
|
||||
"firstName": "Nate",
|
||||
"question": "How do I stay consistent with daily Bible reading?",
|
||||
"answer": "Start small and protect that time like an appointment. Even 10 minutes in the morning before checking your phone can be transformative over a year. A reading plan helps a lot — there are great ones on apps like YouVersion that break the whole Bible into daily chunks so you never have to decide what to read. And give yourself grace on the days you miss; guilt is not a good motivator. Just pick back up where you left off.",
|
||||
"isApproved": true,
|
||||
"topic": "Bible Study"
|
||||
},
|
||||
{
|
||||
"id": "cb_007",
|
||||
"firstName": "Nate",
|
||||
"question": "How do I submit a question to Nate?",
|
||||
"answer": "You can submit a question using the contact form at the bottom of this page. Select 'Bible Question' as the message type. Nate reads every question personally and answers the best ones on the site's Q&A section or in a future episode. There's no guarantee every question gets a public response, but all are read and appreciated!",
|
||||
"isApproved": true,
|
||||
"topic": "Podcast"
|
||||
},
|
||||
{
|
||||
"id": "cb_faith_family_001",
|
||||
"firstName": "Nate",
|
||||
"question": "How can I share my faith with skeptical family?",
|
||||
"answer": "Lead with relationship, not argument. Skeptical family members usually don't need more information — they need to see the faith lived out genuinely. Consistency, love, and patience over time speaks louder than any debate win.\n\nPray specifically and persistently. This is the most underrated strategy. God moves hearts in ways arguments never can.",
|
||||
"isApproved": true,
|
||||
"topic": "Faith & Life"
|
||||
},
|
||||
{
|
||||
"id": "cb_blessed_hope_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What is the blessed hope?",
|
||||
"answer": "The blessed hope is a phrase from Titus 2:13 — \"as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ.\" In Episode 9, Nate breaks down the Greek: makaria elpis. Makaria is the same word used in the Beatitudes — a divinely favored condition. And elpis in the New Testament is not wishful thinking. Biblical hope is a confident expectation of something certain. Not a wish. An anchor.\n\nThe blessed hope is simply this: Jesus is coming back. Not maybe. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_who_is_titus_001",
|
||||
"firstName": "Nate",
|
||||
"question": "Who was Titus?",
|
||||
"answer": "Titus was a Gentile — not Jewish — who came to faith through Paul's ministry. Paul calls him \"my true child in our common faith\" (Titus 1:4). He was one of Paul's closest and most trusted co-workers. In 2 Corinthians 7, Paul describes being comforted by Titus arriving during a difficult season. In chapter 8, Titus helped organize a relief offering for believers in Jerusalem. In Galatians 2, he appears as proof that the gospel is for everyone.\n\nPaul left Titus on the island of Crete with a difficult assignment: appoint elders, correct false teaching, and help a young church find its footing in a challenging culture. The letter of Titus is essentially his briefing.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_deny_works_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What does it mean to deny God by your works?",
|
||||
"answer": "Titus 1:16 says: \"They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed.\"\n\nIn Episode 6, Nate explains this using three precise words Paul chose. Detestable — morally repulsive before God, regardless of religious appearance. Disobedient — not persuadable, resistant to truth even when confronted with it. Unfit — a Greek metalworking term for metal tested and found counterfeit. Religious on the surface, but no real substance underneath.\n\nThe warning cuts both ways: a person can know the right words, attend the right services, hold the right opinions — and still live in a way that functionally says God doesn't matter. Theology and life are inseparable. What you actually believe will show itself in how you actually live.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_grace_trains_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What does grace actually train us to do?",
|
||||
"answer": "Titus 2:11-12 says grace \"instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives.\"\n\nIn Episode 8, Nate points out that the Greek word for instructs is paideuō — the education of a child. Not classroom instruction but formation. Grace is the subject doing the teaching.\n\nGrace teaches two things: what to say no to (ungodliness and worldly passions) and what to say yes to — sensible (an ordered mind), upright (right in relationship with others), and godly (God genuinely at the center). Inward, outward, and upward.\n\nThe key insight: the pathway to saying no to sin is going deeper into grace, not trying harder. Grace properly understood reorients your desires — it doesn't just change the rules, it changes what you want. Grace is not a hall pass. It's a school you attend every day.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_leadership_titus1_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What does Titus 1 teach about church leadership?",
|
||||
"answer": "Paul's first instruction in Titus was to appoint elders — because a church without mature, qualified leadership is vulnerable. From Episode 4 (Titus 1:5-9):\n\nThe elder's home is exhibit A. Before looking at preaching or public presence, look at the household. If a man cannot lead his own family well, there's no reason to believe he'll lead God's family well. Integrity can't be compartmentalized.\n\nPaul gives five things an elder must NOT be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy. And six things he must BE: hospitable, a lover of good, self-controlled, upright, holy, and disciplined in sound doctrine.\n\nThis is not a job description for a CEO. Nate frames it as a picture of Christlike character — authority that flows from below, from a posture of submission and service, not dominance.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_titus347_summary_001",
|
||||
"firstName": "Nate",
|
||||
"question": "Give me a summary of Titus 3:4-7",
|
||||
"answer": "Titus 3:4-7 is what Nate calls 'the gospel in one paragraph' — one of the most concentrated summaries of salvation anywhere in Paul's letters. Episode 12 breaks it down:\n\nAll three persons of the Trinity are present: God the Father is the initiating motivation — He is kind and full of love toward humanity. The Holy Spirit is the agent of new birth and ongoing renewal. Jesus Christ is the one through whom the Spirit is poured out, and verse 7 calls him 'our Savior.'\n\nThe hinge of the passage is verse 5: He saved us not by the righteous deeds we had done, but according to His mercy. Not partially. Not mostly. The basis is His mercy, not our merit — which means salvation rests on something that cannot change.\n\nVerse 7 gives us two results: justified (declared righteous in God's courtroom) and heirs (members of the family with a guaranteed inheritance). You are not just legally acquitted — you belong.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_christians_politics_001",
|
||||
"firstName": "Nate",
|
||||
"question": "How should Christians engage with politics and public life?",
|
||||
"answer": "Titus 3:1-2 is Paul's answer, covered in Episode 10. He tells Titus to remind believers to submit to rulers and authorities, be obedient, be ready for every good work, malign no one, and be peaceable, gentle, and considerate to everyone.\n\nNate's key insight: Paul is not saying agree with every government decision. He's saying your engagement with public life should be marked by something recognizable as different. Submission, readiness to do good, and a peaceable presence are not just civic virtues — they are missionary strategies. The way you navigate public life either opens doors for the gospel or closes them.\n\nThe word for peaceable in Greek is amachous — literally non-combative. Paul doesn't say malign no one except your political opponents. He says no one. This is not weakness. It is the confidence of people who know how the story ends and don't need to win every argument to prove it.",
|
||||
"isApproved": true,
|
||||
"topic": "Faith & Life"
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-05-07T13:36:16.859Z"
|
||||
}
|
||||
@@ -1,102 +1,98 @@
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"id": "cb_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What Bible translation do you use?",
|
||||
"answer": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.",
|
||||
"isApproved": true,
|
||||
"topic": "Bible Study",
|
||||
"answeredAt": "2026-05-07T13:36:16.859Z"
|
||||
},
|
||||
{
|
||||
"id": "cb_002",
|
||||
"firstName": "Nate",
|
||||
"question": "How do you approach a difficult or confusing Bible passage?",
|
||||
"answer": "I always start by reading the surrounding context — a confusing verse often makes perfect sense once you zoom out a chapter or two. Then I look at who the author was, who they were writing to, and what was happening historically. A good study Bible or a commentary like Matthew Henry's can be a huge help. Most importantly, pray for understanding before you dive in. The Holy Spirit is your best guide.",
|
||||
"isApproved": true,
|
||||
"topic": "Bible Study"
|
||||
},
|
||||
{
|
||||
"id": "cb_003",
|
||||
"firstName": "Nate",
|
||||
"question": "How do I stay consistent with daily Bible reading?",
|
||||
"answer": "Start small and protect that time like an appointment. Even 10 minutes in the morning before checking your phone can be transformative over a year. A reading plan helps a lot — there are great ones on apps like YouVersion that break the whole Bible into daily chunks so you never have to decide what to read. And give yourself grace on the days you miss; guilt is not a good motivator. Just pick back up where you left off.",
|
||||
"isApproved": true,
|
||||
"topic": "Bible Study"
|
||||
},
|
||||
{
|
||||
"id": "cb_007",
|
||||
"firstName": "Nate",
|
||||
"question": "How do I submit a question to Nate?",
|
||||
"answer": "You can submit a question using the contact form at the bottom of this page. Select 'Bible Question' as the message type. Nate reads every question personally and answers the best ones on the site's Q&A section or in a future episode. There's no guarantee every question gets a public response, but all are read and appreciated!",
|
||||
"isApproved": true,
|
||||
"topic": "Podcast"
|
||||
},
|
||||
{
|
||||
"id": "cb_faith_family_001",
|
||||
"firstName": "Nate",
|
||||
"question": "How can I share my faith with skeptical family?",
|
||||
"answer": "Lead with relationship, not argument. Skeptical family members usually don't need more information — they need to see the faith lived out genuinely. Consistency, love, and patience over time speaks louder than any debate win.\n\nPray specifically and persistently. This is the most underrated strategy. God moves hearts in ways arguments never can.",
|
||||
"isApproved": true,
|
||||
"topic": "Faith & Life"
|
||||
},
|
||||
{
|
||||
"id": "cb_blessed_hope_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What is the blessed hope?",
|
||||
"answer": "The blessed hope is a phrase from Titus 2:13 — \"as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ.\" In Episode 9, Nate breaks down the Greek: makaria elpis. Makaria is the same word used in the Beatitudes — a divinely favored condition. And elpis in the New Testament is not wishful thinking. Biblical hope is a confident expectation of something certain. Not a wish. An anchor.\n\nThe blessed hope is simply this: Jesus is coming back. Not maybe. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_who_is_titus_001",
|
||||
"firstName": "Nate",
|
||||
"question": "Who was Titus?",
|
||||
"answer": "Titus was a Gentile — not Jewish — who came to faith through Paul's ministry. Paul calls him \"my true child in our common faith\" (Titus 1:4). He was one of Paul's closest and most trusted co-workers. In 2 Corinthians 7, Paul describes being comforted by Titus arriving during a difficult season. In chapter 8, Titus helped organize a relief offering for believers in Jerusalem. In Galatians 2, he appears as proof that the gospel is for everyone.\n\nPaul left Titus on the island of Crete with a difficult assignment: appoint elders, correct false teaching, and help a young church find its footing in a challenging culture. The letter of Titus is essentially his briefing.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_deny_works_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What does it mean to deny God by your works?",
|
||||
"answer": "Titus 1:16 says: \"They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed.\"\n\nIn Episode 6, Nate explains this using three precise words Paul chose. Detestable — morally repulsive before God, regardless of religious appearance. Disobedient — not persuadable, resistant to truth even when confronted with it. Unfit — a Greek metalworking term for metal tested and found counterfeit. Religious on the surface, but no real substance underneath.\n\nThe warning cuts both ways: a person can know the right words, attend the right services, hold the right opinions — and still live in a way that functionally says God doesn't matter. Theology and life are inseparable. What you actually believe will show itself in how you actually live.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_grace_trains_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What does grace actually train us to do?",
|
||||
"answer": "Titus 2:11-12 says grace \"instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives.\"\n\nIn Episode 8, Nate points out that the Greek word for instructs is paideuō — the education of a child. Not classroom instruction but formation. Grace is the subject doing the teaching.\n\nGrace teaches two things: what to say no to (ungodliness and worldly passions) and what to say yes to — sensible (an ordered mind), upright (right in relationship with others), and godly (God genuinely at the center). Inward, outward, and upward.\n\nThe key insight: the pathway to saying no to sin is going deeper into grace, not trying harder. Grace properly understood reorients your desires — it doesn't just change the rules, it changes what you want. Grace is not a hall pass. It's a school you attend every day.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_leadership_titus1_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What does Titus 1 teach about church leadership?",
|
||||
"answer": "Paul's first instruction in Titus was to appoint elders — because a church without mature, qualified leadership is vulnerable. From Episode 4 (Titus 1:5-9):\n\nThe elder's home is exhibit A. Before looking at preaching or public presence, look at the household. If a man cannot lead his own family well, there's no reason to believe he'll lead God's family well. Integrity can't be compartmentalized.\n\nPaul gives five things an elder must NOT be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy. And six things he must BE: hospitable, a lover of good, self-controlled, upright, holy, and disciplined in sound doctrine.\n\nThis is not a job description for a CEO. Nate frames it as a picture of Christlike character — authority that flows from below, from a posture of submission and service, not dominance.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_titus347_summary_001",
|
||||
"firstName": "Nate",
|
||||
"question": "Give me a summary of Titus 3:4-7",
|
||||
"answer": "Titus 3:4-7 is what Nate calls 'the gospel in one paragraph' — one of the most concentrated summaries of salvation anywhere in Paul's letters. Episode 12 breaks it down:\n\nAll three persons of the Trinity are present: God the Father is the initiating motivation — He is kind and full of love toward humanity. The Holy Spirit is the agent of new birth and ongoing renewal. Jesus Christ is the one through whom the Spirit is poured out, and verse 7 calls him 'our Savior.'\n\nThe hinge of the passage is verse 5: He saved us not by the righteous deeds we had done, but according to His mercy. Not partially. Not mostly. The basis is His mercy, not our merit — which means salvation rests on something that cannot change.\n\nVerse 7 gives us two results: justified (declared righteous in God's courtroom) and heirs (members of the family with a guaranteed inheritance). You are not just legally acquitted — you belong.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_christians_politics_001",
|
||||
"firstName": "Nate",
|
||||
"question": "How should Christians engage with politics and public life?",
|
||||
"answer": "Titus 3:1-2 is Paul's answer, covered in Episode 10. He tells Titus to remind believers to submit to rulers and authorities, be obedient, be ready for every good work, malign no one, and be peaceable, gentle, and considerate to everyone.\n\nNate's key insight: Paul is not saying agree with every government decision. He's saying your engagement with public life should be marked by something recognizable as different. Submission, readiness to do good, and a peaceable presence are not just civic virtues — they are missionary strategies. The way you navigate public life either opens doors for the gospel or closes them.\n\nThe word for peaceable in Greek is amachous — literally non-combative. Paul doesn't say malign no one except your political opponents. He says no one. This is not weakness. It is the confidence of people who know how the story ends and don't need to win every argument to prove it.",
|
||||
"isApproved": true,
|
||||
"topic": "Faith & Life"
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-06-03T14:17:48.087Z"
|
||||
}
|
||||
[
|
||||
{
|
||||
"id": "cb_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What Bible translation do you use?",
|
||||
"answer": "In the podcast, I primarily teach from the BSB (Berean Standard Bible). I may compare other translations at times, but BSB is my main teaching text in the Verse by Verse episodes.",
|
||||
"isApproved": true,
|
||||
"topic": "Bible Study"
|
||||
},
|
||||
{
|
||||
"id": "cb_002",
|
||||
"firstName": "Nate",
|
||||
"question": "How do you approach a difficult or confusing Bible passage?",
|
||||
"answer": "I always start by reading the surrounding context — a confusing verse often makes perfect sense once you zoom out a chapter or two. Then I look at who the author was, who they were writing to, and what was happening historically. A good study Bible or a commentary like Matthew Henry's can be a huge help. Most importantly, pray for understanding before you dive in. The Holy Spirit is your best guide.",
|
||||
"isApproved": true,
|
||||
"topic": "Bible Study"
|
||||
},
|
||||
{
|
||||
"id": "cb_003",
|
||||
"firstName": "Nate",
|
||||
"question": "How do I stay consistent with daily Bible reading?",
|
||||
"answer": "Start small and protect that time like an appointment. Even 10 minutes in the morning before checking your phone can be transformative over a year. A reading plan helps a lot — there are great ones on apps like YouVersion that break the whole Bible into daily chunks so you never have to decide what to read. And give yourself grace on the days you miss; guilt is not a good motivator. Just pick back up where you left off.",
|
||||
"isApproved": true,
|
||||
"topic": "Bible Study"
|
||||
},
|
||||
{
|
||||
"id": "cb_007",
|
||||
"firstName": "Nate",
|
||||
"question": "How do I submit a question to Nate?",
|
||||
"answer": "You can submit a question using the contact form at the bottom of this page. Select 'Bible Question' as the message type. Nate reads every question personally and answers the best ones on the site's Q&A section or in a future episode. There's no guarantee every question gets a public response, but all are read and appreciated!",
|
||||
"isApproved": true,
|
||||
"topic": "Podcast"
|
||||
},
|
||||
{
|
||||
"id": "cb_faith_family_001",
|
||||
"firstName": "Nate",
|
||||
"question": "How can I share my faith with skeptical family?",
|
||||
"answer": "Lead with relationship, not argument. Skeptical family members usually don't need more information — they need to see the faith lived out genuinely. Consistency, love, and patience over time speaks louder than any debate win.\n\nPray specifically and persistently. This is the most underrated strategy. God moves hearts in ways arguments never can.",
|
||||
"isApproved": true,
|
||||
"topic": "Faith & Life"
|
||||
},
|
||||
{
|
||||
"id": "cb_blessed_hope_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What is the blessed hope?",
|
||||
"answer": "The blessed hope is a phrase from Titus 2:13 — \"as we await the blessed hope and glorious appearance of our great God and Savior Jesus Christ.\" In Episode 9, Nate breaks down the Greek: makaria elpis. Makaria is the same word used in the Beatitudes — a divinely favored condition. And elpis in the New Testament is not wishful thinking. Biblical hope is a confident expectation of something certain. Not a wish. An anchor.\n\nThe blessed hope is simply this: Jesus is coming back. Not maybe. He is coming back. And the person who really believes that is in a blessed condition even now, living toward a certainty that changes how everything feels today.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_who_is_titus_001",
|
||||
"firstName": "Nate",
|
||||
"question": "Who was Titus?",
|
||||
"answer": "Titus was a Gentile — not Jewish — who came to faith through Paul's ministry. Paul calls him \"my true child in our common faith\" (Titus 1:4). He was one of Paul's closest and most trusted co-workers. In 2 Corinthians 7, Paul describes being comforted by Titus arriving during a difficult season. In chapter 8, Titus helped organize a relief offering for believers in Jerusalem. In Galatians 2, he appears as proof that the gospel is for everyone.\n\nPaul left Titus on the island of Crete with a difficult assignment: appoint elders, correct false teaching, and help a young church find its footing in a challenging culture. The letter of Titus is essentially his briefing.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_deny_works_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What does it mean to deny God by your works?",
|
||||
"answer": "Titus 1:16 says: \"They profess to know God, but by their actions they deny Him. They are detestable, disobedient, and unfit for any good deed.\"\n\nIn Episode 6, Nate explains this using three precise words Paul chose. Detestable — morally repulsive before God, regardless of religious appearance. Disobedient — not persuadable, resistant to truth even when confronted with it. Unfit — a Greek metalworking term for metal tested and found counterfeit. Religious on the surface, but no real substance underneath.\n\nThe warning cuts both ways: a person can know the right words, attend the right services, hold the right opinions — and still live in a way that functionally says God doesn't matter. Theology and life are inseparable. What you actually believe will show itself in how you actually live.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_grace_trains_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What does grace actually train us to do?",
|
||||
"answer": "Titus 2:11-12 says grace \"instructs us to renounce ungodliness and worldly passions, and to live sensible, upright, and godly lives.\"\n\nIn Episode 8, Nate points out that the Greek word for instructs is paideuō — the education of a child. Not classroom instruction but formation. Grace is the subject doing the teaching.\n\nGrace teaches two things: what to say no to (ungodliness and worldly passions) and what to say yes to — sensible (an ordered mind), upright (right in relationship with others), and godly (God genuinely at the center). Inward, outward, and upward.\n\nThe key insight: the pathway to saying no to sin is going deeper into grace, not trying harder. Grace properly understood reorients your desires — it doesn't just change the rules, it changes what you want. Grace is not a hall pass. It's a school you attend every day.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_leadership_titus1_001",
|
||||
"firstName": "Nate",
|
||||
"question": "What does Titus 1 teach about church leadership?",
|
||||
"answer": "Paul's first instruction in Titus was to appoint elders — because a church without mature, qualified leadership is vulnerable. From Episode 4 (Titus 1:5-9):\n\nThe elder's home is exhibit A. Before looking at preaching or public presence, look at the household. If a man cannot lead his own family well, there's no reason to believe he'll lead God's family well. Integrity can't be compartmentalized.\n\nPaul gives five things an elder must NOT be: not self-absorbed, not quick-tempered, not given to drunkenness, not violent, not greedy. And six things he must BE: hospitable, a lover of good, self-controlled, upright, holy, and disciplined in sound doctrine.\n\nThis is not a job description for a CEO. Nate frames it as a picture of Christlike character — authority that flows from below, from a posture of submission and service, not dominance.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_titus347_summary_001",
|
||||
"firstName": "Nate",
|
||||
"question": "Give me a summary of Titus 3:4-7",
|
||||
"answer": "Titus 3:4-7 is what Nate calls 'the gospel in one paragraph' — one of the most concentrated summaries of salvation anywhere in Paul's letters. Episode 12 breaks it down:\n\nAll three persons of the Trinity are present: God the Father is the initiating motivation — He is kind and full of love toward humanity. The Holy Spirit is the agent of new birth and ongoing renewal. Jesus Christ is the one through whom the Spirit is poured out, and verse 7 calls him 'our Savior.'\n\nThe hinge of the passage is verse 5: He saved us not by the righteous deeds we had done, but according to His mercy. Not partially. Not mostly. The basis is His mercy, not our merit — which means salvation rests on something that cannot change.\n\nVerse 7 gives us two results: justified (declared righteous in God's courtroom) and heirs (members of the family with a guaranteed inheritance). You are not just legally acquitted — you belong.",
|
||||
"isApproved": true,
|
||||
"topic": "Titus"
|
||||
},
|
||||
{
|
||||
"id": "cb_christians_politics_001",
|
||||
"firstName": "Nate",
|
||||
"question": "How should Christians engage with politics and public life?",
|
||||
"answer": "Titus 3:1-2 is Paul's answer, covered in Episode 10. He tells Titus to remind believers to submit to rulers and authorities, be obedient, be ready for every good work, malign no one, and be peaceable, gentle, and considerate to everyone.\n\nNate's key insight: Paul is not saying agree with every government decision. He's saying your engagement with public life should be marked by something recognizable as different. Submission, readiness to do good, and a peaceable presence are not just civic virtues — they are missionary strategies. The way you navigate public life either opens doors for the gospel or closes them.\n\nThe word for peaceable in Greek is amachous — literally non-combative. Paul doesn't say malign no one except your political opponents. He says no one. This is not weakness. It is the confidence of people who know how the story ends and don't need to win every argument to prove it.",
|
||||
"isApproved": true,
|
||||
"topic": "Faith & Life"
|
||||
}
|
||||
]
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"posts": [
|
||||
{
|
||||
"id": "fd6f2d9c-8abc-43b2-8e93-c58c0cb1ff7b",
|
||||
"studySlug": "colossians",
|
||||
"sectionId": "",
|
||||
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
|
||||
"authorName": "Test Student",
|
||||
"message": "reading",
|
||||
"createdAt": "2026-06-02T17:41:48.684Z",
|
||||
"replies": []
|
||||
},
|
||||
{
|
||||
"id": "001f2545-e1ee-4748-902b-5da5c47554bb",
|
||||
"studySlug": "colossians",
|
||||
"sectionId": "",
|
||||
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
|
||||
"authorName": "Test Student",
|
||||
"message": "sool",
|
||||
"createdAt": "2026-06-02T17:41:41.608Z",
|
||||
"replies": []
|
||||
},
|
||||
{
|
||||
"id": "c40e4c10-47a3-42e8-88bd-78116af5babe",
|
||||
"studySlug": "colossians",
|
||||
"sectionId": "",
|
||||
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
|
||||
"authorName": "Test Student",
|
||||
"message": "test",
|
||||
"createdAt": "2026-06-02T17:41:16.795Z",
|
||||
"replies": [
|
||||
{
|
||||
"id": "efe0bf3d-66e8-4dad-98c1-2e8c742e3120",
|
||||
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
|
||||
"authorName": "Test Student",
|
||||
"message": "test",
|
||||
"createdAt": "2026-06-02T17:41:25.003Z"
|
||||
},
|
||||
{
|
||||
"id": "1d6c3bd1-5ada-4f98-8ca3-5ed15bd00a68",
|
||||
"authorUserId": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
|
||||
"authorName": "Test Student",
|
||||
"message": "test",
|
||||
"createdAt": "2026-06-02T17:41:34.406Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-06-02T17:41:48.685Z"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"notesByUser": {
|
||||
"e43e7217-be69-49c6-a65d-edd0e7745884": {
|
||||
"1-1-2": "Test"
|
||||
}
|
||||
},
|
||||
"updatedAt": "2026-05-12T15:51:54.402Z"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"colossians--1-1-2": "test....teset"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"1-1-2": "Test"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"byStudy": {
|
||||
"colossians": {
|
||||
"completedSectionIds": [
|
||||
"1-1-2",
|
||||
"1-3-8"
|
||||
],
|
||||
"quizAnswers": {
|
||||
"1-1-2": [
|
||||
"test",
|
||||
"test"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"updatedAt": "2026-06-08T20:30:19.689Z"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"users": {},
|
||||
"updatedAt": "2026-08-10T14:22:31.192Z"
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "9b74971e-bfdc-4e64-a8d7-87cd8f2bb5c4",
|
||||
"username": "student.test@versebyverse.local",
|
||||
"passwordHash": "6500cc2e717d1d646f371dba1ec918d2f2f9fddbb8576c792df69a1f2340dcd9",
|
||||
"displayName": "Student Test",
|
||||
"subscribeNewsletter": false,
|
||||
"studyRemindersEnabled": false,
|
||||
"enrolledStudySlugs": [],
|
||||
"avatarUrl": "",
|
||||
"createdAt": null,
|
||||
"updatedAt": "2026-08-10T12:39:25.413Z",
|
||||
"lastLoginAt": "2026-05-21T15:41:16.259Z",
|
||||
"reengagementSentAt": {},
|
||||
"pendingEmailChange": null,
|
||||
"twoFaMethod": null,
|
||||
"totpSecret": null,
|
||||
"totpVerified": false,
|
||||
"totpEnabledAt": null,
|
||||
"totpRecoveryCodes": [],
|
||||
"birthdayMonth": 7,
|
||||
"birthdayDay": 4,
|
||||
"birthdayAlternateMonth": null,
|
||||
"birthdayAlternateDay": null,
|
||||
"birthdayEmailSentYear": null
|
||||
},
|
||||
{
|
||||
"id": "3307bdd7-a770-4fad-a28e-cf2d0c2a6e34",
|
||||
"username": "test.user@versebyverse.local",
|
||||
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
|
||||
"displayName": "Test Student",
|
||||
"subscribeNewsletter": false,
|
||||
"studyRemindersEnabled": false,
|
||||
"enrolledStudySlugs": [
|
||||
"colossians"
|
||||
],
|
||||
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2288%22%20height%3D%2288%22%3E%3Crect%20width%3D%22100%25%22%20height%3D%22100%25%22%20rx%3D%2218%22%20ry%3D%2218%22%20fill%3D%22%2311100d%22%2F%3E%3Ctext%20x%3D%2250%25%22%20y%3D%2250%25%22%20dominant-baseline%3D%22middle%22%20text-anchor%3D%22middle%22%20font-size%3D%2256%22%3E%F0%9F%A6%81%3C%2Ftext%3E%3C%2Fsvg%3E",
|
||||
"createdAt": null,
|
||||
"updatedAt": "2026-06-08T20:46:30.804Z",
|
||||
"lastLoginAt": "2026-06-08T20:46:30.804Z",
|
||||
"reengagementSentAt": {},
|
||||
"pendingEmailChange": null,
|
||||
"twoFaMethod": null,
|
||||
"totpSecret": null,
|
||||
"totpVerified": false,
|
||||
"totpEnabledAt": null,
|
||||
"totpRecoveryCodes": [],
|
||||
"totpSecretPending": "FGR7UI5TWA4HB4DURCXEUXATTVVVJDJ5"
|
||||
},
|
||||
{
|
||||
"id": "81c01e32-d94e-4e19-93ea-e5cd7644ea6a",
|
||||
"username": "test.avatar@versebyverse.local",
|
||||
"passwordHash": "fc89481d72dbea9af1d4f054faad48813227fa018da39259272dd27b5e493bb5",
|
||||
"displayName": "Avatar Test",
|
||||
"subscribeNewsletter": false,
|
||||
"studyRemindersEnabled": false,
|
||||
"enrolledStudySlugs": [],
|
||||
"avatarUrl": "data:image/svg+xml;utf8,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" width=\"88\" height=\"88\"%3E%3Crect width=\"100%\" height=\"100%\" rx=\"18\" ry=\"18\" fill=\"%2311100d\"/%3E%3Ctext x=\"50%\" y=\"50%\" dominant-baseline=\"middle\" text-anchor=\"middle\" font-size=\"56\"%3E🐱%3C/text%3E%3C/svg%3E",
|
||||
"createdAt": null,
|
||||
"updatedAt": null,
|
||||
"lastLoginAt": "2026-06-02T17:33:13.669Z",
|
||||
"reengagementSentAt": {},
|
||||
"pendingEmailChange": null,
|
||||
"twoFaMethod": null,
|
||||
"totpSecret": null,
|
||||
"totpVerified": false,
|
||||
"totpEnabledAt": null,
|
||||
"totpRecoveryCodes": []
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-08-10T12:39:25.413Z"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 59 KiB |
@@ -1,13 +1,12 @@
|
||||
services:
|
||||
portfolio:
|
||||
image: git.necloud.us/nate/siteforge:latest
|
||||
image: ghcr.io/nmemmert/siteforge:latest
|
||||
pull_policy: always
|
||||
container_name: siteforge-app
|
||||
ports:
|
||||
- "4173:4173"
|
||||
environment:
|
||||
- PORT=4173
|
||||
- SITEFORGE_DATA_DIR=/app/data
|
||||
- ADMIN_PASSWORD=`generate a random password and set it here`
|
||||
- RESEND_API_KEY=`set your Resend API key here`
|
||||
volumes:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
DATA_DIR=${SITEFORGE_DATA_DIR:-/app/data}
|
||||
DATA_DIR=/app/data
|
||||
SEED_DIR=/app/data-seed
|
||||
|
||||
# Ensure the data directory exists (in case the volume was not mounted)
|
||||
@@ -23,192 +23,4 @@ if [ -d "$SEED_DIR" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# Merge newly added seeded platform links into existing admin content on upgrades
|
||||
# without overwriting user-edited values already present in the live volume.
|
||||
merge_seed_platform_links() {
|
||||
target_file="$1"
|
||||
seed_file="$2"
|
||||
|
||||
[ -f "$target_file" ] || return 0
|
||||
[ -f "$seed_file" ] || return 0
|
||||
|
||||
node - "$target_file" "$seed_file" <<'NODE'
|
||||
const fs = require('fs')
|
||||
|
||||
const [targetFile, seedFile] = process.argv.slice(2)
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
}
|
||||
|
||||
function normalizeLinks(value) {
|
||||
return Array.isArray(value) ? value.filter(item => item && typeof item === 'object') : []
|
||||
}
|
||||
|
||||
const target = readJson(targetFile)
|
||||
const seed = readJson(seedFile)
|
||||
|
||||
if (!target.siteContent || !seed.siteContent) process.exit(0)
|
||||
|
||||
const targetLinks = normalizeLinks(target.siteContent.customLinks)
|
||||
const seedLinks = normalizeLinks(seed.siteContent.customLinks)
|
||||
|
||||
let changed = false
|
||||
|
||||
for (const seedLink of seedLinks) {
|
||||
if (seedLink.placement !== 'platforms' || !seedLink.id || !seedLink.label || !seedLink.url) continue
|
||||
|
||||
const existingIndex = targetLinks.findIndex(link => link.id === seedLink.id)
|
||||
if (existingIndex === -1) {
|
||||
targetLinks.push(seedLink)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = targetLinks[existingIndex]
|
||||
const merged = { ...existing }
|
||||
|
||||
if (!merged.imageUrl && seedLink.imageUrl) {
|
||||
merged.imageUrl = seedLink.imageUrl
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (!merged.placement && seedLink.placement) {
|
||||
merged.placement = seedLink.placement
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (!merged.label && seedLink.label) {
|
||||
merged.label = seedLink.label
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (!merged.url && seedLink.url) {
|
||||
merged.url = seedLink.url
|
||||
changed = true
|
||||
}
|
||||
|
||||
targetLinks[existingIndex] = merged
|
||||
}
|
||||
|
||||
if (!changed) process.exit(0)
|
||||
|
||||
target.siteContent.customLinks = targetLinks
|
||||
fs.writeFileSync(targetFile, `${JSON.stringify(target, null, 2)}\n`)
|
||||
console.log(`[siteforge] Merged seeded platform links into ${targetFile}`)
|
||||
NODE
|
||||
}
|
||||
|
||||
merge_seed_platform_links "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json"
|
||||
merge_seed_platform_links "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json"
|
||||
|
||||
# Backfill missing lesson releasedAt values from seeded defaults on upgrades.
|
||||
# This keeps persistent volumes compatible with release-date access gating.
|
||||
merge_seed_study_release_dates() {
|
||||
target_file="$1"
|
||||
seed_file="$2"
|
||||
|
||||
[ -f "$target_file" ] || return 0
|
||||
[ -f "$seed_file" ] || return 0
|
||||
|
||||
node - "$target_file" "$seed_file" <<'NODE'
|
||||
const fs = require('fs')
|
||||
|
||||
const [targetFile, seedFile] = process.argv.slice(2)
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
}
|
||||
|
||||
function asArray(value) {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function mergeStudyDates(targetStudies, seedStudies) {
|
||||
let changed = false
|
||||
const seedBySlug = new Map()
|
||||
|
||||
for (const seedStudy of asArray(seedStudies)) {
|
||||
if (!seedStudy || typeof seedStudy !== 'object') continue
|
||||
if (typeof seedStudy.slug !== 'string' || !seedStudy.slug) continue
|
||||
seedBySlug.set(seedStudy.slug, seedStudy)
|
||||
}
|
||||
|
||||
for (const targetStudy of asArray(targetStudies)) {
|
||||
if (!targetStudy || typeof targetStudy !== 'object') continue
|
||||
if (typeof targetStudy.slug !== 'string' || !targetStudy.slug) continue
|
||||
|
||||
const seedStudy = seedBySlug.get(targetStudy.slug)
|
||||
if (!seedStudy) continue
|
||||
|
||||
const seedSectionById = new Map()
|
||||
for (const seedSection of asArray(seedStudy.sections)) {
|
||||
if (!seedSection || typeof seedSection !== 'object') continue
|
||||
if (typeof seedSection.id !== 'string' || !seedSection.id) continue
|
||||
seedSectionById.set(seedSection.id, seedSection)
|
||||
}
|
||||
|
||||
for (const targetSection of asArray(targetStudy.sections)) {
|
||||
if (!targetSection || typeof targetSection !== 'object') continue
|
||||
if (typeof targetSection.id !== 'string' || !targetSection.id) continue
|
||||
if (typeof targetSection.releasedAt === 'string' && targetSection.releasedAt.trim()) continue
|
||||
|
||||
const seedSection = seedSectionById.get(targetSection.id)
|
||||
if (!seedSection || typeof seedSection.releasedAt !== 'string' || !seedSection.releasedAt.trim()) continue
|
||||
|
||||
targetSection.releasedAt = seedSection.releasedAt
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
function mergeLegacyColossiansDates(targetSections, seedSections) {
|
||||
let changed = false
|
||||
const seedById = new Map()
|
||||
|
||||
for (const seedSection of asArray(seedSections)) {
|
||||
if (!seedSection || typeof seedSection !== 'object') continue
|
||||
if (typeof seedSection.id !== 'string' || !seedSection.id) continue
|
||||
seedById.set(seedSection.id, seedSection)
|
||||
}
|
||||
|
||||
for (const targetSection of asArray(targetSections)) {
|
||||
if (!targetSection || typeof targetSection !== 'object') continue
|
||||
if (typeof targetSection.id !== 'string' || !targetSection.id) continue
|
||||
if (typeof targetSection.releasedAt === 'string' && targetSection.releasedAt.trim()) continue
|
||||
|
||||
const seedSection = seedById.get(targetSection.id)
|
||||
if (!seedSection || typeof seedSection.releasedAt !== 'string' || !seedSection.releasedAt.trim()) continue
|
||||
|
||||
targetSection.releasedAt = seedSection.releasedAt
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
const target = readJson(targetFile)
|
||||
const seed = readJson(seedFile)
|
||||
|
||||
if (!target.siteContent || !seed.siteContent) process.exit(0)
|
||||
|
||||
const targetContent = target.siteContent
|
||||
const seedContent = seed.siteContent
|
||||
|
||||
const changedStudies = mergeStudyDates(targetContent.studies, seedContent.studies)
|
||||
const changedLegacy = mergeLegacyColossiansDates(targetContent.colossiansStudySections, seedContent.colossiansStudySections)
|
||||
|
||||
if (!changedStudies && !changedLegacy) process.exit(0)
|
||||
|
||||
fs.writeFileSync(targetFile, `${JSON.stringify(target, null, 2)}\n`)
|
||||
console.log(`[siteforge] Backfilled missing releasedAt values in ${targetFile}`)
|
||||
NODE
|
||||
}
|
||||
|
||||
merge_seed_study_release_dates "$DATA_DIR/admin-content.json" "$SEED_DIR/admin-content.json"
|
||||
merge_seed_study_release_dates "$DATA_DIR/admin-content-draft.json" "$SEED_DIR/admin-content-draft.json"
|
||||
|
||||
export SITEFORGE_DATA_DIR="$DATA_DIR"
|
||||
exec node server.js
|
||||
|
||||
@@ -11,17 +11,17 @@
|
||||
<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://versebyversewithnate.us/images/banner.png" />
|
||||
<meta property="og:image" content="https://necloud.us/images/banner.png" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:secure_url" content="https://versebyversewithnate.us/images/banner.png" />
|
||||
<meta property="og:image:secure_url" content="https://necloud.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://versebyversewithnate.us" />
|
||||
<meta property="og:url" content="https://necloud.us" />
|
||||
<meta property="og:site_name" content="Verse by Verse with Nate" />
|
||||
<link rel="canonical" href="https://versebyversewithnate.us" />
|
||||
<link rel="canonical" href="https://necloud.us" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600;1,700&family=Lora:ital,wght@0,400;0,500;0,600;1,400;1,500&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;0,700;1,300;1,400;1,600;1,700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "1.1.41",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -10,37 +10,21 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"start": "node --env-file=.env server.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"chatbot:eval": "node scripts/evaluate-chatbot.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tiptap/react": "^3.27.0",
|
||||
"@tiptap/starter-kit": "^3.27.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"docx": "^9.6.1",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"fuse.js": "^7.4.2",
|
||||
"mammoth": "^1.12.0",
|
||||
"otplib": "^13.4.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"resend": "^6.10.0",
|
||||
"tar": "^7.5.19"
|
||||
"resend": "^6.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
@@ -51,7 +35,6 @@
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
# Finished Books — Episode Playlist Feature
|
||||
|
||||
## Goal
|
||||
Add a proper **Finished Books** section where completed book studies live with a full ordered episode playlist. The Downloads page gets a minor label/copy update (Option A). A single "End Current Series" button in admin handles the full book transition in one step.
|
||||
|
||||
---
|
||||
|
||||
## User Flow (Public Site)
|
||||
|
||||
1. User clicks **Finished Books** in the nav
|
||||
2. Lands on `/finished` — grid of completed book cards (image, title, description)
|
||||
3. Clicks a book → `/finished/:id` — sees all episodes in numbered order, each with a player
|
||||
4. Breadcrumb: `Finished Books → Titus`
|
||||
|
||||
**Episode Highlights** on the Episodes page only ever shows picks from the current book. When a book ends, highlights are cleared and replaced with picks from the new series automatically (see transition workflow below).
|
||||
|
||||
---
|
||||
|
||||
## Book Transition Workflow — "End Current Series" Button
|
||||
|
||||
A single button in admin (lives on the Current Series tab) that does all three transition steps at once:
|
||||
|
||||
### What it does
|
||||
1. **Creates a Finished Book entry** from the current series data (title, image, description, season number) — immediately appears on `/finished`
|
||||
2. **Clears Episode Highlights** — wipes `podcastFeaturedLinks` so the old season's picks are gone
|
||||
3. **Opens a guided form** for the new series — prompts for new series title, image URL, and season number before saving
|
||||
|
||||
### The form flow
|
||||
- User clicks **"End Current Series & Start New Book"**
|
||||
- A confirmation modal appears with a summary: "This will archive [Titus] to Finished Books and clear Episode Highlights."
|
||||
- Below the confirmation, a small form: New Series Title, New Series Image, New Season Number
|
||||
- User fills it in and hits **Confirm**
|
||||
- All three changes save together as one content update
|
||||
|
||||
### What the user still does manually after
|
||||
- Go to Episode Highlights and add 3–5 hand-picked episodes from the new series (the section is now empty and ready)
|
||||
|
||||
---
|
||||
|
||||
## Changes Required
|
||||
|
||||
### 1. `server/routes/episodes.js` — Parse `itunes:season` from RSS
|
||||
One line added to `parseRssItems` alongside the existing `itunes:episode` extraction:
|
||||
```js
|
||||
const season = (/<itunes:season>([\s\S]*?)<\/itunes:season>/.exec(block)?.[1] ?? '').trim()
|
||||
```
|
||||
Include `season` in the returned episode object.
|
||||
|
||||
### 2. `src/content.ts` — New `FinishedBook` type + field on `SiteContent`
|
||||
Clean, purpose-built interface:
|
||||
```ts
|
||||
export interface FinishedBook {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
imageUrl: string
|
||||
season: number
|
||||
}
|
||||
```
|
||||
Add `finishedBooks: FinishedBook[]` to `SiteContent` with default `[]`.
|
||||
|
||||
### 3. `src/App.tsx` — New `useEpisodesForBook` hook
|
||||
Fetches `/api/episodes/all` and filters by season number, sorted ascending:
|
||||
```js
|
||||
const filtered = (data.episodes ?? [])
|
||||
.filter(ep => ep.season === String(book.season))
|
||||
.sort((a, b) => parseInt(a.episode) - parseInt(b.episode))
|
||||
```
|
||||
|
||||
### 4. `src/App.tsx` — Nav: "Episodes" dropdown replacing flat link
|
||||
|
||||
"Episodes" becomes a dropdown parent with two children: **Current Series** (`/episodes`) and **Finished Books** (`/finished`). The flat `<NavLink to="/episodes">` is replaced with a dropdown component. Everything else in the nav stays flat and unchanged.
|
||||
|
||||
**Desktop behavior:**
|
||||
- "Episodes ▾" shows on hover (or focus), revealing a small popover menu below with the two links
|
||||
- Parent label stays gold/active when either child route is active
|
||||
- Implemented with CSS `:hover` + `:focus-within` on a wrapper — no JS state needed for desktop
|
||||
|
||||
**Mobile behavior (under ~540px):**
|
||||
- The mobile nav already collapses to a full-width vertical list with `max-height` animation
|
||||
- "Episodes" becomes a top-level row with an expand arrow (chevron)
|
||||
- Tapping it toggles a sub-list showing "Current Series" and "Finished Books" indented beneath it
|
||||
- Implemented with a small React `useState` toggle on the dropdown item — consistent with how `menuOpen` already works
|
||||
- Sub-items get slightly smaller text and left padding to show hierarchy
|
||||
- The `max-height: 500px` on `.header-nav--open` will need to increase to `600px` to accommodate the expanded sub-items
|
||||
|
||||
**New elements needed:**
|
||||
- `NavDropdown` component (or inline JSX) wrapping the two episode links
|
||||
- CSS classes: `.nav-dropdown`, `.nav-dropdown-menu`, `.nav-dropdown-item`, `.nav-dropdown--open` (mobile only)
|
||||
- Mobile sub-item styles: indented, smaller text, no bottom border on last child before parent closes
|
||||
|
||||
**Footer:** Add a flat "Finished Books" link alongside the existing footer nav links — no dropdown needed there.
|
||||
|
||||
### 5. `src/App.tsx` — Two new routes
|
||||
```tsx
|
||||
<Route path="/finished" element={<FinishedBooksPage content={content} />} />
|
||||
<Route path="/finished/:id" element={<FinishedSeriesPage content={content} />} />
|
||||
```
|
||||
|
||||
### 6. `src/App.tsx` — Two new page components
|
||||
|
||||
**`FinishedBooksPage`** — grid of `finishedBooks` cards (image, title, description), each linking to `/finished/:id`. Friendly empty state if no books yet.
|
||||
|
||||
**`FinishedSeriesPage`** — uses `useEpisodesForBook`, renders episodes sorted ascending by episode number as a numbered playlist. Each row: episode number, title, duration, `EpisodeAudioPlayer`.
|
||||
|
||||
### 7. `src/AdminPage.tsx` — "End Current Series" button on Current Series tab
|
||||
- Button labeled **"End Current Series & Start New Book"**
|
||||
- Opens an inline confirmation + new series form (not a browser `confirm()` — a proper UI panel)
|
||||
- On confirm: creates `FinishedBook` from current series data, clears `podcastFeaturedLinks`, updates series fields with new book info
|
||||
- All saved as one atomic content update
|
||||
|
||||
### 8. `src/AdminPage.tsx` — Finished Books management section
|
||||
Separate tab for viewing and managing the finished books list (in case you need to edit or remove one):
|
||||
- Shows each finished book as a collapsible card
|
||||
- Edit title, description, image, season number
|
||||
- Remove button
|
||||
- "Add Finished Book" button for manually adding one without going through the transition wizard
|
||||
|
||||
### 9. CSS — Playlist and finished books grid styles
|
||||
Reuse existing episode-list and episode-card patterns where possible.
|
||||
|
||||
### 10. `src/App.tsx` — Downloads page copy tweak (Option A, low priority)
|
||||
Minor label changes only — no structural change:
|
||||
- Eyebrow becomes "Current Study"
|
||||
- Heading becomes `{content.seriesTitle} — Study Guide`
|
||||
- Everything else (download form, Amazon button, library) stays identical
|
||||
|
||||
---
|
||||
|
||||
## Prerequisite — Verify RSS Season Tags
|
||||
Before starting, confirm Titus episodes have `<itunes:season>` tags and what number they use:
|
||||
```
|
||||
curl -s https://anchor.fm/s/11068d290/podcast/rss | grep -o '<itunes:season>[^<]*</itunes:season>' | head -5
|
||||
```
|
||||
If seasons are missing from the RSS, the fallback is an `episodeRange: { from, to }` field on `FinishedBook` instead of `season`.
|
||||
|
||||
---
|
||||
|
||||
## Files Touched Summary
|
||||
| File | What changes |
|
||||
|---|---|
|
||||
| `server/routes/episodes.js` | Parse `itunes:season` into episode objects |
|
||||
| `src/content.ts` | New `FinishedBook` interface + `finishedBooks` field on `SiteContent` |
|
||||
| `src/App.tsx` | New hook, nav links, 2 routes, 2 page components, Downloads copy tweak |
|
||||
| `src/AdminPage.tsx` | "End Current Series" transition button + Finished Books management tab |
|
||||
| CSS | Playlist page + finished books grid styles |
|
||||
|
||||
---
|
||||
|
||||
## Order of Work
|
||||
1. Run the RSS curl check to confirm season tags (5 min)
|
||||
2. `content.ts` — add `FinishedBook` type and `finishedBooks` field
|
||||
3. `server/routes/episodes.js` — parse season from RSS
|
||||
4. Admin — "End Current Series" transition button + Finished Books management tab
|
||||
5. Public pages — nav link, routes, `FinishedBooksPage`, `FinishedSeriesPage`
|
||||
6. Downloads copy tweak (last, lowest priority)
|
||||
@@ -1,18 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY server.js ./
|
||||
COPY public/ ./public/
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENV PORT=3000
|
||||
ENV DATA_FILE=/data/progress.json
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,63 +0,0 @@
|
||||
# Podcast Checklist
|
||||
|
||||
Episode production tracker for *Verse by Verse with Nate*.
|
||||
|
||||
## Deploy
|
||||
|
||||
### 1. Build and push to GHCR
|
||||
|
||||
```bash
|
||||
docker build -t ghcr.io/nmemmert/podcast-checklist:latest .
|
||||
docker push ghcr.io/nmemmert/podcast-checklist:latest
|
||||
```
|
||||
|
||||
### 2. On the Oracle Cloud VM
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/nmemmert/podcast-checklist:latest
|
||||
docker rm -f podcast-checklist 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name podcast-checklist \
|
||||
--restart unless-stopped \
|
||||
-p 3001:3000 \
|
||||
-v podcast-checklist-data:/data \
|
||||
ghcr.io/nmemmert/podcast-checklist:latest
|
||||
```
|
||||
|
||||
> Uses port **3001** on the host so it doesn't clash with your existing site on 3000.
|
||||
> Progress is saved to a named Docker volume (`podcast-checklist-data`) so it survives container restarts and re-deploys.
|
||||
|
||||
### 3. Cloudflare DNS
|
||||
|
||||
Add a CNAME record in Cloudflare:
|
||||
- **Name:** `checklist` (or whatever subdomain you want)
|
||||
- **Target:** `versebyversewithnate.us`
|
||||
- **Proxy:** Enabled (orange cloud)
|
||||
|
||||
### 4. Cloudflare Tunnel or Nginx proxy
|
||||
|
||||
If you're using Nginx Proxy Manager or a Cloudflare Tunnel on the VM, add a rule to forward `checklist.versebyversewithnate.us` → `localhost:3001`.
|
||||
|
||||
---
|
||||
|
||||
## Update workflow
|
||||
|
||||
Same as your site:
|
||||
|
||||
```bash
|
||||
# Local
|
||||
docker build -t ghcr.io/nmemmert/podcast-checklist:latest .
|
||||
docker push ghcr.io/nmemmert/podcast-checklist:latest
|
||||
|
||||
# SSH into VM
|
||||
docker pull ghcr.io/nmemmert/podcast-checklist:latest
|
||||
docker rm -f podcast-checklist
|
||||
docker run -d \
|
||||
--name podcast-checklist \
|
||||
--restart unless-stopped \
|
||||
-p 3001:3000 \
|
||||
-v podcast-checklist-data:/data \
|
||||
ghcr.io/nmemmert/podcast-checklist:latest
|
||||
```
|
||||
|
||||
Progress data is in the named volume and is **not affected** by pulling a new image.
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "podcast-checklist",
|
||||
"version": "1.0.0",
|
||||
"description": "Verse by Verse with Nate — Episode Production Checklist",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
}
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Episode Checklist — Verse by Verse with Nate</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,600&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--black: #0d0d0a;
|
||||
--card-bg: #111108;
|
||||
--border: #1e1c14;
|
||||
--border-mid: #2a2518;
|
||||
--gold: #c9a84c;
|
||||
--gold-light: #e0c070;
|
||||
--warm-white: #f0ead8;
|
||||
--warm-gray: #7a7060;
|
||||
--muted: #4a4438;
|
||||
--dim: #3a3020;
|
||||
--deep: #252018;
|
||||
}
|
||||
|
||||
body { background: var(--black); color: var(--warm-white); font-family: 'Cormorant Garamond', Georgia, serif; min-height: 100vh; }
|
||||
::-webkit-scrollbar { width: 4px; }
|
||||
::-webkit-scrollbar-track { background: var(--black); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-mid); }
|
||||
|
||||
.root { max-width: 720px; margin: 0 auto; padding: 48px 20px 100px; }
|
||||
|
||||
.masthead { text-align: center; margin-bottom: 40px; }
|
||||
.eyebrow { font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase; color: var(--warm-gray); margin-bottom: 10px; }
|
||||
.main-title { font-size: clamp(28px, 6vw, 44px); font-weight: 300; font-style: italic; color: var(--gold); line-height: 1.1; margin-bottom: 4px; }
|
||||
.subtitle { font-size: 13px; color: var(--muted); letter-spacing: 0.1em; }
|
||||
.gold-rule { width: 80px; height: 1px; background: linear-gradient(90deg, transparent, var(--gold), transparent); margin: 18px auto; }
|
||||
|
||||
.save-status { text-align: center; font-size: 11px; letter-spacing: 0.12em; color: var(--dim); margin-bottom: 24px; height: 16px; transition: color 0.3s; }
|
||||
.save-status.saving { color: var(--warm-gray); }
|
||||
.save-status.saved { color: var(--gold); }
|
||||
.save-status.error { color: #8b3030; }
|
||||
|
||||
.overall { background: var(--card-bg); border: 1px solid var(--border); border-radius: 2px; padding: 18px 22px; margin-bottom: 32px; }
|
||||
.overall-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; }
|
||||
.overall-label { font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--warm-gray); }
|
||||
.overall-pct { font-size: 28px; font-weight: 300; color: var(--gold); }
|
||||
.bar-wrap { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-bottom: 14px; }
|
||||
.bar-fill { height: 100%; background: linear-gradient(90deg, var(--gold), var(--gold-light)); border-radius: 2px; transition: width 0.4s ease; }
|
||||
.series-stats { display: flex; gap: 24px; flex-wrap: wrap; }
|
||||
.stat { font-size: 12px; color: var(--muted); }
|
||||
.stat b { color: var(--gold); font-weight: 500; }
|
||||
|
||||
.filters { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
|
||||
.filter-btn { font-family: 'Cormorant Garamond', serif; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; background: none; border: 1px solid var(--border-mid); color: var(--muted); padding: 6px 14px; border-radius: 1px; cursor: pointer; transition: all 0.15s; }
|
||||
.filter-btn:hover { color: var(--gold); border-color: var(--dim); }
|
||||
.filter-btn.active { color: var(--gold); border-color: var(--gold); background: var(--card-bg); }
|
||||
|
||||
.section-label { font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase; color: var(--dim); margin: 28px 0 12px; padding-left: 2px; }
|
||||
|
||||
/* Card */
|
||||
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 2px; margin-bottom: 8px; overflow: hidden; transition: border-color 0.2s; }
|
||||
.card:hover { border-color: var(--border-mid); }
|
||||
.card.complete { border-left: 2px solid var(--gold); }
|
||||
|
||||
.card-header { display: flex; align-items: center; gap: 12px; padding: 14px 18px; cursor: pointer; user-select: none; }
|
||||
|
||||
.series-tag { font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; padding: 2px 7px; border-radius: 1px; flex-shrink: 0; }
|
||||
.tag-Titus { color: var(--gold); background: #1a1810; border: 1px solid var(--border-mid); }
|
||||
.tag-Colossians { color: var(--warm-gray); background: #131310; border: 1px solid var(--border); }
|
||||
|
||||
.ep-num { font-size: 13px; font-weight: 600; color: var(--muted); flex-shrink: 0; min-width: 36px; }
|
||||
.card.complete .ep-num { color: var(--gold); }
|
||||
|
||||
.ep-label { flex: 1; font-size: 15px; color: #c8c0a8; }
|
||||
.card.complete .ep-label { color: var(--muted); }
|
||||
|
||||
.pub-date-pill { font-size: 10px; color: var(--warm-gray); font-style: italic; flex-shrink: 0; letter-spacing: 0.03em; }
|
||||
|
||||
.task-count { font-size: 11px; color: var(--dim); flex-shrink: 0; }
|
||||
.card.complete .task-count { color: var(--gold); }
|
||||
|
||||
.mini-bar { width: 48px; height: 2px; background: var(--border); border-radius: 1px; overflow: hidden; flex-shrink: 0; }
|
||||
.mini-fill { height: 100%; background: linear-gradient(90deg, var(--gold), var(--gold-light)); transition: width 0.3s; }
|
||||
|
||||
.chevron { font-size: 10px; color: var(--border-mid); transition: transform 0.2s, color 0.15s; flex-shrink: 0; }
|
||||
.chevron.open { transform: rotate(180deg); color: var(--muted); }
|
||||
|
||||
.card-body { border-top: 1px solid #1a1810; padding: 0 18px 18px; }
|
||||
|
||||
/* Date published row */
|
||||
.date-row { display: flex; align-items: center; gap: 12px; padding: 14px 0 12px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
|
||||
.date-label { font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--warm-gray); flex-shrink: 0; }
|
||||
.date-input {
|
||||
background: transparent; border: none; border-bottom: 1px solid var(--border-mid);
|
||||
outline: none; font-family: 'Cormorant Garamond', serif; font-size: 14px;
|
||||
color: var(--gold-light); padding: 2px 6px 3px; cursor: pointer;
|
||||
color-scheme: dark;
|
||||
}
|
||||
.date-input:focus { border-bottom-color: var(--gold); }
|
||||
.date-input::-webkit-calendar-picker-indicator { filter: invert(0.6) sepia(1) saturate(2) hue-rotate(5deg); cursor: pointer; }
|
||||
|
||||
/* Phase headers */
|
||||
.phase-header { font-size: 10px; letter-spacing: 0.25em; text-transform: uppercase; color: var(--warm-gray); padding: 14px 0 6px; border-bottom: 1px solid var(--border); margin-bottom: 2px; display: flex; align-items: center; gap: 8px; }
|
||||
.phase-header::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
.phase-pre { color: var(--warm-gray); }
|
||||
.phase-post { color: var(--gold); }
|
||||
|
||||
.task-list { list-style: none; }
|
||||
.task-item { display: flex; align-items: flex-start; gap: 12px; padding: 9px 0; border-bottom: 1px solid #141210; cursor: pointer; }
|
||||
.task-item:last-child { border-bottom: none; }
|
||||
|
||||
.check-box { width: 16px; height: 16px; border: 1px solid var(--border-mid); border-radius: 1px; flex-shrink: 0; margin-top: 3px; display: flex; align-items: center; justify-content: center; transition: background 0.15s, border-color 0.15s; }
|
||||
.task-item.done .check-box { background: var(--gold); border-color: var(--gold); }
|
||||
.check-svg { display: none; }
|
||||
.task-item.done .check-svg { display: block; }
|
||||
|
||||
.step-num { font-size: 9px; letter-spacing: 0.15em; color: var(--border-mid); margin-top: 4px; flex-shrink: 0; width: 22px; }
|
||||
|
||||
.task-body { flex: 1; }
|
||||
.task-name { font-size: 14px; font-weight: 500; color: #d8d0b8; line-height: 1.3; transition: color 0.15s; }
|
||||
.task-item.done .task-name { color: var(--dim); text-decoration: line-through; text-decoration-color: var(--border-mid); }
|
||||
.task-hint { font-size: 12px; color: var(--muted); font-style: italic; margin-top: 1px; }
|
||||
.task-item.done .task-hint { color: var(--deep); }
|
||||
.task-item:hover .task-name { color: var(--warm-white); }
|
||||
.task-item.done:hover .task-name { color: var(--dim); }
|
||||
|
||||
.card-footer { display: flex; justify-content: flex-end; padding-top: 10px; margin-top: 4px; border-top: 1px solid #141210; }
|
||||
.btn-reset { font-family: 'Cormorant Garamond', serif; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; background: none; border: 1px solid var(--border); color: var(--dim); padding: 4px 10px; border-radius: 1px; cursor: pointer; transition: all 0.15s; }
|
||||
.btn-reset:hover { color: var(--warm-gray); border-color: var(--border-mid); }
|
||||
.complete-badge { font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--gold); padding: 2px 8px; border: 1px solid var(--border-mid); border-radius: 1px; }
|
||||
|
||||
.loading { text-align: center; color: var(--muted); font-style: italic; padding: 40px; font-size: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="root">
|
||||
|
||||
<div class="masthead">
|
||||
<div class="eyebrow">Verse by Verse with Nate</div>
|
||||
<div class="main-title">Production Checklist</div>
|
||||
<div class="subtitle">Titus · Colossians</div>
|
||||
<div class="gold-rule"></div>
|
||||
</div>
|
||||
|
||||
<div class="save-status" id="saveStatus"></div>
|
||||
|
||||
<div class="overall">
|
||||
<div class="overall-row">
|
||||
<div class="overall-label">Overall Progress</div>
|
||||
<div class="overall-pct" id="overallPct">0%</div>
|
||||
</div>
|
||||
<div class="bar-wrap"><div class="bar-fill" id="overallBar" style="width:0%"></div></div>
|
||||
<div class="series-stats">
|
||||
<div class="stat">Titus — <b id="titusDone">0</b> / 5 episodes done</div>
|
||||
<div class="stat">Colossians — <b id="colossDone">0</b> / 27 episodes done</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<button class="filter-btn active" onclick="setFilter('All')">All</button>
|
||||
<button class="filter-btn" onclick="setFilter('Titus')">Titus</button>
|
||||
<button class="filter-btn" onclick="setFilter('Colossians')">Colossians</button>
|
||||
</div>
|
||||
|
||||
<div id="app"><div class="loading">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Task definitions ────────────────────────────────────────────────────────
|
||||
const PRE_TASKS = [
|
||||
{ id: "verify_script", step: "01", label: "Verify Script", desc: "Review for accuracy, theology, and flow" },
|
||||
{ id: "read_script", step: "02", label: "Read Script", desc: "Read aloud, mark pacing & emphasis" },
|
||||
{ id: "record", step: "03", label: "Record", desc: "Capture final audio take" },
|
||||
{ id: "edit", step: "04", label: "Edit", desc: "Trim, cut, clean up in Ferrite / GarageBand" },
|
||||
{ id: "mix", step: "05", label: "Mix", desc: "Levels, EQ, music beds, final master" },
|
||||
{ id: "video_script", step: "06", label: "Run Video Conversion Script", desc: "Generate video file from audio" },
|
||||
{ id: "post_spotify", step: "07", label: "Post on Spotify", desc: "Upload with show notes & discussion questions" },
|
||||
];
|
||||
|
||||
const POST_TASKS = [
|
||||
{ id: "update_website", step: "08", label: "Update Website", desc: "Add episode highlights to versebyversewithnate.us" },
|
||||
{ id: "send_email", step: "09", label: "Send Email", desc: "Send newsletter to subscribers via Resend" },
|
||||
];
|
||||
|
||||
const ALL_TASK_IDS = [...PRE_TASKS, ...POST_TASKS].map(t => t.id);
|
||||
|
||||
function makeEpisode(series, number) {
|
||||
const tasks = {};
|
||||
ALL_TASK_IDS.forEach(id => tasks[id] = false);
|
||||
return { id: `${series}-${number}`, series, number, tasks, datePublished: "", expanded: false };
|
||||
}
|
||||
|
||||
function buildEpisodes() {
|
||||
const titus = [11,12,13,14,15].map(n => makeEpisode("Titus", n));
|
||||
const colossians = Array.from({ length: 27 }, (_, i) => makeEpisode("Colossians", i + 1));
|
||||
return [...titus, ...colossians];
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
let episodes = [];
|
||||
let activeFilter = "All";
|
||||
let saveTimer = null;
|
||||
|
||||
// ── Server I/O ──────────────────────────────────────────────────────────────
|
||||
async function loadFromServer() {
|
||||
try {
|
||||
const res = await fetch("/api/progress");
|
||||
const saved = await res.json();
|
||||
const base = buildEpisodes();
|
||||
episodes = base.map(ep => {
|
||||
const s = saved[ep.id];
|
||||
if (!s) return ep;
|
||||
return {
|
||||
...ep,
|
||||
expanded: s.expanded ?? false,
|
||||
datePublished: s.datePublished ?? "",
|
||||
tasks: Object.fromEntries(ALL_TASK_IDS.map(id => [id, s.tasks?.[id] ?? false]))
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
episodes = buildEpisodes();
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
clearTimeout(saveTimer);
|
||||
setStatus("saving", "Saving…");
|
||||
saveTimer = setTimeout(saveToServer, 800);
|
||||
}
|
||||
|
||||
async function saveToServer() {
|
||||
const payload = {};
|
||||
episodes.forEach(ep => {
|
||||
payload[ep.id] = { expanded: ep.expanded, datePublished: ep.datePublished, tasks: ep.tasks };
|
||||
});
|
||||
try {
|
||||
await fetch("/api/progress", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
setStatus("saved", "✦ Saved");
|
||||
setTimeout(() => setStatus("", ""), 2000);
|
||||
} catch {
|
||||
setStatus("error", "Save failed — check connection");
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(cls, msg) {
|
||||
const el = document.getElementById("saveStatus");
|
||||
el.className = "save-status" + (cls ? " " + cls : "");
|
||||
el.textContent = msg;
|
||||
}
|
||||
|
||||
// ── Mutations ────────────────────────────────────────────────────────────────
|
||||
function toggleTask(epId, taskId) {
|
||||
const ep = episodes.find(e => e.id === epId);
|
||||
ep.tasks[taskId] = !ep.tasks[taskId];
|
||||
scheduleSave(); render();
|
||||
}
|
||||
|
||||
function toggleExpand(epId) {
|
||||
const ep = episodes.find(e => e.id === epId);
|
||||
ep.expanded = !ep.expanded;
|
||||
scheduleSave(); render();
|
||||
}
|
||||
|
||||
function setDate(epId, val) {
|
||||
const ep = episodes.find(e => e.id === epId);
|
||||
ep.datePublished = val;
|
||||
scheduleSave(); render();
|
||||
}
|
||||
|
||||
function resetEpisode(epId) {
|
||||
const ep = episodes.find(e => e.id === epId);
|
||||
ALL_TASK_IDS.forEach(id => ep.tasks[id] = false);
|
||||
ep.datePublished = "";
|
||||
scheduleSave(); render();
|
||||
}
|
||||
|
||||
function setFilter(f) {
|
||||
activeFilter = f;
|
||||
document.querySelectorAll(".filter-btn").forEach(btn =>
|
||||
btn.classList.toggle("active", btn.textContent === f)
|
||||
);
|
||||
render();
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────────
|
||||
function render() {
|
||||
updateStats();
|
||||
const app = document.getElementById("app");
|
||||
const filtered = activeFilter === "All" ? episodes : episodes.filter(e => e.series === activeFilter);
|
||||
const titus = filtered.filter(e => e.series === "Titus");
|
||||
const colossians = filtered.filter(e => e.series === "Colossians");
|
||||
|
||||
let html = "";
|
||||
if (titus.length) {
|
||||
html += `<div class="section-label">Titus — Episodes 11–15</div>`;
|
||||
titus.forEach(ep => html += renderCard(ep));
|
||||
}
|
||||
if (colossians.length) {
|
||||
html += `<div class="section-label">Colossians — Episodes 1–27</div>`;
|
||||
colossians.forEach(ep => html += renderCard(ep));
|
||||
}
|
||||
app.innerHTML = html;
|
||||
}
|
||||
|
||||
function formatDate(val) {
|
||||
if (!val) return "";
|
||||
const [y, m, d] = val.split("-");
|
||||
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
return `${months[parseInt(m)-1]} ${parseInt(d)}, ${y}`;
|
||||
}
|
||||
|
||||
function renderCard(ep) {
|
||||
const allDone = ALL_TASK_IDS.every(id => ep.tasks[id]);
|
||||
const doneCount = ALL_TASK_IDS.filter(id => ep.tasks[id]).length;
|
||||
const total = ALL_TASK_IDS.length;
|
||||
const pct = Math.round((doneCount / total) * 100);
|
||||
const dateDisplay = ep.datePublished ? formatDate(ep.datePublished) : "";
|
||||
|
||||
let body = "";
|
||||
if (ep.expanded) {
|
||||
// Pre-publish tasks
|
||||
const preTasks = PRE_TASKS.map(t => renderTask(ep.id, t, ep.tasks[t.id])).join("");
|
||||
// Post-publish tasks
|
||||
const postTasks = POST_TASKS.map(t => renderTask(ep.id, t, ep.tasks[t.id])).join("");
|
||||
|
||||
const footer = allDone
|
||||
? `<span class="complete-badge">✦ Complete</span>`
|
||||
: `<button class="btn-reset" onclick="event.stopPropagation();resetEpisode('${ep.id}')">Reset</button>`;
|
||||
|
||||
body = `
|
||||
<div class="card-body">
|
||||
<div class="date-row">
|
||||
<span class="date-label">Date Published</span>
|
||||
<input type="date" class="date-input" value="${ep.datePublished}"
|
||||
onchange="setDate('${ep.id}', this.value)"
|
||||
onclick="event.stopPropagation()" />
|
||||
${ep.datePublished ? `<span style="font-size:12px;color:var(--warm-gray);font-style:italic;">${formatDate(ep.datePublished)}</span>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="phase-header phase-pre">Pre-Publish</div>
|
||||
<ul class="task-list">${preTasks}</ul>
|
||||
|
||||
<div class="phase-header phase-post" style="margin-top:10px;">Post-Publish</div>
|
||||
<ul class="task-list">${postTasks}</ul>
|
||||
|
||||
<div class="card-footer">${footer}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="card${allDone ? " complete" : ""}">
|
||||
<div class="card-header" onclick="toggleExpand('${ep.id}')">
|
||||
<span class="series-tag tag-${ep.series}">${ep.series}</span>
|
||||
<span class="ep-num">Ep ${ep.number}</span>
|
||||
<span class="ep-label">${ep.series} — Episode ${ep.number}</span>
|
||||
${dateDisplay ? `<span class="pub-date-pill">${dateDisplay}</span>` : ""}
|
||||
<span class="task-count">${doneCount}/${total}</span>
|
||||
<div class="mini-bar"><div class="mini-fill" style="width:${pct}%"></div></div>
|
||||
<span class="chevron${ep.expanded ? " open" : ""}">▼</span>
|
||||
</div>
|
||||
${body}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderTask(epId, t, done) {
|
||||
return `
|
||||
<li class="task-item${done ? " done" : ""}" onclick="toggleTask('${epId}','${t.id}')">
|
||||
<div class="check-box">
|
||||
<svg class="check-svg" width="10" height="8" viewBox="0 0 10 8" fill="none">
|
||||
<path d="M1 4L3.5 6.5L9 1" stroke="#0d0d0a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="step-num">${t.step}</span>
|
||||
<div class="task-body">
|
||||
<div class="task-name">${t.label}</div>
|
||||
<div class="task-hint">${t.desc}</div>
|
||||
</div>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const totalDone = episodes.reduce((a, e) => a + ALL_TASK_IDS.filter(id => e.tasks[id]).length, 0);
|
||||
const totalTasks = episodes.length * ALL_TASK_IDS.length;
|
||||
const pct = Math.round((totalDone / totalTasks) * 100);
|
||||
document.getElementById("overallPct").textContent = pct + "%";
|
||||
document.getElementById("overallBar").style.width = pct + "%";
|
||||
document.getElementById("titusDone").textContent =
|
||||
episodes.filter(e => e.series === "Titus" && ALL_TASK_IDS.every(id => e.tasks[id])).length;
|
||||
document.getElementById("colossDone").textContent =
|
||||
episodes.filter(e => e.series === "Colossians" && ALL_TASK_IDS.every(id => e.tasks[id])).length;
|
||||
}
|
||||
|
||||
loadFromServer();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,43 +0,0 @@
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const DATA_FILE = process.env.DATA_FILE || "/data/progress.json";
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, "public")));
|
||||
|
||||
// Ensure data directory and file exist
|
||||
function ensureDataFile() {
|
||||
const dir = path.dirname(DATA_FILE);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
if (!fs.existsSync(DATA_FILE)) fs.writeFileSync(DATA_FILE, JSON.stringify({}));
|
||||
}
|
||||
|
||||
// GET progress
|
||||
app.get("/api/progress", (req, res) => {
|
||||
try {
|
||||
ensureDataFile();
|
||||
const data = JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.error("Read error:", err);
|
||||
res.status(500).json({ error: "Failed to read progress" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST progress (full state)
|
||||
app.post("/api/progress", (req, res) => {
|
||||
try {
|
||||
ensureDataFile();
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(req.body, null, 2));
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error("Write error:", err);
|
||||
res.status(500).json({ error: "Failed to save progress" });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => console.log(`Podcast checklist running on port ${PORT}`));
|
||||
|
Before Width: | Height: | Size: 89 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="184" height="48" viewBox="0 0 184 48">
|
||||
<rect width="184" height="48" rx="6" fill="#1877F2"/>
|
||||
<g transform="translate(11,11)">
|
||||
<circle cx="13" cy="13" r="12" fill="rgba(255,255,255,0.16)"/>
|
||||
<path fill="#ffffff" d="M17.755 13.037C17.755 10.385 15.617 8.237 12.98 8.237c-2.638 0-4.776 2.148-4.776 4.8 0 2.396 1.744 4.383 4.028 4.743v-3.36H11.01v-1.383h1.222v-1.054c0-1.215.719-1.887 1.82-1.887.527 0 1.078.094 1.078.094v1.193h-.608c-.598 0-.784.373-.784.755v.899h1.334l-.213 1.383h-1.121v3.36c2.284-.36 4.017-2.347 4.017-4.743z"/>
|
||||
</g>
|
||||
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="rgba(255,255,255,0.82)" font-size="9.5" x="52" y="19">Follow on</text>
|
||||
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="16" font-weight="bold" x="52" y="36">Facebook</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 891 B |
|
Before Width: | Height: | Size: 127 KiB |
@@ -1,12 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="210" height="48" viewBox="0 0 210 48">
|
||||
<rect width="210" height="48" rx="6" fill="#131921"/>
|
||||
<!-- Amazon Music icon: headphone/music note style - simple "a" with music mark -->
|
||||
<g transform="translate(11,11)">
|
||||
<!-- Amazon arrow swoosh shape -->
|
||||
<circle cx="13" cy="13" r="12" fill="#00A8E1"/>
|
||||
<!-- Simplified headphone icon in white -->
|
||||
<path fill="#ffffff" d="M13 4C8.03 4 4 8.03 4 13v1h2v-1c0-3.87 3.13-7 7-7s7 3.13 7 7v1h2v-1c0-4.97-4.03-9-9-9zm-3 10H8c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1h2c.55 0 1-.45 1-1v-3c0-.55-.45-1-1-1zm6 0h-2c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1h2c.55 0 1-.45 1-1v-3c0-.55-.45-1-1-1z"/>
|
||||
</g>
|
||||
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#b3b3b3" font-size="9.5" x="52" y="19">Listen on</text>
|
||||
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="15" font-weight="bold" x="52" y="36">Amazon Music</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 972 B |
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="183" height="48" viewBox="0 0 183 48">
|
||||
<rect width="183" height="48" rx="6" fill="#1a1a1a"/>
|
||||
<!-- Castbox icon: headphones/podcast icon in orange -->
|
||||
<g transform="translate(11,11)">
|
||||
<circle cx="13" cy="13" r="12" fill="#F55223"/>
|
||||
<!-- Headphones shape in white -->
|
||||
<path fill="#ffffff" d="M13 5C8.58 5 5 8.58 5 13v.5H7V13c0-3.31 2.69-6 6-6s6 2.69 6 6v.5h2V13c0-4.42-3.58-8-8-8zm-3.5 9H7c-.55 0-1 .45-1 1v2.5c0 .55.45 1 1 1h2.5c.55 0 1-.45 1-1V15c0-.55-.45-1-1-1zm7 0h-2.5c-.55 0-1 .45-1 1v2.5c0 .55.45 1 1 1H17c.55 0 1-.45 1-1V15c0-.55-.45-1-1-1z"/>
|
||||
</g>
|
||||
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#b3b3b3" font-size="9.5" x="52" y="19">Listen on</text>
|
||||
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="16" font-weight="bold" x="52" y="36">Castbox</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 904 B |
@@ -1,15 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="195" height="48" viewBox="0 0 195 48">
|
||||
<rect width="195" height="48" rx="6" fill="#F43E37"/>
|
||||
<!-- Pocket Casts logo: circle with play-in-wave icon -->
|
||||
<g transform="translate(11,11)">
|
||||
<circle cx="13" cy="13" r="12" fill="rgba(0,0,0,0.25)"/>
|
||||
<!-- Outer arc -->
|
||||
<path fill="#ffffff" d="M13 2C7 2 2 7 2 13s5 11 11 11 11-5 11-11S19 2 13 2zm0 19c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/>
|
||||
<!-- Inner arc -->
|
||||
<path fill="#ffffff" d="M13 6c-3.87 0-7 3.13-7 7s3.13 7 7 7 7-3.13 7-7-3.13-7-7-7zm0 11c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z"/>
|
||||
<!-- Center dot -->
|
||||
<circle cx="13" cy="13" r="2.5" fill="#ffffff"/>
|
||||
</g>
|
||||
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="rgba(255,255,255,0.8)" font-size="9.5" x="52" y="19">Listen on</text>
|
||||
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="14.5" font-weight="bold" x="52" y="36">Pocket Casts</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1019 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="48" viewBox="0 0 180 48">
|
||||
<rect width="180" height="48" rx="6" fill="#191414"/>
|
||||
<!-- Spotify icon (24x24 viewBox path, scaled to fit 26x26 at offset 11,11) -->
|
||||
<g transform="translate(11,11) scale(1.083)">
|
||||
<path fill="#1DB954" d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/>
|
||||
</g>
|
||||
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#b3b3b3" font-size="9.5" x="48" y="19">Listen on</text>
|
||||
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="16" font-weight="bold" x="48" y="36">Spotify</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 152 KiB |
@@ -1,12 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="190" height="48" viewBox="0 0 190 48">
|
||||
<rect width="190" height="48" rx="6" fill="#0f0f0f"/>
|
||||
<!-- YouTube icon: red rounded rect with white play triangle -->
|
||||
<g transform="translate(11,11)">
|
||||
<!-- YouTube red background rect -->
|
||||
<rect x="1" y="4.5" width="24" height="17" rx="4" fill="#FF0000"/>
|
||||
<!-- White play triangle -->
|
||||
<polygon points="10,9 20,13 10,17" fill="#ffffff"/>
|
||||
</g>
|
||||
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#b3b3b3" font-size="9.5" x="52" y="19">Watch on</text>
|
||||
<text font-family="Helvetica Neue, Helvetica, Arial, sans-serif" fill="#ffffff" font-size="16" font-weight="bold" x="52" y="36">YouTube</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 732 B |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 61 KiB |
@@ -1,9 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Disallow: /admin
|
||||
Disallow: /api/
|
||||
Disallow: /thanks
|
||||
Disallow: /subscribe/thanks
|
||||
|
||||
Sitemap: https://versebyversewithnate.us/sitemap.xml
|
||||
@@ -1,899 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gospel Harmony — The Trial & Crucifixion</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=EB+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--parchment: #f5edd8;
|
||||
--parchment-dark: #e8d9b8;
|
||||
--ink: #1a1208;
|
||||
--ink-light: #3d2c10;
|
||||
--crimson: #8b1a1a;
|
||||
--gold: #b8860b;
|
||||
--gold-light: #d4a843;
|
||||
--matt-color: #4a6fa5;
|
||||
--mark-color: #7a4a8f;
|
||||
--luke-color: #2e7d52;
|
||||
--john-color: #b5651d;
|
||||
--shadow: rgba(26,18,8,0.18);
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #1a0f05;
|
||||
font-family: 'EB Garamond', Georgia, serif;
|
||||
color: var(--ink);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='400' height='400' filter='url(%23n)' opacity='0.07'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.scroll-wrapper {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.5rem 4rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
header {
|
||||
text-align: center;
|
||||
padding: 3.5rem 2rem 2rem;
|
||||
}
|
||||
.header-rule {
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, transparent, var(--gold), var(--crimson), var(--gold), transparent);
|
||||
margin-bottom: 1.8rem;
|
||||
position: relative;
|
||||
}
|
||||
.header-rule::before, .header-rule::after {
|
||||
content: '✦';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--gold);
|
||||
font-size: 0.9rem;
|
||||
background: #1a0f05;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
.header-rule::before { left: 20%; }
|
||||
.header-rule::after { right: 20%; }
|
||||
header h1 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: clamp(1.6rem, 5vw, 3rem);
|
||||
font-weight: 900;
|
||||
color: var(--parchment);
|
||||
letter-spacing: 0.06em;
|
||||
text-shadow: 0 2px 8px rgba(0,0,0,0.5);
|
||||
line-height: 1.15;
|
||||
}
|
||||
header h1 span { color: var(--gold-light); }
|
||||
header .subtitle {
|
||||
font-family: 'EB Garamond', serif;
|
||||
font-style: italic;
|
||||
color: var(--parchment-dark);
|
||||
font-size: 1.1rem;
|
||||
margin-top: 0.6rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.6rem 1.4rem;
|
||||
margin: 2rem auto 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(245,237,216,0.06);
|
||||
border: 1px solid rgba(184,134,11,0.3);
|
||||
border-radius: 4px;
|
||||
max-width: 700px;
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
color: var(--parchment);
|
||||
user-select: none;
|
||||
}
|
||||
.legend-item.dimmed { opacity: 0.35; }
|
||||
.legend-dot {
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0 2.5rem;
|
||||
}
|
||||
.filter-btn {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 0.35rem 0.9rem;
|
||||
border-radius: 2px;
|
||||
border: 1px solid;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.filter-btn.active-filter { color: var(--ink); font-weight: 700; }
|
||||
.filter-btn:not(.active-filter) {
|
||||
background: transparent;
|
||||
color: var(--parchment-dark);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.filter-btn:hover { opacity: 1; }
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.95rem;
|
||||
top: 0; bottom: 0;
|
||||
width: 2px;
|
||||
background: linear-gradient(180deg, var(--gold) 0%, var(--crimson) 50%, var(--gold) 100%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.phase-header {
|
||||
position: relative;
|
||||
margin: 2.5rem 0 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.phase-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -2.5rem;
|
||||
width: 2rem;
|
||||
height: 2px;
|
||||
background: var(--gold);
|
||||
}
|
||||
.phase-node {
|
||||
width: 1.8rem; height: 1.8rem;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--gold), var(--crimson));
|
||||
border: 3px solid var(--parchment-dark);
|
||||
position: absolute;
|
||||
left: -2.42rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
box-shadow: 0 0 12px rgba(184,134,11,0.5);
|
||||
z-index: 2;
|
||||
}
|
||||
.phase-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gold-light);
|
||||
padding: 0.2rem 0.8rem;
|
||||
border: 1px solid rgba(184,134,11,0.4);
|
||||
border-radius: 2px;
|
||||
background: rgba(26,9,5,0.6);
|
||||
}
|
||||
.phase-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--parchment);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.event-card {
|
||||
position: relative;
|
||||
margin: 0.65rem 0;
|
||||
background: linear-gradient(135deg, rgba(245,237,216,0.96) 0%, rgba(232,217,184,0.94) 100%);
|
||||
border-radius: 4px;
|
||||
padding: 1rem 1.2rem 1rem 1.4rem;
|
||||
border-left: 4px solid var(--crimson);
|
||||
box-shadow: 2px 3px 14px var(--shadow), inset 0 1px 0 rgba(255,255,255,0.4);
|
||||
transition: transform 0.18s, box-shadow 0.18s, opacity 0.25s;
|
||||
}
|
||||
.event-card:hover {
|
||||
transform: translateX(3px);
|
||||
box-shadow: 4px 5px 20px rgba(26,18,8,0.28);
|
||||
}
|
||||
.event-card.hidden { display: none; }
|
||||
.event-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -2.09rem;
|
||||
top: 1.1rem;
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--parchment);
|
||||
border: 2px solid var(--crimson);
|
||||
box-shadow: 0 0 0 2px rgba(26,18,8,0.15);
|
||||
}
|
||||
.event-title {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.35rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.event-body {
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink-light);
|
||||
line-height: 1.6;
|
||||
font-style: italic;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.gospel-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
/* shared badge base */
|
||||
.badge {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 2px;
|
||||
border: 1px solid;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
cursor: default;
|
||||
}
|
||||
a.badge {
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s, transform 0.15s;
|
||||
}
|
||||
a.badge:hover {
|
||||
filter: brightness(1.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.badge-matt { background: var(--matt-color); border-color: #3a5a8a; }
|
||||
.badge-mark { background: var(--mark-color); border-color: #6a3a7f; }
|
||||
.badge-luke { background: var(--luke-color); border-color: #1e6040; }
|
||||
.badge-john { background: var(--john-color); border-color: #8b4513; }
|
||||
.ref-text {
|
||||
font-size: 0.72rem;
|
||||
color: #7a5a2a;
|
||||
margin-top: 0.35rem;
|
||||
font-style: normal;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.unique-note {
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: var(--crimson);
|
||||
margin-top: 0.3rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px dashed rgba(139,26,26,0.25);
|
||||
}
|
||||
.bsb-hint {
|
||||
font-size: 0.68rem;
|
||||
color: #9a7a3a;
|
||||
margin-top: 0.4rem;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem 1.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(245,237,216,0.35);
|
||||
font-style: italic;
|
||||
font-family: 'EB Garamond', serif;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.timeline { padding-left: 2rem; }
|
||||
.timeline::before { left: 0.7rem; }
|
||||
.event-card::before { left: -1.6rem; }
|
||||
.phase-node { left: -1.92rem; }
|
||||
.phase-header::before { left: -2rem; width: 1.6rem; }
|
||||
.event-card { padding: 0.9rem 1rem 0.9rem 1.1rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="scroll-wrapper">
|
||||
|
||||
<header>
|
||||
<div class="header-rule"></div>
|
||||
<h1>A <span>Gospel Harmony</span><br>The Trial & Crucifixion of Jesus</h1>
|
||||
<p class="subtitle">Matthew · Mark · Luke · John — Woven into One Timeline</p>
|
||||
<div class="header-rule" style="margin-top:1.6rem;margin-bottom:0"></div>
|
||||
</header>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item" data-gospel="matt" onclick="toggleGospel('matt',this)">
|
||||
<div class="legend-dot" style="background:var(--matt-color)"></div> Matthew
|
||||
</div>
|
||||
<div class="legend-item" data-gospel="mark" onclick="toggleGospel('mark',this)">
|
||||
<div class="legend-dot" style="background:var(--mark-color)"></div> Mark
|
||||
</div>
|
||||
<div class="legend-item" data-gospel="luke" onclick="toggleGospel('luke',this)">
|
||||
<div class="legend-dot" style="background:var(--luke-color)"></div> Luke
|
||||
</div>
|
||||
<div class="legend-item" data-gospel="john" onclick="toggleGospel('john',this)">
|
||||
<div class="legend-dot" style="background:var(--john-color)"></div> John
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<button class="filter-btn active-filter" style="border-color:var(--gold);background:var(--gold);color:var(--ink)" onclick="filterPhase('all',this)">All Events</button>
|
||||
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('arrest',this)">Arrest</button>
|
||||
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('jewish-trial',this)">Jewish Trial</button>
|
||||
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('roman-trial',this)">Roman Trial</button>
|
||||
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('crucifixion',this)">Crucifixion</button>
|
||||
<button class="filter-btn" style="border-color:#555" onclick="filterPhase('burial',this)">Burial</button>
|
||||
</div>
|
||||
|
||||
<div class="timeline" id="timeline"></div>
|
||||
|
||||
<footer>
|
||||
A harmonic arrangement of Matthew 26–27, Mark 14–15, Luke 22–23, and John 18–19.<br>
|
||||
Click any Gospel badge to open that passage in the Berean Standard Bible (BSB) at BibleHub.
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
// BSB URL helper — BibleHub format: https://biblehub.com/bsb/matthew/26.htm
|
||||
const BSB = {
|
||||
matt: (ch) => `https://biblehub.com/bsb/matthew/${ch}.htm`,
|
||||
mark: (ch) => `https://biblehub.com/bsb/mark/${ch}.htm`,
|
||||
luke: (ch) => `https://biblehub.com/bsb/luke/${ch}.htm`,
|
||||
john: (ch) => `https://biblehub.com/bsb/john/${ch}.htm`,
|
||||
};
|
||||
|
||||
const GOSPELS = ['matt','mark','luke','john'];
|
||||
const activeGospels = new Set(GOSPELS);
|
||||
let activePhase = 'all';
|
||||
|
||||
const data = [
|
||||
// ── PHASE 1: ARREST ──────────────────────────────────────────
|
||||
{ phase:'arrest', phaseLabel:'Phase I', phaseTitle:'The Arrest in Gethsemane', events:[
|
||||
{
|
||||
title:'The Last Supper & Passover Meal',
|
||||
body:"Jesus eats the Passover meal with his twelve disciples, institutes the Lord's Supper, and predicts his betrayal.",
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 26:20–29 · Mark 14:17–25 · Luke 22:14–23 · John 13:1–30',
|
||||
unique:"John alone records Jesus washing the disciples' feet and the extended farewell discourse (John 13–17).",
|
||||
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(13) }
|
||||
},
|
||||
{
|
||||
title:"Jesus Predicts Peter's Denial",
|
||||
body:"Jesus tells Peter that before the rooster crows, he will deny him three times.",
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 26:31–35 · Mark 14:27–31 · Luke 22:31–34 · John 13:36–38',
|
||||
unique:'Luke adds that Jesus prayed specifically for Peter that his faith would not fail.',
|
||||
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(13) }
|
||||
},
|
||||
{
|
||||
title:'The Agony in Gethsemane',
|
||||
body:'Jesus prays alone in the garden in great anguish: "Father, if you are willing, remove this cup from me; nevertheless, not my will, but yours, be done."',
|
||||
gospels:['matt','mark','luke'],
|
||||
refs:'Matt 26:36–46 · Mark 14:32–42 · Luke 22:39–46',
|
||||
unique:'Luke alone records that an angel appeared to strengthen Jesus, and that his sweat became like drops of blood.',
|
||||
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22) }
|
||||
},
|
||||
{
|
||||
title:'Judas Arrives with the Crowd',
|
||||
body:'Judas leads a crowd with swords and clubs, sent from the chief priests and elders, into the garden.',
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 26:47 · Mark 14:43 · Luke 22:47 · John 18:3',
|
||||
unique:'John specifies it was a Roman cohort plus Jewish officers, and that they carried lanterns and torches.',
|
||||
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
|
||||
},
|
||||
{
|
||||
title:"The Betrayer's Kiss",
|
||||
body:"Judas greets Jesus with a kiss as the prearranged sign of identification.",
|
||||
gospels:['matt','mark','luke'],
|
||||
refs:'Matt 26:48–49 · Mark 14:44–45 · Luke 22:47–48',
|
||||
unique:'John omits the kiss; instead Jesus steps forward and declares "I am he," causing the crowd to fall to the ground.',
|
||||
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22) }
|
||||
},
|
||||
{
|
||||
title:'Jesus Identifies Himself — "I Am He" (John)',
|
||||
body:'Jesus asks "Whom do you seek?" When they say "Jesus of Nazareth," he says "I am he," and the entire crowd falls backward to the ground.',
|
||||
gospels:['john'],
|
||||
refs:'John 18:4–9',
|
||||
unique:'Unique to John — a powerful "I Am" moment that echoes the divine name.',
|
||||
bsbLinks:{ john:BSB.john(18) }
|
||||
},
|
||||
{
|
||||
title:"The Servant's Ear Cut Off",
|
||||
body:"One of those with Jesus draws a sword and cuts off the right ear of the high priest's servant, Malchus.",
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 26:51 · Mark 14:47 · Luke 22:50 · John 18:10',
|
||||
unique:'Only John names the attacker as Peter and the servant as Malchus. Only Luke records that Jesus healed the ear.',
|
||||
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
|
||||
},
|
||||
{
|
||||
title:'Jesus Rebukes the Sword & Surrenders',
|
||||
body:'"Put your sword back in its place. Shall I not drink the cup the Father has given me?" Then all the disciples fled.',
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 26:52–56 · Mark 14:48–50 · Luke 22:51–53 · John 18:11',
|
||||
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
|
||||
},
|
||||
{
|
||||
title:'The Young Man Who Fled (Mark)',
|
||||
body:'A young man follows Jesus wearing only a linen cloth; when seized, he leaves the cloth behind and flees naked.',
|
||||
gospels:['mark'],
|
||||
refs:'Mark 14:51–52',
|
||||
unique:'Unique to Mark — possibly a self-reference by the author.',
|
||||
bsbLinks:{ mark:BSB.mark(14) }
|
||||
},
|
||||
]},
|
||||
|
||||
// ── PHASE 2: JEWISH TRIAL ─────────────────────────────────────
|
||||
{ phase:'jewish-trial', phaseLabel:'Phase II', phaseTitle:'The Jewish Trial', events:[
|
||||
{
|
||||
title:'Jesus Led to Annas (John)',
|
||||
body:"Jesus is first brought to Annas, father-in-law of Caiaphas, who questions Jesus about his disciples and teaching.",
|
||||
gospels:['john'],
|
||||
refs:'John 18:12–14, 19–24',
|
||||
unique:'Unique to John. After the interrogation, Annas sends Jesus bound to Caiaphas.',
|
||||
bsbLinks:{ john:BSB.john(18) }
|
||||
},
|
||||
{
|
||||
title:"Peter Follows to the Courtyard",
|
||||
body:"Peter and another disciple follow Jesus to the high priest's courtyard. Peter stands warming himself by the fire.",
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 26:58 · Mark 14:54 · Luke 22:54–55 · John 18:15–16',
|
||||
unique:"John identifies 'another disciple' known to the high priest who helped Peter gain entry.",
|
||||
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
|
||||
},
|
||||
{
|
||||
title:'The Trial Before Caiaphas & the Sanhedrin',
|
||||
body:"The Sanhedrin assembles and seeks false testimony against Jesus. Many false witnesses come, but their testimony does not agree.",
|
||||
gospels:['matt','mark'],
|
||||
refs:'Matt 26:57, 59–61 · Mark 14:53, 55–59',
|
||||
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14) }
|
||||
},
|
||||
{
|
||||
title:'"Are You the Christ?" — The High Priest\'s Question',
|
||||
body:'"I adjure you by the living God, tell us if you are the Christ, the Son of God." Jesus answered, "You have said so. But I tell you, from now on you will see the Son of Man seated at the right hand of Power."',
|
||||
gospels:['matt','mark','luke'],
|
||||
refs:'Matt 26:63–64 · Mark 14:61–62 · Luke 22:66–70',
|
||||
unique:"Luke places a formal Sanhedrin inquiry at daybreak. Mark records Jesus' most direct 'I am' reply.",
|
||||
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22) }
|
||||
},
|
||||
{
|
||||
title:'The Verdict: Blasphemy — Deserving Death',
|
||||
body:'The high priest tears his robes: "He has uttered blasphemy!" The assembly declares Jesus deserves death and begins spitting on him, striking him, and mocking him.',
|
||||
gospels:['matt','mark','luke'],
|
||||
refs:'Matt 26:65–68 · Mark 14:63–65 · Luke 22:63–65',
|
||||
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22) }
|
||||
},
|
||||
{
|
||||
title:"Peter's Three Denials",
|
||||
body:"As Jesus is being tried, Peter denies knowing him three times — to a servant girl, another servant, and bystanders — and the rooster crows.",
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 26:69–75 · Mark 14:66–72 · Luke 22:56–62 · John 18:17–18, 25–27',
|
||||
unique:'Luke alone records that Jesus turned and looked at Peter, and Peter went out and wept bitterly.',
|
||||
bsbLinks:{ matt:BSB.matt(26), mark:BSB.mark(14), luke:BSB.luke(22), john:BSB.john(18) }
|
||||
},
|
||||
{
|
||||
title:"Judas's Remorse & Death (Matthew)",
|
||||
body:"Judas sees that Jesus has been condemned, brings the thirty pieces of silver back to the chief priests, and goes and hangs himself. The priests use the money to buy the Potter's Field.",
|
||||
gospels:['matt'],
|
||||
refs:'Matt 27:3–10',
|
||||
unique:'Unique to Matthew. Acts 1:18–19 offers a parallel account of Judas\'s end.',
|
||||
bsbLinks:{ matt:BSB.matt(27) }
|
||||
},
|
||||
{
|
||||
title:'Early Morning Formal Condemnation',
|
||||
body:'At daybreak the chief priests and the whole council hold a formal session and bind Jesus to hand him over to Pilate.',
|
||||
gospels:['matt','mark','luke'],
|
||||
refs:'Matt 27:1–2 · Mark 15:1 · Luke 22:66–23:1',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||
},
|
||||
]},
|
||||
|
||||
// ── PHASE 3: ROMAN TRIAL ──────────────────────────────────────
|
||||
{ phase:'roman-trial', phaseLabel:'Phase III', phaseTitle:'The Roman Trial Before Pilate', events:[
|
||||
{
|
||||
title:'Jesus Brought Before Pilate',
|
||||
body:"The Jewish leaders bring Jesus to Pilate's headquarters. They do not enter so as not to be defiled before Passover. They accuse Jesus of misleading the nation, opposing taxes, and claiming to be King.",
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 27:2,11 · Mark 15:1–2 · Luke 23:1–2 · John 18:28–32',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(18) }
|
||||
},
|
||||
{
|
||||
title:'"Are You the King of the Jews?" — Pilate\'s First Question',
|
||||
body:'"Are you the King of the Jews?" Jesus replies, "You have said so" — or in John\'s longer account, "My kingdom is not of this world."',
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 27:11 · Mark 15:2 · Luke 23:3 · John 18:33–37',
|
||||
unique:'John records the extensive private exchange between Pilate and Jesus about kingship and truth.',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(18) }
|
||||
},
|
||||
{
|
||||
title:'Jesus Silent Before Accusations',
|
||||
body:'As the chief priests heap on charges, Jesus gives no answer, so that Pilate is greatly amazed.',
|
||||
gospels:['matt','mark'],
|
||||
refs:'Matt 27:12–14 · Mark 15:3–5',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15) }
|
||||
},
|
||||
{
|
||||
title:'Jesus Sent to Herod Antipas (Luke)',
|
||||
body:"Learning Jesus is from Galilee, Pilate sends him to Herod, who is in Jerusalem. Herod questions Jesus at length but Jesus says nothing. Herod mocks him, dresses him in splendid clothing, and sends him back.",
|
||||
gospels:['luke'],
|
||||
refs:'Luke 23:6–12',
|
||||
unique:'Unique to Luke. Luke notes this made Herod and Pilate friends that day.',
|
||||
bsbLinks:{ luke:BSB.luke(23) }
|
||||
},
|
||||
{
|
||||
title:'Pilate Declares Jesus Innocent',
|
||||
body:'Pilate announces he finds no guilt in Jesus, and that neither does Herod. He offers to release one prisoner according to the custom.',
|
||||
gospels:['luke','john'],
|
||||
refs:'Luke 23:13–16 · John 18:38–19:6',
|
||||
unique:"Luke alone notes three formal declarations of innocence by Pilate.",
|
||||
bsbLinks:{ luke:BSB.luke(23), john:BSB.john(18) }
|
||||
},
|
||||
{
|
||||
title:'Barabbas Released — Jesus Condemned',
|
||||
body:'The crowd chooses to release Barabbas, a notorious rebel and murderer, instead of Jesus. The crowd shouts "Crucify him!"',
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 27:15–23 · Mark 15:6–14 · Luke 23:18–23 · John 18:39–40',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(18) }
|
||||
},
|
||||
{
|
||||
title:"Pilate's Wife's Warning (Matthew)",
|
||||
body:'"Have nothing to do with that righteous man, for I have suffered much over him today in a dream."',
|
||||
gospels:['matt'],
|
||||
refs:'Matt 27:19',
|
||||
unique:'Unique to Matthew.',
|
||||
bsbLinks:{ matt:BSB.matt(27) }
|
||||
},
|
||||
{
|
||||
title:"Pilate Washes His Hands (Matthew)",
|
||||
body:'Pilate takes water and washes his hands before the crowd: "I am innocent of this man\'s blood." The people respond, "His blood be on us and on our children."',
|
||||
gospels:['matt'],
|
||||
refs:'Matt 27:24–25',
|
||||
unique:'Unique to Matthew.',
|
||||
bsbLinks:{ matt:BSB.matt(27) }
|
||||
},
|
||||
{
|
||||
title:'"Behold the Man" — Ecce Homo (John)',
|
||||
body:'Pilate leads Jesus out wearing the crown of thorns and a purple robe: "Behold the man!" The chief priests cry "Crucify him!"',
|
||||
gospels:['john'],
|
||||
refs:'John 19:4–7',
|
||||
unique:'Unique to John.',
|
||||
bsbLinks:{ john:BSB.john(19) }
|
||||
},
|
||||
{
|
||||
title:'"Son of God" Accusation Frightens Pilate (John)',
|
||||
body:'When the Jews say Jesus made himself the Son of God, Pilate is even more afraid and questions Jesus again about his origin, but Jesus gives no answer.',
|
||||
gospels:['john'],
|
||||
refs:'John 19:8–11',
|
||||
unique:"Unique to John — Jesus' declaration that Pilate's authority comes from above.",
|
||||
bsbLinks:{ john:BSB.john(19) }
|
||||
},
|
||||
{
|
||||
title:"The Soldiers' Mockery",
|
||||
body:"Pilate's soldiers strip Jesus, put a scarlet/purple robe on him, press a crown of thorns on his head, put a reed in his right hand, kneel before him mockingly, spit on him, and strike him.",
|
||||
gospels:['matt','mark','john'],
|
||||
refs:'Matt 27:27–31 · Mark 15:16–20 · John 19:2–3',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), john:BSB.john(19) }
|
||||
},
|
||||
{
|
||||
title:'Final Condemnation — Jesus Handed Over',
|
||||
body:'Pilate, seeking to release Jesus but fearful of the cry "You are no friend of Caesar!", finally hands Jesus over to be crucified.',
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 27:26 · Mark 15:15 · Luke 23:24–25 · John 19:12–16',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
|
||||
},
|
||||
]},
|
||||
|
||||
// ── PHASE 4: CRUCIFIXION ──────────────────────────────────────
|
||||
{ phase:'crucifixion', phaseLabel:'Phase IV', phaseTitle:'The Way of the Cross & Crucifixion', events:[
|
||||
{
|
||||
title:'Jesus Carries His Cross',
|
||||
body:'Jesus goes out carrying his own cross to the place called Golgotha, "The Place of a Skull."',
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 27:31–32 · Mark 15:20–21 · Luke 23:26 · John 19:17',
|
||||
unique:'John uniquely emphasizes Jesus carried his own cross (echoing Isaac carrying wood in Gen 22). The Synoptics record Simon of Cyrene being compelled to carry it.',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
|
||||
},
|
||||
{
|
||||
title:'Simon of Cyrene Carries the Cross',
|
||||
body:'Coming in from the country, Simon of Cyrene is seized and compelled to carry the cross behind Jesus.',
|
||||
gospels:['matt','mark','luke'],
|
||||
refs:'Matt 27:32 · Mark 15:21 · Luke 23:26',
|
||||
unique:"Mark names Simon's sons, Alexander and Rufus, suggesting they were known to Mark's community.",
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||
},
|
||||
{
|
||||
title:'Jesus Addresses the Daughters of Jerusalem (Luke)',
|
||||
body:'"Daughters of Jerusalem, do not weep for me, but weep for yourselves and for your children... For if they do these things when the wood is green, what will happen when it is dry?"',
|
||||
gospels:['luke'],
|
||||
refs:'Luke 23:27–31',
|
||||
unique:'Unique to Luke.',
|
||||
bsbLinks:{ luke:BSB.luke(23) }
|
||||
},
|
||||
{
|
||||
title:'The Offer of Wine Mixed with Gall/Myrrh',
|
||||
body:'Before the crucifixion, soldiers offer Jesus wine mixed with gall (or myrrh) — a narcotic to dull the pain — but Jesus refuses after tasting it.',
|
||||
gospels:['matt','mark'],
|
||||
refs:'Matt 27:34 · Mark 15:23',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15) }
|
||||
},
|
||||
{
|
||||
title:'The Crucifixion — The Third Hour',
|
||||
body:'Jesus is crucified between two criminals. The soldiers cast lots for his garments, fulfilling Psalm 22:18.',
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 27:35 · Mark 15:24–25 · Luke 23:33 · John 19:18, 23–24',
|
||||
unique:"John uniquely emphasizes the lot-casting fulfills Scripture and describes the tunic as seamless (echoing the high priest's garment).",
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
|
||||
},
|
||||
{
|
||||
title:'The Title on the Cross: "King of the Jews"',
|
||||
body:'Pilate has a notice written in Hebrew, Greek, and Latin: "Jesus of Nazareth, the King of the Jews."',
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 27:37 · Mark 15:26 · Luke 23:38 · John 19:19–22',
|
||||
unique:'John alone records the Jewish leaders\' protest and Pilate\'s firm reply: "What I have written, I have written."',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
|
||||
},
|
||||
{
|
||||
title:'The Mockery at the Cross',
|
||||
body:'Passersby wag their heads: "You who would destroy the temple, save yourself!" The chief priests, scribes, and elders join in mocking.',
|
||||
gospels:['matt','mark','luke'],
|
||||
refs:'Matt 27:39–44 · Mark 15:29–32 · Luke 23:35–37',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||
},
|
||||
{
|
||||
title:'One Criminal Repents — "Remember Me" (Luke)',
|
||||
body:'One criminal joins the mockery. The other rebukes him and says, "Jesus, remember me when you come into your kingdom." Jesus replies, "Today you will be with me in paradise."',
|
||||
gospels:['luke'],
|
||||
refs:'Luke 23:39–43',
|
||||
unique:'Unique to Luke — the only death-row conversion recorded in the Gospels.',
|
||||
bsbLinks:{ luke:BSB.luke(23) }
|
||||
},
|
||||
{
|
||||
title:"Jesus Entrusts His Mother to the Beloved Disciple (John)",
|
||||
body:'"Woman, behold your son... Behold your mother." From that hour the disciple took her into his own home.',
|
||||
gospels:['john'],
|
||||
refs:'John 19:25–27',
|
||||
unique:'Unique to John.',
|
||||
bsbLinks:{ john:BSB.john(19) }
|
||||
},
|
||||
{
|
||||
title:'Darkness Over the Land — Three Hours',
|
||||
body:'From the sixth hour (noon) to the ninth hour (3 p.m.), darkness covers all the land.',
|
||||
gospels:['matt','mark','luke'],
|
||||
refs:'Matt 27:45 · Mark 15:33 · Luke 23:44',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||
},
|
||||
{
|
||||
title:'"My God, My God, Why Have You Forsaken Me?"',
|
||||
body:'About the ninth hour Jesus cries out, "Eloi, Eloi, lema sabachthani?" — quoting Psalm 22:1. Some bystanders think he is calling for Elijah.',
|
||||
gospels:['matt','mark'],
|
||||
refs:'Matt 27:46–47 · Mark 15:34–35',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15) }
|
||||
},
|
||||
{
|
||||
title:'"I Thirst" (John)',
|
||||
body:'Jesus says "I thirst." A sponge soaked in sour wine is put on a hyssop branch and offered to him.',
|
||||
gospels:['john'],
|
||||
refs:'John 19:28–29',
|
||||
unique:"John frames this as Jesus knowing 'all was now finished, to fulfill Scripture' (Ps 22:15; 69:21). The hyssop branch echoes the Passover lamb.",
|
||||
bsbLinks:{ john:BSB.john(19) }
|
||||
},
|
||||
{
|
||||
title:'"Father, Forgive Them" (Luke)',
|
||||
body:'"Father, forgive them, for they know not what they do."',
|
||||
gospels:['luke'],
|
||||
refs:'Luke 23:34',
|
||||
unique:'Unique to Luke.',
|
||||
bsbLinks:{ luke:BSB.luke(23) }
|
||||
},
|
||||
{
|
||||
title:'"It Is Finished" — Tetelestai (John)',
|
||||
body:'Jesus says, "It is finished" (τετέλεσται — tetelestai, meaning "paid in full"). He bows his head and gives up his spirit.',
|
||||
gospels:['john'],
|
||||
refs:'John 19:30',
|
||||
unique:'Unique to John — the climactic proclamation of completed redemption.',
|
||||
bsbLinks:{ john:BSB.john(19) }
|
||||
},
|
||||
{
|
||||
title:'"Father, Into Your Hands I Commit My Spirit" (Luke)',
|
||||
body:'With a loud cry, Jesus says, "Father, into your hands I commit my spirit!" (quoting Psalm 31:5), and breathes his last.',
|
||||
gospels:['luke'],
|
||||
refs:'Luke 23:46',
|
||||
unique:'Unique to Luke.',
|
||||
bsbLinks:{ luke:BSB.luke(23) }
|
||||
},
|
||||
{
|
||||
title:'Jesus Breathes His Last & The Temple Curtain Torn',
|
||||
body:'Jesus utters a loud cry and breathes his last. The curtain of the temple is torn in two, from top to bottom.',
|
||||
gospels:['matt','mark','luke'],
|
||||
refs:'Matt 27:50–51 · Mark 15:37–38 · Luke 23:45–46',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||
},
|
||||
{
|
||||
title:'Earthquake, Rocks Split & Tombs Opened (Matthew)',
|
||||
body:'The earth shakes and rocks split. Tombs are opened and many saints who had fallen asleep are raised, appearing to many in Jerusalem after the resurrection.',
|
||||
gospels:['matt'],
|
||||
refs:'Matt 27:51–53',
|
||||
unique:'Unique to Matthew — a remarkable eschatological sign accompanying the death of Christ.',
|
||||
bsbLinks:{ matt:BSB.matt(27) }
|
||||
},
|
||||
{
|
||||
title:"The Centurion's Confession",
|
||||
body:'The Roman centurion, seeing how Jesus died, declares: "Truly this man was the Son of God!" (Matt/Mark) or "Certainly this man was innocent!" (Luke).',
|
||||
gospels:['matt','mark','luke'],
|
||||
refs:'Matt 27:54 · Mark 15:39 · Luke 23:47',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||
},
|
||||
{
|
||||
title:'The Women Watching from a Distance',
|
||||
body:'Many women who followed Jesus from Galilee look on from a distance, including Mary Magdalene, Mary the mother of James and Joseph, and Salome.',
|
||||
gospels:['matt','mark','luke'],
|
||||
refs:'Matt 27:55–56 · Mark 15:40–41 · Luke 23:49',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||
},
|
||||
{
|
||||
title:"Soldiers Break No Bones — Spear Thrust (John)",
|
||||
body:"To hasten death, soldiers break the legs of the two criminals. Finding Jesus already dead, a soldier pierces his side with a spear, and blood and water immediately flow out.",
|
||||
gospels:['john'],
|
||||
refs:'John 19:31–37',
|
||||
unique:"Unique to John. He sees this fulfilling 'Not one of his bones will be broken' (Ex 12:46; Ps 34:20) and Zechariah 12:10.",
|
||||
bsbLinks:{ john:BSB.john(19) }
|
||||
},
|
||||
]},
|
||||
|
||||
// ── PHASE 5: BURIAL ──────────────────────────────────────────
|
||||
{ phase:'burial', phaseLabel:'Phase V', phaseTitle:'The Burial of Jesus', events:[
|
||||
{
|
||||
title:'Joseph of Arimathea Requests the Body',
|
||||
body:"Joseph of Arimathea, a respected council member and secret disciple, goes to Pilate and asks for the body of Jesus. Pilate confirms with the centurion that Jesus is dead and grants the request.",
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 27:57–58 · Mark 15:42–45 · Luke 23:50–52 · John 19:38',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
|
||||
},
|
||||
{
|
||||
title:'Nicodemus Brings Spices (John)',
|
||||
body:"Nicodemus, who had come to Jesus by night, brings a mixture of myrrh and aloes — about seventy-five pounds.",
|
||||
gospels:['john'],
|
||||
refs:'John 19:39',
|
||||
unique:'Unique to John. The extravagant amount of spices honors Jesus as royalty.',
|
||||
bsbLinks:{ john:BSB.john(19) }
|
||||
},
|
||||
{
|
||||
title:'The Body Wrapped & Laid in the Tomb',
|
||||
body:'The body is wrapped in a clean linen shroud and laid in a new tomb cut from rock, in which no one had yet been buried. A large stone is rolled against the entrance.',
|
||||
gospels:['matt','mark','luke','john'],
|
||||
refs:'Matt 27:59–60 · Mark 15:46 · Luke 23:53 · John 19:40–42',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23), john:BSB.john(19) }
|
||||
},
|
||||
{
|
||||
title:'The Women See Where He Is Laid',
|
||||
body:'Mary Magdalene and Mary the mother of Joses observe where Jesus is laid. They return home and prepare spices and ointments.',
|
||||
gospels:['matt','mark','luke'],
|
||||
refs:'Matt 27:61 · Mark 15:47 · Luke 23:55–56',
|
||||
bsbLinks:{ matt:BSB.matt(27), mark:BSB.mark(15), luke:BSB.luke(23) }
|
||||
},
|
||||
{
|
||||
title:'The Guard Placed at the Tomb (Matthew)',
|
||||
body:"The chief priests and Pharisees ask Pilate to secure the tomb, fearing the disciples will steal the body and claim a resurrection. Pilate gives them a guard; they seal the stone.",
|
||||
gospels:['matt'],
|
||||
refs:'Matt 27:62–66',
|
||||
unique:'Unique to Matthew — sets up the resurrection narrative in Matthew 28.',
|
||||
bsbLinks:{ matt:BSB.matt(27) }
|
||||
},
|
||||
]},
|
||||
];
|
||||
|
||||
function buildTimeline() {
|
||||
const container = document.getElementById('timeline');
|
||||
container.innerHTML = '';
|
||||
const colorMap = { matt:'var(--matt-color)', mark:'var(--mark-color)', luke:'var(--luke-color)', john:'var(--john-color)' };
|
||||
|
||||
data.forEach(phase => {
|
||||
const phHeader = document.createElement('div');
|
||||
phHeader.className = 'phase-header';
|
||||
phHeader.setAttribute('data-phase', phase.phase);
|
||||
phHeader.innerHTML = `<div class="phase-node"></div><span class="phase-label">${phase.phaseLabel}</span><span class="phase-title">${phase.phaseTitle}</span>`;
|
||||
container.appendChild(phHeader);
|
||||
|
||||
phase.events.forEach(ev => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'event-card';
|
||||
card.setAttribute('data-gospels', ev.gospels.join(' '));
|
||||
card.setAttribute('data-phase', phase.phase);
|
||||
|
||||
const borderColor = ev.gospels.length === 4 ? 'var(--crimson)' : colorMap[ev.gospels[0]];
|
||||
card.style.borderLeftColor = borderColor;
|
||||
|
||||
const badges = ev.gospels.map(g => {
|
||||
const url = ev.bsbLinks && ev.bsbLinks[g];
|
||||
const label = g.charAt(0).toUpperCase() + g.slice(1);
|
||||
if (url) {
|
||||
return `<a class="badge badge-${g}" href="${url}" target="_blank" rel="noopener" title="Open ${label} in BSB">${label} ↗</a>`;
|
||||
}
|
||||
return `<span class="badge badge-${g}">${label}</span>`;
|
||||
}).join('');
|
||||
|
||||
const uniqueNote = ev.unique ? `<div class="unique-note">✦ ${ev.unique}</div>` : '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="event-title">${ev.title}</div>
|
||||
<div class="event-body">${ev.body}</div>
|
||||
<div class="gospel-badges">${badges}</div>
|
||||
<div class="ref-text">${ev.refs}</div>
|
||||
${uniqueNote}
|
||||
`;
|
||||
container.appendChild(card);
|
||||
});
|
||||
});
|
||||
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
document.querySelectorAll('.event-card').forEach(card => {
|
||||
const cPhase = card.getAttribute('data-phase');
|
||||
const cGospels = card.getAttribute('data-gospels').split(' ');
|
||||
const phaseOk = activePhase === 'all' || cPhase === activePhase;
|
||||
const gospelOk = cGospels.some(g => activeGospels.has(g));
|
||||
card.classList.toggle('hidden', !phaseOk || !gospelOk);
|
||||
});
|
||||
document.querySelectorAll('.phase-header').forEach(h => {
|
||||
const hPhase = h.getAttribute('data-phase');
|
||||
h.style.display = (activePhase === 'all' || hPhase === activePhase) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function toggleGospel(gospel, el) {
|
||||
if (activeGospels.has(gospel)) {
|
||||
if (activeGospels.size === 1) return;
|
||||
activeGospels.delete(gospel);
|
||||
el.classList.add('dimmed');
|
||||
} else {
|
||||
activeGospels.add(gospel);
|
||||
el.classList.remove('dimmed');
|
||||
}
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function filterPhase(phase, btn) {
|
||||
activePhase = phase;
|
||||
document.querySelectorAll('.filter-btn').forEach(b => {
|
||||
b.classList.remove('active-filter');
|
||||
b.style.background = 'transparent';
|
||||
b.style.color = 'var(--parchment-dark)';
|
||||
b.style.borderColor = '#555';
|
||||
});
|
||||
btn.classList.add('active-filter');
|
||||
btn.style.background = 'var(--gold)';
|
||||
btn.style.color = 'var(--ink)';
|
||||
btn.style.borderColor = 'var(--gold)';
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
buildTimeline();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,69 +0,0 @@
|
||||
<?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-06-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/start-here</loc>
|
||||
<lastmod>2026-06-01</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/episodes</loc>
|
||||
<lastmod>2026-06-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/study</loc>
|
||||
<lastmod>2026-06-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.85</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/resources</loc>
|
||||
<lastmod>2026-06-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/questions</loc>
|
||||
<lastmod>2026-06-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/about</loc>
|
||||
<lastmod>2026-06-01</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/contact</loc>
|
||||
<lastmod>2026-06-01</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/subscribe</loc>
|
||||
<lastmod>2026-06-01</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/privacy</loc>
|
||||
<lastmod>2026-06-01</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://versebyversewithnate.us/terms</loc>
|
||||
<lastmod>2026-06-01</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -0,0 +1,92 @@
|
||||
import { execSync } from 'child_process'
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
const BASE = "/Users/nate.emmert/Documents/github/Siteforge/Verse by Verse with Nate Complete Series"
|
||||
const CHATBOT_FILE = "/Users/nate.emmert/Documents/github/Siteforge/data/chatbot-content.json"
|
||||
|
||||
const FILES = [
|
||||
{ file: `${BASE}/Done/Verse_by_Verse_with_Nate_Episode02.docx`, ep: 2 },
|
||||
{ file: `${BASE}/Done/Verse_by_Verse_with_Nate_Episode03.docx`, ep: 3 },
|
||||
{ file: `${BASE}/Done/Verse_by_Verse_with_Nate_Episode04_Updated.docx`, ep: 4 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode05.docx`, ep: 5 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode06_expanded.docx`, ep: 6 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode07.docx`, ep: 7 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode08.docx`, ep: 8 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode09.docx`, ep: 9 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode10.docx`, ep: 10 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode11.docx`, ep: 11 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode12.docx`, ep: 12 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode13.docx`, ep: 13 },
|
||||
{ file: `${BASE}/Verse_by_Verse_with_Nate_Episode14.docx`, ep: 14 },
|
||||
]
|
||||
|
||||
function extractText(filePath) {
|
||||
const xml = execSync(`unzip -p "${filePath}" word/document.xml 2>/dev/null`, { encoding: 'utf8' })
|
||||
return xml
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function parseEpisode(raw, epNum) {
|
||||
// Extract episode subtitle and passage reference from header
|
||||
const headerMatch = raw.match(/EPISODE\s+\d+\s*[—\-\u2013\u2014]+\s*(.+?)\s+(Titus\s+[\d:]+(?:\s*[\-\u2013\u2014]+\s*[\d:]+)?)\s*·/i)
|
||||
const subtitle = headerMatch ? headerMatch[1].trim().replace(/\s+/g, ' ') : ''
|
||||
const passageRef = headerMatch ? headerMatch[2].trim() : 'Titus'
|
||||
const episodeTitle = `Episode ${epNum} — ${subtitle || 'Verse by Verse with Nate'}`
|
||||
|
||||
// Find where the actual teaching content starts
|
||||
let contentStart = raw.indexOf('SEGMENT 1')
|
||||
if (contentStart === -1) contentStart = raw.indexOf('WHO WAS PAUL')
|
||||
if (contentStart === -1) contentStart = raw.indexOf('COLD OPEN')
|
||||
if (contentStart === -1) contentStart = 400
|
||||
|
||||
const rawContent = raw.slice(contentStart, contentStart + 4000)
|
||||
const content = rawContent
|
||||
.replace(/\[[^\]]{0,100}\]/g, '') // remove [stage directions]
|
||||
.replace(/[✝🎙️📖💬🧠💡🔑✅◀▶]/gu, '') // remove emoji
|
||||
.replace(/SEGMENT\s+\d+\s*[—\-\u2013]+\s*/g, '\n\n') // turn SEGMENT headers into breaks
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim()
|
||||
|
||||
// Build keyword list
|
||||
const verseRefs = [...new Set((raw.match(/Titus\s+\d+:\d+/g) || []))].slice(0, 5).map(k => k.toLowerCase())
|
||||
const titleWords = subtitle.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(w => w.length > 3)
|
||||
const keywords = [...new Set([
|
||||
'titus', `episode ${epNum}`, passageRef.toLowerCase(),
|
||||
...verseRefs, ...titleWords
|
||||
])].slice(0, 20)
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
type: 'episode',
|
||||
title: `${episodeTitle} (${passageRef})`,
|
||||
content: content.slice(0, 3900),
|
||||
keywords,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// Load existing entries (keep the 8 hand-written ones)
|
||||
const existing = JSON.parse(readFileSync(CHATBOT_FILE, 'utf8'))
|
||||
// Remove any previously generated episode entries to avoid duplication
|
||||
const baseEntries = existing.filter(e => e.type !== 'episode')
|
||||
|
||||
const newEntries = []
|
||||
for (const { file, ep } of FILES) {
|
||||
try {
|
||||
const raw = extractText(file)
|
||||
const entry = parseEpisode(raw, ep)
|
||||
newEntries.push(entry)
|
||||
console.log(`✓ Ep ${ep}: ${entry.title.slice(0, 80)}`)
|
||||
} catch (err) {
|
||||
console.error(`✗ Ep ${ep}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const combined = [...baseEntries, ...newEntries]
|
||||
writeFileSync(CHATBOT_FILE, JSON.stringify(combined, null, 2), 'utf8')
|
||||
console.log(`\nDone. ${newEntries.length} episode entries added. Total: ${combined.length}`)
|
||||
@@ -0,0 +1,137 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const root = process.cwd()
|
||||
const dataPath = path.join(root, 'data', 'chatbot-content.json')
|
||||
const evalPath = path.join(root, 'data', 'chatbot-eval.json')
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'a','an','the','is','are','was','were','be','been','being','have','has','had','do','does','did',
|
||||
'will','would','could','should','may','might','shall','can','i','you','he','she','it','we','they',
|
||||
'me','him','her','us','them','my','your','his','its','our','their','this','that','these','those',
|
||||
'and','but','or','nor','so','yet','for','of','in','on','at','to','from','with','by','about',
|
||||
'what','how','why','when','where','who','which','if','then','than','as','just','not',
|
||||
])
|
||||
|
||||
const TOKEN_ALIASES = {
|
||||
bible: ['translation', 'version', 'scripture', 'bsb', 'berean'],
|
||||
translation: ['version', 'bsb', 'berean', 'bible'],
|
||||
version: ['translation', 'bsb', 'berean', 'bible'],
|
||||
elders: ['elder', 'leadership', 'leaders', 'overseer', 'pastor'],
|
||||
leadership: ['elders', 'elder', 'overseer', 'leaders'],
|
||||
grace: ['salvation', 'saved', 'godliness', 'mercy'],
|
||||
salvation: ['saved', 'grace', 'mercy', 'gospel'],
|
||||
saved: ['salvation', 'grace', 'mercy', 'gospel'],
|
||||
hope: ['blessed', 'appearing', 'return', 'coming'],
|
||||
politics: ['public', 'government', 'authorities'],
|
||||
}
|
||||
|
||||
function tokenize(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(token => token.length > 2 && !STOP_WORDS.has(token))
|
||||
}
|
||||
|
||||
function expandTokens(tokens) {
|
||||
const expanded = new Set(tokens)
|
||||
for (const token of tokens) {
|
||||
const aliases = TOKEN_ALIASES[token] ?? []
|
||||
for (const alias of aliases) {
|
||||
for (const aliasToken of tokenize(alias)) expanded.add(aliasToken)
|
||||
}
|
||||
}
|
||||
return [...expanded]
|
||||
}
|
||||
|
||||
function literalTerms(text) {
|
||||
return [...new Set(
|
||||
text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9:\-\s']/g, ' ')
|
||||
.split(/\s+/)
|
||||
.map(term => term.trim())
|
||||
.filter(term => term.length >= 2 && !STOP_WORDS.has(term)),
|
||||
)]
|
||||
}
|
||||
|
||||
function scoreEntry(entry, query) {
|
||||
const indexText = `${entry.title} ${entry.content} ${entry.keywords.join(' ')}`.toLowerCase()
|
||||
const queryTokens = expandTokens(tokenize(query))
|
||||
const terms = literalTerms(query)
|
||||
|
||||
let score = 0
|
||||
|
||||
for (const token of queryTokens) {
|
||||
if (entry.title.toLowerCase().includes(token)) score += 4
|
||||
else if (indexText.includes(token)) score += 2
|
||||
}
|
||||
|
||||
for (const term of terms) {
|
||||
if (entry.title.toLowerCase().includes(term)) score += 2
|
||||
else if (indexText.includes(term)) score += 1
|
||||
}
|
||||
|
||||
const phrase = query.toLowerCase().replace(/[^a-z0-9:\-\s']/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (phrase.length > 6) {
|
||||
if (entry.title.toLowerCase().includes(phrase)) score += 10
|
||||
else if (indexText.includes(phrase)) score += 6
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [contentRaw, evalRaw] = await Promise.all([
|
||||
fs.readFile(dataPath, 'utf8'),
|
||||
fs.readFile(evalPath, 'utf8'),
|
||||
])
|
||||
|
||||
const entries = JSON.parse(contentRaw)
|
||||
const tests = JSON.parse(evalRaw)
|
||||
|
||||
let pass = 0
|
||||
const failures = []
|
||||
|
||||
for (const test of tests) {
|
||||
const ranked = entries
|
||||
.map(entry => ({ entry, score: scoreEntry(entry, test.query) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
const topK = ranked.slice(0, test.expectedTopK)
|
||||
const hit = topK.some(item => item.entry.id === test.expectedEntryId)
|
||||
|
||||
if (hit) {
|
||||
pass += 1
|
||||
continue
|
||||
}
|
||||
|
||||
failures.push({
|
||||
query: test.query,
|
||||
expectedEntryId: test.expectedEntryId,
|
||||
expectedTopK: test.expectedTopK,
|
||||
actualTop: topK.map(item => ({ id: item.entry.id, title: item.entry.title, score: item.score })),
|
||||
})
|
||||
}
|
||||
|
||||
const total = tests.length
|
||||
const pct = ((pass / total) * 100).toFixed(1)
|
||||
|
||||
console.log(`Chatbot eval: ${pass}/${total} (${pct}%) passed`)
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log('\nFailures:')
|
||||
for (const failure of failures) {
|
||||
console.log(`- Query: ${failure.query}`)
|
||||
console.log(` Expected: ${failure.expectedEntryId} in top ${failure.expectedTopK}`)
|
||||
console.log(` Actual: ${failure.actualTop.map(item => `${item.id} (${item.score})`).join(', ')}`)
|
||||
}
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
|
||||
const path = '/Users/nate.emmert/Documents/github/Siteforge/data/chatbot-content.json'
|
||||
const data = JSON.parse(readFileSync(path, 'utf8'))
|
||||
|
||||
const fixes = {
|
||||
2: { title: 'Episode 2 — Introduction to Titus (Background & Overview)', extra: ['introduction', 'background', 'overview', 'crete', 'letter'] },
|
||||
5: { title: 'Episode 5 — The Danger of Empty Words (Titus 1:10–13a)', extra: ['danger', 'empty', 'words', 'false', 'teacher', 'titus 1:10', 'titus 1:13'] },
|
||||
6: { title: 'Episode 6 — Words That Deny What We Claim to Believe (Titus 1:13b–16)', extra: ['deny', 'claim', 'believe', 'titus 1:13', 'titus 1:16'] },
|
||||
14: { title: 'Episode 14 — Grace: Where It Starts and Where It Ends (Titus 3:12–15)', extra: ['grace', 'starts', 'ends', 'review', 'titus 3:12', 'titus 3:15'] },
|
||||
}
|
||||
|
||||
let count = 0
|
||||
for (const entry of data) {
|
||||
const m = entry.title.match(/^Episode (\d+)/)
|
||||
if (!m) continue
|
||||
const ep = Number(m[1])
|
||||
if (fixes[ep]) {
|
||||
entry.title = fixes[ep].title
|
||||
entry.keywords = [...new Set([...entry.keywords, ...fixes[ep].extra])].slice(0, 20)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(path, JSON.stringify(data, null, 2), 'utf8')
|
||||
console.log(`Fixed ${count} entries. Total: ${data.length}`)
|
||||
@@ -1,274 +0,0 @@
|
||||
import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { parseCookies, cookieSecureFlag } from './helpers.js'
|
||||
import { DATA_DIR } from './paths.js'
|
||||
|
||||
const TOTP_SECRET_FILE = path.join(DATA_DIR, 'totp-secret.json')
|
||||
|
||||
// Pending sessions: password verified, waiting for TOTP code
|
||||
// Map<pendingToken, { expiresAt }>
|
||||
const TOTP_PENDING_TTL_MS = 5 * 60 * 1000
|
||||
const totpPendingSessions = new Map()
|
||||
|
||||
const ADMIN_SESSION_COOKIE = 'vbn_admin_session'
|
||||
const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const ADMIN_SESSION_ABSOLUTE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD
|
||||
const adminSessions = new Map()
|
||||
|
||||
function cookieFlags() {
|
||||
return cookieSecureFlag()
|
||||
}
|
||||
|
||||
export function sha256(value) {
|
||||
return createHash('sha256').update(String(value)).digest('hex')
|
||||
}
|
||||
|
||||
export function isAdminPasswordConfigured() {
|
||||
return Boolean(ADMIN_PASSWORD)
|
||||
}
|
||||
|
||||
export function validateAdminPasswordSetup() {
|
||||
if (!isAdminPasswordConfigured() && process.env.NODE_ENV === 'production') {
|
||||
throw new Error('ADMIN_PASSWORD is required in production.')
|
||||
}
|
||||
if (!isAdminPasswordConfigured()) {
|
||||
console.warn('ADMIN_PASSWORD is not configured; admin routes will remain disabled until the environment is configured.')
|
||||
}
|
||||
}
|
||||
|
||||
export function isAdminPasswordValid(password) {
|
||||
if (!isAdminPasswordConfigured()) return false
|
||||
const a = Buffer.from(sha256(password), 'utf8')
|
||||
const b = Buffer.from(sha256(ADMIN_PASSWORD), 'utf8')
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
// ── TOTP (RFC 6238) — implemented with Node built-in crypto ─────────────────
|
||||
|
||||
const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
|
||||
function base32Decode(str) {
|
||||
const s = str.toUpperCase().replace(/=+$/, '')
|
||||
let bits = 0
|
||||
let value = 0
|
||||
const output = []
|
||||
for (const char of s) {
|
||||
const idx = BASE32_CHARS.indexOf(char)
|
||||
if (idx === -1) continue
|
||||
value = (value << 5) | idx
|
||||
bits += 5
|
||||
if (bits >= 8) {
|
||||
output.push((value >>> (bits - 8)) & 0xff)
|
||||
bits -= 8
|
||||
}
|
||||
}
|
||||
return Buffer.from(output)
|
||||
}
|
||||
|
||||
function base32Encode(buf) {
|
||||
let bits = 0
|
||||
let value = 0
|
||||
let output = ''
|
||||
for (const byte of buf) {
|
||||
value = (value << 8) | byte
|
||||
bits += 8
|
||||
while (bits >= 5) {
|
||||
output += BASE32_CHARS[(value >>> (bits - 5)) & 0x1f]
|
||||
bits -= 5
|
||||
}
|
||||
}
|
||||
if (bits > 0) output += BASE32_CHARS[(value << (5 - bits)) & 0x1f]
|
||||
return output
|
||||
}
|
||||
|
||||
function totpToken(secret, counter) {
|
||||
const key = base32Decode(secret)
|
||||
const msg = Buffer.alloc(8)
|
||||
// Write 64-bit big-endian counter
|
||||
const hi = Math.floor(counter / 0x100000000)
|
||||
const lo = counter >>> 0
|
||||
msg.writeUInt32BE(hi, 0)
|
||||
msg.writeUInt32BE(lo, 4)
|
||||
const hmac = createHmac('sha1', key).update(msg).digest()
|
||||
const offset = hmac[hmac.length - 1] & 0x0f
|
||||
const code = ((hmac[offset] & 0x7f) << 24)
|
||||
| (hmac[offset + 1] << 16)
|
||||
| (hmac[offset + 2] << 8)
|
||||
| hmac[offset + 3]
|
||||
return String(code % 1000000).padStart(6, '0')
|
||||
}
|
||||
|
||||
export function generateTotpSecret() {
|
||||
const buf = Buffer.allocUnsafe(20)
|
||||
randomFillSync(buf)
|
||||
return base32Encode(buf)
|
||||
}
|
||||
|
||||
export async function loadTotpState() {
|
||||
try {
|
||||
const raw = await readFile(TOTP_SECRET_FILE, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTotpState(state) {
|
||||
await writeFile(TOTP_SECRET_FILE, JSON.stringify(state, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
export async function isTotpEnabled() {
|
||||
const state = await loadTotpState()
|
||||
return Boolean(state?.secret && state?.verified)
|
||||
}
|
||||
|
||||
function randomBytesForRecovery(n) {
|
||||
const buf = Buffer.allocUnsafe(n)
|
||||
randomFillSync(buf)
|
||||
return buf
|
||||
}
|
||||
|
||||
export function getTotpUri(secret, label = 'Siteforge Admin') {
|
||||
const issuer = 'Siteforge'
|
||||
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`
|
||||
}
|
||||
|
||||
export function verifyTotpCode(secret, code) {
|
||||
try {
|
||||
const token = String(code).replace(/\s/g, '')
|
||||
const step = Math.floor(Date.now() / 1000 / 30)
|
||||
// Accept current step and one step either side (±30 seconds clock skew)
|
||||
for (const offset of [-1, 0, 1]) {
|
||||
if (totpToken(secret, step + offset) === token) return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recovery Codes ──────────────────────────────────────────────────────────
|
||||
|
||||
const RECOVERY_CODE_COUNT = 8
|
||||
|
||||
function generateRecoveryCode() {
|
||||
// Format: XXXX-XXXX-XXXX (uppercase alphanumeric, no ambiguous chars)
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
const randBytes = randomBytesForRecovery(12)
|
||||
let code = ''
|
||||
for (let i = 0; i < 12; i++) {
|
||||
if (i > 0 && i % 4 === 0) code += '-'
|
||||
code += chars[randBytes[i] % chars.length]
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
export function generateRecoveryCodes() {
|
||||
const codes = []
|
||||
for (let i = 0; i < RECOVERY_CODE_COUNT; i++) {
|
||||
codes.push(generateRecoveryCode())
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
export function hashRecoveryCode(code) {
|
||||
return sha256(code.replace(/-/g, '').toUpperCase())
|
||||
}
|
||||
|
||||
// Returns the matched code if valid, null otherwise. Mutates state.hashedRecoveryCodes.
|
||||
export function consumeRecoveryCode(state, inputCode) {
|
||||
if (!Array.isArray(state.hashedRecoveryCodes) || state.hashedRecoveryCodes.length === 0) return false
|
||||
const normalized = inputCode.replace(/[-\s]/g, '').toUpperCase()
|
||||
const inputHash = sha256(normalized)
|
||||
const idx = state.hashedRecoveryCodes.findIndex(h => {
|
||||
const a = Buffer.from(h, 'utf8')
|
||||
const b = Buffer.from(inputHash, 'utf8')
|
||||
return a.length === b.length && timingSafeEqual(a, b)
|
||||
})
|
||||
if (idx === -1) return false
|
||||
state.hashedRecoveryCodes.splice(idx, 1)
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Pending (password-ok, awaiting TOTP) sessions ───────────────────────────
|
||||
|
||||
export function createPendingSession() {
|
||||
const token = randomUUID()
|
||||
totpPendingSessions.set(token, { expiresAt: Date.now() + TOTP_PENDING_TTL_MS })
|
||||
return token
|
||||
}
|
||||
|
||||
export function consumePendingSession(token) {
|
||||
if (!token) return false
|
||||
const entry = totpPendingSessions.get(token)
|
||||
if (!entry || entry.expiresAt <= Date.now()) {
|
||||
totpPendingSessions.delete(token)
|
||||
return false
|
||||
}
|
||||
totpPendingSessions.delete(token)
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Admin Sessions ───────────────────────────────────────────────────────────
|
||||
|
||||
export function createAdminSession() {
|
||||
const token = randomUUID()
|
||||
const now = Date.now()
|
||||
adminSessions.set(token, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt: now + ADMIN_SESSION_ABSOLUTE_TTL_MS })
|
||||
return token
|
||||
}
|
||||
|
||||
export function deleteAdminSession(token) {
|
||||
if (token) {
|
||||
adminSessions.delete(token)
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidAdminSession(req) {
|
||||
if (!isAdminPasswordConfigured()) return false
|
||||
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
const sessionToken = cookies[ADMIN_SESSION_COOKIE]
|
||||
if (!sessionToken) return false
|
||||
|
||||
const now = Date.now()
|
||||
const session = adminSessions.get(sessionToken)
|
||||
if (!session) return false
|
||||
|
||||
// Support legacy sessions stored as a plain number (expiresAt)
|
||||
const expiresAt = typeof session === 'object' ? session.expiresAt : session
|
||||
const absoluteExpiresAt = typeof session === 'object' ? session.absoluteExpiresAt : Infinity
|
||||
|
||||
if (expiresAt <= now || absoluteExpiresAt <= now) {
|
||||
adminSessions.delete(sessionToken)
|
||||
return false
|
||||
}
|
||||
|
||||
adminSessions.set(sessionToken, { expiresAt: now + ADMIN_SESSION_TTL_MS, absoluteExpiresAt })
|
||||
return true
|
||||
}
|
||||
|
||||
export function setAdminSessionCookie(res, token) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function clearAdminSessionCookie(res) {
|
||||
res.append(
|
||||
'Set-Cookie',
|
||||
`${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function requireAdminAuth(req, res, next) {
|
||||
if (!isValidAdminSession(req)) {
|
||||
res.status(401).json({ message: 'Unauthorized' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||