From 652b003bd93d300a8f9c66ce57544f3a4861183c Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 8 Oct 2026 10:46:33 -0400 Subject: [PATCH] Episode highlights per series: tag by lane, group on Episodes page, move to Finished Book when series ends; v2.9.1 - Highlights get a Series field (Lane A / Lane B / General); legacy highlights count as Lane A - Episodes page groups highlights under each series' title - Ending a series (scheduled or manual) moves only that series' highlights onto its Finished Book page; other lane and General stay - Archived highlights keep working: detail pages, sitemap, scripture index and search include them Co-Authored-By: Claude Sonnet 5.5 --- package.json | 2 +- server/helpers.js | 1 + server/routes/admin-content.js | 14 +++-- server/routes/public.js | 10 +++- src/AdminPage.tsx | 32 +++++++++- src/App.css | 10 ++++ src/App.tsx | 103 +++++++++++++++++++-------------- src/content.ts | 13 +++++ src/hooks/useGlobalSearch.ts | 5 +- 9 files changed, 133 insertions(+), 57 deletions(-) diff --git a/package.json b/package.json index d489d50..0d09a5f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.9.0", + "version": "2.9.1", "type": "module", "scripts": { "dev": "vite", diff --git a/server/helpers.js b/server/helpers.js index 24e1780..e8dd7b0 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -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) : '', diff --git a/server/routes/admin-content.js b/server/routes/admin-content.js index 82b57e8..05f6ff9 100644 --- a/server/routes/admin-content.js +++ b/server/routes/admin-content.js @@ -82,6 +82,12 @@ function applySeriesTransition(content, sched, lane = 'primary') { 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), @@ -90,6 +96,7 @@ function applySeriesTransition(content, sched, lane = 'primary') { imageUrl: current.imageUrl, season: current.season ?? ((newSeason ?? 2) - 1 || 1), ...(current.studySlug ? { studySlug: current.studySlug } : {}), + ...(endingHighlights.length > 0 ? { highlights: endingHighlights.map(({ lane: _lane, ...link }) => link) } : {}), } : null @@ -126,10 +133,6 @@ function applySeriesTransition(content, sched, lane = 'primary') { return study }) - const hasOtherLane = lane === 'second' - ? Boolean(content.seriesTitle?.trim()) - : Boolean(content.secondSeries?.title?.trim()) - const lanePatch = lane === 'second' ? { secondSeries: { @@ -155,8 +158,7 @@ function applySeriesTransition(content, sched, lane = 'primary') { ...content, ...lanePatch, studies, - // Highlights belong to whatever is playing now; don't wipe them while the other lane is still running. - podcastFeaturedLinks: hasOtherLane ? content.podcastFeaturedLinks : [], + podcastFeaturedLinks: keptHighlights, finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), ...(archivedBook ? [archivedBook] : [])], ...patch, }) diff --git a/server/routes/public.js b/server/routes/public.js index 671e696..73e980e 100644 --- a/server/routes/public.js +++ b/server/routes/public.js @@ -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 = [] diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 742227b..115f434 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -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) @@ -2216,6 +2230,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) ...(f.podcastFeaturedLinks ?? []), { id: Date.now().toString(36), + lane: 'primary' as const, title: '', episodeNumber: '', summary: '', @@ -4154,6 +4169,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) 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, @@ -4161,9 +4177,11 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) imageUrl: cur.imageUrl, season: cur.season ?? 1, ...(cur.studySlug ? { studySlug: cur.studySlug } : {}), + ...(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, @@ -4207,6 +4225,7 @@ 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, @@ -4214,6 +4233,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) imageUrl: form.seriesImageUrl, season: form.seriesSeason ?? (parseInt(endSeriesNewSeason) - 1 || 1), ...(form.seriesStudySlug ? { studySlug: form.seriesStudySlug } : {}), + ...(endingHighlights.length > 0 ? { highlights: endingHighlights } : {}), } setForm(f => ({ ...f, @@ -4221,7 +4241,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) seriesStudySlug: '', seriesSeason: parseInt(endSeriesNewSeason) || undefined, finishedBooks: [...(f.finishedBooks ?? []), archivedBook], - podcastFeaturedLinks: f.secondSeries?.title?.trim() ? f.podcastFeaturedLinks : [], + podcastFeaturedLinks: keptHighlights, seriesTitle: endSeriesNewTitle.trim(), seriesImageUrl: endSeriesNewImage.trim() || f.seriesImageUrl, seriesLabel: 'Now Playing', @@ -4475,6 +4495,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
{item.episodeNumber ? `Ep. ${item.episodeNumber}` : '—'} {item.title || Untitled} + {highlightLaneOf(item) === 'second' ? 'Lane B' : highlightLaneOf(item) === 'general' ? 'General' : 'Lane A'} {item.embedUrl ? 'Audio' : 'No audio'}
@@ -4502,6 +4523,15 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())

{activeHighlight.title || 'Untitled Highlight'}{activeHighlight.episodeNumber ? ` · Ep. ${activeHighlight.episodeNumber}` : ''}

+
+ + +

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.

+
updatePodcastLink(activeHighlight.id, 'episodeNumber', e.target.value)} /> diff --git a/src/App.css b/src/App.css index eda367f..c998a43 100644 --- a/src/App.css +++ b/src/App.css @@ -13125,3 +13125,13 @@ .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-group + .highlight-group { margin-top: 2.5rem; } +.highlight-group-title { + margin: 0 0 1rem; + font-size: 1.05rem; + letter-spacing: 0.04em; + color: #c9a84c; + border-bottom: 1px solid rgba(201, 168, 76, 0.22); + padding-bottom: 0.5rem; +} diff --git a/src/App.tsx b/src/App.tsx index 693b6b9..35822bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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' @@ -715,10 +715,49 @@ 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 = ( + <> +
+ {link.episodeNumber && Ep. {link.episodeNumber}} +
+

{link.title || link.url}

+ {link.summary &&

{link.summary}

} + Listen → + + ) + if (!hasRichContent && link.url) { + return {inner} + } + return {inner} +} + +// Highlights are grouped by the series they belong to, so each book being studied gets its own row. function PodcastHighlightsSection({ content }: { content: SiteContent }) { 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 secondTitle = content.secondSeries?.title?.trim() + const groups = [ + { key: 'primary', title: content.seriesTitle, links: links.filter(link => laneOf(link) === 'primary') }, + { key: 'second', title: secondTitle, 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) + // Only label the rows when there is more than one to tell apart + const showGroupTitles = groups.length > 1 + return (
@@ -726,48 +765,14 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) { ✦ Podcast Highlights{' '} ✦ -
- {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 = ( - <> -
- {link.episodeNumber && Ep. {link.episodeNumber}} -
-

{link.title || link.url}

- {link.summary &&

{link.summary}

} - Listen → - - ) - - if ('external' in dest) { - return ( - - {inner} - - ) - } - return ( - - {inner} - - ) - })} -
+ {groups.map(group => ( +
+ {showGroupTitles &&

{group.title}

} +
+ {group.links.map(link => )} +
+
+ ))}
) @@ -1424,7 +1429,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(() => { @@ -1751,6 +1756,14 @@ function FinishedSeriesPage({ content }: { content: SiteContent }) { )}
+ {(book.highlights ?? []).length > 0 && ( + <> +

Episode Highlights

+
+ {[...(book.highlights ?? [])].reverse().map(link => )} +
+ + )}

Episode Playlist

{loading ? (

Loading episodes…

diff --git a/src/content.ts b/src/content.ts index d840b88..a49551e 100644 --- a/src/content.ts +++ b/src/content.ts @@ -68,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 @@ -110,6 +113,8 @@ export interface FinishedBook { 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[] downloadUrl?: string downloadQrUrl?: string } @@ -666,3 +671,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): PodcastFeaturedLink[] { + return [ + ...(content.podcastFeaturedLinks ?? []), + ...(content.finishedBooks ?? []).flatMap(book => book.highlights ?? []), + ] +} diff --git a/src/hooks/useGlobalSearch.ts b/src/hooks/useGlobalSearch.ts index 4942b18..23ca7a3 100644 --- a/src/hooks/useGlobalSearch.ts +++ b/src/hooks/useGlobalSearch.ts @@ -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' @@ -58,7 +59,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 +77,7 @@ export function useGlobalSearch(content: SiteContent) { setEpisodeItems(eps) }) .catch(() => {}) - }, [content.podcastFeaturedLinks]) + }, [content]) // Fetch nuggets episodes once on mount useEffect(() => {