Remove standalone podcast-checklist app; admin checklist now uses the existing checklist backend; v2.14.1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-12 08:06:32 -04:00
1 parent d6d2c087b2
commit 66b7bbef52
10 files changed
+101 -683

No files matched your search

+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.14.0",
"version": "2.14.1",
"type": "module",
"scripts": {
"dev": "vite",
-18
View File
@@ -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"]
-63
View File
@@ -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.
-12
View File
@@ -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"
}
}
-401
View File
@@ -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>
-43
View File
@@ -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}`));
-2
View File
@@ -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)
-47
View File
@@ -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 })
})
}
+98 -94
View File
@@ -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<string, boolean>
[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<Record<ChecklistLane, ChecklistEpisode[]>>({ A: [], B: [] })
const [loaded, setLoaded] = useState(false)
const [checklist, setChecklist] = useState<PodcastChecklist | null>(null)
const [lane, setLane] = useState<ChecklistLane>('A')
const [openId, setOpenId] = useState('')
const [status, setStatus] = useState('')
@@ -1834,23 +1822,22 @@ function PodcastChecklistAdmin({ laneATitle, laneBTitle }: { laneATitle: string;
const saveTimer = useRef<number | null>(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<ChecklistLane, ChecklistEpisode[]>) => {
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 (
<section className="admin-panel-section" aria-label="Podcast production checklist">
<div className="admin-panel-head"><h2>Podcast — Production Checklist</h2></div>
<p className="admin-stats-note">{status || 'Loading…'}</p>
</section>
)
}
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<ChecklistEpisode>) =>
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') => (
<ul style={{ listStyle: 'none', padding: 0, margin: '4px 0 10px' }}>
{tasks.map(t => (
{tasks.filter(t => t.phase === phase).map(t => (
<li key={t.id} style={{ padding: '4px 0' }}>
<label style={{ display: 'flex', gap: 8, cursor: 'pointer', alignItems: 'flex-start' }}>
<input type="checkbox" checked={Boolean(ep.tasks[t.id])} onChange={() => toggle(ep, t.id)} style={{ marginTop: 4 }} />
<span>
<span style={{ textDecoration: ep.tasks[t.id] ? 'line-through' : 'none', opacity: ep.tasks[t.id] ? 0.6 : 1 }}>{t.label}</span>
<span style={{ display: 'block', fontSize: '0.78rem', opacity: 0.65 }}>{t.desc}</span>
</span>
<label style={{ display: 'flex', gap: 8, cursor: 'pointer', alignItems: 'center' }}>
<input type="checkbox" checked={Boolean(ep.tasks[t.id])} onChange={() => toggle(ep, t.id)} />
<span style={{ textDecoration: ep.tasks[t.id] ? 'line-through' : 'none', opacity: ep.tasks[t.id] ? 0.6 : 1 }}>{t.label}</span>
</label>
</li>
))}
@@ -1907,65 +1918,58 @@ function PodcastChecklistAdmin({ laneATitle, laneBTitle }: { laneATitle: string;
<section className="admin-panel-section" aria-label="Podcast production checklist">
<div className="admin-panel-head">
<h2>Podcast — Production Checklist</h2>
<p>Track every episode from script to newsletter, one lane per running series.</p>
<p>Track every episode from script to newsletter, one lane per running series. Published dates feed the calendar and reminders.</p>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
{(['A', 'B'] as ChecklistLane[]).map(l => (
<button
key={l}
type="button"
className={lane === l ? 'btn-admin-save' : 'btn-admin-ghost'}
onClick={() => { setLane(l); setOpenId('') }}
>
Lane {l} — {(l === 'A' ? laneATitle : laneBTitle) || (l === 'A' ? 'Primary series' : 'Second series')} ({data[l].filter(e => doneCount(e) === CHECKLIST_ALL.length).length}/{data[l].length})
{lanes.map(l => (
<button key={l.key} type="button" className={lane === l.key ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => { setLane(l.key); setOpenId('') }}>
{l.label} ({laneEpisodes(l.key).filter(isDone).length}/{laneEpisodes(l.key).length})
</button>
))}
<span className="admin-stats-note" style={{ alignSelf: 'center' }}>{status}</span>
</div>
{!loaded ? <p className="admin-stats-note">Loading…</p> : (
<>
<p className="admin-stats-note">{laneDone} of {episodes.length} episodes complete.</p>
{sorted.map(ep => {
const done = doneCount(ep)
const open = openId === ep.id
return (
<div key={ep.id} style={{ border: '1px solid var(--color-border, #e5e0d5)', borderRadius: 6, marginBottom: 6 }}>
<button
type="button"
onClick={() => 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' }}
>
<strong>Ep {ep.number}</strong>
<span style={{ flex: 1 }}>{ep.title}</span>
{ep.datePublished && <span style={{ fontSize: '0.8rem', opacity: 0.7 }}>{ep.datePublished}</span>}
<span style={{ fontSize: '0.8rem' }}>{done}/{CHECKLIST_ALL.length}{done === CHECKLIST_ALL.length ? ' ✓' : ''}</span>
</button>
{open && (
<div style={{ padding: '0 12px 12px' }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 8 }}>
<input type="number" aria-label="Episode number" value={ep.number} min={0} style={{ width: 80 }}
onChange={e => update(ep.id, { number: Number(e.target.value) || 0 })} />
<input type="text" aria-label="Episode title" placeholder="Episode title (optional)" value={ep.title} style={{ flex: 1, minWidth: 180 }}
onChange={e => update(ep.id, { title: e.target.value })} />
<input type="date" aria-label="Date published" value={ep.datePublished}
onChange={e => update(ep.id, { datePublished: e.target.value })} />
</div>
<strong style={{ fontSize: '0.8rem' }}>Pre-publish</strong>
{renderTasks(ep, CHECKLIST_PRE)}
<strong style={{ fontSize: '0.8rem' }}>Post-publish</strong>
{renderTasks(ep, CHECKLIST_POST)}
<button type="button" className="btn-admin-remove" onClick={() => removeEpisode(ep.id)}>Delete episode</button>
</div>
)}
{sorted.map(ep => {
const done = doneCount(ep)
const open = openId === ep.id
return (
<div key={ep.id} style={{ border: '1px solid var(--color-border, #e5e0d5)', borderRadius: 6, marginBottom: 6 }}>
<button
type="button"
onClick={() => 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' }}
>
<strong>Ep {ep.episodeNumber ?? '?'}</strong>
<span style={{ flex: 1 }}>{ep.title}</span>
{ep.datePublished && <span style={{ fontSize: '0.8rem', opacity: 0.7 }}>{ep.datePublished}</span>}
<span style={{ fontSize: '0.8rem' }}>{done}/{tasks.length}{isDone(ep) ? ' ✓' : ''}</span>
</button>
{open && (
<div style={{ padding: '0 12px 12px' }}>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 8 }}>
<input type="number" aria-label="Episode number" value={ep.episodeNumber ?? ''} min={0} style={{ width: 80 }}
onChange={e => update(ep.id, { episodeNumber: e.target.value === '' ? null : Number(e.target.value) })} />
<input type="text" aria-label="Episode title" placeholder="Episode title (optional)" value={ep.title} style={{ flex: 1, minWidth: 180 }}
onChange={e => update(ep.id, { title: e.target.value })} />
<input type="date" aria-label="Date published" value={ep.datePublished}
onChange={e => update(ep.id, { datePublished: e.target.value })} />
</div>
<strong style={{ fontSize: '0.8rem' }}>Pre-publish</strong>
{renderTasks(ep, 'pre')}
<strong style={{ fontSize: '0.8rem' }}>Post-publish</strong>
{renderTasks(ep, 'post')}
<button type="button" className="btn-admin-remove" onClick={() => removeEpisode(ep.id)}>Delete episode</button>
</div>
)
})}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 12 }}>
<input type="number" min={1} max={50} value={bulk} onChange={e => setBulk(e.target.value)} style={{ width: 70 }} aria-label="Number of episodes to add" />
<button type="button" className="btn-admin-add" onClick={addEpisodes}>Add episode(s) to Lane {lane}</button>
)}
</div>
</>
)
})}
{sorted.length === 0 && <p className="admin-stats-note">No episodes in this lane yet.</p>}
{lane !== 'other' && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 12 }}>
<input type="number" min={1} max={50} value={bulk} onChange={e => setBulk(e.target.value)} style={{ width: 70 }} aria-label="Number of episodes to add" />
<button type="button" className="btn-admin-add" onClick={addEpisodes}>Add episode(s) to Lane {lane}</button>
</div>
)}
</section>
)