Podcast workflow: episodes start manually via Start new episode (name, season, ep #); scripts no longer create entries; v2.17.0
Publish Container / build (push) Successful in 16m42s
Publish Container / build (push) Successful in 16m42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3a69e03e60
commit
326838e5bc
4 files changed
+55
-38
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"version": "2.16.1",
|
||||
"version": "2.17.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "siteforge",
|
||||
"version": "2.16.1",
|
||||
"version": "2.17.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1135.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.16.1",
|
||||
"version": "2.17.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { scriptKey } from '../episode-script-keys.js'
|
||||
import { fetchAllEpisodes } from './episodes.js'
|
||||
|
||||
const WORKFLOW_FILE = path.join(DATA_DIR, 'podcast-workflow.json')
|
||||
@@ -46,7 +46,7 @@ function laneFor(season, wf) {
|
||||
}
|
||||
|
||||
const normTitle = t => String(t ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
|
||||
const plannedKey = p => p.key ?? scriptKey(p.season, p.episodeNumber)
|
||||
const plannedKey = p => scriptKey(p.season, p.episodeNumber)
|
||||
|
||||
function toIsoDate(pubDate) {
|
||||
const d = new Date(pubDate)
|
||||
@@ -67,18 +67,10 @@ export function register(app) {
|
||||
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 = plannedKey(p)
|
||||
const prev = byKey.get(key)
|
||||
upsert(key, { season: p.season, episodeNumber: p.episodeNumber, title: prev?.title || p.title || '', planned: true, plannedLane: p.lane })
|
||||
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)
|
||||
@@ -104,7 +96,7 @@ export function register(app) {
|
||||
|
||||
const items = [...byKey.values()].map(e => ({
|
||||
...e,
|
||||
lane: wf.laneOverrides[e.key] ?? e.plannedLane ?? laneFor(e.season, wf),
|
||||
lane: wf.laneOverrides[e.key] ?? laneFor(e.season, wf),
|
||||
laneOverride: wf.laneOverrides[e.key] ?? '',
|
||||
released: Boolean(e.released),
|
||||
state: { ...emptyState(), ...(wf.items[e.key] ?? {}) },
|
||||
@@ -113,22 +105,28 @@ export function register(app) {
|
||||
res.json({ items, feedOk, laneSeasons: { A: laneSeasonOf('A', wf), B: laneSeasonOf('B', wf) } })
|
||||
})
|
||||
|
||||
// Start the workflow for a new episode. Name, season and episode number are all required
|
||||
// so the episode lands in the right lane and matches the feed once it is released.
|
||||
app.post('/api/admin/podcast-workflow/planned', requireAdminAuth, async (req, res) => {
|
||||
const season = /^\d+$/.test(String(req.body?.season ?? '')) ? String(req.body.season) : ''
|
||||
const season = String(req.body?.season ?? '').trim()
|
||||
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.' })
|
||||
if (!title) {
|
||||
res.status(400).json({ message: 'Episode name is required.' })
|
||||
return
|
||||
}
|
||||
const lane = ['A', 'B'].includes(req.body?.lane) ? req.body.lane : 'other'
|
||||
const wf = await load()
|
||||
// Without a season number the key must still be unique per lane.
|
||||
const key = season ? scriptKey(season, episodeNumber) : `${lane}-p${episodeNumber}`
|
||||
if (!wf.planned.some(p => plannedKey(p) === key)) {
|
||||
wf.planned.push({ key, season, episodeNumber, title, lane })
|
||||
await save(wf)
|
||||
if (!/^\d+$/.test(season) || !/^\d+$/.test(episodeNumber)) {
|
||||
res.status(400).json({ message: 'Season and episode number must be whole numbers.' })
|
||||
return
|
||||
}
|
||||
const wf = await load()
|
||||
const key = scriptKey(season, episodeNumber)
|
||||
if (wf.planned.some(p => plannedKey(p) === key)) {
|
||||
res.status(409).json({ message: `Season ${season} episode ${episodeNumber} has already been started.` })
|
||||
return
|
||||
}
|
||||
wf.planned.push({ season, episodeNumber, title })
|
||||
await save(wf)
|
||||
res.json({ ok: true, key })
|
||||
})
|
||||
|
||||
|
||||
+32
-13
@@ -1820,7 +1820,6 @@ interface PodcastWorkflowItem {
|
||||
released: boolean
|
||||
releaseDate?: string
|
||||
spotifyUrl?: string
|
||||
hasScript?: boolean
|
||||
planned?: boolean
|
||||
state: PodcastWorkflowState
|
||||
}
|
||||
@@ -1833,6 +1832,8 @@ function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string }; lane
|
||||
const [lane, setLane] = useState<'A' | 'B' | 'other'>('A')
|
||||
const [selectedKey, setSelectedKey] = useState('')
|
||||
const [showReleased, setShowReleased] = useState(false)
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [newSeason, setNewSeason] = useState('')
|
||||
const [newNumber, setNewNumber] = useState('')
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -1888,11 +1889,11 @@ function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string }; lane
|
||||
const r = await fetch('/api/admin/podcast-workflow/planned', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ season: laneSeason, episodeNumber: newNumber, title: newTitle, lane }),
|
||||
body: JSON.stringify({ season: newSeason, episodeNumber: newNumber, title: newTitle }),
|
||||
})
|
||||
const d = await r.json()
|
||||
if (!r.ok) { setError(d.message ?? 'Could not add episode.'); return }
|
||||
setNewNumber(''); setNewTitle('')
|
||||
setNewNumber(''); setNewTitle(''); setStarting(false)
|
||||
await reload()
|
||||
setSelectedKey(d.key)
|
||||
}
|
||||
@@ -1929,7 +1930,6 @@ function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string }; lane
|
||||
<strong>{label(i)}</strong>
|
||||
<div style={{ fontSize: '0.78rem', opacity: 0.75 }}>
|
||||
{i.released ? `Released ${i.releaseDate || ''}` : `${doneCount(i)}/${PODCAST_STEP_DEFS.length} steps`}
|
||||
{!i.released && i.hasScript ? ' · script ready' : ''}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
@@ -1967,7 +1967,7 @@ function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string }; lane
|
||||
onBlur={e => { if (e.target.value !== laneSeason) saveLaneSeason(e.target.value) }}
|
||||
/>
|
||||
<span className="admin-stats-note">
|
||||
{laneSeason ? `Only season ${laneSeason} episodes are shown here.` : 'Set a season number so this lane picks up its episodes, or assign episodes by hand.'}
|
||||
{laneSeason ? `Only season ${laneSeason} episodes are shown here.` : 'Set a season number so this lane picks up its episodes, or start an episode and assign it by hand.'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1982,11 +1982,30 @@ function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string }; lane
|
||||
{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>
|
||||
{!starting ? (
|
||||
<button type="button" className="btn-admin-add" style={{ margin: '8px 0 14px' }} onClick={() => { setNewSeason(laneSeason); setStarting(true) }}>
|
||||
+ Start new episode
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ margin: '8px 0 14px', padding: 10, border: '1px solid var(--color-border, #e5e0d5)', borderRadius: 6 }}>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="pw-new-title">Episode name</label>
|
||||
<input id="pw-new-title" type="text" value={newTitle} onChange={e => setNewTitle(e.target.value)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 8 }}>
|
||||
<div className="admin-field" style={{ flex: 1 }}>
|
||||
<label htmlFor="pw-new-season">Season</label>
|
||||
<input id="pw-new-season" type="number" min={1} value={newSeason} onChange={e => setNewSeason(e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field" style={{ flex: 1 }}>
|
||||
<label htmlFor="pw-new-number">Episode #</label>
|
||||
<input id="pw-new-number" type="number" min={1} value={newNumber} onChange={e => setNewNumber(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn-admin-save" onClick={addPlanned} disabled={!newTitle.trim() || !newSeason || !newNumber}>Start workflow</button>
|
||||
<button type="button" className="btn-admin-ghost" onClick={() => setStarting(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<label style={{ fontSize: '0.82rem', display: 'block', marginBottom: 6 }}>
|
||||
@@ -2006,7 +2025,7 @@ function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string }; lane
|
||||
</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.
|
||||
This moves to Released on its own once season {selected.season} episode {selected.episodeNumber} shows up in your Spotify feed.
|
||||
</p>
|
||||
)}
|
||||
<div className="admin-field" style={{ maxWidth: 360, margin: '8px 0 12px' }}>
|
||||
@@ -2053,8 +2072,8 @@ function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string }; lane
|
||||
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>
|
||||
{selected.planned && !selected.released && (
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removePlanned(selected.key)}>Remove this episode</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user