diff --git a/package-lock.json b/package-lock.json
index d9f846b..2cf4342 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "siteforge",
- "version": "2.14.0",
+ "version": "2.14.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "siteforge",
- "version": "2.14.0",
+ "version": "2.14.1",
"dependencies": {
"@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1",
diff --git a/package.json b/package.json
index 3d843c9..541e9ae 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
- "version": "2.14.0",
+ "version": "2.14.1",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/podcast-checklist/Dockerfile b/podcast-checklist/Dockerfile
deleted file mode 100644
index eb99c1a..0000000
--- a/podcast-checklist/Dockerfile
+++ /dev/null
@@ -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"]
diff --git a/podcast-checklist/README.md b/podcast-checklist/README.md
deleted file mode 100644
index 9b3b113..0000000
--- a/podcast-checklist/README.md
+++ /dev/null
@@ -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.
diff --git a/podcast-checklist/package.json b/podcast-checklist/package.json
deleted file mode 100644
index b6e1815..0000000
--- a/podcast-checklist/package.json
+++ /dev/null
@@ -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"
- }
-}
diff --git a/podcast-checklist/public/index.html b/podcast-checklist/public/index.html
deleted file mode 100644
index 0a28f1e..0000000
--- a/podcast-checklist/public/index.html
+++ /dev/null
@@ -1,401 +0,0 @@
-
-
-
-
-
- Episode Checklist — Verse by Verse with Nate
-
-
-
-
-
-
-
-
-
-
-
-
-
Overall Progress
-
0%
-
-
-
-
Titus — 0 / 5 episodes done
-
Colossians — 0 / 27 episodes done
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/podcast-checklist/server.js b/podcast-checklist/server.js
deleted file mode 100644
index efded97..0000000
--- a/podcast-checklist/server.js
+++ /dev/null
@@ -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}`));
diff --git a/server.js b/server.js
index 143a3a8..ead46aa 100644
--- a/server.js
+++ b/server.js
@@ -70,7 +70,6 @@ import { register as registerEpisodes } from './server/routes/episodes.js'
import { register as registerNuggets } from './server/routes/nuggets.js'
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js'
-import { register as registerPodcastChecklist } from './server/routes/podcast-checklist.js'
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
import { register as registerFeed } from './server/routes/feed.js'
import { register as registerQrCodes } from './server/routes/qr-codes.js'
@@ -129,7 +128,6 @@ registerEpisodes(app)
registerNuggets(app)
registerNuggetsHostUpload(app)
registerNuggetsWorkflow(app)
-registerPodcastChecklist(app)
registerFeed(app)
registerQrCodes(app)
registerBiblePassage(app)
diff --git a/server/routes/podcast-checklist.js b/server/routes/podcast-checklist.js
deleted file mode 100644
index 6e57df0..0000000
--- a/server/routes/podcast-checklist.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import { randomUUID } from 'node:crypto'
-import { readFile } from 'node:fs/promises'
-import path from 'node:path'
-import { writeFile } from '../atomic-write.js'
-import { requireAdminAuth } from '../auth.js'
-import { DATA_DIR } from '../config.js'
-
-const CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
-
-export const CHECKLIST_TASK_IDS = [
- 'verify_script', 'read_script', 'record', 'edit', 'mix', 'video_script', 'post_spotify',
- 'update_website', 'send_email',
-]
-
-const LANES = ['A', 'B']
-
-function sanitizeEpisodes(raw) {
- if (!Array.isArray(raw)) return []
- return raw.slice(0, 500).filter(e => e && typeof e === 'object').map(e => ({
- id: typeof e.id === 'string' && e.id ? e.id.slice(0, 64) : randomUUID(),
- number: Number.isFinite(Number(e.number)) ? Number(e.number) : 0,
- title: typeof e.title === 'string' ? e.title.trim().slice(0, 160) : '',
- datePublished: typeof e.datePublished === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(e.datePublished) ? e.datePublished : '',
- tasks: Object.fromEntries(CHECKLIST_TASK_IDS.map(id => [id, e.tasks?.[id] === true])),
- }))
-}
-
-async function load() {
- try {
- const data = JSON.parse(await readFile(CHECKLIST_FILE, 'utf8'))
- return Object.fromEntries(LANES.map(l => [l, sanitizeEpisodes(data[l])]))
- } catch {
- return { A: [], B: [] }
- }
-}
-
-export function register(app) {
- app.get('/api/admin/podcast-checklist', requireAdminAuth, async (_req, res) => {
- res.json(await load())
- })
-
- app.put('/api/admin/podcast-checklist', requireAdminAuth, async (req, res) => {
- const data = Object.fromEntries(LANES.map(l => [l, sanitizeEpisodes(req.body?.[l])]))
- await writeFile(CHECKLIST_FILE, JSON.stringify(data, null, 2), 'utf8')
- res.json({ ok: true })
- })
-}
diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx
index c2d4c9d..e429f17 100644
--- a/src/AdminPage.tsx
+++ b/src/AdminPage.tsx
@@ -1800,33 +1800,21 @@ function NuggetsWorkflowAdmin() {
}
// ── Podcast production checklist (Lane A / Lane B) ─────────────────────────
-const CHECKLIST_PRE = [
- { id: 'verify_script', label: 'Verify Script', desc: 'Review for accuracy, theology, and flow' },
- { id: 'read_script', label: 'Read Script', desc: 'Read aloud, mark pacing & emphasis' },
- { id: 'record', label: 'Record', desc: 'Capture final audio take' },
- { id: 'edit', label: 'Edit', desc: 'Trim, cut, clean up' },
- { id: 'mix', label: 'Mix', desc: 'Levels, EQ, music beds, final master' },
- { id: 'video_script', label: 'Run Video Conversion Script', desc: 'Generate video file from audio' },
- { id: 'post_spotify', label: 'Post on Spotify', desc: 'Upload with show notes & discussion questions' },
-]
-const CHECKLIST_POST = [
- { id: 'update_website', label: 'Update Website', desc: 'Add episode highlights to the site' },
- { id: 'send_email', label: 'Send Email', desc: 'Send newsletter to subscribers' },
-]
-const CHECKLIST_ALL = [...CHECKLIST_PRE, ...CHECKLIST_POST]
-
+interface ChecklistTask { id: string; label: string; phase: 'pre' | 'post' }
interface ChecklistEpisode {
id: string
- number: number
+ series: string
+ episodeNumber: number | null
title: string
datePublished: string
tasks: Record
+ [extra: string]: unknown // reminderDays, startTime etc. are preserved untouched
}
-type ChecklistLane = 'A' | 'B'
+interface PodcastChecklist { tasks: ChecklistTask[]; episodes: ChecklistEpisode[] }
+type ChecklistLane = 'A' | 'B' | 'other'
function PodcastChecklistAdmin({ laneATitle, laneBTitle }: { laneATitle: string; laneBTitle: string }) {
- const [data, setData] = useState>({ A: [], B: [] })
- const [loaded, setLoaded] = useState(false)
+ const [checklist, setChecklist] = useState(null)
const [lane, setLane] = useState('A')
const [openId, setOpenId] = useState('')
const [status, setStatus] = useState('')
@@ -1834,23 +1822,22 @@ function PodcastChecklistAdmin({ laneATitle, laneBTitle }: { laneATitle: string;
const saveTimer = useRef(null)
useEffect(() => {
- fetch('/api/admin/podcast-checklist')
+ fetch('/api/admin-podcast-checklist')
.then(r => r.json())
- .then(d => setData({ A: d.A ?? [], B: d.B ?? [] }))
+ .then(d => setChecklist(d.checklist))
.catch(() => setStatus('Could not load checklist.'))
- .finally(() => setLoaded(true))
}, [])
- const persist = (next: Record) => {
- setData(next)
+ const persist = (next: PodcastChecklist) => {
+ setChecklist(next)
setStatus('Saving…')
if (saveTimer.current) window.clearTimeout(saveTimer.current)
saveTimer.current = window.setTimeout(async () => {
try {
- const r = await fetch('/api/admin/podcast-checklist', {
+ const r = await fetch('/api/admin-podcast-checklist', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(next),
+ body: JSON.stringify({ checklist: next }),
})
setStatus(r.ok ? 'Saved' : 'Save failed')
} catch {
@@ -1859,44 +1846,68 @@ function PodcastChecklistAdmin({ laneATitle, laneBTitle }: { laneATitle: string;
}, 600)
}
- const episodes = data[lane]
+ if (!checklist) {
+ return (
+
+ Podcast — Production Checklist
+ {status || 'Loading…'}
+
+ )
+ }
+
+ const matches = (series: string, title: string) => {
+ const a = series.trim().toLowerCase()
+ const b = title.trim().toLowerCase()
+ return Boolean(a && b && (b.includes(a) || a.includes(b)))
+ }
+ const laneOf = (e: ChecklistEpisode): ChecklistLane =>
+ matches(e.series, laneATitle) ? 'A' : matches(e.series, laneBTitle) ? 'B' : 'other'
+ const laneEpisodes = (l: ChecklistLane) => checklist.episodes.filter(e => laneOf(e) === l)
+ const tasks = checklist.tasks
+ const doneCount = (e: ChecklistEpisode) => tasks.filter(t => e.tasks[t.id]).length
+ const isDone = (e: ChecklistEpisode) => tasks.length > 0 && doneCount(e) === tasks.length
+
const update = (id: string, patch: Partial) =>
- persist({ ...data, [lane]: episodes.map(e => (e.id === id ? { ...e, ...patch } : e)) })
+ persist({ ...checklist, episodes: checklist.episodes.map(e => (e.id === id ? { ...e, ...patch } : e)) })
const toggle = (ep: ChecklistEpisode, taskId: string) =>
update(ep.id, { tasks: { ...ep.tasks, [taskId]: !ep.tasks[taskId] } })
const addEpisodes = () => {
+ if (lane === 'other') return
+ const seriesName = (lane === 'A' ? laneATitle : laneBTitle).trim()
+ if (!seriesName) { setStatus('Set this lane’s series title in Current Series first.'); return }
const n = Math.max(1, Math.min(50, parseInt(bulk, 10) || 1))
- const start = episodes.reduce((m, e) => Math.max(m, e.number), 0) + 1
+ const start = laneEpisodes(lane).reduce((m, e) => Math.max(m, e.episodeNumber ?? 0), 0) + 1
const added: ChecklistEpisode[] = Array.from({ length: n }, (_, i) => ({
id: crypto.randomUUID(),
- number: start + i,
+ series: seriesName,
+ episodeNumber: start + i,
title: '',
datePublished: '',
- tasks: Object.fromEntries(CHECKLIST_ALL.map(t => [t.id, false])),
+ tasks: Object.fromEntries(tasks.map(t => [t.id, false])),
}))
- persist({ ...data, [lane]: [...episodes, ...added] })
+ persist({ ...checklist, episodes: [...checklist.episodes, ...added] })
}
const removeEpisode = (id: string) => {
if (!confirm('Delete this episode from the checklist?')) return
- persist({ ...data, [lane]: episodes.filter(e => e.id !== id) })
+ persist({ ...checklist, episodes: checklist.episodes.filter(e => e.id !== id) })
}
- const doneCount = (e: ChecklistEpisode) => CHECKLIST_ALL.filter(t => e.tasks[t.id]).length
- const laneDone = episodes.filter(e => doneCount(e) === CHECKLIST_ALL.length).length
- const sorted = [...episodes].sort((a, b) => a.number - b.number)
+ const lanes: { key: ChecklistLane; label: string }[] = [
+ { key: 'A', label: `Lane A — ${laneATitle || 'Primary series'}` },
+ { key: 'B', label: `Lane B — ${laneBTitle || 'Second series'}` },
+ ...(laneEpisodes('other').length > 0 ? [{ key: 'other' as ChecklistLane, label: 'Other series' }] : []),
+ ]
+ const sorted = [...laneEpisodes(lane)].sort((a, b) => (a.episodeNumber ?? 0) - (b.episodeNumber ?? 0))
- const renderTasks = (ep: ChecklistEpisode, tasks: typeof CHECKLIST_PRE) => (
+ const renderTasks = (ep: ChecklistEpisode, phase: 'pre' | 'post') => (
- {tasks.map(t => (
+ {tasks.filter(t => t.phase === phase).map(t => (
-
-
))}
@@ -1907,65 +1918,58 @@ function PodcastChecklistAdmin({ laneATitle, laneBTitle }: { laneATitle: string;
Podcast — Production Checklist
-
Track every episode from script to newsletter, one lane per running series.
+
Track every episode from script to newsletter, one lane per running series. Published dates feed the calendar and reminders.
- {(['A', 'B'] as ChecklistLane[]).map(l => (
-
- {!loaded ? Loading…
: (
- <>
- {laneDone} of {episodes.length} episodes complete.
- {sorted.map(ep => {
- const done = doneCount(ep)
- const open = openId === ep.id
- return (
-
-
setOpenId(open ? '' : ep.id)}
- style={{ display: 'flex', width: '100%', gap: 12, padding: '8px 12px', background: 'transparent', border: 0, color: 'inherit', cursor: 'pointer', textAlign: 'left', alignItems: 'center' }}
- >
- Ep {ep.number}
- {ep.title}
- {ep.datePublished && {ep.datePublished}}
- {done}/{CHECKLIST_ALL.length}{done === CHECKLIST_ALL.length ? ' ✓' : ''}
-
- {open && (
-
- )}
+ {sorted.map(ep => {
+ const done = doneCount(ep)
+ const open = openId === ep.id
+ return (
+
+
setOpenId(open ? '' : ep.id)}
+ style={{ display: 'flex', width: '100%', gap: 12, padding: '8px 12px', background: 'transparent', border: 0, color: 'inherit', cursor: 'pointer', textAlign: 'left', alignItems: 'center' }}
+ >
+ Ep {ep.episodeNumber ?? '?'}
+ {ep.title}
+ {ep.datePublished && {ep.datePublished}}
+ {done}/{tasks.length}{isDone(ep) ? ' ✓' : ''}
+
+ {open && (
+
- )
- })}
-
- setBulk(e.target.value)} style={{ width: 70 }} aria-label="Number of episodes to add" />
- Add episode(s) to Lane {lane}
+ )}
- >
+ )
+ })}
+ {sorted.length === 0 &&
No episodes in this lane yet.
}
+ {lane !== 'other' && (
+
+ setBulk(e.target.value)} style={{ width: 70 }} aria-label="Number of episodes to add" />
+ Add episode(s) to Lane {lane}
+
)}
)