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 - - - - -
- -
-
Verse by Verse with Nate
-
Production Checklist
-
Titus · Colossians
-
-
- -
- -
-
-
Overall Progress
-
0%
-
-
-
-
Titus — 0 / 5 episodes done
-
Colossians — 0 / 27 episodes done
-
-
- -
- - - -
- -
Loading…
-
- - - - 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 => ( - ))} {status}
    - {!loaded ?

    Loading…

    : ( - <> -

    {laneDone} of {episodes.length} episodes complete.

    - {sorted.map(ep => { - const done = doneCount(ep) - const open = openId === ep.id - return ( -
    - - {open && ( -
    -
    - update(ep.id, { number: Number(e.target.value) || 0 })} /> - update(ep.id, { title: e.target.value })} /> - update(ep.id, { datePublished: e.target.value })} /> -
    - Pre-publish - {renderTasks(ep, CHECKLIST_PRE)} - Post-publish - {renderTasks(ep, CHECKLIST_POST)} - -
    - )} + {sorted.map(ep => { + const done = doneCount(ep) + const open = openId === ep.id + return ( +
    + + {open && ( +
    +
    + update(ep.id, { episodeNumber: e.target.value === '' ? null : Number(e.target.value) })} /> + update(ep.id, { title: e.target.value })} /> + update(ep.id, { datePublished: e.target.value })} /> +
    + Pre-publish + {renderTasks(ep, 'pre')} + Post-publish + {renderTasks(ep, 'post')} +
    - ) - })} -
    - setBulk(e.target.value)} style={{ width: 70 }} aria-label="Number of episodes to add" /> - + )}
    - + ) + })} + {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" /> + +
    )}
    )