diff --git a/package-lock.json b/package-lock.json index 809342e..9fc2b80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.12.0", + "version": "2.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.12.0", + "version": "2.13.0", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 387cbcd..f76cc29 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.12.0", + "version": "2.13.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index 5fadae2..ead46aa 100644 --- a/server.js +++ b/server.js @@ -69,6 +69,7 @@ import { register as registerDownloads } from './server/routes/downloads.js' import { register as registerEpisodes } from './server/routes/episodes.js' import { register as registerNuggets } from './server/routes/nuggets.js' import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js' +import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js' import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js' import { register as registerFeed } from './server/routes/feed.js' import { register as registerQrCodes } from './server/routes/qr-codes.js' @@ -126,6 +127,7 @@ registerDownloads(app) registerEpisodes(app) registerNuggets(app) registerNuggetsHostUpload(app) +registerNuggetsWorkflow(app) registerFeed(app) registerQrCodes(app) registerBiblePassage(app) diff --git a/server/routes/nuggets-workflow.js b/server/routes/nuggets-workflow.js new file mode 100644 index 0000000..22ce471 --- /dev/null +++ b/server/routes/nuggets-workflow.js @@ -0,0 +1,130 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { Readable } from 'node:stream' +import path from 'node:path' +import { writeFile } from '../atomic-write.js' +import { requireAdminAuth } from '../auth.js' +import { loadHistory } from './nuggets-host-upload.js' +import { + DATA_DIR, + SEAFILE_URL, + SEAFILE_TOKEN, + SEAFILE_NUGGETS_REPO_ID, + SEAFILE_EPISODE_UPLOAD_DIR, +} from '../config.js' + +const WORKFLOW_FILE = path.join(DATA_DIR, 'nuggets-workflow.json') + +export const WORKFLOW_STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify'] + +async function loadWorkflow() { + try { + return JSON.parse(await readFile(WORKFLOW_FILE, 'utf8')) + } catch { + return { items: {} } + } +} + +function entryId(entry) { + return createHash('sha1').update(`${entry.uploadedAt}|${entry.filename}`).digest('hex').slice(0, 16) +} + +function emptyState() { + return { + steps: Object.fromEntries(WORKFLOW_STEPS.map(s => [s, null])), // step → ISO completion time + coldOpenText: '', + notes: '', + spotifyUrl: '', + } +} + +function seafileConfigured() { + return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID) +} + +export function register(app) { + app.get('/api/admin/nuggets-workflow', requireAdminAuth, async (_req, res) => { + const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()]) + const items = history.entries.map(entry => { + const id = entryId(entry) + return { id, entry, state: { ...emptyState(), ...(wf.items[id] ?? {}) } } + }) + res.json({ items }) + }) + + app.put('/api/admin/nuggets-workflow/:id', requireAdminAuth, async (req, res) => { + const { id } = req.params + const history = await loadHistory() + if (!history.entries.some(e => entryId(e) === id)) { + res.status(404).json({ message: 'Upload not found.' }) + return + } + const wf = await loadWorkflow() + const prev = { ...emptyState(), ...(wf.items[id] ?? {}) } + const body = req.body ?? {} + const next = { ...prev, steps: { ...prev.steps } } + + if (body.steps && typeof body.steps === 'object') { + for (const s of WORKFLOW_STEPS) { + if (typeof body.steps[s] === 'boolean') { + // Keep the original completion time if the step was already done + 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) + if (typeof body.spotifyUrl === 'string') next.spotifyUrl = body.spotifyUrl.trim().slice(0, 500) + + wf.items[id] = next + await writeFile(WORKFLOW_FILE, JSON.stringify(wf, null, 2), 'utf8') + res.json({ ok: true, state: next }) + }) + + // Stream the uploaded audio from Seafile so it can be auditioned in the admin. + app.get('/api/admin/nuggets-workflow/:id/audio', requireAdminAuth, async (req, res) => { + if (!seafileConfigured()) { + res.status(503).json({ message: 'Seafile is not configured.' }) + return + } + const history = await loadHistory() + const entry = history.entries.find(e => entryId(e) === req.params.id) + if (!entry) { + res.status(404).json({ message: 'Upload not found.' }) + return + } + try { + const filePath = `${SEAFILE_EPISODE_UPLOAD_DIR.replace(/\/$/, '')}/${entry.filename}` + const linkRes = await fetch( + `${SEAFILE_URL}/api2/repos/${SEAFILE_NUGGETS_REPO_ID}/file/?p=${encodeURIComponent(filePath)}&reuse=1`, + { headers: { Authorization: `Token ${SEAFILE_TOKEN}` }, signal: AbortSignal.timeout(10_000) }, + ) + if (!linkRes.ok) { + res.status(404).json({ message: 'Audio file not found in Seafile.' }) + return + } + const downloadUrl = (await linkRes.text()).replace(/^"|"$/g, '') + + const headers = {} + if (req.headers.range) headers.Range = req.headers.range + const upstream = await fetch(downloadUrl, { headers }) + if (!upstream.ok && upstream.status !== 206) { + res.status(502).json({ message: 'Could not fetch audio from Seafile.' }) + return + } + res.status(upstream.status) + for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges']) { + const v = upstream.headers.get(h) + if (v) res.setHeader(h, v) + } + if (!upstream.headers.get('content-type')) res.setHeader('content-type', 'audio/mpeg') + const stream = Readable.fromWeb(upstream.body) + req.on('close', () => stream.destroy()) + stream.on('error', () => res.end()) + stream.pipe(res) + } catch (err) { + console.error('[nuggets-workflow] audio error:', err.message) + if (!res.headersSent) res.status(500).json({ message: 'Audio stream failed.' }) + } + }) +} diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 52fd6de..9e31b5d 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -731,7 +731,7 @@ type AdminView = | 'about:show-copy' | 'about:bio' | 'about:testimonials' | 'contact:profile' | 'contact:copy' | '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:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow' | 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources' | 'inbox' | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes' @@ -790,6 +790,7 @@ const ADMIN_NAV: NavGroup[] = [ { kind: 'leaf', view: 'nuggets:upload-portal', label: 'Upload Portal' }, { kind: 'leaf', view: 'nuggets:announcement', label: 'Announcement' }, { kind: 'leaf', view: 'nuggets:guidelines', label: 'Recording Guidelines' }, + { kind: 'leaf', view: 'nuggets:workflow', label: 'Episode Workflow' }, { kind: 'leaf', view: 'nuggets:history', label: 'Upload History' }, ]}, { kind: 'leaf', view: 'colossians-study', label: 'Studies' }, @@ -1615,6 +1616,188 @@ function NuggetsHistoryAdmin() { ) } +// ── Nuggets Episode Workflow admin ───────────────────────────────────────── +type WorkflowStepKey = 'listen' | 'coldOpen' | 'recordColdOpen' | 'edit' | 'spotify' + +interface WorkflowState { + steps: Record + coldOpenText: string + notes: string + spotifyUrl: string +} + +interface WorkflowItem { + id: string + entry: { hostName: string; episodeName: string; passage: string; description?: string; filename: string; uploadedAt: string } + state: WorkflowState +} + +const WORKFLOW_STEP_DEFS: { key: WorkflowStepKey; label: string; hint: string }[] = [ + { key: 'listen', label: 'Listen to the uploaded file', hint: 'Audition the host’s recording and jot down notes.' }, + { key: 'coldOpen', label: 'Get a cold open', hint: 'Pick or write the hook that opens the episode.' }, + { key: 'recordColdOpen', label: 'Record the cold open', hint: 'Record the cold open take.' }, + { key: 'edit', label: 'Edit the episode', hint: 'Cold open + episode: trim, clean up, mix, master.' }, + { key: 'spotify', label: 'Upload to Spotify', hint: 'Publish with show notes and paste the episode link below.' }, +] + +function NuggetsWorkflowAdmin() { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedId, setSelectedId] = useState('') + const [showDone, setShowDone] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + fetch('/api/admin/nuggets-workflow') + .then(r => r.json()) + .then(d => setItems(d.items ?? [])) + .catch(() => setError('Could not load uploads.')) + .finally(() => setLoading(false)) + }, []) + + const isDone = (i: WorkflowItem) => WORKFLOW_STEP_DEFS.every(s => i.state.steps[s.key]) + const visible = showDone ? items : items.filter(i => !isDone(i)) + const selected = items.find(i => i.id === selectedId) ?? null + + const save = async (id: string, patch: Partial> & { steps?: Partial> }) => { + setSaving(true) + setError('') + try { + const r = await fetch(`/api/admin/nuggets-workflow/${id}`, { + 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.id === id ? { ...i, state: d.state } : i))) + } catch (e) { + setError(e instanceof Error ? e.message : 'Save failed.') + } finally { + setSaving(false) + } + } + + const patchLocal = (id: string, patch: Partial) => + setItems(prev => prev.map(i => (i.id === id ? { ...i, state: { ...i.state, ...patch } } : i))) + + return ( +
+
+

Nuggets — Episode Workflow

+

Walk each portal upload through: listen → cold open → record cold open → edit → Spotify.

+
+ {error &&

{error}

} + {loading ?

Loading…

: ( +
+
+ + {visible.length === 0 &&

{items.length === 0 ? 'No uploads yet.' : 'All caught up.'}

} + {visible.map(i => { + const done = WORKFLOW_STEP_DEFS.filter(s => i.state.steps[s.key]).length + return ( + + ) + })} +
+ +
+ {!selected ?

Select an upload to work on.

: ( + <> +

{selected.entry.episodeName}

+

+ {selected.entry.passage} · uploaded by {selected.entry.hostName} · {selected.entry.filename} +

+ {selected.entry.description &&

{selected.entry.description}

} + +
    + {WORKFLOW_STEP_DEFS.map((s, idx) => { + const completedAt = selected.state.steps[s.key] + return ( +
  1. + + + {s.key === 'listen' && ( +
    +
    + )} + {s.key === 'coldOpen' && ( +