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 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-06-30 10:22:37 -04:00
parent a8f6024234
commit 2eb98b66d0
5 changed files with 576 additions and 470 deletions
+2 -1
View File
@@ -29,11 +29,12 @@ function parseRssItems(xml, limit = Infinity) {
const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim() const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim() const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim()
const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim() const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim()
const season = (/<itunes:season>([\s\S]*?)<\/itunes:season>/.exec(block)?.[1] ?? '').trim()
items.push({ items.push({
title, pubDate, link, title, pubDate, link,
audioUrl: enclosureUrl, audioUrl: enclosureUrl,
description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''), description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''),
duration, episode, duration, episode, season,
}) })
} }
return items return items
+115 -437
View File
@@ -4,7 +4,7 @@ import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable }
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import type { ChangeEvent } from 'react' import type { ChangeEvent } from 'react'
import { Link } from 'react-router-dom' 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 { DEFAULTS } from './content'
import { AnalyticsPanel } from './components/AnalyticsPanel' import { AnalyticsPanel } from './components/AnalyticsPanel'
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard' import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
@@ -692,11 +692,11 @@ interface PodcastChecklistData {
episodes: PodcastChecklistEpisode[] episodes: PodcastChecklistEpisode[]
} }
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'> type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'redirects' | 'podcastFeaturedLinks' | 'finishedBooks' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
type AdminView = type AdminView =
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact' | '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' | 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes' | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates' | 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
@@ -804,7 +804,7 @@ const ADMIN_SECTION_LINKS: Partial<Record<AdminView, AdminSectionLink[]>> = {
], ],
} }
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' 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<OpsStatus | null>(null) const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
const [assetUploadPending, setAssetUploadPending] = useState(false) 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<Question[]>([]) const [questions, setQuestions] = useState<Question[]>([])
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([]) const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
const [contactStatus, setContactStatus] = useState<'loading' | 'ready' | 'error'>('loading') 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 [manualQuestionStatus, setManualQuestionStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [manualQuestionMsg, setManualQuestionMsg] = useState('') const [manualQuestionMsg, setManualQuestionMsg] = useState('')
const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({})
const [previewOpen, setPreviewOpen] = useState(false) const [previewOpen, setPreviewOpen] = useState(false)
const previewIframeRef = useRef<HTMLIFrameElement>(null) const previewIframeRef = useRef<HTMLIFrameElement>(null)
const dragSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) 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) })) 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() { function addStudyProgram() {
setForm(f => ({ setForm(f => ({
...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) { async function handleAnswerQuestion(questionId: string, answer: string) {
if (!answer.trim()) return if (!answer.trim()) return
try { try {
@@ -3045,9 +2818,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
} }
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources') 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 filteredAdminQuestions = questions.filter(question => {
const search = questionSearch.trim().toLowerCase() const search = questionSearch.trim().toLowerCase()
@@ -3881,8 +3651,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<div className="admin-tabs admin-tabs--podcast"> <div className="admin-tabs admin-tabs--podcast">
<button type="button" className={`admin-tab${podcastTab === 'current-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('current-series')}>Current Series</button> <button type="button" className={`admin-tab${podcastTab === 'current-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('current-series')}>Current Series</button>
<button type="button" className={`admin-tab${podcastTab === 'episode-highlights' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-highlights')}>Ep. Highlights</button> <button type="button" className={`admin-tab${podcastTab === 'episode-highlights' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-highlights')}>Ep. Highlights</button>
<button type="button" className={`admin-tab${podcastTab === 'finished-books' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('finished-books')}>Finished Books</button>
<button type="button" className={`admin-tab${podcastTab === 'podcast-checklist' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('podcast-checklist')}>Production Checklist</button> <button type="button" className={`admin-tab${podcastTab === 'podcast-checklist' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('podcast-checklist')}>Production Checklist</button>
<button type="button" className={`admin-tab${podcastTab === 'archived-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('archived-series')}>Archived Series</button>
<button type="button" className={`admin-tab${podcastTab === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button> <button type="button" className={`admin-tab${podcastTab === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button>
</div> </div>
</section> </section>
@@ -3911,6 +3681,64 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
))} ))}
<p className="admin-stats-note">Companion study guide title, description, and Amazon link are now managed in Downloads.</p> <p className="admin-stats-note">Companion study guide title, description, and Amazon link are now managed in Downloads.</p>
{renderSaveStatus()} {renderSaveStatus()}
<div className="admin-end-series-wrap">
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', margin: '2rem 0 1.5rem' }} />
{!endSeriesOpen ? (
<button type="button" className="btn-danger-outline" onClick={() => setEndSeriesOpen(true)}>
End Current Series &amp; Start New Book
</button>
) : (
<div className="admin-end-series-panel">
<h3 style={{ marginBottom: '0.5rem' }}>Archive &ldquo;{form.seriesTitle}&rdquo; to Finished Books</h3>
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
This will save the current series as a finished book and clear Episode Highlights. Fill in the new series details below, then confirm.
</p>
<div className="admin-field">
<label>New Series Title</label>
<input type="text" placeholder="e.g. Study of Romans" value={endSeriesNewTitle} onChange={e => setEndSeriesNewTitle(e.target.value)} />
</div>
<div className="admin-field">
<label>New Series Image URL</label>
<input type="text" placeholder="/images/romans-cover.png" value={endSeriesNewImage} onChange={e => setEndSeriesNewImage(e.target.value)} />
</div>
<div className="admin-field">
<label>New Season Number</label>
<input type="number" placeholder="2" value={endSeriesNewSeason} onChange={e => setEndSeriesNewSeason(e.target.value)} />
</div>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
<button
type="button"
className="btn-primary"
disabled={!endSeriesNewTitle.trim() || !endSeriesNewSeason.trim()}
onClick={() => {
const archivedBook = {
id: Date.now().toString(36),
title: form.seriesTitle,
description: form.seriesDescription,
imageUrl: form.seriesImageUrl,
season: parseInt(endSeriesNewSeason) - 1 || 1,
}
setForm(f => ({
...f,
finishedBooks: [...(f.finishedBooks ?? []), archivedBook],
podcastFeaturedLinks: [],
seriesTitle: endSeriesNewTitle.trim(),
seriesImageUrl: endSeriesNewImage.trim() || f.seriesImageUrl,
seriesLabel: 'Now Playing',
}))
setEndSeriesOpen(false)
setEndSeriesNewTitle('')
setEndSeriesNewImage('')
setEndSeriesNewSeason('')
}}
>
Confirm &amp; Archive
</button>
<button type="button" className="btn-secondary" onClick={() => setEndSeriesOpen(false)}>Cancel</button>
</div>
</div>
)}
</div>
</section> </section>
)} )}
@@ -3978,6 +3806,53 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section> </section>
)} )}
{/* FINISHED BOOKS */}
{(adminView === 'podcast' && podcastTab === 'finished-books') && (
<section className="admin-panel-section" aria-label="Finished books">
<div className="admin-panel-head">
<h2>Finished Books</h2>
<p>Series that have been completed. Each one gets a playlist page at <code>/finished/:id</code>.</p>
</div>
{(form.finishedBooks ?? []).length === 0 && (
<p className="admin-stats-note">No finished books yet. Use &ldquo;End Current Series&rdquo; on the Current Series tab to archive one, or add one manually below.</p>
)}
{(form.finishedBooks ?? []).map(book => (
<AdminCollapsibleCard key={book.id} title={book.title || 'Untitled'} subtitle={`Season ${book.season}`}>
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label>Title</label>
<input type="text" value={book.title} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, title: e.target.value } : b) }))} />
</div>
<div className="admin-field">
<label>Description</label>
<textarea rows={3} value={book.description} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, description: e.target.value } : b) }))} />
</div>
<div className="admin-field">
<label>Cover Image URL</label>
<input type="text" value={book.imageUrl} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, imageUrl: e.target.value } : b) }))} />
{renderImagePreview(book.imageUrl, `${book.title} cover`)}
</div>
<div className="admin-field">
<label>Season Number</label>
<input type="number" value={book.season} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, season: parseInt(e.target.value) || 1 } : b) }))} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).filter(b => b.id !== book.id) }))}>Remove</button>
</div>
</AdminCollapsibleCard>
))}
<button
type="button"
className="btn-admin-add"
onClick={() => setForm(f => ({ ...f, finishedBooks: [...(f.finishedBooks ?? []), { id: Date.now().toString(36), title: '', description: '', imageUrl: '', season: 1 }] }))}
>
+ Add Finished Book
</button>
{renderSaveStatus()}
</section>
)}
{/* PODCAST CHECKLIST */} {/* PODCAST CHECKLIST */}
{(adminView === 'podcast-checklist' || (adminView === 'podcast' && podcastTab === 'podcast-checklist')) && ( {(adminView === 'podcast-checklist' || (adminView === 'podcast' && podcastTab === 'podcast-checklist')) && (
<section className="admin-panel-section" aria-label="Podcast production checklist"> <section className="admin-panel-section" aria-label="Podcast production checklist">
@@ -4178,143 +4053,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section> </section>
)} )}
{/* ARCHIVED SERIES */}
{(adminView === 'archived-series' || (adminView === 'podcast' && podcastTab === 'archived-series')) && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Archived Series</h2>
<p>Move finished studies here so users can still access old resources after you switch the current series.</p>
</div>
<div className="admin-archive-helper">
<h3>Archive Current Series</h3>
<p>Use this when you move from one study to the next. It creates a pre-filled archived entry from the current series, study guide, custom resource links, and custom content blocks.</p>
<button type="button" className="btn-admin-add" onClick={archiveCurrentSeriesSnapshot}>+ Archive Current Series Snapshot</button>
</div>
{(form.archivedSeries ?? []).length === 0 && (
<p className="admin-stats-note">No archived series yet.</p>
)}
{(form.archivedSeries ?? []).map(series => (
<div key={series.id} className="admin-archive-card">
<div className="admin-archive-card-head">
<div>
<h4>{series.title || 'Untitled archived series'}</h4>
<p>{series.label || 'Archived Study'}</p>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeries(series.id)}>Remove Series</button>
</div>
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`archive-label-${series.id}`}>Label</label>
<input id={`archive-label-${series.id}`} type="text" value={series.label} placeholder="Archived Study" onChange={e => updateArchivedSeries(series.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-title-${series.id}`}>Series Title</label>
<input id={`archive-title-${series.id}`} type="text" value={series.title} placeholder="Study of Titus: Sound Doctrine" onChange={e => updateArchivedSeries(series.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-description-${series.id}`}>Description</label>
<textarea id={`archive-description-${series.id}`} value={series.description} rows={4} placeholder="Describe the archived study and why it still matters." onChange={e => updateArchivedSeries(series.id, 'description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-image-${series.id}`}>Cover Image URL</label>
<input id={`archive-image-${series.id}`} type="text" value={series.imageUrl} placeholder="/images/titus-cover.png" onChange={e => updateArchivedSeries(series.id, 'imageUrl', e.target.value)} />
{renderImageAssetSelector(series.imageUrl, value => updateArchivedSeries(series.id, 'imageUrl', value), `archive-image-${series.id}-asset`)}
</div>
<div className="admin-field">
<label htmlFor={`archive-listen-${series.id}`}>Listen URL</label>
<input id={`archive-listen-${series.id}`} type="url" value={series.listenUrl} placeholder="https://..." onChange={e => updateArchivedSeries(series.id, 'listenUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-guide-title-${series.id}`}>Study Guide Title</label>
<input id={`archive-guide-title-${series.id}`} type="text" value={series.studyGuideTitle} placeholder="Companion Study Guide" onChange={e => updateArchivedSeries(series.id, 'studyGuideTitle', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-guide-description-${series.id}`}>Study Guide Description</label>
<textarea id={`archive-guide-description-${series.id}`} value={series.studyGuideDescription} rows={3} placeholder="Describe the archived guide or workbook." onChange={e => updateArchivedSeries(series.id, 'studyGuideDescription', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-guide-url-${series.id}`}>Study Guide URL</label>
<input id={`archive-guide-url-${series.id}`} type="url" value={series.studyGuideUrl} placeholder="https://..." onChange={e => updateArchivedSeries(series.id, 'studyGuideUrl', e.target.value)} />
</div>
<div className="admin-field">
<label>Episode Range (for archive grouping on Episodes page)</label>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input type="number" value={series.episodeRange?.from ?? ''} placeholder="First ep #" min={1} style={{ width: '7rem' }} onChange={e => { const from = parseInt(e.target.value, 10); updateArchivedSeries(series.id, 'episodeRange', { from: isNaN(from) ? 0 : from, to: series.episodeRange?.to ?? 0 }) }} />
<span style={{ color: 'var(--brand-gold)' }}>to</span>
<input type="number" value={series.episodeRange?.to ?? ''} placeholder="Last ep #" min={1} style={{ width: '7rem' }} onChange={e => { const to = parseInt(e.target.value, 10); updateArchivedSeries(series.id, 'episodeRange', { from: series.episodeRange?.from ?? 0, to: isNaN(to) ? 0 : to }) }} />
</div>
<p className="admin-stats-note" style={{ marginTop: '0.35rem' }}>Episodes in this range will be grouped under this series on the public Episodes page.</p>
</div>
</div>
<div className="admin-archive-subsection">
<div className="admin-archive-subsection-head">
<h5>Archived Resource Links</h5>
<div className="admin-archive-subsection-actions">
<select value={archiveLinkSelectionBySeries[series.id] ?? ''} onChange={e => setArchiveLinkSelectionBySeries(prev => ({ ...prev, [series.id]: e.target.value }))}>
<option value="">Pick existing custom link</option>
{(form.customLinks ?? []).filter(link => link.url.trim().length > 0).map(link => (
<option key={`pick-${series.id}-${link.id}`} value={link.id}>{link.label || link.url}</option>
))}
</select>
<button type="button" className="btn-admin-add" onClick={() => addExistingCustomLinkToArchivedSeries(series.id)} disabled={!archiveLinkSelectionBySeries[series.id]}>+ Add Picked Link</button>
<button type="button" className="btn-admin-add" onClick={() => addAllExistingCustomLinksToArchivedSeries(series.id)} disabled={(form.customLinks ?? []).filter(link => link.url.trim().length > 0).length === 0}>+ Add All Custom Links</button>
<button type="button" className="btn-admin-add" onClick={() => addArchivedSeriesLink(series.id)}>+ Add Blank Link</button>
</div>
</div>
{(series.resourceLinks ?? []).length === 0 && <p className="admin-stats-note">No archived resource links yet.</p>}
{(series.resourceLinks ?? []).map(link => (
<div key={link.id} className="admin-array-row admin-array-row--nested">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`archive-link-label-${link.id}`}>Label</label>
<input id={`archive-link-label-${link.id}`} type="text" value={link.label} placeholder="Episode guide" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-link-url-${link.id}`}>URL</label>
<input id={`archive-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'url', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-link-amazon-url-${link.id}`}>Amazon URL (optional)</label>
<input id={`archive-link-amazon-url-${link.id}`} type="url" value={link.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-link-amazon-label-${link.id}`}>Amazon Button Label</label>
<input id={`archive-link-amazon-label-${link.id}`} type="text" value={link.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonLabel', e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeriesLink(series.id, link.id)}>Remove</button>
</div>
))}
</div>
<div className="admin-archive-subsection">
<div className="admin-archive-subsection-head">
<h5>Archived Notes / Blocks</h5>
<button type="button" className="btn-admin-add" onClick={() => addArchivedSeriesNote(series.id)}>+ Add Note Block</button>
</div>
{(series.notes ?? []).length === 0 && <p className="admin-stats-note">No archived note blocks yet.</p>}
{(series.notes ?? []).map(note => (
<div key={note.id} className="admin-array-row admin-array-row--nested">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`archive-note-heading-${note.id}`}>Heading</label>
<input id={`archive-note-heading-${note.id}`} type="text" value={note.heading} placeholder="Titus overview" onChange={e => updateArchivedSeriesNote(series.id, note.id, 'heading', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`archive-note-body-${note.id}`}>Body</label>
<textarea id={`archive-note-body-${note.id}`} value={note.body} rows={3} placeholder="Add archived notes, explanation, or links context." onChange={e => updateArchivedSeriesNote(series.id, note.id, 'body', e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeriesNote(series.id, note.id)}>Remove</button>
</div>
))}
</div>
</div>
))}
<button type="button" className="btn-admin-add" onClick={addArchivedSeries}>+ Add Archived Series</button>
{renderSaveStatus()}
</section>
)}
{/* DOWNLOADS */} {/* DOWNLOADS */}
{adminView === 'downloads' && ( {adminView === 'downloads' && (
<section className="admin-panel-section"> <section className="admin-panel-section">
@@ -4328,8 +4066,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<div className="admin-content-summary"> <div className="admin-content-summary">
<div className="admin-summary-card"><h3>Companion Study Guide</h3><p>Included</p></div> <div className="admin-summary-card"><h3>Companion Study Guide</h3><p>Included</p></div>
<div className="admin-summary-card"><h3>Current Downloads</h3><p>{resourceLinks.length}</p></div> <div className="admin-summary-card"><h3>Current Downloads</h3><p>{resourceLinks.length}</p></div>
<div className="admin-summary-card"><h3>Previous Study Downloads</h3><p>{archivedResourceCount}</p></div>
<div className="admin-summary-card"><h3>Total Library Items</h3><p>{resourceLinks.length + archivedResourceCount}</p></div>
</div> </div>
<div className="admin-section-header" id="downloads-study-guide"> <div className="admin-section-header" id="downloads-study-guide">
@@ -4412,64 +4148,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
))} ))}
<button type="button" className="btn-admin-add" onClick={addResource}>+ Add Download</button> <button type="button" className="btn-admin-add" onClick={addResource}>+ Add Download</button>
<div className="admin-section-header" id="downloads-previous-studies">
<h3>Previous Study Downloads</h3>
<p>These downloads appear in the Previous Studies area of the download library.</p>
</div>
{(form.archivedSeries ?? []).length === 0 && (
<p className="admin-stats-note">No archived series yet. Add one in Archived Series, then manage its downloads here.</p>
)}
{(form.archivedSeries ?? []).map(series => (
<AdminCollapsibleCard
key={series.id}
title={series.title || 'Untitled archived series'}
subtitle={`${(series.resourceLinks ?? []).length} download${(series.resourceLinks ?? []).length === 1 ? '' : 's'}`}
hint="Expand to manage"
className="admin-collapsible-card--group"
>
<div className="admin-archive-subsection">
<div className="admin-archive-subsection-head">
<h5>{series.title || 'Untitled archived series'}</h5>
<button type="button" className="btn-admin-add" onClick={() => addArchivedSeriesLink(series.id)}>+ Add Link</button>
</div>
{(series.resourceLinks ?? []).length === 0 && <p className="admin-stats-note">No archived resource links yet.</p>}
{(series.resourceLinks ?? []).map(link => (
<AdminCollapsibleCard
key={link.id}
title={link.label || 'Untitled archived download'}
subtitle={link.description || 'Previous study download'}
className="admin-collapsible-card--nested"
>
<div className="admin-array-row admin-array-row--nested">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`resources-archive-link-label-${link.id}`}>Label</label>
<input id={`resources-archive-link-label-${link.id}`} type="text" value={link.label} placeholder="Episode guide" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'label', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resources-archive-link-url-${link.id}`}>URL</label>
<input id={`resources-archive-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'url', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resources-archive-link-description-${link.id}`}>Description</label>
<textarea id={`resources-archive-link-description-${link.id}`} rows={3} value={link.description ?? ''} placeholder="Description shown on the download page" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resources-archive-link-amazon-url-${link.id}`}>Amazon URL (optional)</label>
<input id={`resources-archive-link-amazon-url-${link.id}`} type="url" value={link.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`resources-archive-link-amazon-label-${link.id}`}>Amazon Button Label</label>
<input id={`resources-archive-link-amazon-label-${link.id}`} type="text" value={link.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonLabel', e.target.value)} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeriesLink(series.id, link.id)}>Remove</button>
</div>
</AdminCollapsibleCard>
))}
</div>
</AdminCollapsibleCard>
))}
{renderSaveStatus()} {renderSaveStatus()}
</section> </section>
)} )}
+306
View File
@@ -8687,3 +8687,309 @@
color: #8a7f5a; color: #8a7f5a;
font-weight: 600; font-weight: 600;
} }
/* ── Nav Dropdown (Episodes) ── */
.nav-dropdown {
position: relative;
}
.nav-dropdown-trigger {
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
gap: 0.25rem;
}
.nav-dropdown-arrow {
font-size: 0.7em;
opacity: 0.7;
transition: transform 200ms;
}
.nav-dropdown-menu {
display: none;
position: absolute;
top: calc(100% + 0.5rem);
left: 50%;
transform: translateX(-50%);
background: rgba(15, 15, 12, 0.98);
border: 1px solid rgba(201, 168, 76, 0.25);
border-radius: 8px;
padding: 0.4rem 0;
min-width: 160px;
z-index: 200;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
.nav-dropdown:hover .nav-dropdown-menu,
.nav-dropdown:focus-within .nav-dropdown-menu {
display: block;
}
.nav-dropdown-item {
display: block;
padding: 0.55rem 1.1rem;
color: var(--brand-muted);
text-decoration: none;
font-family: var(--brand-font-body);
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
font-size: 0.88rem;
transition: color 200ms, background 200ms;
white-space: nowrap;
}
.nav-dropdown-item:hover,
.nav-dropdown-item--active {
color: var(--brand-gold);
background: rgba(201, 168, 76, 0.07);
}
/* ── Finished Books & Series Pages ── */
.finished-books-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
}
.finished-book-card {
background: rgba(26, 26, 21, 0.9);
border: 1px solid rgba(201, 168, 76, 0.18);
border-radius: 12px;
overflow: hidden;
text-decoration: none;
color: inherit;
transition: border-color 200ms, transform 200ms;
display: flex;
flex-direction: column;
}
.finished-book-card:hover {
border-color: rgba(201, 168, 76, 0.5);
transform: translateY(-2px);
}
.finished-book-card-img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
}
.finished-book-card-body {
padding: 1.1rem 1.2rem 1.4rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
flex: 1;
}
.finished-book-card-season {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--brand-gold);
opacity: 0.8;
margin: 0;
}
.finished-book-card-title {
font-family: var(--brand-font-heading);
font-size: 1.25rem;
color: #e8d9b5;
margin: 0;
}
.finished-book-card-desc {
font-size: 0.9rem;
color: var(--brand-muted);
line-height: 1.5;
margin: 0.2rem 0 0;
}
.finished-book-card-cta {
display: block;
margin-top: 0.8rem;
font-size: 0.85rem;
color: var(--brand-gold);
letter-spacing: 0.05em;
}
.finished-books-empty {
color: var(--brand-muted);
margin-top: 2rem;
}
.finished-series-hero {
display: flex;
gap: 2rem;
align-items: flex-start;
margin-bottom: 2.5rem;
}
.finished-series-hero-img {
width: 160px;
height: 160px;
object-fit: cover;
border-radius: 10px;
flex-shrink: 0;
}
.finished-series-title {
font-family: var(--brand-font-heading);
font-size: 2rem;
color: #e8d9b5;
margin: 0.25rem 0 0.5rem;
}
.finished-series-desc {
color: var(--brand-muted);
line-height: 1.6;
margin: 0;
}
.finished-series-playlist-heading {
font-family: var(--brand-font-heading);
font-size: 1.3rem;
color: #e8d9b5;
border-bottom: 1px solid rgba(201, 168, 76, 0.2);
padding-bottom: 0.5rem;
margin-bottom: 1.25rem;
}
.finished-series-playlist {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.finished-series-episode {
display: flex;
gap: 1rem;
align-items: flex-start;
padding: 1.1rem 1.2rem;
background: rgba(26, 26, 21, 0.7);
border: 1px solid rgba(201, 168, 76, 0.14);
border-radius: 10px;
}
.finished-series-ep-num {
font-size: 0.85rem;
font-weight: 700;
color: var(--brand-gold);
min-width: 2rem;
text-align: center;
padding-top: 0.15rem;
opacity: 0.8;
}
.finished-series-ep-body {
flex: 1;
min-width: 0;
}
.finished-series-ep-title {
font-weight: 600;
color: #d4c49a;
margin: 0 0 0.2rem;
font-size: 0.97rem;
}
.finished-series-ep-meta {
font-size: 0.8rem;
color: var(--brand-muted);
margin: 0 0 0.6rem;
}
.breadcrumb {
font-size: 0.85rem;
color: var(--brand-muted);
margin-bottom: 1.5rem;
}
.breadcrumb a {
color: var(--brand-gold);
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
/* ── Admin: End Series panel ── */
.btn-danger-outline {
background: none;
border: 1px solid rgba(200, 80, 60, 0.5);
color: #e87070;
border-radius: 6px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
cursor: pointer;
font-family: var(--brand-font-body);
transition: background 200ms, border-color 200ms;
}
.btn-danger-outline:hover {
background: rgba(200, 80, 60, 0.08);
border-color: rgba(200, 80, 60, 0.8);
}
.admin-end-series-panel {
background: rgba(20, 15, 10, 0.6);
border: 1px solid rgba(200, 80, 60, 0.25);
border-radius: 10px;
padding: 1.5rem;
}
@media (max-width: 540px) {
.header-nav--open {
max-height: 660px;
}
.nav-dropdown-trigger {
width: 100%;
display: flex;
justify-content: space-between;
padding: 0.8rem 0.25rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.12);
}
.nav-dropdown-menu {
display: none;
position: static;
transform: none;
background: transparent;
border: none;
border-radius: 0;
padding: 0;
box-shadow: none;
min-width: 0;
}
.nav-dropdown--open .nav-dropdown-menu {
display: block;
}
.nav-dropdown-item {
padding: 0.65rem 0.25rem 0.65rem 1.5rem;
font-size: 0.88rem;
border-bottom: 1px solid rgba(201, 168, 76, 0.08);
}
.finished-series-hero {
flex-direction: column;
gap: 1rem;
}
.finished-series-hero-img {
width: 120px;
height: 120px;
}
}
+142 -1
View File
@@ -354,7 +354,10 @@ function SiteSearchBar({ content }: { content: SiteContent }) {
function SiteHeader({ content }: { content: SiteContent }) { function SiteHeader({ content }: { content: SiteContent }) {
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [episodesOpen, setEpisodesOpen] = useState(false)
const spotifyUrl = content.platformSpotifyUrl || '/spotify' const spotifyUrl = content.platformSpotifyUrl || '/spotify'
const location = useLocation()
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished')
return ( return (
<header className="site-header"> <header className="site-header">
@@ -372,7 +375,21 @@ function SiteHeader({ content }: { content: SiteContent }) {
{menuOpen ? 'Close' : 'Menu'} {menuOpen ? 'Close' : 'Menu'}
</button> </button>
<nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}> <nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}>
<NavLink to="/episodes" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Episodes</NavLink> {/* Episodes dropdown */}
<div className={`nav-dropdown${episodesOpen ? ' nav-dropdown--open' : ''}`}>
<button
type="button"
className={`header-nav-link nav-dropdown-trigger${episodesActive ? ' header-nav-link--active' : ''}`}
onClick={() => setEpisodesOpen(o => !o)}
aria-expanded={episodesOpen}
>
Episodes <span className="nav-dropdown-arrow" aria-hidden="true"></span>
</button>
<div className="nav-dropdown-menu">
<NavLink to="/episodes" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series</NavLink>
<NavLink to="/finished" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books</NavLink>
</div>
</div>
<NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&amp;A</NavLink> <NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&amp;A</NavLink>
<NavLink to="/study" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Studies</NavLink> <NavLink to="/study" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Studies</NavLink>
<NavLink to="/study/account" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>My Account</NavLink> <NavLink to="/study/account" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>My Account</NavLink>
@@ -442,6 +459,8 @@ function SiteFooter({ content }: { content: SiteContent }) {
)} )}
</nav> </nav>
<nav className="footer-links footer-links--legal" aria-label="Legal links"> <nav className="footer-links footer-links--legal" aria-label="Legal links">
<Link to="/finished">Finished Books</Link>
<span aria-hidden="true">·</span>
<Link to="/privacy">Privacy Policy</Link> <Link to="/privacy">Privacy Policy</Link>
<span aria-hidden="true">·</span> <span aria-hidden="true">·</span>
<Link to="/terms">Terms</Link> <Link to="/terms">Terms</Link>
@@ -1378,6 +1397,126 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
) )
} }
function useEpisodesForBook(season: number) {
const [episodes, setEpisodes] = useState<{ title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string }[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/episodes/all')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { episodes?: { title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string }[] }) => {
const filtered = (data.episodes ?? [])
.filter(ep => ep.season === String(season))
.sort((a, b) => parseInt(a.episode) - parseInt(b.episode))
setEpisodes(filtered)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [season])
return { episodes, loading }
}
function FinishedBooksPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(`Finished Books | ${siteTitle}`, 'Completed series from Verse by Verse with Nate — browse episode playlists for past book studies.')
const books = content.finishedBooks ?? []
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<main className="page-inner">
<h1 className="page-heading">Finished Books</h1>
<p className="page-sub">Completed series browse the full episode playlist for each book.</p>
{books.length === 0 ? (
<p className="finished-books-empty">No finished series yet. Check back after the current study wraps up.</p>
) : (
<div className="finished-books-grid">
{books.map(book => (
<Link key={book.id} to={`/finished/${book.id}`} className="finished-book-card">
{book.imageUrl && <img src={book.imageUrl} alt={book.title} className="finished-book-card-img" />}
<div className="finished-book-card-body">
<p className="finished-book-card-season">Season {book.season}</p>
<h2 className="finished-book-card-title">{book.title}</h2>
{book.description && <p className="finished-book-card-desc">{book.description}</p>}
<span className="finished-book-card-cta">View Playlist </span>
</div>
</Link>
))}
</div>
)}
</main>
<SiteFooter content={content} />
</div>
)
}
function FinishedSeriesPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>()
const book = (content.finishedBooks ?? []).find(b => b.id === id)
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
book ? `${book.title} | ${siteTitle}` : `Finished Series | ${siteTitle}`,
book?.description || 'Episode playlist for a completed series.',
)
const { episodes, loading } = useEpisodesForBook(book?.season ?? 0)
if (!book) {
return (
<div className="site">
<SiteHeader content={content} />
<main className="page-inner">
<p>Series not found. <Link to="/finished"> Back to Finished Books</Link></p>
</main>
<SiteFooter content={content} />
</div>
)
}
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<main className="page-inner">
<nav className="breadcrumb" aria-label="Breadcrumb">
<Link to="/finished">Finished Books</Link>
<span aria-hidden="true"> </span>
<span>{book.title}</span>
</nav>
<div className="finished-series-hero">
{book.imageUrl && <img src={book.imageUrl} alt={book.title} className="finished-series-hero-img" />}
<div>
<p className="finished-book-card-season">Season {book.season}</p>
<h1 className="finished-series-title">{book.title}</h1>
{book.description && <p className="finished-series-desc">{book.description}</p>}
</div>
</div>
<h2 className="finished-series-playlist-heading">Episode Playlist</h2>
{loading ? (
<p className="finished-books-empty">Loading episodes</p>
) : episodes.length === 0 ? (
<p className="finished-books-empty">No episodes found for this season.</p>
) : (
<ol className="finished-series-playlist">
{episodes.map((ep, i) => (
<li key={ep.audioUrl || i} className="finished-series-episode">
<span className="finished-series-ep-num">{ep.episode || i + 1}</span>
<div className="finished-series-ep-body">
<p className="finished-series-ep-title">{ep.title}</p>
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
{ep.audioUrl && (
<EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />
)}
</div>
</li>
))}
</ol>
)}
</main>
<SiteFooter content={content} />
</div>
)
}
function EpisodesPage({ content }: { content: SiteContent }) { function EpisodesPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta( usePageMeta(
@@ -2173,6 +2312,8 @@ export default function App() {
<Route path="/study/:studySlug/notes" element={<StudyRouteFrame content={content} child={<ColossiansStudyNotesPage content={content} />} />} /> <Route path="/study/:studySlug/notes" element={<StudyRouteFrame content={content} child={<ColossiansStudyNotesPage content={content} />} />} />
<Route path="/study/:studySlug/:sectionId/quiz" element={<StudyRouteFrame content={content} child={<StudyQuizPage content={content} />} />} /> <Route path="/study/:studySlug/:sectionId/quiz" element={<StudyRouteFrame content={content} child={<StudyQuizPage content={content} />} />} />
<Route path="/study/:studySlug/:sectionId" element={<StudyRouteFrame content={content} child={<ColossiansStudySectionPage content={content} />} />} /> <Route path="/study/:studySlug/:sectionId" element={<StudyRouteFrame content={content} child={<ColossiansStudySectionPage content={content} />} />} />
<Route path="/finished" element={<FinishedBooksPage content={content} />} />
<Route path="/finished/:id" element={<FinishedSeriesPage content={content} />} />
<Route path="/episodes" element={<EpisodesPage content={content} />} /> <Route path="/episodes" element={<EpisodesPage content={content} />} />
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} /> <Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
<Route path="/resources" element={<ResourcesPage content={content} />} /> <Route path="/resources" element={<ResourcesPage content={content} />} />
+11 -31
View File
@@ -47,35 +47,6 @@ export interface WhereToNextCard {
path: string path: string
} }
export interface ArchivedSeriesResourceLink {
id: string
label: string
description?: string
url: string
amazonUrl?: string
amazonLabel?: string
}
export interface ArchivedSeriesNote {
id: string
heading: string
body: string
}
export interface ArchivedSeries {
id: string
label: string
title: string
description: string
imageUrl: string
listenUrl: string
studyGuideTitle: string
studyGuideDescription: string
studyGuideUrl: string
resourceLinks: ArchivedSeriesResourceLink[]
notes: ArchivedSeriesNote[]
episodeRange?: { from: number; to: number }
}
export interface RedirectRule { export interface RedirectRule {
id: string id: string
@@ -95,6 +66,14 @@ export interface PodcastFeaturedLink {
discussionQuestions?: string[] discussionQuestions?: string[]
} }
export interface FinishedBook {
id: string
title: string
description: string
imageUrl: string
season: number
}
export interface SeoSettings { export interface SeoSettings {
title: string title: string
description: string description: string
@@ -260,13 +239,14 @@ export interface SiteContent {
// ── Arrays ── // ── Arrays ──
customLinks: CustomLink[] customLinks: CustomLink[]
customBlocks: CustomBlock[] customBlocks: CustomBlock[]
archivedSeries: ArchivedSeries[]
redirects: RedirectRule[] redirects: RedirectRule[]
podcastFeaturedLinks: PodcastFeaturedLink[] podcastFeaturedLinks: PodcastFeaturedLink[]
seo: SeoSettings seo: SeoSettings
legal: LegalSettings legal: LegalSettings
// ── Episodes page ── // ── Episodes page ──
episodesSeoIntro: string episodesSeoIntro: string
// ── Finished Books ──
finishedBooks: FinishedBook[]
} }
export const DEFAULTS: SiteContent = { export const DEFAULTS: SiteContent = {
@@ -445,7 +425,6 @@ export const DEFAULTS: SiteContent = {
cookieBannerText: 'We use optional analytics cookies to better understand how people find and engage with this content. By accepting, you allow us to measure how you interact with the site — no personal data is sold or shared.', cookieBannerText: 'We use optional analytics cookies to better understand how people find and engage with this content. By accepting, you allow us to measure how you interact with the site — no personal data is sold or shared.',
customLinks: [], customLinks: [],
customBlocks: [], customBlocks: [],
archivedSeries: [],
redirects: [ redirects: [
{ {
id: 'spotify', id: 'spotify',
@@ -467,6 +446,7 @@ export const DEFAULTS: SiteContent = {
}, },
], ],
podcastFeaturedLinks: [], podcastFeaturedLinks: [],
finishedBooks: [],
episodesSeoIntro: 'Verse by Verse with Nate is an expository Bible teaching podcast where we go through Scripture one verse at a time. Each episode digs into the text carefully and practically, helping you understand what the Bible says, what it means, and how to live it out. New episodes released regularly — subscribe on Spotify or your favorite podcast platform.', episodesSeoIntro: 'Verse by Verse with Nate is an expository Bible teaching podcast where we go through Scripture one verse at a time. Each episode digs into the text carefully and practically, helping you understand what the Bible says, what it means, and how to live it out. New episodes released regularly — subscribe on Spotify or your favorite podcast platform.',
seo: { seo: {
title: 'Verse by Verse with Nate', title: 'Verse by Verse with Nate',