diff --git a/package-lock.json b/package-lock.json index a5df2d8..d6c1f2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "siteforge", - "version": "2.30.0", + "version": "2.31.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "siteforge", - "version": "2.30.0", + "version": "2.31.0", "dependencies": { "@aws-sdk/client-s3": "^3.1135.0", "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 2783464..bbd3343 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.30.0", + "version": "2.31.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.css b/src/App.css index bd14aa4..114194c 100644 --- a/src/App.css +++ b/src/App.css @@ -13786,3 +13786,45 @@ a.study-track-card:hover { .si-verse-btn { margin-left: 0; } } + +/* ── Episodes hub & Finished Books library ── */ +.ep-hero { + display: grid; + grid-template-columns: 220px 1fr; + grid-template-areas: "art body" "player player"; + gap: 1.5rem 2rem; + align-items: center; + max-width: 1000px; +} +.ep-hero-art { grid-area: art; width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 12px; border: 1px solid rgba(201, 168, 76, 0.25); } +.ep-hero-body { grid-area: body; } +.ep-hero-player { grid-area: player; } +.ep-hero-title { font-family: var(--brand-font-heading); font-size: 1.7rem; color: #e8d9b5; margin: 0.2rem 0 0.6rem; } +.ep-hero-desc { color: var(--brand-muted); line-height: 1.6; margin: 0; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } +.section-episode-browser { padding: 3rem 0 2rem; } +.ep-season-chips { margin: 0 0 1.25rem; } +.ep-season-link { margin: -0.5rem 0 1rem; font-size: 0.9rem; } +.ep-season-link a { color: var(--brand-gold); text-decoration: none; } +.section-episode-headliner { padding: 0 0 3rem; } + +.finished-books-toolbar { display: flex; flex-direction: column; gap: 0.9rem; margin-bottom: 0.5rem; } +.finished-books-toolbar .finished-series-filter { margin: 0; } +.finished-book-card-facts { font-size: 0.82rem; color: var(--brand-gold); opacity: 0.85; margin: 0; } +.finished-book-card-badges { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem; } +.finished-book-badge { font-size: 0.72rem; letter-spacing: 0.04em; padding: 0.15rem 0.6rem; border: 1px solid rgba(201, 168, 76, 0.3); border-radius: 999px; color: var(--brand-muted); } +.finished-book-card--featured { grid-column: 1 / -1; flex-direction: row; } +.finished-book-card--featured .finished-book-card-img-wrap { flex: 0 0 280px; } +.finished-book-card--featured .finished-book-card-img { height: 100%; aspect-ratio: auto; } +.finished-book-card--featured .finished-book-card-body { justify-content: center; padding: 1.5rem 1.8rem; } +.finished-book-card--featured .finished-book-card-title { font-size: 1.8rem; } + +@media (max-width: 640px) { + .ep-hero { grid-template-columns: 96px 1fr; gap: 1rem; } + .ep-hero-title { font-size: 1.3rem; } + .ep-hero-desc { -webkit-line-clamp: 3; font-size: 0.9rem; } + .ep-season-chips { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 0.2rem; } + .finished-book-card--featured { flex-direction: column; } + .finished-book-card--featured .finished-book-card-img-wrap { flex-basis: auto; } + .finished-book-card--featured .finished-book-card-img { aspect-ratio: 16 / 9; height: auto; } +} +.ep-hero-player { margin-bottom: 0.5rem; } diff --git a/src/App.tsx b/src/App.tsx index 5ad253c..d0d92c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1612,28 +1612,150 @@ 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 }[]>([]) +type PodEpisode = { title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string; pubDate: string } + +function useAllEpisodes() { + const [episodes, setEpisodes] = useState([]) 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) - }) + .then((data: { episodes?: PodEpisode[] }) => setEpisodes(data.episodes ?? [])) .catch(() => {}) .finally(() => setLoading(false)) - }, [season]) + }, []) return { episodes, loading } } +function useEpisodesForBook(season: number) { + const { episodes: all, loading } = useAllEpisodes() + const episodes = useMemo( + () => all.filter(ep => ep.season === String(season)).sort((a, b) => parseInt(a.episode) - parseInt(b.episode)), + [all, season], + ) + return { episodes, loading } +} + +function durationSeconds(d: string) { + if (!d) return 0 + if (/^\d+$/.test(d)) return parseInt(d, 10) + const parts = d.split(':').map(Number) + if (parts.some(n => Number.isNaN(n))) return 0 + return parts.reduce((acc, n) => acc * 60 + n, 0) +} + +function formatRuntime(sec: number) { + if (sec <= 0) return '' + const h = Math.floor(sec / 3600) + const m = Math.round((sec % 3600) / 60) + return h > 0 ? `${h}h ${m}m` : `${m}m` +} + +function formatEpisodeDate(pubDate: string) { + const d = new Date(pubDate) + return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +} + +// One playlist row: number, title, date/duration, and a Play button that opens the audio player in place. +function EpisodeRow({ ep, label, isOpen, onToggle }: { ep: PodEpisode; label: string | number; isOpen: boolean; onToggle: () => void }) { + const date = formatEpisodeDate(ep.pubDate) + const meta = [date, ep.duration].filter(Boolean).join(' · ') + return ( +
  • + {label} +
    +
    +
    +

    {ep.title}

    + {meta &&

    {meta}

    } +
    + {ep.audioUrl && ( + + )} +
    + {isOpen && ep.audioUrl && } +
    +
  • + ) +} + +// Maps a finished-book title ("Titus", "Study of 1 John") to its canonical Bible book id. +function bibleBookIdForTitle(title: string): string | null { + const t = title.toLowerCase() + const hits = BIBLE_BOOKS.filter(b => t.includes(b.name.toLowerCase())).sort((a, b) => b.name.length - a.name.length) + return hits[0]?.id ?? null +} + 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 ?? [] + const { episodes } = useAllEpisodes() + const [query, setQuery] = useState('') + const [testament, setTestament] = useState<'all' | 'ot' | 'nt'>('all') + const [sort, setSort] = useState<'recent' | 'canon'>('recent') + + const statsBySeason = useMemo(() => { + const map = new Map() + for (const ep of episodes) { + const season = parseInt(ep.season, 10) + if (Number.isNaN(season)) continue + const cur = map.get(season) ?? { count: 0, seconds: 0 } + cur.count += 1 + cur.seconds += durationSeconds(ep.duration) + map.set(season, cur) + } + return map + }, [episodes]) + + const canonIndex = (title: string) => { + const id = bibleBookIdForTitle(title) + const i = id ? BIBLE_BOOKS.findIndex(b => b.id === id) : -1 + return i < 0 ? 999 : i + } + const needle = query.trim().toLowerCase() + const filtering = Boolean(needle) || testament !== 'all' + const visible = books + .filter(book => { + if (needle && !`${book.title} ${book.description}`.toLowerCase().includes(needle)) return false + if (testament !== 'all') { + const i = canonIndex(book.title) + if (i === 999 || (testament === 'ot') !== (i < 39)) return false + } + return true + }) + .sort((a, b) => sort === 'canon' ? canonIndex(a.title) - canonIndex(b.title) : b.season - a.season) + const showFeatured = !filtering && sort === 'recent' && visible.length > 2 + + const renderCard = (book: (typeof books)[number], featured: boolean) => { + const stats = statsBySeason.get(book.season) + const facts = [stats ? `${stats.count} episodes` : '', stats ? formatRuntime(stats.seconds) : ''].filter(Boolean).join(' · ') + return ( + + {book.imageUrl && ( +
    + {book.title} +
    + )} +
    +

    {featured ? `Most recently finished · Season ${book.season}` : `Season ${book.season}`}

    +

    {book.title}

    + {facts &&

    {facts}

    } + {book.description &&

    {book.description}

    } +
    + {book.downloadUrl && ⬇ Album} + {book.studySlug && 📖 Study} + {book.guide?.title && 📄 Guide} +
    + View Playlist → +
    + + ) + } + + const pill = (active: boolean) => `si-pill${active ? ' si-pill--on' : ''}` return (
    @@ -1654,24 +1776,38 @@ function FinishedBooksPage({ content }: { content: SiteContent }) {
    ) : ( -
    - {books.map(book => ( - - {book.imageUrl && ( -
    - {book.title} - {book.downloadUrl && ⬇} + <> + {books.length > 3 && ( +
    + setQuery(e.target.value)} + /> +
    +
    + + + +
    +
    + +
    - )} -
    -

    Season {book.season}

    -

    {book.title}

    - {book.description &&

    {book.description}

    } - View Playlist →
    - - ))} -
    +
    + )} + {visible.length === 0 ? ( +

    No finished books match your filters.

    + ) : ( +
    + {visible.map((book, i) => renderCard(book, showFeatured && i === 0))} +
    + )} + )}
    @@ -1683,7 +1819,7 @@ function FinishedBooksPage({ content }: { content: SiteContent }) { const FINISHED_PLAYLIST_PREVIEW_COUNT = 10 const FINISHED_PLAYLIST_FILTER_THRESHOLD = 12 -type FinishedBookEpisode = { title: string; episode: string; duration: string; audioUrl: string; description: string } +type FinishedBookEpisode = PodEpisode // One section with two tabs instead of two full-length lists stacked on top of each other. // "Highlights" are the hand-picked episodes (with notes); "All Episodes" is the complete @@ -1781,31 +1917,7 @@ function FinishedSeriesEpisodes({ highlights, episodes, loading }: {
      {shownEpisodes.map((ep, i) => { const key = ep.audioUrl || `${ep.episode}-${i}` - const isOpen = openKey === key - return ( -
    1. - {ep.episode || i + 1} -
      -
      -
      -

      {ep.title}

      - {ep.duration &&

      {ep.duration}

      } -
      - {ep.audioUrl && ( - - )} -
      - {isOpen && ep.audioUrl && } -
      -
    2. - ) + return setOpenKey(openKey === key ? null : key)} /> })}
    )} @@ -2036,12 +2148,36 @@ function LatestEpisodePlayer({ content }: { content: SiteContent }) { ) } +const EPISODES_PREVIEW_COUNT = 8 + function EpisodesPage({ content }: { content: SiteContent }) { const siteTitle = content.seo?.title || DEFAULTS.seo.title usePageMeta( `Episodes | ${siteTitle}`, content.episodesSeoIntro || 'Browse all episodes of Verse by Verse with Nate — expository Bible teaching, one verse at a time.', ) + const { episodes, loading } = useAllEpisodes() + const [query, setQuery] = useState('') + const [season, setSeason] = useState('all') + const [expanded, setExpanded] = useState(false) + const [openKey, setOpenKey] = useState(null) + + const finishedBooks = [...(content.finishedBooks ?? [])].sort((a, b) => b.season - a.season) + const seasonChips = [ + ...(content.seriesSeason ? [{ key: String(content.seriesSeason), label: 'Current series' }] : []), + ...finishedBooks.map(book => ({ key: String(book.season), label: book.title })), + ] + const countFor = (key: string) => episodes.filter(ep => ep.season === key).length + + const needle = query.trim().toLowerCase() + const matching = episodes.filter(ep => { + if (season !== 'all' && ep.season !== season) return false + return !needle || `${ep.episode} ${ep.title}`.toLowerCase().includes(needle) + }) + const showAll = expanded || needle.length > 0 + const shown = showAll ? matching : matching.slice(0, EPISODES_PREVIEW_COUNT) + const activeBook = finishedBooks.find(book => String(book.season) === season) + const pill = (active: boolean) => `si-pill${active ? ' si-pill--on' : ''}` return (
    @@ -2049,22 +2185,80 @@ function EpisodesPage({ content }: { content: SiteContent }) { -
    -
    +
    +
    -
    + {content.seriesImageUrl && {content.seriesTitle}} +
    +

    Current series{content.seriesSeason ? ` · Season ${content.seriesSeason}` : ''}

    +

    {content.seriesTitle}

    + {content.seriesDescription &&

    {content.seriesDescription}

    } +
    +
    +
    +
    -
    - +
    +
    +

    ✦ Browse Episodes ✦

    + setQuery(e.target.value)} + /> + {seasonChips.length > 0 && ( +
    + + {seasonChips.map(chip => ( + + ))} +
    + )} + {activeBook && ( +

    Open the full {activeBook.title} playlist →

    + )} + {loading ? ( +

    Loading episodes…

    + ) : matching.length === 0 ? ( +

    {needle ? `No episodes match “${query.trim()}”.` : 'No episodes found.'}

    + ) : ( +
      + {shown.map((ep, i) => { + const key = ep.audioUrl || `${ep.episode}-${i}` + return setOpenKey(openKey === key ? null : key)} /> + })} +
    + )} + {!needle && matching.length > EPISODES_PREVIEW_COUNT && ( +
    + +
    + )} +
    +
    + + +
    +
    +