Add Finished Books feature with episode playlists and nav dropdown
Public: - /finished page with ornament heading, intro text, book grid, and styled empty state - /finished/:id episode playlist page filtered by RSS season number - Episodes nav becomes click-toggle dropdown (Current Series / Finished Books) - Finished Books link in footer - Dropdown font fixed to match other nav links Admin: - End Current Series & Start New Book wizard on Current Series tab - Finished Books management tab with add/edit/remove and image asset picker Data: - FinishedBook type + finishedBooks[] field on SiteContent - RSS parser extracts itunes:season per episode Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3831,6 +3831,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Cover Image URL</label>
|
<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) }))} />
|
<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) }))} />
|
||||||
|
{renderImageAssetSelector(book.imageUrl, value => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, imageUrl: value } : b) })), `finished-book-img-${book.id}`)}
|
||||||
{renderImagePreview(book.imageUrl, `${book.title} cover`)}
|
{renderImagePreview(book.imageUrl, `${book.title} cover`)}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
|
|||||||
+65
-9
@@ -190,7 +190,8 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-nav a {
|
.header-nav a,
|
||||||
|
.header-nav button {
|
||||||
color: var(--brand-muted);
|
color: var(--brand-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-family: var(--brand-font-body);
|
font-family: var(--brand-font-body);
|
||||||
@@ -8701,18 +8702,29 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
color: var(--brand-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.96rem;
|
||||||
|
line-height: inherit;
|
||||||
|
transition: color 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-trigger:hover,
|
||||||
|
.nav-dropdown--open .nav-dropdown-trigger {
|
||||||
|
color: var(--brand-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-dropdown-arrow {
|
.nav-dropdown-arrow {
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transition: transform 200ms;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-dropdown-menu {
|
.nav-dropdown-menu {
|
||||||
display: none;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 0.5rem);
|
top: calc(100% + 0.75rem);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: rgba(15, 15, 12, 0.98);
|
background: rgba(15, 15, 12, 0.98);
|
||||||
@@ -8724,11 +8736,6 @@
|
|||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
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 {
|
.nav-dropdown-item {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.55rem 1.1rem;
|
padding: 0.55rem 1.1rem;
|
||||||
@@ -8993,3 +9000,52 @@
|
|||||||
height: 120px;
|
height: 120px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Finished Books page section ── */
|
||||||
|
.section-finished-books {
|
||||||
|
min-height: 60vh;
|
||||||
|
padding: 4rem 0 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-books-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-books-intro {
|
||||||
|
color: var(--brand-muted);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
max-width: 540px;
|
||||||
|
margin: -1rem auto 0;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-books-empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem 5rem;
|
||||||
|
border: 1px solid rgba(201, 168, 76, 0.15);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(26, 26, 21, 0.5);
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-books-empty-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-books-empty-heading {
|
||||||
|
font-family: var(--brand-font-heading);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--brand-warm-white);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-books-empty-text {
|
||||||
|
color: var(--brand-muted);
|
||||||
|
font-size: 0.97rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|||||||
+58
-28
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import AdminPage from './AdminPage'
|
import AdminPage from './AdminPage'
|
||||||
@@ -355,10 +355,21 @@ 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 [episodesOpen, setEpisodesOpen] = useState(false)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
|
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished')
|
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleOutsideClick(e: MouseEvent) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setEpisodesOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (episodesOpen) document.addEventListener('mousedown', handleOutsideClick)
|
||||||
|
return () => document.removeEventListener('mousedown', handleOutsideClick)
|
||||||
|
}, [episodesOpen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
<div className="header-inner">
|
<div className="header-inner">
|
||||||
@@ -376,19 +387,21 @@ function SiteHeader({ content }: { content: SiteContent }) {
|
|||||||
</button>
|
</button>
|
||||||
<nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}>
|
<nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}>
|
||||||
{/* Episodes dropdown */}
|
{/* Episodes dropdown */}
|
||||||
<div className={`nav-dropdown${episodesOpen ? ' nav-dropdown--open' : ''}`}>
|
<div ref={dropdownRef} className={`nav-dropdown${episodesOpen ? ' nav-dropdown--open' : ''}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`header-nav-link nav-dropdown-trigger${episodesActive ? ' header-nav-link--active' : ''}`}
|
className={`nav-dropdown-trigger${episodesActive ? ' header-nav-link--active' : ''}`}
|
||||||
onClick={() => setEpisodesOpen(o => !o)}
|
onClick={() => setEpisodesOpen(o => !o)}
|
||||||
aria-expanded={episodesOpen}
|
aria-expanded={episodesOpen}
|
||||||
>
|
>
|
||||||
Episodes <span className="nav-dropdown-arrow" aria-hidden="true">▾</span>
|
Episodes <span className="nav-dropdown-arrow" aria-hidden="true">{episodesOpen ? '▴' : '▾'}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="nav-dropdown-menu">
|
{episodesOpen && (
|
||||||
<NavLink to="/episodes" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series</NavLink>
|
<div className="nav-dropdown-menu">
|
||||||
<NavLink to="/finished" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books</NavLink>
|
<NavLink to="/episodes" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series</NavLink>
|
||||||
</div>
|
<NavLink to="/finished" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books</NavLink>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&A</NavLink>
|
<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" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Studies</NavLink>
|
||||||
@@ -1424,27 +1437,44 @@ function FinishedBooksPage({ content }: { content: SiteContent }) {
|
|||||||
<div className="site">
|
<div className="site">
|
||||||
<SiteHeader content={content} />
|
<SiteHeader content={content} />
|
||||||
<SiteSearchBar content={content} />
|
<SiteSearchBar content={content} />
|
||||||
<main className="page-inner">
|
<section className="section-finished-books">
|
||||||
<h1 className="page-heading">Finished Books</h1>
|
<div className="section-inner">
|
||||||
<p className="page-sub">Completed series — browse the full episode playlist for each book.</p>
|
<div className="finished-books-header">
|
||||||
{books.length === 0 ? (
|
<h1 className="section-heading">
|
||||||
<p className="finished-books-empty">No finished series yet. Check back after the current study wraps up.</p>
|
<span className="ornament">✦</span> Finished Books <span className="ornament">✦</span>
|
||||||
) : (
|
</h1>
|
||||||
<div className="finished-books-grid">
|
<p className="finished-books-intro">
|
||||||
{books.map(book => (
|
Every series we've completed — with a full ordered episode playlist for each book of the Bible.
|
||||||
<Link key={book.id} to={`/finished/${book.id}`} className="finished-book-card">
|
</p>
|
||||||
{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>
|
</div>
|
||||||
)}
|
{books.length === 0 ? (
|
||||||
</main>
|
<div className="finished-books-empty-state">
|
||||||
|
<div className="finished-books-empty-icon">📖</div>
|
||||||
|
<h2 className="finished-books-empty-heading">Nothing here yet</h2>
|
||||||
|
<p className="finished-books-empty-text">
|
||||||
|
We're still working through the current series. When a book study wraps up, it'll live here with the full episode playlist. Check back soon.
|
||||||
|
</p>
|
||||||
|
<Link to="/episodes" className="btn-primary" style={{ display: 'inline-block', marginTop: '1.5rem' }}>
|
||||||
|
Listen to Current Series →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<SiteFooter content={content} />
|
<SiteFooter content={content} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user