Compare commits
4
Commits
69d445013a
...
e6bef529e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6bef529e6 | ||
|
|
35d3c9ca33 | ||
|
|
50b0e83c2b | ||
|
|
77bce2ae0c |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.18.0",
|
"version": "2.21.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.18.0",
|
"version": "2.21.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.18.0",
|
"version": "2.21.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ 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 { register as registerPodcastWorkflow } from './server/routes/podcast-workflow.js'
|
||||||
|
import { register as registerSeasonCheck } from './server/season-check.js'
|
||||||
|
import { warmFeedSnapshot } from './server/feed-release.js'
|
||||||
import { preferIpv4ForSeafile } from './server/seafile-dns.js'
|
import { preferIpv4ForSeafile } from './server/seafile-dns.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'
|
||||||
@@ -128,6 +130,7 @@ registerNuggets(app)
|
|||||||
registerNuggetsHostUpload(app)
|
registerNuggetsHostUpload(app)
|
||||||
registerNuggetsWorkflow(app)
|
registerNuggetsWorkflow(app)
|
||||||
registerPodcastWorkflow(app)
|
registerPodcastWorkflow(app)
|
||||||
|
registerSeasonCheck(app)
|
||||||
registerFeed(app)
|
registerFeed(app)
|
||||||
registerQrCodes(app)
|
registerQrCodes(app)
|
||||||
registerBiblePassage(app)
|
registerBiblePassage(app)
|
||||||
@@ -208,6 +211,7 @@ Promise.all([
|
|||||||
}, BACKUP_INTERVAL_MS)
|
}, BACKUP_INTERVAL_MS)
|
||||||
|
|
||||||
startNuggetsSeafileWatcher()
|
startNuggetsSeafileWatcher()
|
||||||
|
warmFeedSnapshot()
|
||||||
|
|
||||||
// Purge expired study sessions every hour
|
// Purge expired study sessions every hour
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { fetchAllEpisodes } from './routes/episodes.js'
|
||||||
|
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
|
||||||
|
|
||||||
|
// The Spotify (Anchor) RSS feed only lists episodes that are live, so a season + episode number
|
||||||
|
// showing up there means the episode has been published. That drives two things on the public site:
|
||||||
|
// - highlights marked "hold until released" stay hidden until their episode is in the feed
|
||||||
|
// - study lessons with no audio of their own pick it up from the matching feed episode
|
||||||
|
|
||||||
|
const SNAPSHOT_TTL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
let snapshot = { items: [], at: 0 }
|
||||||
|
let inflight = null
|
||||||
|
|
||||||
|
function refreshSnapshot() {
|
||||||
|
if (!inflight) {
|
||||||
|
inflight = fetchAllEpisodes()
|
||||||
|
.then(items => { snapshot = { items, at: Date.now() } })
|
||||||
|
// Keep serving the last good copy; retry on the next TTL window
|
||||||
|
.catch(() => { snapshot = { items: snapshot.items, at: Date.now() } })
|
||||||
|
.finally(() => { inflight = null })
|
||||||
|
}
|
||||||
|
return inflight
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFeedIndex() {
|
||||||
|
if (!snapshot.at) await refreshSnapshot()
|
||||||
|
else if (Date.now() - snapshot.at > SNAPSHOT_TTL_MS) refreshSnapshot()
|
||||||
|
|
||||||
|
const index = new Map()
|
||||||
|
for (const item of snapshot.items) {
|
||||||
|
if (item.season && /^\d+$/.test(item.episode)) index.set(`${item.season}-${item.episode}`, item)
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
export function warmFeedSnapshot() {
|
||||||
|
return refreshSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a copy of the published site content with the feed applied. Run it before
|
||||||
|
// filterSiteContentByReleaseDate so audio is never added to a lesson that is still locked.
|
||||||
|
export async function applyFeedRelease(content) {
|
||||||
|
if (!content || typeof content !== 'object' || Array.isArray(content)) return content
|
||||||
|
|
||||||
|
const links = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
||||||
|
const studies = Array.isArray(content.studies) ? content.studies : []
|
||||||
|
if (!links.some(l => l?.holdUntilReleased) && studies.length === 0) return content
|
||||||
|
|
||||||
|
const [index, wf] = await Promise.all([getFeedIndex(), loadPodcastWorkflow()])
|
||||||
|
const seasonA = laneSeasonOf('A', wf, content)
|
||||||
|
const seasonB = laneSeasonOf('B', wf, content)
|
||||||
|
|
||||||
|
const nextLinks = links.flatMap(link => {
|
||||||
|
// General highlights aren't tied to a series, so there's no season to match against
|
||||||
|
if (!link?.holdUntilReleased || link.lane === 'general') return [link]
|
||||||
|
const season = link.lane === 'second' ? seasonB : seasonA
|
||||||
|
const item = season && link.episodeNumber ? index.get(`${season}-${link.episodeNumber}`) : null
|
||||||
|
if (!item) return []
|
||||||
|
return [{ ...link, url: link.url || item.link || '' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const studyLane = study => {
|
||||||
|
if (study?.lane === 'A' || study?.lane === 'B') return study.lane
|
||||||
|
if (study?.slug && study.slug === content.seriesStudySlug) return 'A'
|
||||||
|
if (study?.slug && study.slug === content.secondSeries?.studySlug) return 'B'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStudies = studies.map(study => {
|
||||||
|
const lane = studyLane(study)
|
||||||
|
const season = lane === 'A' ? seasonA : lane === 'B' ? seasonB : ''
|
||||||
|
if (!season || !Array.isArray(study.sections)) return study
|
||||||
|
return {
|
||||||
|
...study,
|
||||||
|
sections: study.sections.map(section => {
|
||||||
|
if (!section || section.audioEmbedUrl?.trim() || !section.episodeNumber) return section
|
||||||
|
const item = index.get(`${season}-${String(section.episodeNumber).trim()}`)
|
||||||
|
return item?.audioUrl ? { ...section, audioEmbedUrl: item.audioUrl } : section
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...content, podcastFeaturedLinks: nextLinks, studies: nextStudies }
|
||||||
|
}
|
||||||
@@ -142,6 +142,7 @@ export function sanitizeFeaturedLinks(value) {
|
|||||||
return {
|
return {
|
||||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||||
lane: item.lane === 'second' || item.lane === 'general' ? item.lane : 'primary',
|
lane: item.lane === 'second' || item.lane === 'general' ? item.lane : 'primary',
|
||||||
|
holdUntilReleased: item.holdUntilReleased === true,
|
||||||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
|
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
|
||||||
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
|
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
|
||||||
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
|
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { applyFeedRelease } from '../feed-release.js'
|
||||||
import { mkdir, readFile } from 'node:fs/promises'
|
import { mkdir, readFile } from 'node:fs/promises'
|
||||||
import { writeFile } from '../atomic-write.js'
|
import { writeFile } from '../atomic-write.js'
|
||||||
import { assertPublicHttpsUrl } from '../url-safety.js'
|
import { assertPublicHttpsUrl } from '../url-safety.js'
|
||||||
@@ -249,7 +250,7 @@ export function register(app) {
|
|||||||
try {
|
try {
|
||||||
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
|
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
|
||||||
if (source === 'published') {
|
if (source === 'published') {
|
||||||
const safeSiteContent = filterSiteContentByReleaseDate(parsed.siteContent)
|
const safeSiteContent = filterSiteContentByReleaseDate(await applyFeedRelease(parsed.siteContent))
|
||||||
res.json({ ...parsed, siteContent: safeSiteContent })
|
res.json({ ...parsed, siteContent: safeSiteContent })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -279,7 +280,7 @@ export function register(app) {
|
|||||||
app.get('/api/site-config', async (_req, res) => {
|
app.get('/api/site-config', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const parsed = await loadSiteContentFile(DATA_FILE)
|
const parsed = await loadSiteContentFile(DATA_FILE)
|
||||||
const siteContent = parsed.siteContent ?? {}
|
const siteContent = await applyFeedRelease(parsed.siteContent ?? {})
|
||||||
res.json({
|
res.json({
|
||||||
seo: siteContent.seo ?? DEFAULT_SEO,
|
seo: siteContent.seo ?? DEFAULT_SEO,
|
||||||
legal: siteContent.legal ?? DEFAULT_LEGAL,
|
legal: siteContent.legal ?? DEFAULT_LEGAL,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Resend } from 'resend'
|
import { Resend } from 'resend'
|
||||||
import { readdir } from 'node:fs/promises'
|
import { readdir } from 'node:fs/promises'
|
||||||
import { requireAdminAuth } from '../auth.js'
|
import { requireAdminAuth } from '../auth.js'
|
||||||
|
import { buildSeasonCheck } from '../season-check.js'
|
||||||
|
import { countPendingUploads } from './nuggets-workflow.js'
|
||||||
import { state } from '../state.js'
|
import { state } from '../state.js'
|
||||||
import { getStorageStatus, loadUserProgress } from '../data.js'
|
import { getStorageStatus, loadUserProgress } from '../data.js'
|
||||||
import { getStudyCatalog } from '../study-helpers.js'
|
import { getStudyCatalog } from '../study-helpers.js'
|
||||||
@@ -151,6 +153,7 @@ async function buildNuggets() {
|
|||||||
const weekAgo = Date.now() - 7 * DAY_MS
|
const weekAgo = Date.now() - 7 * DAY_MS
|
||||||
return {
|
return {
|
||||||
uploadsThisWeek: entries.filter(e => new Date(e.uploadedAt).getTime() >= weekAgo).length,
|
uploadsThisWeek: entries.filter(e => new Date(e.uploadedAt).getTime() >= weekAgo).length,
|
||||||
|
pending: await countPendingUploads(),
|
||||||
latest: entries.slice(0, 3).map(e => ({ hostName: e.hostName, episodeName: e.episodeName, uploadedAt: e.uploadedAt })),
|
latest: entries.slice(0, 3).map(e => ({ hostName: e.hostName, episodeName: e.episodeName, uploadedAt: e.uploadedAt })),
|
||||||
entries: entries.slice(0, 10),
|
entries: entries.slice(0, 10),
|
||||||
}
|
}
|
||||||
@@ -219,13 +222,23 @@ export function register(app) {
|
|||||||
buildStudyProgress(), buildLatestEpisode(), buildNuggets(), buildHealth(), buildLastBroadcast(),
|
buildStudyProgress(), buildLatestEpisode(), buildNuggets(), buildHealth(), buildLastBroadcast(),
|
||||||
])
|
])
|
||||||
const comments = state.studyComments ?? []
|
const comments = state.studyComments ?? []
|
||||||
|
const seasonCheck = await buildSeasonCheck()
|
||||||
|
.then(c => ({
|
||||||
|
total: c.total,
|
||||||
|
errors: c.errors,
|
||||||
|
warnings: c.warnings,
|
||||||
|
general: c.general.length,
|
||||||
|
lanes: c.lanes.map(l => ({ lane: l.lane, title: l.title, season: l.season, errors: l.errors, warnings: l.warnings })),
|
||||||
|
}))
|
||||||
|
.catch(() => null)
|
||||||
res.json({
|
res.json({
|
||||||
|
seasonCheck,
|
||||||
trends: buildTrends(),
|
trends: buildTrends(),
|
||||||
traffic: buildTopTraffic(),
|
traffic: buildTopTraffic(),
|
||||||
reengagement: buildReengagement(),
|
reengagement: buildReengagement(),
|
||||||
studyProgress,
|
studyProgress,
|
||||||
latestEpisode,
|
latestEpisode,
|
||||||
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, latest: nuggets.latest },
|
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, pending: nuggets.pending, latest: nuggets.latest },
|
||||||
health,
|
health,
|
||||||
lastBroadcast,
|
lastBroadcast,
|
||||||
comments: {
|
comments: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const RSS_FEED_URL = 'https://anchor.fm/s/11068d290/podcast/rss'
|
|||||||
let episodesCache = null
|
let episodesCache = null
|
||||||
let episodesCacheAt = 0
|
let episodesCacheAt = 0
|
||||||
let rawXmlCache = null
|
let rawXmlCache = null
|
||||||
const EPISODES_CACHE_TTL = 30 * 60 * 1000
|
const EPISODES_CACHE_TTL = 10 * 60 * 1000
|
||||||
|
|
||||||
export async function fetchAllEpisodes() {
|
export async function fetchAllEpisodes() {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const AUDIO_TYPES = {
|
|||||||
|
|
||||||
export const WORKFLOW_STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify']
|
export const WORKFLOW_STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify']
|
||||||
|
|
||||||
async function loadWorkflow() {
|
export async function loadWorkflow() {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
|
return JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
|
||||||
} catch {
|
} catch {
|
||||||
@@ -34,7 +34,7 @@ async function loadWorkflow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryId(entry) {
|
export function entryId(entry) {
|
||||||
return createHash('sha1').update(`${entry.uploadedAt}|${entry.filename}`).digest('hex').slice(0, 16)
|
return createHash('sha1').update(`${entry.uploadedAt}|${entry.filename}`).digest('hex').slice(0, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +51,15 @@ function seafileConfigured() {
|
|||||||
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
|
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Portal uploads whose workflow still has steps left to do (any age, so an old one keeps showing).
|
||||||
|
export async function countPendingUploads() {
|
||||||
|
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
|
||||||
|
return history.entries.filter(entry => {
|
||||||
|
const steps = wf.items[entryId(entry)]?.steps ?? {}
|
||||||
|
return !WORKFLOW_STEPS.every(step => steps[step])
|
||||||
|
}).length
|
||||||
|
}
|
||||||
|
|
||||||
export function register(app) {
|
export function register(app) {
|
||||||
app.get('/api/admin/nuggets-workflow', requireAdminAuth, async (_req, res) => {
|
app.get('/api/admin/nuggets-workflow', requireAdminAuth, async (_req, res) => {
|
||||||
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
|
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const WORKFLOW_FILE = path.join(DATA_DIR, 'podcast-workflow.json')
|
|||||||
|
|
||||||
const STEPS = ['readScript', 'record', 'edit', 'upload']
|
const STEPS = ['readScript', 'record', 'edit', 'upload']
|
||||||
|
|
||||||
async function load() {
|
export async function loadPodcastWorkflow() {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
|
const data = JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
|
||||||
return { planned: Array.isArray(data.planned) ? data.planned : [], items: data.items ?? {}, laneOverrides: data.laneOverrides ?? {}, laneSeasons: data.laneSeasons ?? {} }
|
return { planned: Array.isArray(data.planned) ? data.planned : [], items: data.items ?? {}, laneOverrides: data.laneOverrides ?? {}, laneSeasons: data.laneSeasons ?? {} }
|
||||||
@@ -30,10 +30,9 @@ function emptyState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A lane's season comes from the workflow page's own setting, falling back to Current Series.
|
// A lane's season comes from the workflow page's own setting, falling back to Current Series.
|
||||||
function laneSeasonOf(lane, wf) {
|
export function laneSeasonOf(lane, wf, content = state.cachedSiteContent ?? {}) {
|
||||||
const saved = wf.laneSeasons?.[lane]
|
const saved = wf.laneSeasons?.[lane]
|
||||||
if (/^\d+$/.test(String(saved ?? ''))) return String(saved)
|
if (/^\d+$/.test(String(saved ?? ''))) return String(saved)
|
||||||
const content = state.cachedSiteContent ?? {}
|
|
||||||
const fallback = lane === 'A' ? content.seriesSeason : content.secondSeries?.season
|
const fallback = lane === 'A' ? content.seriesSeason : content.secondSeries?.season
|
||||||
return fallback ? String(fallback) : ''
|
return fallback ? String(fallback) : ''
|
||||||
}
|
}
|
||||||
@@ -55,7 +54,7 @@ function toIsoDate(pubDate) {
|
|||||||
|
|
||||||
export function register(app) {
|
export function register(app) {
|
||||||
app.get('/api/admin/podcast-workflow', requireAdminAuth, async (_req, res) => {
|
app.get('/api/admin/podcast-workflow', requireAdminAuth, async (_req, res) => {
|
||||||
const wf = await load()
|
const wf = await loadPodcastWorkflow()
|
||||||
let feed = []
|
let feed = []
|
||||||
let feedOk = true
|
let feedOk = true
|
||||||
try {
|
try {
|
||||||
@@ -119,7 +118,7 @@ export function register(app) {
|
|||||||
res.status(400).json({ message: 'Season and episode number must be whole numbers.' })
|
res.status(400).json({ message: 'Season and episode number must be whole numbers.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const wf = await load()
|
const wf = await loadPodcastWorkflow()
|
||||||
const key = scriptKey(season, episodeNumber)
|
const key = scriptKey(season, episodeNumber)
|
||||||
if (wf.planned.some(p => plannedKey(p) === key)) {
|
if (wf.planned.some(p => plannedKey(p) === key)) {
|
||||||
res.status(409).json({ message: `Season ${season} episode ${episodeNumber} has already been started.` })
|
res.status(409).json({ message: `Season ${season} episode ${episodeNumber} has already been started.` })
|
||||||
@@ -131,7 +130,7 @@ export function register(app) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.delete('/api/admin/podcast-workflow/planned/:key', requireAdminAuth, async (req, res) => {
|
app.delete('/api/admin/podcast-workflow/planned/:key', requireAdminAuth, async (req, res) => {
|
||||||
const wf = await load()
|
const wf = await loadPodcastWorkflow()
|
||||||
wf.planned = wf.planned.filter(p => plannedKey(p) !== req.params.key)
|
wf.planned = wf.planned.filter(p => plannedKey(p) !== req.params.key)
|
||||||
delete wf.items[req.params.key]
|
delete wf.items[req.params.key]
|
||||||
delete wf.laneOverrides[req.params.key]
|
delete wf.laneOverrides[req.params.key]
|
||||||
@@ -141,7 +140,7 @@ export function register(app) {
|
|||||||
|
|
||||||
// Set the season number each lane looks for ('' goes back to the Current Series value).
|
// Set the season number each lane looks for ('' goes back to the Current Series value).
|
||||||
app.put('/api/admin/podcast-workflow-lane-seasons', requireAdminAuth, async (req, res) => {
|
app.put('/api/admin/podcast-workflow-lane-seasons', requireAdminAuth, async (req, res) => {
|
||||||
const wf = await load()
|
const wf = await loadPodcastWorkflow()
|
||||||
for (const lane of ['A', 'B']) {
|
for (const lane of ['A', 'B']) {
|
||||||
const v = req.body?.[lane]
|
const v = req.body?.[lane]
|
||||||
if (v === undefined) continue
|
if (v === undefined) continue
|
||||||
@@ -161,7 +160,7 @@ export function register(app) {
|
|||||||
res.status(400).json({ message: 'Lane must be A, B, other or auto.' })
|
res.status(400).json({ message: 'Lane must be A, B, other or auto.' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const wf = await load()
|
const wf = await loadPodcastWorkflow()
|
||||||
if (lane === 'auto') delete wf.laneOverrides[req.params.key]
|
if (lane === 'auto') delete wf.laneOverrides[req.params.key]
|
||||||
else wf.laneOverrides[req.params.key] = lane
|
else wf.laneOverrides[req.params.key] = lane
|
||||||
await save(wf)
|
await save(wf)
|
||||||
@@ -170,7 +169,7 @@ export function register(app) {
|
|||||||
|
|
||||||
app.put('/api/admin/podcast-workflow/:key', requireAdminAuth, async (req, res) => {
|
app.put('/api/admin/podcast-workflow/:key', requireAdminAuth, async (req, res) => {
|
||||||
const { key } = req.params
|
const { key } = req.params
|
||||||
const wf = await load()
|
const wf = await loadPodcastWorkflow()
|
||||||
const prev = { ...emptyState(), ...(wf.items[key] ?? {}) }
|
const prev = { ...emptyState(), ...(wf.items[key] ?? {}) }
|
||||||
const next = { ...prev, steps: { ...prev.steps } }
|
const next = { ...prev, steps: { ...prev.steps } }
|
||||||
const body = req.body ?? {}
|
const body = req.body ?? {}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
import { applyFeedRelease } from '../feed-release.js'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { readFile } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js'
|
import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js'
|
||||||
@@ -59,6 +60,7 @@ export function register(app) {
|
|||||||
content = parsed.siteContent
|
content = parsed.siteContent
|
||||||
} catch { content = {} }
|
} catch { content = {} }
|
||||||
}
|
}
|
||||||
|
content = await applyFeedRelease(content)
|
||||||
|
|
||||||
const seo = content?.seo ?? DEFAULT_SEO
|
const seo = content?.seo ?? DEFAULT_SEO
|
||||||
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
|
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
|
||||||
@@ -196,7 +198,7 @@ export function register(app) {
|
|||||||
|
|
||||||
// Scripture index — every passage covered across released study lessons and podcast episodes
|
// Scripture index — every passage covered across released study lessons and podcast episodes
|
||||||
app.get('/api/public/scripture-index', async (_req, res) => {
|
app.get('/api/public/scripture-index', async (_req, res) => {
|
||||||
const content = state.cachedSiteContent
|
const content = await applyFeedRelease(state.cachedSiteContent)
|
||||||
const allStudies = Array.isArray(content?.studies) ? content.studies : []
|
const allStudies = Array.isArray(content?.studies) ? content.studies : []
|
||||||
const allEpisodes = [
|
const allEpisodes = [
|
||||||
...(Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []),
|
...(Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []),
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { state } from './state.js'
|
||||||
|
import { requireAdminAuth } from './auth.js'
|
||||||
|
import { scriptKey } from './episode-script-keys.js'
|
||||||
|
import { getFeedIndex } from './feed-release.js'
|
||||||
|
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
|
||||||
|
|
||||||
|
// One report per lane (A = primary series, B = second series) listing what is set up, what is
|
||||||
|
// missing, and an episode-by-episode grid. Reads the working draft so problems show up before
|
||||||
|
// publishing. Read-only: it never changes anything.
|
||||||
|
|
||||||
|
const err = (message, view) => ({ severity: 'error', message, view })
|
||||||
|
const warn = (message, view) => ({ severity: 'warn', message, view })
|
||||||
|
|
||||||
|
function laneConfig(content, lane) {
|
||||||
|
if (lane === 'A') {
|
||||||
|
return {
|
||||||
|
title: String(content.seriesTitle ?? '').trim(),
|
||||||
|
studySlug: String(content.seriesStudySlug ?? '').trim(),
|
||||||
|
highlightLane: 'primary',
|
||||||
|
description: String(content.seriesDescription ?? '').trim(),
|
||||||
|
imageUrl: String(content.seriesImageUrl ?? '').trim(),
|
||||||
|
listenUrl: String(content.seriesListenUrl ?? '').trim(),
|
||||||
|
hasGuide: Boolean(String(content.studyGuideTitle ?? '').trim() && (content.studyGuideDownloadUrl || content.studyGuideUrl || content.studyGuideAppleBooksUrl)),
|
||||||
|
transition: content.scheduledSeriesTransition,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const second = content.secondSeries ?? {}
|
||||||
|
return {
|
||||||
|
title: String(second.title ?? '').trim(),
|
||||||
|
studySlug: String(second.studySlug ?? '').trim(),
|
||||||
|
highlightLane: 'second',
|
||||||
|
description: String(second.description ?? '').trim(),
|
||||||
|
imageUrl: String(second.imageUrl ?? '').trim(),
|
||||||
|
listenUrl: String(second.listenUrl ?? '').trim(),
|
||||||
|
hasGuide: Boolean(String(second.guide?.title ?? '').trim() && (second.guide?.downloadUrl || second.guide?.amazonUrl || second.guide?.appleBooksUrl)),
|
||||||
|
transition: content.scheduledSecondSeriesTransition,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildSeasonCheck() {
|
||||||
|
const content = state.cachedDraftSiteContent ?? state.cachedSiteContent ?? {}
|
||||||
|
const [wf, index] = await Promise.all([loadPodcastWorkflow(), getFeedIndex()])
|
||||||
|
const seasons = { A: laneSeasonOf('A', wf, content), B: laneSeasonOf('B', wf, content) }
|
||||||
|
|
||||||
|
const studies = Array.isArray(content.studies) ? content.studies : []
|
||||||
|
const highlights = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
||||||
|
const planned = new Set(wf.planned.map(p => scriptKey(p.season, p.episodeNumber)))
|
||||||
|
|
||||||
|
const lanes = []
|
||||||
|
for (const lane of ['A', 'B']) {
|
||||||
|
const cfg = laneConfig(content, lane)
|
||||||
|
// Lane B only exists once a second series has been set up
|
||||||
|
if (lane === 'B' && !cfg.title && !cfg.studySlug && !seasons.B) continue
|
||||||
|
|
||||||
|
const season = seasons[lane]
|
||||||
|
const problems = []
|
||||||
|
if (!cfg.title) problems.push(warn(`Lane ${lane} has no series title.`, 'podcast:current-series'))
|
||||||
|
if (!season) problems.push(err(`Lane ${lane} has no season number. Highlights and lesson audio can't match the feed without it.`, 'podcast:workflow'))
|
||||||
|
if (season && lane === 'B' && season === seasons.A) problems.push(err(`Lanes A and B both use season ${season}.`, 'podcast:workflow'))
|
||||||
|
|
||||||
|
const study = studies.find(s => s?.lane === lane) ?? studies.find(s => s?.slug && s.slug === cfg.studySlug)
|
||||||
|
if (!study) problems.push(warn(`No study is linked to Lane ${lane}. Set the study's lane, or link it in Current Series.`, 'colossians-study'))
|
||||||
|
else if (study.lane !== lane) problems.push(warn(`"${study.title}" is linked to Lane ${lane} in Current Series but the study's own lane isn't set to ${lane}.`, 'colossians-study'))
|
||||||
|
|
||||||
|
const sections = Array.isArray(study?.sections) ? study.sections : []
|
||||||
|
const laneHighlights = highlights.filter(h => (h.lane ?? 'primary') === cfg.highlightLane)
|
||||||
|
|
||||||
|
const noNumber = sections.filter(s => !String(s.episodeNumber ?? '').trim())
|
||||||
|
if (study && noNumber.length > 0) {
|
||||||
|
problems.push(warn(`${noNumber.length} lesson${noNumber.length === 1 ? '' : 's'} in "${study.title}" ${noNumber.length === 1 ? 'has' : 'have'} no episode number, so ${noNumber.length === 1 ? 'it' : 'they'} can't pick up audio.`, 'colossians-study'))
|
||||||
|
}
|
||||||
|
const noDate = sections.filter(s => String(s.episodeNumber ?? '').trim() && !s.releasedAt)
|
||||||
|
if (study && noDate.length > 0) {
|
||||||
|
problems.push(warn(`${noDate.length} lesson${noDate.length === 1 ? '' : 's'} with an episode number ${noDate.length === 1 ? 'has' : 'have'} no release date.`, 'colossians-study'))
|
||||||
|
}
|
||||||
|
const noHighlightNumber = laneHighlights.filter(h => !String(h.episodeNumber ?? '').trim())
|
||||||
|
if (noHighlightNumber.length > 0) {
|
||||||
|
problems.push(warn(`${noHighlightNumber.length} highlight${noHighlightNumber.length === 1 ? '' : 's'} in Lane ${lane} ${noHighlightNumber.length === 1 ? 'has' : 'have'} no episode number.`, 'podcast:highlights'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Series card
|
||||||
|
if (!cfg.imageUrl) problems.push(warn(`Lane ${lane} has no cover image.`, 'podcast:current-series'))
|
||||||
|
if (!cfg.description) problems.push(warn(`Lane ${lane} has no series description.`, 'podcast:current-series'))
|
||||||
|
if (!cfg.listenUrl) problems.push(warn(`Lane ${lane} has no listen link.`, 'podcast:current-series'))
|
||||||
|
if (!cfg.hasGuide) problems.push(warn(`Lane ${lane} has no companion study guide (title plus a download or purchase link).`, 'podcast:current-series'))
|
||||||
|
|
||||||
|
// Handover to the next season
|
||||||
|
const t = cfg.transition
|
||||||
|
if (t) {
|
||||||
|
if (!String(t.newTitle ?? '').trim() || !t.newSeason) {
|
||||||
|
problems.push(warn(`The next-season handover for Lane ${lane} is scheduled but is missing its title or season number.`, 'podcast:current-series'))
|
||||||
|
} else if (season && String(t.newSeason) === season) {
|
||||||
|
problems.push(warn(`The next-season handover for Lane ${lane} uses the same season number (${season}) as the current one.`, 'podcast:current-series'))
|
||||||
|
}
|
||||||
|
} else if (study && sections.length > 0 && sections.every(x => x.releasedAt && new Date(x.releasedAt) <= new Date())) {
|
||||||
|
problems.push(warn(`Every lesson in "${study.title}" is released, but no next season is scheduled for Lane ${lane}.`, 'podcast:current-series'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Episode numbers to show: every number any part of the season mentions, plus 1..lesson count
|
||||||
|
const numbers = new Set()
|
||||||
|
for (let n = 1; n <= sections.length; n++) numbers.add(n)
|
||||||
|
const addNumber = v => { const n = Number(v); if (Number.isInteger(n) && n > 0) numbers.add(n) }
|
||||||
|
sections.forEach(s => addNumber(s.episodeNumber))
|
||||||
|
laneHighlights.forEach(h => addNumber(h.episodeNumber))
|
||||||
|
for (const [key, item] of index) if (season && key.startsWith(`${season}-`)) addNumber(item.episode)
|
||||||
|
for (const p of wf.planned) if (season && String(p.season) === season) addNumber(p.episodeNumber)
|
||||||
|
for (const key of Object.keys(state.episodeScripts ?? {})) if (season && key.startsWith(`${season}-`)) addNumber(key.split('-')[1])
|
||||||
|
|
||||||
|
const episodes = [...numbers].sort((a, b) => a - b).map(n => {
|
||||||
|
const num = String(n)
|
||||||
|
const feedItem = season ? index.get(`${season}-${num}`) : undefined
|
||||||
|
const released = Boolean(feedItem)
|
||||||
|
const lessons = sections.filter(s => String(s.episodeNumber ?? '').trim() === num)
|
||||||
|
const hl = laneHighlights.filter(h => String(h.episodeNumber ?? '').trim() === num)
|
||||||
|
const lessonHasAudio = lessons.some(s => s.audioEmbedUrl?.trim() || (released && feedItem?.audioUrl))
|
||||||
|
|
||||||
|
if (lessons.length > 1) problems.push(warn(`Episode ${num}: ${lessons.length} lessons share this episode number.`, 'colossians-study'))
|
||||||
|
if (hl.length > 1) problems.push(warn(`Episode ${num}: ${hl.length} highlights share this episode number.`, 'podcast:highlights'))
|
||||||
|
if (season && !released && hl.some(h => !h.holdUntilReleased && h.lane !== 'general')) {
|
||||||
|
problems.push(warn(`Episode ${num}: the highlight will show on the site before the episode is out. Turn on "Publish when the episode is released".`, 'podcast:highlights'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
number: n,
|
||||||
|
released,
|
||||||
|
releaseDate: feedItem?.pubDate ? new Date(feedItem.pubDate).toISOString().slice(0, 10) : '',
|
||||||
|
workflowStarted: season ? planned.has(scriptKey(season, num)) : false,
|
||||||
|
script: season ? Boolean(state.episodeScripts?.[scriptKey(season, num)]) : false,
|
||||||
|
lesson: lessons.length > 0,
|
||||||
|
lessonAudio: lessons.length > 0 ? lessonHasAudio : null,
|
||||||
|
highlight: hl.length > 0,
|
||||||
|
highlightHeld: hl.length > 0 ? hl.every(h => h.holdUntilReleased === true) : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
lanes.push({
|
||||||
|
lane,
|
||||||
|
title: cfg.title,
|
||||||
|
season,
|
||||||
|
studyTitle: study?.title ?? '',
|
||||||
|
problems,
|
||||||
|
errors: problems.filter(p => p.severity === 'error').length,
|
||||||
|
warnings: problems.filter(p => p.severity === 'warn').length,
|
||||||
|
episodes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scripts are filed by season + episode, so one with no season can't be matched to an episode
|
||||||
|
const general = []
|
||||||
|
const unseasoned = Object.entries(state.episodeScripts ?? {}).filter(([, entry]) => !String(entry?.season ?? '').trim())
|
||||||
|
if (unseasoned.length > 0) {
|
||||||
|
general.push(warn(`${unseasoned.length} episode script${unseasoned.length === 1 ? ' has' : 's have'} no season number, so ${unseasoned.length === 1 ? 'it' : 'they'} can't be matched to an episode. Use the season backfill in Episode Scripts.`, 'podcast:scripts'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = [...lanes.flatMap(l => l.problems), ...general]
|
||||||
|
return {
|
||||||
|
lanes,
|
||||||
|
general,
|
||||||
|
total: all.length,
|
||||||
|
errors: all.filter(p => p.severity === 'error').length,
|
||||||
|
warnings: all.filter(p => p.severity === 'warn').length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(app) {
|
||||||
|
app.get('/api/admin/season-check', requireAdminAuth, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await buildSeasonCheck())
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[season-check] failed:', err)
|
||||||
|
res.status(500).json({ message: 'Could not build the season check.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+143
-2
@@ -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:workflow' | 'podcast:rss'
|
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:workflow' | 'podcast:season-check' | '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'
|
||||||
@@ -778,6 +778,7 @@ const ADMIN_NAV: NavGroup[] = [
|
|||||||
group: 'Content',
|
group: 'Content',
|
||||||
items: [
|
items: [
|
||||||
{ kind: 'parent', id: 'podcast', label: 'Podcast Hub', children: [
|
{ kind: 'parent', id: 'podcast', label: 'Podcast Hub', children: [
|
||||||
|
{ kind: 'leaf', view: 'podcast:season-check', label: 'Season Check' },
|
||||||
{ kind: 'leaf', view: 'podcast:current-series', label: 'Current Series' },
|
{ kind: 'leaf', view: 'podcast:current-series', label: 'Current Series' },
|
||||||
{ 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' },
|
||||||
@@ -2122,6 +2123,127 @@ function PodcastWorkflowAdmin({ laneA, laneB }: { laneA: { title: string }; lane
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Season Check (everything a season needs, per lane) ─────────────────────
|
||||||
|
interface SeasonCheckProblem { severity: 'error' | 'warn'; message: string; view: string }
|
||||||
|
interface SeasonCheckEpisode {
|
||||||
|
number: number
|
||||||
|
released: boolean
|
||||||
|
releaseDate: string
|
||||||
|
workflowStarted: boolean
|
||||||
|
script: boolean
|
||||||
|
lesson: boolean
|
||||||
|
lessonAudio: boolean | null
|
||||||
|
highlight: boolean
|
||||||
|
highlightHeld: boolean | null
|
||||||
|
}
|
||||||
|
interface SeasonCheckLane {
|
||||||
|
lane: 'A' | 'B'
|
||||||
|
title: string
|
||||||
|
season: string
|
||||||
|
studyTitle: string
|
||||||
|
problems: SeasonCheckProblem[]
|
||||||
|
errors: number
|
||||||
|
warnings: number
|
||||||
|
episodes: SeasonCheckEpisode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeasonCheckAdmin({ onNavigate }: { onNavigate: (view: AdminView) => void }) {
|
||||||
|
const [report, setReport] = useState<{ lanes: SeasonCheckLane[]; general: SeasonCheckProblem[]; total: number } | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchReport = () =>
|
||||||
|
fetch('/api/admin/season-check')
|
||||||
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('failed'))))
|
||||||
|
.then(d => { setReport(d); setError('') })
|
||||||
|
.catch(() => setError('Could not run the season check.'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
useEffect(() => { fetchReport() }, [])
|
||||||
|
|
||||||
|
const yes = <span style={{ color: '#4a9c5d' }} aria-label="yes">✓</span>
|
||||||
|
const no = <span style={{ opacity: 0.35 }} aria-label="no">–</span>
|
||||||
|
const cell = { padding: '4px 8px', textAlign: 'center' as const }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-panel-section" aria-label="Season check">
|
||||||
|
<div className="admin-panel-head">
|
||||||
|
<h2>Podcast — Season Check</h2>
|
||||||
|
<p>Everything a season needs, per lane. This reads your saved draft, so it catches gaps before you publish. Nothing here changes anything.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn-admin-ghost" onClick={() => { setLoading(true); fetchReport() }} disabled={loading}>{loading ? 'Checking…' : 'Check again'}</button>
|
||||||
|
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
|
||||||
|
{report && report.lanes.length === 0 && <p className="admin-stats-note">No lanes are set up yet.</p>}
|
||||||
|
{report && report.general.length > 0 && (
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<h3 style={{ marginBottom: 4 }}>All lanes</h3>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '8px 0 12px' }}>
|
||||||
|
{report.general.map((p, i) => (
|
||||||
|
<li key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '5px 0' }}>
|
||||||
|
<span style={{ color: p.severity === 'error' ? '#c0392b' : '#b8860b' }}>{p.severity === 'error' ? '●' : '▲'}</span>
|
||||||
|
<span style={{ flex: 1 }}>{p.message}</span>
|
||||||
|
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate(p.view as AdminView)}>Fix →</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{report?.lanes.map(l => (
|
||||||
|
<div key={l.lane} style={{ marginTop: 20 }}>
|
||||||
|
<h3 style={{ marginBottom: 4 }}>
|
||||||
|
Lane {l.lane}{l.title ? ` — ${l.title}` : ''}
|
||||||
|
</h3>
|
||||||
|
<p className="admin-stats-note" style={{ marginTop: 0 }}>
|
||||||
|
{l.season ? `Season ${l.season}` : 'No season number'}{l.studyTitle ? ` · Study: ${l.studyTitle}` : ' · No study linked'}
|
||||||
|
</p>
|
||||||
|
{l.problems.length === 0 ? (
|
||||||
|
<p style={{ color: '#4a9c5d' }}>✓ Nothing to fix.</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '8px 0 12px' }}>
|
||||||
|
{l.problems.map((p, i) => (
|
||||||
|
<li key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', padding: '5px 0' }}>
|
||||||
|
<span style={{ color: p.severity === 'error' ? '#c0392b' : '#b8860b' }}>{p.severity === 'error' ? '●' : '▲'}</span>
|
||||||
|
<span style={{ flex: 1 }}>{p.message}</span>
|
||||||
|
<button type="button" className="btn-admin-ghost" onClick={() => onNavigate(p.view as AdminView)}>Fix →</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{l.episodes.length > 0 && (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid var(--color-border, #e5e0d5)' }}>
|
||||||
|
<th style={{ ...cell, textAlign: 'left' }}>Ep</th>
|
||||||
|
<th style={cell}>Workflow</th>
|
||||||
|
<th style={cell}>Script</th>
|
||||||
|
<th style={cell}>Lesson</th>
|
||||||
|
<th style={cell}>Lesson audio</th>
|
||||||
|
<th style={cell}>Highlight</th>
|
||||||
|
<th style={cell}>Live on Spotify</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{l.episodes.map(e => (
|
||||||
|
<tr key={e.number} style={{ borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
|
||||||
|
<td style={{ ...cell, textAlign: 'left' }}><strong>{e.number}</strong></td>
|
||||||
|
<td style={cell}>{e.released ? 'Released' : e.workflowStarted ? 'In progress' : no}</td>
|
||||||
|
<td style={cell}>{e.script ? yes : no}</td>
|
||||||
|
<td style={cell}>{e.lesson ? yes : no}</td>
|
||||||
|
<td style={cell}>{e.lessonAudio === null ? no : e.lessonAudio ? yes : no}</td>
|
||||||
|
<td style={cell}>{e.highlight ? (e.highlightHeld ? 'Waits for release' : yes) : no}</td>
|
||||||
|
<td style={cell}>{e.released ? <>{yes} {e.releaseDate}</> : no}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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)
|
||||||
@@ -2762,6 +2884,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
{
|
{
|
||||||
id: Date.now().toString(36),
|
id: Date.now().toString(36),
|
||||||
lane: 'primary' as const,
|
lane: 'primary' as const,
|
||||||
|
holdUntilReleased: true,
|
||||||
title: '',
|
title: '',
|
||||||
episodeNumber: '',
|
episodeNumber: '',
|
||||||
summary: '',
|
summary: '',
|
||||||
@@ -2778,7 +2901,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePodcastLink(id: string, field: keyof PodcastFeaturedLink, value: string) {
|
function updatePodcastLink(id: string, field: keyof PodcastFeaturedLink, value: string | boolean) {
|
||||||
setForm(f => ({
|
setForm(f => ({
|
||||||
...f,
|
...f,
|
||||||
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link => link.id === id ? { ...link, [field]: value } : link),
|
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link => link.id === id ? { ...link, [field]: value } : link),
|
||||||
@@ -5151,6 +5274,22 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
</select>
|
</select>
|
||||||
<p className="admin-field-help">Groups this highlight under its series on the Episodes page. When a series ends, its highlights move onto that book's Finished Books page. General highlights stay.</p>
|
<p className="admin-field-help">Groups this highlight under its series on the Episodes page. When a series ends, its highlights move onto that book's Finished Books page. General highlights stay.</p>
|
||||||
</div>
|
</div>
|
||||||
|
{activeHighlight.lane !== 'general' && (
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`podcast-hold-${activeHighlight.id}`}>
|
||||||
|
<input
|
||||||
|
id={`podcast-hold-${activeHighlight.id}`}
|
||||||
|
type="checkbox"
|
||||||
|
checked={activeHighlight.holdUntilReleased === true}
|
||||||
|
onChange={e => updatePodcastLink(activeHighlight.id, 'holdUntilReleased', e.target.checked)}
|
||||||
|
/>{' '}
|
||||||
|
Publish when the episode is released on Spotify
|
||||||
|
</label>
|
||||||
|
<p className="admin-field-help">
|
||||||
|
Hidden on the site until this lane's season number and the episode number below appear in your Spotify feed (checked every few minutes). Needs an episode number. Set each lane's season number on the Episode Workflow page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`podcast-ep-${activeHighlight.id}`}>Episode Number</label>
|
<label htmlFor={`podcast-ep-${activeHighlight.id}`}>Episode Number</label>
|
||||||
<input id={`podcast-ep-${activeHighlight.id}`} type="text" placeholder="e.g. 42" value={activeHighlight.episodeNumber ?? ''} onChange={e => updatePodcastLink(activeHighlight.id, 'episodeNumber', e.target.value)} />
|
<input id={`podcast-ep-${activeHighlight.id}`} type="text" placeholder="e.g. 42" value={activeHighlight.episodeNumber ?? ''} onChange={e => updatePodcastLink(activeHighlight.id, 'episodeNumber', e.target.value)} />
|
||||||
@@ -6185,6 +6324,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
<option value={activeLesson.audioEmbedUrl}>{activeLesson.audioEmbedUrl}</option>
|
<option value={activeLesson.audioEmbedUrl}>{activeLesson.audioEmbedUrl}</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
|
<p className="admin-field-help">Leave this as "No audio" to fill it in automatically: once this lane's season number and the Episode # below appear in your Spotify feed, the lesson uses that episode's audio.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`sl-released-${activeLesson.id}`}>Release Date (optional)</label>
|
<label htmlFor={`sl-released-${activeLesson.id}`}>Release Date (optional)</label>
|
||||||
@@ -6316,6 +6456,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* EPISODE SCRIPTS */}
|
{/* EPISODE SCRIPTS */}
|
||||||
|
{adminView === 'podcast:season-check' && <SeasonCheckAdmin onNavigate={setAdminView} />}
|
||||||
{adminView === 'podcast:workflow' && <PodcastWorkflowAdmin laneA={{ title: form.seriesTitle }} laneB={{ title: form.secondSeries?.title ?? '' }} />}
|
{adminView === 'podcast:workflow' && <PodcastWorkflowAdmin laneA={{ title: form.seriesTitle }} laneB={{ title: form.secondSeries?.title ?? '' }} />}
|
||||||
{adminView === 'podcast:scripts' && (
|
{adminView === 'podcast:scripts' && (
|
||||||
<section className="admin-panel-section">
|
<section className="admin-panel-section">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ interface Trend { thisWeek: number; lastWeek: number; series: number[] }
|
|||||||
interface ResendStatus { available: boolean; reason?: string; broadcast?: { id: string; name: string; subject: string; sentAt: string | null; dashboardUrl: string } | null }
|
interface ResendStatus { available: boolean; reason?: string; broadcast?: { id: string; name: string; subject: string; sentAt: string | null; dashboardUrl: string } | null }
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
|
seasonCheck: { total: number; errors: number; warnings: number; general: number; lanes: { lane: string; title: string; season: string; errors: number; warnings: number }[] } | null
|
||||||
trends: { visits: Trend; subscribers: Trend; studySignups: Trend }
|
trends: { visits: Trend; subscribers: Trend; studySignups: Trend }
|
||||||
traffic: { topPages: { path: string; hits: number }[]; topReferrers: { referrer: string; count: number }[] }
|
traffic: { topPages: { path: string; hits: number }[]; topReferrers: { referrer: string; count: number }[] }
|
||||||
reengagement: { t1: number; t2: number; t3: number; recentlySent: number; remindersEnabled: number }
|
reengagement: { t1: number; t2: number; t3: number; recentlySent: number; remindersEnabled: number }
|
||||||
@@ -13,7 +14,7 @@ export interface DashboardData {
|
|||||||
lastActivityAt: string | null
|
lastActivityAt: string | null
|
||||||
}
|
}
|
||||||
latestEpisode: { available: boolean; title?: string; episode?: string; season?: string; publishedAt?: string | null; daysAgo?: number | null; stale?: boolean; staleAfterDays?: number }
|
latestEpisode: { available: boolean; title?: string; episode?: string; season?: string; publishedAt?: string | null; daysAgo?: number | null; stale?: boolean; staleAfterDays?: number }
|
||||||
nuggets: { uploadsThisWeek: number; latest: { hostName: string; episodeName: string; uploadedAt: string }[] }
|
nuggets: { uploadsThisWeek: number; pending: number; latest: { hostName: string; episodeName: string; uploadedAt: string }[] }
|
||||||
health: {
|
health: {
|
||||||
resendConfigured: boolean
|
resendConfigured: boolean
|
||||||
lastBackup: { ok: boolean; at: string | null; error: string | null; file: string | null }
|
lastBackup: { ok: boolean; at: string | null; error: string | null; file: string | null }
|
||||||
@@ -90,8 +91,9 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN
|
|||||||
|
|
||||||
const attention: { key: string; label: string; tone: 'alert' | 'ok'; action?: () => void; actionLabel?: string }[] = []
|
const attention: { key: string; label: string; tone: 'alert' | 'ok'; action?: () => void; actionLabel?: string }[] = []
|
||||||
if (data) {
|
if (data) {
|
||||||
|
if (data.seasonCheck && data.seasonCheck.total > 0) attention.push({ key: 'season-check', label: `Season check: ${data.seasonCheck.total} thing${data.seasonCheck.total === 1 ? '' : 's'} to fix`, tone: 'alert', action: () => onNavigate('podcast:season-check'), actionLabel: 'Open Season Check' })
|
||||||
if (data.comments.pending > 0) attention.push({ key: 'comments', label: `${data.comments.pending} study comment${data.comments.pending === 1 ? '' : 's'} awaiting approval`, tone: 'alert', action: () => onNavigate('study-comments'), actionLabel: 'Review' })
|
if (data.comments.pending > 0) attention.push({ key: 'comments', label: `${data.comments.pending} study comment${data.comments.pending === 1 ? '' : 's'} awaiting approval`, tone: 'alert', action: () => onNavigate('study-comments'), actionLabel: 'Review' })
|
||||||
if (data.nuggets.uploadsThisWeek > 0) attention.push({ key: 'nuggets', label: `${data.nuggets.uploadsThisWeek} Nuggets upload${data.nuggets.uploadsThisWeek === 1 ? '' : 's'} in the last 7 days`, tone: 'alert', action: () => onNavigate('nuggets:history'), actionLabel: 'View uploads' })
|
if (data.nuggets.pending > 0) attention.push({ key: 'nuggets', label: `${data.nuggets.pending} Nuggets upload${data.nuggets.pending === 1 ? '' : 's'} waiting in the workflow`, tone: 'alert', action: () => onNavigate('nuggets:workflow'), actionLabel: 'Open workflow' })
|
||||||
if (data.latestEpisode.available && data.latestEpisode.stale) attention.push({ key: 'feed', label: `Podcast feed has no new episode in ${data.latestEpisode.daysAgo} days`, tone: 'alert', action: () => onNavigate('podcast:rss'), actionLabel: 'Check feed' })
|
if (data.latestEpisode.available && data.latestEpisode.stale) attention.push({ key: 'feed', label: `Podcast feed has no new episode in ${data.latestEpisode.daysAgo} days`, tone: 'alert', action: () => onNavigate('podcast:rss'), actionLabel: 'Check feed' })
|
||||||
if (!data.health.lastBackup.ok) attention.push({ key: 'backup', label: 'Last data backup failed', tone: 'alert' })
|
if (!data.health.lastBackup.ok) attention.push({ key: 'backup', label: 'Last data backup failed', tone: 'alert' })
|
||||||
if (data.health.dataDirWritable === false) attention.push({ key: 'disk', label: 'Data directory is not writable', tone: 'alert' })
|
if (data.health.dataDirWritable === false) attention.push({ key: 'disk', label: 'Data directory is not writable', tone: 'alert' })
|
||||||
@@ -137,6 +139,30 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{data.seasonCheck && data.seasonCheck.lanes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 className="admin-dashboard-section-title">Season check</h3>
|
||||||
|
<div className="admin-dashboard-grid">
|
||||||
|
{data.seasonCheck.lanes.map(l => {
|
||||||
|
const issues = l.errors + l.warnings
|
||||||
|
return (
|
||||||
|
<div key={l.lane} className="admin-dashboard-card">
|
||||||
|
<div className="admin-dashboard-card-value">{issues === 0 ? '✓' : issues}</div>
|
||||||
|
<div className="admin-dashboard-card-label">Lane {l.lane}{l.title ? ` — ${l.title}` : ''}</div>
|
||||||
|
<div className="admin-dashboard-card-sub">
|
||||||
|
{l.season ? `Season ${l.season}` : 'No season number set'} · {issues === 0 ? 'all set' : `${issues} to fix`}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="admin-dashboard-card-action" onClick={() => onNavigate('podcast:season-check')}>Season Check →</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{data.seasonCheck.general > 0 && (
|
||||||
|
<p className="admin-stats-note">Plus {data.seasonCheck.general} thing{data.seasonCheck.general === 1 ? '' : 's'} that apply to every lane (such as scripts with no season number).</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<h3 className="admin-dashboard-section-title">Trends</h3>
|
<h3 className="admin-dashboard-section-title">Trends</h3>
|
||||||
<div className="admin-dashboard-grid">
|
<div className="admin-dashboard-grid">
|
||||||
<TrendCard label="Real visits (7 days)" trend={data.trends.visits} onClick={() => onNavigate('analytics')} actionLabel="Analytics" />
|
<TrendCard label="Real visits (7 days)" trend={data.trends.visits} onClick={() => onNavigate('analytics')} actionLabel="Analytics" />
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export interface PodcastFeaturedLink {
|
|||||||
// Which series this highlight belongs to: Lane A (primary), Lane B (second), or general.
|
// Which series this highlight belongs to: Lane A (primary), Lane B (second), or general.
|
||||||
// Missing = primary (older highlights were all about the one running series).
|
// Missing = primary (older highlights were all about the one running series).
|
||||||
lane?: 'primary' | 'second' | 'general'
|
lane?: 'primary' | 'second' | 'general'
|
||||||
|
// When true the highlight stays hidden on the public site until its season + episode number
|
||||||
|
// appear in the Spotify feed (the lane's season number is set on the Episode Workflow page).
|
||||||
|
holdUntilReleased?: boolean
|
||||||
title: string
|
title: string
|
||||||
episodeNumber?: string
|
episodeNumber?: string
|
||||||
summary: string
|
summary: string
|
||||||
|
|||||||
Reference in new issue
Block a user