Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2150e1cd7 | ||
|
|
39691aedb3 | ||
|
|
752611d121 | ||
|
|
4ddedf0f81 | ||
|
|
3bd467e198 | ||
|
|
810097bb8e | ||
|
|
1eb0dc1296 | ||
|
|
652b003bd9 | ||
|
|
ddcaec0ddf |
No files matched your search
@@ -2,7 +2,7 @@ name: Publish Container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
branches: ["main", "beta"]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -48,6 +48,7 @@ jobs:
|
||||
images: git.necloud.us/nate/siteforge
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' }}
|
||||
type=sha
|
||||
type=ref,event=tag
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Publish Container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
branches: ["main", "beta"]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -50,6 +50,7 @@ jobs:
|
||||
images: git.necloud.us/nate/siteforge
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' }}
|
||||
type=sha
|
||||
type=ref,event=tag
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.8.5",
|
||||
"version": "2.9.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+6
-1
@@ -1,3 +1,4 @@
|
||||
import { migrateScriptKeys } from './episode-script-keys.js'
|
||||
import { mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
@@ -804,7 +805,11 @@ export async function loadEpisodeScriptsFromDisk() {
|
||||
try {
|
||||
const raw = await readFile(EPISODE_SCRIPTS_FILE, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
state.episodeScripts = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
|
||||
const loaded = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
|
||||
const { scripts, changed } = migrateScriptKeys(loaded)
|
||||
state.episodeScripts = scripts
|
||||
// Persist the move to season-episode keys once, so it isn't redone on every start
|
||||
if (changed) queueEpisodeScriptsWrite()
|
||||
} catch {
|
||||
state.episodeScripts = {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Episode scripts are filed by season + episode number ("2-1"), because each book of the Bible
|
||||
// is its own podcast season and episode numbers can restart in every season. A script with no
|
||||
// season keeps its plain episode number as its key.
|
||||
|
||||
export function scriptKey(season, episodeNumber) {
|
||||
const ep = String(episodeNumber ?? '').trim()
|
||||
const s = String(season ?? '').trim()
|
||||
return /^\d+$/.test(s) ? `${s}-${ep}` : ep
|
||||
}
|
||||
|
||||
// The episode number a stored script belongs to (new entries record it; legacy ones are keyed by it).
|
||||
export function scriptEpisodeNumber(key, entry) {
|
||||
if (entry && typeof entry.episodeNumber === 'string' && entry.episodeNumber) return entry.episodeNumber
|
||||
const parts = String(key).split('-')
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
// One-time move of legacy scripts ({ "7": { season: "1" } }) to season-episode keys ({ "1-7": ... }).
|
||||
// Scripts without a season stay under their plain episode number. Never overwrites an existing key.
|
||||
export function migrateScriptKeys(scripts) {
|
||||
const next = {}
|
||||
let changed = false
|
||||
for (const [key, entry] of Object.entries(scripts ?? {})) {
|
||||
if (!entry || typeof entry !== 'object') continue
|
||||
const episodeNumber = scriptEpisodeNumber(key, entry)
|
||||
const wanted = scriptKey(entry.season, episodeNumber)
|
||||
const targetKey = wanted !== key && !(wanted in scripts) && !(wanted in next) ? wanted : key
|
||||
const withEpisode = entry.episodeNumber === episodeNumber ? entry : { ...entry, episodeNumber }
|
||||
if (targetKey !== key || withEpisode !== entry) changed = true
|
||||
next[targetKey] = withEpisode
|
||||
}
|
||||
return { scripts: next, changed }
|
||||
}
|
||||
+47
-1
@@ -141,6 +141,7 @@ export function sanitizeFeaturedLinks(value) {
|
||||
|
||||
return {
|
||||
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
|
||||
lane: item.lane === 'second' || item.lane === 'general' ? item.lane : 'primary',
|
||||
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
|
||||
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
|
||||
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
|
||||
@@ -290,6 +291,45 @@ function sanitizeColossiansStudySections(value) {
|
||||
.filter(item => item.title || item.summary || item.commentary || item.greekNotes.length > 0 || item.studyQuestions.length > 0 || item.passageText || item.checkpointPrompt || item.checkpointQuestions.length > 0)
|
||||
}
|
||||
|
||||
function sanitizeIsoDate(value) {
|
||||
if (typeof value !== 'string' || !value.trim()) return ''
|
||||
const time = Date.parse(value)
|
||||
return Number.isFinite(time) ? new Date(time).toISOString() : ''
|
||||
}
|
||||
|
||||
function sanitizeStudyGuide(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||
const str = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '')
|
||||
const title = str(value.title, 140)
|
||||
if (!title) return undefined
|
||||
return {
|
||||
title,
|
||||
description: str(value.description, 4000),
|
||||
downloadUrl: sanitizeUrl(value.downloadUrl),
|
||||
amazonUrl: sanitizeUrl(value.amazonUrl),
|
||||
amazonLabel: str(value.amazonLabel, 80),
|
||||
appleBooksUrl: sanitizeUrl(value.appleBooksUrl),
|
||||
appleBooksLabel: str(value.appleBooksLabel, 80),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeSecondSeries(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||
const str = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '')
|
||||
const season = Number.isInteger(value.season) && value.season >= 1 && value.season <= 999 ? value.season : undefined
|
||||
return {
|
||||
label: str(value.label, 80) || 'Now Playing',
|
||||
title: str(value.title, 160),
|
||||
description: str(value.description, 800),
|
||||
imageUrl: str(value.imageUrl, 500),
|
||||
listenUrl: str(value.listenUrl, 500),
|
||||
listenBtnLabel: str(value.listenBtnLabel, 80),
|
||||
studySlug: str(value.studySlug, 120).toLowerCase().replace(/[^a-z0-9-]/g, '-'),
|
||||
...(season ? { season } : {}),
|
||||
...(sanitizeStudyGuide(value.guide) ? { guide: sanitizeStudyGuide(value.guide) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeStudies(value) {
|
||||
const source = Array.isArray(value) ? value : []
|
||||
return source
|
||||
@@ -303,7 +343,9 @@ function sanitizeStudies(value) {
|
||||
showOnHomepage: item.showOnHomepage === true,
|
||||
showNewTag: item.showNewTag === true,
|
||||
newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '',
|
||||
status: item.status === 'planned' ? 'planned' : 'active',
|
||||
status: item.status === 'planned' ? 'planned' : (item.status === 'complete' ? 'complete' : 'active'),
|
||||
lane: item.lane === 'A' || item.lane === 'B' ? item.lane : '',
|
||||
startsAt: sanitizeIsoDate(item.startsAt),
|
||||
difficulty: item.difficulty === 'advanced' ? 'advanced' : (item.difficulty === 'intermediate' ? 'intermediate' : 'beginner'),
|
||||
estimatedHours: Number.isFinite(Number(item.estimatedHours)) ? Math.max(1, Math.min(500, Number(item.estimatedHours))) : 8,
|
||||
completionBadge: typeof item.completionBadge === 'string' ? item.completionBadge.trim().slice(0, 120) : '',
|
||||
@@ -384,6 +426,10 @@ export function sanitizeSiteContent(siteContent) {
|
||||
customBlocks: sanitizeCustomBlocks(siteContent.customBlocks),
|
||||
archivedSeries: sanitizeArchivedSeries(siteContent.archivedSeries),
|
||||
studies: sanitizeStudies(siteContent.studies),
|
||||
secondSeries: sanitizeSecondSeries(siteContent.secondSeries),
|
||||
finishedBooks: Array.isArray(siteContent.finishedBooks)
|
||||
? siteContent.finishedBooks.map(book => (book && typeof book === 'object' ? { ...book, guide: sanitizeStudyGuide(book.guide) } : book))
|
||||
: siteContent.finishedBooks,
|
||||
colossiansStudySections: sanitizeColossiansStudySections(siteContent.colossiansStudySections),
|
||||
redirects: sanitizeRedirectRules(siteContent.redirects),
|
||||
podcastFeaturedLinks: sanitizeFeaturedLinks(siteContent.podcastFeaturedLinks ?? DEFAULT_PODCAST_FEATURED_LINKS),
|
||||
|
||||
+132
-32
@@ -53,15 +53,66 @@ function invokeWebhook(url, action) {
|
||||
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
|
||||
}
|
||||
|
||||
// Builds the content that results from applying a scheduled series transition.
|
||||
function applySeriesTransition(content, sched) {
|
||||
const archivedBook = {
|
||||
id: Date.now().toString(36),
|
||||
title: content.seriesTitle ?? '',
|
||||
description: content.seriesDescription ?? '',
|
||||
imageUrl: content.seriesImageUrl ?? '',
|
||||
season: (sched.newSeason ?? 2) - 1 || 1,
|
||||
// The site can run two series at once ("lanes"): the primary series (flat series* fields)
|
||||
// and an optional second series (secondSeries). Each lane has its own scheduled transition.
|
||||
const SERIES_LANES = [
|
||||
{ lane: 'primary', scheduleKey: 'scheduledSeriesTransition' },
|
||||
{ lane: 'second', scheduleKey: 'scheduledSecondSeriesTransition' },
|
||||
]
|
||||
|
||||
function readCurrentSeries(content, lane) {
|
||||
if (lane === 'second') {
|
||||
const s = content.secondSeries ?? {}
|
||||
return {
|
||||
title: s.title ?? '', description: s.description ?? '', imageUrl: s.imageUrl ?? '',
|
||||
season: s.season, studySlug: s.studySlug ?? '',
|
||||
guide: s.guide?.title ? s.guide : undefined,
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: content.seriesTitle ?? '', description: content.seriesDescription ?? '', imageUrl: content.seriesImageUrl ?? '',
|
||||
season: content.seriesSeason, studySlug: content.seriesStudySlug ?? '',
|
||||
// The main series' guide lives in the flat studyGuide* fields
|
||||
guide: content.studyGuideTitle?.trim()
|
||||
? {
|
||||
title: content.studyGuideTitle,
|
||||
description: content.studyGuideDescription ?? '',
|
||||
downloadUrl: content.studyGuideDownloadUrl ?? '',
|
||||
amazonUrl: content.studyGuideUrl ?? '',
|
||||
amazonLabel: content.studyGuideAmazonButtonLabel ?? '',
|
||||
appleBooksUrl: content.studyGuideAppleBooksUrl ?? '',
|
||||
appleBooksLabel: content.studyGuideAppleBooksLabel ?? '',
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the content that results from applying one lane's scheduled series transition.
|
||||
// Only the named lane changes; the other lane's series, studies and highlights are left alone.
|
||||
function applySeriesTransition(content, sched, lane = 'primary') {
|
||||
const scheduleKey = lane === 'second' ? 'scheduledSecondSeriesTransition' : 'scheduledSeriesTransition'
|
||||
const current = readCurrentSeries(content, lane)
|
||||
const newSeason = Number.isInteger(sched.newSeason) ? sched.newSeason : undefined
|
||||
const newStudySlug = typeof sched.newStudySlug === 'string' ? sched.newStudySlug.trim().toLowerCase() : ''
|
||||
|
||||
// Highlights for the ending series move onto its Finished Book; the other lane's and general ones stay.
|
||||
const allHighlights = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
||||
const isThisLane = link => (link.lane === 'second' ? 'second' : link.lane === 'general' ? 'general' : 'primary') === lane
|
||||
const endingHighlights = current.title.trim() ? allHighlights.filter(isThisLane) : []
|
||||
const keptHighlights = current.title.trim() ? allHighlights.filter(link => !isThisLane(link)) : allHighlights
|
||||
|
||||
const archivedBook = current.title.trim()
|
||||
? {
|
||||
id: Date.now().toString(36),
|
||||
title: current.title,
|
||||
description: current.description,
|
||||
imageUrl: current.imageUrl,
|
||||
season: current.season ?? ((newSeason ?? 2) - 1 || 1),
|
||||
...(current.studySlug ? { studySlug: current.studySlug } : {}),
|
||||
...(current.guide ? { guide: current.guide } : {}),
|
||||
...(endingHighlights.length > 0 ? { highlights: endingHighlights.map(({ lane: _lane, ...link }) => link) } : {}),
|
||||
}
|
||||
: null
|
||||
|
||||
const patch = {}
|
||||
const copy = (from, to) => {
|
||||
@@ -82,55 +133,104 @@ function applySeriesTransition(content, sched) {
|
||||
copy('newStudyGuideAppleBooksUrl', 'studyGuideAppleBooksUrl')
|
||||
copy('newStudyGuideAppleBooksLabel', 'studyGuideAppleBooksLabel')
|
||||
|
||||
// The finished series' study becomes Complete (still enrollable, self-paced); the
|
||||
// incoming series' study opens up, taking the lane it launches in.
|
||||
const laneLetter = lane === 'second' ? 'B' : 'A'
|
||||
const studies = (Array.isArray(content.studies) ? content.studies : []).map(study => {
|
||||
if (!study) return study
|
||||
if (current.studySlug && study.slug === current.studySlug && study.status !== 'planned') {
|
||||
return { ...study, status: 'complete' }
|
||||
}
|
||||
if (newStudySlug && study.slug === newStudySlug) {
|
||||
return { ...study, status: 'active', lane: study.lane || laneLetter, startsAt: '' }
|
||||
}
|
||||
return study
|
||||
})
|
||||
|
||||
const lanePatch = lane === 'second'
|
||||
? {
|
||||
secondSeries: {
|
||||
...(content.secondSeries ?? {}),
|
||||
label: 'Now Playing',
|
||||
title: sched.newTitle.trim(),
|
||||
description: sched.newDescription?.trim() ?? '',
|
||||
imageUrl: sched.newImageUrl?.trim() || content.secondSeries?.imageUrl || '',
|
||||
studySlug: newStudySlug,
|
||||
// The ending series' guide moved onto its Finished Book; the incoming series brings its own
|
||||
guide: sched.newGuide?.title?.trim() ? sched.newGuide : undefined,
|
||||
...(newSeason ? { season: newSeason } : {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
seriesTitle: sched.newTitle.trim(),
|
||||
seriesDescription: sched.newDescription?.trim() ?? '',
|
||||
seriesImageUrl: sched.newImageUrl?.trim() || content.seriesImageUrl || '',
|
||||
seriesLabel: 'Now Playing',
|
||||
seriesStudySlug: newStudySlug,
|
||||
...(newSeason ? { seriesSeason: newSeason } : {}),
|
||||
// The ending series' guide moved onto its Finished Book; unless the launch brings a new one, clear it
|
||||
...(current.guide && !sched.newStudyGuideTitle?.trim()
|
||||
? {
|
||||
studyGuideTitle: '', studyGuideDescription: '', studyGuideDownloadUrl: '', studyGuideUrl: '',
|
||||
studyGuideAmazonButtonLabel: '', studyGuideAppleBooksUrl: '', studyGuideAppleBooksLabel: '',
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
|
||||
const next = sanitizeSiteContent({
|
||||
...content,
|
||||
seriesTitle: sched.newTitle.trim(),
|
||||
seriesDescription: sched.newDescription?.trim() ?? '',
|
||||
seriesImageUrl: sched.newImageUrl?.trim() || content.seriesImageUrl || '',
|
||||
seriesLabel: 'Now Playing',
|
||||
podcastFeaturedLinks: [],
|
||||
finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), archivedBook],
|
||||
...lanePatch,
|
||||
studies,
|
||||
podcastFeaturedLinks: keptHighlights,
|
||||
finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), ...(archivedBook ? [archivedBook] : [])],
|
||||
...patch,
|
||||
})
|
||||
delete next.scheduledSeriesTransition
|
||||
delete next[scheduleKey]
|
||||
return { next, archivedBook }
|
||||
}
|
||||
|
||||
// Applies a scheduled series transition if its triggersAt time has passed.
|
||||
// The schedule is saved with the draft (Save Schedule), so look in the draft as
|
||||
// well as published content. Applies to both so a later publish doesn't undo it.
|
||||
// Applies any scheduled series transitions (one per lane) whose triggersAt time has passed.
|
||||
// A schedule is saved with the draft (Save Schedule), so look in the draft as well as
|
||||
// published content. Applies to both so a later publish doesn't undo it.
|
||||
// Called on a server interval (see server.js). Safe to call if no transition is set.
|
||||
export async function runScheduledSeriesTransition() {
|
||||
const published = state.cachedSiteContent
|
||||
const draft = state.cachedDraftSiteContent
|
||||
if (!published) return
|
||||
|
||||
const sched = draft?.scheduledSeriesTransition ?? published.scheduledSeriesTransition
|
||||
if (!sched || !sched.triggersAt || !sched.newTitle?.trim()) return
|
||||
const triggerTime = new Date(sched.triggersAt).getTime()
|
||||
if (Number.isNaN(triggerTime) || triggerTime > Date.now()) return
|
||||
let nextPublished = published
|
||||
let nextDraft = draft
|
||||
const fired = []
|
||||
for (const { lane, scheduleKey } of SERIES_LANES) {
|
||||
const sched = nextDraft?.[scheduleKey] ?? nextPublished[scheduleKey]
|
||||
if (!sched || !sched.triggersAt || !sched.newTitle?.trim()) continue
|
||||
const triggerTime = new Date(sched.triggersAt).getTime()
|
||||
if (Number.isNaN(triggerTime) || triggerTime > Date.now()) continue
|
||||
|
||||
console.log('[series-scheduler] Firing scheduled series transition to:', sched.newTitle)
|
||||
|
||||
const { next: updatedContent, archivedBook } = applySeriesTransition(published, sched)
|
||||
console.log(`[series-scheduler] Firing scheduled ${lane} series transition to:`, sched.newTitle)
|
||||
const result = applySeriesTransition(nextPublished, sched, lane)
|
||||
nextPublished = result.next
|
||||
if (nextDraft) nextDraft = applySeriesTransition(nextDraft, sched, lane).next
|
||||
fired.push(result.archivedBook?.title ?? '(nothing archived)')
|
||||
}
|
||||
if (fired.length === 0) return
|
||||
|
||||
const publishedAt = new Date().toISOString()
|
||||
try {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(DATA_FILE, JSON.stringify({ siteContent: updatedContent, updatedAt: publishedAt }, null, 2), 'utf8')
|
||||
state.cachedSiteContent = updatedContent
|
||||
await writeFile(DATA_FILE, JSON.stringify({ siteContent: nextPublished, updatedAt: publishedAt }, null, 2), 'utf8')
|
||||
state.cachedSiteContent = nextPublished
|
||||
state.publishState.publishedAt = publishedAt
|
||||
console.log('[series-scheduler] Transition complete — archived:', archivedBook.title)
|
||||
console.log('[series-scheduler] Transition complete — archived:', fired.join(', '))
|
||||
} catch (err) {
|
||||
console.error('[series-scheduler] Failed to write transition:', err)
|
||||
return
|
||||
}
|
||||
|
||||
if (draft) {
|
||||
if (nextDraft && nextDraft !== draft) {
|
||||
try {
|
||||
const { next: updatedDraft } = applySeriesTransition(draft, sched)
|
||||
await writeFile(DRAFT_DATA_FILE, JSON.stringify({ siteContent: updatedDraft, updatedAt: publishedAt }, null, 2), 'utf8')
|
||||
state.cachedDraftSiteContent = updatedDraft
|
||||
await writeFile(DRAFT_DATA_FILE, JSON.stringify({ siteContent: nextDraft, updatedAt: publishedAt }, null, 2), 'utf8')
|
||||
state.cachedDraftSiteContent = nextDraft
|
||||
state.publishState.draftUpdatedAt = publishedAt
|
||||
} catch (err) {
|
||||
console.error('[series-scheduler] Failed to update draft after transition:', err)
|
||||
|
||||
@@ -145,6 +145,18 @@ export function register(app) {
|
||||
const customResources = Array.isArray(siteContent.customLinks)
|
||||
? siteContent.customLinks.filter(link => link?.placement === 'resources')
|
||||
: []
|
||||
// Companion study guides: the second series' guide, and guides kept on finished books
|
||||
if (id === 'guide:second') {
|
||||
const guide = siteContent.secondSeries?.guide
|
||||
const guideUrl = sanitizeUrl(guide?.downloadUrl)
|
||||
return guideUrl ? { label: guide.title, url: guideUrl } : null
|
||||
}
|
||||
if (id.startsWith('guide:book:')) {
|
||||
const bookId = id.slice('guide:book:'.length)
|
||||
const book = Array.isArray(siteContent.finishedBooks) ? siteContent.finishedBooks.find(item => item?.id === bookId) : null
|
||||
const bookGuideUrl = sanitizeUrl(book?.guide?.downloadUrl)
|
||||
return bookGuideUrl ? { label: book.guide.title, url: bookGuideUrl } : null
|
||||
}
|
||||
if (id.startsWith('custom:')) {
|
||||
const customId = id.slice('custom:'.length)
|
||||
const match = customResources.find(link => link.id === customId)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { state } from '../state.js'
|
||||
import { queueEpisodeScriptsWrite } from '../data.js'
|
||||
import { MAX_EPISODE_SCRIPT_LENGTH } from '../config.js'
|
||||
import { fetchAllEpisodes } from './episodes.js'
|
||||
import { scriptKey, scriptEpisodeNumber } from '../episode-script-keys.js'
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
@@ -30,14 +31,18 @@ function searchScripts(query) {
|
||||
const season = seasonQuery[1]
|
||||
return Object.entries(state.episodeScripts)
|
||||
.filter(([, entry]) => String(entry.season ?? '') === season)
|
||||
.map(([episodeNumber, entry]) => ({
|
||||
episodeNumber,
|
||||
title: entry.title ?? `Episode ${episodeNumber}`,
|
||||
filename: entry.filename ?? '',
|
||||
season: entry.season ?? null,
|
||||
snippet: '',
|
||||
score: 1,
|
||||
}))
|
||||
.map(([key, entry]) => {
|
||||
const episodeNumber = scriptEpisodeNumber(key, entry)
|
||||
return {
|
||||
key,
|
||||
episodeNumber,
|
||||
title: entry.title ?? `Episode ${episodeNumber}`,
|
||||
filename: entry.filename ?? '',
|
||||
season: entry.season ?? null,
|
||||
snippet: '',
|
||||
score: 1,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber))
|
||||
.slice(0, 8)
|
||||
}
|
||||
@@ -46,8 +51,9 @@ function searchScripts(query) {
|
||||
if (words.length === 0) return []
|
||||
const results = []
|
||||
|
||||
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) {
|
||||
for (const [key, entry] of Object.entries(state.episodeScripts)) {
|
||||
if (!entry?.text) continue
|
||||
const episodeNumber = scriptEpisodeNumber(key, entry)
|
||||
const text = entry.text.toLowerCase()
|
||||
|
||||
const matchedWords = words.filter(w => containsWord(text, w))
|
||||
@@ -67,6 +73,7 @@ function searchScripts(query) {
|
||||
if (end < entry.text.length) snippet = snippet + '…'
|
||||
|
||||
results.push({
|
||||
key,
|
||||
episodeNumber,
|
||||
title: entry.title ?? `Episode ${episodeNumber}`,
|
||||
filename: entry.filename ?? '',
|
||||
@@ -89,8 +96,9 @@ export function register(app) {
|
||||
|
||||
// Admin: list all uploaded scripts
|
||||
app.get('/api/admin-episode-scripts', requireAdminAuth, (_req, res) => {
|
||||
const list = Object.entries(state.episodeScripts).map(([episodeNumber, entry]) => ({
|
||||
episodeNumber,
|
||||
const list = Object.entries(state.episodeScripts).map(([key, entry]) => ({
|
||||
key,
|
||||
episodeNumber: scriptEpisodeNumber(key, entry),
|
||||
title: entry.title ?? `Episode ${episodeNumber}`,
|
||||
filename: entry.filename ?? '',
|
||||
season: entry.season ?? null,
|
||||
@@ -102,7 +110,9 @@ export function register(app) {
|
||||
})
|
||||
|
||||
// Admin: upload a docx script for an episode
|
||||
app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res) => {
|
||||
app.post('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, async (req, res, next) => {
|
||||
// "backfill-seasons" is its own route below; don't let this one swallow it
|
||||
if (req.params.episodeNumber === 'backfill-seasons') { next(); return }
|
||||
const episodeNumber = req.params.episodeNumber?.trim()
|
||||
if (!episodeNumber || !/^\d+$/.test(episodeNumber)) {
|
||||
res.status(400).json({ message: 'Episode number must be a positive integer.' }); return
|
||||
@@ -144,7 +154,13 @@ export function register(app) {
|
||||
text = text.slice(0, MAX_EPISODE_SCRIPT_LENGTH)
|
||||
}
|
||||
|
||||
state.episodeScripts[episodeNumber] = {
|
||||
const key = scriptKey(season, episodeNumber)
|
||||
// A script with no season that this upload replaces would otherwise linger as a duplicate
|
||||
if (season && state.episodeScripts[episodeNumber] && !state.episodeScripts[episodeNumber].season) {
|
||||
delete state.episodeScripts[episodeNumber]
|
||||
}
|
||||
state.episodeScripts[key] = {
|
||||
episodeNumber,
|
||||
title,
|
||||
filename,
|
||||
season,
|
||||
@@ -156,9 +172,10 @@ export function register(app) {
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
key,
|
||||
episodeNumber,
|
||||
season,
|
||||
wordCount: state.episodeScripts[episodeNumber].wordCount,
|
||||
wordCount: state.episodeScripts[key].wordCount,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -174,22 +191,36 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
const seasonByEpisodeNumber = new Map()
|
||||
const normalize = value => String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
|
||||
// Episode numbers can repeat across seasons, so keep every season an episode number appears in.
|
||||
const feedByEpisodeNumber = new Map()
|
||||
for (const ep of episodes) {
|
||||
if (ep.episode && ep.season) seasonByEpisodeNumber.set(ep.episode, ep.season)
|
||||
if (!ep.episode || !ep.season) continue
|
||||
const list = feedByEpisodeNumber.get(ep.episode) ?? []
|
||||
list.push({ season: String(ep.season), title: normalize(ep.title) })
|
||||
feedByEpisodeNumber.set(ep.episode, list)
|
||||
}
|
||||
|
||||
let updated = 0
|
||||
const stillMissing = []
|
||||
for (const [episodeNumber, entry] of Object.entries(state.episodeScripts)) {
|
||||
for (const [key, entry] of Object.entries({ ...state.episodeScripts })) {
|
||||
if (entry.season) continue
|
||||
const found = seasonByEpisodeNumber.get(episodeNumber)
|
||||
if (found) {
|
||||
entry.season = found
|
||||
updated += 1
|
||||
} else {
|
||||
stillMissing.push(episodeNumber)
|
||||
const episodeNumber = scriptEpisodeNumber(key, entry)
|
||||
const candidates = feedByEpisodeNumber.get(episodeNumber) ?? []
|
||||
let match = candidates.length === 1 ? candidates[0] : undefined
|
||||
// Several seasons share this episode number: fall back to matching the title
|
||||
if (!match && candidates.length > 1) {
|
||||
const byTitle = candidates.filter(c => c.title && c.title === normalize(entry.title))
|
||||
if (byTitle.length === 1) match = byTitle[0]
|
||||
}
|
||||
const newKey = match ? scriptKey(match.season, episodeNumber) : ''
|
||||
if (!match || (newKey !== key && state.episodeScripts[newKey])) {
|
||||
stillMissing.push(episodeNumber)
|
||||
continue
|
||||
}
|
||||
delete state.episodeScripts[key]
|
||||
state.episodeScripts[newKey] = { ...entry, season: match.season, episodeNumber }
|
||||
updated += 1
|
||||
}
|
||||
|
||||
if (updated > 0) queueEpisodeScriptsWrite()
|
||||
@@ -198,12 +229,12 @@ export function register(app) {
|
||||
})
|
||||
|
||||
// Admin: delete a script
|
||||
app.delete('/api/admin-episode-scripts/:episodeNumber', requireAdminAuth, (req, res) => {
|
||||
const episodeNumber = req.params.episodeNumber?.trim()
|
||||
if (!state.episodeScripts[episodeNumber]) {
|
||||
app.delete('/api/admin-episode-scripts/:key', requireAdminAuth, (req, res) => {
|
||||
const key = req.params.key?.trim()
|
||||
if (!key || !state.episodeScripts[key]) {
|
||||
res.status(404).json({ message: 'Script not found.' }); return
|
||||
}
|
||||
delete state.episodeScripts[episodeNumber]
|
||||
delete state.episodeScripts[key]
|
||||
queueEpisodeScriptsWrite()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
@@ -102,7 +102,10 @@ export function register(app) {
|
||||
}
|
||||
}
|
||||
// Dynamic episode highlight URLs
|
||||
const highlights = Array.isArray(state.cachedSiteContent?.podcastFeaturedLinks) ? state.cachedSiteContent.podcastFeaturedLinks : []
|
||||
const highlights = [
|
||||
...(Array.isArray(state.cachedSiteContent?.podcastFeaturedLinks) ? state.cachedSiteContent.podcastFeaturedLinks : []),
|
||||
...(Array.isArray(state.cachedSiteContent?.finishedBooks) ? state.cachedSiteContent.finishedBooks.flatMap(book => (Array.isArray(book?.highlights) ? book.highlights : [])) : []),
|
||||
]
|
||||
for (const ep of highlights) {
|
||||
if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`)
|
||||
}
|
||||
@@ -195,7 +198,10 @@ export function register(app) {
|
||||
app.get('/api/public/scripture-index', async (_req, res) => {
|
||||
const content = state.cachedSiteContent
|
||||
const allStudies = Array.isArray(content?.studies) ? content.studies : []
|
||||
const allEpisodes = Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
||||
const allEpisodes = [
|
||||
...(Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []),
|
||||
...(Array.isArray(content?.finishedBooks) ? content.finishedBooks.flatMap(book => (Array.isArray(book?.highlights) ? book.highlights : [])) : []),
|
||||
]
|
||||
const cutoff = new Date()
|
||||
const entries = []
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ const publicCertRateLimiter = rateLimit({
|
||||
})
|
||||
|
||||
/**
|
||||
* Returns the list of required section IDs for a study (only released sections).
|
||||
* Returns the list of required section IDs for a study. Every lesson is required, so a
|
||||
* certificate is only available once the whole study has been released and completed.
|
||||
*/
|
||||
function getRequiredSectionIds(studySlug) {
|
||||
const content = state.cachedSiteContent
|
||||
@@ -24,7 +25,7 @@ function getRequiredSectionIds(studySlug) {
|
||||
const study = content.studies.find(s => s?.slug === studySlug)
|
||||
if (!study || !Array.isArray(study.sections)) return null
|
||||
return study.sections
|
||||
.filter(s => s?.status !== 'unreleased' && s?.id)
|
||||
.filter(s => s?.id)
|
||||
.map(s => s.id)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ export const state = {
|
||||
studyCertificates: [],
|
||||
studyCertificatesWritePromise: Promise.resolve(),
|
||||
|
||||
// episodeScripts: { [episodeNumber]: { text, title, filename, season, uploadedAt } }
|
||||
// episodeScripts: { ["<season>-<episode>" or "<episode>" if no season]: { episodeNumber, text, title, filename, season, uploadedAt } }
|
||||
episodeScripts: {},
|
||||
episodeScriptsWritePromise: Promise.resolve(),
|
||||
|
||||
|
||||
+16
-1
@@ -74,6 +74,17 @@ export function hashEmailVerifyToken(token) {
|
||||
|
||||
// ── Catalog / enrollment helpers ───────────────────────────────────────────
|
||||
|
||||
// A study is 'complete' when an admin marks it so, or when it has lessons and every
|
||||
// one of them has already been released. Planned studies never auto-complete.
|
||||
export function getEffectiveStudyStatus(study) {
|
||||
if (!study || typeof study !== 'object') return 'active'
|
||||
if (study.status === 'planned') return 'planned'
|
||||
if (study.status === 'complete') return 'complete'
|
||||
const sections = Array.isArray(study.sections) ? study.sections : []
|
||||
if (sections.length > 0 && sections.every(isSectionReleased)) return 'complete'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
export function getStudyCatalog() {
|
||||
const fallback = [
|
||||
{ slug: 'colossians', title: 'Colossians: Rooted in Christ', status: 'active' },
|
||||
@@ -91,7 +102,7 @@ export function getStudyCatalog() {
|
||||
out.push({
|
||||
slug,
|
||||
title: typeof study?.title === 'string' && study.title.trim() ? study.title.trim() : slug,
|
||||
status: study?.status === 'planned' ? 'planned' : 'active',
|
||||
status: getEffectiveStudyStatus(study),
|
||||
})
|
||||
}
|
||||
if (out.length > 0) return out
|
||||
@@ -743,9 +754,13 @@ export async function scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail) {
|
||||
: undefined
|
||||
if (!study) continue
|
||||
|
||||
// Finished studies are self-paced: only mention lessons from the last week so a
|
||||
// late enrollee isn't sent the whole back catalogue in one digest.
|
||||
const isComplete = getEffectiveStudyStatus(study) === 'complete'
|
||||
for (const section of study.sections ?? []) {
|
||||
const releaseTime = getSectionReleaseTime(section)
|
||||
if (!Number.isFinite(releaseTime) || releaseTime > nowMs) continue
|
||||
if (isComplete && nowMs - releaseTime > ONE_WEEK_MS) continue
|
||||
const sentForStudy = Array.isArray(userSent[studySlug]) ? userSent[studySlug] : []
|
||||
if (sentForStudy.includes(section.id)) continue
|
||||
|
||||
|
||||
+558
-36
@@ -3,7 +3,7 @@ import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from
|
||||
import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings, PodcastHost } from './content'
|
||||
import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings, PodcastHost, SecondSeries, StudyGuide } from './content'
|
||||
import { DEFAULTS } from './content'
|
||||
import { AnalyticsPanel } from './components/AnalyticsPanel'
|
||||
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
|
||||
@@ -14,6 +14,20 @@ import InboxPanel, { type InboxSubmission } from './InboxPanel'
|
||||
import AssetManager, { type AdminAsset } from './AssetManager'
|
||||
|
||||
|
||||
type HighlightLane = 'primary' | 'second' | 'general'
|
||||
|
||||
function highlightLaneOf(link: PodcastFeaturedLink): HighlightLane {
|
||||
return link.lane === 'second' || link.lane === 'general' ? link.lane : 'primary'
|
||||
}
|
||||
|
||||
// Splits highlights into the ones belonging to a series (moved onto its Finished Book) and the rest (kept).
|
||||
function splitHighlightsForLane(links: PodcastFeaturedLink[] | undefined, lane: HighlightLane) {
|
||||
const all = links ?? []
|
||||
const ending = all.filter(link => highlightLaneOf(link) === lane).map(({ lane: _lane, ...link }) => link)
|
||||
const kept = all.filter(link => highlightLaneOf(link) !== lane)
|
||||
return { ending, kept }
|
||||
}
|
||||
|
||||
function toLocalInputValue(iso?: string): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
@@ -844,6 +858,18 @@ const ADMIN_SECTION_LINKS: Partial<Record<AdminView, AdminSectionLink[]>> = {
|
||||
{ id: 'global-banner', label: 'Analytics Banner' },
|
||||
{ id: 'global-welcome-email', label: 'Welcome Email' },
|
||||
],
|
||||
'start-here': [
|
||||
{ id: 'start-here-intro', label: 'Intro' },
|
||||
{ id: 'start-here-step-1', label: 'Step 1' },
|
||||
{ id: 'start-here-step-2', label: 'Step 2' },
|
||||
{ id: 'start-here-step-3', label: 'Step 3' },
|
||||
],
|
||||
'custom-links': [
|
||||
{ id: 'custom-links-homepage', label: 'Homepage External' },
|
||||
{ id: 'custom-links-platform', label: 'Platform + Footer' },
|
||||
{ id: 'custom-links-other', label: 'Other Sites' },
|
||||
{ id: 'custom-links-more', label: 'More Resources' },
|
||||
],
|
||||
seo: [
|
||||
{ id: 'seo-metadata', label: 'Metadata' },
|
||||
{ id: 'seo-redirects', label: 'Redirects' },
|
||||
@@ -1613,6 +1639,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [endSeriesNewTitle, setEndSeriesNewTitle] = useState('')
|
||||
const [endSeriesNewImage, setEndSeriesNewImage] = useState('')
|
||||
const [endSeriesNewSeason, setEndSeriesNewSeason] = useState('')
|
||||
const [bulkStart, setBulkStart] = useState('')
|
||||
const [bulkInterval, setBulkInterval] = useState('14')
|
||||
const [bulkOnlyEmpty, setBulkOnlyEmpty] = useState(false)
|
||||
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
|
||||
@@ -1641,7 +1670,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [commentSearch, setCommentSearch] = useState('')
|
||||
|
||||
// Episode scripts state
|
||||
const [scriptsList, setScriptsList] = useState<Array<{ episodeNumber: string; title: string; filename: string; season: string | null; uploadedAt: string | null; wordCount: number }>>([])
|
||||
const [scriptsList, setScriptsList] = useState<Array<{ key: string; episodeNumber: string; title: string; filename: string; season: string | null; uploadedAt: string | null; wordCount: number }>>([])
|
||||
const [scriptsLoading, setScriptsLoading] = useState(false)
|
||||
const [scriptUploadEpisode, setScriptUploadEpisode] = useState('')
|
||||
const [scriptUploadTitle, setScriptUploadTitle] = useState('')
|
||||
@@ -2213,6 +2242,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
...(f.podcastFeaturedLinks ?? []),
|
||||
{
|
||||
id: Date.now().toString(36),
|
||||
lane: 'primary' as const,
|
||||
title: '',
|
||||
episodeNumber: '',
|
||||
summary: '',
|
||||
@@ -2589,31 +2619,55 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
)
|
||||
}
|
||||
|
||||
function jumpToAdminSection(sectionId: string) {
|
||||
document.getElementById(sectionId)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
// Tabs inside long admin pages. The chosen tab is remembered per page while the admin is open.
|
||||
const [adminTab, setAdminTab] = useState<Record<string, string>>({})
|
||||
const tabOf = (view: string, first: string) => adminTab[view] ?? first
|
||||
|
||||
function renderTabs(view: string, first: string, tabs: { id: string; label: string }[]) {
|
||||
const current = tabOf(view, first)
|
||||
return (
|
||||
<div className="admin-tabs admin-tabs--page" role="tablist" aria-label="Page sections">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={current === tab.id}
|
||||
className={`admin-tab${current === tab.id ? ' admin-tab--active' : ''}`}
|
||||
onClick={() => setAdminTab(prev => ({ ...prev, [view]: tab.id }))}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Sections of long pages are shown as tabs instead of one long scroll.
|
||||
function renderSectionJumpNav(view: AdminView) {
|
||||
const links = ADMIN_SECTION_LINKS[view] ?? []
|
||||
if (links.length < 2) return null
|
||||
return renderTabs(view, links[0].id, links)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-section-jump-nav" aria-label="Jump to section">
|
||||
<span className="admin-section-jump-nav-label">Jump to</span>
|
||||
<div className="admin-section-jump-nav-links">
|
||||
{links.map(link => (
|
||||
<button
|
||||
key={link.id}
|
||||
type="button"
|
||||
className="admin-section-jump-nav-btn"
|
||||
onClick={() => jumpToAdminSection(link.id)}
|
||||
>
|
||||
{link.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
// Each series has its own companion guide: the main series' lives in the studyGuide* fields,
|
||||
// the second series' in secondSeries.guide, and finished books keep the one they had.
|
||||
function patchSecondGuide(patch: Partial<StudyGuide>) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
secondSeries: {
|
||||
label: 'Now Playing', title: '', description: '', imageUrl: '', listenUrl: '', listenBtnLabel: '',
|
||||
...(f.secondSeries ?? {}),
|
||||
guide: { title: '', ...(f.secondSeries?.guide ?? {}), ...patch },
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function patchBookGuide(bookId: string, patch: Partial<StudyGuide>) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
finishedBooks: (f.finishedBooks ?? []).map(b => b.id === bookId ? { ...b, guide: { title: '', ...(b.guide ?? {}), ...patch } } : b),
|
||||
}))
|
||||
}
|
||||
|
||||
function addLink() {
|
||||
@@ -2784,6 +2838,50 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
}))
|
||||
}
|
||||
|
||||
// Stamps release dates on a study's lessons in order: lesson N = start + N × interval days.
|
||||
// Uses local-time date arithmetic so the time of day survives daylight-saving changes.
|
||||
function applyBulkSchedule(studyId: string) {
|
||||
if (!bulkStart) return
|
||||
const interval = Math.max(1, parseInt(bulkInterval) || 14)
|
||||
const base = new Date(bulkStart)
|
||||
if (isNaN(base.getTime())) return
|
||||
setForm(f => ({
|
||||
...f,
|
||||
studies: (f.studies ?? []).map(study => study.id !== studyId ? study : {
|
||||
...study,
|
||||
sections: (study.sections ?? []).map((section, index) => {
|
||||
if (bulkOnlyEmpty && section.releasedAt) return section
|
||||
const date = new Date(base)
|
||||
date.setDate(date.getDate() + index * interval)
|
||||
return { ...section, releasedAt: date.toISOString() }
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
// Lessons from this study that land on the same calendar day as a lesson in another study.
|
||||
function findReleaseCollisions(study: StudyProgram) {
|
||||
const dayKey = (iso?: string) => {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
return isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-CA')
|
||||
}
|
||||
const otherDays = new Map<string, string>()
|
||||
for (const other of form.studies ?? []) {
|
||||
if (other.id === study.id || other.status === 'planned') continue
|
||||
for (const section of other.sections ?? []) {
|
||||
const key = dayKey(section.releasedAt)
|
||||
if (key) otherDays.set(key, other.title)
|
||||
}
|
||||
}
|
||||
const hits: { date: string; title: string; other: string }[] = []
|
||||
for (const section of study.sections ?? []) {
|
||||
const key = dayKey(section.releasedAt)
|
||||
if (key && otherDays.has(key)) hits.push({ date: key, title: section.title || section.reference, other: otherDays.get(key) ?? '' })
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
function updateStudySection(studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | number | string[]) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
@@ -3070,7 +3168,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { setScriptUploadMsg(data.message ?? 'Upload failed.'); return }
|
||||
setScriptUploadMsg(`✓ Episode ${scriptUploadEpisode} uploaded — ${data.wordCount?.toLocaleString()} words indexed.`)
|
||||
setScriptUploadMsg(`✓ Episode ${scriptUploadEpisode}${scriptUploadSeason.trim() ? ` (Season ${scriptUploadSeason.trim()})` : ''} uploaded — ${data.wordCount?.toLocaleString()} words indexed.`)
|
||||
setScriptUploadEpisode('')
|
||||
setScriptUploadTitle('')
|
||||
setScriptUploadSeason('')
|
||||
@@ -3103,12 +3201,12 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteScript(episodeNumber: string) {
|
||||
if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return
|
||||
async function handleDeleteScript(key: string, label: string) {
|
||||
if (!confirm(`Remove script for ${label}?`)) return
|
||||
try {
|
||||
const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(episodeNumber)}`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(key)}`, { method: 'DELETE' })
|
||||
if (!res.ok) { alert('Failed to delete script.'); return }
|
||||
setScriptsList(prev => prev.filter(s => s.episodeNumber !== episodeNumber))
|
||||
setScriptsList(prev => prev.filter(s => s.key !== key))
|
||||
} catch { alert('Failed to delete script.') }
|
||||
}
|
||||
|
||||
@@ -3402,7 +3500,9 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<h2>Dashboard</h2>
|
||||
<p>Quick overview of your ministry site.</p>
|
||||
</div>
|
||||
{renderTabs('dashboard', 'overview', [{ id: 'overview', label: 'Overview' }, { id: 'activity', label: 'Recent Activity' }, { id: 'users', label: 'Study Users' }])}
|
||||
|
||||
{tabOf('dashboard', 'overview') === 'overview' && <>
|
||||
<div className="admin-dashboard-welcome">
|
||||
<div>
|
||||
<h3 className="admin-dashboard-welcome-title">{welcomeMessage}</h3>
|
||||
@@ -3471,6 +3571,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
)}
|
||||
</div>
|
||||
|
||||
</>}
|
||||
{tabOf('dashboard', 'overview') === 'activity' && <>
|
||||
<div className="admin-dashboard-activity">
|
||||
<div className="admin-dashboard-activity-col">
|
||||
<h3 className="admin-dashboard-activity-heading">Recent Contacts</h3>
|
||||
@@ -3516,6 +3618,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>}
|
||||
{tabOf('dashboard', 'overview') === 'users' && <>
|
||||
<div className="admin-dashboard-activity" style={{ marginTop: '1rem' }}>
|
||||
<div className="admin-dashboard-activity-col" style={{ gridColumn: '1 / -1' }}>
|
||||
<h3 className="admin-dashboard-activity-heading">Study Users & Enrollments</h3>
|
||||
@@ -3541,6 +3645,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
</section>
|
||||
)
|
||||
})()}
|
||||
@@ -3827,6 +3932,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
|
||||
{renderSectionJumpNav('start-here')}
|
||||
|
||||
{tabOf('start-here', 'start-here-intro') === 'start-here-intro' && <>
|
||||
<div className="admin-panel-subhead" id="start-here-intro">
|
||||
<h3>Intro</h3>
|
||||
<p>Top-level heading and opening copy for the page.</p>
|
||||
@@ -3840,6 +3946,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
))}
|
||||
|
||||
</>}
|
||||
{tabOf('start-here', 'start-here-intro') === 'start-here-step-1' && <>
|
||||
<div className="admin-panel-subhead" id="start-here-step-1">
|
||||
<h3>Step 1</h3>
|
||||
</div>
|
||||
@@ -3852,6 +3960,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
))}
|
||||
|
||||
</>}
|
||||
{tabOf('start-here', 'start-here-intro') === 'start-here-step-2' && <>
|
||||
<div className="admin-panel-subhead" id="start-here-step-2">
|
||||
<h3>Step 2</h3>
|
||||
</div>
|
||||
@@ -3864,6 +3974,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
))}
|
||||
|
||||
</>}
|
||||
{tabOf('start-here', 'start-here-intro') === 'start-here-step-3' && <>
|
||||
<div className="admin-panel-subhead" id="start-here-step-3">
|
||||
<h3>Step 3</h3>
|
||||
</div>
|
||||
@@ -3875,6 +3987,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
||||
</div>
|
||||
))}
|
||||
</>}
|
||||
{renderSaveStatus()}
|
||||
</section>
|
||||
)}
|
||||
@@ -4026,6 +4139,13 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<h2>Current Series</h2>
|
||||
<p>Update the active series card shown on the homepage and episodes page.</p>
|
||||
</div>
|
||||
{renderTabs('series', 'main', [
|
||||
{ id: 'main', label: 'Main Series' },
|
||||
{ id: 'second', label: 'Second Series' },
|
||||
{ id: 'schedule', label: 'Launch: Main' },
|
||||
{ id: 'schedule2', label: 'Launch: Second' },
|
||||
])}
|
||||
{tabOf('series', 'main') === 'main' && <>
|
||||
{FIELDS.filter(f => f.section === 'series' && !['studyGuideTitle', 'studyGuideDescription', 'studyGuideUrl'].includes(f.key)).map(({ key, label, multiline }) => (
|
||||
<div className="admin-field" key={key}>
|
||||
<label htmlFor={`field-${key}`}>{label}</label>
|
||||
@@ -4040,9 +4160,103 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="admin-field">
|
||||
<label htmlFor="series-study-slug">Linked Study (Lane A)</label>
|
||||
<select id="series-study-slug" value={form.seriesStudySlug ?? ''} onChange={e => setForm(f => ({ ...f, seriesStudySlug: e.target.value }))}>
|
||||
<option value="">None</option>
|
||||
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
|
||||
</select>
|
||||
<p className="admin-field-help">Adds a “Study Along” button to the series card. When this series ends, its study is marked Complete.</p>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="series-season">Podcast Season Number (Lane A)</label>
|
||||
<input id="series-season" type="number" min="1" value={form.seriesSeason ?? ''} onChange={e => setForm(f => ({ ...f, seriesSeason: parseInt(e.target.value) || undefined }))} />
|
||||
<p className="admin-field-help">Must match the season number you set on these episodes in Spotify for Creators. Used for the Finished Books playlist.</p>
|
||||
</div>
|
||||
|
||||
</>}
|
||||
|
||||
{tabOf('series', 'main') === 'second' && (() => {
|
||||
const second = form.secondSeries
|
||||
const patchSecond = (p: Partial<SecondSeries>) =>
|
||||
setForm(f => ({ ...f, secondSeries: { label: 'Now Playing', title: '', description: '', imageUrl: '', listenUrl: '', listenBtnLabel: '', ...(f.secondSeries ?? {}), ...p } }))
|
||||
return (
|
||||
<div style={{ marginTop: '2rem' }}>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', marginBottom: '1.5rem' }} />
|
||||
<h3 style={{ marginBottom: '0.35rem', fontSize: '1rem' }}>Second Series (Lane B)</h3>
|
||||
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
|
||||
Running two books at once? Fill this in and the homepage shows a second Now Playing card. Leave the title blank to show only one series.
|
||||
</p>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="second-label">Label</label>
|
||||
<input id="second-label" type="text" value={second?.label ?? 'Now Playing'} onChange={e => patchSecond({ label: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="second-title">Series Title</label>
|
||||
<input id="second-title" type="text" value={second?.title ?? ''} onChange={e => patchSecond({ title: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="second-desc">Series Description</label>
|
||||
<textarea id="second-desc" rows={3} value={second?.description ?? ''} onChange={e => patchSecond({ description: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="second-image">Cover Image URL</label>
|
||||
<input id="second-image" type="text" value={second?.imageUrl ?? ''} onChange={e => patchSecond({ imageUrl: e.target.value })} />
|
||||
{renderImageAssetSelector(second?.imageUrl ?? '', value => patchSecond({ imageUrl: value }), 'second-image-asset')}
|
||||
{renderImagePreview(second?.imageUrl ?? '', 'Second series preview')}
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="second-listen">Listen URL</label>
|
||||
<input id="second-listen" type="text" placeholder="Leave blank to use the main Spotify link" value={second?.listenUrl ?? ''} onChange={e => patchSecond({ listenUrl: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="second-study">Linked Study (Lane B)</label>
|
||||
<select id="second-study" value={second?.studySlug ?? ''} onChange={e => patchSecond({ studySlug: e.target.value })}>
|
||||
<option value="">None</option>
|
||||
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="second-season">Podcast Season Number</label>
|
||||
<input id="second-season" type="number" min="1" value={second?.season ?? ''} onChange={e => patchSecond({ season: parseInt(e.target.value) || undefined })} />
|
||||
</div>
|
||||
{second?.title?.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger-outline"
|
||||
onClick={() => {
|
||||
if (!window.confirm(`Archive "${second.title}" to Finished Books and remove the second series card?`)) return
|
||||
setForm(f => {
|
||||
const cur = f.secondSeries
|
||||
if (!cur?.title?.trim()) return f
|
||||
const { ending: endingHighlights, kept: keptHighlights } = splitHighlightsForLane(f.podcastFeaturedLinks, 'second')
|
||||
const archived = {
|
||||
id: Date.now().toString(36),
|
||||
title: cur.title,
|
||||
description: cur.description,
|
||||
imageUrl: cur.imageUrl,
|
||||
season: cur.season ?? 1,
|
||||
...(cur.studySlug ? { studySlug: cur.studySlug } : {}),
|
||||
...(cur.guide?.title ? { guide: cur.guide } : {}),
|
||||
...(endingHighlights.length > 0 ? { highlights: endingHighlights } : {}),
|
||||
}
|
||||
return {
|
||||
...f,
|
||||
podcastFeaturedLinks: keptHighlights,
|
||||
finishedBooks: [...(f.finishedBooks ?? []), archived],
|
||||
studies: (f.studies ?? []).map(st => st.slug === cur.studySlug && st.status !== 'planned' ? { ...st, status: 'complete' as const } : st),
|
||||
secondSeries: undefined,
|
||||
}
|
||||
})
|
||||
}}
|
||||
>End Second Series & Archive</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<p className="admin-stats-note">Companion study guide title, description, and Amazon link are now managed in Resources.</p>
|
||||
{renderSaveStatus()}
|
||||
<div className="admin-end-series-wrap">
|
||||
{tabOf('series', 'main') === 'main' && <div className="admin-end-series-wrap">
|
||||
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', margin: '2rem 0 1.5rem' }} />
|
||||
{!endSeriesOpen ? (
|
||||
<button type="button" className="btn-danger-outline" onClick={() => setEndSeriesOpen(true)}>
|
||||
@@ -4072,17 +4286,44 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
className="btn-primary"
|
||||
disabled={!endSeriesNewTitle.trim() || !endSeriesNewSeason.trim()}
|
||||
onClick={() => {
|
||||
const { ending: endingHighlights, kept: keptHighlights } = splitHighlightsForLane(form.podcastFeaturedLinks, 'primary')
|
||||
const archivedBook = {
|
||||
id: Date.now().toString(36),
|
||||
title: form.seriesTitle,
|
||||
description: form.seriesDescription,
|
||||
imageUrl: form.seriesImageUrl,
|
||||
season: parseInt(endSeriesNewSeason) - 1 || 1,
|
||||
season: form.seriesSeason ?? (parseInt(endSeriesNewSeason) - 1 || 1),
|
||||
...(form.seriesStudySlug ? { studySlug: form.seriesStudySlug } : {}),
|
||||
...(form.studyGuideTitle?.trim()
|
||||
? {
|
||||
guide: {
|
||||
title: form.studyGuideTitle,
|
||||
description: form.studyGuideDescription,
|
||||
downloadUrl: form.studyGuideDownloadUrl,
|
||||
amazonUrl: form.studyGuideUrl,
|
||||
amazonLabel: form.studyGuideAmazonButtonLabel,
|
||||
appleBooksUrl: form.studyGuideAppleBooksUrl,
|
||||
appleBooksLabel: form.studyGuideAppleBooksLabel,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(endingHighlights.length > 0 ? { highlights: endingHighlights } : {}),
|
||||
}
|
||||
setForm(f => ({
|
||||
...f,
|
||||
studies: (f.studies ?? []).map(st => st.slug === f.seriesStudySlug && st.status !== 'planned' ? { ...st, status: 'complete' as const } : st),
|
||||
seriesStudySlug: '',
|
||||
seriesSeason: parseInt(endSeriesNewSeason) || undefined,
|
||||
// The guide moved onto the finished book; the new series gets its own
|
||||
studyGuideTitle: '',
|
||||
studyGuideDescription: '',
|
||||
studyGuideDownloadUrl: '',
|
||||
studyGuideUrl: '',
|
||||
studyGuideAmazonButtonLabel: '',
|
||||
studyGuideAppleBooksUrl: '',
|
||||
studyGuideAppleBooksLabel: '',
|
||||
finishedBooks: [...(f.finishedBooks ?? []), archivedBook],
|
||||
podcastFeaturedLinks: [],
|
||||
podcastFeaturedLinks: keptHighlights,
|
||||
seriesTitle: endSeriesNewTitle.trim(),
|
||||
seriesImageUrl: endSeriesNewImage.trim() || f.seriesImageUrl,
|
||||
seriesLabel: 'Now Playing',
|
||||
@@ -4099,10 +4340,10 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Schedule Auto-Transition */}
|
||||
{(() => {
|
||||
{tabOf('series', 'main') === 'schedule' && (() => {
|
||||
const sched = form.scheduledSeriesTransition
|
||||
const patch = (p: Partial<NonNullable<typeof sched>>) =>
|
||||
setForm(f => ({ ...f, scheduledSeriesTransition: { ...(f.scheduledSeriesTransition ?? { triggersAt: '', newTitle: '', newSeason: 2 }), ...p } }))
|
||||
@@ -4111,7 +4352,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', marginBottom: '1.5rem' }} />
|
||||
<h3 style={{ marginBottom: '0.35rem', fontSize: '1rem' }}>Schedule Auto Series Launch</h3>
|
||||
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
|
||||
Set one date & time to automatically archive the current series and swap in new content for the Series card, Start Here section, and Companion Study Guide. The server checks every minute. Leave optional fields blank to keep the current values unchanged.
|
||||
Set one date & time to automatically archive the current series and swap in new content for the Series card, Start Here section, and Companion Study Guide. The server checks every minute. Leave Start Here fields blank to keep them unchanged. The ending series' Companion Study Guide moves onto its Finished Book, so fill in the guide fields below to publish the new series' guide (left blank, the guide is cleared).
|
||||
</p>
|
||||
{sched?.triggersAt && (
|
||||
<div style={{ background: 'rgba(201,168,76,0.08)', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '10px', padding: '0.8rem 1rem', marginBottom: '1rem' }}>
|
||||
@@ -4149,6 +4390,14 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<label htmlFor="sched-new-season">New Season Number</label>
|
||||
<input id="sched-new-season" type="number" min="1" placeholder="2" value={sched?.newSeason ?? ''} onChange={e => patch({ newSeason: parseInt(e.target.value) || 2 })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-new-study">Study to Open</label>
|
||||
<select id="sched-new-study" value={sched?.newStudySlug ?? ''} onChange={e => patch({ newStudySlug: e.target.value })}>
|
||||
<option value="">None</option>
|
||||
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
|
||||
</select>
|
||||
<p className="admin-field-help">At launch this study switches from Planned to Active, and the ending series' study is marked Complete.</p>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>Start Here Section <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional)</span></p>
|
||||
<div className="admin-field">
|
||||
@@ -4244,6 +4493,81 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Schedule Auto-Transition — second series (Lane B) */}
|
||||
{tabOf('series', 'main') === 'schedule2' && (() => {
|
||||
const sched = form.scheduledSecondSeriesTransition
|
||||
const patch = (p: Partial<NonNullable<typeof sched>>) =>
|
||||
setForm(f => ({ ...f, scheduledSecondSeriesTransition: { ...(f.scheduledSecondSeriesTransition ?? { triggersAt: '', newTitle: '', newSeason: 2 }), ...p } }))
|
||||
return (
|
||||
<div style={{ marginTop: '2rem' }}>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', marginBottom: '1.5rem' }} />
|
||||
<h3 style={{ marginBottom: '0.35rem', fontSize: '1rem' }}>Schedule Second Series (Lane B) Launch</h3>
|
||||
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
|
||||
Same as above, but only the second series changes: its current book is archived to Finished Books and marked Complete, and the main series keeps playing. If no second series is running yet, nothing is archived and the new book simply appears.
|
||||
</p>
|
||||
{sched?.triggersAt && (
|
||||
<div style={{ background: 'rgba(201,168,76,0.08)', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '10px', padding: '0.8rem 1rem', marginBottom: '1rem' }}>
|
||||
<p style={{ margin: 0, fontSize: '0.88rem', color: '#c9a84c' }}>
|
||||
<strong>Scheduled:</strong> Launch “{sched.newTitle}” on{' '}
|
||||
{new Date(sched.triggersAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched2-triggers-at">Launch Date & Time</label>
|
||||
<input id="sched2-triggers-at" type="datetime-local" value={sched?.triggersAt ? toLocalDatetimeInput(sched.triggersAt) : ''} onChange={e => patch({ triggersAt: e.target.value ? new Date(e.target.value).toISOString() : '' })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched2-new-title">Series Title</label>
|
||||
<input id="sched2-new-title" type="text" value={sched?.newTitle ?? ''} onChange={e => patch({ newTitle: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched2-new-desc">Series Description</label>
|
||||
<textarea id="sched2-new-desc" rows={3} value={sched?.newDescription ?? ''} onChange={e => patch({ newDescription: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched2-new-image">Series Image URL</label>
|
||||
<input id="sched2-new-image" type="text" value={sched?.newImageUrl ?? ''} onChange={e => patch({ newImageUrl: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched2-new-season">New Season Number</label>
|
||||
<input id="sched2-new-season" type="number" min="1" value={sched?.newSeason ?? ''} onChange={e => patch({ newSeason: parseInt(e.target.value) || 2 })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched2-new-study">Study to Open</label>
|
||||
<select id="sched2-new-study" value={sched?.newStudySlug ?? ''} onChange={e => patch({ newStudySlug: e.target.value })}>
|
||||
<option value="">None</option>
|
||||
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>Companion Study Guide <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional)</span></p>
|
||||
<p className="admin-stats-note" style={{ marginBottom: '0.75rem' }}>The ending series' guide moves onto its Finished Book at launch. Fill these in to publish the new series' guide at the same time.</p>
|
||||
{(() => {
|
||||
const g = sched?.newGuide
|
||||
const patchGuide = (p: Partial<StudyGuide>) => patch({ newGuide: { title: '', ...(g ?? {}), ...p } })
|
||||
return (
|
||||
<>
|
||||
<div className="admin-field"><label htmlFor="sched2-guide-title">Guide Title</label><input id="sched2-guide-title" type="text" value={g?.title ?? ''} onChange={e => patchGuide({ title: e.target.value })} /></div>
|
||||
<div className="admin-field"><label htmlFor="sched2-guide-desc">Guide Description</label><textarea id="sched2-guide-desc" rows={3} value={g?.description ?? ''} onChange={e => patchGuide({ description: e.target.value })} /></div>
|
||||
<div className="admin-field"><label htmlFor="sched2-guide-download">Download URL</label><input id="sched2-guide-download" type="url" placeholder="/uploads/guide.pdf" value={g?.downloadUrl ?? ''} onChange={e => patchGuide({ downloadUrl: e.target.value })} /></div>
|
||||
<div className="admin-field"><label htmlFor="sched2-guide-amazon">Amazon URL</label><input id="sched2-guide-amazon" type="url" value={g?.amazonUrl ?? ''} onChange={e => patchGuide({ amazonUrl: e.target.value })} /></div>
|
||||
<div className="admin-field"><label htmlFor="sched2-guide-amazon-label">Amazon Button Label</label><input id="sched2-guide-amazon-label" type="text" placeholder="Get it on Amazon" value={g?.amazonLabel ?? ''} onChange={e => patchGuide({ amazonLabel: e.target.value })} /></div>
|
||||
<div className="admin-field"><label htmlFor="sched2-guide-apple">Apple Books URL</label><input id="sched2-guide-apple" type="url" value={g?.appleBooksUrl ?? ''} onChange={e => patchGuide({ appleBooksUrl: e.target.value })} /></div>
|
||||
<div className="admin-field"><label htmlFor="sched2-guide-apple-label">Apple Books Button Label</label><input id="sched2-guide-apple-label" type="text" placeholder="Get it on Apple Books" value={g?.appleBooksLabel ?? ''} onChange={e => patchGuide({ appleBooksLabel: e.target.value })} /></div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem', flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn-primary" disabled={!sched?.triggersAt || !sched?.newTitle?.trim()} onClick={() => handleSaveDraft()}>Save Schedule</button>
|
||||
{sched?.triggersAt && (
|
||||
<button type="button" className="btn-danger-outline" onClick={() => setForm(f => ({ ...f, scheduledSecondSeriesTransition: undefined }))}>Clear Schedule</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -4271,6 +4595,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<div key={item.id} className="study-lesson-row">
|
||||
<span className="study-lesson-ref" style={{ width: 70 }}>{item.episodeNumber ? `Ep. ${item.episodeNumber}` : '—'}</span>
|
||||
<span className="study-lesson-title">{item.title || <em style={{ opacity: 0.5 }}>Untitled</em>}</span>
|
||||
<span className="study-lesson-pill draft" style={{ width: 70 }}>{highlightLaneOf(item) === 'second' ? 'Lane B' : highlightLaneOf(item) === 'general' ? 'General' : 'Lane A'}</span>
|
||||
<span className={`study-lesson-pill ${item.embedUrl ? 'live' : 'draft'}`} style={{ width: 68 }}>{item.embedUrl ? 'Audio' : 'No audio'}</span>
|
||||
<div className="study-lesson-actions">
|
||||
<button type="button" className="btn-admin-ghost" onClick={() => setSelectedHighlightId(item.id)}>Edit</button>
|
||||
@@ -4298,6 +4623,15 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<h3>{activeHighlight.title || 'Untitled Highlight'}{activeHighlight.episodeNumber ? ` · Ep. ${activeHighlight.episodeNumber}` : ''}</h3>
|
||||
</div>
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`podcast-lane-${activeHighlight.id}`}>Series</label>
|
||||
<select id={`podcast-lane-${activeHighlight.id}`} value={highlightLaneOf(activeHighlight)} onChange={e => updatePodcastLink(activeHighlight.id, 'lane', e.target.value)}>
|
||||
<option value="primary">Lane A — {form.seriesTitle || 'main series'}</option>
|
||||
<option value="second">Lane B — {form.secondSeries?.title?.trim() || 'second series (not set)'}</option>
|
||||
<option value="general">General (not tied to a series)</option>
|
||||
</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>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<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)} />
|
||||
@@ -4420,6 +4754,61 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<label>Season Number</label>
|
||||
<input type="number" value={book.season} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, season: parseInt(e.target.value) || 1 } : b) }))} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Linked Study</label>
|
||||
<select value={book.studySlug ?? ''} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, studySlug: e.target.value } : b) }))}>
|
||||
<option value="">None</option>
|
||||
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
|
||||
</select>
|
||||
<p className="admin-field-help">Adds a “Study This Book” button to the playlist page and an audio-only link on the study hub.</p>
|
||||
</div>
|
||||
{(book.highlights ?? []).length > 0 && (
|
||||
<div className="admin-field">
|
||||
<label>Episode Highlights on this book's page ({(book.highlights ?? []).length})</label>
|
||||
{(book.highlights ?? []).map(link => (
|
||||
<div key={link.id} className="study-lesson-row">
|
||||
<span className="study-lesson-ref" style={{ width: 70 }}>{link.episodeNumber ? `Ep. ${link.episodeNumber}` : '—'}</span>
|
||||
<span className="study-lesson-title">{link.title || <em style={{ opacity: 0.5 }}>Untitled</em>}</span>
|
||||
<div className="study-lesson-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-ghost"
|
||||
title="Move back to the Episodes page as a General highlight so it can be edited"
|
||||
onClick={() => setForm(f => ({
|
||||
...f,
|
||||
podcastFeaturedLinks: [...(f.podcastFeaturedLinks ?? []), { ...link, lane: 'general' as const }],
|
||||
finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, highlights: (b.highlights ?? []).filter(h => h.id !== link.id) } : b),
|
||||
}))}
|
||||
>Restore</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-remove"
|
||||
style={{ fontSize: '0.8rem', padding: '0.2rem 0.5rem' }}
|
||||
onClick={() => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, highlights: (b.highlights ?? []).filter(h => h.id !== link.id) } : b) }))}
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="admin-field-help">Highlights that moved here when this series ended. Restore puts one back on the Episodes page (as General) so you can edit it.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-field">
|
||||
<label>Companion Guide Title</label>
|
||||
<input type="text" placeholder="Companion Study Guide" value={book.guide?.title ?? ''} onChange={e => patchBookGuide(book.id, { title: e.target.value })} />
|
||||
<p className="admin-field-help">Saved here automatically when a series ends. Shows a guide button on the book's page.</p>
|
||||
</div>
|
||||
{book.guide?.title && (
|
||||
<>
|
||||
<div className="admin-field">
|
||||
<label>Guide Download URL</label>
|
||||
<input type="url" placeholder="/uploads/guide.pdf or https://..." value={book.guide?.downloadUrl ?? ''} onChange={e => patchBookGuide(book.id, { downloadUrl: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Guide Amazon URL</label>
|
||||
<input type="url" placeholder="https://..." value={book.guide?.amazonUrl ?? ''} onChange={e => patchBookGuide(book.id, { amazonUrl: e.target.value })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="admin-field">
|
||||
<label>Download URL <span style={{ fontWeight: 400, opacity: 0.7 }}>(ZIP — Google Drive, Dropbox, etc.)</span></label>
|
||||
<input type="url" placeholder="https://drive.google.com/…" value={book.downloadUrl ?? ''} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, downloadUrl: e.target.value } : b) }))} />
|
||||
@@ -4461,6 +4850,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<div className="admin-summary-card"><h3>Current Downloads</h3><p>{resourceLinks.length}</p></div>
|
||||
</div>
|
||||
|
||||
{tabOf('downloads', 'downloads-study-guide') === 'downloads-study-guide' && <>
|
||||
<div className="admin-section-header" id="downloads-study-guide">
|
||||
<h3>Companion Study Guide</h3>
|
||||
<p>This is the featured primary download at the top of the Resources page.</p>
|
||||
@@ -4500,6 +4890,45 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-section-header" style={{ marginTop: '2rem' }}>
|
||||
<h3>Second Series Guide{form.secondSeries?.title?.trim() ? ` — ${form.secondSeries.title}` : ''}</h3>
|
||||
<p>The companion guide for the second series (Lane B). The guide above belongs to the main series. When a series ends, its guide moves onto its Finished Book.</p>
|
||||
</div>
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor="second-guide-title">Guide Title</label>
|
||||
<input id="second-guide-title" type="text" value={form.secondSeries?.guide?.title ?? ''} placeholder="Companion Study Guide" onChange={e => patchSecondGuide({ title: e.target.value })} />
|
||||
<p className="admin-field-help">The guide only appears on the Resources page once it has a title.</p>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="second-guide-description">Description</label>
|
||||
<textarea id="second-guide-description" rows={3} value={form.secondSeries?.guide?.description ?? ''} onChange={e => patchSecondGuide({ description: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="second-guide-download">Download URL</label>
|
||||
<input id="second-guide-download" type="url" value={form.secondSeries?.guide?.downloadUrl ?? ''} placeholder="/uploads/new-guide.pdf or https://..." onChange={e => patchSecondGuide({ downloadUrl: e.target.value })} />
|
||||
{renderFileAssetSelector(form.secondSeries?.guide?.downloadUrl, value => patchSecondGuide({ downloadUrl: value }), 'second-guide-download-asset')}
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="second-guide-amazon">Amazon URL (optional)</label>
|
||||
<input id="second-guide-amazon" type="url" value={form.secondSeries?.guide?.amazonUrl ?? ''} placeholder="https://..." onChange={e => patchSecondGuide({ amazonUrl: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="second-guide-amazon-label">Amazon Button Label</label>
|
||||
<input id="second-guide-amazon-label" type="text" value={form.secondSeries?.guide?.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => patchSecondGuide({ amazonLabel: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="second-guide-apple">Apple Books URL (optional)</label>
|
||||
<input id="second-guide-apple" type="url" value={form.secondSeries?.guide?.appleBooksUrl ?? ''} placeholder="https://books.apple.com/..." onChange={e => patchSecondGuide({ appleBooksUrl: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="second-guide-apple-label">Apple Books Button Label</label>
|
||||
<input id="second-guide-apple-label" type="text" value={form.secondSeries?.guide?.appleBooksLabel ?? ''} placeholder="Get it on Apple Books" onChange={e => patchSecondGuide({ appleBooksLabel: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>}
|
||||
{tabOf('downloads', 'downloads-study-guide') === 'downloads-library' && <>
|
||||
{/* Download Library — master-detail */}
|
||||
{(() => {
|
||||
const activeRes = selectedResourceId ? resourceLinks.find(l => l.id === selectedResourceId) ?? null : null
|
||||
@@ -4589,6 +5018,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
)
|
||||
})()}
|
||||
|
||||
</>}
|
||||
{renderSaveStatus()}
|
||||
</section>
|
||||
)}
|
||||
@@ -4601,6 +5031,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<p>Add links to show in platform buttons, footer navigation, or the More Resources download library.</p>
|
||||
</div>
|
||||
|
||||
{renderSectionJumpNav('custom-links')}
|
||||
{tabOf('custom-links', 'custom-links-homepage') === 'custom-links-homepage' && <>
|
||||
<div className="admin-panel-subhead">
|
||||
<h3>Homepage External Links</h3>
|
||||
<p>Links shown on the homepage, separate from the footer.</p>
|
||||
@@ -4648,6 +5080,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" onClick={addExternalSiteLink}>+ Add Homepage External Link</button>
|
||||
|
||||
</>}
|
||||
{tabOf('custom-links', 'custom-links-homepage') === 'custom-links-platform' && <>
|
||||
<div className="admin-panel-subhead">
|
||||
<h3>Platform + Footer Links</h3>
|
||||
<p>Links that appear on the listen buttons row or footer nav.</p>
|
||||
@@ -4699,6 +5133,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" onClick={addLink}>+ Add Platform/Footer Link</button>
|
||||
|
||||
</>}
|
||||
{tabOf('custom-links', 'custom-links-homepage') === 'custom-links-other' && <>
|
||||
<div className="admin-panel-subhead">
|
||||
<h3>Other Site Links</h3>
|
||||
<p>External links you want to surface in the footer dropdown.</p>
|
||||
@@ -4742,6 +5178,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" onClick={addOtherSiteLink}>+ Add Other Site Link</button>
|
||||
|
||||
</>}
|
||||
{tabOf('custom-links', 'custom-links-homepage') === 'custom-links-more' && <>
|
||||
<div className="admin-panel-subhead">
|
||||
<h3>More Resources Links</h3>
|
||||
<p>These create cards in Resources → More guides, worksheets, and past study downloads.</p>
|
||||
@@ -4804,6 +5242,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" onClick={addResource}>+ Add Resource Link</button>
|
||||
</>}
|
||||
{renderSaveStatus()}
|
||||
</section>
|
||||
)}
|
||||
@@ -4999,8 +5438,10 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</button>
|
||||
<h2>{activeStudy.title || 'Untitled Study'}</h2>
|
||||
</div>
|
||||
{renderTabs('study', 'lessons', [{ id: 'lessons', label: 'Lessons' }, { id: 'settings', label: 'Study Settings' }])}
|
||||
|
||||
<details className="admin-collapsible-card" style={{ marginBottom: '1.5rem' }}>
|
||||
{tabOf('study', 'lessons') === 'settings' && <>
|
||||
<details open className="admin-collapsible-card" style={{ marginBottom: '1.5rem' }}>
|
||||
<summary className="admin-collapsible-summary">
|
||||
<div><strong>Study Settings</strong><p>Title, slug, description, and homepage display</p></div>
|
||||
<span className="admin-collapsible-hint">Expand to edit</span>
|
||||
@@ -5024,9 +5465,27 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
<label htmlFor={`study-status-${activeStudy.id}`}>Status</label>
|
||||
<select id={`study-status-${activeStudy.id}`} value={activeStudy.status} onChange={e => updateStudyProgram(activeStudy.id, 'status', e.target.value)}>
|
||||
<option value="active">Active</option>
|
||||
<option value="complete">Complete (finished — self-paced)</option>
|
||||
<option value="planned">Planned</option>
|
||||
</select>
|
||||
<p className="admin-field-help">A study whose lessons have all been released is shown as Complete automatically. Complete studies stay open for enrollment.</p>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-lane-${activeStudy.id}`}>Release Lane</label>
|
||||
<select id={`study-lane-${activeStudy.id}`} value={activeStudy.lane ?? ''} onChange={e => updateStudyProgram(activeStudy.id, 'lane', e.target.value)}>
|
||||
<option value="">None</option>
|
||||
<option value="A">Lane A (primary series)</option>
|
||||
<option value="B">Lane B (second series)</option>
|
||||
</select>
|
||||
<p className="admin-field-help">Two series can air at once on opposite Mondays. Lane is a label for planning; dates come from each lesson.</p>
|
||||
</div>
|
||||
{activeStudy.status === 'planned' && (
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-starts-at-${activeStudy.id}`}>Expected Start Date</label>
|
||||
<input id={`study-starts-at-${activeStudy.id}`} type="datetime-local" value={activeStudy.startsAt ? toLocalInputValue(activeStudy.startsAt) : ''} onChange={e => updateStudyProgram(activeStudy.id, 'startsAt', fromLocalInputValue(e.target.value))} />
|
||||
<p className="admin-field-help">Shown as “Starts <date>” on the study hub while the study is still Planned.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-description-${activeStudy.id}`}>Description</label>
|
||||
<textarea id={`study-description-${activeStudy.id}`} rows={3} value={activeStudy.description} placeholder="Brief description for the study hub card" onChange={e => updateStudyProgram(activeStudy.id, 'description', e.target.value)} />
|
||||
@@ -5056,10 +5515,54 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</>}
|
||||
{tabOf('study', 'lessons') === 'lessons' && <>
|
||||
<div className="admin-panel-subhead" style={{ marginBottom: '0.75rem' }}>
|
||||
<h3>Lessons ({(activeStudy.sections ?? []).length})</h3>
|
||||
</div>
|
||||
|
||||
{(activeStudy.sections ?? []).length > 0 && (
|
||||
<details className="admin-collapsible-card" style={{ marginBottom: '1rem' }}>
|
||||
<summary className="admin-collapsible-summary">
|
||||
<div><strong>Bulk Schedule Releases</strong><p>Set every lesson's date at once (e.g. every other Monday)</p></div>
|
||||
<span className="admin-collapsible-hint">Expand to edit</span>
|
||||
</summary>
|
||||
<div className="admin-collapsible-body">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor="bulk-start">First lesson releases</label>
|
||||
<input id="bulk-start" type="datetime-local" value={bulkStart} onChange={e => setBulkStart(e.target.value)} />
|
||||
<p className="admin-field-help">Pick the Monday and time for lesson 1. Lane B should start the Monday after Lane A.</p>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="bulk-interval">Days between lessons</label>
|
||||
<input id="bulk-interval" type="number" min="1" value={bulkInterval} onChange={e => setBulkInterval(e.target.value)} />
|
||||
<p className="admin-field-help">14 = every other week.</p>
|
||||
</div>
|
||||
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<input id="bulk-only-empty" type="checkbox" checked={bulkOnlyEmpty} onChange={e => setBulkOnlyEmpty(e.target.checked)} />
|
||||
<label htmlFor="bulk-only-empty">Only fill lessons that have no date yet</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-primary" disabled={!bulkStart} onClick={() => applyBulkSchedule(activeStudy.id)}>Apply to {(activeStudy.sections ?? []).length} lessons</button>
|
||||
<p className="admin-field-help" style={{ marginTop: '0.5rem' }}>Remember to Save Draft and Publish afterwards.</p>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const hits = findReleaseCollisions(activeStudy)
|
||||
if (hits.length === 0) return null
|
||||
return (
|
||||
<div style={{ background: 'rgba(211,47,47,0.1)', border: '1px solid rgba(211,47,47,0.4)', borderRadius: '10px', padding: '0.8rem 1rem', marginBottom: '1rem' }}>
|
||||
<p style={{ margin: 0, fontSize: '0.88rem', color: '#ef9a9a' }}>
|
||||
<strong>{hits.length} lesson{hits.length === 1 ? '' : 's'} release on the same day as another study:</strong>{' '}
|
||||
{hits.slice(0, 4).map(h => `${h.date} (${h.title} · ${h.other})`).join('; ')}{hits.length > 4 ? '…' : ''}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{(activeStudy.sections ?? []).length === 0 && <p className="admin-stats-note">No lessons yet. Add one below.</p>}
|
||||
|
||||
<div className="study-lesson-list-header">
|
||||
@@ -5105,6 +5608,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</DndContext>
|
||||
|
||||
<button type="button" className="btn-admin-add" style={{ marginTop: '0.75rem' }} onClick={() => addStudySection(activeStudy.id)}>+ Add Lesson</button>
|
||||
</>}
|
||||
{renderSaveStatus()}
|
||||
</>
|
||||
)}
|
||||
@@ -5228,6 +5732,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
|
||||
{renderSectionJumpNav('global')}
|
||||
|
||||
{tabOf('global', 'global-footer') === 'global-footer' && <>
|
||||
<div className="admin-panel-subhead" id="global-footer"><h3>Footer</h3></div>
|
||||
{FIELDS.filter(f => f.section === 'global' && ['footerTitle','footerSubtitle','footerEmail','footerCopyright','footerPrivacyNote'].includes(f.key)).map(({ key, label, multiline }) => (
|
||||
<div className="admin-field" key={key}>
|
||||
@@ -5244,6 +5749,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
))}
|
||||
|
||||
</>}
|
||||
{tabOf('global', 'global-footer') === 'global-header' && <>
|
||||
<div className="admin-panel-subhead" id="global-header"><h3>Header</h3></div>
|
||||
{FIELDS.filter(f => f.section === 'global' && f.key === 'headerFollowLabel').map(({ key, label }) => (
|
||||
<div className="admin-field" key={key}>
|
||||
@@ -5252,6 +5759,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
))}
|
||||
|
||||
</>}
|
||||
{tabOf('global', 'global-footer') === 'global-platforms' && <>
|
||||
<div className="admin-panel-subhead" id="global-platforms"><h3>Platform URLs</h3></div>
|
||||
{FIELDS.filter(f => f.section === 'global' && f.key.startsWith('platform')).map(({ key, label }) => (
|
||||
<div className="admin-field" key={key}>
|
||||
@@ -5260,6 +5769,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
))}
|
||||
|
||||
</>}
|
||||
{tabOf('global', 'global-footer') === 'global-banner' && <>
|
||||
<div className="admin-panel-subhead" id="global-banner"><h3>Analytics Banner</h3></div>
|
||||
{FIELDS.filter(f => f.section === 'global' && f.key === 'cookieBannerText').map(({ key, label }) => (
|
||||
<div className="admin-field" key={key}>
|
||||
@@ -5268,6 +5779,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
))}
|
||||
|
||||
</>}
|
||||
{tabOf('global', 'global-footer') === 'global-welcome-email' && <>
|
||||
<div className="admin-panel-subhead" id="global-welcome-email"><h3>Welcome Email</h3></div>
|
||||
{FIELDS.filter(f => f.section === 'global' && f.key.startsWith('welcomeEmail')).map(({ key, label, multiline }) => (
|
||||
<div className="admin-field" key={key}>
|
||||
@@ -5278,6 +5791,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
))}
|
||||
|
||||
</>}
|
||||
{renderSaveStatus()}
|
||||
</section>
|
||||
)}
|
||||
@@ -5314,7 +5828,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="script-season">Season <span style={{ color: '#5a5440', fontWeight: 400 }}>(optional — matches the podcast season number)</span></label>
|
||||
<label htmlFor="script-season">Season <span style={{ color: '#5a5440', fontWeight: 400 }}>(recommended — scripts are filed by season + episode, so episode numbers can repeat in each book)</span></label>
|
||||
<input
|
||||
id="script-season"
|
||||
type="number"
|
||||
@@ -5379,14 +5893,14 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
{scriptsList
|
||||
.filter(script => scriptSeasonFilter === 'all' || script.season === scriptSeasonFilter)
|
||||
.map(script => (
|
||||
<div key={script.episodeNumber} className="admin-script-row">
|
||||
<div key={script.key} className="admin-script-row">
|
||||
<div className="admin-script-row-info">
|
||||
<span className="admin-script-ep">Ep. {script.episodeNumber}</span>
|
||||
{script.season && <span className="admin-script-ep">Season {script.season}</span>}
|
||||
<span className="admin-script-title">{script.title}</span>
|
||||
<span className="admin-script-meta">{script.wordCount.toLocaleString()} words · {script.filename}</span>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteScript(script.episodeNumber)}>Remove</button>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteScript(script.key, `Episode ${script.episodeNumber}${script.season ? ` (Season ${script.season})` : ''}`)}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -6005,6 +6519,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
|
||||
{renderSectionJumpNav('seo')}
|
||||
|
||||
{tabOf('seo', 'seo-metadata') === 'seo-metadata' && <>
|
||||
<div className="admin-section-header" id="seo-metadata">
|
||||
<h3>Metadata</h3>
|
||||
<p>Search title, descriptions, Open Graph image, canonical URL, robots policy, and sitemap paths.</p>
|
||||
@@ -6028,6 +6543,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
{tabOf('seo', 'seo-metadata') === 'seo-redirects' && <>
|
||||
<div className="admin-section-header" id="seo-redirects">
|
||||
<h3>Redirect Manager</h3>
|
||||
<p>Maintain first-party short links like /spotify without code changes.</p>
|
||||
@@ -6049,6 +6566,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" onClick={addRedirectRule}>+ Add Redirect</button>
|
||||
</>}
|
||||
{renderSaveStatus()}
|
||||
</section>
|
||||
)}
|
||||
@@ -6063,6 +6581,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
|
||||
{renderSectionJumpNav('legal')}
|
||||
|
||||
{tabOf('legal', 'legal-privacy') === 'legal-privacy' && <>
|
||||
<div className="admin-section-header" id="legal-privacy">
|
||||
<h3>Privacy</h3>
|
||||
<p>Edit the title and body content for the privacy page.</p>
|
||||
@@ -6077,6 +6596,8 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>}
|
||||
{tabOf('legal', 'legal-privacy') === 'legal-terms' && <>
|
||||
<div className="admin-section-header" id="legal-terms">
|
||||
<h3>Terms</h3>
|
||||
<p>Edit the title and body content for the terms page.</p>
|
||||
@@ -6090,6 +6611,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
{renderSaveStatus()}
|
||||
</section>
|
||||
)}
|
||||
|
||||
+72
-1
@@ -1079,7 +1079,9 @@
|
||||
|
||||
.series-grid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.series-card {
|
||||
@@ -1132,6 +1134,48 @@
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Two series running at once: side-by-side compact cards instead of two stacked wide ones */
|
||||
.series-grid--two {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.75rem;
|
||||
align-items: stretch;
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.series-grid--two .series-card {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
max-width: none;
|
||||
padding: 1.75rem;
|
||||
align-content: start;
|
||||
text-align: center;
|
||||
}
|
||||
.series-grid--two .series-art {
|
||||
max-width: 200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.series-grid--two .series-info h3 {
|
||||
font-size: clamp(1.25rem, 2vw, 1.5rem);
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.series-grid--two .series-info p {
|
||||
font-size: 1rem;
|
||||
line-height: 1.55;
|
||||
margin-bottom: 1.25rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.series-grid--two .series-info .btn-primary,
|
||||
.series-grid--two .series-info .btn-secondary {
|
||||
margin: 0.25rem 0.3rem;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.series-grid--two { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.section-archive-series {
|
||||
background: #0b0b0b;
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.12);
|
||||
@@ -13122,3 +13166,30 @@
|
||||
}
|
||||
.study-status-badge.active { background: rgba(201, 168, 76, 0.18); color: #c9a84c; border: 1px solid rgba(201, 168, 76, 0.45); }
|
||||
.study-status-badge.planned { background: rgba(255, 255, 255, 0.05); color: #9a9486; border: 1px solid rgba(255, 255, 255, 0.15); }
|
||||
.study-status-badge.complete { background: rgba(120, 170, 130, 0.16); color: #8fc79b; border: 1px solid rgba(120, 170, 130, 0.45); }
|
||||
|
||||
.highlight-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0 0 1.75rem;
|
||||
}
|
||||
.highlight-tab {
|
||||
background: transparent;
|
||||
color: #b9b09b;
|
||||
border: 1px solid rgba(201, 168, 76, 0.3);
|
||||
border-radius: 999px;
|
||||
padding: 0.5rem 1.1rem;
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 200ms, color 200ms, background 200ms;
|
||||
}
|
||||
.highlight-tab:hover { color: #f0ead8; border-color: rgba(201, 168, 76, 0.6); }
|
||||
.highlight-tab--active { background: #c9a84c; border-color: #c9a84c; color: #0a0a08; }
|
||||
.highlight-tab-count { opacity: 0.7; font-size: 0.8em; margin-left: 0.25rem; }
|
||||
.highlight-more { display: flex; justify-content: center; margin-top: 1.5rem; }
|
||||
|
||||
.admin-tabs--page { margin: 0 0 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid #2a2518; }
|
||||
.admin-tabs--page .admin-tab { font-size: 0.8rem; padding: 0.45rem 0.9rem; }
|
||||
+190
-67
@@ -6,8 +6,8 @@ import QASection from './components/QASection'
|
||||
import ContactForm from './components/ContactForm'
|
||||
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyDiscussionHubPage, StudyQuizPage, StudyResetPasswordPage, StudyVerifyEmailPage } from './studyPages'
|
||||
import { SpotifyIcon } from './icons'
|
||||
import type { SiteContent, StudyProgram, Testimonial } from './content'
|
||||
import { DEFAULTS } from './content'
|
||||
import type { SiteContent, StudyProgram, Testimonial, PodcastFeaturedLink } from './content'
|
||||
import { DEFAULTS, getAllHighlights } from './content'
|
||||
import { usePageMeta } from './hooks/usePageMeta'
|
||||
import { useGlobalSearch } from './hooks/useGlobalSearch'
|
||||
import { GlobalSearch } from './components/GlobalSearch'
|
||||
@@ -303,6 +303,33 @@ function resolveDownloadPageResource(content: SiteContent, pageId: string | unde
|
||||
}
|
||||
}
|
||||
|
||||
// Companion guide of the second series, or one kept on a finished book
|
||||
const guideSource = pageId === 'second-guide'
|
||||
? { guide: content.secondSeries?.guide, imageUrl: content.secondSeries?.imageUrl, resourceId: 'guide:second' }
|
||||
: pageId.startsWith('book-guide--')
|
||||
? (() => {
|
||||
const book = (content.finishedBooks ?? []).find(item => item.id === pageId.slice('book-guide--'.length))
|
||||
return { guide: book?.guide, imageUrl: book?.imageUrl, resourceId: `guide:book:${book?.id ?? ''}` }
|
||||
})()
|
||||
: null
|
||||
if (guideSource) {
|
||||
const guide = guideSource.guide
|
||||
if (!guide?.title) return null
|
||||
return {
|
||||
id: pageId,
|
||||
label: guide.title,
|
||||
imageUrl: guideSource.imageUrl,
|
||||
summary: guide.description || 'Complete the short form below and your download will start right away.',
|
||||
tags: [],
|
||||
buttonText: `Download ${guide.title}`,
|
||||
resourceId: guideSource.resourceId,
|
||||
amazonUrl: guide.amazonUrl,
|
||||
amazonLabel: guide.amazonLabel,
|
||||
appleBooksUrl: guide.appleBooksUrl,
|
||||
appleBooksLabel: guide.appleBooksLabel,
|
||||
}
|
||||
}
|
||||
|
||||
if (pageId.startsWith('custom--')) {
|
||||
const customId = pageId.slice('custom--'.length)
|
||||
const resource = (content.customLinks ?? []).find(link => link.id === customId && link.placement === 'resources')
|
||||
@@ -715,10 +742,54 @@ function ContactSection({ content }: { content: SiteContent }) {
|
||||
)
|
||||
}
|
||||
|
||||
function HighlightCard({ link }: { link: PodcastFeaturedLink }) {
|
||||
const hasRichContent = !!(
|
||||
link.embedUrl
|
||||
|| isLikelySpotifyEpisodeUrl(link.url)
|
||||
|| (link.discussionQuestions ?? []).length > 0
|
||||
|| link.showNotes
|
||||
|| link.showNotesSummary
|
||||
|| (link.showNotesThemes ?? []).length > 0
|
||||
|| (link.showNotesScripture ?? []).length > 0
|
||||
|| (link.showNotesQuotes ?? []).length > 0
|
||||
|| (link.showNotesResources ?? []).length > 0
|
||||
)
|
||||
const inner = (
|
||||
<>
|
||||
<div className="episode-card-meta">
|
||||
{link.episodeNumber && <span className="episode-number">Ep. {link.episodeNumber}</span>}
|
||||
</div>
|
||||
<h3 className="episode-title">{link.title || link.url}</h3>
|
||||
{link.summary && <p className="episode-desc">{link.summary}</p>}
|
||||
<span className="episode-listen-cta">Listen →</span>
|
||||
</>
|
||||
)
|
||||
if (!hasRichContent && link.url) {
|
||||
return <a href={link.url} target="_blank" rel="noreferrer" className="episode-card">{inner}</a>
|
||||
}
|
||||
return <Link to={`/episodes/${link.id}`} className="episode-card">{inner}</Link>
|
||||
}
|
||||
|
||||
const HIGHLIGHTS_PREVIEW_COUNT = 6
|
||||
|
||||
// Highlights are grouped by the series they belong to, one tab per series, so a long series
|
||||
// never pushes the others down the page. Each tab previews the newest few with a "show all" option.
|
||||
function PodcastHighlightsSection({ content }: { content: SiteContent }) {
|
||||
const [activeKey, setActiveKey] = useState('')
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
const links = [...(content.podcastFeaturedLinks ?? [])].reverse()
|
||||
if (links.length === 0) return null
|
||||
|
||||
const laneOf = (link: PodcastFeaturedLink) => (link.lane === 'second' ? 'second' : link.lane === 'general' ? 'general' : 'primary')
|
||||
const groups = [
|
||||
{ key: 'primary', title: content.seriesTitle, links: links.filter(link => laneOf(link) === 'primary') },
|
||||
{ key: 'second', title: content.secondSeries?.title?.trim() || 'Second Series', links: links.filter(link => laneOf(link) === 'second') },
|
||||
{ key: 'general', title: 'More Highlights', links: links.filter(link => laneOf(link) === 'general') },
|
||||
].filter(group => group.links.length > 0)
|
||||
const active = groups.find(group => group.key === activeKey) ?? groups[0]
|
||||
const isExpanded = expanded[active.key] === true
|
||||
const shown = isExpanded ? active.links : active.links.slice(0, HIGHLIGHTS_PREVIEW_COUNT)
|
||||
|
||||
return (
|
||||
<section className="section-resources" aria-label="Podcast highlights">
|
||||
<div className="section-inner">
|
||||
@@ -726,48 +797,32 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
|
||||
<span className="ornament">✦</span> Podcast Highlights{' '}
|
||||
<span className="ornament">✦</span>
|
||||
</h2>
|
||||
{groups.length > 1 && (
|
||||
<div className="highlight-tabs" role="tablist" aria-label="Highlights by series">
|
||||
{groups.map(group => (
|
||||
<button
|
||||
key={group.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={group.key === active.key}
|
||||
className={`highlight-tab${group.key === active.key ? ' highlight-tab--active' : ''}`}
|
||||
onClick={() => setActiveKey(group.key)}
|
||||
>
|
||||
{group.title} <span className="highlight-tab-count">{group.links.length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="episode-list">
|
||||
{links.map(link => {
|
||||
const hasRichContent = !!(
|
||||
link.embedUrl
|
||||
|| isLikelySpotifyEpisodeUrl(link.url)
|
||||
|| (link.discussionQuestions ?? []).length > 0
|
||||
|| link.showNotes
|
||||
|| link.showNotesSummary
|
||||
|| (link.showNotesThemes ?? []).length > 0
|
||||
|| (link.showNotesScripture ?? []).length > 0
|
||||
|| (link.showNotesQuotes ?? []).length > 0
|
||||
|| (link.showNotesResources ?? []).length > 0
|
||||
)
|
||||
const dest = (!hasRichContent && link.url)
|
||||
? { external: link.url }
|
||||
: { internal: `/episodes/${link.id}` }
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<div className="episode-card-meta">
|
||||
{link.episodeNumber && <span className="episode-number">Ep. {link.episodeNumber}</span>}
|
||||
</div>
|
||||
<h3 className="episode-title">{link.title || link.url}</h3>
|
||||
{link.summary && <p className="episode-desc">{link.summary}</p>}
|
||||
<span className="episode-listen-cta">Listen →</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if ('external' in dest) {
|
||||
return (
|
||||
<a key={link.id} href={dest.external} target="_blank" rel="noreferrer" className="episode-card">
|
||||
{inner}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Link key={link.id} to={dest.internal} className="episode-card">
|
||||
{inner}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{shown.map(link => <HighlightCard key={link.id} link={link} />)}
|
||||
</div>
|
||||
{active.links.length > HIGHLIGHTS_PREVIEW_COUNT && (
|
||||
<div className="highlight-more">
|
||||
<button type="button" className="btn-secondary" onClick={() => setExpanded(prev => ({ ...prev, [active.key]: !isExpanded }))}>
|
||||
{isExpanded ? 'Show fewer' : `Show all ${active.links.length} highlights`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
@@ -796,7 +851,7 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
||||
return !activeTag || (r.tags ?? []).includes(activeTag)
|
||||
})
|
||||
|
||||
if (!hasPrimaryGuide && resources.length === 0) return null
|
||||
if (!hasPrimaryGuide && !content.secondSeries?.guide?.title && resources.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="section-resources section-download-library" aria-label="Download library">
|
||||
@@ -824,6 +879,29 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{content.secondSeries?.guide?.title && (
|
||||
<Link to="/downloads/second-guide" className="resource-download-card resource-download-card--link resource-download-card--featured">
|
||||
<div className="resource-download-header">
|
||||
{content.secondSeries.imageUrl && (
|
||||
<img
|
||||
src={content.secondSeries.imageUrl}
|
||||
alt={content.secondSeries.title}
|
||||
className="resource-link-image resource-link-image--featured"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="resource-download-meta">
|
||||
<div className="featured-badge">Featured</div>
|
||||
<span className="resource-link-label">{content.secondSeries.guide.title}</span>
|
||||
{content.secondSeries.guide.description && (
|
||||
<p className="resource-link-description">{content.secondSeries.guide.description}</p>
|
||||
)}
|
||||
<span className="resource-link-action">Download free guide →</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{resources.length > 0 && (
|
||||
<>
|
||||
{allTags.length > 0 && (
|
||||
@@ -1313,29 +1391,56 @@ function LandingPage({ content }: { content: SiteContent }) {
|
||||
<span className="ornament">✦</span> Current Series{' '}
|
||||
<span className="ornament">✦</span>
|
||||
</h2>
|
||||
<div className="series-grid">
|
||||
<article className="series-card">
|
||||
<img
|
||||
src={content.seriesImageUrl}
|
||||
alt={content.seriesTitle}
|
||||
className="series-art"
|
||||
/>
|
||||
<div className="series-info">
|
||||
<p className="series-label">{content.seriesLabel}</p>
|
||||
<h3>{content.seriesTitle}</h3>
|
||||
<p>{content.seriesDescription}</p>
|
||||
<a
|
||||
href={content.seriesListenUrl || content.platformSpotifyUrl || '/spotify'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="btn-primary"
|
||||
>
|
||||
<SpotifyIcon />
|
||||
{content.seriesListenBtnLabel || 'Listen to Series'}
|
||||
</a>
|
||||
<Link to="/resources" className="btn-secondary">{content.seriesDownloadsBtnLabel || 'View Resources'}</Link>
|
||||
</div>
|
||||
</article>
|
||||
<div className={`series-grid${content.secondSeries?.title?.trim() ? ' series-grid--two' : ''}`}>
|
||||
{[
|
||||
{
|
||||
key: 'primary',
|
||||
imageUrl: content.seriesImageUrl,
|
||||
title: content.seriesTitle,
|
||||
label: content.seriesLabel,
|
||||
description: content.seriesDescription,
|
||||
listenUrl: content.seriesListenUrl,
|
||||
listenBtnLabel: content.seriesListenBtnLabel,
|
||||
studySlug: content.seriesStudySlug,
|
||||
},
|
||||
...(content.secondSeries?.title?.trim()
|
||||
? [{
|
||||
key: 'second',
|
||||
imageUrl: content.secondSeries.imageUrl,
|
||||
title: content.secondSeries.title,
|
||||
label: content.secondSeries.label,
|
||||
description: content.secondSeries.description,
|
||||
listenUrl: content.secondSeries.listenUrl,
|
||||
listenBtnLabel: content.secondSeries.listenBtnLabel,
|
||||
studySlug: content.secondSeries.studySlug,
|
||||
}]
|
||||
: []),
|
||||
].map(series => (
|
||||
<article className="series-card" key={series.key}>
|
||||
<img
|
||||
src={series.imageUrl}
|
||||
alt={series.title}
|
||||
className="series-art"
|
||||
/>
|
||||
<div className="series-info">
|
||||
<p className="series-label">{series.label}</p>
|
||||
<h3>{series.title}</h3>
|
||||
<p>{series.description}</p>
|
||||
<a
|
||||
href={series.listenUrl || content.platformSpotifyUrl || '/spotify'}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="btn-primary"
|
||||
>
|
||||
<SpotifyIcon />
|
||||
{series.listenBtnLabel || content.seriesListenBtnLabel || 'Listen to Series'}
|
||||
</a>
|
||||
{series.studySlug
|
||||
? <Link to={`/study/${series.studySlug}`} className="btn-secondary">Study Along</Link>
|
||||
: <Link to="/resources" className="btn-secondary">{content.seriesDownloadsBtnLabel || 'View Resources'}</Link>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1397,7 +1502,7 @@ function LandingPage({ content }: { content: SiteContent }) {
|
||||
|
||||
function EpisodeDetailPage({ content }: { content: SiteContent }) {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const episode = (content.podcastFeaturedLinks ?? []).find(e => e.id === id)
|
||||
const episode = getAllHighlights(content).find(e => e.id === id)
|
||||
const [resolvedEmbedUrl, setResolvedEmbedUrl] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1708,6 +1813,16 @@ function FinishedSeriesPage({ content }: { content: SiteContent }) {
|
||||
⬇ Download Album
|
||||
</a>
|
||||
)}
|
||||
{book.studySlug && (
|
||||
<Link to={`/study/${book.studySlug}`} className="finished-series-btn-download">
|
||||
📖 Study This Book
|
||||
</Link>
|
||||
)}
|
||||
{book.guide?.title && (
|
||||
<Link to={`/downloads/book-guide--${book.id}`} className="finished-series-btn-download">
|
||||
📄 {book.guide.title}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{book.downloadQrUrl && book.downloadUrl && (
|
||||
<div className="finished-series-qr">
|
||||
@@ -1719,6 +1834,14 @@ function FinishedSeriesPage({ content }: { content: SiteContent }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(book.highlights ?? []).length > 0 && (
|
||||
<>
|
||||
<h2 className="finished-series-playlist-heading">Episode Highlights</h2>
|
||||
<div className="episode-list">
|
||||
{[...(book.highlights ?? [])].reverse().map(link => <HighlightCard key={link.id} link={link} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<h2 id="playlist" className="finished-series-playlist-heading">Episode Playlist</h2>
|
||||
{loading ? (
|
||||
<p className="finished-books-empty">Loading episodes…</p>
|
||||
|
||||
+87
-29
@@ -14,7 +14,13 @@ export interface StudyProgram {
|
||||
showOnHomepage?: boolean
|
||||
showNewTag?: boolean
|
||||
newTagLabel?: string
|
||||
status: 'active' | 'planned'
|
||||
// 'complete' = finished and self-paced (still open for enrollment). A study whose lessons
|
||||
// have all been released is treated as complete even if left as 'active'.
|
||||
status: 'active' | 'planned' | 'complete'
|
||||
// Which of the two concurrent release lanes this study airs in (A = primary series, B = second series)
|
||||
lane?: '' | 'A' | 'B'
|
||||
// When a planned study is expected to start (ISO) — shown as "Starts <date>" on the hub
|
||||
startsAt?: string
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||
estimatedHours: number
|
||||
completionBadge: string
|
||||
@@ -62,6 +68,9 @@ export interface RedirectRule {
|
||||
|
||||
export interface PodcastFeaturedLink {
|
||||
id: string
|
||||
// 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).
|
||||
lane?: 'primary' | 'second' | 'general'
|
||||
title: string
|
||||
episodeNumber?: string
|
||||
summary: string
|
||||
@@ -85,16 +94,78 @@ export interface Testimonial {
|
||||
source?: string
|
||||
}
|
||||
|
||||
// A series' companion study guide (the main series' guide lives in the studyGuide* fields)
|
||||
export interface StudyGuide {
|
||||
title: string
|
||||
description?: string
|
||||
downloadUrl?: string
|
||||
amazonUrl?: string
|
||||
amazonLabel?: string
|
||||
appleBooksUrl?: string
|
||||
appleBooksLabel?: string
|
||||
}
|
||||
|
||||
export interface SecondSeries {
|
||||
label: string
|
||||
title: string
|
||||
description: string
|
||||
imageUrl: string
|
||||
listenUrl: string
|
||||
listenBtnLabel: string
|
||||
studySlug?: string
|
||||
season?: number
|
||||
guide?: StudyGuide
|
||||
}
|
||||
|
||||
export interface FinishedBook {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
imageUrl: string
|
||||
season: number
|
||||
// The study that goes with this book, so the card can offer both "Listen" and "Study"
|
||||
studySlug?: string
|
||||
// Episode highlights that were featured while this series was playing, kept for its page
|
||||
highlights?: PodcastFeaturedLink[]
|
||||
// The series' companion study guide, kept so it stays available after the series ends
|
||||
guide?: StudyGuide
|
||||
downloadUrl?: string
|
||||
downloadQrUrl?: string
|
||||
}
|
||||
|
||||
export interface ScheduledSeriesTransition {
|
||||
triggersAt: string // ISO datetime — server fires at or after this time (checks hourly)
|
||||
// New series card
|
||||
newTitle: string
|
||||
newDescription?: string
|
||||
newImageUrl?: string
|
||||
newSeason: number
|
||||
// Study that opens (planned → active) when this series launches; the ending series' study becomes Complete
|
||||
newStudySlug?: string
|
||||
// Companion guide for the incoming series (second series lane only; the main series uses the newStudyGuide* fields)
|
||||
newGuide?: StudyGuide
|
||||
// Start Here section (optional)
|
||||
newStartHereHeading?: string
|
||||
newStartHereIntro?: string
|
||||
newStartHereStep1Title?: string
|
||||
newStartHereStep1Body?: string
|
||||
newStartHereStep1Cta?: string
|
||||
newStartHereStep2Title?: string
|
||||
newStartHereStep2Body?: string
|
||||
newStartHereStep2Cta?: string
|
||||
newStartHereStep3Title?: string
|
||||
newStartHereStep3Body?: string
|
||||
newStartHereStep3Cta?: string
|
||||
// Companion Study Guide / Featured Download (optional)
|
||||
newStudyGuideTitle?: string
|
||||
newStudyGuideDescription?: string
|
||||
newStudyGuideDownloadUrl?: string
|
||||
newStudyGuideUrl?: string
|
||||
newStudyGuideAmazonButtonLabel?: string
|
||||
newStudyGuideAppleBooksUrl?: string
|
||||
newStudyGuideAppleBooksLabel?: string
|
||||
}
|
||||
|
||||
export interface PodcastHost {
|
||||
id: string
|
||||
name: string
|
||||
@@ -184,6 +255,10 @@ export interface SiteContent {
|
||||
seriesImageUrl: string
|
||||
seriesListenUrl: string
|
||||
seriesListenBtnLabel: string
|
||||
seriesStudySlug?: string
|
||||
seriesSeason?: number
|
||||
// Optional second concurrent series (Lane B). Same card as the primary series.
|
||||
secondSeries?: SecondSeries
|
||||
seriesDownloadsBtnLabel: string
|
||||
// ── Downloads ──
|
||||
studyGuideTitle: string
|
||||
@@ -334,34 +409,9 @@ export interface SiteContent {
|
||||
// ── Scheduled Series Transition ──
|
||||
// All fields fire together at triggersAt. Start Here and Study Guide fields are optional —
|
||||
// omit them to leave those sections unchanged when the series transitions.
|
||||
scheduledSeriesTransition?: {
|
||||
triggersAt: string // ISO datetime — server fires at or after this time (checks hourly)
|
||||
// New series card
|
||||
newTitle: string
|
||||
newDescription?: string
|
||||
newImageUrl?: string
|
||||
newSeason: number
|
||||
// Start Here section (optional)
|
||||
newStartHereHeading?: string
|
||||
newStartHereIntro?: string
|
||||
newStartHereStep1Title?: string
|
||||
newStartHereStep1Body?: string
|
||||
newStartHereStep1Cta?: string
|
||||
newStartHereStep2Title?: string
|
||||
newStartHereStep2Body?: string
|
||||
newStartHereStep2Cta?: string
|
||||
newStartHereStep3Title?: string
|
||||
newStartHereStep3Body?: string
|
||||
newStartHereStep3Cta?: string
|
||||
// Companion Study Guide / Featured Download (optional)
|
||||
newStudyGuideTitle?: string
|
||||
newStudyGuideDescription?: string
|
||||
newStudyGuideDownloadUrl?: string
|
||||
newStudyGuideUrl?: string
|
||||
newStudyGuideAmazonButtonLabel?: string
|
||||
newStudyGuideAppleBooksUrl?: string
|
||||
newStudyGuideAppleBooksLabel?: string
|
||||
}
|
||||
scheduledSeriesTransition?: ScheduledSeriesTransition
|
||||
// Same, for the second series lane
|
||||
scheduledSecondSeriesTransition?: ScheduledSeriesTransition
|
||||
// ── Testimonials ──
|
||||
testimonials: Testimonial[]
|
||||
// ── Nuggets (sub-show) ──
|
||||
@@ -637,3 +687,11 @@ export const DEFAULTS: SiteContent = {
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// Highlights currently featured plus those archived onto finished books (so their detail pages keep working).
|
||||
export function getAllHighlights(content: Pick<SiteContent, 'podcastFeaturedLinks' | 'finishedBooks'>): PodcastFeaturedLink[] {
|
||||
return [
|
||||
...(content.podcastFeaturedLinks ?? []),
|
||||
...(content.finishedBooks ?? []).flatMap(book => book.highlights ?? []),
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState, useEffect, useRef } from 'react'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { SiteContent } from '../content'
|
||||
import { getAllHighlights } from '../content'
|
||||
|
||||
export type SearchResultType = 'episode' | 'question' | 'resource' | 'series' | 'newsletter'
|
||||
|
||||
@@ -24,6 +25,7 @@ interface PublicQuestion {
|
||||
}
|
||||
|
||||
interface ScriptSearchResult {
|
||||
key?: string
|
||||
episodeNumber: string
|
||||
title: string
|
||||
filename: string
|
||||
@@ -58,7 +60,7 @@ export function useGlobalSearch(content: SiteContent) {
|
||||
.then((data: { episodes?: Array<{ title: string; link: string; description?: string; episode?: string }> }) => {
|
||||
const eps: SearchResult[] = (data.episodes ?? []).map(ep => {
|
||||
// Check if there's a matching podcastFeaturedLink highlight page for this episode number
|
||||
const highlight = content.podcastFeaturedLinks?.find(
|
||||
const highlight = getAllHighlights(content).find(
|
||||
f => f.episodeNumber && ep.episode && String(f.episodeNumber) === String(ep.episode)
|
||||
)
|
||||
const hasRichPage = highlight && !!(
|
||||
@@ -76,7 +78,7 @@ export function useGlobalSearch(content: SiteContent) {
|
||||
setEpisodeItems(eps)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [content.podcastFeaturedLinks])
|
||||
}, [content])
|
||||
|
||||
// Fetch nuggets episodes once on mount
|
||||
useEffect(() => {
|
||||
@@ -120,8 +122,11 @@ export function useGlobalSearch(content: SiteContent) {
|
||||
for (const book of content.finishedBooks ?? []) {
|
||||
if (book.season != null && book.title) map.set(String(book.season), book.title)
|
||||
}
|
||||
// The series playing now (one or two at a time), so their scripts show a title too
|
||||
if (content.seriesSeason != null && content.seriesTitle) map.set(String(content.seriesSeason), content.seriesTitle)
|
||||
if (content.secondSeries?.season != null && content.secondSeries.title) map.set(String(content.secondSeries.season), content.secondSeries.title)
|
||||
return map
|
||||
}, [content.finishedBooks])
|
||||
}, [content.finishedBooks, content.seriesSeason, content.seriesTitle, content.secondSeries])
|
||||
|
||||
// Debounced server-side script search — runs whenever query changes
|
||||
useEffect(() => {
|
||||
@@ -137,7 +142,7 @@ export function useGlobalSearch(content: SiteContent) {
|
||||
const results = (data.results ?? []).map(r => {
|
||||
const seasonLabel = r.season ? (seasonTitleMap.get(r.season) ?? `Season ${r.season}`) : null
|
||||
return {
|
||||
id: `script-${r.episodeNumber}`,
|
||||
id: `script-${r.key ?? r.episodeNumber}`,
|
||||
type: 'episode' as const,
|
||||
title: r.title,
|
||||
subtitle: [`Episode ${r.episodeNumber}`, seasonLabel, 'Script'].filter(Boolean).join(' · '),
|
||||
|
||||
+80
-11
@@ -64,7 +64,7 @@ type StudyAccountOverview = {
|
||||
studies: Array<{
|
||||
slug: string
|
||||
title: string
|
||||
status: 'active' | 'planned'
|
||||
status: 'active' | 'planned' | 'complete'
|
||||
enrolled: boolean
|
||||
totalLessons: number
|
||||
completedLessons: number
|
||||
@@ -107,8 +107,9 @@ type StudyCommunityPost = {
|
||||
pending?: boolean
|
||||
}
|
||||
|
||||
function StudyStatusBadge({ status }: { status: 'active' | 'planned' }) {
|
||||
return <span className={`study-status-badge ${status}`}>{status === 'active' ? 'Active' : 'Planned'}</span>
|
||||
function StudyStatusBadge({ status }: { status: 'active' | 'planned' | 'complete' }) {
|
||||
const label = status === 'active' ? 'Active' : status === 'complete' ? 'Complete' : 'Planned'
|
||||
return <span className={`study-status-badge ${status}`}>{label}</span>
|
||||
}
|
||||
|
||||
function getLegacyColossiansStudy(content: SiteContent): StudyProgram {
|
||||
@@ -200,6 +201,24 @@ function isSectionReleased(section: StudySection): boolean {
|
||||
return releaseDate <= new Date()
|
||||
}
|
||||
|
||||
// A study is complete when an admin marks it so, or when every lesson has been released.
|
||||
// Complete studies stay open for enrollment and are fully self-paced.
|
||||
function getEffectiveStudyStatus(study: StudyProgram): 'active' | 'planned' | 'complete' {
|
||||
if (study.status === 'planned') return 'planned'
|
||||
if (study.status === 'complete') return 'complete'
|
||||
if (study.sections.length > 0 && study.sections.every(isSectionReleased)) return 'complete'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
function getNextReleaseLabel(study: StudyProgram): string {
|
||||
const upcoming = study.sections
|
||||
.map(getSectionReleaseDate)
|
||||
.filter((date): date is Date => date !== null && date > new Date())
|
||||
.sort((a, b) => a.getTime() - b.getTime())[0]
|
||||
if (!upcoming) return ''
|
||||
return upcoming.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function getReleaseDateDisplay(section: StudySection): string {
|
||||
const date = getSectionReleaseDate(section)
|
||||
if (!date) return ''
|
||||
@@ -589,9 +608,10 @@ export function StudyLandingPage({ content }: Props) {
|
||||
const [newsletterNudgeDone, setNewsletterNudgeDone] = useState(false)
|
||||
|
||||
const studies = getStudies(content)
|
||||
const activeStudies = studies.filter(study => study.status !== 'planned')
|
||||
const plannedStudies = studies.filter(study => study.status === 'planned')
|
||||
const firstActiveStudy = activeStudies[0]
|
||||
const activeStudies = studies.filter(study => getEffectiveStudyStatus(study) === 'active')
|
||||
const completeStudies = studies.filter(study => getEffectiveStudyStatus(study) === 'complete')
|
||||
const plannedStudies = studies.filter(study => getEffectiveStudyStatus(study) === 'planned')
|
||||
const firstActiveStudy = activeStudies[0] ?? completeStudies[0]
|
||||
const firstActiveStudyFirstReleasedSection = firstActiveStudy?.sections.find(isSectionReleased)
|
||||
|
||||
async function refreshOverview() {
|
||||
@@ -929,7 +949,7 @@ export function StudyLandingPage({ content }: Props) {
|
||||
<h1>Study at Your Own Pace</h1>
|
||||
<p className="study-course-hero-copy">Pick a study track, move lesson by lesson on your own schedule, and keep personal notes as you grow through each passage.</p>
|
||||
<div className="study-course-meta">
|
||||
<span>{activeStudies.length} {activeStudies.length === 1 ? 'study' : 'studies'} available{plannedStudies.length > 0 ? ` · ${plannedStudies.length} coming soon` : ''}</span>
|
||||
<span>{activeStudies.length + completeStudies.length} {activeStudies.length + completeStudies.length === 1 ? 'study' : 'studies'} available{completeStudies.length > 0 ? ` (${completeStudies.length} finished, start anytime)` : ''}{plannedStudies.length > 0 ? ` · ${plannedStudies.length} coming soon` : ''}</span>
|
||||
<span>Self-paced flow</span>
|
||||
<span>Personal notes</span>
|
||||
<span>Audio + commentary</span>
|
||||
@@ -992,7 +1012,11 @@ export function StudyLandingPage({ content }: Props) {
|
||||
<div className="study-module-row-right">
|
||||
<p className="study-module-focus">Track Snapshot</p>
|
||||
<p>{releasedCount > 0 ? `${releasedCount} lessons available now` : 'Lessons will be published soon.'}</p>
|
||||
<p>{study.sections.length > 0 ? 'Estimated pace: every other Monday' : 'Pacing details coming with first lesson release.'}</p>
|
||||
<p>{(() => {
|
||||
const next = getNextReleaseLabel(study)
|
||||
if (next) return `Next lesson: ${next}`
|
||||
return study.sections.length > 0 ? 'New lessons release on a regular schedule.' : 'Pacing details coming with first lesson release.'
|
||||
})()}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
@@ -1002,6 +1026,43 @@ export function StudyLandingPage({ content }: Props) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{completeStudies.length > 0 && (
|
||||
<section className="section-study-module" aria-label="Finished studies" id="finished-studies">
|
||||
<div className="section-inner">
|
||||
<div className="study-module-header">
|
||||
<p>Study On Your Own Time</p>
|
||||
<h2>Finished Studies</h2>
|
||||
</div>
|
||||
<div className="study-module-list">
|
||||
{completeStudies.map(study => {
|
||||
const book = (content.finishedBooks ?? []).find(item => item.studySlug === study.slug)
|
||||
return (
|
||||
<div key={study.id}>
|
||||
<Link to={`/study/${study.slug}`} className="study-module-row">
|
||||
<div className="study-module-row-left">
|
||||
<p className="study-module-lesson">Complete · Start Anytime</p>
|
||||
<h3>{study.title}</h3>
|
||||
<p>{study.description}</p>
|
||||
</div>
|
||||
<div className="study-module-row-right">
|
||||
<p className="study-module-focus">Self-Paced</p>
|
||||
<p>All {study.sections.length} lessons available now</p>
|
||||
<p>No schedule — go as fast or slow as you like.</p>
|
||||
</div>
|
||||
</Link>
|
||||
{book && (
|
||||
<p style={{ margin: '0.4rem 0 0', fontSize: '0.9rem' }}>
|
||||
Just want to listen? <Link to={`/finished/${book.id}`}>Open the audio playlist — no account needed →</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{plannedStudies.length > 0 && (
|
||||
<section className="section-study-module" aria-label="Upcoming studies">
|
||||
<div className="section-inner">
|
||||
@@ -1019,7 +1080,9 @@ export function StudyLandingPage({ content }: Props) {
|
||||
</div>
|
||||
<div className="study-module-row-right">
|
||||
<p className="study-module-focus">Status</p>
|
||||
<p>Preparing lesson structure and media.</p>
|
||||
<p>{study.startsAt && new Date(study.startsAt).getTime() > Date.now()
|
||||
? `Starts ${new Date(study.startsAt).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}`
|
||||
: 'Preparing lesson structure and media.'}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -1567,6 +1630,11 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
||||
const chapterSummaries = getChapterSummaries(study)
|
||||
const releasedSections = sections.filter(isSectionReleased)
|
||||
const firstReleasedSection = releasedSections[0]
|
||||
const studyStatus = getEffectiveStudyStatus(study)
|
||||
const isSelfPaced = studyStatus === 'complete'
|
||||
// Resume at the first released lesson the student hasn't finished yet
|
||||
const resumeSection = releasedSections.find(item => !studyProgress.completedSectionIds.includes(item.id)) ?? firstReleasedSection
|
||||
const hasStarted = enrolled && studyProgress.completedSectionIds.length > 0
|
||||
const populatedChapterCount = chapterSummaries.filter(chapter => chapter.lessonCount > 0).length
|
||||
|
||||
async function enrollInStudy() {
|
||||
@@ -1606,9 +1674,10 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
||||
) : null}
|
||||
<span>Text + commentary + discussion</span>
|
||||
<span>Student notes enabled</span>
|
||||
{isSelfPaced && <span>Finished study — go at your own pace</span>}
|
||||
</div>
|
||||
<div className="study-course-hero-actions">
|
||||
{enrolled && firstReleasedSection && <Link to={`/study/${study.slug}/${firstReleasedSection.id}`} className="btn-primary">Start Class</Link>}
|
||||
{enrolled && resumeSection && <Link to={`/study/${study.slug}/${resumeSection.id}`} className="btn-primary">{hasStarted ? 'Resume Where You Left Off' : 'Start Class'}</Link>}
|
||||
{!auth.authenticated && <Link to="/study/signup" className="btn-primary">Sign In to Enroll</Link>}
|
||||
{auth.authenticated && !enrolled && (
|
||||
<button type="button" className="btn-primary" disabled={enrolling} onClick={enrollInStudy}>
|
||||
@@ -1671,7 +1740,7 @@ export function ColossiansStudyIndexPage({ content }: Props) {
|
||||
const coming = isComingSoon(section)
|
||||
const prevSection = sectionIdx > 0 ? sections[sectionIdx - 1] : null
|
||||
const prevCompleted = !prevSection || studyProgress.completedSectionIds.includes(prevSection.id)
|
||||
const lockedByProgress = enrolled && !coming && sectionIdx > 0 && !prevCompleted && !studyProgress.completedSectionIds.includes(section.id)
|
||||
const lockedByProgress = enrolled && !isSelfPaced && !coming && sectionIdx > 0 && !prevCompleted && !studyProgress.completedSectionIds.includes(section.id)
|
||||
const isDisabled = coming || !enrolled || lockedByProgress
|
||||
|
||||
return (
|
||||
|
||||
Reference in new issue
Block a user