From db762c5a05b863ea503fec22ec5fb9b69ee969be Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 18 May 2026 08:26:53 -0400 Subject: [PATCH] Add admin podcast checklist UX and persistence updates --- server.js | 172 +++++++++++++++++- src/AdminPage.tsx | 436 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 606 insertions(+), 2 deletions(-) diff --git a/server.js b/server.js index 47971fb..a70e954 100644 --- a/server.js +++ b/server.js @@ -57,6 +57,7 @@ const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json') const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json') const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json') const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json') +const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json') const BACKUP_DIR = path.join(DATA_DIR, 'backups') const UPLOADS_DIR = path.join(DATA_DIR, 'uploads') const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json') @@ -208,6 +209,122 @@ const DEFAULT_PUBLISH_STATE = { publishedAt: null, } +const DEFAULT_PODCAST_CHECKLIST_TASKS = [ + { id: 'verify_script', label: 'Verify Script', phase: 'pre' }, + { id: 'read_script', label: 'Read Script', phase: 'pre' }, + { id: 'record', label: 'Record', phase: 'pre' }, + { id: 'edit', label: 'Edit', phase: 'pre' }, + { id: 'mix', label: 'Mix', phase: 'pre' }, + { id: 'video_script', label: 'Run Video Conversion Script', phase: 'pre' }, + { id: 'post_spotify', label: 'Post on Spotify', phase: 'pre' }, + { id: 'update_website', label: 'Update Website', phase: 'post' }, + { id: 'send_email', label: 'Send Email', phase: 'post' }, +] + +function buildChecklistEpisode(series, number) { + const tasks = {} + for (const task of DEFAULT_PODCAST_CHECKLIST_TASKS) { + tasks[task.id] = false + } + + return { + id: `${series.toLowerCase()}-${number}`, + series, + episodeNumber: number, + title: '', + datePublished: '', + expanded: false, + tasks, + } +} + +function buildDefaultPodcastChecklist() { + const titusEpisodes = [11, 12, 13, 14, 15].map(number => buildChecklistEpisode('Titus', number)) + const colossiansEpisodes = Array.from({ length: 27 }, (_, index) => buildChecklistEpisode('Colossians', index + 1)) + + return { + tasks: DEFAULT_PODCAST_CHECKLIST_TASKS, + episodes: [...titusEpisodes, ...colossiansEpisodes], + } +} + +function sanitizeChecklistTask(task) { + const label = typeof task?.label === 'string' ? task.label.trim().slice(0, 120) : '' + if (!label) return null + + const phase = task?.phase === 'post' ? 'post' : 'pre' + const id = typeof task?.id === 'string' && task.id.trim() ? task.id.trim() : randomUUID() + return { id, label, phase } +} + +function sanitizePodcastChecklist(value) { + const fallback = buildDefaultPodcastChecklist() + const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {} + + const taskInput = Array.isArray(source.tasks) ? source.tasks : fallback.tasks + const seenTaskIds = new Set() + const tasks = [] + + for (const item of taskInput) { + const safeTask = sanitizeChecklistTask(item) + if (!safeTask) continue + if (seenTaskIds.has(safeTask.id)) continue + seenTaskIds.add(safeTask.id) + tasks.push(safeTask) + } + + if (tasks.length === 0) { + for (const task of fallback.tasks) { + tasks.push({ ...task }) + seenTaskIds.add(task.id) + } + } + + const taskIds = tasks.map(task => task.id) + const episodesInput = Array.isArray(source.episodes) ? source.episodes : fallback.episodes + const episodes = [] + + for (const item of episodesInput) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue + + const id = typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID() + const series = typeof item.series === 'string' ? item.series.trim().slice(0, 80) : '' + const title = typeof item.title === 'string' ? item.title.trim().slice(0, 180) : '' + const rawEpisodeNumber = Number(item.episodeNumber) + const episodeNumber = Number.isFinite(rawEpisodeNumber) && rawEpisodeNumber >= 0 + ? Math.round(rawEpisodeNumber) + : null + + const datePublished = typeof item.datePublished === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(item.datePublished.trim()) + ? item.datePublished.trim() + : '' + + const sourceTasks = item.tasks && typeof item.tasks === 'object' && !Array.isArray(item.tasks) + ? item.tasks + : {} + const taskState = {} + for (const taskId of taskIds) { + taskState[taskId] = sourceTasks[taskId] === true + } + + episodes.push({ + id, + series, + episodeNumber, + title, + datePublished, + expanded: item.expanded === true, + tasks: taskState, + }) + } + + if (episodes.length === 0) { + return fallback + } + + return { tasks, episodes } +} + const DEFAULT_REPLY_TEMPLATES = [ { id: 'thanks-for-reaching-out', @@ -238,6 +355,8 @@ let replyTemplates = [...DEFAULT_REPLY_TEMPLATES] let replyTemplatesWritePromise = Promise.resolve() let replyHistory = [] let replyHistoryWritePromise = Promise.resolve() +let podcastChecklist = buildDefaultPodcastChecklist() +let podcastChecklistWritePromise = Promise.resolve() async function loadSiteContentFile(filePath) { const raw = await readFile(filePath, 'utf8') @@ -620,6 +739,31 @@ function loadReplyHistoryFromDisk() { }) } +function loadPodcastChecklistFromDisk() { + return readFile(PODCAST_CHECKLIST_FILE, 'utf8') + .then(raw => { + const parsed = JSON.parse(raw) + podcastChecklist = sanitizePodcastChecklist(parsed?.checklist) + }) + .catch(() => { + podcastChecklist = buildDefaultPodcastChecklist() + }) +} + +function queuePodcastChecklistWrite() { + const updatedAt = new Date().toISOString() + podcastChecklistWritePromise = podcastChecklistWritePromise.then(async () => { + await mkdir(DATA_DIR, { recursive: true }) + await writeFile( + PODCAST_CHECKLIST_FILE, + JSON.stringify({ checklist: podcastChecklist, updatedAt }, null, 2), + 'utf8', + ) + }) + + return podcastChecklistWritePromise +} + function normalizeMessageType(value) { if (value === 'question' || value === 'testimony' || value === 'topic') return value return 'general' @@ -1027,6 +1171,7 @@ async function createBackupSnapshot(reason = 'scheduled') { reason, adminContent: null, draftContent: null, + podcastChecklist, publishState, hitStats, visitorStats, @@ -1187,14 +1332,23 @@ async function restoreFromBackup(filename) { contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions) replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates) replyHistory = sanitizeReplyHistory(parsed?.replyHistory) + podcastChecklist = sanitizePodcastChecklist(parsed?.podcastChecklist) queueHitStatsWrite() queueVisitorStatsWrite() queueContactSubmissionsWrite() queueReplyTemplatesWrite() queueReplyHistoryWrite() + queuePodcastChecklistWrite() - await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise, replyTemplatesWritePromise, replyHistoryWritePromise]) + await Promise.all([ + hitStatsWritePromise, + visitorStatsWritePromise, + contactSubmissionsWritePromise, + replyTemplatesWritePromise, + replyHistoryWritePromise, + podcastChecklistWritePromise, + ]) await refreshContentCaches() await createBackupSnapshot('post-restore') } @@ -1346,6 +1500,21 @@ app.get('/api/admin-content-state', requireAdminAuth, (_req, res) => { }) }) +app.get('/api/admin-podcast-checklist', requireAdminAuth, (_req, res) => { + res.json({ checklist: podcastChecklist }) +}) + +app.put('/api/admin-podcast-checklist', requireAdminAuth, async (req, res) => { + try { + const safeChecklist = sanitizePodcastChecklist(req.body?.checklist) + podcastChecklist = safeChecklist + await queuePodcastChecklistWrite() + res.json({ ok: true, checklist: safeChecklist }) + } catch { + res.status(500).json({ message: 'Failed to save podcast checklist.' }) + } +}) + app.get('/api/site-config', async (_req, res) => { try { const parsed = await loadSiteContentFile(DATA_FILE) @@ -3447,6 +3616,7 @@ Promise.all([ loadStudyUsersFromDisk(), loadStudyNotesFromDisk(), loadDownloadCountsFromDisk(), + loadPodcastChecklistFromDisk(), refreshContentCaches(), ]) .catch(err => { diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index e60a843..717dc11 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -156,11 +156,34 @@ interface ContactReplyConfig { note: string } +type ChecklistPhase = 'pre' | 'post' + +interface PodcastChecklistTask { + id: string + label: string + phase: ChecklistPhase +} + +interface PodcastChecklistEpisode { + id: string + series: string + episodeNumber: number | null + title: string + datePublished: string + expanded: boolean + tasks: Record +} + +interface PodcastChecklistData { + tasks: PodcastChecklistTask[] + episodes: PodcastChecklistEpisode[] +} + type StringField = Exclude type AdminView = | 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact' - | 'current-series' | 'episode-highlights' | 'archived-series' + | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' | 'downloads' | 'custom-links' | 'content-blocks' | 'questions' | 'analytics' | 'assets' | 'colossians-study' | 'emails' | 'subscribers' | 'contacts' @@ -396,6 +419,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [subscriberSearch, setSubscriberSearch] = useState('') const [contactSearch, setContactSearch] = useState('') const [downloadStats, setDownloadStats] = useState>({}) + const [podcastChecklist, setPodcastChecklist] = useState({ tasks: [], episodes: [] }) + const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') + const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('') const [dashboardNow, setDashboardNow] = useState(() => new Date()) const [manualQuestion, setManualQuestion] = useState({ firstName: '', @@ -550,6 +576,16 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { .then(r => (r.ok ? r.json() : Promise.reject())) .then(data => setDownloadStats((data as { counts: Record }).counts ?? {})) .catch(() => {}) + + fetch('/api/admin-podcast-checklist') + .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load podcast checklist')))) + .then(data => { + const checklist = (data as { checklist?: PodcastChecklistData }).checklist + if (checklist?.tasks && checklist?.episodes) { + setPodcastChecklist(checklist) + } + }) + .catch(() => {}) }, []) useEffect(() => { @@ -869,6 +905,149 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { setForm(f => ({ ...f, podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).filter(link => link.id !== id) })) } + function addChecklistTask(phase: ChecklistPhase) { + const id = `task-${Date.now().toString(36)}` + setPodcastChecklist(prev => ({ + tasks: [...prev.tasks, { id, label: '', phase }], + episodes: prev.episodes.map(episode => ({ + ...episode, + tasks: { + ...episode.tasks, + [id]: false, + }, + })), + })) + } + + function updateChecklistTask(id: string, field: 'label' | 'phase', value: string) { + setPodcastChecklist(prev => ({ + ...prev, + tasks: prev.tasks.map(task => task.id === id + ? { + ...task, + [field]: field === 'phase' ? (value === 'post' ? 'post' : 'pre') : value, + } + : task), + })) + } + + function removeChecklistTask(id: string) { + setPodcastChecklist(prev => ({ + tasks: prev.tasks.filter(task => task.id !== id), + episodes: prev.episodes.map(episode => { + const nextTasks = { ...episode.tasks } + delete nextTasks[id] + return { + ...episode, + tasks: nextTasks, + } + }), + })) + } + + function addChecklistEpisode() { + const id = `episode-${Date.now().toString(36)}` + setPodcastChecklist(prev => ({ + ...prev, + episodes: [ + ...prev.episodes, + { + id, + series: 'Colossians', + episodeNumber: null, + title: '', + datePublished: '', + expanded: false, + tasks: Object.fromEntries(prev.tasks.map(task => [task.id, false])), + }, + ], + })) + } + + function updateChecklistEpisode(id: string, field: 'series' | 'episodeNumber' | 'title' | 'datePublished', value: string) { + setPodcastChecklist(prev => ({ + ...prev, + episodes: prev.episodes.map(episode => { + if (episode.id !== id) return episode + if (field === 'episodeNumber') { + const parsed = Number.parseInt(value, 10) + return { + ...episode, + episodeNumber: Number.isNaN(parsed) ? null : parsed, + } + } + return { + ...episode, + [field]: value, + } + }), + })) + } + + function toggleChecklistEpisodeTask(episodeId: string, taskId: string) { + setPodcastChecklist(prev => ({ + ...prev, + episodes: prev.episodes.map(episode => { + if (episode.id !== episodeId) return episode + return { + ...episode, + tasks: { + ...episode.tasks, + [taskId]: !episode.tasks[taskId], + }, + } + }), + })) + } + + function resetChecklistEpisode(episodeId: string) { + setPodcastChecklist(prev => ({ + ...prev, + episodes: prev.episodes.map(episode => { + if (episode.id !== episodeId) return episode + return { + ...episode, + datePublished: '', + tasks: Object.fromEntries(prev.tasks.map(task => [task.id, false])), + } + }), + })) + } + + function removeChecklistEpisode(id: string) { + setPodcastChecklist(prev => ({ + ...prev, + episodes: prev.episodes.filter(episode => episode.id !== id), + })) + } + + async function handleSavePodcastChecklist() { + setPodcastChecklistStatus('saving') + setPodcastChecklistMsg('') + try { + const res = await fetch('/api/admin-podcast-checklist', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ checklist: podcastChecklist }), + }) + + if (!res.ok) { + const data = await res.json().catch(() => ({})) as { message?: string } + throw new Error(data.message ?? 'Failed to save checklist') + } + + const data = await res.json() as { checklist?: PodcastChecklistData } + if (data.checklist?.tasks && data.checklist?.episodes) { + setPodcastChecklist(data.checklist) + } + setPodcastChecklistStatus('saved') + setTimeout(() => setPodcastChecklistStatus('idle'), 3000) + } catch (err) { + setPodcastChecklistMsg(err instanceof Error ? err.message : 'Failed to save checklist') + setPodcastChecklistStatus('error') + } + } + async function handleAssetUpload(event: ChangeEvent) { const file = event.target.files?.[0] if (!file) return @@ -1795,6 +1974,44 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { questionPage * QUESTION_PAGE_SIZE, (questionPage + 1) * QUESTION_PAGE_SIZE, ) + const checklistTasksSorted = [...podcastChecklist.tasks].sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })) + const checklistPreTasks = checklistTasksSorted.filter(task => task.phase === 'pre') + const checklistPostTasks = checklistTasksSorted.filter(task => task.phase === 'post') + const checklistEpisodesSorted = [...podcastChecklist.episodes].sort((a, b) => { + const isDraft = (episode: PodcastChecklistEpisode) => { + const hasNumber = episode.episodeNumber !== null + const hasTitle = Boolean(episode.title?.trim()) + const hasDate = Boolean(episode.datePublished?.trim()) + return !hasNumber && !hasTitle && !hasDate + } + + const aDraft = isDraft(a) + const bDraft = isDraft(b) + if (aDraft && !bDraft) return -1 + if (!aDraft && bDraft) return 1 + + const seriesOrder = (series: string) => { + const key = series.trim().toLowerCase() + if (key === 'titus') return 0 + if (key === 'colossians') return 1 + return 2 + } + + const bySeries = seriesOrder(a.series) - seriesOrder(b.series) + if (bySeries !== 0) return bySeries + + const nameSort = a.series.localeCompare(b.series, undefined, { sensitivity: 'base' }) + if (nameSort !== 0) return nameSort + + const aNum = a.episodeNumber + const bNum = b.episodeNumber + if (aNum === null && bNum === null) return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }) + if (aNum === null) return 1 + if (bNum === null) return -1 + if (aNum !== bNum) return aNum - bNum + + return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }) + }) function renderSaveStatus() { return ( @@ -1805,6 +2022,15 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { ) } + function renderPodcastChecklistStatus() { + return ( + <> + {podcastChecklistStatus === 'saved' &&

✓ Checklist saved.

} + {podcastChecklistStatus === 'error' &&

✗ {podcastChecklistMsg}

} + + ) + } + return (
{/* ── Top bar ── */} @@ -1881,6 +2107,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { Podcast +
@@ -2422,6 +2649,213 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { )} + {/* PODCAST CHECKLIST */} + {adminView === 'podcast-checklist' && ( +
+
+

Podcast Production Checklist

+

Track production progress for each episode. Add as many tasks and episodes as you need, then save.

+
+ +
+
+

Total Episodes

+

{podcastChecklist.episodes.length}

+
+
+

Pre-Publish Tasks

+

{checklistPreTasks.length}

+
+
+

Post-Publish Tasks

+

{checklistPostTasks.length}

+
+
+ +
+ +
+ Task Template +

{podcastChecklist.tasks.length} tasks configured

+
+ Expand to edit +
+
+
+ + +
+ + {podcastChecklist.tasks.length === 0 && ( +

No tasks yet. Add a pre-publish or post-publish task above.

+ )} + + {checklistTasksSorted.map(task => ( +
+
+
+ + updateChecklistTask(task.id, 'label', e.target.value)} + placeholder="e.g. Upload transcript" + /> +
+
+ + +
+
+ +
+ ))} +
+
+ +
+
+
Episodes
+
+ + +
+
+ + {podcastChecklist.episodes.length === 0 && ( +

No episodes yet. Add one above to start tracking progress.

+ )} + + {checklistEpisodesSorted.map(episode => { + const doneCount = checklistTasksSorted.reduce((count, task) => count + (episode.tasks[task.id] ? 1 : 0), 0) + const totalCount = checklistTasksSorted.length + const episodeLabelParts = [episode.series?.trim()] + if (episode.episodeNumber !== null) { + episodeLabelParts.push(String(episode.episodeNumber)) + } + if (episode.title?.trim()) { + episodeLabelParts.push(episode.title.trim()) + } + const episodeLabel = episodeLabelParts.filter(Boolean).join(' - ') || 'Untitled' + + return ( +
+ +
+ {episodeLabel} +

{totalCount > 0 ? `${doneCount}/${totalCount} tasks completed` : 'No tasks assigned yet'}

+
+ Expand to edit +
+
+
+
+
+ + updateChecklistEpisode(episode.id, 'series', e.target.value)} + placeholder="Colossians" + /> +
+
+ + updateChecklistEpisode(episode.id, 'episodeNumber', e.target.value)} + min={0} + /> +
+
+ + updateChecklistEpisode(episode.id, 'title', e.target.value)} + placeholder="Grace that Trains Us" + /> +
+
+ + updateChecklistEpisode(episode.id, 'datePublished', e.target.value)} + /> +
+
+
+ + {checklistPreTasks.length > 0 && ( +
+
+
Pre-Publish Tasks
+
+
+ {checklistPreTasks.map(task => ( + + ))} +
+
+ )} + + {checklistPostTasks.length > 0 && ( +
+
+
Post-Publish Tasks
+
+
+ {checklistPostTasks.map(task => ( + + ))} +
+
+ )} + +
+ + +
+
+
+ ) + })} +
+ + {renderPodcastChecklistStatus()} +
+ )} + {/* ARCHIVED SERIES */} {adminView === 'archived-series' && (