Remove Lane A/B production checklist panel from admin; v2.14.2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-12 08:09:28 -04:00
1 parent 66b7bbef52
commit 99b19faede
3 files changed
+4 -182

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "siteforge", "name": "siteforge",
"version": "2.14.1", "version": "2.14.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "siteforge", "name": "siteforge",
"version": "2.14.1", "version": "2.14.2",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1135.0", "@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.14.1", "version": "2.14.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -179
View File
@@ -730,7 +730,7 @@ type AdminView =
| 'start-here' | 'start-here'
| 'about:show-copy' | 'about:bio' | 'about:testimonials' | 'about:show-copy' | 'about:bio' | 'about:testimonials'
| 'contact:profile' | 'contact:copy' | 'contact:profile' | 'contact:copy'
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:checklist' | 'podcast:rss' | 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow' | 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources' | 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'inbox' | 'inbox'
@@ -782,7 +782,6 @@ const ADMIN_NAV: NavGroup[] = [
{ kind: 'leaf', view: 'podcast:highlights', label: 'Ep. Highlights' }, { kind: 'leaf', view: 'podcast:highlights', label: 'Ep. Highlights' },
{ kind: 'leaf', view: 'podcast:finished-books', label: 'Finished Books' }, { kind: 'leaf', view: 'podcast:finished-books', label: 'Finished Books' },
{ kind: 'leaf', view: 'podcast:scripts', label: 'Episode Scripts' }, { 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: 'leaf', view: 'podcast:rss', label: 'RSS Feed' },
]}, ]},
{ kind: 'parent', id: 'nuggets', label: 'Nuggets', children: [ { kind: 'parent', id: 'nuggets', label: 'Nuggets', children: [
@@ -1799,182 +1798,6 @@ function NuggetsWorkflowAdmin() {
) )
} }
// ── Podcast production checklist (Lane A / Lane B) ─────────────────────────
interface ChecklistTask { id: string; label: string; phase: 'pre' | 'post' }
interface ChecklistEpisode {
id: string
series: string
episodeNumber: number | null
title: string
datePublished: string
tasks: Record<string, boolean>
[extra: string]: unknown // reminderDays, startTime etc. are preserved untouched
}
interface PodcastChecklist { tasks: ChecklistTask[]; episodes: ChecklistEpisode[] }
type ChecklistLane = 'A' | 'B' | 'other'
function PodcastChecklistAdmin({ laneATitle, laneBTitle }: { laneATitle: string; laneBTitle: string }) {
const [checklist, setChecklist] = useState<PodcastChecklist | null>(null)
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 => setChecklist(d.checklist))
.catch(() => setStatus('Could not load checklist.'))
}, [])
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', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ checklist: next }),
})
setStatus(r.ok ? 'Saved' : 'Save failed')
} catch {
setStatus('Save failed')
}
}, 600)
}
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({ ...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 = laneEpisodes(lane).reduce((m, e) => Math.max(m, e.episodeNumber ?? 0), 0) + 1
const added: ChecklistEpisode[] = Array.from({ length: n }, (_, i) => ({
id: crypto.randomUUID(),
series: seriesName,
episodeNumber: start + i,
title: '',
datePublished: '',
tasks: Object.fromEntries(tasks.map(t => [t.id, false])),
}))
persist({ ...checklist, episodes: [...checklist.episodes, ...added] })
}
const removeEpisode = (id: string) => {
if (!confirm('Delete this episode from the checklist?')) return
persist({ ...checklist, episodes: checklist.episodes.filter(e => e.id !== id) })
}
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, phase: 'pre' | 'post') => (
<ul style={{ listStyle: 'none', padding: 0, margin: '4px 0 10px' }}>
{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: '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>
))}
</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. Published dates feed the calendar and reminders.</p>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
{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>
{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>
)
})}
{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>
)
}
export default function AdminPage({ content, onSave, onLogout }: Props) { export default function AdminPage({ content, onSave, onLogout }: Props) {
const normalizedContent = normalizeSiteContentForAdmin(content) const normalizedContent = normalizeSiteContentForAdmin(content)
const [form, setForm] = useState<SiteContent>(normalizedContent) const [form, setForm] = useState<SiteContent>(normalizedContent)
@@ -6169,7 +5992,6 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
)} )}
{/* EPISODE SCRIPTS */} {/* EPISODE SCRIPTS */}
{adminView === 'podcast:checklist' && <PodcastChecklistAdmin laneATitle={form.seriesTitle} laneBTitle={form.secondSeries?.title ?? ''} />}
{adminView === 'podcast:scripts' && ( {adminView === 'podcast:scripts' && (
<section className="admin-panel-section"> <section className="admin-panel-section">
<div className="admin-panel-head"> <div className="admin-panel-head">