Add Podcast Episode Workflow (Lane A / Lane B) with Nuggets steps; released dates from the feed; v2.15.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5c2f9113b7
commit
f181cdb182
5 files changed
+369
-4
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.14.4",
|
"version": "2.15.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.14.4",
|
"version": "2.15.0",
|
||||||
"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.4",
|
"version": "2.15.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import { register as registerEpisodes } from './server/routes/episodes.js'
|
|||||||
import { register as registerNuggets } from './server/routes/nuggets.js'
|
import { register as registerNuggets } from './server/routes/nuggets.js'
|
||||||
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
|
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
|
||||||
import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js'
|
import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js'
|
||||||
|
import { register as registerPodcastWorkflow } from './server/routes/podcast-workflow.js'
|
||||||
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
|
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
|
||||||
import { register as registerFeed } from './server/routes/feed.js'
|
import { register as registerFeed } from './server/routes/feed.js'
|
||||||
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
||||||
@@ -123,6 +124,7 @@ registerEpisodes(app)
|
|||||||
registerNuggets(app)
|
registerNuggets(app)
|
||||||
registerNuggetsHostUpload(app)
|
registerNuggetsHostUpload(app)
|
||||||
registerNuggetsWorkflow(app)
|
registerNuggetsWorkflow(app)
|
||||||
|
registerPodcastWorkflow(app)
|
||||||
registerFeed(app)
|
registerFeed(app)
|
||||||
registerQrCodes(app)
|
registerQrCodes(app)
|
||||||
registerBiblePassage(app)
|
registerBiblePassage(app)
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { writeFile } from '../atomic-write.js'
|
||||||
|
import { requireAdminAuth } from '../auth.js'
|
||||||
|
import { state } from '../state.js'
|
||||||
|
import { DATA_DIR } from '../config.js'
|
||||||
|
import { scriptKey, scriptEpisodeNumber } from '../episode-script-keys.js'
|
||||||
|
import { fetchAllEpisodes } from './episodes.js'
|
||||||
|
|
||||||
|
const WORKFLOW_FILE = path.join(DATA_DIR, 'podcast-workflow.json')
|
||||||
|
|
||||||
|
// Same steps as the Nuggets workflow.
|
||||||
|
const STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify']
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
|
||||||
|
return { planned: Array.isArray(data.planned) ? data.planned : [], items: data.items ?? {} }
|
||||||
|
} catch {
|
||||||
|
return { planned: [], items: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = data => writeFile(WORKFLOW_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||||
|
|
||||||
|
function emptyState() {
|
||||||
|
return {
|
||||||
|
steps: Object.fromEntries(STEPS.map(s => [s, null])),
|
||||||
|
coldOpenText: '',
|
||||||
|
notes: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function laneFor(season) {
|
||||||
|
const content = state.cachedSiteContent ?? {}
|
||||||
|
const s = String(season ?? '')
|
||||||
|
if (s && s === String(content.seriesSeason ?? '')) return 'A'
|
||||||
|
if (s && s === String(content.secondSeries?.season ?? '')) return 'B'
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoDate(pubDate) {
|
||||||
|
const d = new Date(pubDate)
|
||||||
|
return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(app) {
|
||||||
|
app.get('/api/admin/podcast-workflow', requireAdminAuth, async (_req, res) => {
|
||||||
|
const wf = await load()
|
||||||
|
let feed = []
|
||||||
|
let feedOk = true
|
||||||
|
try {
|
||||||
|
feed = await fetchAllEpisodes()
|
||||||
|
} catch {
|
||||||
|
feedOk = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const byKey = new Map()
|
||||||
|
const upsert = (key, patch) => byKey.set(key, { ...(byKey.get(key) ?? {}), key, ...patch })
|
||||||
|
|
||||||
|
for (const [key, entry] of Object.entries(state.episodeScripts ?? {})) {
|
||||||
|
upsert(key, {
|
||||||
|
season: entry.season ?? '',
|
||||||
|
episodeNumber: scriptEpisodeNumber(key, entry),
|
||||||
|
title: entry.title ?? '',
|
||||||
|
hasScript: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (const p of wf.planned) {
|
||||||
|
const key = scriptKey(p.season, p.episodeNumber)
|
||||||
|
const prev = byKey.get(key)
|
||||||
|
upsert(key, { season: p.season, episodeNumber: p.episodeNumber, title: prev?.title || p.title || '', planned: true })
|
||||||
|
}
|
||||||
|
for (const item of feed) {
|
||||||
|
const hasNum = /^\d+$/.test(item.episode)
|
||||||
|
const key = hasNum ? scriptKey(item.season, item.episode) : `t:${item.title}`
|
||||||
|
const prev = byKey.get(key)
|
||||||
|
upsert(key, {
|
||||||
|
season: item.season,
|
||||||
|
episodeNumber: hasNum ? item.episode : '',
|
||||||
|
title: item.title || prev?.title || '',
|
||||||
|
released: true,
|
||||||
|
releaseDate: toIsoDate(item.pubDate),
|
||||||
|
spotifyUrl: item.link || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...byKey.values()].map(e => ({
|
||||||
|
...e,
|
||||||
|
lane: laneFor(e.season),
|
||||||
|
released: Boolean(e.released),
|
||||||
|
state: { ...emptyState(), ...(wf.items[e.key] ?? {}) },
|
||||||
|
}))
|
||||||
|
items.sort((a, b) => (Number(a.season) || 0) - (Number(b.season) || 0) || (Number(a.episodeNumber) || 0) - (Number(b.episodeNumber) || 0))
|
||||||
|
res.json({ items, feedOk })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/admin/podcast-workflow/planned', requireAdminAuth, async (req, res) => {
|
||||||
|
const season = /^\d+$/.test(String(req.body?.season ?? '')) ? String(req.body.season) : ''
|
||||||
|
const episodeNumber = String(req.body?.episodeNumber ?? '').trim()
|
||||||
|
const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 160) : ''
|
||||||
|
if (!/^\d+$/.test(episodeNumber)) {
|
||||||
|
res.status(400).json({ message: 'Episode number must be a whole number.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const wf = await load()
|
||||||
|
const key = scriptKey(season, episodeNumber)
|
||||||
|
if (!wf.planned.some(p => scriptKey(p.season, p.episodeNumber) === key)) {
|
||||||
|
wf.planned.push({ season, episodeNumber, title })
|
||||||
|
await save(wf)
|
||||||
|
}
|
||||||
|
res.json({ ok: true, key })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/admin/podcast-workflow/planned/:key', requireAdminAuth, async (req, res) => {
|
||||||
|
const wf = await load()
|
||||||
|
wf.planned = wf.planned.filter(p => scriptKey(p.season, p.episodeNumber) !== req.params.key)
|
||||||
|
delete wf.items[req.params.key]
|
||||||
|
await save(wf)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/admin/podcast-workflow/:key', requireAdminAuth, async (req, res) => {
|
||||||
|
const { key } = req.params
|
||||||
|
const wf = await load()
|
||||||
|
const prev = { ...emptyState(), ...(wf.items[key] ?? {}) }
|
||||||
|
const next = { ...prev, steps: { ...prev.steps } }
|
||||||
|
const body = req.body ?? {}
|
||||||
|
if (body.steps && typeof body.steps === 'object') {
|
||||||
|
for (const s of STEPS) {
|
||||||
|
if (typeof body.steps[s] === 'boolean') {
|
||||||
|
next.steps[s] = body.steps[s] ? (prev.steps[s] ?? new Date().toISOString()) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof body.coldOpenText === 'string') next.coldOpenText = body.coldOpenText.slice(0, 5000)
|
||||||
|
if (typeof body.notes === 'string') next.notes = body.notes.slice(0, 5000)
|
||||||
|
wf.items[key] = next
|
||||||
|
await save(wf)
|
||||||
|
res.json({ ok: true, state: next })
|
||||||
|
})
|
||||||
|
}
|
||||||
+222
-1
@@ -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:rss'
|
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:workflow' | '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,6 +782,7 @@ 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:workflow', label: 'Episode Workflow' },
|
||||||
{ 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: [
|
||||||
@@ -1798,6 +1799,225 @@ function NuggetsWorkflowAdmin() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Podcast Episode Workflow (Lane A / Lane B) ─────────────────────────────
|
||||||
|
interface PodcastWorkflowItem {
|
||||||
|
key: string
|
||||||
|
season: string
|
||||||
|
episodeNumber: string
|
||||||
|
title: string
|
||||||
|
lane: 'A' | 'B' | 'other'
|
||||||
|
released: boolean
|
||||||
|
releaseDate?: string
|
||||||
|
spotifyUrl?: string
|
||||||
|
hasScript?: boolean
|
||||||
|
planned?: boolean
|
||||||
|
state: WorkflowState
|
||||||
|
}
|
||||||
|
|
||||||
|
function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string; season?: number }; laneB: { title: string; season?: number } }) {
|
||||||
|
const [items, setItems] = useState<PodcastWorkflowItem[]>([])
|
||||||
|
const [feedOk, setFeedOk] = useState(true)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [lane, setLane] = useState<'A' | 'B' | 'other'>('A')
|
||||||
|
const [selectedKey, setSelectedKey] = useState('')
|
||||||
|
const [showReleased, setShowReleased] = useState(false)
|
||||||
|
const [newNumber, setNewNumber] = useState('')
|
||||||
|
const [newTitle, setNewTitle] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const reload = () =>
|
||||||
|
fetch('/api/admin/podcast-workflow')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { setItems(d.items ?? []); setFeedOk(d.feedOk !== false) })
|
||||||
|
.catch(() => setError('Could not load episodes.'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
|
||||||
|
useEffect(() => { reload() }, [])
|
||||||
|
|
||||||
|
const laneSeason = lane === 'A' ? laneA.season : lane === 'B' ? laneB.season : undefined
|
||||||
|
const hasOther = items.some(i => i.lane === 'other')
|
||||||
|
const inLane = items.filter(i => i.lane === lane)
|
||||||
|
const upcoming = inLane.filter(i => !i.released)
|
||||||
|
const released = inLane.filter(i => i.released)
|
||||||
|
const selected = items.find(i => i.key === selectedKey) ?? null
|
||||||
|
|
||||||
|
const save = async (key: string, patch: Record<string, unknown>) => {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/admin/podcast-workflow/${encodeURIComponent(key)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
})
|
||||||
|
const d = await r.json()
|
||||||
|
if (!r.ok) throw new Error(d.message ?? 'Save failed.')
|
||||||
|
setItems(prev => prev.map(i => (i.key === key ? { ...i, state: d.state } : i)))
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Save failed.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const patchLocal = (key: string, patch: Partial<WorkflowState>) =>
|
||||||
|
setItems(prev => prev.map(i => (i.key === key ? { ...i, state: { ...i.state, ...patch } } : i)))
|
||||||
|
|
||||||
|
const addPlanned = async () => {
|
||||||
|
setError('')
|
||||||
|
const r = await fetch('/api/admin/podcast-workflow/planned', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ season: laneSeason ? String(laneSeason) : '', episodeNumber: newNumber, title: newTitle }),
|
||||||
|
})
|
||||||
|
const d = await r.json()
|
||||||
|
if (!r.ok) { setError(d.message ?? 'Could not add episode.'); return }
|
||||||
|
setNewNumber(''); setNewTitle('')
|
||||||
|
await reload()
|
||||||
|
setSelectedKey(d.key)
|
||||||
|
}
|
||||||
|
const removePlanned = async (key: string) => {
|
||||||
|
if (!confirm('Remove this planned episode and its progress?')) return
|
||||||
|
await fetch(`/api/admin/podcast-workflow/planned/${encodeURIComponent(key)}`, { method: 'DELETE' })
|
||||||
|
setSelectedKey('')
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = (i: PodcastWorkflowItem) => `${i.episodeNumber ? `Ep ${i.episodeNumber}` : 'Ep'}${i.title ? ` — ${i.title}` : ''}`
|
||||||
|
const doneCount = (i: PodcastWorkflowItem) => (i.released ? WORKFLOW_STEP_DEFS.length : WORKFLOW_STEP_DEFS.filter(s => i.state.steps[s.key]).length)
|
||||||
|
|
||||||
|
const row = (i: PodcastWorkflowItem) => (
|
||||||
|
<button
|
||||||
|
key={i.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedKey(i.key)}
|
||||||
|
style={{
|
||||||
|
display: 'block', width: '100%', textAlign: 'left', padding: '8px 10px', marginBottom: 6,
|
||||||
|
border: `1px solid ${i.key === selectedKey ? '#8a6d2f' : 'var(--color-border, #e5e0d5)'}`,
|
||||||
|
borderRadius: 6, background: 'transparent', cursor: 'pointer', color: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{label(i)}</strong>
|
||||||
|
<div style={{ fontSize: '0.78rem', opacity: 0.75 }}>
|
||||||
|
{i.released ? `Released ${i.releaseDate || ''}` : `${doneCount(i)}/${WORKFLOW_STEP_DEFS.length} steps`}
|
||||||
|
{!i.released && i.hasScript ? ' · script ready' : ''}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-panel-section" aria-label="Podcast episode workflow">
|
||||||
|
<div className="admin-panel-head">
|
||||||
|
<h2>Podcast — Episode Workflow</h2>
|
||||||
|
<p>Every episode for each series: what’s still in progress, and what’s already out (dates come from your Spotify feed).</p>
|
||||||
|
</div>
|
||||||
|
{!feedOk && <p style={{ color: '#c0392b' }}>Couldn’t reach the podcast feed, so released episodes may be missing right now.</p>}
|
||||||
|
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||||
|
{([['A', laneA.title || 'Primary series'], ['B', laneB.title || 'Second series']] as const).map(([k, t]) => (
|
||||||
|
<button key={k} type="button" className={lane === k ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => { setLane(k); setSelectedKey('') }}>
|
||||||
|
Lane {k} — {t} ({items.filter(i => i.lane === k && !i.released).length} in progress)
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{hasOther && (
|
||||||
|
<button type="button" className={lane === 'other' ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => { setLane('other'); setSelectedKey('') }}>
|
||||||
|
Other ({items.filter(i => i.lane === 'other' && !i.released).length} in progress)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{lane !== 'other' && !laneSeason && (
|
||||||
|
<p className="admin-stats-note">Set this lane’s Podcast Season Number in Current Series so its episodes can be matched.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? <p className="admin-stats-note">Loading…</p> : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(220px, 300px) 1fr', gap: 20, alignItems: 'start' }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ fontSize: '0.82rem' }}>Not released yet</strong>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
{upcoming.length === 0 && <p className="admin-stats-note">Nothing in progress.</p>}
|
||||||
|
{upcoming.map(row)}
|
||||||
|
</div>
|
||||||
|
{lane !== 'other' && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, margin: '8px 0 14px', flexWrap: 'wrap' }}>
|
||||||
|
<input type="number" min={1} placeholder="Ep #" value={newNumber} onChange={e => setNewNumber(e.target.value)} style={{ width: 70 }} aria-label="New episode number" />
|
||||||
|
<input type="text" placeholder="Title (optional)" value={newTitle} onChange={e => setNewTitle(e.target.value)} style={{ flex: 1, minWidth: 120 }} aria-label="New episode title" />
|
||||||
|
<button type="button" className="btn-admin-add" onClick={addPlanned} disabled={!newNumber}>Add</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label style={{ fontSize: '0.82rem', display: 'block', marginBottom: 6 }}>
|
||||||
|
<input type="checkbox" checked={showReleased} onChange={e => setShowReleased(e.target.checked)} /> Show released ({released.length})
|
||||||
|
</label>
|
||||||
|
{showReleased && released.map(row)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{!selected ? <p className="admin-stats-note">Select an episode to work on.</p> : (
|
||||||
|
<>
|
||||||
|
<h3 style={{ marginTop: 0 }}>{label(selected)}</h3>
|
||||||
|
{selected.released ? (
|
||||||
|
<p style={{ fontSize: '0.86rem' }}>
|
||||||
|
Released {selected.releaseDate}
|
||||||
|
{selected.spotifyUrl && <> · <a href={selected.spotifyUrl} target="_blank" rel="noopener noreferrer">Listen</a></>}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: '0.86rem', opacity: 0.8 }}>
|
||||||
|
{selected.hasScript ? 'Script uploaded.' : 'No script yet.'} This moves to Released on its own once it shows up in the feed.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ol style={{ paddingLeft: 0, listStyle: 'none', margin: 0 }}>
|
||||||
|
{WORKFLOW_STEP_DEFS.map((s, idx) => {
|
||||||
|
const completedAt = selected.state.steps[s.key]
|
||||||
|
const checked = selected.released || Boolean(completedAt)
|
||||||
|
return (
|
||||||
|
<li key={s.key} style={{ padding: '12px 0', borderTop: '1px solid var(--color-border, #e5e0d5)' }}>
|
||||||
|
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start', cursor: selected.released ? 'default' : 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={selected.released}
|
||||||
|
onChange={e => save(selected.key, { steps: { [s.key]: e.target.checked } })}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{idx + 1}. {s.label}</strong>
|
||||||
|
<span style={{ display: 'block', fontSize: '0.8rem', opacity: 0.7 }}>
|
||||||
|
{s.hint}{completedAt && !selected.released ? ` · done ${new Date(completedAt).toLocaleString()}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{s.key === 'coldOpen' && (
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
placeholder="Cold open script / hook…"
|
||||||
|
value={selected.state.coldOpenText}
|
||||||
|
disabled={selected.released}
|
||||||
|
onChange={e => patchLocal(selected.key, { coldOpenText: e.target.value })}
|
||||||
|
onBlur={e => save(selected.key, { coldOpenText: e.target.value })}
|
||||||
|
style={{ width: '100%', marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
<div className="admin-field" style={{ marginTop: 12 }}>
|
||||||
|
<label htmlFor="pw-notes">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="pw-notes"
|
||||||
|
rows={3}
|
||||||
|
value={selected.state.notes}
|
||||||
|
onChange={e => patchLocal(selected.key, { notes: e.target.value })}
|
||||||
|
onBlur={e => save(selected.key, { notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{selected.planned && !selected.released && !selected.hasScript && (
|
||||||
|
<button type="button" className="btn-admin-remove" onClick={() => removePlanned(selected.key)}>Remove planned episode</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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)
|
||||||
@@ -5992,6 +6212,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* EPISODE SCRIPTS */}
|
{/* EPISODE SCRIPTS */}
|
||||||
|
{adminView === 'podcast:workflow' && <PodcastWorkflowAdmin laneA={{ title: form.seriesTitle, season: form.seriesSeason }} laneB={{ title: form.secondSeries?.title ?? '', season: form.secondSeries?.season }} />}
|
||||||
{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