Hold episode highlights until the episode is in the Spotify feed; auto-fill study lesson audio from the feed by season + episode; v2.19.0
Publish Container / build (push) Successful in 16m16s
Publish Container / build (push) Successful in 16m16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
69d445013a
commit
77bce2ae0c
11 files changed
+127
-17
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.18.0",
|
"version": "2.19.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.18.0",
|
"version": "2.19.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.19.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ 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 { 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'
|
||||||
@@ -208,6 +209,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
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 : []),
|
||||||
|
|||||||
+19
-1
@@ -2762,6 +2762,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 +2779,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 +5152,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 +6202,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>
|
||||||
|
|||||||
@@ -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