From d6d2c087b269c70a5eef9122f4ee9828002cbab1 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 12 Oct 2026 08:04:10 -0400 Subject: [PATCH] Add Podcast Production Checklist (Lane A / Lane B) to admin; v2.14.0 Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 4 +- package.json | 2 +- server.js | 2 + server/routes/podcast-checklist.js | 47 ++++++++ src/AdminPage.tsx | 176 ++++++++++++++++++++++++++++- 5 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 server/routes/podcast-checklist.js diff --git a/package-lock.json b/package-lock.json index 9fc2b80..d9f846b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.13.0", + "version": "2.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.13.0", + "version": "2.14.0", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index f76cc29..3d843c9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.13.0", + "version": "2.14.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index ead46aa..143a3a8 100644 --- a/server.js +++ b/server.js @@ -70,6 +70,7 @@ 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' @@ -128,6 +129,7 @@ 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 new file mode 100644 index 0000000..6e57df0 --- /dev/null +++ b/server/routes/podcast-checklist.js @@ -0,0 +1,47 @@ +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 9e31b5d..c2d4c9d 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -730,7 +730,7 @@ type AdminView = | 'start-here' | 'about:show-copy' | 'about:bio' | 'about:testimonials' | 'contact:profile' | 'contact:copy' - | 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss' + | 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:checklist' | 'podcast:rss' | 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow' | 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources' | 'inbox' @@ -782,6 +782,7 @@ const ADMIN_NAV: NavGroup[] = [ { kind: 'leaf', view: 'podcast:highlights', label: 'Ep. Highlights' }, { kind: 'leaf', view: 'podcast:finished-books', label: 'Finished Books' }, { kind: 'leaf', view: 'podcast:scripts', label: 'Episode Scripts' }, + { kind: 'leaf', view: 'podcast:checklist', label: 'Production Checklist' }, { kind: 'leaf', view: 'podcast:rss', label: 'RSS Feed' }, ]}, { kind: 'parent', id: 'nuggets', label: 'Nuggets', children: [ @@ -1798,6 +1799,178 @@ 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 ChecklistEpisode { + id: string + number: number + title: string + datePublished: string + tasks: Record +} +type ChecklistLane = 'A' | 'B' + +function PodcastChecklistAdmin({ laneATitle, laneBTitle }: { laneATitle: string; laneBTitle: string }) { + const [data, setData] = useState>({ A: [], B: [] }) + const [loaded, setLoaded] = useState(false) + const [lane, setLane] = useState('A') + const [openId, setOpenId] = useState('') + const [status, setStatus] = useState('') + const [bulk, setBulk] = useState('1') + const saveTimer = useRef(null) + + useEffect(() => { + fetch('/api/admin/podcast-checklist') + .then(r => r.json()) + .then(d => setData({ A: d.A ?? [], B: d.B ?? [] })) + .catch(() => setStatus('Could not load checklist.')) + .finally(() => setLoaded(true)) + }, []) + + const persist = (next: Record) => { + setData(next) + setStatus('Saving…') + if (saveTimer.current) window.clearTimeout(saveTimer.current) + saveTimer.current = window.setTimeout(async () => { + try { + const r = await fetch('/api/admin/podcast-checklist', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(next), + }) + setStatus(r.ok ? 'Saved' : 'Save failed') + } catch { + setStatus('Save failed') + } + }, 600) + } + + const episodes = data[lane] + const update = (id: string, patch: Partial) => + persist({ ...data, [lane]: 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 = () => { + 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 added: ChecklistEpisode[] = Array.from({ length: n }, (_, i) => ({ + id: crypto.randomUUID(), + number: start + i, + title: '', + datePublished: '', + tasks: Object.fromEntries(CHECKLIST_ALL.map(t => [t.id, false])), + })) + persist({ ...data, [lane]: [...episodes, ...added] }) + } + + const removeEpisode = (id: string) => { + if (!confirm('Delete this episode from the checklist?')) return + persist({ ...data, [lane]: 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 renderTasks = (ep: ChecklistEpisode, tasks: typeof CHECKLIST_PRE) => ( +
    + {tasks.map(t => ( +
  • + +
  • + ))} +
+ ) + + return ( +
+
+

Podcast — Production Checklist

+

Track every episode from script to newsletter, one lane per running series.

+
+
+ {(['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)} + +
+ )} +
+ ) + })} +
+ setBulk(e.target.value)} style={{ width: 70 }} aria-label="Number of episodes to add" /> + +
+ + )} +
+ ) +} + export default function AdminPage({ content, onSave, onLogout }: Props) { const normalizedContent = normalizeSiteContentForAdmin(content) const [form, setForm] = useState(normalizedContent) @@ -5992,6 +6165,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) )} {/* EPISODE SCRIPTS */} + {adminView === 'podcast:checklist' && } {adminView === 'podcast:scripts' && (