Finished Books detail: one tabbed Episodes section (Highlights / All Episodes), searchable playlist, on-demand player; v2.10.3
Publish Container / build (push) Has been cancelled
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b96e9a79dc
commit
4d37fac641
4 files changed
+232
-31
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.10.2",
|
"version": "2.10.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Siteforge TODO
|
||||||
|
|
||||||
|
Open gaps found in the beta audit (2026-10-09). Items already shipped are in git history (v2.10.0 – v2.10.2).
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- [ ] **Email 2FA codes use `Math.random()`** — switch `generateEmailOtp` in `server/study-helpers.js` to `crypto.randomInt`; compare hashes with `timingSafeEqual`.
|
||||||
|
- [ ] **TOTP replay** — `verifyTotpCode` in `server/auth.js` accepts a code again within its ±1 step window. Record the last accepted time step per secret (admin + study users) and reject anything at or before it.
|
||||||
|
- [ ] **`unhandledRejection` / `uncaughtException` handlers** in `server.js` — Node 22 exits on an unhandled rejection; log and (for rejections) keep running.
|
||||||
|
- [ ] **Scrub old user data from git history** — `data/study-users.json` (old SHA-256 password hashes), study notes/progress etc. were committed before being untracked in v2.10.0. Needs a history rewrite (`git filter-repo`) + force-push; old hashes remain recoverable until then.
|
||||||
|
- [ ] **CSP `'unsafe-inline'` in `script-src`** — needs a nonce setup for the Vite build to remove.
|
||||||
|
|
||||||
|
## Frontend / performance
|
||||||
|
|
||||||
|
- [ ] **Code splitting** — no `lazy()`/`Suspense` anywhere; one 1.7 MB JS bundle includes the 7,000-line `AdminPage`. Lazy-load `AdminPage` and the study pages.
|
||||||
|
- [ ] **React error boundary** — a render error currently white-screens the page; add a top-level boundary with a fallback.
|
||||||
|
- [ ] **Per-route SEO / link previews** — OG tags are fixed in `index.html` and titles are set client-side (`usePageMeta`), so non-JS crawlers (iMessage, Facebook, Slack) show the generic preview for `/episodes/:id` and study lesson URLs. Inject per-route title/description/OG tags server-side.
|
||||||
|
- [ ] **Accessibility audit** — run axe (contrast, focus order, modal dialogs, image alt text).
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
- [ ] **Split the large files** — `AdminPage.tsx` (~7,000 lines), `studyPages.tsx` (~4,200), `App.tsx` (~3,600).
|
||||||
|
- [ ] **Clear the 11 `react-hooks/set-state-in-effect` lint warnings** (rule downgraded to `warn` in v2.10.0), then restore it to `error`.
|
||||||
|
- [ ] **Remaining npm audit findings** — `d3-color` (high, via `react-simple-maps`) and `sprintf-js` (moderate, via `mammoth`; fix is a breaking downgrade).
|
||||||
|
- [ ] **CI installs with `--legacy-peer-deps`** — resolve the tiptap peer-dependency conflict so plain `npm ci` works.
|
||||||
|
- [ ] **Resend webhook `?token=` query form** — in logs/URLs; kept because Resend can't send custom headers. Revisit if Resend adds header support or switch to svix signature verification.
|
||||||
+61
@@ -10212,6 +10212,67 @@
|
|||||||
margin: 0 0 0.6rem;
|
margin: 0 0 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finished-series-episodes {
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-series-episodes .highlight-tabs {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-series-filter {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
background: rgba(26, 26, 21, 0.7);
|
||||||
|
border: 1px solid rgba(201, 168, 76, 0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--brand-warm-white);
|
||||||
|
font-family: var(--brand-font-body);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-series-filter:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(201, 168, 76, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-series-playlist { gap: 0.6rem; }
|
||||||
|
|
||||||
|
.finished-series-episode { padding: 0.8rem 1.1rem; }
|
||||||
|
|
||||||
|
.finished-series-ep-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-series-ep-row .finished-series-ep-meta { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.finished-series-ep-play {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(201, 168, 76, 0.45);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--brand-gold);
|
||||||
|
font-family: var(--brand-font-body);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.4rem 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-series-ep-play:hover,
|
||||||
|
.finished-series-episode--open .finished-series-ep-play {
|
||||||
|
background: rgba(201, 168, 76, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished-series-episode--open .finished-series-ep-body > :last-child { margin-top: 0.75rem; }
|
||||||
|
|
||||||
/* ── Nuggets (sub-show) page ─────────────────────────────────────────────── */
|
/* ── Nuggets (sub-show) page ─────────────────────────────────────────────── */
|
||||||
.section-nuggets-intro { padding: 1rem 1.5rem 0; }
|
.section-nuggets-intro { padding: 1rem 1.5rem 0; }
|
||||||
.nuggets-intro-layout { display: flex; gap: 2rem; align-items: flex-start; flex-wrap: wrap; }
|
.nuggets-intro-layout { display: flex; gap: 2rem; align-items: flex-start; flex-wrap: wrap; }
|
||||||
|
|||||||
+144
-30
@@ -1753,6 +1753,148 @@ 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 }
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// ordered playlist, which previews the first few, can be filtered, and only loads an audio
|
||||||
|
// player for the episode you open.
|
||||||
|
function FinishedSeriesEpisodes({ highlights, episodes, loading }: {
|
||||||
|
highlights: PodcastFeaturedLink[]
|
||||||
|
episodes: FinishedBookEpisode[]
|
||||||
|
loading: boolean
|
||||||
|
}) {
|
||||||
|
const hasHighlights = highlights.length > 0
|
||||||
|
const [tab, setTab] = useState<'highlights' | 'all'>(hasHighlights ? 'highlights' : 'all')
|
||||||
|
const [highlightsExpanded, setHighlightsExpanded] = useState(false)
|
||||||
|
const [playlistExpanded, setPlaylistExpanded] = useState(false)
|
||||||
|
const [filter, setFilter] = useState('')
|
||||||
|
const [openKey, setOpenKey] = useState<string | null>(null)
|
||||||
|
const activeTab = hasHighlights ? tab : 'all'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onHash() {
|
||||||
|
if (window.location.hash === '#episodes') setTab('all')
|
||||||
|
}
|
||||||
|
onHash()
|
||||||
|
window.addEventListener('hashchange', onHash)
|
||||||
|
return () => window.removeEventListener('hashchange', onHash)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const orderedHighlights = [...highlights].reverse()
|
||||||
|
const shownHighlights = highlightsExpanded ? orderedHighlights : orderedHighlights.slice(0, HIGHLIGHTS_PREVIEW_COUNT)
|
||||||
|
|
||||||
|
const query = filter.trim().toLowerCase()
|
||||||
|
const matching = query
|
||||||
|
? episodes.filter(ep => `${ep.episode} ${ep.title}`.toLowerCase().includes(query))
|
||||||
|
: episodes
|
||||||
|
const showAllPlaylist = playlistExpanded || query.length > 0
|
||||||
|
const shownEpisodes = showAllPlaylist ? matching : matching.slice(0, FINISHED_PLAYLIST_PREVIEW_COUNT)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="episodes" className="finished-series-episodes" aria-label="Episodes">
|
||||||
|
<div className="highlight-tabs" role="tablist" aria-label="Episode views">
|
||||||
|
{hasHighlights && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'highlights'}
|
||||||
|
className={`highlight-tab${activeTab === 'highlights' ? ' highlight-tab--active' : ''}`}
|
||||||
|
onClick={() => setTab('highlights')}
|
||||||
|
>
|
||||||
|
Highlights <span className="highlight-tab-count">{highlights.length}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'all'}
|
||||||
|
className={`highlight-tab${activeTab === 'all' ? ' highlight-tab--active' : ''}`}
|
||||||
|
onClick={() => setTab('all')}
|
||||||
|
>
|
||||||
|
All Episodes {!loading && <span className="highlight-tab-count">{episodes.length}</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'highlights' ? (
|
||||||
|
<>
|
||||||
|
<div className="episode-list">
|
||||||
|
{shownHighlights.map(link => <HighlightCard key={link.id} link={link} />)}
|
||||||
|
</div>
|
||||||
|
{orderedHighlights.length > HIGHLIGHTS_PREVIEW_COUNT && (
|
||||||
|
<div className="highlight-more">
|
||||||
|
<button type="button" className="btn-secondary" onClick={() => setHighlightsExpanded(v => !v)}>
|
||||||
|
{highlightsExpanded ? 'Show fewer' : `Show all ${orderedHighlights.length} highlights`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : loading ? (
|
||||||
|
<p className="finished-books-empty">Loading episodes…</p>
|
||||||
|
) : episodes.length === 0 ? (
|
||||||
|
<p className="finished-books-empty">No episodes found for this season.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{episodes.length > FINISHED_PLAYLIST_FILTER_THRESHOLD && (
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="finished-series-filter"
|
||||||
|
placeholder="Search episodes by number or title…"
|
||||||
|
aria-label="Search episodes"
|
||||||
|
value={filter}
|
||||||
|
onChange={e => setFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{matching.length === 0 ? (
|
||||||
|
<p className="finished-books-empty">No episodes match “{filter.trim()}”.</p>
|
||||||
|
) : (
|
||||||
|
<ol className="finished-series-playlist">
|
||||||
|
{shownEpisodes.map((ep, i) => {
|
||||||
|
const key = ep.audioUrl || `${ep.episode}-${i}`
|
||||||
|
const isOpen = openKey === key
|
||||||
|
return (
|
||||||
|
<li key={key} className={`finished-series-episode${isOpen ? ' finished-series-episode--open' : ''}`}>
|
||||||
|
<span className="finished-series-ep-num">{ep.episode || i + 1}</span>
|
||||||
|
<div className="finished-series-ep-body">
|
||||||
|
<div className="finished-series-ep-row">
|
||||||
|
<div>
|
||||||
|
<p className="finished-series-ep-title">{ep.title}</p>
|
||||||
|
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
|
||||||
|
</div>
|
||||||
|
{ep.audioUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="finished-series-ep-play"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
onClick={() => setOpenKey(isOpen ? null : key)}
|
||||||
|
>
|
||||||
|
{isOpen ? 'Hide player' : '▶ Play'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isOpen && ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
{!query && matching.length > FINISHED_PLAYLIST_PREVIEW_COUNT && (
|
||||||
|
<div className="highlight-more">
|
||||||
|
<button type="button" className="btn-secondary" onClick={() => setPlaylistExpanded(v => !v)}>
|
||||||
|
{playlistExpanded ? 'Show fewer' : `Show all ${matching.length} episodes`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FinishedSeriesPage({ content }: { content: SiteContent }) {
|
function FinishedSeriesPage({ content }: { content: SiteContent }) {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const book = (content.finishedBooks ?? []).find(b => b.id === id)
|
const book = (content.finishedBooks ?? []).find(b => b.id === id)
|
||||||
@@ -1800,7 +1942,7 @@ function FinishedSeriesPage({ content }: { content: SiteContent }) {
|
|||||||
<h1 className="finished-series-title">{book.title}</h1>
|
<h1 className="finished-series-title">{book.title}</h1>
|
||||||
{book.description && <p className="finished-series-desc">{book.description}</p>}
|
{book.description && <p className="finished-series-desc">{book.description}</p>}
|
||||||
<div className="finished-series-actions">
|
<div className="finished-series-actions">
|
||||||
<a href="#playlist" className="finished-series-btn-stream">▶ Stream Episodes</a>
|
<a href="#episodes" className="finished-series-btn-stream">▶ Stream Episodes</a>
|
||||||
{book.downloadUrl && (
|
{book.downloadUrl && (
|
||||||
<a
|
<a
|
||||||
href={book.downloadUrl}
|
href={book.downloadUrl}
|
||||||
@@ -1832,35 +1974,7 @@ function FinishedSeriesPage({ content }: { content: SiteContent }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(book.highlights ?? []).length > 0 && (
|
<FinishedSeriesEpisodes highlights={book.highlights ?? []} episodes={episodes} loading={loading} />
|
||||||
<>
|
|
||||||
<h2 className="finished-series-playlist-heading">Episode Highlights</h2>
|
|
||||||
<div className="episode-list">
|
|
||||||
{[...(book.highlights ?? [])].reverse().map(link => <HighlightCard key={link.id} link={link} />)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<h2 id="playlist" 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>
|
</main>
|
||||||
<SiteFooter content={content} />
|
<SiteFooter content={content} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user