From f181cdb182ba5a77f783d24048c730815dc49820 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 12 Oct 2026 08:16:32 -0400 Subject: [PATCH] Add Podcast Episode Workflow (Lane A / Lane B) with Nuggets steps; released dates from the feed; v2.15.0 Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 4 +- package.json | 2 +- server.js | 2 + server/routes/podcast-workflow.js | 142 +++++++++++++++++++ src/AdminPage.tsx | 223 +++++++++++++++++++++++++++++- 5 files changed, 369 insertions(+), 4 deletions(-) create mode 100644 server/routes/podcast-workflow.js diff --git a/package-lock.json b/package-lock.json index dfa8050..3996135 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.14.4", + "version": "2.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.14.4", + "version": "2.15.0", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index ae32178..b662fe2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.14.4", + "version": "2.15.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index 05cab4f..d37e695 100644 --- a/server.js +++ b/server.js @@ -66,6 +66,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 registerPodcastWorkflow } from './server/routes/podcast-workflow.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' @@ -123,6 +124,7 @@ registerEpisodes(app) registerNuggets(app) registerNuggetsHostUpload(app) registerNuggetsWorkflow(app) +registerPodcastWorkflow(app) registerFeed(app) registerQrCodes(app) registerBiblePassage(app) diff --git a/server/routes/podcast-workflow.js b/server/routes/podcast-workflow.js new file mode 100644 index 0000000..bf93f87 --- /dev/null +++ b/server/routes/podcast-workflow.js @@ -0,0 +1,142 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { writeFile } from '../atomic-write.js' +import { requireAdminAuth } from '../auth.js' +import { state } from '../state.js' +import { DATA_DIR } from '../config.js' +import { scriptKey, scriptEpisodeNumber } from '../episode-script-keys.js' +import { fetchAllEpisodes } from './episodes.js' + +const WORKFLOW_FILE = path.join(DATA_DIR, 'podcast-workflow.json') + +// Same steps as the Nuggets workflow. +const STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify'] + +async function load() { + try { + const data = JSON.parse(await readFile(WORKFLOW_FILE, 'utf8')) + return { planned: Array.isArray(data.planned) ? data.planned : [], items: data.items ?? {} } + } catch { + return { planned: [], items: {} } + } +} + +const save = data => writeFile(WORKFLOW_FILE, JSON.stringify(data, null, 2), 'utf8') + +function emptyState() { + return { + steps: Object.fromEntries(STEPS.map(s => [s, null])), + coldOpenText: '', + notes: '', + } +} + +function laneFor(season) { + const content = state.cachedSiteContent ?? {} + const s = String(season ?? '') + if (s && s === String(content.seriesSeason ?? '')) return 'A' + if (s && s === String(content.secondSeries?.season ?? '')) return 'B' + return 'other' +} + +function toIsoDate(pubDate) { + const d = new Date(pubDate) + return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10) +} + +export function register(app) { + app.get('/api/admin/podcast-workflow', requireAdminAuth, async (_req, res) => { + const wf = await load() + let feed = [] + let feedOk = true + try { + feed = await fetchAllEpisodes() + } catch { + feedOk = false + } + + const byKey = new Map() + const upsert = (key, patch) => byKey.set(key, { ...(byKey.get(key) ?? {}), key, ...patch }) + + for (const [key, entry] of Object.entries(state.episodeScripts ?? {})) { + upsert(key, { + season: entry.season ?? '', + episodeNumber: scriptEpisodeNumber(key, entry), + title: entry.title ?? '', + hasScript: true, + }) + } + for (const p of wf.planned) { + const key = scriptKey(p.season, p.episodeNumber) + const prev = byKey.get(key) + upsert(key, { season: p.season, episodeNumber: p.episodeNumber, title: prev?.title || p.title || '', planned: true }) + } + for (const item of feed) { + const hasNum = /^\d+$/.test(item.episode) + const key = hasNum ? scriptKey(item.season, item.episode) : `t:${item.title}` + const prev = byKey.get(key) + upsert(key, { + season: item.season, + episodeNumber: hasNum ? item.episode : '', + title: item.title || prev?.title || '', + released: true, + releaseDate: toIsoDate(item.pubDate), + spotifyUrl: item.link || '', + }) + } + + const items = [...byKey.values()].map(e => ({ + ...e, + lane: laneFor(e.season), + released: Boolean(e.released), + state: { ...emptyState(), ...(wf.items[e.key] ?? {}) }, + })) + items.sort((a, b) => (Number(a.season) || 0) - (Number(b.season) || 0) || (Number(a.episodeNumber) || 0) - (Number(b.episodeNumber) || 0)) + res.json({ items, feedOk }) + }) + + app.post('/api/admin/podcast-workflow/planned', requireAdminAuth, async (req, res) => { + const season = /^\d+$/.test(String(req.body?.season ?? '')) ? String(req.body.season) : '' + const episodeNumber = String(req.body?.episodeNumber ?? '').trim() + const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 160) : '' + if (!/^\d+$/.test(episodeNumber)) { + res.status(400).json({ message: 'Episode number must be a whole number.' }) + return + } + const wf = await load() + const key = scriptKey(season, episodeNumber) + if (!wf.planned.some(p => scriptKey(p.season, p.episodeNumber) === key)) { + wf.planned.push({ season, episodeNumber, title }) + await save(wf) + } + res.json({ ok: true, key }) + }) + + app.delete('/api/admin/podcast-workflow/planned/:key', requireAdminAuth, async (req, res) => { + const wf = await load() + wf.planned = wf.planned.filter(p => scriptKey(p.season, p.episodeNumber) !== req.params.key) + delete wf.items[req.params.key] + await save(wf) + res.json({ ok: true }) + }) + + app.put('/api/admin/podcast-workflow/:key', requireAdminAuth, async (req, res) => { + const { key } = req.params + const wf = await load() + const prev = { ...emptyState(), ...(wf.items[key] ?? {}) } + const next = { ...prev, steps: { ...prev.steps } } + const body = req.body ?? {} + if (body.steps && typeof body.steps === 'object') { + for (const s of STEPS) { + if (typeof body.steps[s] === 'boolean') { + next.steps[s] = body.steps[s] ? (prev.steps[s] ?? new Date().toISOString()) : null + } + } + } + if (typeof body.coldOpenText === 'string') next.coldOpenText = body.coldOpenText.slice(0, 5000) + if (typeof body.notes === 'string') next.notes = body.notes.slice(0, 5000) + wf.items[key] = next + await save(wf) + res.json({ ok: true, state: next }) + }) +} diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 9e31b5d..df75016 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:workflow' | '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:workflow', label: 'Episode Workflow' }, { kind: 'leaf', view: 'podcast:rss', label: 'RSS Feed' }, ]}, { kind: 'parent', id: 'nuggets', label: 'Nuggets', children: [ @@ -1798,6 +1799,225 @@ function NuggetsWorkflowAdmin() { ) } +// ── Podcast Episode Workflow (Lane A / Lane B) ───────────────────────────── +interface PodcastWorkflowItem { + key: string + season: string + episodeNumber: string + title: string + lane: 'A' | 'B' | 'other' + released: boolean + releaseDate?: string + spotifyUrl?: string + hasScript?: boolean + planned?: boolean + state: WorkflowState +} + +function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string; season?: number }; laneB: { title: string; season?: number } }) { + const [items, setItems] = useState([]) + const [feedOk, setFeedOk] = useState(true) + const [loading, setLoading] = useState(true) + const [lane, setLane] = useState<'A' | 'B' | 'other'>('A') + const [selectedKey, setSelectedKey] = useState('') + const [showReleased, setShowReleased] = useState(false) + const [newNumber, setNewNumber] = useState('') + const [newTitle, setNewTitle] = useState('') + const [error, setError] = useState('') + + const reload = () => + fetch('/api/admin/podcast-workflow') + .then(r => r.json()) + .then(d => { setItems(d.items ?? []); setFeedOk(d.feedOk !== false) }) + .catch(() => setError('Could not load episodes.')) + .finally(() => setLoading(false)) + + useEffect(() => { reload() }, []) + + const laneSeason = lane === 'A' ? laneA.season : lane === 'B' ? laneB.season : undefined + const hasOther = items.some(i => i.lane === 'other') + const inLane = items.filter(i => i.lane === lane) + const upcoming = inLane.filter(i => !i.released) + const released = inLane.filter(i => i.released) + const selected = items.find(i => i.key === selectedKey) ?? null + + const save = async (key: string, patch: Record) => { + setError('') + try { + const r = await fetch(`/api/admin/podcast-workflow/${encodeURIComponent(key)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }) + const d = await r.json() + if (!r.ok) throw new Error(d.message ?? 'Save failed.') + setItems(prev => prev.map(i => (i.key === key ? { ...i, state: d.state } : i))) + } catch (e) { + setError(e instanceof Error ? e.message : 'Save failed.') + } + } + const patchLocal = (key: string, patch: Partial) => + setItems(prev => prev.map(i => (i.key === key ? { ...i, state: { ...i.state, ...patch } } : i))) + + const addPlanned = async () => { + setError('') + const r = await fetch('/api/admin/podcast-workflow/planned', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ season: laneSeason ? String(laneSeason) : '', episodeNumber: newNumber, title: newTitle }), + }) + const d = await r.json() + if (!r.ok) { setError(d.message ?? 'Could not add episode.'); return } + setNewNumber(''); setNewTitle('') + await reload() + setSelectedKey(d.key) + } + const removePlanned = async (key: string) => { + if (!confirm('Remove this planned episode and its progress?')) return + await fetch(`/api/admin/podcast-workflow/planned/${encodeURIComponent(key)}`, { method: 'DELETE' }) + setSelectedKey('') + reload() + } + + const label = (i: PodcastWorkflowItem) => `${i.episodeNumber ? `Ep ${i.episodeNumber}` : 'Ep'}${i.title ? ` — ${i.title}` : ''}` + const doneCount = (i: PodcastWorkflowItem) => (i.released ? WORKFLOW_STEP_DEFS.length : WORKFLOW_STEP_DEFS.filter(s => i.state.steps[s.key]).length) + + const row = (i: PodcastWorkflowItem) => ( + + ) + + return ( +
+
+

Podcast — Episode Workflow

+

Every episode for each series: what’s still in progress, and what’s already out (dates come from your Spotify feed).

+
+ {!feedOk &&

Couldn’t reach the podcast feed, so released episodes may be missing right now.

} + {error &&

{error}

} +
+ {([['A', laneA.title || 'Primary series'], ['B', laneB.title || 'Second series']] as const).map(([k, t]) => ( + + ))} + {hasOther && ( + + )} +
+ {lane !== 'other' && !laneSeason && ( +

Set this lane’s Podcast Season Number in Current Series so its episodes can be matched.

+ )} + + {loading ?

Loading…

: ( +
+
+ Not released yet +
+ {upcoming.length === 0 &&

Nothing in progress.

} + {upcoming.map(row)} +
+ {lane !== 'other' && ( +
+ setNewNumber(e.target.value)} style={{ width: 70 }} aria-label="New episode number" /> + setNewTitle(e.target.value)} style={{ flex: 1, minWidth: 120 }} aria-label="New episode title" /> + +
+ )} + + {showReleased && released.map(row)} +
+ +
+ {!selected ?

Select an episode to work on.

: ( + <> +

{label(selected)}

+ {selected.released ? ( +

+ Released {selected.releaseDate} + {selected.spotifyUrl && <> · Listen} +

+ ) : ( +

+ {selected.hasScript ? 'Script uploaded.' : 'No script yet.'} This moves to Released on its own once it shows up in the feed. +

+ )} +
    + {WORKFLOW_STEP_DEFS.map((s, idx) => { + const completedAt = selected.state.steps[s.key] + const checked = selected.released || Boolean(completedAt) + return ( +
  1. + + {s.key === 'coldOpen' && ( +