diff --git a/package.json b/package.json index a2883aa..486cd1e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.10.2", + "version": "2.10.3", "type": "module", "scripts": { "dev": "vite", diff --git a/plans/todo.md b/plans/todo.md new file mode 100644 index 0000000..eb5a067 --- /dev/null +++ b/plans/todo.md @@ -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. diff --git a/src/App.css b/src/App.css index 683d0f6..f234156 100644 --- a/src/App.css +++ b/src/App.css @@ -10212,6 +10212,67 @@ 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 ─────────────────────────────────────────────── */ .section-nuggets-intro { padding: 1rem 1.5rem 0; } .nuggets-intro-layout { display: flex; gap: 2rem; align-items: flex-start; flex-wrap: wrap; } diff --git a/src/App.tsx b/src/App.tsx index edf2019..51738c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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 ( +
+
+ {hasHighlights && ( + + )} + +
+ + {activeTab === 'highlights' ? ( + <> +
+ {shownHighlights.map(link => )} +
+ {orderedHighlights.length > HIGHLIGHTS_PREVIEW_COUNT && ( +
+ +
+ )} + + ) : loading ? ( +

Loading episodes…

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

No episodes found for this season.

+ ) : ( + <> + {episodes.length > FINISHED_PLAYLIST_FILTER_THRESHOLD && ( + setFilter(e.target.value)} + /> + )} + {matching.length === 0 ? ( +

No episodes match “{filter.trim()}”.

+ ) : ( +
    + {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. + ) + })} +
+ )} + {!query && matching.length > FINISHED_PLAYLIST_PREVIEW_COUNT && ( +
+ +
+ )} + + )} +
+ ) +} + function FinishedSeriesPage({ content }: { content: SiteContent }) { const { id } = useParams<{ id: string }>() const book = (content.finishedBooks ?? []).find(b => b.id === id) @@ -1800,7 +1942,7 @@ function FinishedSeriesPage({ content }: { content: SiteContent }) {

{book.title}

{book.description &&

{book.description}

}
- ▶ Stream Episodes + ▶ Stream Episodes {book.downloadUrl && (
- {(book.highlights ?? []).length > 0 && ( - <> -

Episode Highlights

-
- {[...(book.highlights ?? [])].reverse().map(link => )} -
- - )} -

Episode Playlist

- {loading ? ( -

Loading episodes…

- ) : episodes.length === 0 ? ( -

No episodes found for this season.

- ) : ( -
    - {episodes.map((ep, i) => ( -
  1. - {ep.episode || i + 1} -
    -

    {ep.title}

    - {ep.duration &&

    {ep.duration}

    } - {ep.audioUrl && ( - - )} -
    -
  2. - ))} -
- )} +