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:
+142
-1
@@ -354,7 +354,10 @@ function SiteSearchBar({ content }: { content: SiteContent }) {
|
||||
|
||||
function SiteHeader({ content }: { content: SiteContent }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [episodesOpen, setEpisodesOpen] = useState(false)
|
||||
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
|
||||
const location = useLocation()
|
||||
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished')
|
||||
|
||||
return (
|
||||
<header className="site-header">
|
||||
@@ -372,7 +375,21 @@ function SiteHeader({ content }: { content: SiteContent }) {
|
||||
{menuOpen ? 'Close' : 'Menu'}
|
||||
</button>
|
||||
<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&A</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>
|
||||
@@ -442,6 +459,8 @@ function SiteFooter({ content }: { content: SiteContent }) {
|
||||
)}
|
||||
</nav>
|
||||
<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>
|
||||
<span aria-hidden="true">·</span>
|
||||
<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 }) {
|
||||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||||
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/: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="/finished" element={<FinishedBooksPage content={content} />} />
|
||||
<Route path="/finished/:id" element={<FinishedSeriesPage content={content} />} />
|
||||
<Route path="/episodes" element={<EpisodesPage content={content} />} />
|
||||
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
|
||||
<Route path="/resources" element={<ResourcesPage content={content} />} />
|
||||
|
||||
Reference in New Issue
Block a user