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

+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>
)