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:
1 parent
66b7bbef52
commit
99b19faede
3 files changed
+4
-182
No files matched your search
Generated
+2
-2
@@ -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
@@ -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
@@ -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">
|
||||||
|
|||||||
Reference in new issue
Block a user