One companion study guide per series; v2.9.8
Publish Container / build (push) Successful in 21m12s

- 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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-08 16:00:52 -04:00
1 parent 39691aedb3
commit e2150e1cd7
7 files changed
+244 -4

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.9.7",
"version": "2.9.8",
"type": "module",
"scripts": {
"dev": "vite",
+20
View File
@@ -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),
+23
View File
@@ -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({
+12
View File
@@ -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)
+116 -2
View File
@@ -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<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() {
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())
<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 &amp; 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 &amp; 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' }}>
@@ -4499,6 +4541,24 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
{(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&apos; guide moves onto its Finished Book at launch. Fill these in to publish the new series&apos; 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 && (
@@ -4732,6 +4792,23 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<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&apos;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) }))} />
@@ -4813,6 +4890,43 @@ 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 */}
+56 -1
View File
@@ -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')
@@ -824,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">
@@ -852,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 && (
@@ -1768,6 +1818,11 @@ function FinishedSeriesPage({ content }: { content: SiteContent }) {
📖 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">
+16
View File
@@ -94,6 +94,17 @@ 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
@@ -103,6 +114,7 @@ export interface SecondSeries {
listenBtnLabel: string
studySlug?: string
season?: number
guide?: StudyGuide
}
export interface FinishedBook {
@@ -115,6 +127,8 @@ export interface FinishedBook {
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
}
@@ -128,6 +142,8 @@ export interface ScheduledSeriesTransition {
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