From 2eb98b66d0c5ad35c09520c5f218bc5517e2046a Mon Sep 17 00:00:00 2001 From: nmemmert Date: Tue, 30 Jun 2026 10:22:37 -0400 Subject: [PATCH] Add Finished Books feature with episode playlists - New FinishedBook type in content.ts + finishedBooks[] field on SiteContent - RSS parser now extracts itunes:season into each episode object - /finished grid page and /finished/:id playlist page (filters episodes by season) - Episodes nav link replaced with dropdown: Current Series / Finished Books - Finished Books link added to footer - Admin: "End Current Series & Start New Book" wizard on Current Series tab - Admin: Finished Books management tab (add/edit/remove entries) Co-Authored-By: Claude Sonnet 4.6 --- server/routes/episodes.js | 3 +- src/AdminPage.tsx | 552 ++++++++------------------------------ src/App.css | 306 +++++++++++++++++++++ src/App.tsx | 143 +++++++++- src/content.ts | 42 +-- 5 files changed, 576 insertions(+), 470 deletions(-) diff --git a/server/routes/episodes.js b/server/routes/episodes.js index 6430396..d5a01df 100644 --- a/server/routes/episodes.js +++ b/server/routes/episodes.js @@ -29,11 +29,12 @@ function parseRssItems(xml, limit = Infinity) { const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim() const duration = (/([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim() const episode = (/([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim() + const season = (/([\s\S]*?)<\/itunes:season>/.exec(block)?.[1] ?? '').trim() items.push({ title, pubDate, link, audioUrl: enclosureUrl, description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''), - duration, episode, + duration, episode, season, }) } return items diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 76219f9..252fe11 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -4,7 +4,7 @@ import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } import { CSS } from '@dnd-kit/utilities' import type { ChangeEvent } from 'react' import { Link } from 'react-router-dom' -import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content' +import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content' import { DEFAULTS } from './content' import { AnalyticsPanel } from './components/AnalyticsPanel' import { AdminCollapsibleCard } from './components/AdminCollapsibleCard' @@ -692,11 +692,11 @@ interface PodcastChecklistData { episodes: PodcastChecklistEpisode[] } -type StringField = Exclude +type StringField = Exclude type AdminView = | 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact' - | 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' + | 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'downloads' | 'custom-links' | 'content-blocks' | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes' | 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates' @@ -804,7 +804,7 @@ const ADMIN_SECTION_LINKS: Partial> = { ], } -type PodcastTab = 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' | 'episode-scripts' +type PodcastTab = 'current-series' | 'episode-highlights' | 'finished-books' | 'podcast-checklist' | 'episode-scripts' type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates' @@ -1071,6 +1071,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [opsStatus, setOpsStatus] = useState(null) const [assetUploadPending, setAssetUploadPending] = useState(false) + const [endSeriesOpen, setEndSeriesOpen] = useState(false) + const [endSeriesNewTitle, setEndSeriesNewTitle] = useState('') + const [endSeriesNewImage, setEndSeriesNewImage] = useState('') + const [endSeriesNewSeason, setEndSeriesNewSeason] = useState('') + const [questions, setQuestions] = useState([]) const [contactSubmissions, setContactSubmissions] = useState([]) const [contactStatus, setContactStatus] = useState<'loading' | 'ready' | 'error'>('loading') @@ -1142,7 +1147,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { }) const [manualQuestionStatus, setManualQuestionStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [manualQuestionMsg, setManualQuestionMsg] = useState('') - const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({}) const [previewOpen, setPreviewOpen] = useState(false) const previewIframeRef = useRef(null) const dragSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) @@ -2321,184 +2325,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { setForm(f => ({ ...f, customBlocks: (f.customBlocks ?? []).filter(b => b.id !== id) })) } - function addArchivedSeries() { - setForm(f => ({ - ...f, - archivedSeries: [ - ...(f.archivedSeries ?? []), - { - id: Date.now().toString(36), - label: 'Archived Study', - title: '', - description: '', - imageUrl: '', - listenUrl: '', - studyGuideTitle: '', - studyGuideDescription: '', - studyGuideUrl: '', - resourceLinks: [], - notes: [], - }, - ], - })) - } - - function updateArchivedSeries(id: string, field: keyof ArchivedSeries, value: string | ArchivedSeriesResourceLink[] | ArchivedSeriesNote[] | { from: number; to: number } | undefined) { - setForm(f => ({ - ...f, - archivedSeries: (f.archivedSeries ?? []).map(series => series.id === id ? { ...series, [field]: value } : series), - })) - } - - function removeArchivedSeries(id: string) { - setForm(f => ({ ...f, archivedSeries: (f.archivedSeries ?? []).filter(series => series.id !== id) })) - } - - function addArchivedSeriesLink(seriesId: string) { - setForm(f => ({ - ...f, - archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId - ? { - ...series, - resourceLinks: [ - ...(series.resourceLinks ?? []), - { id: `${seriesId}-${Date.now().toString(36)}`, label: '', description: '', url: '', amazonUrl: '', amazonLabel: '' }, - ], - } - : series), - })) - } - - function updateArchivedSeriesLink(seriesId: string, linkId: string, field: keyof ArchivedSeriesResourceLink, value: string) { - setForm(f => ({ - ...f, - archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId - ? { - ...series, - resourceLinks: (series.resourceLinks ?? []).map(link => link.id === linkId ? { ...link, [field]: value } : link), - } - : series), - })) - } - - function removeArchivedSeriesLink(seriesId: string, linkId: string) { - setForm(f => ({ - ...f, - archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId - ? { ...series, resourceLinks: (series.resourceLinks ?? []).filter(link => link.id !== linkId) } - : series), - })) - } - - function addExistingCustomLinkToArchivedSeries(seriesId: string) { - const selectedLinkId = archiveLinkSelectionBySeries[seriesId] - if (!selectedLinkId) return - - const source = (form.customLinks ?? []).find(link => link.id === selectedLinkId) - if (!source) return - - setForm(f => ({ - ...f, - archivedSeries: (f.archivedSeries ?? []).map(series => { - if (series.id !== seriesId) return series - - const alreadyExists = (series.resourceLinks ?? []).some(link => - link.url.trim().toLowerCase() === source.url.trim().toLowerCase(), - ) - - if (alreadyExists) return series - - return { - ...series, - resourceLinks: [ - ...(series.resourceLinks ?? []), - { - id: `${seriesId}-${Date.now().toString(36)}`, - label: source.label, - description: source.description ?? '', - url: source.url, - amazonUrl: source.amazonUrl ?? '', - amazonLabel: source.amazonLabel ?? '', - }, - ], - } - }), - })) - } - - function addAllExistingCustomLinksToArchivedSeries(seriesId: string) { - setForm(f => ({ - ...f, - archivedSeries: (f.archivedSeries ?? []).map(series => { - if (series.id !== seriesId) return series - - const existingUrls = new Set( - (series.resourceLinks ?? []) - .map(link => link.url.trim().toLowerCase()) - .filter(Boolean), - ) - - const toAdd = (f.customLinks ?? []) - .filter(link => link.url.trim().length > 0) - .filter(link => !existingUrls.has(link.url.trim().toLowerCase())) - .map(link => ({ - id: `${seriesId}-${Date.now().toString(36)}-${link.id}`, - label: link.label, - description: link.description ?? '', - url: link.url, - amazonUrl: link.amazonUrl ?? '', - amazonLabel: link.amazonLabel ?? '', - })) - - if (toAdd.length === 0) return series - - return { - ...series, - resourceLinks: [ - ...(series.resourceLinks ?? []), - ...toAdd, - ], - } - }), - })) - } - - function addArchivedSeriesNote(seriesId: string) { - setForm(f => ({ - ...f, - archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId - ? { - ...series, - notes: [ - ...(series.notes ?? []), - { id: `${seriesId}-note-${Date.now().toString(36)}`, heading: '', body: '' }, - ], - } - : series), - })) - } - - function updateArchivedSeriesNote(seriesId: string, noteId: string, field: keyof ArchivedSeriesNote, value: string) { - setForm(f => ({ - ...f, - archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId - ? { - ...series, - notes: (series.notes ?? []).map(note => note.id === noteId ? { ...note, [field]: value } : note), - } - : series), - })) - } - - function removeArchivedSeriesNote(seriesId: string, noteId: string) { - setForm(f => ({ - ...f, - archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId - ? { ...series, notes: (series.notes ?? []).filter(note => note.id !== noteId) } - : series), - })) - } - function addStudyProgram() { setForm(f => ({ ...f, @@ -2587,59 +2413,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { })) } - function archiveCurrentSeriesSnapshot() { - const currentTitle = form.seriesTitle.trim() - if (!currentTitle) { - alert('Set a current series title first, then archive it.') - return - } - - const existing = (form.archivedSeries ?? []).some( - series => series.title.trim().toLowerCase() === currentTitle.toLowerCase(), - ) - - if (existing && !confirm(`An archived series named "${currentTitle}" already exists. Create another snapshot anyway?`)) { - return - } - - const resourceLinks = (form.customLinks ?? []) - .filter(link => link.placement === 'resources') - .filter(link => link.label.trim().length > 0 || link.url.trim().length > 0) - .map(link => ({ - id: `archive-link-${Date.now().toString(36)}-${link.id}`, - label: link.label, - url: link.url, - })) - - const notes = (form.customBlocks ?? []) - .filter(block => block.heading.trim().length > 0 || block.body.trim().length > 0) - .map(block => ({ - id: `archive-note-${Date.now().toString(36)}-${block.id}`, - heading: block.heading, - body: block.body, - })) - - const archived: ArchivedSeries = { - id: `archive-${Date.now().toString(36)}`, - label: form.seriesLabel?.trim() || 'Archived Study', - title: form.seriesTitle, - description: form.seriesDescription, - imageUrl: form.seriesImageUrl, - listenUrl: form.seriesListenUrl, - studyGuideTitle: form.studyGuideTitle, - studyGuideDescription: form.studyGuideDescription, - studyGuideUrl: form.studyGuideUrl, - resourceLinks, - notes, - } - - setForm(f => ({ - ...f, - archivedSeries: [archived, ...(f.archivedSeries ?? [])], - })) - navigateTo('archived-series') - } - async function handleAnswerQuestion(questionId: string, answer: string) { if (!answer.trim()) return try { @@ -3045,9 +2818,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { } const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources') - const archivedResourceCount = (form.archivedSeries ?? []).reduce((count, series) => { - return count + (series.resourceLinks ?? []).length - }, 0) const filteredAdminQuestions = questions.filter(question => { const search = questionSearch.trim().toLowerCase() @@ -3881,8 +3651,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
+ -
@@ -3911,6 +3681,64 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { ))}

Companion study guide title, description, and Amazon link are now managed in Downloads.

{renderSaveStatus()} +
+
+ {!endSeriesOpen ? ( + + ) : ( +
+

Archive “{form.seriesTitle}” to Finished Books

+

+ This will save the current series as a finished book and clear Episode Highlights. Fill in the new series details below, then confirm. +

+
+ + setEndSeriesNewTitle(e.target.value)} /> +
+
+ + setEndSeriesNewImage(e.target.value)} /> +
+
+ + setEndSeriesNewSeason(e.target.value)} /> +
+
+ + +
+
+ )} +
)} @@ -3978,6 +3806,53 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { )} + {/* FINISHED BOOKS */} + {(adminView === 'podcast' && podcastTab === 'finished-books') && ( +
+
+

Finished Books

+

Series that have been completed. Each one gets a playlist page at /finished/:id.

+
+ {(form.finishedBooks ?? []).length === 0 && ( +

No finished books yet. Use “End Current Series” on the Current Series tab to archive one, or add one manually below.

+ )} + {(form.finishedBooks ?? []).map(book => ( + +
+
+
+ + setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, title: e.target.value } : b) }))} /> +
+
+ +