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:
1 parent
ffb3551b7e
commit
d6d2c087b2
5 files changed
+227
-4
No files matched your search
+175
-1
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user