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 })} />
+