Add Podcast Production Checklist (Lane A / Lane B) to admin; v2.14.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-12 08:04:10 -04:00
1 parent ffb3551b7e
commit d6d2c087b2
5 files changed
+227 -4

No files matched your search

+175 -1
View File
@@ -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<string, boolean>
}
type ChecklistLane = 'A' | 'B'
function PodcastChecklistAdmin({ laneATitle, laneBTitle }: { laneATitle: string; laneBTitle: string }) {
const [data, setData] = useState<Record<ChecklistLane, ChecklistEpisode[]>>({ A: [], B: [] })
const [loaded, setLoaded] = useState(false)
const [lane, setLane] = useState<ChecklistLane>('A')
const [openId, setOpenId] = useState('')
const [status, setStatus] = useState('')
const [bulk, setBulk] = useState('1')
const saveTimer = useRef<number | null>(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<ChecklistLane, ChecklistEpisode[]>) => {
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<ChecklistEpisode>) =>
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) => (
<ul style={{ listStyle: 'none', padding: 0, margin: '4px 0 10px' }}>
{tasks.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>
</li>
))}
</ul>
)
return (
<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>
</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})
</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>
)}
</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>
</>
)}
</section>
)
}
export default function AdminPage({ content, onSave, onLogout }: Props) {
const normalizedContent = normalizeSiteContentForAdmin(content)
const [form, setForm] = useState<SiteContent>(normalizedContent)
@@ -5992,6 +6165,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
)}
{/* EPISODE SCRIPTS */}
{adminView === 'podcast:checklist' && <PodcastChecklistAdmin laneATitle={form.seriesTitle} laneBTitle={form.secondSeries?.title ?? ''} />}
{adminView === 'podcast:scripts' && (
<section className="admin-panel-section">
<div className="admin-panel-head">