From e2150e1cd73f94e4377a5290ad38e913e28f2ba2 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 8 Oct 2026 16:00:52 -0400 Subject: [PATCH] One companion study guide per series; v2.9.8 - Second series gets its own guide (Resources page card, download page, admin fields) - When a series ends (scheduled or manual) its guide moves onto its Finished Book, which shows a guide button; the main series' guide is cleared unless the launch supplies a new one - Lane B launch schedule can supply the incoming series' guide - Download lookup covers second-series and finished-book guides; guide URLs are sanitized on save and on use Co-Authored-By: Claude Sonnet 5.5 --- package.json | 2 +- server/helpers.js | 20 ++++++ server/routes/admin-content.js | 23 +++++++ server/routes/downloads.js | 12 ++++ src/AdminPage.tsx | 118 ++++++++++++++++++++++++++++++++- src/App.tsx | 57 +++++++++++++++- src/content.ts | 16 +++++ 7 files changed, 244 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 10d463e..b4c1556 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.9.7", + "version": "2.9.8", "type": "module", "scripts": { "dev": "vite", diff --git a/server/helpers.js b/server/helpers.js index e8dd7b0..abab79f 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -297,6 +297,22 @@ function sanitizeIsoDate(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) : '') @@ -310,6 +326,7 @@ function sanitizeSecondSeries(value) { 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) } : {}), } } @@ -410,6 +427,9 @@ export function sanitizeSiteContent(siteContent) { 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), diff --git a/server/routes/admin-content.js b/server/routes/admin-content.js index 05f6ff9..659ffe8 100644 --- a/server/routes/admin-content.js +++ b/server/routes/admin-content.js @@ -66,11 +66,24 @@ function readCurrentSeries(content, lane) { 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, } } @@ -96,6 +109,7 @@ function applySeriesTransition(content, sched, lane = 'primary') { 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 @@ -142,6 +156,8 @@ function applySeriesTransition(content, sched, lane = 'primary') { 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 } : {}), }, } @@ -152,6 +168,13 @@ function applySeriesTransition(content, sched, lane = 'primary') { 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({ diff --git a/server/routes/downloads.js b/server/routes/downloads.js index e04fc8a..7cea3a1 100644 --- a/server/routes/downloads.js +++ b/server/routes/downloads.js @@ -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) diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 134b42b..e7d940e 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -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, SecondSeries } 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' @@ -2650,6 +2650,26 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) return renderTabs(view, links[0].id, links) } + // 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) { + 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) { + setForm(f => ({ + ...f, + finishedBooks: (f.finishedBooks ?? []).map(b => b.id === bookId ? { ...b, guide: { title: '', ...(b.guide ?? {}), ...patch } } : b), + })) + } + function addLink() { setForm(f => ({ ...f, @@ -4217,6 +4237,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) imageUrl: cur.imageUrl, season: cur.season ?? 1, ...(cur.studySlug ? { studySlug: cur.studySlug } : {}), + ...(cur.guide?.title ? { guide: cur.guide } : {}), ...(endingHighlights.length > 0 ? { highlights: endingHighlights } : {}), } return { @@ -4273,6 +4294,19 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) imageUrl: form.seriesImageUrl, 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 => ({ @@ -4280,6 +4314,14 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) 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: keptHighlights, seriesTitle: endSeriesNewTitle.trim(), @@ -4310,7 +4352,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())

Schedule Auto Series Launch

- 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).

{sched?.triggersAt && (
@@ -4499,6 +4541,24 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) {(form.studies ?? []).map(st => )}
+ +

Companion Study Guide (optional)

+

The ending series' guide moves onto its Finished Book at launch. Fill these in to publish the new series' guide at the same time.

+ {(() => { + const g = sched?.newGuide + const patchGuide = (p: Partial) => patch({ newGuide: { title: '', ...(g ?? {}), ...p } }) + return ( + <> +
patchGuide({ title: e.target.value })} />
+