Compare commits

..
78 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 03c3dd72fe Highlights: auto-fill episode audio from the feed by season + episode number; v2.46.1
Publish Container / build (push) Successful in 15m38s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 09:09:23 -04:00
nmemmertandClaude Sonnet 5.5 30a7e8a6cb Study: lesson reminders on by default for new accounts; one-time backfill turns them on for existing accounts; v2.46.0
Publish Container / build (push) Successful in 16m7s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 16:53:03 -04:00
nmemmertandClaude Sonnet 5.5 a11adf8ad2 Episodes: Browse Episodes defaults to the current series' season; v2.45.3
Publish Container / build (push) Successful in 16m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:37:35 -04:00
nmemmertandClaude Sonnet 5.5 e2cd66386b Episodes: Podcast Highlights always defaults to the current series tab; v2.45.2
Publish Container / build (push) Successful in 16m24s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 10:01:16 -04:00
nmemmertandClaude Sonnet 5.5 597f996474 Fix mobile nav: tapping Episodes/Studies opens submenu instead of navigating, larger caret, scrollable open menu; v2.45.1
Publish Container / build (push) Successful in 14m27s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 16:10:16 -04:00
nmemmertandClaude Sonnet 5.5 a1adfa0ef1 Scripture Index: verse of the week (admin override or weekly rotation through covered passages); v2.45.0
Publish Container / build (push) Successful in 17m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:50:19 -04:00
nmemmertandClaude Sonnet 5.5 e9d9dd0d50 Analytics: unique visitors this week + probed-URLs list; Scripture Index: most-studied strip + print; admin warning for unparseable passage references; v2.44.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:38:26 -04:00
nmemmertandClaude Sonnet 5.5 6a5d525d6e Fix Scripture Index doubled/unparseable refs, Titus passage fetch 400, Ko-fi QR 404, auto-load study comments; stop probe URLs inflating Real Hits, real 404 status for unknown URLs; v2.43.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 11:30:05 -04:00
nmemmertandClaude Sonnet 5.5 09177f47ed Notes: multiple notes per section with last-edited dates, delete, sort by recently edited; Word export now readable; shared note-format module + tests; v2.43.0
Publish Container / build (push) Successful in 16m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:14:45 -04:00
nmemmertandClaude Sonnet 5.5 401fd451de Fix lint errors, upgrade react-simple-maps to v5 (clears high vuln), My Notes: search other studies + Markdown download; v2.42.2
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:08:08 -04:00
nmemmertandClaude Sonnet 5.5 cf8835119e Update dependencies within semver ranges (react 19.3, tiptap 3.32, express 5.3, etc.); v2.42.1
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:03:03 -04:00
nmemmertandClaude Sonnet 5.5 9710834b16 Notes: raise limits (3,000 notes, 200k chars each), reject instead of truncating oversize notes, save-error feedback, richer editor toolbar + word count, copy note; v2.42.0
Publish Container / build (push) Successful in 19m58s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 07:57:26 -04:00
nmemmertandClaude Sonnet 5.5 ae0016cfee Gospel page audio: choose-your-path narration (case, then Guilty or Not guilty tracks), branching player, three admin audio fields, generator --sample/--only; v2.41.0
Publish Container / build (push) Successful in 16m4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 08:24:33 -04:00
nmemmertandClaude Sonnet 5.5 ad1c3e35b1 Gospel page audio: narration script, ElevenLabs generator, player on /salvation, admin Gospel Page setting; v2.40.0
Publish Container / build (push) Successful in 17m6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 08:06:32 -04:00
nmemmertandClaude Sonnet 5.5 330c354d54 Salvation page: big, glowing 'Enter Your Plea' call-to-action at the end of the record; v2.39.2
Publish Container / build (push) Successful in 16m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 16:22:49 -04:00
nmemmertandClaude Sonnet 5.5 b3e887aef2 Salvation page: redesigned notification email, visitor confirmation email, clear on-screen confirmation and email notice, phone fixes; v2.39.1
Publish Container / build (push) Successful in 15m35s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 16:12:07 -04:00
nmemmertandClaude Sonnet 5.5 27656b84f2 Gospel courtroom page: tabs, plea dropdown, plea email, Acts, accuser/charges, site nav and links; fix service worker for /salvation; courtroom contact type; accept community type; v2.39.0
Publish Container / build (push) Successful in 16m32s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 15:36:37 -04:00
nmemmertandClaude Sonnet 5.5 ea463b5851 Redo detail, study, utility and gate pages; fix admin live preview (CSP frame-src self) and preview every admin section; v2.38.0
Publish Container / build (push) Successful in 15m34s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 14:23:21 -04:00
nmemmertandClaude Sonnet 5.5 5d5c026762 Redo /newsletter and /nuggets pages; add Nuggets episode tags with admin editor; v2.37.0
Publish Container / build (push) Successful in 16m20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 13:49:15 -04:00
nmemmertandClaude Sonnet 5.5 ba35ccab19 Remove local PRISM.mp4; default PRISM video to the files host; v2.36.5
Publish Container / build (push) Successful in 16m4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 12:01:14 -04:00
nmemmertandClaude Sonnet 5.5 a0a9775c8a PRISM video: remount player when URL changes so admin value replaces the default; v2.36.4
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 12:00:21 -04:00
nmemmertandClaude Sonnet 5.5 166ef2e2fb CSP: allow Ko-fi widget, Cloudflare Insights beacon and Truth for Life styles; v2.36.3
Publish Container / build (push) Successful in 15m28s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 11:39:40 -04:00
nmemmertandClaude Sonnet 5.5 88e9d0f7c5 Bump version to 2.36.2
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 11:36:51 -04:00
nmemmertandClaude Sonnet 5.5 059fc7f8c0 Service worker: stop CacheFirst-caching videos under /images/ so replaced videos play; v2.36.2
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 11:36:48 -04:00
nmemmertandClaude Sonnet 5.5 753b5c77cf Study guide button downloads the file directly with no name/email form; v2.36.1
Publish Container / build (push) Successful in 15m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 09:56:20 -04:00
nmemmertandClaude Sonnet 5.5 fed2b6bacc Studies: add a cover image setting, used as the backdrop of the featured study card on the homepage; v2.36.0
Publish Container / build (push) Successful in 16m2s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 09:14:59 -04:00
nmemmertandClaude Sonnet 5.5 82ce312898 Studies: add a Download Study Guide button to the study page, automatic from the linked series/book or chosen per study in admin; v2.35.0
Publish Container / build (push) Successful in 18m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:16:44 -04:00
nmemmertandClaude Sonnet 5.5 b48a81a00c Home page: replace Where to Next card grid with a featured-study card plus an Explore list; v2.34.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:13:57 -04:00
nmemmertandClaude Sonnet 5.5 b04a8b30cd Stronger hover on gold buttons: brighter fill, lift and glow; v2.33.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:10:17 -04:00
nmemmertandClaude Sonnet 5.5 f3fdd32943 Give non-gold buttons and pills a soft gold tint instead of an empty outline; v2.33.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:08:53 -04:00
nmemmertandClaude Sonnet 5.5 ee707fad10 Analytics consent banner redesign: compact corner card with title, privacy link and equal-weight Accept/Decline; v2.33.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:01:33 -04:00
nmemmertandClaude Sonnet 5.5 e82e8222f8 Home page: rename Downloads card and series button to Resources; v2.32.4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 08:00:29 -04:00
nmemmertandClaude Sonnet 5.5 04a572bb6e Home page: center all text in the newsletter, share and Ko-fi cards; v2.32.3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:59:24 -04:00
nmemmertandClaude Sonnet 5.5 448e3eec2b Home page: center the QR codes in the share and Ko-fi cards; v2.32.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:58:36 -04:00
nmemmertandClaude Sonnet 5.5 95aecf739f Home page: add Ko-fi as a third card beside newsletter and share; v2.32.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:58:17 -04:00
nmemmertandClaude Sonnet 5.5 729cf94c12 Home page redesign: latest-episode player beside current series, ways-in cards, finished-books shelf, shortened PRISM, newsletter + share band, quieter 'more from us' group; v2.32.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:56:11 -04:00
nmemmertandClaude Sonnet 5.5 d1c84aec6b Episodes page: show both series lanes (A and B) in the hero with lane-aware series chips; v2.31.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:52:22 -04:00
nmemmertandClaude Sonnet 5.5 f1577e9d8a Episodes + Finished Books redesign: current-series hero, searchable episode browser with series chips, shared episode rows; finished-books library with search, testament/sort filters, featured card, episode counts and badges; v2.31.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 07:47:56 -04:00
nmemmertandClaude Sonnet 5.5 03f46ae683 Scripture Index redesign: search, testament/source filters, book chips, collapsible books, on-demand verse text; v2.30.0
Publish Container / build (push) Successful in 15m16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 17:07:28 -04:00
nmemmertandClaude Sonnet 5.5 9f52437a07 Ask for name and email before sending visitors to Amazon or Apple Books (switchable in admin → Books); v2.29.0
Publish Container / build (push) Successful in 15m46s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:51:51 -04:00
nmemmertandClaude Sonnet 5.5 2c342f14bd About page redesign: photo + bio intro with actions, compact about-the-show card, where-to-start links, testimonials before the support card; v2.28.0
Publish Container / build (push) Successful in 15m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:25:03 -04:00
nmemmertandClaude Sonnet 5.5 3cf7e02561 Contact page redesign: message-type chips with hints, side-by-side names, character count, sticky profile card, form first on phones, compact Ko-fi card; v2.27.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:22:27 -04:00
nmemmertandClaude Sonnet 5.5 d06fcba60b Resources page redesign: tabs + compact grids; download page redesign with site header, two-column card and a mobile-safe form; v2.26.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:19:28 -04:00
nmemmertandClaude Sonnet 5.5 9268514219 Search bar pinned again, now flush under the header at every width (sticky offset follows the header's real height); v2.25.2
Publish Container / build (push) Successful in 15m47s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:10:31 -04:00
nmemmertandClaude Sonnet 5.5 5801af9dfb Search bar scrolls away with the page instead of staying pinned under the header; v2.25.1
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:07:04 -04:00
nmemmertandClaude Sonnet 5.5 d0741d628e Admin shortcut in the account menu, switched on per study user from /admin; v2.25.0
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 16:03:41 -04:00
nmemmertandClaude Sonnet 5.5 853e758bab Desktop type scale: page titles 48px, section headings 32px, hero 64px, About copy 17px; keep certificate title out of the scale; v2.24.0
Publish Container / build (push) Successful in 16m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 15:05:42 -04:00
nmemmertandClaude Sonnet 5.5 99d8b23567 Mobile: one type scale across public pages (h1 32px, hero 40px, h2 24px, nothing under 12px, About copy 17px); v2.23.3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:46:39 -04:00
nmemmertandClaude Sonnet 5.5 5fb44f0490 Header: Follow on Spotify button gets the Spotify icon, solid gold style, hover/focus states and a bigger mobile tap target; v2.23.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:41:16 -04:00
nmemmertandClaude Sonnet 5.5 9e01c04249 Fix sideways scroll on phones: clip the hero glow on narrow screens; v2.23.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:39:18 -04:00
nmemmertandClaude Sonnet 5.5 54575c22ef Header: replace My Account nav link with an account icon in the top right (sign-in link / account menu), collapse header below 900px; v2.23.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:36:22 -04:00
nmemmertandClaude Sonnet 5.5 4a7c3cc967 Q&A: pinned questions are featured on the home page, home cards link to the full answer, newest answer first; v2.22.1
Publish Container / build (push) Successful in 16m39s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:30:04 -04:00
nmemmertandClaude Sonnet 5.5 960ee41375 Rework Q&A: admin inbox with one-step publish, topics and scripture references; simpler public page with ask-a-question; stop exposing asker emails; v2.22.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:27:17 -04:00
nmemmertandClaude Sonnet 5.5 e6bef529e6 Dashboard: Nuggets attention line counts uploads still open in the workflow and clears when finished; v2.21.0
Publish Container / build (push) Successful in 16m6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:02:30 -04:00
nmemmertandClaude Sonnet 5.5 35d3c9ca33 Season Check: series card, study guide, next-season handover and unseasoned-script checks; v2.20.1
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:56:42 -04:00
nmemmertandClaude Sonnet 5.5 50b0e83c2b Add Season Check page and dashboard card: per-lane readiness (season, study, lessons, highlights, feed); v2.20.0
Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:54:00 -04:00
nmemmertandClaude Sonnet 5.5 77bce2ae0c Hold episode highlights until the episode is in the Spotify feed; auto-fill study lesson audio from the feed by season + episode; v2.19.0
Publish Container / build (push) Successful in 16m16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:47:22 -04:00
nmemmertandClaude Sonnet 5.5 69d445013a Podcast workflow: Finished seasons section grouping fully released seasons; v2.18.0
Publish Container / build (push) Successful in 16m19s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 10:34:44 -04:00
nmemmertandClaude Sonnet 5.5 1bf11a3669 Resolve the Seafile host over IPv4 only (musl/Alpine ENOTFOUND on IPv6 NXDOMAIN); v2.17.3
Publish Container / build (push) Successful in 14m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 10:01:13 -04:00
nmemmertandClaude Sonnet 5.5 22c0038ebb Nuggets workflow audio: include failure reason in the error message; v2.17.2
Publish Container / build (push) Successful in 15m27s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 09:38:17 -04:00
nmemmertandClaude Sonnet 5.5 11f3d76e9b Fix Nuggets workflow audio: serve with audio content type (nosniff blocked octet-stream), show playback errors; v2.17.1
Publish Container / build (push) Successful in 16m34s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:56:41 -04:00
nmemmertandClaude Sonnet 5.5 326838e5bc Podcast workflow: episodes start manually via Start new episode (name, season, ep #); scripts no longer create entries; v2.17.0
Publish Container / build (push) Successful in 16m42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:33:23 -04:00
nmemmertandClaude Sonnet 5.5 3a69e03e60 Podcast workflow: per-lane season number set on the page, only that season shown; v2.16.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:28:04 -04:00
nmemmertandClaude Sonnet 5.5 900a3efee3 Podcast workflow: assign lanes by hand, match by season, unique keys for unseasoned episodes; drop old checklist data file; v2.16.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:25:00 -04:00
nmemmertandClaude Sonnet 5.5 ac1b06f945 Fix admin dashboard crash after removing upcoming events (drop Upcoming card); v2.15.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:21:37 -04:00
nmemmertandClaude Sonnet 5.5 28fc822132 Podcast workflow steps: read script, record, edit, upload; v2.15.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:18:51 -04:00
nmemmertandClaude Sonnet 5.5 f181cdb182 Add Podcast Episode Workflow (Lane A / Lane B) with Nuggets steps; released dates from the feed; v2.15.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:16:32 -04:00
nmemmertandClaude Sonnet 5.5 5c2f9113b7 Remove unused calendar styles; v2.14.4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:12:22 -04:00
nmemmertandClaude Sonnet 5.5 13d0eb6651 Remove podcast checklist backend, calendar events and reminder emails; v2.14.3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:11:09 -04:00
nmemmertandClaude Sonnet 5.5 99b19faede Remove Lane A/B production checklist panel from admin; v2.14.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:09:28 -04:00
nmemmertandClaude Sonnet 5.5 66b7bbef52 Remove standalone podcast-checklist app; admin checklist now uses the existing checklist backend; v2.14.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:06:32 -04:00
nmemmertandClaude Sonnet 5.5 d6d2c087b2 Add Podcast Production Checklist (Lane A / Lane B) to admin; v2.14.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 08:04:10 -04:00
nmemmertandClaude Sonnet 5.5 ffb3551b7e Add Nuggets Episode Workflow to admin (listen, cold open, record, edit, Spotify); v2.13.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 07:59:04 -04:00
nmemmertandClaude Sonnet 5.5 eff119e7f0 Redesign study Notes, Reflection questions and Community pages; v2.12.0
Publish Container / build (push) Successful in 15m20s
- Notes: two-pane reader with searchable lesson list and snippets, track chips, friendlier empty states
- Reflection questions: numbered question cards, answered progress bar, unsaved-changes status, sticky save bar, share-to-discussion only when discussion is open
- Community: compact header, lesson browser sidebar with post counts (select on mobile), guidelines card, card-style posts

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 12:00:09 -04:00
nmemmertandClaude Sonnet 5.5 0ab94bfcd4 Discussion stays open for finished studies; only planned studies are closed; v2.11.2
Publish Container / build (push) Successful in 16m10s
A finished study is just no longer the main study, so students who take it later still need discussion.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:52:56 -04:00
nmemmertandClaude Sonnet 5.5 f1d4909acb Discussion only open while a study is active; v2.11.1
Publish Container / build (push) Has been cancelled
- Community posts, replies, likes and lesson comments are refused (403) for finished and planned studies
- Reading a closed study's community/comments returns an empty list with closed:true
- Discussion hub lists only active studies; community page shows a closed notice
- Lesson page hides Lesson Discussion, 'Discuss this lesson' and 'Go to Community' for inactive studies; Community buttons hidden on study/dashboard pages
- Tests for isStudyDiscussionOpen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:49:03 -04:00
nmemmertandClaude Sonnet 5.5 bb83f3e8c5 Completed studies: finishing a study keeps access after leaving; certificates always shown; no reminders for finished studies; v2.11.0
Publish Container / build (push) Successful in 15m31s
- Completion is stored on the user (completedStudySlugs, sticky) when every lesson is done; backfilled at startup
- Lessons, notes, progress, quiz, community and certificate stay open for completed studies after leaving the enrollment
- Dashboard/My Account list completed studies with Review Lessons and certificate access
- Study page shows a completed banner
- Lesson-release reminders, weekly digest and re-engagement emails skip finished studies
- Fix: streak fields (current/longest/last studied) and firstCompletionAt were dropped on every restart when users were loaded
- Tests for completion/access/re-engagement

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:42:58 -04:00
nmemmertandClaude Sonnet 5.5 7301982bb1 Fix certificate View link (wrong /study/certificate URL), certificates in My Account, name on certificate; v2.10.10
Publish Container / build (push) Successful in 15m29s
- Certificate URLs in the account overview and the issuance email pointed at /study/certificate/:token,
  which the router treated as a lesson page; now /certificate/:token (old links redirect)
- My Account Certificates card lists every enrolled study: progress, Generate, View, Copy link, Download PNG, Share
- Certificate canvas used a non-existent /api/study-account route and always printed 'Student'; now reads the account name
- Panel no longer collapses after generating

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:13:44 -04:00
73 changed files with 10725 additions and 7832 deletions

No files matched your search

+10
View File
@@ -55,3 +55,13 @@ data/study-community.json
data/analytics-events.json
data/admin-reply-history.json
data/episode-plays.json
# Episode workflow progress — lives only in the server data volume
data/podcast-workflow.json
data/nuggets-workflow.json
# Live download counters — server data volume only
data/download-counts.json
# Generated audio (upload via Admin → Asset Manager, do not commit)
audio-out/
-1
View File
@@ -141,7 +141,6 @@ siteforge/
| `POST /api/admin-contact-submissions/:id/reply` | Reply to a submission |
| `GET /api/admin/qr-codes` | QR code management |
| `GET /api/admin-episode-scripts/:episodeNumber` | Episode script |
| `GET /api/admin-podcast-checklist` | Podcast checklist state |
| `GET /api/admin-subscribers` | Subscriber list |
---
+3 -3
View File
@@ -42,7 +42,7 @@
"seriesImageUrl": "/images/titus-cover.png",
"seriesListenUrl": "/spotify",
"seriesListenBtnLabel": "Listen to Series",
"seriesDownloadsBtnLabel": "View Downloads",
"seriesDownloadsBtnLabel": "View Resources",
"studyGuideTitle": "Companion Study Guide",
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
"studyGuideDownloadUrl": "",
@@ -1007,7 +1007,7 @@
],
"shareHeading": "Help one more person hear the Word this week.",
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
"prismVideoUrl": "/images/PRISM.mp4",
"prismVideoUrl": "https://files.versebyversewithnate.us/images/PRISM.mp4",
"prismEyebrow": "Featured Video",
"prismHeading": "PRISM",
"prismIntro": "What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?",
@@ -1030,7 +1030,7 @@
},
{
"id": "downloads",
"title": "Downloads",
"title": "Resources",
"description": "Main guide, extra downloads, and past study files.",
"path": "/resources"
},
+3 -3
View File
@@ -43,7 +43,7 @@
"seriesImageUrl": "/images/titus-cover.png",
"seriesListenUrl": "/spotify",
"seriesListenBtnLabel": "Listen to Series",
"seriesDownloadsBtnLabel": "View Downloads",
"seriesDownloadsBtnLabel": "View Resources",
"studyGuideTitle": "Companion Study Guide",
"studyGuideDescription": "Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.",
"studyGuideDownloadUrl": "",
@@ -1061,7 +1061,7 @@
"shareHeading": "Help one more person hear the Word this week.",
"shareP": "Scan the QR code or text the show link to a friend who needs encouragement today.",
"shareQrImageUrl": "/images/qr-code.jpg",
"prismVideoUrl": "/images/PRISM.mp4",
"prismVideoUrl": "https://files.versebyversewithnate.us/images/PRISM.mp4",
"prismEyebrow": "Featured Video",
"prismHeading": "PRISM",
"prismIntro": "What if every time you opened your Bible, you had a clear, repeatable method to actually dig in?",
@@ -1084,7 +1084,7 @@
},
{
"id": "downloads",
"title": "Downloads",
"title": "Resources",
"description": "Main guide, extra downloads, and past study files.",
"path": "/resources"
},
-681
View File
@@ -1,681 +0,0 @@
{
"checklist": {
"tasks": [
{
"id": "verify_script",
"label": "Verify Script",
"phase": "pre"
},
{
"id": "read_script",
"label": "Read Script",
"phase": "pre"
},
{
"id": "record",
"label": "Record",
"phase": "pre"
},
{
"id": "edit",
"label": "Edit",
"phase": "pre"
},
{
"id": "mix",
"label": "Mix",
"phase": "pre"
},
{
"id": "video_script",
"label": "Run Video Conversion Script",
"phase": "pre"
},
{
"id": "post_spotify",
"label": "Post on Spotify",
"phase": "pre"
},
{
"id": "update_website",
"label": "Update Website",
"phase": "post"
},
{
"id": "send_email",
"label": "Send Email",
"phase": "post"
}
],
"episodes": [
{
"id": "titus-11",
"series": "Titus",
"episodeNumber": 11,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "titus-12",
"series": "Titus",
"episodeNumber": 12,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "titus-13",
"series": "Titus",
"episodeNumber": 13,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "titus-14",
"series": "Titus",
"episodeNumber": 14,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "titus-15",
"series": "Titus",
"episodeNumber": 15,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-1",
"series": "Colossians",
"episodeNumber": 1,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-2",
"series": "Colossians",
"episodeNumber": 2,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-3",
"series": "Colossians",
"episodeNumber": 3,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-4",
"series": "Colossians",
"episodeNumber": 4,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-5",
"series": "Colossians",
"episodeNumber": 5,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-6",
"series": "Colossians",
"episodeNumber": 6,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-7",
"series": "Colossians",
"episodeNumber": 7,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-8",
"series": "Colossians",
"episodeNumber": 8,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-9",
"series": "Colossians",
"episodeNumber": 9,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-10",
"series": "Colossians",
"episodeNumber": 10,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-11",
"series": "Colossians",
"episodeNumber": 11,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-12",
"series": "Colossians",
"episodeNumber": 12,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-13",
"series": "Colossians",
"episodeNumber": 13,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-14",
"series": "Colossians",
"episodeNumber": 14,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-15",
"series": "Colossians",
"episodeNumber": 15,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-16",
"series": "Colossians",
"episodeNumber": 16,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-17",
"series": "Colossians",
"episodeNumber": 17,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-18",
"series": "Colossians",
"episodeNumber": 18,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-19",
"series": "Colossians",
"episodeNumber": 19,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-20",
"series": "Colossians",
"episodeNumber": 20,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-21",
"series": "Colossians",
"episodeNumber": 21,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-22",
"series": "Colossians",
"episodeNumber": 22,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-23",
"series": "Colossians",
"episodeNumber": 23,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-24",
"series": "Colossians",
"episodeNumber": 24,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-25",
"series": "Colossians",
"episodeNumber": 25,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-26",
"series": "Colossians",
"episodeNumber": 26,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "colossians-27",
"series": "Colossians",
"episodeNumber": 27,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
},
{
"id": "episode-mpb66z1c",
"series": "Titus -10",
"episodeNumber": 0,
"title": "",
"datePublished": "",
"expanded": false,
"tasks": {
"verify_script": false,
"read_script": false,
"record": false,
"edit": false,
"mix": false,
"video_script": false,
"post_spotify": false,
"update_website": false,
"send_email": false
}
}
]
},
"updatedAt": "2026-05-18T12:18:35.130Z"
}
+1258 -1125
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.10.9",
"version": "2.46.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -38,7 +38,7 @@
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"react-simple-maps": "^3.0.0",
"react-simple-maps": "^5.0.5",
"remark-gfm": "^4.0.1",
"resend": "^6.10.0",
"tar": "^7.5.19"
-18
View File
@@ -1,18 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY server.js ./
COPY public/ ./public/
VOLUME ["/data"]
ENV PORT=3000
ENV DATA_FILE=/data/progress.json
EXPOSE 3000
CMD ["node", "server.js"]
-63
View File
@@ -1,63 +0,0 @@
# Podcast Checklist
Episode production tracker for *Verse by Verse with Nate*.
## Deploy
### 1. Build and push to GHCR
```bash
docker build -t ghcr.io/nmemmert/podcast-checklist:latest .
docker push ghcr.io/nmemmert/podcast-checklist:latest
```
### 2. On the Oracle Cloud VM
```bash
docker pull ghcr.io/nmemmert/podcast-checklist:latest
docker rm -f podcast-checklist 2>/dev/null || true
docker run -d \
--name podcast-checklist \
--restart unless-stopped \
-p 3001:3000 \
-v podcast-checklist-data:/data \
ghcr.io/nmemmert/podcast-checklist:latest
```
> Uses port **3001** on the host so it doesn't clash with your existing site on 3000.
> Progress is saved to a named Docker volume (`podcast-checklist-data`) so it survives container restarts and re-deploys.
### 3. Cloudflare DNS
Add a CNAME record in Cloudflare:
- **Name:** `checklist` (or whatever subdomain you want)
- **Target:** `versebyversewithnate.us`
- **Proxy:** Enabled (orange cloud)
### 4. Cloudflare Tunnel or Nginx proxy
If you're using Nginx Proxy Manager or a Cloudflare Tunnel on the VM, add a rule to forward `checklist.versebyversewithnate.us` → `localhost:3001`.
---
## Update workflow
Same as your site:
```bash
# Local
docker build -t ghcr.io/nmemmert/podcast-checklist:latest .
docker push ghcr.io/nmemmert/podcast-checklist:latest
# SSH into VM
docker pull ghcr.io/nmemmert/podcast-checklist:latest
docker rm -f podcast-checklist
docker run -d \
--name podcast-checklist \
--restart unless-stopped \
-p 3001:3000 \
-v podcast-checklist-data:/data \
ghcr.io/nmemmert/podcast-checklist:latest
```
Progress data is in the named volume and is **not affected** by pulling a new image.
-12
View File
@@ -1,12 +0,0 @@
{
"name": "podcast-checklist",
"version": "1.0.0",
"description": "Verse by Verse with Nate — Episode Production Checklist",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
-401
View File
@@ -1,401 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Episode Checklist — Verse by Verse with Nate</title>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,600&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--black: #0d0d0a;
--card-bg: #111108;
--border: #1e1c14;
--border-mid: #2a2518;
--gold: #c9a84c;
--gold-light: #e0c070;
--warm-white: #f0ead8;
--warm-gray: #7a7060;
--muted: #4a4438;
--dim: #3a3020;
--deep: #252018;
}
body { background: var(--black); color: var(--warm-white); font-family: 'Cormorant Garamond', Georgia, serif; min-height: 100vh; }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: var(--black); }
::-webkit-scrollbar-thumb { background: var(--border-mid); }
.root { max-width: 720px; margin: 0 auto; padding: 48px 20px 100px; }
.masthead { text-align: center; margin-bottom: 40px; }
.eyebrow { font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase; color: var(--warm-gray); margin-bottom: 10px; }
.main-title { font-size: clamp(28px, 6vw, 44px); font-weight: 300; font-style: italic; color: var(--gold); line-height: 1.1; margin-bottom: 4px; }
.subtitle { font-size: 13px; color: var(--muted); letter-spacing: 0.1em; }
.gold-rule { width: 80px; height: 1px; background: linear-gradient(90deg, transparent, var(--gold), transparent); margin: 18px auto; }
.save-status { text-align: center; font-size: 11px; letter-spacing: 0.12em; color: var(--dim); margin-bottom: 24px; height: 16px; transition: color 0.3s; }
.save-status.saving { color: var(--warm-gray); }
.save-status.saved { color: var(--gold); }
.save-status.error { color: #8b3030; }
.overall { background: var(--card-bg); border: 1px solid var(--border); border-radius: 2px; padding: 18px 22px; margin-bottom: 32px; }
.overall-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 10px; }
.overall-label { font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--warm-gray); }
.overall-pct { font-size: 28px; font-weight: 300; color: var(--gold); }
.bar-wrap { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-bottom: 14px; }
.bar-fill { height: 100%; background: linear-gradient(90deg, var(--gold), var(--gold-light)); border-radius: 2px; transition: width 0.4s ease; }
.series-stats { display: flex; gap: 24px; flex-wrap: wrap; }
.stat { font-size: 12px; color: var(--muted); }
.stat b { color: var(--gold); font-weight: 500; }
.filters { display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap; }
.filter-btn { font-family: 'Cormorant Garamond', serif; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; background: none; border: 1px solid var(--border-mid); color: var(--muted); padding: 6px 14px; border-radius: 1px; cursor: pointer; transition: all 0.15s; }
.filter-btn:hover { color: var(--gold); border-color: var(--dim); }
.filter-btn.active { color: var(--gold); border-color: var(--gold); background: var(--card-bg); }
.section-label { font-size: 10px; letter-spacing: 0.3em; text-transform: uppercase; color: var(--dim); margin: 28px 0 12px; padding-left: 2px; }
/* Card */
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 2px; margin-bottom: 8px; overflow: hidden; transition: border-color 0.2s; }
.card:hover { border-color: var(--border-mid); }
.card.complete { border-left: 2px solid var(--gold); }
.card-header { display: flex; align-items: center; gap: 12px; padding: 14px 18px; cursor: pointer; user-select: none; }
.series-tag { font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; padding: 2px 7px; border-radius: 1px; flex-shrink: 0; }
.tag-Titus { color: var(--gold); background: #1a1810; border: 1px solid var(--border-mid); }
.tag-Colossians { color: var(--warm-gray); background: #131310; border: 1px solid var(--border); }
.ep-num { font-size: 13px; font-weight: 600; color: var(--muted); flex-shrink: 0; min-width: 36px; }
.card.complete .ep-num { color: var(--gold); }
.ep-label { flex: 1; font-size: 15px; color: #c8c0a8; }
.card.complete .ep-label { color: var(--muted); }
.pub-date-pill { font-size: 10px; color: var(--warm-gray); font-style: italic; flex-shrink: 0; letter-spacing: 0.03em; }
.task-count { font-size: 11px; color: var(--dim); flex-shrink: 0; }
.card.complete .task-count { color: var(--gold); }
.mini-bar { width: 48px; height: 2px; background: var(--border); border-radius: 1px; overflow: hidden; flex-shrink: 0; }
.mini-fill { height: 100%; background: linear-gradient(90deg, var(--gold), var(--gold-light)); transition: width 0.3s; }
.chevron { font-size: 10px; color: var(--border-mid); transition: transform 0.2s, color 0.15s; flex-shrink: 0; }
.chevron.open { transform: rotate(180deg); color: var(--muted); }
.card-body { border-top: 1px solid #1a1810; padding: 0 18px 18px; }
/* Date published row */
.date-row { display: flex; align-items: center; gap: 12px; padding: 14px 0 12px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
.date-label { font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--warm-gray); flex-shrink: 0; }
.date-input {
background: transparent; border: none; border-bottom: 1px solid var(--border-mid);
outline: none; font-family: 'Cormorant Garamond', serif; font-size: 14px;
color: var(--gold-light); padding: 2px 6px 3px; cursor: pointer;
color-scheme: dark;
}
.date-input:focus { border-bottom-color: var(--gold); }
.date-input::-webkit-calendar-picker-indicator { filter: invert(0.6) sepia(1) saturate(2) hue-rotate(5deg); cursor: pointer; }
/* Phase headers */
.phase-header { font-size: 10px; letter-spacing: 0.25em; text-transform: uppercase; color: var(--warm-gray); padding: 14px 0 6px; border-bottom: 1px solid var(--border); margin-bottom: 2px; display: flex; align-items: center; gap: 8px; }
.phase-header::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.phase-pre { color: var(--warm-gray); }
.phase-post { color: var(--gold); }
.task-list { list-style: none; }
.task-item { display: flex; align-items: flex-start; gap: 12px; padding: 9px 0; border-bottom: 1px solid #141210; cursor: pointer; }
.task-item:last-child { border-bottom: none; }
.check-box { width: 16px; height: 16px; border: 1px solid var(--border-mid); border-radius: 1px; flex-shrink: 0; margin-top: 3px; display: flex; align-items: center; justify-content: center; transition: background 0.15s, border-color 0.15s; }
.task-item.done .check-box { background: var(--gold); border-color: var(--gold); }
.check-svg { display: none; }
.task-item.done .check-svg { display: block; }
.step-num { font-size: 9px; letter-spacing: 0.15em; color: var(--border-mid); margin-top: 4px; flex-shrink: 0; width: 22px; }
.task-body { flex: 1; }
.task-name { font-size: 14px; font-weight: 500; color: #d8d0b8; line-height: 1.3; transition: color 0.15s; }
.task-item.done .task-name { color: var(--dim); text-decoration: line-through; text-decoration-color: var(--border-mid); }
.task-hint { font-size: 12px; color: var(--muted); font-style: italic; margin-top: 1px; }
.task-item.done .task-hint { color: var(--deep); }
.task-item:hover .task-name { color: var(--warm-white); }
.task-item.done:hover .task-name { color: var(--dim); }
.card-footer { display: flex; justify-content: flex-end; padding-top: 10px; margin-top: 4px; border-top: 1px solid #141210; }
.btn-reset { font-family: 'Cormorant Garamond', serif; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; background: none; border: 1px solid var(--border); color: var(--dim); padding: 4px 10px; border-radius: 1px; cursor: pointer; transition: all 0.15s; }
.btn-reset:hover { color: var(--warm-gray); border-color: var(--border-mid); }
.complete-badge { font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--gold); padding: 2px 8px; border: 1px solid var(--border-mid); border-radius: 1px; }
.loading { text-align: center; color: var(--muted); font-style: italic; padding: 40px; font-size: 15px; }
</style>
</head>
<body>
<div class="root">
<div class="masthead">
<div class="eyebrow">Verse by Verse with Nate</div>
<div class="main-title">Production Checklist</div>
<div class="subtitle">Titus · Colossians</div>
<div class="gold-rule"></div>
</div>
<div class="save-status" id="saveStatus"></div>
<div class="overall">
<div class="overall-row">
<div class="overall-label">Overall Progress</div>
<div class="overall-pct" id="overallPct">0%</div>
</div>
<div class="bar-wrap"><div class="bar-fill" id="overallBar" style="width:0%"></div></div>
<div class="series-stats">
<div class="stat">Titus — <b id="titusDone">0</b> / 5 episodes done</div>
<div class="stat">Colossians — <b id="colossDone">0</b> / 27 episodes done</div>
</div>
</div>
<div class="filters">
<button class="filter-btn active" onclick="setFilter('All')">All</button>
<button class="filter-btn" onclick="setFilter('Titus')">Titus</button>
<button class="filter-btn" onclick="setFilter('Colossians')">Colossians</button>
</div>
<div id="app"><div class="loading">Loading…</div></div>
</div>
<script>
// ── Task definitions ────────────────────────────────────────────────────────
const PRE_TASKS = [
{ id: "verify_script", step: "01", label: "Verify Script", desc: "Review for accuracy, theology, and flow" },
{ id: "read_script", step: "02", label: "Read Script", desc: "Read aloud, mark pacing & emphasis" },
{ id: "record", step: "03", label: "Record", desc: "Capture final audio take" },
{ id: "edit", step: "04", label: "Edit", desc: "Trim, cut, clean up in Ferrite / GarageBand" },
{ id: "mix", step: "05", label: "Mix", desc: "Levels, EQ, music beds, final master" },
{ id: "video_script", step: "06", label: "Run Video Conversion Script", desc: "Generate video file from audio" },
{ id: "post_spotify", step: "07", label: "Post on Spotify", desc: "Upload with show notes & discussion questions" },
];
const POST_TASKS = [
{ id: "update_website", step: "08", label: "Update Website", desc: "Add episode highlights to versebyversewithnate.us" },
{ id: "send_email", step: "09", label: "Send Email", desc: "Send newsletter to subscribers via Resend" },
];
const ALL_TASK_IDS = [...PRE_TASKS, ...POST_TASKS].map(t => t.id);
function makeEpisode(series, number) {
const tasks = {};
ALL_TASK_IDS.forEach(id => tasks[id] = false);
return { id: `${series}-${number}`, series, number, tasks, datePublished: "", expanded: false };
}
function buildEpisodes() {
const titus = [11,12,13,14,15].map(n => makeEpisode("Titus", n));
const colossians = Array.from({ length: 27 }, (_, i) => makeEpisode("Colossians", i + 1));
return [...titus, ...colossians];
}
// ── State ───────────────────────────────────────────────────────────────────
let episodes = [];
let activeFilter = "All";
let saveTimer = null;
// ── Server I/O ──────────────────────────────────────────────────────────────
async function loadFromServer() {
try {
const res = await fetch("/api/progress");
const saved = await res.json();
const base = buildEpisodes();
episodes = base.map(ep => {
const s = saved[ep.id];
if (!s) return ep;
return {
...ep,
expanded: s.expanded ?? false,
datePublished: s.datePublished ?? "",
tasks: Object.fromEntries(ALL_TASK_IDS.map(id => [id, s.tasks?.[id] ?? false]))
};
});
} catch {
episodes = buildEpisodes();
}
render();
}
function scheduleSave() {
clearTimeout(saveTimer);
setStatus("saving", "Saving…");
saveTimer = setTimeout(saveToServer, 800);
}
async function saveToServer() {
const payload = {};
episodes.forEach(ep => {
payload[ep.id] = { expanded: ep.expanded, datePublished: ep.datePublished, tasks: ep.tasks };
});
try {
await fetch("/api/progress", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
setStatus("saved", "✦ Saved");
setTimeout(() => setStatus("", ""), 2000);
} catch {
setStatus("error", "Save failed — check connection");
}
}
function setStatus(cls, msg) {
const el = document.getElementById("saveStatus");
el.className = "save-status" + (cls ? " " + cls : "");
el.textContent = msg;
}
// ── Mutations ────────────────────────────────────────────────────────────────
function toggleTask(epId, taskId) {
const ep = episodes.find(e => e.id === epId);
ep.tasks[taskId] = !ep.tasks[taskId];
scheduleSave(); render();
}
function toggleExpand(epId) {
const ep = episodes.find(e => e.id === epId);
ep.expanded = !ep.expanded;
scheduleSave(); render();
}
function setDate(epId, val) {
const ep = episodes.find(e => e.id === epId);
ep.datePublished = val;
scheduleSave(); render();
}
function resetEpisode(epId) {
const ep = episodes.find(e => e.id === epId);
ALL_TASK_IDS.forEach(id => ep.tasks[id] = false);
ep.datePublished = "";
scheduleSave(); render();
}
function setFilter(f) {
activeFilter = f;
document.querySelectorAll(".filter-btn").forEach(btn =>
btn.classList.toggle("active", btn.textContent === f)
);
render();
}
// ── Render ───────────────────────────────────────────────────────────────────
function render() {
updateStats();
const app = document.getElementById("app");
const filtered = activeFilter === "All" ? episodes : episodes.filter(e => e.series === activeFilter);
const titus = filtered.filter(e => e.series === "Titus");
const colossians = filtered.filter(e => e.series === "Colossians");
let html = "";
if (titus.length) {
html += `<div class="section-label">Titus — Episodes 11–15</div>`;
titus.forEach(ep => html += renderCard(ep));
}
if (colossians.length) {
html += `<div class="section-label">Colossians — Episodes 1–27</div>`;
colossians.forEach(ep => html += renderCard(ep));
}
app.innerHTML = html;
}
function formatDate(val) {
if (!val) return "";
const [y, m, d] = val.split("-");
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return `${months[parseInt(m)-1]} ${parseInt(d)}, ${y}`;
}
function renderCard(ep) {
const allDone = ALL_TASK_IDS.every(id => ep.tasks[id]);
const doneCount = ALL_TASK_IDS.filter(id => ep.tasks[id]).length;
const total = ALL_TASK_IDS.length;
const pct = Math.round((doneCount / total) * 100);
const dateDisplay = ep.datePublished ? formatDate(ep.datePublished) : "";
let body = "";
if (ep.expanded) {
// Pre-publish tasks
const preTasks = PRE_TASKS.map(t => renderTask(ep.id, t, ep.tasks[t.id])).join("");
// Post-publish tasks
const postTasks = POST_TASKS.map(t => renderTask(ep.id, t, ep.tasks[t.id])).join("");
const footer = allDone
? `<span class="complete-badge">✦ Complete</span>`
: `<button class="btn-reset" onclick="event.stopPropagation();resetEpisode('${ep.id}')">Reset</button>`;
body = `
<div class="card-body">
<div class="date-row">
<span class="date-label">Date Published</span>
<input type="date" class="date-input" value="${ep.datePublished}"
onchange="setDate('${ep.id}', this.value)"
onclick="event.stopPropagation()" />
${ep.datePublished ? `<span style="font-size:12px;color:var(--warm-gray);font-style:italic;">${formatDate(ep.datePublished)}</span>` : ''}
</div>
<div class="phase-header phase-pre">Pre-Publish</div>
<ul class="task-list">${preTasks}</ul>
<div class="phase-header phase-post" style="margin-top:10px;">Post-Publish</div>
<ul class="task-list">${postTasks}</ul>
<div class="card-footer">${footer}</div>
</div>`;
}
return `
<div class="card${allDone ? " complete" : ""}">
<div class="card-header" onclick="toggleExpand('${ep.id}')">
<span class="series-tag tag-${ep.series}">${ep.series}</span>
<span class="ep-num">Ep ${ep.number}</span>
<span class="ep-label">${ep.series} — Episode ${ep.number}</span>
${dateDisplay ? `<span class="pub-date-pill">${dateDisplay}</span>` : ""}
<span class="task-count">${doneCount}/${total}</span>
<div class="mini-bar"><div class="mini-fill" style="width:${pct}%"></div></div>
<span class="chevron${ep.expanded ? " open" : ""}">▼</span>
</div>
${body}
</div>`;
}
function renderTask(epId, t, done) {
return `
<li class="task-item${done ? " done" : ""}" onclick="toggleTask('${epId}','${t.id}')">
<div class="check-box">
<svg class="check-svg" width="10" height="8" viewBox="0 0 10 8" fill="none">
<path d="M1 4L3.5 6.5L9 1" stroke="#0d0d0a" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<span class="step-num">${t.step}</span>
<div class="task-body">
<div class="task-name">${t.label}</div>
<div class="task-hint">${t.desc}</div>
</div>
</li>`;
}
function updateStats() {
const totalDone = episodes.reduce((a, e) => a + ALL_TASK_IDS.filter(id => e.tasks[id]).length, 0);
const totalTasks = episodes.length * ALL_TASK_IDS.length;
const pct = Math.round((totalDone / totalTasks) * 100);
document.getElementById("overallPct").textContent = pct + "%";
document.getElementById("overallBar").style.width = pct + "%";
document.getElementById("titusDone").textContent =
episodes.filter(e => e.series === "Titus" && ALL_TASK_IDS.every(id => e.tasks[id])).length;
document.getElementById("colossDone").textContent =
episodes.filter(e => e.series === "Colossians" && ALL_TASK_IDS.every(id => e.tasks[id])).length;
}
loadFromServer();
</script>
</body>
</html>
-43
View File
@@ -1,43 +0,0 @@
const express = require("express");
const fs = require("fs");
const path = require("path");
const app = express();
const PORT = process.env.PORT || 3000;
const DATA_FILE = process.env.DATA_FILE || "/data/progress.json";
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
// Ensure data directory and file exist
function ensureDataFile() {
const dir = path.dirname(DATA_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(DATA_FILE)) fs.writeFileSync(DATA_FILE, JSON.stringify({}));
}
// GET progress
app.get("/api/progress", (req, res) => {
try {
ensureDataFile();
const data = JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
res.json(data);
} catch (err) {
console.error("Read error:", err);
res.status(500).json({ error: "Failed to read progress" });
}
});
// POST progress (full state)
app.post("/api/progress", (req, res) => {
try {
ensureDataFile();
fs.writeFileSync(DATA_FILE, JSON.stringify(req.body, null, 2));
res.json({ ok: true });
} catch (err) {
console.error("Write error:", err);
res.status(500).json({ error: "Failed to save progress" });
}
});
app.listen(PORT, () => console.log(`Podcast checklist running on port ${PORT}`));
Binary file not shown.
File diff suppressed because it is too large. Load diff
+148
View File
@@ -0,0 +1,148 @@
// Generates the /salvation audio from the narration files in scripts/salvation/ using ElevenLabs.
// There are three tracks: the case, then the listener chooses Guilty or Not guilty.
//
// node --env-file=.env scripts/generate-salvation-audio.mjs --list-voices # find your voice id
// node --env-file=.env scripts/generate-salvation-audio.mjs --sample # ~40 seconds, to test the voice (cheap)
// node --env-file=.env scripts/generate-salvation-audio.mjs # generate all three MP3s
// node --env-file=.env scripts/generate-salvation-audio.mjs --only=guilty # just one: case | guilty | not-guilty
//
// Needs, in your local .env (never commit it):
// ELEVENLABS_API_KEY=...
// ELEVENLABS_VOICE_ID=... (from --list-voices, or your cloned voice)
// Optional:
// ELEVENLABS_MODEL=eleven_multilingual_v2
// ELEVENLABS_STABILITY=0.5 (lower = more expressive, higher = steadier)
// ELEVENLABS_SPEED=1
//
// Output goes to audio-out/ (git-ignored). Upload the MP3s in Admin → Asset Manager, then paste their
// URLs into Admin → Gospel Page.
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const API = 'https://api.elevenlabs.io/v1'
const apiKey = process.env.ELEVENLABS_API_KEY
const voiceId = process.env.ELEVENLABS_VOICE_ID
const model = process.env.ELEVENLABS_MODEL || 'eleven_multilingual_v2'
const MAX_CHARS = 4500
let totalCost = 0
function fail(message) {
console.error(`\n${message}\n`)
process.exit(1)
}
if (!apiKey) fail('ELEVENLABS_API_KEY is not set. Add it to your local .env file (not to chat or to git).')
async function listVoices() {
const res = await fetch(`${API}/voices`, { headers: { 'xi-api-key': apiKey } })
if (!res.ok) fail(`Could not list voices (${res.status}). Check the API key.`)
const { voices = [] } = await res.json()
console.log('\nYour voices (name: voice id)\n')
for (const v of voices) console.log(` ${v.name}${v.category ? ` [${v.category}]` : ''}: ${v.voice_id}`)
console.log('\nPut the one you want in .env as ELEVENLABS_VOICE_ID=...\n')
}
// Split on paragraph boundaries so each request stays under MAX_CHARS and never cuts a sentence.
function chunkText(text) {
const paragraphs = text.split(/\n{2,}/).map(p => p.trim()).filter(Boolean)
const chunks = []
let current = ''
for (const p of paragraphs) {
if (current && current.length + p.length + 2 > MAX_CHARS) { chunks.push(current); current = '' }
current = current ? `${current}\n\n${p}` : p
}
if (current) chunks.push(current)
return chunks
}
async function speak(text, previous, next) {
const res = await fetch(`${API}/text-to-speech/${voiceId}?output_format=mp3_44100_128`, {
method: 'POST',
headers: { 'xi-api-key': apiKey, 'Content-Type': 'application/json', Accept: 'audio/mpeg' },
body: JSON.stringify({
text,
model_id: model,
voice_settings: {
stability: Number(process.env.ELEVENLABS_STABILITY ?? 0.5),
similarity_boost: 0.75,
style: 0,
use_speaker_boost: true,
speed: Number(process.env.ELEVENLABS_SPEED ?? 1),
},
...(previous ? { previous_text: previous.slice(-600) } : {}),
...(next ? { next_text: next.slice(0, 600) } : {}),
}),
})
if (!res.ok) {
const detail = await res.text().catch(() => '')
fail(`ElevenLabs returned ${res.status}.\n${detail.slice(0, 400)}`)
}
const cost = res.headers.get('character-cost')
if (cost) totalCost += Number(cost) || 0
return Buffer.from(await res.arrayBuffer())
}
const TRACKS = [
{ id: 'case', file: 'narration-1-case.txt', out: 'salvation-case.mp3' },
{ id: 'guilty', file: 'narration-2-guilty.txt', out: 'salvation-guilty.mp3' },
{ id: 'not-guilty', file: 'narration-3-not-guilty.txt', out: 'salvation-not-guilty.mp3' },
]
async function renderTrack(text, outPath) {
const chunks = chunkText(text)
const parts = []
for (let i = 0; i < chunks.length; i += 1) {
process.stdout.write(` part ${i + 1}/${chunks.length}… `)
parts.push(await speak(chunks[i], chunks[i - 1], chunks[i + 1]))
console.log('done')
}
await writeFile(outPath, Buffer.concat(parts))
}
async function main() {
if (process.argv.includes('--list-voices')) { await listVoices(); return }
if (!voiceId) fail('ELEVENLABS_VOICE_ID is not set. Run with --list-voices to find it, then add it to .env.')
const outDir = path.join(ROOT, 'audio-out')
await mkdir(outDir, { recursive: true })
if (process.argv.includes('--sample')) {
const full = (await readFile(path.join(ROOT, 'scripts/salvation', TRACKS[0].file), 'utf8')).trim()
// Whole paragraphs only, so the sample never stops mid-sentence (about 700 characters).
let sample = ''
for (const para of full.split(/\n{2,}/)) {
if (sample && sample.length + para.length > 750) break
sample = sample ? `${sample}\n\n${para}` : para
}
// Don't end on a lone heading like "The Accuser."
const lastBreak = sample.lastIndexOf('\n\n')
if (lastBreak > 0 && sample.length - lastBreak < 40) sample = sample.slice(0, lastBreak)
console.log(`\nSample: ${sample.length} characters (model ${model}).\n`)
const out = path.join(outDir, 'salvation-sample.mp3')
await renderTrack(sample, out)
if (totalCost) console.log(`\nElevenLabs reported ${totalCost.toLocaleString()} credits used.`)
console.log(`\nSaved ${out}\nIf the voice sounds right, run again without --sample.\n`)
return
}
const only = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1]
const tracks = only ? TRACKS.filter(t => t.id === only) : TRACKS
if (!tracks.length) fail(`Unknown --only value "${only}". Use: case, guilty or not-guilty.`)
const texts = []
for (const t of tracks) texts.push((await readFile(path.join(ROOT, 'scripts/salvation', t.file), 'utf8')).trim())
const chars = texts.reduce((n, t) => n + t.length, 0)
console.log(`\n${tracks.length} track(s), ${chars.toLocaleString()} characters in total (model ${model}).`)
console.log('This uses about that many ElevenLabs credits.\n')
for (const [i, t] of tracks.entries()) {
console.log(` ${t.id} (${texts[i].length.toLocaleString()} characters)`)
await renderTrack(texts[i], path.join(outDir, t.out))
}
if (totalCost) console.log(`\nElevenLabs reported ${totalCost.toLocaleString()} credits used.`)
console.log(`\nSaved to ${outDir}:\n${tracks.map(t => ` ${t.out}`).join('\n')}\nListen to each one before you upload it. Check the Scripture names and references.\n`)
}
main().catch(err => fail(err?.message ?? String(err)))
+71
View File
@@ -0,0 +1,71 @@
The Gospel, in a Courtroom.
Imagine a courtroom. Not a courtroom on earth, but the highest court there is: the Court of Heaven. God is the Judge. Satan is the accuser. The charges are your sins, and you are the one on trial. But before the verdict is read, a Substitute has stepped forward to take your place. Let me walk you through the case.
The Judge.
God is perfectly just. He will not overlook wrongdoing. Nothing is hidden from him, and he judges rightly. In the book of Genesis, chapter eighteen, verse twenty-five, Scripture asks: "Will not the Judge of all the earth do right?" And in Acts, chapter seventeen, verse thirty-one: "For He has set a day when He will judge the world with justice by the Man He has appointed."
The Accuser.
Every courtroom has an accuser, and ours is Satan. His very name means "adversary." Scripture calls him the accuser of the brothers, who brings charges against us before God. He does not have to invent anything. He only has to point to what we have actually done. Revelation, chapter twelve, verse ten, calls him "the accuser of our brothers, who accuses them before our God day and night." And First Peter, chapter five, verse eight, warns: "Your adversary the devil prowls around like a roaring lion, looking for someone to devour."
The Charges.
So what are the charges? The charges are the sins you have committed.
Sin is breaking God's law. That includes the wrong things we knowingly do, the good things we fail to do, and the things we say and think in secret.
It also includes breaking his law without knowing it. Not knowing the law has never made anyone innocent. God's standard is written on every conscience, so that no one is without excuse. Even sin done unknowingly still leaves a person guilty.
And God does not grade on a curve. Stumbling at one point means you have broken the whole law. We have all sinned. That includes you, and it includes me.
First John, chapter three, verse four: "Everyone who practices sin practices lawlessness; indeed, sin is lawlessness." James, chapter two, verse ten: "For whoever keeps the whole law but stumbles at just one point is guilty of breaking all of it." Romans, chapter three, verse ten: "There is no one righteous, not even one." And Romans, chapter three, verse twenty-three: "For all have sinned and fall short of the glory of God."
The Verdict.
The evidence is clear, and there is no defense. We cannot argue our way out, and our good deeds cannot cancel our guilt. The sentence for sin is death: separation from the God who made us. Romans, chapter three, verse nineteen: "...so that every mouth may be silenced and the whole world held accountable to God." And Romans, chapter six, verse twenty-three: "For the wages of sin is death..."
But that is not the end of the story.
The Substitute.
Then God himself stepped forward to take your place.
Jesus is God. The eternal Son took on flesh and was born as a baby in Bethlehem, and he came for one purpose: to take the place of sinners like you and me. He did not come to be served. He came to give his life, and the cross was always where the road was heading.
He lived the perfect life you could not live. On the cross he took the sentence that belonged to you, and he rose again on the third day to prove the payment was accepted.
John, chapter one, verse fourteen: "The Word became flesh and dwelt among us." Matthew, chapter one, verse twenty-one: "She will bear a Son, and you are to name Him Jesus, because He will save His people from their sins." Mark, chapter ten, verse forty-five: "For even the Son of Man did not come to be served, but to serve, and to give His life as a ransom for many." First Peter, chapter three, verse eighteen: "For Christ also suffered once for sins, the righteous for the unrighteous, to bring you to God." Second Corinthians, chapter five, verse twenty-one: "God made Him who knew no sin to be sin for us, so that in Him we might become the righteousness of God." And Romans, chapter five, verse eight: "But God proves His love for us in this: While we were still sinners, Christ died for us."
The Pardon.
The pardon is a gift. It cannot be earned or bought. You receive it by turning from your sin and trusting Jesus alone. When you do, God declares you forgiven and counts you righteous because of what Christ has done.
Ephesians, chapter two, verses eight and nine: "For by grace you have been saved through faith, and this is not from yourselves, but it is the gift of God, not by works, so that no one can boast." And Romans, chapter ten, verse nine: "If you confess with your mouth that Jesus is Lord, and believe in your heart that God raised Him from the dead, you will be saved."
The Result.
Case closed. The accuser has nothing left to say. Our sins were real charges, but they have been answered, because the penalty has been paid in full by the Substitute. The one who trusts in Christ is not waiting nervously for a verdict. The verdict has already been given in their favor.
Romans, chapter eight, verse one: "Therefore, there is now no condemnation for those who are in Christ Jesus." And verses thirty-three and thirty-four: "Who can bring an accusation against those whom God has chosen? It is God who justifies. Who then can condemn? Christ Jesus, who died, more than that, was raised to life, is at the right hand of God, and He is interceding for us."
Before you plead.
Before you plead, read what Jesus did for you. Everything you have just heard tells you that the Substitute took your place. The record shows you how: the night he was arrested, the trials where he was declared innocent and condemned anyway, the long road to the cross, and the empty tomb on the third day.
Please do not skip it. It is the story of what Jesus did for you, told by four witnesses and woven into one account. Take your time and read it slowly. You can pause this audio at any time, read the record, and then come back and press play. When you are ready, continue.
How do you plead?
The court is not asking whether you are a good person. It is asking you to be honest about the charges, and to decide where you will stand: on your own record, or on what Jesus has done in your place.
You have two choices.
You can plead guilty. That means: I admit it. I have broken God's law, whether I knew it or not, and I cannot save myself. I am not asking the court to find me innocent. I am asking for the pardon Jesus paid for with his life. And here is what happens: the Judge declares you forgiven, and counts you righteous because of Christ. The accuser has nothing left to say.
Or you can plead not guilty. That means: I stand on my own record. I believe I have kept God's law well enough to be found innocent, and I do not need anyone to take my place. That plea has a cost, and I will explain it if you choose it.
Now look at the two buttons below the player. When you are ready, press the one that matches your plea, and I will walk you through what happens next.
Take a moment. Be honest with God, and honest with yourself.
+19
View File
@@ -0,0 +1,19 @@
You have pleaded guilty. That is the beginning of the good news.
You are not earning anything by saying this. You are receiving a gift. Romans, chapter six, verse twenty-three: "For the wages of sin is death, but the gift of God is eternal life in Christ Jesus our Lord." Here is what to do next.
First, tell God. Words do not save you. Trusting Jesus does. But you can say it to him in your own words, or you can pray this prayer. If you mean it, pray it out loud with me.
"Lord God, I plead guilty. I have sinned against you, and I cannot save myself. I believe Jesus Christ took my place on the cross, died for my sins, and rose again. I turn from my sin, and I trust him alone. Thank you for the pardon I could never earn. Make me new, and help me follow you. Amen."
Second, believe it is finished. If you meant that prayer, God has forgiven you. Romans, chapter eight, verse one: "Therefore, there is now no condemnation for those who are in Christ Jesus." Your feelings may go up and down, but the verdict does not change.
Third, tell someone. Tell a Christian friend or family member what you decided.
Fourth, start reading. Begin with the Gospel of John, then keep going through the New Testament, and talk to God every day.
Fifth, find a church. Join a Bible-teaching church where you can grow, and be baptized as a public sign of your faith. Acts, chapter two, verse thirty-eight.
And if you prayed that prayer and meant it, we would love to rejoice with you. Press the button below that says, Let us know. You can leave your name and your email, and someone from our team will reach out to you personally. It is completely optional.
Welcome to the family of God.
@@ -0,0 +1,23 @@
You have pleaded not guilty. Please be sure you understand what that means.
Pleading not guilty is not just saying, "I am not a bad person." It is telling the Judge, "I have kept your law perfectly, and I need no one to take my place."
God's standard is not "better than most people." It is perfection. Matthew, chapter five, verse forty-eight: "Be perfect, therefore, as your heavenly Father is perfect." And breaking that standard at even one point makes a person guilty of breaking all of it. James, chapter two, verse ten: "For whoever keeps the whole law but stumbles at just one point is guilty of breaking all of it." That includes the things done without knowing they were wrong.
So test your plea honestly.
Have you ever told a lie, even a small one?
Have you ever taken something that was not yours?
Have you ever used God's name carelessly, or put something else ahead of him: money, a person, yourself?
Have you ever hated someone in your heart, or looked at someone with lust? Jesus said those count too. Matthew, chapter five.
If the answer to any of those is yes, then by God's own standard, the plea does not hold. First John, chapter one, verse eight: "If we say that we have no sin, we are deceiving ourselves, and the truth is not in us."
And here is what a not guilty plea costs. It means standing before the Judge on your own record, with no Substitute. The sentence for sin stands. Romans, chapter six, verse twenty-three. But that is not what God wants for you. He says he takes no pleasure in anyone's death. Ezekiel, chapter thirty-three, verse eleven. And the offer of pardon is open right now. Second Corinthians, chapter six, verse two: "Now is the day of salvation."
If you are not sure, that is okay. Questions and doubts are fine. Asking honestly is not the same as refusing.
If you would like to change your plea, press the button below that says, Change my plea to guilty. You can also read the record of what Jesus did for you, or ask us a question. Whatever you decide, thank you for listening honestly. The offer of the pardon is still open.
+33 -14
View File
@@ -21,15 +21,14 @@ import {
loadDownloadCountsFromDisk,
loadQrCodesFromDisk,
loadEpisodePlaysFromDisk,
loadPodcastChecklistFromDisk,
loadAnalyticsEventsFromDisk,
loadEmailSettingsFromDisk,
loadCalendarEventsFromDisk,
loadAuditLogFromDisk,
loadWebhooksFromDisk,
loadStudySessionsFromDisk,
queueStudySessionsWrite,
loadNewsletterIssuesFromDisk,
loadNuggetsTagsFromDisk,
createBackupSnapshot,
refreshContentCaches,
queueHitStatsWrite,
@@ -40,9 +39,12 @@ import {
sanitizeUserAgent,
shouldCountHit,
recordHit,
isKnownSitePath,
scheduleStudyReminders,
scheduleReengagementEmails,
scheduleWeeklyDigestEmails,
backfillCompletedStudies,
backfillReminderDefault,
} from './server/study-helpers.js'
import { recordVisitor } from './server/routes/analytics.js'
@@ -59,8 +61,6 @@ import { register as registerStudyComments } from './server/routes/study-comment
import { register as registerStudyCertificate } from './server/routes/study-certificate.js'
import { register as registerEpisodeScripts } from './server/routes/episode-scripts.js'
import { register as registerContact } from './server/routes/contact.js'
import { register as registerCalendarEvents } from './server/routes/calendar-events.js'
import { startReminderScheduler } from './server/reminder.js'
import { register as registerInboundEmail } from './server/routes/inbound-email.js'
import { register as registerQuestions } from './server/routes/questions.js'
import { register as registerAnalytics } from './server/routes/analytics.js'
@@ -68,13 +68,21 @@ import { register as registerDownloads } from './server/routes/downloads.js'
import { register as registerEpisodes } from './server/routes/episodes.js'
import { register as registerNuggets } from './server/routes/nuggets.js'
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js'
import { register as registerPodcastWorkflow } from './server/routes/podcast-workflow.js'
import { register as registerSeasonCheck } from './server/season-check.js'
import { warmFeedSnapshot } from './server/feed-release.js'
import { preferIpv4ForSeafile } from './server/seafile-dns.js'
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
import { register as registerFeed } from './server/routes/feed.js'
import { register as registerQrCodes } from './server/routes/qr-codes.js'
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
import { register as registerNewsletter } from './server/routes/newsletter.js'
import { register as registerSalvation } from './server/routes/salvation.js'
import { register as registerPublic } from './server/routes/public.js'
preferIpv4ForSeafile()
const app = express()
app.use(express.json({ limit: '10mb' }))
const trustProxyHops = Number(process.env.TRUST_PROXY_HOPS ?? 1)
@@ -92,12 +100,12 @@ app.use((_req, res, next) => {
'Content-Security-Policy',
[
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://www.truthforlife.org https://ajax.googleapis.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"script-src 'self' 'unsafe-inline' https://www.truthforlife.org https://ajax.googleapis.com https://storage.ko-fi.com https://static.cloudflareinsights.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://www.truthforlife.org",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: https:",
"media-src 'self' https:",
"frame-src https:",
"frame-src 'self' https:",
"connect-src 'self' https:",
].join('; '),
)
@@ -117,7 +125,6 @@ registerStudyComments(app)
registerStudyCertificate(app)
registerEpisodeScripts(app)
registerContact(app)
registerCalendarEvents(app)
registerInboundEmail(app)
registerQuestions(app)
registerAnalytics(app)
@@ -125,16 +132,21 @@ registerDownloads(app)
registerEpisodes(app)
registerNuggets(app)
registerNuggetsHostUpload(app)
registerNuggetsWorkflow(app)
registerPodcastWorkflow(app)
registerSeasonCheck(app)
registerFeed(app)
registerQrCodes(app)
registerBiblePassage(app)
registerNewsletter(app)
registerSalvation(app)
// Hit-counting middleware (must come before public routes)
app.use((req, res, next) => {
if (shouldCountHit(req)) {
const ua = sanitizeUserAgent(req.get('user-agent'))
const botDetection = detectBot(ua)
const knownPath = isKnownSitePath(req.path)
const botDetection = knownPath ? detectBot(ua) : { isBot: true, reason: 'unknown-path' }
recordHit(req.path, botDetection.isBot, botDetection.reason)
queueHitStatsWrite()
if (hasVisitorConsent(req) && !botDetection.isBot && !isValidAdminSession(req)) {
@@ -182,27 +194,36 @@ Promise.all([
loadDownloadCountsFromDisk(),
loadQrCodesFromDisk(),
loadEpisodePlaysFromDisk(),
loadPodcastChecklistFromDisk(),
loadAnalyticsEventsFromDisk(),
loadEmailSettingsFromDisk(),
loadCalendarEventsFromDisk(),
loadAuditLogFromDisk(),
loadWebhooksFromDisk(),
loadStudySessionsFromDisk(),
loadNewsletterIssuesFromDisk(),
loadNuggetsTagsFromDisk(),
refreshContentCaches(),
])
.catch(err => {
console.error('[stats] failed to load persisted stats:', err)
})
.then(() => backfillCompletedStudies())
.then(updated => { if (updated > 0) console.log(`[study] marked ${updated} finished studies as completed`) })
.catch(err => {
console.error('[study] completed-study backfill failed:', err)
})
.then(() => backfillReminderDefault())
.then(updated => { if (updated > 0) console.log(`[study] turned lesson reminders on for ${updated} existing accounts`) })
.catch(err => {
console.error('[study] reminder-default backfill failed:', err)
})
.finally(() => {
createBackupSnapshot('startup').catch(() => {})
setInterval(() => {
createBackupSnapshot('scheduled').catch(() => {})
}, BACKUP_INTERVAL_MS)
startReminderScheduler()
startNuggetsSeafileWatcher()
warmFeedSnapshot()
// Purge expired study sessions every hour
setInterval(() => {
@@ -273,7 +294,6 @@ Promise.all([
state.draftQuestionsWritePromise,
state.replyTemplatesWritePromise,
state.replyHistoryWritePromise,
state.podcastChecklistWritePromise,
state.studyUsersWritePromise,
state.studyCommunityWritePromise,
state.studyRemindersWritePromise,
@@ -285,7 +305,6 @@ Promise.all([
state.analyticsEventsWritePromise,
state.qrCodesWritePromise,
state.emailSettingsWritePromise,
state.calendarEventsWritePromise,
state.auditLogWritePromise,
state.webhooksWritePromise,
state.studySessionsWritePromise,
+4 -42
View File
@@ -47,7 +47,6 @@ export const STUDY_PROGRESS_DIR = path.join(DATA_DIR, 'study-progress')
export const STUDY_COMMUNITY_FILE = path.join(DATA_DIR, 'study-community.json')
export const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
export const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
export const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
export const BACKUP_DIR = path.join(DATA_DIR, 'backups')
export const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
export const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
@@ -70,11 +69,11 @@ export const SEAFILE_NUGGETS_SHARE_URL = process.env.SEAFILE_NUGGETS_SHARE_URL ?
export const NUGGETS_NOTIFY_EMAIL = process.env.NUGGETS_NOTIFY_EMAIL ?? process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'
export const SEAFILE_POLL_INTERVAL_MS = 15 * 60 * 1000 // 15 minutes
export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json')
export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json')
export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json')
export const NEWSLETTER_ISSUES_FILE = path.join(DATA_DIR, 'newsletter-issues.json')
export const NUGGETS_TAGS_FILE = path.join(DATA_DIR, 'nuggets-tags.json')
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
// Seafile integration for Nuggets episode uploads
@@ -123,8 +122,8 @@ export const MAX_STUDY_COMMENTS = 10000
export const MAX_STUDY_COMMENT_LENGTH = 2000
export const MAX_STUDY_USERS = 5000
export const MAX_STUDY_ENROLLMENTS_PER_USER = 100
export const MAX_STUDY_NOTES_PER_USER = 500
export const MAX_STUDY_NOTE_LENGTH = 12000
export const MAX_STUDY_NOTES_PER_USER = 3000
export const MAX_STUDY_NOTE_LENGTH = 200000
export const EMAIL_CHANGE_TOKEN_TTL_MS = 24 * 60 * 60 * 1000
export const EMAIL_VERIFY_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000
export const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000
@@ -164,18 +163,6 @@ export const DEFAULT_REPLY_TEMPLATES = [
},
]
export const DEFAULT_PODCAST_CHECKLIST_TASKS = [
{ id: 'verify_script', label: 'Verify Script', phase: 'pre' },
{ id: 'read_script', label: 'Read Script', phase: 'pre' },
{ id: 'record', label: 'Record', phase: 'pre' },
{ id: 'mix', label: 'Mix', phase: 'pre' },
{ id: 'edit', label: 'Edit', phase: 'pre' },
{ id: 'video_script', label: 'Run Video Conversion Script', phase: 'pre' },
{ id: 'post_spotify', label: 'Post on Spotify', phase: 'pre' },
{ id: 'update_website', label: 'Update Website', phase: 'post' },
{ id: 'send_email', label: 'Send Email', phase: 'post' },
]
export const EMPTY_HIT_STATS = {
totalHits: 0,
realHits: 0,
@@ -185,6 +172,7 @@ export const EMPTY_HIT_STATS = {
byPath: {},
byPathReal: {},
byPathBot: {},
byPathUnknown: {},
byDay: {},
byDayReal: {},
byDayBot: {},
@@ -203,29 +191,3 @@ export const EMPTY_VISITOR_STATS = {
geoCacheByIp: {},
}
// ── Podcast checklist helpers (no state dependency) ────────────────────────
function buildChecklistEpisode(series, number) {
const tasks = {}
for (const task of DEFAULT_PODCAST_CHECKLIST_TASKS) {
tasks[task.id] = false
}
return {
id: `${series.toLowerCase()}-${number}`,
series,
episodeNumber: number,
title: '',
datePublished: '',
expanded: false,
tasks,
}
}
export function buildDefaultPodcastChecklist() {
const titusEpisodes = [11, 12, 13, 14, 15].map(number => buildChecklistEpisode('Titus', number))
const colossiansEpisodes = Array.from({ length: 27 }, (_, index) => buildChecklistEpisode('Colossians', index + 1))
return {
tasks: DEFAULT_PODCAST_CHECKLIST_TASKS,
episodes: [...titusEpisodes, ...colossiansEpisodes],
}
}
+39 -134
View File
@@ -26,7 +26,6 @@ import {
QR_CODES_FILE,
REPLY_TEMPLATES_FILE,
REPLY_HISTORY_FILE,
PODCAST_CHECKLIST_FILE,
BACKUP_DIR,
UPLOADS_DIR,
UPLOADS_META_FILE,
@@ -34,11 +33,11 @@ import {
EPISODE_PLAYS_FILE,
ANALYTICS_EVENTS_FILE,
EMAIL_SETTINGS_FILE,
CALENDAR_EVENTS_FILE,
AUDIT_LOG_FILE,
WEBHOOKS_FILE,
STUDY_SESSIONS_FILE,
NEWSLETTER_ISSUES_FILE,
NUGGETS_TAGS_FILE,
EMPTY_HIT_STATS,
EMPTY_VISITOR_STATS,
DEFAULT_REPLY_TEMPLATES,
@@ -51,8 +50,6 @@ import {
MAX_STUDY_NOTES_PER_USER,
MAX_STUDY_NOTE_LENGTH,
MAX_STUDY_COMMENTS,
DEFAULT_PODCAST_CHECKLIST_TASKS,
buildDefaultPodcastChecklist,
} from './config.js'
import { state } from './state.js'
@@ -170,6 +167,7 @@ export function loadHitStatsFromDisk() {
byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {},
byPathReal: parsed?.byPathReal && typeof parsed.byPathReal === 'object' ? parsed.byPathReal : {},
byPathBot: parsed?.byPathBot && typeof parsed.byPathBot === 'object' ? parsed.byPathBot : {},
byPathUnknown: parsed?.byPathUnknown && typeof parsed.byPathUnknown === 'object' ? parsed.byPathUnknown : {},
byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {},
byDayReal: parsed?.byDayReal && typeof parsed.byDayReal === 'object' ? parsed.byDayReal : {},
byDayBot: parsed?.byDayBot && typeof parsed.byDayBot === 'object' ? parsed.byDayBot : {},
@@ -424,36 +422,6 @@ export function loadEmailSettingsFromDisk() {
})
}
// ── Calendar events ────────────────────────────────────────────────────────
export function queueCalendarEventsWrite() {
state.calendarEventsWritePromise = state.calendarEventsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
CALENDAR_EVENTS_FILE,
JSON.stringify({ events: state.calendarEvents, updatedAt: new Date().toISOString() }, null, 2),
'utf8',
)
})
.catch(err => {
console.error('[calendar-events] failed to write:', err)
})
}
export function loadCalendarEventsFromDisk() {
return readFile(CALENDAR_EVENTS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed?.events)) {
state.calendarEvents = parsed.events
}
})
.catch(() => {
// No file yet — start with empty array
})
}
// ── Audit log ─────────────────────────────────────────────────────────────
export function appendAuditEntry(action, details) {
@@ -552,32 +520,6 @@ export async function fireWebhooks(eventType, payload) {
}
}
// ── Podcast checklist ──────────────────────────────────────────────────────
export function queuePodcastChecklistWrite() {
const updatedAt = new Date().toISOString()
state.podcastChecklistWritePromise = state.podcastChecklistWritePromise.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
PODCAST_CHECKLIST_FILE,
JSON.stringify({ checklist: state.podcastChecklist, updatedAt }, null, 2),
'utf8',
)
})
return state.podcastChecklistWritePromise
}
export function loadPodcastChecklistFromDisk() {
return readFile(PODCAST_CHECKLIST_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.podcastChecklist = sanitizePodcastChecklist(parsed?.checklist)
})
.catch(() => {
state.podcastChecklist = buildDefaultPodcastChecklist()
})
}
// ── Study users ────────────────────────────────────────────────────────────
export function queueStudyUsersWrite() {
@@ -1074,7 +1016,6 @@ export async function createBackupSnapshot(reason = 'scheduled') {
reason,
adminContent: null,
draftContent: null,
podcastChecklist: state.podcastChecklist,
publishState: state.publishState,
hitStats: state.hitStats,
visitorStats: state.visitorStats,
@@ -1200,7 +1141,6 @@ export async function restoreFromBackup(filename) {
state.contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
state.replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
state.replyHistory = sanitizeReplyHistory(parsed?.replyHistory)
state.podcastChecklist = sanitizePodcastChecklist(parsed?.podcastChecklist)
queueHitStatsWrite()
queueVisitorStatsWrite()
@@ -1209,7 +1149,6 @@ export async function restoreFromBackup(filename) {
queueContactSubmissionsWrite()
queueReplyTemplatesWrite()
queueReplyHistoryWrite()
queuePodcastChecklistWrite()
await Promise.all([
state.hitStatsWritePromise,
@@ -1219,7 +1158,6 @@ export async function restoreFromBackup(filename) {
state.contactSubmissionsWritePromise,
state.replyTemplatesWritePromise,
state.replyHistoryWritePromise,
state.podcastChecklistWritePromise,
])
await refreshContentCaches()
await createBackupSnapshot('post-restore')
@@ -1229,7 +1167,7 @@ export async function restoreFromBackup(filename) {
// (These are defined here rather than study-helpers to avoid circular imports)
export function normalizeMessageType(value) {
if (value === 'question' || value === 'testimony' || value === 'topic') return value
if (value === 'question' || value === 'testimony' || value === 'topic' || value === 'gospel' || value === 'community') return value
return 'general'
}
@@ -1313,6 +1251,7 @@ function sanitizeLoadedHitStats(value) {
byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {},
byPathReal: value?.byPathReal && typeof value.byPathReal === 'object' ? value.byPathReal : {},
byPathBot: value?.byPathBot && typeof value.byPathBot === 'object' ? value.byPathBot : {},
byPathUnknown: value?.byPathUnknown && typeof value.byPathUnknown === 'object' ? value.byPathUnknown : {},
byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {},
byDayReal: value?.byDayReal && typeof value.byDayReal === 'object' ? value.byDayReal : {},
byDayBot: value?.byDayBot && typeof value.byDayBot === 'object' ? value.byDayBot : {},
@@ -1393,75 +1332,6 @@ export function sanitizeReplyHistory(value) {
}))
}
function sanitizeChecklistTask(task) {
const label = typeof task?.label === 'string' ? task.label.trim().slice(0, 120) : ''
if (!label) return null
const phase = task?.phase === 'post' ? 'post' : 'pre'
const id = typeof task?.id === 'string' && task.id.trim() ? task.id.trim() : randomUUID()
return { id, label, phase }
}
export function sanitizePodcastChecklist(value) {
const fallback = buildDefaultPodcastChecklist()
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
const taskInput = Array.isArray(source.tasks) ? source.tasks : fallback.tasks
const seenTaskIds = new Set()
const tasks = []
for (const item of taskInput) {
const safeTask = sanitizeChecklistTask(item)
if (!safeTask) continue
if (seenTaskIds.has(safeTask.id)) continue
seenTaskIds.add(safeTask.id)
tasks.push(safeTask)
}
if (tasks.length === 0) {
for (const task of fallback.tasks) {
tasks.push({ ...task })
seenTaskIds.add(task.id)
}
}
const taskIds = tasks.map(task => task.id)
const episodesInput = Array.isArray(source.episodes) ? source.episodes : fallback.episodes
const episodes = []
for (const item of episodesInput) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
const id = typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID()
const series = typeof item.series === 'string' ? item.series.trim().slice(0, 80) : ''
const title = typeof item.title === 'string' ? item.title.trim().slice(0, 180) : ''
const rawEpisodeNumber = Number(item.episodeNumber)
const episodeNumber = Number.isFinite(rawEpisodeNumber) && rawEpisodeNumber >= 0
? Math.round(rawEpisodeNumber)
: null
const datePublished = typeof item.datePublished === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(item.datePublished.trim())
? item.datePublished.trim()
: ''
const sourceTasks = item.tasks && typeof item.tasks === 'object' && !Array.isArray(item.tasks)
? item.tasks
: {}
const taskState = {}
for (const taskId of taskIds) {
taskState[taskId] = sourceTasks[taskId] === true
}
const reminderDays = Number.isFinite(Number(item.reminderDays)) && Number(item.reminderDays) >= 0 ? Number(item.reminderDays) : 0
const reminderSentAt = typeof item.reminderSentAt === 'string' && item.reminderSentAt ? item.reminderSentAt : undefined
const startTime = typeof item.startTime === 'string' && /^\d{2}:\d{2}$/.test(item.startTime) ? item.startTime : undefined
const PROD_STATUSES = ['idea', 'recorded', 'edited', 'scheduled', 'published']
const productionStatus = PROD_STATUSES.includes(item.productionStatus) ? item.productionStatus : undefined
episodes.push({ id, series, episodeNumber, title, datePublished, expanded: item.expanded === true, tasks: taskState, reminderDays, reminderSentAt, ...(startTime ? { startTime } : {}), ...(productionStatus ? { productionStatus } : {}) })
}
if (episodes.length === 0) {
return fallback
}
return { tasks, episodes }
}
function normalizeStudyUsername(value) {
if (typeof value !== 'string') return ''
return value.trim().toLowerCase()
@@ -1489,6 +1359,9 @@ export function sanitizeStudyUsers(value) {
const enrolledStudySlugs = Array.isArray(item?.enrolledStudySlugs)
? Array.from(new Set(item.enrolledStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER)
: []
const completedStudySlugs = Array.isArray(item?.completedStudySlugs)
? Array.from(new Set(item.completedStudySlugs.map(normalizeStudySlug).filter(Boolean))).slice(0, MAX_STUDY_ENROLLMENTS_PER_USER * 4)
: []
const displayName = typeof item?.displayName === 'string' ? item.displayName.trim().slice(0, 80) : ''
const subscribeNewsletter = item?.subscribeNewsletter !== false
const studyRemindersEnabled = item?.studyRemindersEnabled === true
@@ -1523,6 +1396,13 @@ export function sanitizeStudyUsers(value) {
subscribeNewsletter,
studyRemindersEnabled,
enrolledStudySlugs,
completedStudySlugs,
// Only the admin panel can set this (see PATCH /api/admin/study-users/:id)
adminShortcut: item?.adminShortcut === true,
currentStreak: Number.isFinite(item?.currentStreak) && item.currentStreak > 0 ? Math.floor(item.currentStreak) : 0,
longestStreak: Number.isFinite(item?.longestStreak) && item.longestStreak > 0 ? Math.floor(item.longestStreak) : 0,
lastStudiedDate: typeof item?.lastStudiedDate === 'string' ? item.lastStudiedDate : null,
firstCompletionAt: typeof item?.firstCompletionAt === 'string' ? item.firstCompletionAt : null,
avatarUrl: typeof item?.avatarUrl === 'string' ? item.avatarUrl.trim() : '',
createdAt: typeof item?.createdAt === 'string' ? item.createdAt : null,
updatedAt: typeof item?.updatedAt === 'string' ? item.updatedAt : null,
@@ -1541,6 +1421,7 @@ export function sanitizeStudyUsers(value) {
remindersPausedUntil: typeof item?.remindersPausedUntil === 'string' ? item.remindersPausedUntil : null,
reminderFrequency: item?.reminderFrequency === 'weekly' ? 'weekly' : 'immediate',
weeklyDigestSentAt: typeof item?.weeklyDigestSentAt === 'string' ? item.weeklyDigestSentAt : null,
remindersDefaultedAt: typeof item?.remindersDefaultedAt === 'string' ? item.remindersDefaultedAt : null,
})
}
return out.slice(0, MAX_STUDY_USERS)
@@ -1711,3 +1592,27 @@ export function loadNewsletterIssuesFromDisk() {
state.newsletterIssues = []
})
}
// ── Nuggets Episode Tags ─────────────────────────────────────────────────────
export function queueNuggetsTagsWrite() {
state.nuggetsEpisodeTagsWritePromise = state.nuggetsEpisodeTagsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(NUGGETS_TAGS_FILE, JSON.stringify({ tags: state.nuggetsEpisodeTags }, null, 2), 'utf8')
})
.catch(err => {
console.error('[nuggets-tags] failed to write:', err)
})
}
export function loadNuggetsTagsFromDisk() {
return readFile(NUGGETS_TAGS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.nuggetsEpisodeTags = parsed?.tags && typeof parsed.tags === 'object' ? parsed.tags : {}
})
.catch(() => {
state.nuggetsEpisodeTags = {}
})
}
+89
View File
@@ -0,0 +1,89 @@
import { fetchAllEpisodes } from './routes/episodes.js'
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
// The Spotify (Anchor) RSS feed only lists episodes that are live, so a season + episode number
// showing up there means the episode has been published. That drives two things on the public site:
// - highlights marked "hold until released" stay hidden until their episode is in the feed
// - highlights and study lessons with no audio of their own pick it up from the matching feed episode
const SNAPSHOT_TTL_MS = 5 * 60 * 1000
let snapshot = { items: [], at: 0 }
let inflight = null
function refreshSnapshot() {
if (!inflight) {
inflight = fetchAllEpisodes()
.then(items => { snapshot = { items, at: Date.now() } })
// Keep serving the last good copy; retry on the next TTL window
.catch(() => { snapshot = { items: snapshot.items, at: Date.now() } })
.finally(() => { inflight = null })
}
return inflight
}
export async function getFeedIndex() {
if (!snapshot.at) await refreshSnapshot()
else if (Date.now() - snapshot.at > SNAPSHOT_TTL_MS) refreshSnapshot()
const index = new Map()
for (const item of snapshot.items) {
if (item.season && /^\d+$/.test(item.episode)) index.set(`${item.season}-${item.episode}`, item)
}
return index
}
export function warmFeedSnapshot() {
return refreshSnapshot()
}
// Returns a copy of the published site content with the feed applied. Run it before
// filterSiteContentByReleaseDate so audio is never added to a lesson that is still locked.
export async function applyFeedRelease(content) {
if (!content || typeof content !== 'object' || Array.isArray(content)) return content
const links = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
const studies = Array.isArray(content.studies) ? content.studies : []
if (links.length === 0 && studies.length === 0) return content
const [index, wf] = await Promise.all([getFeedIndex(), loadPodcastWorkflow()])
const seasonA = laneSeasonOf('A', wf, content)
const seasonB = laneSeasonOf('B', wf, content)
const nextLinks = links.flatMap(link => {
// General highlights aren't tied to a series, so there's no season to match against
if (!link || link.lane === 'general') return [link]
const season = link.lane === 'second' ? seasonB : seasonA
const item = season && link.episodeNumber ? index.get(`${season}-${String(link.episodeNumber).trim()}`) : null
if (!item) return link.holdUntilReleased ? [] : [link]
// Highlights with no audio of their own pick it up from the matching feed episode
return [{
...link,
url: link.url || item.link || '',
embedUrl: link.embedUrl?.trim() ? link.embedUrl : (item.audioUrl || link.embedUrl),
}]
})
const studyLane = study => {
if (study?.lane === 'A' || study?.lane === 'B') return study.lane
if (study?.slug && study.slug === content.seriesStudySlug) return 'A'
if (study?.slug && study.slug === content.secondSeries?.studySlug) return 'B'
return ''
}
const nextStudies = studies.map(study => {
const lane = studyLane(study)
const season = lane === 'A' ? seasonA : lane === 'B' ? seasonB : ''
if (!season || !Array.isArray(study.sections)) return study
return {
...study,
sections: study.sections.map(section => {
if (!section || section.audioEmbedUrl?.trim() || !section.episodeNumber) return section
const item = index.get(`${season}-${String(section.episodeNumber).trim()}`)
return item?.audioUrl ? { ...section, audioEmbedUrl: item.audioUrl } : section
}),
}
})
return { ...content, podcastFeaturedLinks: nextLinks, studies: nextStudies }
}
+3
View File
@@ -142,6 +142,7 @@ export function sanitizeFeaturedLinks(value) {
return {
id: typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID(),
lane: item.lane === 'second' || item.lane === 'general' ? item.lane : 'primary',
holdUntilReleased: item.holdUntilReleased === true,
title: typeof item.title === 'string' ? item.title.trim().slice(0, 140) : '',
episodeNumber: typeof item.episodeNumber === 'string' ? item.episodeNumber.trim().slice(0, 20) : '',
summary: typeof item.summary === 'string' ? item.summary.trim().slice(0, 600) : '',
@@ -340,9 +341,11 @@ function sanitizeStudies(value) {
title: typeof item.title === 'string' ? item.title.trim().slice(0, 160) : '',
description: typeof item.description === 'string' ? item.description.trim().slice(0, 800) : '',
homepageEyebrow: typeof item.homepageEyebrow === 'string' ? item.homepageEyebrow.trim().slice(0, 80) : '',
imageUrl: sanitizeUrl(item.imageUrl) || '',
showOnHomepage: item.showOnHomepage === true,
showNewTag: item.showNewTag === true,
newTagLabel: typeof item.newTagLabel === 'string' ? item.newTagLabel.trim().slice(0, 24) : '',
guideSource: typeof item.guideSource === 'string' && /^(none|primary|second|book:[A-Za-z0-9_-]{1,80})$/.test(item.guideSource.trim()) ? item.guideSource.trim() : '',
status: item.status === 'planned' ? 'planned' : (item.status === 'complete' ? 'complete' : 'active'),
lane: item.lane === 'A' || item.lane === 'B' ? item.lane : '',
startsAt: sanitizeIsoDate(item.startsAt),
-111
View File
@@ -1,111 +0,0 @@
import { Resend } from 'resend'
import { state } from './state.js'
import { queuePodcastChecklistWrite, queueCalendarEventsWrite } from './data.js'
import { DEFAULT_RESEND_FROM, DEFAULT_RESEND_TO } from './config.js'
function toDateKey(date) {
return date.toISOString().slice(0, 10)
}
export function startReminderScheduler() {
checkReminders()
setInterval(checkReminders, 60 * 60 * 1000)
}
async function checkReminders() {
const todayKey = toDateKey(new Date())
// Episodes
const episodes = state.podcastChecklist?.episodes
if (Array.isArray(episodes)) {
let changed = false
for (const ep of episodes) {
if (!ep.datePublished || !(ep.reminderDays > 0) || ep.reminderSentAt) continue
const publish = new Date(ep.datePublished + 'T12:00:00Z')
if (isNaN(publish.getTime())) continue
const reminderDate = new Date(publish)
reminderDate.setUTCDate(reminderDate.getUTCDate() - ep.reminderDays)
if (todayKey === toDateKey(reminderDate)) {
const sent = await sendReminderEmail({
label: ep.episodeNumber ? `Episode ${ep.episodeNumber}` : 'Episode',
title: ep.title || 'Untitled',
series: ep.series,
date: ep.datePublished,
reminderDays: ep.reminderDays,
type: 'episode',
})
if (sent) { ep.reminderSentAt = new Date().toISOString(); changed = true }
}
}
if (changed) queuePodcastChecklistWrite()
}
// Calendar events
const events = state.calendarEvents
if (Array.isArray(events)) {
let changed = false
for (const ev of events) {
if (!ev.date || !(ev.reminderDays > 0) || ev.reminderSentAt) continue
const publish = new Date(ev.date + 'T12:00:00Z')
if (isNaN(publish.getTime())) continue
const reminderDate = new Date(publish)
reminderDate.setUTCDate(reminderDate.getUTCDate() - ev.reminderDays)
if (todayKey === toDateKey(reminderDate)) {
const sent = await sendReminderEmail({
label: ev.type.charAt(0).toUpperCase() + ev.type.slice(1),
title: ev.title,
series: null,
date: ev.date,
reminderDays: ev.reminderDays,
type: ev.type,
})
if (sent) { ev.reminderSentAt = new Date().toISOString(); changed = true }
}
}
if (changed) queueCalendarEventsWrite()
}
}
const TYPE_ICONS = { episode: '📅', general: '📌', recording: '🎙️', social: '📱', task: '✅' }
async function sendReminderEmail({ label, title, series, date, reminderDays, type }) {
if (!process.env.RESEND_API_KEY) return false
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
const to = process.env.RESEND_TO ?? DEFAULT_RESEND_TO
const eventDate = new Date(date + 'T12:00:00Z')
const msLeft = eventDate.getTime() - Date.now()
const daysLeft = Math.max(0, Math.ceil(msLeft / (1000 * 60 * 60 * 24)))
const seriesStr = series ? ` (${series})` : ''
const daysText = daysLeft === 0 ? 'today' : daysLeft === 1 ? 'in 1 day' : `in ${daysLeft} days`
const icon = TYPE_ICONS[type] ?? '📅'
const subject = `Reminder: ${label}: ${title} — ${daysText}`
const html = `
<div style="font-family:system-ui,sans-serif;max-width:540px;margin:0 auto;color:#222">
<h2 style="color:#c8860a;margin-bottom:4px">${icon} Calendar Reminder</h2>
<p style="font-size:1.1rem;margin-bottom:16px">
<strong>${label}: ${title}</strong>${seriesStr}<br>
<span style="color:#555">Scheduled for <strong>${date}</strong> — ${daysText}</span>
</p>
<hr style="border:none;border-top:1px solid #eee;margin:16px 0">
<p style="color:#777;font-size:0.85rem">
This reminder was set ${reminderDays} day${reminderDays === 1 ? '' : 's'} before the date.
To change or remove it, open the Calendar in your admin panel.
</p>
</div>
`
const { error } = await resend.emails.send({ from, to, subject, html })
if (error) { console.error('[reminder] send error:', error); return false }
console.log(`[reminder] sent for "${title}" (${date})`)
return true
} catch (err) {
console.error('[reminder] send exception:', err)
return false
}
}
+7 -2
View File
@@ -239,6 +239,7 @@ export function register(app) {
currentStreak: user.currentStreak ?? 0,
longestStreak: user.longestStreak ?? 0,
reengagementSentAt: user.reengagementSentAt ?? {},
adminShortcut: user.adminShortcut === true,
}
}))
@@ -282,12 +283,16 @@ export function register(app) {
const user = state.studyUsers.find(u => u.id === req.params.id)
if (!user) { res.status(404).json({ message: 'User not found.' }); return }
const { displayName, newPassword, addEnrollment, removeEnrollment } = req.body ?? {}
const { displayName, newPassword, addEnrollment, removeEnrollment, adminShortcut } = req.body ?? {}
if (typeof displayName === 'string') {
user.displayName = displayName.trim().slice(0, 80)
}
if (typeof adminShortcut === 'boolean') {
user.adminShortcut = adminShortcut
}
if (typeof newPassword === 'string') {
if (newPassword.length < 8 || newPassword.length > 200) {
res.status(400).json({ message: 'Password must be 8–200 characters.' }); return
@@ -314,7 +319,7 @@ export function register(app) {
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs })
res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs, adminShortcut: user.adminShortcut === true })
})
app.delete('/api/admin/study-users/:id', requireAdminAuth, async (req, res) => {
-3
View File
@@ -24,7 +24,6 @@ import {
loadDownloadCountsFromDisk,
loadQrCodesFromDisk,
loadEpisodePlaysFromDisk,
loadPodcastChecklistFromDisk,
loadAnalyticsEventsFromDisk,
createBackupSnapshot,
refreshContentCaches,
@@ -105,7 +104,6 @@ async function flushPendingWrites() {
state.draftQuestionsWritePromise,
state.replyTemplatesWritePromise,
state.replyHistoryWritePromise,
state.podcastChecklistWritePromise,
state.studyUsersWritePromise,
state.studyCommunityWritePromise,
state.studyRemindersWritePromise,
@@ -148,7 +146,6 @@ async function reloadStateFromDisk() {
loadDownloadCountsFromDisk(),
loadQrCodesFromDisk(),
loadEpisodePlaysFromDisk(),
loadPodcastChecklistFromDisk(),
loadAnalyticsEventsFromDisk(),
refreshContentCaches(),
])
+3 -19
View File
@@ -1,3 +1,4 @@
import { applyFeedRelease } from '../feed-release.js'
import { mkdir, readFile } from 'node:fs/promises'
import { writeFile } from '../atomic-write.js'
import { assertPublicHttpsUrl } from '../url-safety.js'
@@ -26,13 +27,11 @@ import {
queueContactSubmissionsWrite,
queueReplyTemplatesWrite,
queueReplyHistoryWrite,
queuePodcastChecklistWrite,
queueWebhooksWrite,
createBackupSnapshot,
listBackupPreviews,
readBackupPreview,
restoreFromBackup,
sanitizePodcastChecklist,
} from '../data.js'
import {
pruneStatsByDays,
@@ -251,7 +250,7 @@ export function register(app) {
try {
const parsed = await loadSiteContentFile(source === 'draft' ? DRAFT_DATA_FILE : DATA_FILE)
if (source === 'published') {
const safeSiteContent = filterSiteContentByReleaseDate(parsed.siteContent)
const safeSiteContent = filterSiteContentByReleaseDate(await applyFeedRelease(parsed.siteContent))
res.json({ ...parsed, siteContent: safeSiteContent })
return
}
@@ -278,25 +277,10 @@ export function register(app) {
res.json(status)
})
app.get('/api/admin-podcast-checklist', requireAdminAuth, (_req, res) => {
res.json({ checklist: state.podcastChecklist })
})
app.put('/api/admin-podcast-checklist', requireAdminAuth, async (req, res) => {
try {
const safeChecklist = sanitizePodcastChecklist(req.body?.checklist)
state.podcastChecklist = safeChecklist
await queuePodcastChecklistWrite()
res.json({ ok: true, checklist: safeChecklist })
} catch {
res.status(500).json({ message: 'Failed to save podcast checklist.' })
}
})
app.get('/api/site-config', async (_req, res) => {
try {
const parsed = await loadSiteContentFile(DATA_FILE)
const siteContent = parsed.siteContent ?? {}
const siteContent = await applyFeedRelease(parsed.siteContent ?? {})
res.json({
seo: siteContent.seo ?? DEFAULT_SEO,
legal: siteContent.legal ?? DEFAULT_LEGAL,
+14 -20
View File
@@ -1,6 +1,8 @@
import { Resend } from 'resend'
import { readdir } from 'node:fs/promises'
import { requireAdminAuth } from '../auth.js'
import { buildSeasonCheck } from '../season-check.js'
import { countPendingUploads } from './nuggets-workflow.js'
import { state } from '../state.js'
import { getStorageStatus, loadUserProgress } from '../data.js'
import { getStudyCatalog } from '../study-helpers.js'
@@ -146,29 +148,12 @@ async function buildLatestEpisode() {
}
}
function buildUpcomingEvents() {
const today = dayKey(0)
const events = (state.calendarEvents ?? [])
.filter(ev => ev.date && ev.date >= today && !ev.completed)
.map(ev => ({ id: ev.id, kind: 'event', type: ev.type, title: ev.title, date: ev.date, startTime: ev.startTime ?? null }))
const episodes = (state.podcastChecklist?.episodes ?? [])
.filter(ep => ep.datePublished && ep.datePublished >= today)
.map(ep => ({
id: `ep-${ep.episodeNumber ?? ep.title}`,
kind: 'episode',
type: 'episode',
title: `${ep.episodeNumber ? `Ep. ${ep.episodeNumber}: ` : ''}${ep.title || 'Untitled'}`,
date: ep.datePublished,
startTime: null,
}))
return [...events, ...episodes].sort((a, b) => a.date.localeCompare(b.date)).slice(0, 3)
}
async function buildNuggets() {
const { entries } = await loadHistory()
const weekAgo = Date.now() - 7 * DAY_MS
return {
uploadsThisWeek: entries.filter(e => new Date(e.uploadedAt).getTime() >= weekAgo).length,
pending: await countPendingUploads(),
latest: entries.slice(0, 3).map(e => ({ hostName: e.hostName, episodeName: e.episodeName, uploadedAt: e.uploadedAt })),
entries: entries.slice(0, 10),
}
@@ -237,14 +222,23 @@ export function register(app) {
buildStudyProgress(), buildLatestEpisode(), buildNuggets(), buildHealth(), buildLastBroadcast(),
])
const comments = state.studyComments ?? []
const seasonCheck = await buildSeasonCheck()
.then(c => ({
total: c.total,
errors: c.errors,
warnings: c.warnings,
general: c.general.length,
lanes: c.lanes.map(l => ({ lane: l.lane, title: l.title, season: l.season, errors: l.errors, warnings: l.warnings })),
}))
.catch(() => null)
res.json({
seasonCheck,
trends: buildTrends(),
traffic: buildTopTraffic(),
reengagement: buildReengagement(),
studyProgress,
latestEpisode,
upcoming: buildUpcomingEvents(),
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, latest: nuggets.latest },
nuggets: { uploadsThisWeek: nuggets.uploadsThisWeek, pending: nuggets.pending, latest: nuggets.latest },
health,
lastBroadcast,
comments: {
+9
View File
@@ -20,6 +20,7 @@ import {
buildLastNDaysStats,
shouldCountHit,
recordHit,
isKnownSitePath,
getStudyCatalog,
} from '../study-helpers.js'
@@ -260,6 +261,7 @@ export function register(app) {
const ip = getClientIp(req)
if (!pageviewAllowed(ip)) { res.json({ ok: false, reason: 'rate-limited' }); return }
const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/'
if (!isKnownSitePath(rawPath)) { res.json({ ok: false, reason: 'unknown-path' }); return }
const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : ''
recordHit(rawPath, false)
queueHitStatsWrite()
@@ -391,6 +393,13 @@ export function register(app) {
firstHitAt: state.hitStats.firstHitAt,
lastHitAt: state.hitStats.lastHitAt,
topPaths, topPathsReal, topPathsBot,
topUnknownPaths: Object.entries(state.hitStats.byPathUnknown ?? {})
.sort((a, b) => b[1] - a[1]).slice(0, 25).map(([path, hits]) => ({ path, hits })),
uniqueVisitors7d: new Set(
state.visitorStats.recentVisits
.filter(v => Date.now() - new Date(v.at).getTime() <= 7 * 24 * 60 * 60 * 1000)
.map(v => v.visitorId),
).size,
last7Days, last7DaysReal, last7DaysBot,
last30DaysTotal, last30DaysRealTotal, last30DaysBotTotal,
botReasons,
+6 -1
View File
@@ -29,7 +29,12 @@ BIBLE_BOOKS.forEach((b, index) => {
})
function parseReference(reference) {
const stripped = reference.replace(/^[a-z]+/i, '')
// Accept a leading book name ("Titus 1:1-2", "1 John 2:3"), en/em dashes and verse-part letters ("1:13a")
const stripped = reference
.replace(/[–—]/g, '-')
.replace(/(\d)[a-z]\b/gi, '$1')
.replace(/^\s*(?:[1-3]\s*)?[a-z][a-z .]*?\s*(?=\d+:)/i, '')
.trim()
const match = stripped.match(/^(\d+):(\d+)(?:-(\d+))?$/)
if (!match) return null
return {
-192
View File
@@ -1,192 +0,0 @@
import { randomUUID } from 'node:crypto'
import { state } from '../state.js'
import { queueCalendarEventsWrite } from '../data.js'
import { requireAdminAuth } from '../auth.js'
const VALID_TYPES = ['general', 'recording', 'social', 'task']
const VALID_FREQS = ['none', 'weekly', 'biweekly', 'monthly']
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
const TIME_RE = /^\d{2}:\d{2}$/
function sanitizeRecurrence(raw) {
if (!raw || typeof raw !== 'object') return null
const freq = VALID_FREQS.includes(raw.freq) ? raw.freq : 'none'
if (freq === 'none') return null
const until = typeof raw.until === 'string' && DATE_RE.test(raw.until) ? raw.until : null
return { freq, until }
}
function sanitize(ev) {
const recurrence = sanitizeRecurrence(ev.recurrence)
return {
id: ev.id,
type: VALID_TYPES.includes(ev.type) ? ev.type : 'general',
title: typeof ev.title === 'string' ? ev.title.slice(0, 300) : '',
date: typeof ev.date === 'string' && DATE_RE.test(ev.date) ? ev.date : '',
startTime: typeof ev.startTime === 'string' && TIME_RE.test(ev.startTime) ? ev.startTime : undefined,
notes: typeof ev.notes === 'string' ? ev.notes.slice(0, 2000) : '',
completed: Boolean(ev.completed),
reminderDays: Number.isFinite(ev.reminderDays) && ev.reminderDays > 0 ? ev.reminderDays : 0,
reminderSentAt: typeof ev.reminderSentAt === 'string' ? ev.reminderSentAt : undefined,
recurrence: recurrence ?? undefined,
createdAt: typeof ev.createdAt === 'string' ? ev.createdAt : new Date().toISOString(),
}
}
// ── iCal helpers ─────────────────────────────────────────────────────────────
function escapeIcs(s) {
return String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n')
}
function foldIcsLine(line) {
const chars = [...line]
if (chars.length <= 75) return line
const parts = []
let current = ''
for (const ch of chars) {
if ((current + ch).length > 75) { parts.push(current); current = ' ' + ch }
else current += ch
}
if (current) parts.push(current)
return parts.join('\r\n')
}
function toIcsDatetime(dateStr, timeStr) {
const d = dateStr.replace(/-/g, '')
if (timeStr && TIME_RE.test(timeStr)) {
const t = timeStr.replace(':', '') + '00'
return `DTSTART:${d}T${t}`
}
return `DTSTART;VALUE=DATE:${d}`
}
function toIcsTimestamp(iso) {
try {
const d = new Date(iso)
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '')
} catch { return '' }
}
function rruleFor(recurrence) {
if (!recurrence || recurrence.freq === 'none') return null
const freqMap = { weekly: 'WEEKLY', biweekly: 'WEEKLY', monthly: 'MONTHLY' }
const freq = freqMap[recurrence.freq]
if (!freq) return null
let rule = `RRULE:FREQ=${freq}`
if (recurrence.freq === 'biweekly') rule += ';INTERVAL=2'
if (recurrence.until) rule += `;UNTIL=${recurrence.until.replace(/-/g, '')}T235959Z`
return rule
}
function generateIcal() {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Siteforge//CalendarExport//EN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:Verse by Verse Calendar',
]
for (const ep of (state.podcastChecklist?.episodes ?? [])) {
if (!ep.datePublished) continue
const summary = [ep.series, ep.episodeNumber != null ? `Ep ${ep.episodeNumber}` : null, ep.title].filter(Boolean).join(' · ')
const dtstart = ep.startTime ? `DTSTART:${ep.datePublished.replace(/-/g, '')}T${ep.startTime.replace(':', '')}00` : `DTSTART;VALUE=DATE:${ep.datePublished.replace(/-/g, '')}`
lines.push('BEGIN:VEVENT')
lines.push(`UID:ep-${ep.id}@siteforge`)
lines.push(dtstart)
lines.push(`SUMMARY:${escapeIcs(summary || 'Episode')}`)
if (ep.title) lines.push(`DESCRIPTION:${escapeIcs(ep.title)}`)
lines.push('END:VEVENT')
}
for (const ev of (state.calendarEvents ?? [])) {
if (!ev.date) continue
lines.push('BEGIN:VEVENT')
lines.push(`UID:ev-${ev.id}@siteforge`)
lines.push(toIcsDatetime(ev.date, ev.startTime))
lines.push(`SUMMARY:${escapeIcs(ev.title || '(Event)')}`)
if (ev.notes) lines.push(`DESCRIPTION:${escapeIcs(ev.notes)}`)
const rrule = rruleFor(ev.recurrence)
if (rrule) lines.push(rrule)
if (ev.createdAt) lines.push(`CREATED:${toIcsTimestamp(ev.createdAt)}`)
lines.push('END:VEVENT')
}
lines.push('END:VCALENDAR')
return lines.map(foldIcsLine).join('\r\n') + '\r\n'
}
// ── Routes ───────────────────────────────────────────────────────────────────
export function register(app) {
app.get('/api/admin-calendar-events', requireAdminAuth, (_req, res) => {
res.json({ events: state.calendarEvents })
})
app.get('/api/admin-calendar.ics', requireAdminAuth, (_req, res) => {
const ical = generateIcal()
res.setHeader('Content-Type', 'text/calendar; charset=utf-8')
res.setHeader('Content-Disposition', 'attachment; filename="siteforge-calendar.ics"')
res.send(ical)
})
app.post('/api/admin-calendar-events', requireAdminAuth, (req, res) => {
const { type, title, date, startTime, notes, reminderDays, recurrence } = req.body ?? {}
if (!title?.trim()) { res.status(400).json({ message: 'Title is required.' }); return }
if (!date || !DATE_RE.test(date)) { res.status(400).json({ message: 'Valid date is required.' }); return }
if (type && !VALID_TYPES.includes(type)) { res.status(400).json({ message: 'Invalid type.' }); return }
const ev = sanitize({
id: randomUUID(),
type: type ?? 'general',
title: String(title).trim(),
date,
startTime: startTime ?? undefined,
notes: notes ?? '',
completed: false,
reminderDays: reminderDays ?? 0,
recurrence: recurrence ?? null,
createdAt: new Date().toISOString(),
})
state.calendarEvents.unshift(ev)
queueCalendarEventsWrite()
res.json({ ok: true, event: ev })
})
app.patch('/api/admin-calendar-events/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
let found = false
state.calendarEvents = state.calendarEvents.map(ev => {
if (ev.id !== id) return ev
found = true
const patch = {}
if (typeof req.body?.title === 'string') patch.title = req.body.title.trim().slice(0, 300)
if (typeof req.body?.date === 'string' && DATE_RE.test(req.body.date)) patch.date = req.body.date
if (typeof req.body?.startTime === 'string') patch.startTime = TIME_RE.test(req.body.startTime) ? req.body.startTime : undefined
if (req.body?.startTime === null) patch.startTime = undefined
if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000)
if (typeof req.body?.completed === 'boolean') patch.completed = req.body.completed
if (VALID_TYPES.includes(req.body?.type)) patch.type = req.body.type
if (Number.isFinite(req.body?.reminderDays)) patch.reminderDays = req.body.reminderDays
if ('recurrence' in (req.body ?? {})) patch.recurrence = sanitizeRecurrence(req.body.recurrence) ?? undefined
const dateChanged = patch.date && patch.date !== ev.date
const reminderChanged = 'reminderDays' in patch && patch.reminderDays !== ev.reminderDays
if (dateChanged || reminderChanged) patch.reminderSentAt = undefined
return { ...ev, ...patch }
})
if (!found) { res.status(404).json({ message: 'Event not found.' }); return }
queueCalendarEventsWrite()
res.json({ ok: true })
})
app.delete('/api/admin-calendar-events/:id', requireAdminAuth, (req, res) => {
const before = state.calendarEvents.length
state.calendarEvents = state.calendarEvents.filter(ev => ev.id !== req.params.id)
if (state.calendarEvents.length === before) { res.status(404).json({ message: 'Event not found.' }); return }
queueCalendarEventsWrite()
res.json({ ok: true })
})
}
+75
View File
@@ -222,6 +222,81 @@ export function register(app) {
}
})
// Collects a visitor's details, then hands back the Amazon / Apple Books link for a guide or book.
// The destination always comes from the saved site content, never from the request.
app.post('/api/store-link', studyDownloadRateLimit, async (req, res) => {
try {
const { source, store, firstName, lastName, email, subscribe, _honey } = req.body ?? {}
if (_honey) { res.json({ ok: true }); return }
if (typeof source !== 'string' || !source || (store !== 'amazon' && store !== 'apple')) {
res.status(400).json({ message: 'That link is not available.' }); return
}
if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) {
res.status(400).json({ message: 'First name is required.' }); return
}
if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) {
res.status(400).json({ message: 'Last name is required.' }); return
}
if (!email || typeof email !== 'string' || !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(email.trim())) {
res.status(400).json({ message: 'A valid email address is required.' }); return
}
const published = await loadSiteContentFile(DATA_FILE)
const content = published?.siteContent ?? {}
const pick = (amazon, apple) => (store === 'amazon' ? amazon : apple)
let label = ''
let url = ''
if (source === 'page:primary-guide') {
label = content.studyGuideTitle
url = pick(content.studyGuideUrl, content.studyGuideAppleBooksUrl)
} else if (source === 'page:second-guide') {
const guide = content.secondSeries?.guide
label = guide?.title
url = pick(guide?.amazonUrl, guide?.appleBooksUrl)
} else if (source.startsWith('page:book-guide--')) {
const id = source.slice('page:book-guide--'.length)
const guide = (Array.isArray(content.finishedBooks) ? content.finishedBooks : []).find(b => b?.id === id)?.guide
label = guide?.title
url = pick(guide?.amazonUrl, guide?.appleBooksUrl)
} else if (source.startsWith('page:custom--')) {
const id = source.slice('page:custom--'.length)
const link = (content.customLinks ?? []).find(l => l?.id === id && l.placement === 'resources')
label = link?.label
url = pick(link?.amazonUrl, link?.appleBooksUrl)
} else if (source.startsWith('book:')) {
const id = source.slice('book:'.length)
const book = (content.customLinks ?? []).find(l => l?.id === id && l.placement === 'books')
label = book?.label
url = pick(book?.amazonUrl || book?.url, book?.appleBooksUrl)
}
const destination = sanitizeUrl(url)
if (!destination) { res.status(404).json({ message: 'That link is not available.' }); return }
const name = `${firstName.trim()} ${lastName.trim()}`.trim()
const trimmedEmail = email.trim()
const wantsSubscribe = subscribe !== false
const storeName = store === 'amazon' ? 'Amazon' : 'Apple Books'
addContactSubmission({
name,
email: trimmedEmail,
message: `Requested ${storeName} link: ${label || source}`,
messageType: 'general',
subscribe: wantsSubscribe,
})
if (wantsSubscribe) await syncContactToResend(name, trimmedEmail)
incrementDownloadCount(`store:${source}:${store}`)
res.json({ ok: true, url: destination })
} catch (err) {
console.error('[store-link] request error:', err)
res.status(500).json({ message: 'Failed to process your request. Please try again.' })
}
})
app.get('/api/admin-download-stats', requireAdminAuth, (_req, res) => {
res.json({ counts: state.downloadCounts })
})
+1 -1
View File
@@ -5,7 +5,7 @@ const RSS_FEED_URL = 'https://anchor.fm/s/11068d290/podcast/rss'
let episodesCache = null
let episodesCacheAt = 0
let rawXmlCache = null
const EPISODES_CACHE_TTL = 30 * 60 * 1000
const EPISODES_CACHE_TTL = 10 * 60 * 1000
export async function fetchAllEpisodes() {
const now = Date.now()
+14 -6
View File
@@ -88,15 +88,22 @@ table[align="left"] { float: none !important; margin-left: auto !important; marg
</style>
</head>`
const topBar = `
<div style="background:#050505;border-bottom:1px solid rgba(184,151,42,0.25);padding:14px 20px;font-family:Georgia,serif">
<div style="max-width:640px;margin:0 auto;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<a href="${BASE}/" style="color:#e8d9b5;text-decoration:none;font-size:15px;letter-spacing:0.04em">Verse by Verse with Nate</a>
<a href="${BASE}/newsletter" style="color:#B8972A;text-decoration:none;font-size:12px;letter-spacing:0.14em;text-transform:uppercase">&larr; All issues</a>
</div></div>`
const ctaBlock = `
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
<tbody><tr><td align="center" style="padding:32px 20px 48px;background-color:#080807">
<tbody><tr><td align="center" style="padding:40px 20px 56px;background-color:#080807;border-top:1px solid rgba(184,151,42,0.2)">
<table width="580" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:580px;width:100%">
<tbody><tr><td align="center" style="padding:0">
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:13px;color:#6b6044">
<a href="${BASE}/newsletter" style="color:#6b6044;text-decoration:none">&larr; Newsletter Archive</a>
</p>
<a href="${BASE}/subscribe" style="display:inline-block;padding:12px 32px;font-family:Georgia,serif;font-size:12px;letter-spacing:0.16em;text-transform:uppercase;color:#B8972A;border:1px solid #B8972A;text-decoration:none">Subscribe by Email</a>
<tbody><tr><td align="center" style="padding:0;font-family:Georgia,serif">
<p style="margin:0 0 8px;font-size:12px;letter-spacing:0.18em;text-transform:uppercase;color:#B8972A">Stay in the Word</p>
<p style="margin:0 0 22px;font-size:22px;line-height:1.3;color:#f0e9cc">Get the next issue in your inbox</p>
<a href="${BASE}/subscribe" style="display:inline-block;padding:13px 34px;font-size:12px;letter-spacing:0.16em;text-transform:uppercase;color:#0c0c08;background:#B8972A;text-decoration:none;border-radius:4px;font-weight:bold">Subscribe by Email</a>
<p style="margin:26px 0 0;font-size:13px"><a href="${BASE}/newsletter" style="color:#8a7e55;text-decoration:none">&larr; Back to the newsletter archive</a></p>
</td></tr></tbody></table>
</td></tr></tbody></table>
</body>`
@@ -104,6 +111,7 @@ table[align="left"] { float: none !important; margin-left: auto !important; marg
let html = stripUnsubscribeLink(issue.html)
html = html.replace(/<title>[\s\S]*?<\/title>/i, '')
html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, metaBlock) : html.replace(/<head[^>]*>/i, m => `${m}${metaBlock.replace('</head>', '')}`)
html = html.replace(/<body[^>]*>/i, m => `${m}${topBar}`)
html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, ctaBlock) : `${html}${ctaBlock}`
return html
}
+156
View File
@@ -0,0 +1,156 @@
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { Readable } from 'node:stream'
import path from 'node:path'
import { writeFile } from '../atomic-write.js'
import { requireAdminAuth } from '../auth.js'
import { loadHistory } from './nuggets-host-upload.js'
import {
DATA_DIR,
SEAFILE_URL,
SEAFILE_TOKEN,
SEAFILE_NUGGETS_REPO_ID,
SEAFILE_EPISODE_UPLOAD_DIR,
} from '../config.js'
const WORKFLOW_FILE = path.join(DATA_DIR, 'nuggets-workflow.json')
const AUDIO_TYPES = {
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.m4a': 'audio/mp4',
'.aac': 'audio/aac',
'.ogg': 'audio/ogg',
'.flac': 'audio/flac',
}
export const WORKFLOW_STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify']
export async function loadWorkflow() {
try {
return JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
} catch {
return { items: {} }
}
}
export function entryId(entry) {
return createHash('sha1').update(`${entry.uploadedAt}|${entry.filename}`).digest('hex').slice(0, 16)
}
function emptyState() {
return {
steps: Object.fromEntries(WORKFLOW_STEPS.map(s => [s, null])), // step → ISO completion time
coldOpenText: '',
notes: '',
spotifyUrl: '',
}
}
function seafileConfigured() {
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
}
// Portal uploads whose workflow still has steps left to do (any age, so an old one keeps showing).
export async function countPendingUploads() {
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
return history.entries.filter(entry => {
const steps = wf.items[entryId(entry)]?.steps ?? {}
return !WORKFLOW_STEPS.every(step => steps[step])
}).length
}
export function register(app) {
app.get('/api/admin/nuggets-workflow', requireAdminAuth, async (_req, res) => {
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
const items = history.entries.map(entry => {
const id = entryId(entry)
return { id, entry, state: { ...emptyState(), ...(wf.items[id] ?? {}) } }
})
res.json({ items })
})
app.put('/api/admin/nuggets-workflow/:id', requireAdminAuth, async (req, res) => {
const { id } = req.params
const history = await loadHistory()
if (!history.entries.some(e => entryId(e) === id)) {
res.status(404).json({ message: 'Upload not found.' })
return
}
const wf = await loadWorkflow()
const prev = { ...emptyState(), ...(wf.items[id] ?? {}) }
const body = req.body ?? {}
const next = { ...prev, steps: { ...prev.steps } }
if (body.steps && typeof body.steps === 'object') {
for (const s of WORKFLOW_STEPS) {
if (typeof body.steps[s] === 'boolean') {
// Keep the original completion time if the step was already done
next.steps[s] = body.steps[s] ? (prev.steps[s] ?? new Date().toISOString()) : null
}
}
}
if (typeof body.coldOpenText === 'string') next.coldOpenText = body.coldOpenText.slice(0, 5000)
if (typeof body.notes === 'string') next.notes = body.notes.slice(0, 5000)
if (typeof body.spotifyUrl === 'string') next.spotifyUrl = body.spotifyUrl.trim().slice(0, 500)
wf.items[id] = next
await writeFile(WORKFLOW_FILE, JSON.stringify(wf, null, 2), 'utf8')
res.json({ ok: true, state: next })
})
// Stream the uploaded audio from Seafile so it can be auditioned in the admin.
app.get('/api/admin/nuggets-workflow/:id/audio', requireAdminAuth, async (req, res) => {
if (!seafileConfigured()) {
res.status(503).json({ message: 'Seafile is not configured.' })
return
}
const history = await loadHistory()
const entry = history.entries.find(e => entryId(e) === req.params.id)
if (!entry) {
res.status(404).json({ message: 'Upload not found.' })
return
}
try {
const filePath = `${SEAFILE_EPISODE_UPLOAD_DIR.replace(/\/$/, '')}/${entry.filename}`
const linkRes = await fetch(
`${SEAFILE_URL}/api2/repos/${SEAFILE_NUGGETS_REPO_ID}/file/?p=${encodeURIComponent(filePath)}&reuse=1`,
{ headers: { Authorization: `Token ${SEAFILE_TOKEN}` }, signal: AbortSignal.timeout(10_000) },
)
if (!linkRes.ok) {
console.error(`[nuggets-workflow] Seafile link lookup for "${filePath}" failed: ${linkRes.status}`)
res.status(404).json({ message: 'Audio file not found in Seafile.' })
return
}
const downloadUrl = (await linkRes.text()).replace(/^"|"$/g, '')
const headers = {}
if (req.headers.range) headers.Range = req.headers.range
const upstream = await fetch(downloadUrl, { headers })
if (!upstream.ok && upstream.status !== 206) {
console.error(`[nuggets-workflow] Seafile download failed: ${upstream.status}`)
res.status(502).json({ message: 'Could not fetch audio from Seafile.' })
return
}
res.status(upstream.status)
for (const h of ['content-length', 'content-range', 'accept-ranges']) {
const v = upstream.headers.get(h)
if (v) res.setHeader(h, v)
}
// Seafile serves downloads as application/octet-stream, which browsers refuse to play
// (the site sends nosniff), so set the audio type from the file extension.
const ext = path.extname(entry.filename).toLowerCase()
res.setHeader('content-type', AUDIO_TYPES[ext] ?? 'audio/mpeg')
if (!res.getHeader('accept-ranges')) res.setHeader('accept-ranges', 'bytes')
const stream = Readable.fromWeb(upstream.body)
req.on('close', () => stream.destroy())
stream.on('error', () => res.end())
stream.pipe(res)
} catch (err) {
console.error('[nuggets-workflow] audio error:', err.message, err.cause?.message ?? '')
// Admin-only route, so the reason is safe to show and saves a trip to the server logs.
const reason = [err.message, err.cause?.message].filter(Boolean).join(': ')
if (!res.headersSent) res.status(500).json({ message: `Audio stream failed${reason ? ` (${reason})` : ''}.` })
}
})
}
+39 -2
View File
@@ -1,5 +1,7 @@
import { parseRssItems } from '../rss-helpers.js'
import { state } from '../state.js'
import { requireAdminAuth } from '../auth.js'
import { queueNuggetsTagsWrite } from '../data.js'
let cache = null // { url, episodes }
let cacheAt = 0
@@ -37,12 +39,47 @@ export async function fetchNuggetsEpisodes() {
}
}
function withTags(episodes) {
return episodes.map(ep => ({ ...ep, tags: state.nuggetsEpisodeTags[ep.audioUrl] ?? [] }))
}
function cleanTags(input) {
const list = Array.isArray(input) ? input : []
const seen = new Set()
const out = []
for (const t of list) {
const tag = String(t ?? '').trim().replace(/\s+/g, ' ').slice(0, 40)
const key = tag.toLowerCase()
if (tag && !seen.has(key)) { seen.add(key); out.push(tag) }
}
return out.slice(0, 12)
}
export function register(app) {
app.get('/api/admin/nuggets/episode-tags', requireAdminAuth, async (_req, res) => {
try {
const episodes = await fetchNuggetsEpisodes()
res.json({ episodes: withTags(episodes).map(({ title, pubDate, audioUrl, episode, tags }) => ({ title, pubDate, audioUrl, episode, tags })) })
} catch (err) {
res.status(502).json({ message: 'Could not load episodes from the feed.' })
}
})
app.put('/api/admin/nuggets/episode-tags', requireAdminAuth, (req, res) => {
const audioUrl = String(req.body?.audioUrl ?? '')
if (!audioUrl) { res.status(400).json({ message: 'audioUrl is required.' }); return }
const tags = cleanTags(req.body?.tags)
if (tags.length) state.nuggetsEpisodeTags[audioUrl] = tags
else delete state.nuggetsEpisodeTags[audioUrl]
queueNuggetsTagsWrite()
res.json({ ok: true, tags })
})
app.get('/api/nuggets/episodes', async (_req, res) => {
const configured = Boolean(feedUrl())
try {
const episodes = await fetchNuggetsEpisodes()
res.json({ episodes: episodes.slice(0, 6), configured })
res.json({ episodes: withTags(episodes.slice(0, 6)), configured })
} catch (err) {
console.error('[nuggets/episodes] RSS fetch error:', err.message)
res.json({ episodes: [], configured })
@@ -53,7 +90,7 @@ export function register(app) {
const configured = Boolean(feedUrl())
try {
const episodes = await fetchNuggetsEpisodes()
res.json({ episodes, configured })
res.json({ episodes: withTags(episodes), configured })
} catch (err) {
console.error('[nuggets/episodes/all] RSS fetch error:', err.message)
res.json({ episodes: [], configured })
+188
View File
@@ -0,0 +1,188 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { writeFile } from '../atomic-write.js'
import { requireAdminAuth } from '../auth.js'
import { state } from '../state.js'
import { DATA_DIR } from '../config.js'
import { scriptKey } from '../episode-script-keys.js'
import { fetchAllEpisodes } from './episodes.js'
const WORKFLOW_FILE = path.join(DATA_DIR, 'podcast-workflow.json')
const STEPS = ['readScript', 'record', 'edit', 'upload']
export async function loadPodcastWorkflow() {
try {
const data = JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
return { planned: Array.isArray(data.planned) ? data.planned : [], items: data.items ?? {}, laneOverrides: data.laneOverrides ?? {}, laneSeasons: data.laneSeasons ?? {} }
} catch {
return { planned: [], items: {}, laneOverrides: {}, laneSeasons: {} }
}
}
const save = data => writeFile(WORKFLOW_FILE, JSON.stringify(data, null, 2), 'utf8')
function emptyState() {
return {
steps: Object.fromEntries(STEPS.map(s => [s, null])),
notes: '',
}
}
// A lane's season comes from the workflow page's own setting, falling back to Current Series.
export function laneSeasonOf(lane, wf, content = state.cachedSiteContent ?? {}) {
const saved = wf.laneSeasons?.[lane]
if (/^\d+$/.test(String(saved ?? ''))) return String(saved)
const fallback = lane === 'A' ? content.seriesSeason : content.secondSeries?.season
return fallback ? String(fallback) : ''
}
function laneFor(season, wf) {
const s = String(season ?? '')
if (s && s === laneSeasonOf('A', wf)) return 'A'
if (s && s === laneSeasonOf('B', wf)) return 'B'
return 'other'
}
const normTitle = t => String(t ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
const plannedKey = p => scriptKey(p.season, p.episodeNumber)
function toIsoDate(pubDate) {
const d = new Date(pubDate)
return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10)
}
export function register(app) {
app.get('/api/admin/podcast-workflow', requireAdminAuth, async (_req, res) => {
const wf = await loadPodcastWorkflow()
let feed = []
let feedOk = true
try {
feed = await fetchAllEpisodes()
} catch {
feedOk = false
}
const byKey = new Map()
const upsert = (key, patch) => byKey.set(key, { ...(byKey.get(key) ?? {}), key, ...patch })
for (const p of wf.planned) {
const key = plannedKey(p)
const prev = byKey.get(key)
upsert(key, { season: p.season, episodeNumber: p.episodeNumber, title: prev?.title || p.title || '', planned: true })
}
for (const item of feed) {
const hasNum = /^\d+$/.test(item.episode)
let key
if (item.season && hasNum) {
key = scriptKey(item.season, item.episode)
} else {
// No season in the feed: pair with a script/planned episode of the same title, else keep it on its own.
const t = normTitle(item.title)
const match = [...byKey.values()].find(e => !e.released && normTitle(e.title) === t)
key = match ? match.key : `t:${t}`
}
const prev = byKey.get(key)
upsert(key, {
season: item.season || prev?.season || '',
episodeNumber: hasNum ? item.episode : (prev?.episodeNumber ?? ''),
title: item.title || prev?.title || '',
released: true,
releaseDate: toIsoDate(item.pubDate),
spotifyUrl: item.link || '',
})
}
const items = [...byKey.values()].map(e => ({
...e,
lane: wf.laneOverrides[e.key] ?? laneFor(e.season, wf),
laneOverride: wf.laneOverrides[e.key] ?? '',
released: Boolean(e.released),
state: { ...emptyState(), ...(wf.items[e.key] ?? {}) },
}))
items.sort((a, b) => (Number(a.season) || 0) - (Number(b.season) || 0) || (Number(a.episodeNumber) || 0) - (Number(b.episodeNumber) || 0))
res.json({ items, feedOk, laneSeasons: { A: laneSeasonOf('A', wf), B: laneSeasonOf('B', wf) } })
})
// Start the workflow for a new episode. Name, season and episode number are all required
// so the episode lands in the right lane and matches the feed once it is released.
app.post('/api/admin/podcast-workflow/planned', requireAdminAuth, async (req, res) => {
const season = String(req.body?.season ?? '').trim()
const episodeNumber = String(req.body?.episodeNumber ?? '').trim()
const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 160) : ''
if (!title) {
res.status(400).json({ message: 'Episode name is required.' })
return
}
if (!/^\d+$/.test(season) || !/^\d+$/.test(episodeNumber)) {
res.status(400).json({ message: 'Season and episode number must be whole numbers.' })
return
}
const wf = await loadPodcastWorkflow()
const key = scriptKey(season, episodeNumber)
if (wf.planned.some(p => plannedKey(p) === key)) {
res.status(409).json({ message: `Season ${season} episode ${episodeNumber} has already been started.` })
return
}
wf.planned.push({ season, episodeNumber, title })
await save(wf)
res.json({ ok: true, key })
})
app.delete('/api/admin/podcast-workflow/planned/:key', requireAdminAuth, async (req, res) => {
const wf = await loadPodcastWorkflow()
wf.planned = wf.planned.filter(p => plannedKey(p) !== req.params.key)
delete wf.items[req.params.key]
delete wf.laneOverrides[req.params.key]
await save(wf)
res.json({ ok: true })
})
// Set the season number each lane looks for ('' goes back to the Current Series value).
app.put('/api/admin/podcast-workflow-lane-seasons', requireAdminAuth, async (req, res) => {
const wf = await loadPodcastWorkflow()
for (const lane of ['A', 'B']) {
const v = req.body?.[lane]
if (v === undefined) continue
const str = String(v).trim()
if (str === '') delete wf.laneSeasons[lane]
else if (/^\d+$/.test(str)) wf.laneSeasons[lane] = str
else { res.status(400).json({ message: 'Season must be a whole number.' }); return }
}
await save(wf)
res.json({ ok: true })
})
// Assign an episode to a lane by hand ('auto' clears the override and goes back to season matching).
app.put('/api/admin/podcast-workflow/:key/lane', requireAdminAuth, async (req, res) => {
const lane = req.body?.lane
if (!['A', 'B', 'other', 'auto'].includes(lane)) {
res.status(400).json({ message: 'Lane must be A, B, other or auto.' })
return
}
const wf = await loadPodcastWorkflow()
if (lane === 'auto') delete wf.laneOverrides[req.params.key]
else wf.laneOverrides[req.params.key] = lane
await save(wf)
res.json({ ok: true })
})
app.put('/api/admin/podcast-workflow/:key', requireAdminAuth, async (req, res) => {
const { key } = req.params
const wf = await loadPodcastWorkflow()
const prev = { ...emptyState(), ...(wf.items[key] ?? {}) }
const next = { ...prev, steps: { ...prev.steps } }
const body = req.body ?? {}
if (body.steps && typeof body.steps === 'object') {
for (const s of STEPS) {
if (typeof body.steps[s] === 'boolean') {
next.steps[s] = body.steps[s] ? (prev.steps[s] ?? new Date().toISOString()) : null
}
}
}
if (typeof body.notes === 'string') next.notes = body.notes.slice(0, 5000)
wf.items[key] = next
await save(wf)
res.json({ ok: true, state: next })
})
}
+13 -5
View File
@@ -1,4 +1,5 @@
import express from 'express'
import { applyFeedRelease } from '../feed-release.js'
import path from 'node:path'
import { readFile } from 'node:fs/promises'
import { escapeHtml, escapeXml, injectSeoIntoHtml, normalizeSitemapPath } from '../helpers.js'
@@ -15,7 +16,7 @@ import {
} from '../config.js'
import { state } from '../state.js'
import { loadSiteContentFile } from '../data.js'
import { sanitizeRedirectRules } from '../study-helpers.js'
import { sanitizeRedirectRules, isKnownSitePath } from '../study-helpers.js'
import { fetchAllEpisodes } from './episodes.js'
import { fetchNuggetsEpisodes } from './nuggets.js'
import { BIBLE_BOOKS } from '../../shared/bible-books.js'
@@ -59,13 +60,14 @@ export function register(app) {
content = parsed.siteContent
} catch { content = {} }
}
content = await applyFeedRelease(content)
const seo = content?.seo ?? DEFAULT_SEO
const canonical = seo.canonicalUrl || DEFAULT_SEO.canonicalUrl
const root = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical
res.type('text/plain').send(
['User-agent: *', 'Allow: /', 'Disallow: /salvation', `Sitemap: ${root}/sitemap.xml`].join('\n'),
['User-agent: *', 'Allow: /', `Sitemap: ${root}/sitemap.xml`].join('\n'),
)
})
@@ -109,6 +111,8 @@ export function register(app) {
for (const ep of highlights) {
if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`)
}
// The standalone gospel page
if (!urls.includes(`${root}/salvation`)) urls.push(`${root}/salvation`)
// Newsletter archive + published issues
urls.push(`${root}/newsletter`)
for (const issue of state.newsletterIssues) {
@@ -151,7 +155,8 @@ export function register(app) {
if (!id || !/^[\w-]{1,120}$/.test(id)) {
res.redirect(302, '/questions'); return
}
const sourceQuestions = state.questions
// Same source as /api/questions, so a freshly published answer's share link works straight away
const sourceQuestions = state.draftQuestions ?? state.questions
const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer)
if (!question) {
res.redirect(302, '/questions'); return
@@ -196,7 +201,7 @@ export function register(app) {
// Scripture index — every passage covered across released study lessons and podcast episodes
app.get('/api/public/scripture-index', async (_req, res) => {
const content = state.cachedSiteContent
const content = await applyFeedRelease(state.cachedSiteContent)
const allStudies = Array.isArray(content?.studies) ? content.studies : []
const allEpisodes = [
...(Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []),
@@ -212,7 +217,8 @@ export function register(app) {
if (!sec.id || !sec.reference || !sec.releasedAt) continue
if (new Date(sec.releasedAt) > cutoff) continue
entries.push({
reference: `${bookName} ${sec.reference}`,
// Section references often already include the book ("Titus 1:1-2"); only prepend when they don't
reference: /^\s*(?:[1-3]\s*)?[A-Za-z]/.test(sec.reference) ? sec.reference.trim() : `${bookName} ${sec.reference}`,
sourceType: 'study',
sourceTitle: s.title || s.slug,
sectionTitle: sec.title || '',
@@ -269,6 +275,8 @@ export function register(app) {
app.use(async (_req, res) => {
try {
const html = await readFile(INDEX_FILE, 'utf8')
// Unknown URLs still render the app's "Page not found" screen, but with a real 404 status
if (!isKnownSitePath(_req.path)) res.status(404)
res.type('html').send(injectSeoIntoHtml(html, state.cachedSiteContent))
} catch {
res.status(503).send('Frontend build not found. Run "npm run build" first.')
+102 -25
View File
@@ -20,6 +20,51 @@ const upvoteRateLimiter = rateLimit({
const upvotedBy = new Set()
const UPVOTED_BY_MAX = 50000
// Only these fields are ever sent to the public; the stored record also holds the asker's email.
function toPublicQuestion(q) {
return {
id: q.id,
firstName: q.firstName,
question: q.question,
answer: q.answer,
topic: q.topic || '',
reference: q.reference || '',
submittedAt: q.submittedAt,
answeredAt: q.answeredAt,
upvotes: q.upvotes ?? 0,
pinned: q.pinned === true,
}
}
const clean = (value, max) => (typeof value === 'string' ? value.trim().slice(0, max) : '')
// Email the asker once when their answer goes live (if they asked to be told).
async function notifyAsker(question) {
if (!question.notifyOnAnswer || !question.email || !question.answer || question.notifiedAt) return
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const from = getResendFromAddress()
const replyTo = getResendReplyToAddress()
const baseUrl = getCanonicalBaseUrl()
const questionUrl = buildAbsoluteUrl(baseUrl, `/questions#qa-${question.id}`)
const template = buildQuestionAnsweredEmailTemplate({
firstName: question.firstName,
question: question.question,
answer: question.answer,
questionUrl,
})
await sendResendEmailWithRetry({
resend,
payload: { from, replyTo, to: question.email, subject: template.subject, text: template.text, html: template.html },
context: `notify-on-answer:${question.id}`,
})
question.notifiedAt = new Date().toISOString()
queueDraftQuestionsWrite()
} catch (err) {
console.error('[notify-on-answer] Failed to send notification email:', err?.message ?? err)
}
}
function ensureDraftQuestions() {
if (state.draftQuestions !== null) return
state.draftQuestions = state.questions.slice(0, MAX_QUESTIONS)
@@ -31,12 +76,14 @@ export function register(app) {
const publicQuestions = sourceQuestions
.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
.sort((a, b) => {
// Pinned questions always float to top
// Pinned first (they are also the ones featured on the home page), then newest answer first
if (a.pinned && !b.pinned) return -1
if (!a.pinned && b.pinned) return 1
return 0
const at = new Date(a.answeredAt ?? a.submittedAt ?? 0).getTime() || 0
const bt = new Date(b.answeredAt ?? b.submittedAt ?? 0).getTime() || 0
return bt - at
})
res.json({ questions: publicQuestions })
res.json({ questions: publicQuestions.map(toPublicQuestion) })
})
app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
@@ -75,6 +122,8 @@ export function register(app) {
answeredAt: answerText ? now : null,
isApproved: approveNow,
approvedAt: approveNow ? now : null,
topic: clean(req.body?.topic, 60),
reference: clean(req.body?.reference, 80),
}
state.draftQuestions.unshift(created)
@@ -119,32 +168,60 @@ export function register(app) {
question.approvedAt = approved === true ? new Date().toISOString() : null
queueDraftQuestionsWrite()
if (approved === true && question.notifyOnAnswer && question.email && question.answer) {
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const from = getResendFromAddress()
const replyTo = getResendReplyToAddress()
const baseUrl = getCanonicalBaseUrl()
const questionUrl = buildAbsoluteUrl(baseUrl, `/questions#qa-${question.id}`)
const template = buildQuestionAnsweredEmailTemplate({
firstName: question.firstName,
question: question.question,
answer: question.answer,
questionUrl,
})
await sendResendEmailWithRetry({
resend,
payload: { from, replyTo, to: question.email, subject: template.subject, text: template.text, html: template.html },
context: `notify-on-answer:${question.id}`,
})
} catch (err) {
console.error('[notify-on-answer] Failed to send notification email:', err?.message ?? err)
}
}
if (approved === true) await notifyAsker(question)
res.json({ ok: true, question })
})
// Edit any part of a question (typo fixes, answer, topic, scripture reference)
app.patch('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
ensureDraftQuestions()
const question = state.draftQuestions.find(q => q.id === req.params.id)
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
const body = req.body ?? {}
if (typeof body.question === 'string') {
const text = body.question.trim()
if (text.length < 5 || text.length > 3000) { res.status(400).json({ message: 'Question must be between 5 and 3000 characters.' }); return }
question.question = text
}
if (typeof body.answer === 'string') {
const text = body.answer.trim()
if (text.length > 5000) { res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return }
if (text !== (question.answer ?? '')) question.answeredAt = text ? new Date().toISOString() : null
question.answer = text
}
if (typeof body.firstName === 'string' && body.firstName.trim()) question.firstName = clean(body.firstName, 100)
if (typeof body.topic === 'string') question.topic = clean(body.topic, 60)
if (typeof body.reference === 'string') question.reference = clean(body.reference, 80)
queueDraftQuestionsWrite()
res.json({ ok: true, question })
})
// Save the answer, make it public and email the asker (once) — all in one step
app.post('/api/admin-questions/:id/publish', requireAdminAuth, async (req, res) => {
ensureDraftQuestions()
const question = state.draftQuestions.find(q => q.id === req.params.id)
if (!question) { res.status(404).json({ message: 'Question not found.' }); return }
const body = req.body ?? {}
if (typeof body.answer === 'string') {
const text = body.answer.trim()
if (text.length > 5000) { res.status(400).json({ message: 'Answer must be 5000 characters or fewer.' }); return }
if (text !== (question.answer ?? '')) question.answeredAt = text ? new Date().toISOString() : null
question.answer = text
}
if (typeof body.topic === 'string') question.topic = clean(body.topic, 60)
if (typeof body.reference === 'string') question.reference = clean(body.reference, 80)
if (!question.answer?.trim()) { res.status(400).json({ message: 'Write an answer before publishing.' }); return }
question.isApproved = true
question.approvedAt = new Date().toISOString()
queueDraftQuestionsWrite()
await notifyAsker(question)
res.json({ ok: true, question })
})
app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
ensureDraftQuestions()
+307
View File
@@ -0,0 +1,307 @@
import { Resend } from 'resend'
import { escapeHtml, sanitizeUrl } from '../helpers.js'
import { state } from '../state.js'
import { getResendFromAddress, getResendInboxAddress, getResendReplyToAddress, getCanonicalBaseUrl, sendResendEmailWithRetry } from '../email.js'
// A visitor who finished the /salvation page can tell us they prayed to receive Christ.
// Nothing is stored on the server: the response is emailed once to the site inbox and that is all.
const hits = new Map()
// One confirmation per address per hour, so the form can't be used to mail someone repeatedly.
const confirmedAt = new Map()
const CONFIRM_COOLDOWN_MS = 60 * 60 * 1000
function mayConfirm(address) {
const key = address.toLowerCase()
const now = Date.now()
const last = confirmedAt.get(key)
if (typeof last === 'number' && now - last < CONFIRM_COOLDOWN_MS) return false
confirmedAt.set(key, now)
if (confirmedAt.size > 2000) {
for (const [k, v] of confirmedAt) { if (now - v > CONFIRM_COOLDOWN_MS) confirmedAt.delete(k) }
}
return true
}
function rateLimit(req, res, next) {
const ip = req.ip ?? 'unknown'
const now = Date.now()
const windowMs = 10 * 60 * 1000
const entry = hits.get(ip) ?? { count: 0, start: now }
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
entry.count += 1
hits.set(ip, entry)
if (hits.size > 2000) {
for (const [k, v] of hits) { if (now - v.start > windowMs) hits.delete(k) }
}
if (entry.count > 3) {
res.status(429).json({ message: 'Please wait a few minutes before trying again.' })
return
}
next()
}
function clean(value, max) {
return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ').slice(0, max) : ''
}
// The notification the site owner receives. Styled to match the other admin emails
// (dark header, gold accent, cream cards) and written to be acted on, not just read.
function buildSalvationEmail({ name, email, message, when }) {
const who = name || 'Someone'
const subject = name ? `A new decision for Christ: ${name}` : 'A new decision for Christ on your salvation page'
const nameLine = name || 'Name not given'
const safeName = escapeHtml(nameLine)
const safeEmail = escapeHtml(email)
const safeWhen = escapeHtml(when)
const followUp = email
? [
'Reply to this email within a day or two. A short, warm note is plenty.',
'Congratulate them, and ask them to tell you their story.',
'Point them to the Gospel of John, a Bible-teaching church, and baptism.',
'Invite them to Start Here at versebyversewithnate.us/start-here.',
'Pray for them by name.',
]
: ['They did not leave an email address, so there is no way to reply.', 'Pray for them. God knows exactly who they are.']
const text = [
`A VERDICT HAS COME IN`,
``,
`${who} pleaded guilty and accepted Christ's pardon on the "The Gospel, in a Courtroom" page and says they prayed to receive Jesus as their Savior.`,
``,
`"There is joy in the presence of the angels of God over one sinner who repents." (Luke 15:10)`,
``,
`DETAILS`,
`Name: ${nameLine}`,
`Email: ${email || 'Not given'}`,
`When: ${when}`,
``,
`THEIR MESSAGE`,
message || '(They did not leave a message.)',
``,
`SUGGESTED FOLLOW-UP`,
...followUp.map((t, i) => `${i + 1}. ${t}`),
``,
`This email is the only record of this response. Nothing is stored on the website, so keep it somewhere safe.`,
email ? `Reply directly to this email to reach ${who}.` : '',
].filter(l => l !== '').join('\n')
const row = (label, value, last = false) =>
`<tr><td style="width:96px;padding:11px 0;font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#8a6d35;${last ? '' : 'border-bottom:1px solid #efe4cc;'}">${label}</td>` +
`<td style="padding:11px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;${last ? '' : 'border-bottom:1px solid #efe4cc;'}">${value}</td></tr>`
const steps = followUp
.map(t => `<li style="margin:0 0 8px;font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#201a10;">${escapeHtml(t)}</li>`)
.join('')
const messageBlock = message
? `<tr><td style="padding:0 28px 22px;">
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 8px;">Their message</div>
<div style="background:#fbf7ef;border-left:4px solid #c8860a;border-radius:4px;padding:16px 18px;font-family:Georgia,serif;font-size:16px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(message)}</div>
</td></tr>`
: `<tr><td style="padding:0 28px 22px;font-family:Arial,sans-serif;font-size:14px;color:#735a2b;font-style:italic;">They did not leave a message.</td></tr>`
const replyButton = email
? `<tr><td align="center" style="padding:0 28px 26px;">
<a href="mailto:${safeEmail}?subject=${encodeURIComponent('Welcome to the family of God')}" style="display:inline-block;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:14px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;text-decoration:none;padding:14px 30px;border-radius:4px;">Reply to ${escapeHtml(who)}</a>
</td></tr>`
: ''
const html = `<!doctype html><html><body style="margin:0;padding:0;background:#f5f1e8;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background:#f5f1e8;"><tr><td align="center" style="padding:28px 14px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:640px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr><td style="background:#111111;padding:30px 28px 26px;text-align:center;border-bottom:4px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.34em;text-transform:uppercase;color:#c8860a;">The Court of Heaven</div>
<div style="font-size:40px;line-height:1;margin:14px 0 6px;color:#c8860a;">&#9878;</div>
<h1 style="margin:0;font-family:Georgia,serif;font-size:30px;line-height:1.2;color:#f4ead5;">A Verdict Has Come In</h1>
<p style="margin:10px 0 0;font-family:Georgia,serif;font-style:italic;font-size:17px;color:#d9c9a3;">${escapeHtml(who)} pleaded guilty and accepted the pardon.</p>
</td></tr>
<tr><td style="padding:28px 28px 8px;">
<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;">Someone just worked through <em>The Gospel, in a Courtroom</em> on your website, pleaded guilty, and says they prayed to receive Jesus as their Savior.</p>
<p style="margin:0 0 20px;font-family:Georgia,serif;font-style:italic;font-size:16px;line-height:1.6;color:#57452b;border-left:3px solid #c8860a;padding-left:14px;">&ldquo;There is joy in the presence of the angels of God over one sinner who repents.&rdquo; <span style="font-style:normal;font-size:13px;color:#8a6d35;">Luke 15:10</span></p>
</td></tr>
<tr><td style="padding:0 28px 22px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="border-top:2px solid #c8860a;">
${row('Name', safeName)}
${row('Email', email ? `<a href="mailto:${safeEmail}" style="color:#8f5f05;text-decoration:none;">${safeEmail}</a>` : '<span style="color:#735a2b;font-style:italic;">Not given</span>')}
${row('When', safeWhen, true)}
</table>
</td></tr>
${messageBlock}
${replyButton}
<tr><td style="padding:0 28px 26px;">
<div style="background:#f7f2e5;border:1px solid #e8dcc1;border-radius:10px;padding:18px 22px;">
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 12px;">Suggested follow-up</div>
<ol style="margin:0;padding-left:20px;">${steps}</ol>
</div>
</td></tr>
<tr><td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:16px 28px;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#735a2b;"><strong>Keep this email.</strong> It is the only record of this response. Nothing is stored on the website. ${email ? 'Replying to this message goes straight to them.' : ''}</p>
</td></tr>
</table>
</td></tr></table></body></html>`
return { subject, text, html }
}
// The warm, one-time confirmation the visitor receives at the address they gave.
function buildSalvationConfirmationEmail({ name }) {
const base = getCanonicalBaseUrl().replace(/\/$/, '')
const greeting = name ? `Hi ${name},` : 'Hi friend,'
const subject = 'We received your message. Welcome to the family of God'
const steps = [
['Read the Gospel of John.', 'Start there, then keep going through the New Testament.'],
['Talk to God every day.', 'Prayer is simply talking with him, in your own words.'],
['Tell someone.', 'Share your decision with a Christian friend or family member.'],
['Find a church.', 'Join a Bible-teaching church, and be baptized as a public sign of your faith (Acts 2:38).'],
]
const text = [
greeting,
'',
'Thank you for letting us know that you prayed to receive Jesus. We are rejoicing with you, and your message has been received.',
'Someone from our team will reach out to you personally soon.',
'',
'"Therefore, there is now no condemnation for those who are in Christ Jesus." (Romans 8:1)',
'You are no longer a defendant. You are a child of the Judge.',
'',
'WHAT TO DO NEXT',
...steps.map(([h, b], i) => `${i + 1}. ${h} ${b}`),
'',
`A good path through the podcast and studies: ${base}/start-here`,
`Questions? Ask here: ${base}/contact?source=salvation`,
'',
'Grace and peace,',
'Verse by Verse with Nate',
'',
`You are receiving this one-time email because this address was entered on ${base}/salvation. If that was not you, you can safely ignore it.`,
].join('\n')
const stepRows = steps
.map(([h, b], i) =>
`<tr><td valign="top" style="width:34px;padding:0 0 14px;"><div style="width:26px;height:26px;border-radius:13px;background:#c8860a;color:#111;font-family:Arial,sans-serif;font-size:13px;font-weight:700;line-height:26px;text-align:center;">${i + 1}</div></td>` +
`<td style="padding:2px 0 14px;font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#201a10;"><strong>${escapeHtml(h)}</strong> ${escapeHtml(b)}</td></tr>`)
.join('')
const html = `<!doctype html><html><body style="margin:0;padding:0;background:#f5f1e8;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="background:#f5f1e8;"><tr><td align="center" style="padding:28px 14px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation" style="max-width:600px;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">
<tr><td style="background:#111111;padding:30px 28px 26px;text-align:center;border-bottom:4px solid #c8860a;">
<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.34em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>
<div style="width:54px;height:54px;border-radius:27px;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:30px;font-weight:700;line-height:54px;margin:16px auto 10px;">&#10003;</div>
<h1 style="margin:0;font-family:Georgia,serif;font-size:28px;line-height:1.25;color:#f4ead5;">Your message was received</h1>
<p style="margin:10px 0 0;font-family:Georgia,serif;font-style:italic;font-size:17px;color:#d9c9a3;">Welcome to the family of God.</p>
</td></tr>
<tr><td style="padding:30px 28px 6px;">
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:18px;line-height:1.6;color:#201a10;">${escapeHtml(greeting)}</p>
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;">Thank you for letting us know that you prayed to receive Jesus. We are rejoicing with you, and your message has been received.</p>
<p style="margin:0 0 22px;font-family:Georgia,serif;font-size:17px;line-height:1.7;color:#201a10;"><strong>Someone from our team will reach out to you personally soon.</strong></p>
</td></tr>
<tr><td style="padding:0 28px 26px;">
<div style="background:#fbf7ef;border-left:4px solid #c8860a;border-radius:4px;padding:18px 20px;">
<p style="margin:0 0 8px;font-family:Georgia,serif;font-style:italic;font-size:17px;line-height:1.6;color:#201a10;">&ldquo;Therefore, there is now no condemnation for those who are in Christ Jesus.&rdquo; <span style="font-style:normal;font-size:13px;color:#8a6d35;">Romans 8:1</span></p>
<p style="margin:0;font-family:Georgia,serif;font-size:15px;line-height:1.6;color:#57452b;">You are no longer a defendant. You are a child of the Judge.</p>
</div>
</td></tr>
<tr><td style="padding:0 28px 8px;">
<div style="font-family:Arial,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:#8a6d35;margin:0 0 14px;">What to do next</div>
<table width="100%" cellpadding="0" cellspacing="0" border="0" role="presentation">${stepRows}</table>
</td></tr>
<tr><td align="center" style="padding:6px 28px 8px;">
<a href="${escapeHtml(base)}/start-here" style="display:inline-block;background:#c8860a;color:#111111;font-family:Arial,sans-serif;font-size:14px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;text-decoration:none;padding:14px 28px;border-radius:4px;">Start Here</a>
</td></tr>
<tr><td align="center" style="padding:6px 28px 26px;font-family:Arial,sans-serif;font-size:14px;color:#57452b;">
Have a question? <a href="${escapeHtml(base)}/contact?source=salvation" style="color:#8f5f05;">Ask us here</a>, or just reply to this email.
</td></tr>
<tr><td style="padding:0 28px 28px;">
<p style="margin:0;font-family:Georgia,serif;font-size:16px;line-height:1.6;color:#201a10;">Grace and peace,<br><strong>Verse by Verse with Nate</strong></p>
</td></tr>
<tr><td style="background:#f7f2e5;border-top:1px solid #e8dcc1;padding:16px 28px;">
<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#735a2b;">You are receiving this one-time email because this address was entered at ${escapeHtml(base)}/salvation. If that was not you, you can safely ignore it.</p>
</td></tr>
</table>
</td></tr></table></body></html>`
return { subject, text, html }
}
export function register(app) {
// The /salvation page is static HTML, so it asks the server for the narration URLs set in Admin → Gospel Page.
app.get('/api/salvation-audio', (_req, res) => {
const c = state.cachedSiteContent ?? {}
res.set('Cache-Control', 'public, max-age=300')
res.json({
url: sanitizeUrl(c.salvationAudioUrl) || '',
guiltyUrl: sanitizeUrl(c.salvationAudioGuiltyUrl) || '',
notGuiltyUrl: sanitizeUrl(c.salvationAudioNotGuiltyUrl) || '',
})
})
app.post('/api/salvation-response', rateLimit, async (req, res) => {
const { name, email, message, _honey } = req.body ?? {}
if (_honey) { res.json({ ok: true }); return }
const safeName = clean(name, 100)
const safeEmail = clean(email, 200)
const safeMessage = typeof message === 'string' ? message.trim().slice(0, 2000) : ''
if (safeEmail && !/^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/.test(safeEmail)) {
res.status(400).json({ message: 'That email address does not look right. You can also leave it blank.' })
return
}
if (!process.env.RESEND_API_KEY) {
console.error('[salvation] RESEND_API_KEY env var not set')
res.status(503).json({ message: 'We could not send this right now.' })
return
}
const when = new Date().toLocaleString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: 'America/New_York', timeZoneName: 'short' })
const { subject, text, html } = buildSalvationEmail({ name: safeName, email: safeEmail, message: safeMessage, when })
try {
const resend = new Resend(process.env.RESEND_API_KEY)
await sendResendEmailWithRetry({
resend,
context: 'salvation-response',
payload: {
from: getResendFromAddress(),
to: [getResendInboxAddress()],
...(safeEmail ? { replyTo: safeEmail } : {}),
subject,
tags: [{ name: 'flow', value: 'salvation-response' }],
text,
html,
},
})
// Best effort: a failed or skipped confirmation must never turn a delivered decision into an error.
let confirmationSent = false
if (safeEmail && mayConfirm(safeEmail)) {
try {
const confirmation = buildSalvationConfirmationEmail({ name: safeName })
await sendResendEmailWithRetry({
resend,
context: 'salvation-confirmation',
payload: {
from: getResendFromAddress(),
to: [safeEmail],
replyTo: getResendReplyToAddress(),
subject: confirmation.subject,
tags: [{ name: 'flow', value: 'salvation-confirmation' }],
text: confirmation.text,
html: confirmation.html,
},
})
confirmationSent = true
} catch (confirmErr) {
console.error('[salvation] confirmation email failed:', confirmErr?.message ?? confirmErr)
}
}
res.json({ ok: true, confirmationSent })
} catch (err) {
console.error('[salvation] email failed:', err?.message ?? err)
res.status(502).json({ message: 'We could not send this right now.' })
}
})
}
+5 -2
View File
@@ -1,3 +1,4 @@
import { noteEntriesToText } from '../../shared/note-format.js'
import { randomUUID, timingSafeEqual } from 'node:crypto'
import rateLimit from 'express-rate-limit'
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from 'docx'
@@ -28,6 +29,7 @@ import {
findStudyUserByUsername,
getStudyAvatarUrl,
isStudyUserEnrolled,
isStudyUserCompleted,
normalizeStudySlug,
getStudyCatalog,
clearStudySessionCookie,
@@ -86,7 +88,7 @@ export function register(app) {
const sectionId = dashIndex >= 0 ? noteKey.slice(dashIndex + 2) : noteKey
if (!studyEntries[studySlug]) studyEntries[studySlug] = {}
studyEntries[studySlug][sectionId] = studyEntries[studySlug][sectionId] || {}
studyEntries[studySlug][sectionId].noteText = noteText.trim()
studyEntries[studySlug][sectionId].noteText = noteEntriesToText(noteText) || noteText.trim()
}
for (const [studySlug, studyProgress] of Object.entries(progress.byStudy)) {
@@ -212,6 +214,7 @@ export function register(app) {
title: study.title,
status: study.status,
enrolled: isStudyUserEnrolled(user, study.slug),
completed: isStudyUserCompleted(user, study.slug),
totalLessons,
completedLessons,
noteCount,
@@ -225,7 +228,7 @@ export function register(app) {
studySlug: c.studySlug,
studyTitle: c.studyTitle,
issuedAt: c.issuedAt,
url: buildAbsoluteUrl(baseUrl, `/study/certificate/${c.token}`),
url: buildAbsoluteUrl(baseUrl, `/certificate/${c.token}`),
}))
res.json({
+11 -3
View File
@@ -37,6 +37,7 @@ import {
storeEmailOtp,
verifyEmailOtp,
getStudyUserFromRequest,
getAccessibleStudySlugs,
} from '../study-helpers.js'
import {
sendEmailOtp,
@@ -69,7 +70,12 @@ export function register(app) {
subscribeNewsletter: user?.subscribeNewsletter !== false,
studyRemindersEnabled: user?.studyRemindersEnabled === true,
avatarUrl: user ? getStudyAvatarUrl(user) : '',
enrolledStudySlugs: Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : [],
// Shows an Admin link in the account menu. Only the admin panel can turn this on, and the link
// still lands on the admin sign-in, so it grants no access by itself.
adminShortcut: user?.adminShortcut === true,
// Studies the student can open: enrolled ones plus any they have finished
enrolledStudySlugs: user ? getAccessibleStudySlugs(user) : [],
completedStudySlugs: Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : [],
emailVerified: user?.emailVerified === true,
totpEnabled: Boolean(user && (user.twoFaMethod === 'app' || user.twoFaMethod === 'email') && (user.twoFaMethod === 'email' || (user.totpSecret && user.totpVerified))),
twoFaMethod: user?.twoFaMethod ?? null,
@@ -111,7 +117,8 @@ export function register(app) {
passwordHash: hashStudyPassword(password),
displayName,
subscribeNewsletter: subscribe,
studyRemindersEnabled: false,
studyRemindersEnabled: true,
remindersDefaultedAt: now,
pendingEmailChange: null,
emailVerified: false,
pendingEmailVerification: {
@@ -211,7 +218,8 @@ export function register(app) {
subscribeNewsletter: user.subscribeNewsletter !== false,
studyRemindersEnabled: user.studyRemindersEnabled === true,
avatarUrl: getStudyAvatarUrl(user.username),
enrolledStudySlugs: user.enrolledStudySlugs ?? [],
enrolledStudySlugs: getAccessibleStudySlugs(user),
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
})
})
+6 -18
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import rateLimit from 'express-rate-limit'
import { requireAdminAuth } from '../auth.js'
import { requireStudyAuth, normalizeStudySlug, isStudyUserEnrolled, getStudyTitleBySlug } from '../study-helpers.js'
import { requireStudyAuth, normalizeStudySlug, hasStudyAccess, getStudyTitleBySlug, getRequiredSectionIds, markStudyCompletedIfDone } from '../study-helpers.js'
import { state } from '../state.js'
import { loadUserProgress, queueStudyCertificatesWrite } from '../data.js'
import { sendStudyCertificateEmail, getCanonicalBaseUrl } from '../email.js'
@@ -15,20 +15,6 @@ const publicCertRateLimiter = rateLimit({
message: { message: 'Too many certificate lookups. Please wait a moment.' },
})
/**
* Returns the list of required section IDs for a study. Every lesson is required, so a
* certificate is only available once the whole study has been released and completed.
*/
function getRequiredSectionIds(studySlug) {
const content = state.cachedSiteContent
if (!content || !Array.isArray(content.studies)) return null
const study = content.studies.find(s => s?.slug === studySlug)
if (!study || !Array.isArray(study.sections)) return null
return study.sections
.filter(s => s?.id)
.map(s => s.id)
}
export function register(app) {
// GET — check eligibility and return existing certificate token if one exists
app.get('/api/study-certificate/:studySlug', requireStudyAuth, async (req, res) => {
@@ -36,7 +22,7 @@ export function register(app) {
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Not enrolled in this study.' }); return
}
@@ -49,6 +35,8 @@ export function register(app) {
const completedIds = progress.byStudy[studySlug]?.completedSectionIds ?? []
const eligible = requiredIds.every(id => completedIds.includes(id))
if (eligible) markStudyCompletedIfDone(user, studySlug, completedIds)
const existing = state.studyCertificates.find(c => c.userId === user.id && c.studySlug === studySlug)
res.json({
@@ -67,7 +55,7 @@ export function register(app) {
const studySlug = normalizeStudySlug(req.params.studySlug)
if (!studySlug) { res.status(400).json({ message: 'Invalid study slug.' }); return }
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Not enrolled in this study.' }); return
}
@@ -98,7 +86,7 @@ export function register(app) {
}
state.studyCertificates.push(cert)
queueStudyCertificatesWrite()
const certUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/study/certificate/${cert.token}`)
const certUrl = buildAbsoluteUrl(getCanonicalBaseUrl(), `/certificate/${cert.token}`)
sendStudyCertificateEmail(user.username, user.displayName || user.username, cert.studyTitle, certUrl)
.catch(err => console.error('[study-certificate] email exception:', err))
}
+10 -2
View File
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'
import { requireAdminAuth } from '../auth.js'
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, isStudyUserEnrolled, findStudyUserById, getCommunityDisplayName } from '../study-helpers.js'
import { requireStudyAuth, normalizeStudySlug, normalizeLessonSectionId, hasStudyAccess, findStudyUserById, getCommunityDisplayName, isStudyDiscussionOpen, DISCUSSION_CLOSED_MESSAGE } from '../study-helpers.js'
import { state } from '../state.js'
import { queueStudyCommentsWrite } from '../data.js'
import { MAX_STUDY_COMMENTS, MAX_STUDY_COMMENT_LENGTH } from '../config.js'
@@ -14,6 +14,10 @@ export function register(app) {
res.status(400).json({ message: 'Invalid study slug or section id.' }); return
}
if (!isStudyDiscussionOpen(studySlug)) {
res.json({ comments: [], closed: true, message: DISCUSSION_CLOSED_MESSAGE }); return
}
const comments = state.studyComments
.filter(c => c.studySlug === studySlug && c.sectionId === sectionId && c.isApproved === true)
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
@@ -40,10 +44,14 @@ export function register(app) {
res.status(400).json({ message: 'Invalid study slug or section id.' }); return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to leave a comment.' }); return
}
if (!isStudyDiscussionOpen(studySlug)) {
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true }); return
}
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : ''
if (!text || text.length < 2 || text.length > MAX_STUDY_COMMENT_LENGTH) {
res.status(400).json({ message: `Comment must be between 2 and ${MAX_STUDY_COMMENT_LENGTH} characters.` }); return
+47 -18
View File
@@ -3,13 +3,17 @@ import {
normalizeStudySlug,
normalizeLessonSectionId,
getStudySlugFromNoteId,
isStudyUserEnrolled,
hasStudyAccess,
isEnrollableStudySlug,
getStudyTitleBySlug,
findStudyUserById,
getStudyAvatarUrl,
getStudyCatalog,
getCommunityDisplayName,
getAccessibleStudySlugs,
markStudyCompletedIfDone,
isStudyDiscussionOpen,
DISCUSSION_CLOSED_MESSAGE,
} from '../study-helpers.js'
import { state } from '../state.js'
import {
@@ -73,7 +77,8 @@ export function register(app) {
ok: true,
studySlug,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
enrolledStudySlugs: user.enrolledStudySlugs,
enrolledStudySlugs: getAccessibleStudySlugs(user),
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
})
})
@@ -96,7 +101,8 @@ export function register(app) {
ok: true,
studySlug,
studyTitle: getStudyTitleBySlug(studySlug) || studySlug,
enrolledStudySlugs: user.enrolledStudySlugs,
enrolledStudySlugs: getAccessibleStudySlugs(user),
completedStudySlugs: Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : [],
})
})
@@ -116,7 +122,7 @@ export function register(app) {
}
const user = req.studyUser
const noteStudySlug = getStudySlugFromNoteId(sectionId)
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
if (noteStudySlug && !hasStudyAccess(user, noteStudySlug)) {
res.status(403).json({ message: 'Please enroll in this study to access notes.' })
return
}
@@ -136,12 +142,16 @@ export function register(app) {
return
}
const noteStudySlug = getStudySlugFromNoteId(sectionId)
if (noteStudySlug && !isStudyUserEnrolled(user, noteStudySlug)) {
if (noteStudySlug && !hasStudyAccess(user, noteStudySlug)) {
res.status(403).json({ message: 'Please enroll in this study to save notes.' })
return
}
const rawNote = typeof req.body?.note === 'string' ? req.body.note : ''
const note = rawNote.trim().slice(0, MAX_STUDY_NOTE_LENGTH)
const note = rawNote.trim()
if (note.length > MAX_STUDY_NOTE_LENGTH) {
res.status(413).json({ message: 'This lesson\'s notes are too long to save. Please trim some text.' })
return
}
const notes = await loadUserNotes(user.id)
if (!note) {
@@ -170,7 +180,7 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view progress.' })
return
}
@@ -197,7 +207,7 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
return
}
@@ -227,8 +237,9 @@ export function register(app) {
user.firstCompletionAt = new Date().toISOString()
}
queueStudyUsersWrite()
const justCompleted = markStudyCompletedIfDone(user, studySlug, studyProgress.completedSectionIds)
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds })
res.json({ ok: true, studySlug, completedSectionIds: studyProgress.completedSectionIds, studyCompleted: justCompleted || undefined })
})
app.delete('/api/study-progress/:studySlug/:sectionId', requireStudyAuth, async (req, res) => {
@@ -240,7 +251,7 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to update progress.' })
return
}
@@ -267,7 +278,7 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view quiz answers.' })
return
}
@@ -286,7 +297,7 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to save quiz answers.' })
return
}
@@ -315,7 +326,7 @@ export function register(app) {
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return }
if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to bookmark lessons.' }); return }
if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Please enroll in this study to bookmark lessons.' }); return }
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
@@ -335,7 +346,7 @@ export function register(app) {
const studySlug = normalizeStudySlug(req.params.studySlug)
const sectionId = normalizeLessonSectionId(req.params.sectionId)
if (!studySlug || !sectionId) { res.status(400).json({ message: 'Invalid study slug or section id.' }); return }
if (!isStudyUserEnrolled(user, studySlug)) { res.status(403).json({ message: 'Not enrolled.' }); return }
if (!hasStudyAccess(user, studySlug)) { res.status(403).json({ message: 'Not enrolled.' }); return }
const progress = await loadUserProgress(user.id)
const studyProgress = progress.byStudy[studySlug] ?? { completedSectionIds: [], bookmarkedSectionIds: [] }
@@ -358,11 +369,16 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to view the community.' })
return
}
if (!isStudyDiscussionOpen(studySlug)) {
res.json({ studySlug, posts: [], closed: true, message: DISCUSSION_CLOSED_MESSAGE })
return
}
const posts = state.studyCommunityPosts
.filter(post => post.studySlug === studySlug)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
@@ -403,11 +419,16 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, studySlug)) {
if (!hasStudyAccess(user, studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to post in the community.' })
return
}
if (!isStudyDiscussionOpen(studySlug)) {
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true })
return
}
const now = new Date().toISOString()
const authorName = getCommunityDisplayName(user)
const post = {
@@ -445,11 +466,16 @@ export function register(app) {
return
}
if (!isStudyUserEnrolled(user, post.studySlug)) {
if (!hasStudyAccess(user, post.studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to reply in the community.' })
return
}
if (!isStudyDiscussionOpen(post.studySlug)) {
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true })
return
}
const reply = {
id: randomUUID(),
authorUserId: user.id,
@@ -472,9 +498,12 @@ export function register(app) {
const postId = typeof req.params.postId === 'string' ? req.params.postId.trim() : ''
const post = state.studyCommunityPosts.find(item => item.id === postId)
if (!post) { res.status(404).json({ message: 'Post not found.' }); return }
if (!isStudyUserEnrolled(user, post.studySlug)) {
if (!hasStudyAccess(user, post.studySlug)) {
res.status(403).json({ message: 'Please enroll in this study to like posts.' }); return
}
if (!isStudyDiscussionOpen(post.studySlug)) {
res.status(403).json({ message: DISCUSSION_CLOSED_MESSAGE, closed: true }); return
}
if (!Array.isArray(post.likedBy)) post.likedBy = []
const idx = post.likedBy.indexOf(user.id)
if (idx === -1) {
+30
View File
@@ -0,0 +1,30 @@
import dns from 'node:dns'
import { SEAFILE_URL } from './config.js'
// Alpine's resolver (musl) fails the whole lookup when a DNS server answers the IPv6 (AAAA)
// query with "no such name" even though the IPv4 answer is fine, so Node reports ENOTFOUND for
// a host that resolves. Ask for IPv4 only, and only for the Seafile host.
export function preferIpv4ForSeafile() {
let host = ''
try {
host = SEAFILE_URL ? new URL(SEAFILE_URL).hostname : ''
} catch {
return
}
if (!host) return
const originalLookup = dns.lookup
dns.lookup = function lookup(hostname, options, callback) {
if (hostname === host) {
if (typeof options === 'function') {
callback = options
options = {}
} else if (typeof options === 'number') {
options = { family: options }
}
if (!options?.family) options = { ...options, family: 4 }
}
return originalLookup.call(this, hostname, options, callback)
}
console.log(`[seafile-dns] resolving ${host} over IPv4 only`)
}
+174
View File
@@ -0,0 +1,174 @@
import { state } from './state.js'
import { requireAdminAuth } from './auth.js'
import { scriptKey } from './episode-script-keys.js'
import { getFeedIndex } from './feed-release.js'
import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
// One report per lane (A = primary series, B = second series) listing what is set up, what is
// missing, and an episode-by-episode grid. Reads the working draft so problems show up before
// publishing. Read-only: it never changes anything.
const err = (message, view) => ({ severity: 'error', message, view })
const warn = (message, view) => ({ severity: 'warn', message, view })
function laneConfig(content, lane) {
if (lane === 'A') {
return {
title: String(content.seriesTitle ?? '').trim(),
studySlug: String(content.seriesStudySlug ?? '').trim(),
highlightLane: 'primary',
description: String(content.seriesDescription ?? '').trim(),
imageUrl: String(content.seriesImageUrl ?? '').trim(),
listenUrl: String(content.seriesListenUrl ?? '').trim(),
hasGuide: Boolean(String(content.studyGuideTitle ?? '').trim() && (content.studyGuideDownloadUrl || content.studyGuideUrl || content.studyGuideAppleBooksUrl)),
transition: content.scheduledSeriesTransition,
}
}
const second = content.secondSeries ?? {}
return {
title: String(second.title ?? '').trim(),
studySlug: String(second.studySlug ?? '').trim(),
highlightLane: 'second',
description: String(second.description ?? '').trim(),
imageUrl: String(second.imageUrl ?? '').trim(),
listenUrl: String(second.listenUrl ?? '').trim(),
hasGuide: Boolean(String(second.guide?.title ?? '').trim() && (second.guide?.downloadUrl || second.guide?.amazonUrl || second.guide?.appleBooksUrl)),
transition: content.scheduledSecondSeriesTransition,
}
}
export async function buildSeasonCheck() {
const content = state.cachedDraftSiteContent ?? state.cachedSiteContent ?? {}
const [wf, index] = await Promise.all([loadPodcastWorkflow(), getFeedIndex()])
const seasons = { A: laneSeasonOf('A', wf, content), B: laneSeasonOf('B', wf, content) }
const studies = Array.isArray(content.studies) ? content.studies : []
const highlights = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
const planned = new Set(wf.planned.map(p => scriptKey(p.season, p.episodeNumber)))
const lanes = []
for (const lane of ['A', 'B']) {
const cfg = laneConfig(content, lane)
// Lane B only exists once a second series has been set up
if (lane === 'B' && !cfg.title && !cfg.studySlug && !seasons.B) continue
const season = seasons[lane]
const problems = []
if (!cfg.title) problems.push(warn(`Lane ${lane} has no series title.`, 'podcast:current-series'))
if (!season) problems.push(err(`Lane ${lane} has no season number. Highlights and lesson audio can't match the feed without it.`, 'podcast:workflow'))
if (season && lane === 'B' && season === seasons.A) problems.push(err(`Lanes A and B both use season ${season}.`, 'podcast:workflow'))
const study = studies.find(s => s?.lane === lane) ?? studies.find(s => s?.slug && s.slug === cfg.studySlug)
if (!study) problems.push(warn(`No study is linked to Lane ${lane}. Set the study's lane, or link it in Current Series.`, 'colossians-study'))
else if (study.lane !== lane) problems.push(warn(`"${study.title}" is linked to Lane ${lane} in Current Series but the study's own lane isn't set to ${lane}.`, 'colossians-study'))
const sections = Array.isArray(study?.sections) ? study.sections : []
const laneHighlights = highlights.filter(h => (h.lane ?? 'primary') === cfg.highlightLane)
const noNumber = sections.filter(s => !String(s.episodeNumber ?? '').trim())
if (study && noNumber.length > 0) {
problems.push(warn(`${noNumber.length} lesson${noNumber.length === 1 ? '' : 's'} in "${study.title}" ${noNumber.length === 1 ? 'has' : 'have'} no episode number, so ${noNumber.length === 1 ? 'it' : 'they'} can't pick up audio.`, 'colossians-study'))
}
const noDate = sections.filter(s => String(s.episodeNumber ?? '').trim() && !s.releasedAt)
if (study && noDate.length > 0) {
problems.push(warn(`${noDate.length} lesson${noDate.length === 1 ? '' : 's'} with an episode number ${noDate.length === 1 ? 'has' : 'have'} no release date.`, 'colossians-study'))
}
const noHighlightNumber = laneHighlights.filter(h => !String(h.episodeNumber ?? '').trim())
if (noHighlightNumber.length > 0) {
problems.push(warn(`${noHighlightNumber.length} highlight${noHighlightNumber.length === 1 ? '' : 's'} in Lane ${lane} ${noHighlightNumber.length === 1 ? 'has' : 'have'} no episode number.`, 'podcast:highlights'))
}
// Series card
if (!cfg.imageUrl) problems.push(warn(`Lane ${lane} has no cover image.`, 'podcast:current-series'))
if (!cfg.description) problems.push(warn(`Lane ${lane} has no series description.`, 'podcast:current-series'))
if (!cfg.listenUrl) problems.push(warn(`Lane ${lane} has no listen link.`, 'podcast:current-series'))
if (!cfg.hasGuide) problems.push(warn(`Lane ${lane} has no companion study guide (title plus a download or purchase link).`, 'podcast:current-series'))
// Handover to the next season
const t = cfg.transition
if (t) {
if (!String(t.newTitle ?? '').trim() || !t.newSeason) {
problems.push(warn(`The next-season handover for Lane ${lane} is scheduled but is missing its title or season number.`, 'podcast:current-series'))
} else if (season && String(t.newSeason) === season) {
problems.push(warn(`The next-season handover for Lane ${lane} uses the same season number (${season}) as the current one.`, 'podcast:current-series'))
}
} else if (study && sections.length > 0 && sections.every(x => x.releasedAt && new Date(x.releasedAt) <= new Date())) {
problems.push(warn(`Every lesson in "${study.title}" is released, but no next season is scheduled for Lane ${lane}.`, 'podcast:current-series'))
}
// Episode numbers to show: every number any part of the season mentions, plus 1..lesson count
const numbers = new Set()
for (let n = 1; n <= sections.length; n++) numbers.add(n)
const addNumber = v => { const n = Number(v); if (Number.isInteger(n) && n > 0) numbers.add(n) }
sections.forEach(s => addNumber(s.episodeNumber))
laneHighlights.forEach(h => addNumber(h.episodeNumber))
for (const [key, item] of index) if (season && key.startsWith(`${season}-`)) addNumber(item.episode)
for (const p of wf.planned) if (season && String(p.season) === season) addNumber(p.episodeNumber)
for (const key of Object.keys(state.episodeScripts ?? {})) if (season && key.startsWith(`${season}-`)) addNumber(key.split('-')[1])
const episodes = [...numbers].sort((a, b) => a - b).map(n => {
const num = String(n)
const feedItem = season ? index.get(`${season}-${num}`) : undefined
const released = Boolean(feedItem)
const lessons = sections.filter(s => String(s.episodeNumber ?? '').trim() === num)
const hl = laneHighlights.filter(h => String(h.episodeNumber ?? '').trim() === num)
const lessonHasAudio = lessons.some(s => s.audioEmbedUrl?.trim() || (released && feedItem?.audioUrl))
if (lessons.length > 1) problems.push(warn(`Episode ${num}: ${lessons.length} lessons share this episode number.`, 'colossians-study'))
if (hl.length > 1) problems.push(warn(`Episode ${num}: ${hl.length} highlights share this episode number.`, 'podcast:highlights'))
if (season && !released && hl.some(h => !h.holdUntilReleased && h.lane !== 'general')) {
problems.push(warn(`Episode ${num}: the highlight will show on the site before the episode is out. Turn on "Publish when the episode is released".`, 'podcast:highlights'))
}
return {
number: n,
released,
releaseDate: feedItem?.pubDate ? new Date(feedItem.pubDate).toISOString().slice(0, 10) : '',
workflowStarted: season ? planned.has(scriptKey(season, num)) : false,
script: season ? Boolean(state.episodeScripts?.[scriptKey(season, num)]) : false,
lesson: lessons.length > 0,
lessonAudio: lessons.length > 0 ? lessonHasAudio : null,
highlight: hl.length > 0,
highlightHeld: hl.length > 0 ? hl.every(h => h.holdUntilReleased === true) : null,
}
})
lanes.push({
lane,
title: cfg.title,
season,
studyTitle: study?.title ?? '',
problems,
errors: problems.filter(p => p.severity === 'error').length,
warnings: problems.filter(p => p.severity === 'warn').length,
episodes,
})
}
// Scripts are filed by season + episode, so one with no season can't be matched to an episode
const general = []
const unseasoned = Object.entries(state.episodeScripts ?? {}).filter(([, entry]) => !String(entry?.season ?? '').trim())
if (unseasoned.length > 0) {
general.push(warn(`${unseasoned.length} episode script${unseasoned.length === 1 ? ' has' : 's have'} no season number, so ${unseasoned.length === 1 ? 'it' : 'they'} can't be matched to an episode. Use the season backfill in Episode Scripts.`, 'podcast:scripts'))
}
const all = [...lanes.flatMap(l => l.problems), ...general]
return {
lanes,
general,
total: all.length,
errors: all.filter(p => p.severity === 'error').length,
warnings: all.filter(p => p.severity === 'warn').length,
}
}
export function register(app) {
app.get('/api/admin/season-check', requireAdminAuth, async (_req, res) => {
try {
res.json(await buildSeasonCheck())
} catch (err) {
console.error('[season-check] failed:', err)
res.status(500).json({ message: 'Could not build the season check.' })
}
})
}
+3 -5
View File
@@ -3,7 +3,6 @@ import {
EMPTY_VISITOR_STATS,
DEFAULT_PUBLISH_STATE,
DEFAULT_REPLY_TEMPLATES,
buildDefaultPodcastChecklist,
} from './config.js'
export const state = {
@@ -36,8 +35,6 @@ export const state = {
replyHistory: [],
replyHistoryWritePromise: Promise.resolve(),
podcastChecklist: buildDefaultPodcastChecklist(),
podcastChecklistWritePromise: Promise.resolve(),
studyUsers: [],
studyUsersWritePromise: Promise.resolve(),
@@ -99,9 +96,10 @@ export const state = {
// newsletterIssues: Array<{ id, resendBroadcastId, slug, title, subject, description, html, sentAt, publishedAt, public }>
newsletterIssues: [],
newsletterIssuesWritePromise: Promise.resolve(),
// nuggetsEpisodeTags: { [audioUrl]: string[] } — admin-assigned tags for Nuggets RSS episodes
nuggetsEpisodeTags: {},
nuggetsEpisodeTagsWritePromise: Promise.resolve(),
calendarEvents: [],
calendarEventsWritePromise: Promise.resolve(),
auditLog: [],
auditLogWritePromise: Promise.resolve(),
+127 -1
View File
@@ -22,7 +22,7 @@ import {
DOWNLOAD_TOKEN_TTL_MS,
} from './config.js'
import { state } from './state.js'
import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite } from './data.js'
import { queueStudyRemindersWrite, queueStudyUsersWrite, queueStudySessionsWrite, loadUserProgress } from './data.js'
// ── Username / slug normalizers ────────────────────────────────────────────
@@ -163,6 +163,81 @@ export function isStudyUserEnrolled(user, studySlug) {
return Array.isArray(user.enrolledStudySlugs) && user.enrolledStudySlugs.includes(normalized)
}
// A student who has finished every lesson in a study keeps access to it (lessons, notes,
// certificate) even if they later leave the enrollment. Completion is stored on the user
// so it survives content edits and un-enrolling.
export function isStudyUserCompleted(user, studySlug) {
const normalized = normalizeStudySlug(studySlug)
if (!normalized || !user) return false
return Array.isArray(user.completedStudySlugs) && user.completedStudySlugs.includes(normalized)
}
export function hasStudyAccess(user, studySlug) {
return isStudyUserEnrolled(user, studySlug) || isStudyUserCompleted(user, studySlug)
}
// Enrolled studies plus finished ones, deduplicated. Sent to the client as the list of
// studies the student can open.
export function getAccessibleStudySlugs(user) {
const enrolled = Array.isArray(user?.enrolledStudySlugs) ? user.enrolledStudySlugs : []
const completed = Array.isArray(user?.completedStudySlugs) ? user.completedStudySlugs : []
return [...new Set([...enrolled, ...completed])]
}
// Every lesson (released or not) is required, matching the certificate rules.
export function getRequiredSectionIds(studySlug) {
const content = state.cachedSiteContent
if (!content || !Array.isArray(content.studies)) return null
const study = content.studies.find(item => item?.slug === studySlug)
if (!study || !Array.isArray(study.sections)) return null
return study.sections.filter(item => item?.id).map(item => item.id)
}
// Marks the study completed on the user when every required lesson is done. Sticky: never
// removed, so a later new lesson doesn't revoke access. Returns true if newly completed.
export function markStudyCompletedIfDone(user, studySlug, completedSectionIds) {
const normalized = normalizeStudySlug(studySlug)
if (!normalized || isStudyUserCompleted(user, normalized)) return false
const required = getRequiredSectionIds(normalized)
if (!required || required.length === 0) return false
if (!required.every(id => completedSectionIds.includes(id))) return false
user.completedStudySlugs = [...(Array.isArray(user.completedStudySlugs) ? user.completedStudySlugs : []), normalized]
user.updatedAt = new Date().toISOString()
queueStudyUsersWrite()
return true
}
// One-off pass at startup so students who finished a study before this existed get the flag.
export async function backfillCompletedStudies() {
if (!state.cachedSiteContent) return 0
let updated = 0
for (const user of state.studyUsers) {
const progress = await loadUserProgress(user.id)
// Enrolled studies plus any with saved progress, so students who already left are caught too
const slugs = new Set([
...(Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []),
...Object.keys(progress.byStudy ?? {}),
])
for (const slug of slugs) {
if (markStudyCompletedIfDone(user, slug, progress.byStudy[slug]?.completedSectionIds ?? [])) updated++
}
}
return updated
}
// Discussion (community posts, replies, likes and lesson comments) is open for any study that
// has started: the current study and finished ones, so students who take a study later can
// still talk with each other. Only planned studies (not started yet) are closed.
export function isStudyDiscussionOpen(studySlug) {
const normalized = normalizeStudySlug(studySlug)
const studies = state.cachedSiteContent?.studies
if (!normalized || !Array.isArray(studies)) return false
const study = studies.find(item => normalizeStudySlug(item?.slug) === normalized)
return Boolean(study) && getEffectiveStudyStatus(study) !== 'planned'
}
export const DISCUSSION_CLOSED_MESSAGE = 'Discussion opens once this study has started.'
export function findStudyUserById(userId) {
if (typeof userId !== 'string' || !userId.trim()) return undefined
return state.studyUsers.find(user => user.id === userId)
@@ -401,6 +476,21 @@ export function normalizeHitPath(pathname) {
return pathname
}
// Top-level URL segments the SPA / static pages actually serve. Anything else is a probe or
// scanner hit, which must not count as a real visit or add a new key to the per-path stats.
const KNOWN_SITE_SEGMENTS = new Set([
'start-here', 'study', 'finished', 'episodes', 'nuggets', 'resources', 'downloads', 'about', 'contact',
'questions', 'newsletter', 'scripture', 'thanks', 'subscribe', 'certificate', 'host', 'admin', 'preview',
'privacy', 'terms', 'salvation', 'qr',
])
const MAX_UNKNOWN_PATHS = 300
export function isKnownSitePath(pathname) {
const first = String(pathname ?? '/').split('/')[1] ?? ''
return first === '' || KNOWN_SITE_SEGMENTS.has(first)
}
export function shouldCountHit(req) {
if (req.method !== 'GET') return false
if (req.path.startsWith('/api/')) return false
@@ -505,6 +595,21 @@ export function recordHit(pathname, isBot = false, botReason = null) {
state.hitStats.lastHitAt = nowIso
state.hitStats.firstHitAt = state.hitStats.firstHitAt ?? nowIso
if (isBot && botReason === 'unknown-path') {
// Probe/scanner URLs: keep a bounded list for the admin view instead of one stats key per URL
const unknown = (state.hitStats.byPathUnknown ??= {})
const key = safePath.slice(0, 120)
if (key in unknown || Object.keys(unknown).length < MAX_UNKNOWN_PATHS) unknown[key] = (unknown[key] ?? 0) + 1
else unknown['(other)'] = (unknown['(other)'] ?? 0) + 1
state.hitStats.botHits += 1
state.hitStats.byPathBot['(unknown)'] = (state.hitStats.byPathBot['(unknown)'] ?? 0) + 1
state.hitStats.byDayBot[dayKey] = (state.hitStats.byDayBot[dayKey] ?? 0) + 1
state.hitStats.botReasons[botReason] = (state.hitStats.botReasons[botReason] ?? 0) + 1
state.hitStats.byPath['(unknown)'] = (state.hitStats.byPath['(unknown)'] ?? 0) + 1
state.hitStats.byDay[dayKey] = (state.hitStats.byDay[dayKey] ?? 0) + 1
return
}
if (isBot) {
state.hitStats.botHits += 1
state.hitStats.byPathBot[safePath] = (state.hitStats.byPathBot[safePath] ?? 0) + 1
@@ -631,6 +736,23 @@ export function extractResendMessageId(result) {
return ''
}
// One-off pass: lesson reminders are now on by default, so switch them on for accounts that
// predate the default. Each account is marked as it is handled, so a later opt-out sticks.
export async function backfillReminderDefault() {
const now = new Date().toISOString()
let updated = 0
for (const user of state.studyUsers) {
if (user.remindersDefaultedAt) continue
if (user.studyRemindersEnabled !== true) {
user.studyRemindersEnabled = true
updated++
}
user.remindersDefaultedAt = now
}
if (state.studyUsers.some(user => user.remindersDefaultedAt === now)) queueStudyUsersWrite()
return updated
}
// ── Study reminder scheduler ───────────────────────────────────────────────
export async function scheduleStudyReminders(sendStudyReminderEmail) {
@@ -649,6 +771,7 @@ export async function scheduleStudyReminders(sendStudyReminderEmail) {
const userSent = state.studyReminders.users[user.id] ?? {}
for (const studySlug of enrolledStudySlugs) {
if (isStudyUserCompleted(user, studySlug)) continue // finished studies don't need lesson reminders
const study = Array.isArray(state.cachedSiteContent.studies)
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
: undefined
@@ -724,7 +847,9 @@ export async function scheduleReengagementEmails(sendStudyReengagementEmail) {
const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : []
const studies = Array.isArray(state.cachedSiteContent.studies) ? state.cachedSiteContent.studies : []
// Studies the student already finished aren't "unfinished business" for a come-back nudge.
const enrolledStudies = enrolledStudySlugs
.filter(slug => !isStudyUserCompleted(user, slug))
.map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug))
.filter(Boolean)
@@ -782,6 +907,7 @@ export async function scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail) {
const newlySentByStudy = {}
for (const studySlug of enrolledStudySlugs) {
if (isStudyUserCompleted(user, studySlug)) continue
const study = Array.isArray(state.cachedSiteContent.studies)
? state.cachedSiteContent.studies.find(item => normalizeStudySlug(item?.slug) === studySlug)
: undefined
+32 -1
View File
@@ -67,7 +67,7 @@ export const BIBLE_BOOKS = [
{ id: 'TH2', name: '2 Thessalonians', aliases: ['2 thess'] },
{ id: 'TI1', name: '1 Timothy', aliases: ['1 tim'] },
{ id: 'TI2', name: '2 Timothy', aliases: ['2 tim'] },
{ id: 'TIT', name: 'Titus' },
{ id: 'TIT', name: 'Titus', aliases: ['tit'] },
{ id: 'PHM', name: 'Philemon', aliases: ['philem'] },
{ id: 'HEB', name: 'Hebrews', aliases: ['heb'] },
{ id: 'JAM', name: 'James', aliases: ['jas'] },
@@ -85,3 +85,34 @@ export function bookNumberFor(usfmId) {
const index = BIBLE_BOOKS.findIndex(b => b.id === usfmId)
return index === -1 ? null : index + 1
}
let referenceBookLookup = null
/**
* Checks a passage reference the way the Scripture Index and passage fetch read it.
* `bookHint` is the study's Bible Book field (or slug) and is used when the reference
* has no book of its own ("1:1-2"). Returns null when it will work, otherwise a short
* message explaining what to change.
*/
export function checkPassageReference(reference, bookHint) {
if (!referenceBookLookup) {
referenceBookLookup = new Set()
for (const b of BIBLE_BOOKS) {
for (const key of [b.name.toLowerCase(), ...(b.aliases ?? [])]) {
referenceBookLookup.add(key)
referenceBookLookup.add(key.replace(/\s+/g, ''))
}
}
}
const clean = String(reference ?? '').replace(/[–—]/g, '-').replace(/(\d)[a-z]\b/gi, '$1').trim()
if (!clean) return null
const m = clean.match(/^(?:(.*?)\s+)?(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/)
if (!m) return 'Use the form 1:1-2 (chapter:verse, optional range). It will be listed under "Other references" in the Scripture Index until fixed.'
const book = (m[1] ?? String(bookHint ?? '')).toLowerCase().replace(/\.\s*/g, ' ').trim()
if (!book || !referenceBookLookup.has(book)) {
return m[1]
? `"${m[1]}" is not a recognized Bible book name.`
: 'Set this study\'s "Bible Book" field (e.g. titus) so this reference can be matched to a book.'
}
return null
}
+14
View File
@@ -0,0 +1,14 @@
export type NoteAnchorId = 'scripture' | 'commentary' | 'questions' | 'general'
export interface NoteEntry {
id: string
anchor: NoteAnchorId
html: string
updatedAt: string | null
}
export const NOTE_ANCHORS: NoteAnchorId[]
export const NOTE_ANCHOR_LABELS: Record<NoteAnchorId, string>
export function newNoteId(): string
export function parseNoteEntries(raw: string): NoteEntry[]
export function serializeNoteEntries(entries: NoteEntry[]): string
export function noteHtmlToText(html: string): string
export function noteEntriesToText(raw: string): string
+79
View File
@@ -0,0 +1,79 @@
// Lesson note storage format, shared by the client and the server.
//
// Each lesson's notes are stored as one string. Current format (v2):
// {"v":2,"entries":[{"id","anchor","html","updatedAt"}]}
// Legacy format (v1) was a JSON object of { anchor: html }, and older still was
// plain text. Both are still read and are upgraded to v2 on the next save.
export const NOTE_ANCHORS = ['scripture', 'commentary', 'questions', 'general']
export const NOTE_ANCHOR_LABELS = {
scripture: 'Scripture Text',
commentary: 'Commentary',
questions: 'Reflection Questions',
general: 'General Notes',
}
export function newNoteId() {
return `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`
}
export function parseNoteEntries(raw) {
if (typeof raw !== 'string' || !raw.trim()) return []
let parsed = null
try {
parsed = JSON.parse(raw)
} catch {
// Legacy plain-text note.
return [{ id: newNoteId(), anchor: 'general', html: raw, updatedAt: null }]
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return [{ id: newNoteId(), anchor: 'general', html: raw, updatedAt: null }]
}
if (parsed.v === 2 && Array.isArray(parsed.entries)) {
return parsed.entries
.filter(e => e && typeof e.html === 'string' && e.html.trim())
.map(e => ({
id: typeof e.id === 'string' && e.id ? e.id : newNoteId(),
anchor: NOTE_ANCHORS.includes(e.anchor) ? e.anchor : 'general',
html: e.html,
updatedAt: typeof e.updatedAt === 'string' ? e.updatedAt : null,
}))
}
return Object.entries(parsed)
.filter(([, html]) => typeof html === 'string' && html.trim())
.map(([anchor, html]) => ({
id: `legacy-${anchor}`,
anchor: NOTE_ANCHORS.includes(anchor) ? anchor : 'general',
html,
updatedAt: null,
}))
}
export function serializeNoteEntries(entries) {
const kept = entries.filter(e => e.html && e.html.trim())
if (kept.length === 0) return ''
return JSON.stringify({ v: 2, entries: kept })
}
export function noteHtmlToText(html) {
return String(html)
.replace(/<\/(p|li|h[1-6]|blockquote)>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
// Plain-text rendering of a whole lesson's notes (used by the Word export).
export function noteEntriesToText(raw) {
return parseNoteEntries(raw)
.map(e => `${NOTE_ANCHOR_LABELS[e.anchor]}\n${noteHtmlToText(e.html)}`)
.join('\n\n')
}
+883 -387
View File
File diff suppressed because it is too large. Load diff
+2072 -2125
View File
File diff suppressed because it is too large. Load diff
+1270 -838
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -28,6 +28,7 @@ const TYPE_LABEL: Record<string, string> = {
community: 'Community Question',
testimony: 'Testimony',
topic: 'Topic Request',
gospel: 'Gospel Courtroom Question',
general: 'General Message',
}
const KIND_LABEL: Record<Kind, string> = { email: 'Email', newsletter: 'Newsletter signup', download: 'Download request', message: 'Contact form' }
+28 -18
View File
@@ -4,6 +4,7 @@ interface Trend { thisWeek: number; lastWeek: number; series: number[] }
interface ResendStatus { available: boolean; reason?: string; broadcast?: { id: string; name: string; subject: string; sentAt: string | null; dashboardUrl: string } | null }
export interface DashboardData {
seasonCheck: { total: number; errors: number; warnings: number; general: number; lanes: { lane: string; title: string; season: string; errors: number; warnings: number }[] } | null
trends: { visits: Trend; subscribers: Trend; studySignups: Trend }
traffic: { topPages: { path: string; hits: number }[]; topReferrers: { referrer: string; count: number }[] }
reengagement: { t1: number; t2: number; t3: number; recentlySent: number; remindersEnabled: number }
@@ -13,8 +14,7 @@ export interface DashboardData {
lastActivityAt: string | null
}
latestEpisode: { available: boolean; title?: string; episode?: string; season?: string; publishedAt?: string | null; daysAgo?: number | null; stale?: boolean; staleAfterDays?: number }
upcoming: { id: string; kind: string; type: string; title: string; date: string; startTime: string | null }[]
nuggets: { uploadsThisWeek: number; latest: { hostName: string; episodeName: string; uploadedAt: string }[] }
nuggets: { uploadsThisWeek: number; pending: number; latest: { hostName: string; episodeName: string; uploadedAt: string }[] }
health: {
resendConfigured: boolean
lastBackup: { ok: boolean; at: string | null; error: string | null; file: string | null }
@@ -73,11 +73,6 @@ function formatWhen(value: string | null | undefined) {
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
function formatDay(value: string) {
const d = new Date(`${value}T12:00:00`)
return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onNavigate, onRequestPublish }: Props) {
const [data, setData] = useState<DashboardData | null>(null)
const [failed, setFailed] = useState(false)
@@ -96,8 +91,9 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN
const attention: { key: string; label: string; tone: 'alert' | 'ok'; action?: () => void; actionLabel?: string }[] = []
if (data) {
if (data.seasonCheck && data.seasonCheck.total > 0) attention.push({ key: 'season-check', label: `Season check: ${data.seasonCheck.total} thing${data.seasonCheck.total === 1 ? '' : 's'} to fix`, tone: 'alert', action: () => onNavigate('podcast:season-check'), actionLabel: 'Open Season Check' })
if (data.comments.pending > 0) attention.push({ key: 'comments', label: `${data.comments.pending} study comment${data.comments.pending === 1 ? '' : 's'} awaiting approval`, tone: 'alert', action: () => onNavigate('study-comments'), actionLabel: 'Review' })
if (data.nuggets.uploadsThisWeek > 0) attention.push({ key: 'nuggets', label: `${data.nuggets.uploadsThisWeek} Nuggets upload${data.nuggets.uploadsThisWeek === 1 ? '' : 's'} in the last 7 days`, tone: 'alert', action: () => onNavigate('nuggets:history'), actionLabel: 'View uploads' })
if (data.nuggets.pending > 0) attention.push({ key: 'nuggets', label: `${data.nuggets.pending} Nuggets upload${data.nuggets.pending === 1 ? '' : 's'} waiting in the workflow`, tone: 'alert', action: () => onNavigate('nuggets:workflow'), actionLabel: 'Open workflow' })
if (data.latestEpisode.available && data.latestEpisode.stale) attention.push({ key: 'feed', label: `Podcast feed has no new episode in ${data.latestEpisode.daysAgo} days`, tone: 'alert', action: () => onNavigate('podcast:rss'), actionLabel: 'Check feed' })
if (!data.health.lastBackup.ok) attention.push({ key: 'backup', label: 'Last data backup failed', tone: 'alert' })
if (data.health.dataDirWritable === false) attention.push({ key: 'disk', label: 'Data directory is not writable', tone: 'alert' })
@@ -143,6 +139,30 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN
</div>
)}
{data.seasonCheck && data.seasonCheck.lanes.length > 0 && (
<>
<h3 className="admin-dashboard-section-title">Season check</h3>
<div className="admin-dashboard-grid">
{data.seasonCheck.lanes.map(l => {
const issues = l.errors + l.warnings
return (
<div key={l.lane} className="admin-dashboard-card">
<div className="admin-dashboard-card-value">{issues === 0 ? '✓' : issues}</div>
<div className="admin-dashboard-card-label">Lane {l.lane}{l.title ? ` — ${l.title}` : ''}</div>
<div className="admin-dashboard-card-sub">
{l.season ? `Season ${l.season}` : 'No season number set'} · {issues === 0 ? 'all set' : `${issues} to fix`}
</div>
<button type="button" className="admin-dashboard-card-action" onClick={() => onNavigate('podcast:season-check')}>Season Check →</button>
</div>
)
})}
</div>
{data.seasonCheck.general > 0 && (
<p className="admin-stats-note">Plus {data.seasonCheck.general} thing{data.seasonCheck.general === 1 ? '' : 's'} that apply to every lane (such as scripts with no season number).</p>
)}
</>
)}
<h3 className="admin-dashboard-section-title">Trends</h3>
<div className="admin-dashboard-grid">
<TrendCard label="Real visits (7 days)" trend={data.trends.visits} onClick={() => onNavigate('analytics')} actionLabel="Analytics" />
@@ -230,16 +250,6 @@ export function AdminDashboardExtras({ publishedAt, draftUpdatedAt, isDirty, onN
{data.latestEpisode.stale && <div className="admin-dashboard-stale">No new episode in over {data.latestEpisode.staleAfterDays} days.</div>}
</div>
)}
<h3 className="admin-dashboard-activity-heading" style={{ marginTop: '1rem' }}>Upcoming</h3>
{data.upcoming.length === 0 ? <p className="admin-stats-note">Nothing scheduled.</p> : (
<div className="admin-dashboard-feed">
{data.upcoming.map(ev => (
<div key={ev.id} className="admin-dashboard-feed-item admin-dashboard-row">
<span>{ev.title}</span><span className="admin-dashboard-feed-date">{formatDay(ev.date)}{ev.startTime ? ` · ${ev.startTime}` : ''}</span>
</div>
))}
</div>
)}
</div>
</div>
+18 -1
View File
@@ -308,7 +308,7 @@ export function AnalyticsPanel({
<article>
<h3>Unique Visitors</h3>
<p>{stats.visitors.uniqueVisitors.toLocaleString()}</p>
<small>Tracked with consent</small>
<small>{stats.uniqueVisitors7d != null ? `${stats.uniqueVisitors7d.toLocaleString()} this week · ` : ''}Tracked with consent</small>
</article>
<article>
<h3>Returning Rate</h3>
@@ -399,6 +399,23 @@ export function AnalyticsPanel({
)}
</div>
</div>
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Probed URLs (not real pages)</h3>
{(stats.topUnknownPaths ?? []).length === 0 ? (
<p className="admin-stats-note">No probe traffic recorded yet. Requests for URLs that are not site pages (like /.env or /wp-login) are listed here and counted as bots.</p>
) : (
<ul className="admin-bot-reason-list">
{(stats.topUnknownPaths ?? []).map(row => (
<li key={row.path} className="admin-bot-reason-item">
<span style={{ wordBreak: 'break-all' }}>{row.path}</span>
<strong>{row.hits.toLocaleString()}</strong>
</li>
))}
</ul>
)}
</div>
</div>
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Top Pages — Real vs Bot ({rangeLabel})</h3>
+62 -35
View File
@@ -3,6 +3,7 @@ import type { ChangeEvent } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,}$/
const MAX_MESSAGE = 3000
interface ContactFields {
firstName: string
@@ -12,6 +13,15 @@ interface ContactFields {
messageType: string
}
const TYPES: { value: string; label: string; hint: string; placeholder: string }[] = [
{ value: 'question', label: 'Bible question', hint: 'Ask anything about Scripture. Answers are posted on the Q&A page.', placeholder: 'What would you like to know?' },
{ value: 'gospel', label: 'Gospel courtroom', hint: 'A question about the gospel courtroom: the Judge, the accuser, the charges, the Substitute, the pardon or your plea. It goes to Nate privately and is not posted publicly.', placeholder: 'What would you like to ask about the courtroom, the verdict or the pardon?' },
{ value: 'community', label: 'Community question', hint: 'A question about a study discussion or the community.', placeholder: 'What’s on your mind?' },
{ value: 'testimony', label: 'Testimony', hint: 'Share what God is doing in your life. Every one is read.', placeholder: 'Tell your story…' },
{ value: 'topic', label: 'Topic request', hint: 'Suggest a passage or topic for a future episode.', placeholder: 'Which passage or topic?' },
{ value: 'general', label: 'General message', hint: 'Anything else you’d like to say.', placeholder: 'Write your message…' },
]
export default function ContactForm() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
@@ -23,7 +33,7 @@ export default function ContactForm() {
lastName: '',
email: '',
message: '',
messageType: source === 'community' ? 'community' : 'question',
messageType: source === 'community' ? 'community' : source === 'salvation' ? 'gospel' : 'question',
})
useEffect(() => {
@@ -42,6 +52,8 @@ export default function ContactForm() {
const [errorMsg, setErrorMsg] = useState('')
const [fieldErrors, setFieldErrors] = useState<{ email?: string }>({})
const activeType = TYPES.find(t => t.value === fields.messageType) ?? TYPES[0]
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
const { name, value } = e.target
setFields(f => ({ ...f, [name]: value }))
@@ -59,10 +71,6 @@ export default function ContactForm() {
}
}
function handleSelectChange(e: ChangeEvent<HTMLSelectElement>) {
setFields(f => ({ ...f, [e.target.name]: e.target.value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setStatus('submitting')
@@ -90,7 +98,7 @@ export default function ContactForm() {
}
return (
<form className="contact-form" onSubmit={handleSubmit} noValidate>
<form className="cx-form" onSubmit={handleSubmit} noValidate>
<input
type="text"
className="contact-honeypot"
@@ -100,14 +108,37 @@ export default function ContactForm() {
value={honey}
onChange={e => setHoney(e.target.value)}
/>
<fieldset className="cx-types">
<legend>What is this about?</legend>
<div className="cx-type-row" role="radiogroup">
{TYPES.map(t => (
<label key={t.value} className="cx-type">
<input
type="radio"
name="messageType"
value={t.value}
checked={fields.messageType === t.value}
onChange={() => setFields(f => ({ ...f, messageType: t.value }))}
/>
<span>{t.label}</span>
</label>
))}
</div>
<p className="cx-hint" aria-live="polite">{activeType.hint}</p>
</fieldset>
<div className="cx-row">
<label>
First Name
First name
<input type="text" name="firstName" required autoComplete="given-name" value={fields.firstName} onChange={handleChange} />
</label>
<label>
Last Name
Last name
<input type="text" name="lastName" required autoComplete="family-name" value={fields.lastName} onChange={handleChange} />
</label>
</div>
<label>
Email
<input
@@ -122,41 +153,37 @@ export default function ContactForm() {
/>
{fieldErrors.email && <span id="contact-email-error" className="study-signup-field-error">{fieldErrors.email}</span>}
</label>
<label>
Message Type
<select name="messageType" value={fields.messageType} onChange={handleSelectChange}>
<option value="question">Bible Question</option>
<option value="community">Community Question</option>
<option value="testimony">Testimony</option>
<option value="topic">Topic Request</option>
<option value="general">General Message</option>
</select>
</label>
<label>
Message
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
</label>
{fields.messageType === 'question' && (
<label className="contact-consent">
<input
type="checkbox"
checked={notifyOnAnswer}
onChange={e => setNotifyOnAnswer(e.target.checked)}
<textarea
name="message"
rows={6}
required
maxLength={MAX_MESSAGE}
placeholder={activeType.placeholder}
value={fields.message}
onChange={handleChange}
/>
<span className="cx-count">{fields.message.length} / {MAX_MESSAGE}</span>
</label>
<div className="cx-checks">
{fields.messageType === 'question' && (
<label className="cx-check">
<input type="checkbox" checked={notifyOnAnswer} onChange={e => setNotifyOnAnswer(e.target.checked)} />
<span>Email me when this question is answered.</span>
</label>
)}
<label className="contact-consent">
<input
type="checkbox"
checked={subscribe}
onChange={e => setSubscribe(e.target.checked)}
/>
<label className="cx-check">
<input type="checkbox" checked={subscribe} onChange={e => setSubscribe(e.target.checked)} />
<span>Send me updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
</label>
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending…' : 'Send Message'}
</div>
{status === 'error' && <p className="contact-error" role="alert">{errorMsg}</p>}
<button type="submit" className="btn-primary cx-submit" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending…' : 'Send message'}
</button>
</form>
)
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
interface AccountStatus {
authenticated: boolean
username: string
displayName?: string
avatarUrl?: string
adminShortcut?: boolean
}
// Shared across header instances so moving between pages doesn't flash the signed-out icon
let cachedStatus: AccountStatus | null = null
function PersonIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-3.6 3.6-6 8-6s8 2.4 8 6" />
</svg>
)
}
export function HeaderAccount() {
const [status, setStatus] = useState<AccountStatus | null>(cachedStatus)
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
let cancelled = false
fetch('/api/study-auth/status')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('status failed'))))
.then((data: AccountStatus) => {
cachedStatus = data
if (!cancelled) setStatus(data)
})
.catch(() => {})
return () => { cancelled = true }
}, [])
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent | TouchEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('mousedown', onDown)
document.addEventListener('touchstart', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('touchstart', onDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
async function signOut() {
try { await fetch('/api/study-auth/logout', { method: 'POST' }) } catch { /* reload below shows the real state */ }
cachedStatus = null
window.location.assign('/')
}
// Signed out (or still checking): one tap goes to the sign-in page
if (!status?.authenticated) {
return (
<Link to="/study/account" className="header-account" aria-label="Sign in or create an account" title="Sign in">
<PersonIcon />
</Link>
)
}
const name = status.displayName?.trim() || status.username
const initial = (name || '?').charAt(0).toUpperCase()
return (
<div className="header-account-wrap" ref={wrapRef}>
<button
type="button"
className="header-account header-account--in"
aria-haspopup="menu"
aria-expanded={open}
aria-label={`Account menu for ${name}`}
onClick={() => setOpen(o => !o)}
>
{status.avatarUrl ? <img src={status.avatarUrl} alt="" className="header-account-avatar" /> : <span aria-hidden="true">{initial}</span>}
</button>
{open && (
<div className="header-account-menu" role="menu">
<p className="header-account-name">Signed in as <strong>{name}</strong></p>
<Link to="/study/account" role="menuitem" className="header-account-item" onClick={() => setOpen(false)}>My Account</Link>
{status.adminShortcut && (
<Link to="/admin" role="menuitem" className="header-account-item" onClick={() => setOpen(false)}>Admin</Link>
)}
<button type="button" role="menuitem" className="header-account-item" onClick={signOut}>Sign out</button>
</div>
)}
</div>
)
}
+40 -1
View File
@@ -5,14 +5,17 @@ import { useEffect } from 'react'
interface NoteCardProps {
anchorLabel: string
html: string
updatedAt?: string | null
onDelete?: () => void
autoSaveMsg?: string
onChange: (html: string) => void
onClose: () => void
}
export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }: NoteCardProps) {
export function NoteCard({ anchorLabel, html, updatedAt, onDelete, autoSaveMsg, onChange, onClose }: NoteCardProps) {
const editor = useEditor({
extensions: [StarterKit],
shouldRerenderOnTransaction: true,
content: html || '<p></p>',
onUpdate({ editor }) {
const next = editor.getHTML()
@@ -32,6 +35,8 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
if (!editor) return null
const wordCount = editor.getText().split(/\s+/).filter(Boolean).length
return (
<div className="note-card">
<div className="note-card-header">
@@ -71,6 +76,27 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
aria-label="Numbered list"
title="Numbered list"
>1.</button>
<button
type="button"
className={`note-tool${editor.isActive('heading', { level: 3 }) ? ' note-tool--active' : ''}`}
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleHeading({ level: 3 }).run() }}
aria-label="Heading"
title="Heading"
>H</button>
<button
type="button"
className={`note-tool${editor.isActive('blockquote') ? ' note-tool--active' : ''}`}
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleBlockquote().run() }}
aria-label="Quote"
title="Quote"
>❝</button>
<button
type="button"
className={`note-tool${editor.isActive('strike') ? ' note-tool--active' : ''}`}
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleStrike().run() }}
aria-label="Strikethrough"
title="Strikethrough"
><s>S</s></button>
<div className="note-tool-divider" />
<button
type="button"
@@ -91,6 +117,19 @@ export function NoteCard({ anchorLabel, html, autoSaveMsg, onChange, onClose }:
</div>
<EditorContent editor={editor} className="note-card-editor" />
<div className="note-card-footer">
{onDelete && (
<button
type="button"
className="note-card-delete"
onClick={() => { if (window.confirm('Delete this note?')) onDelete() }}
>Delete note</button>
)}
<span>
{updatedAt ? `Edited ${new Date(updatedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })} · ` : ''}
{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}
</span>
</div>
</div>
)
}
+215 -1043
View File
File diff suppressed because it is too large. Load diff
+363
View File
@@ -0,0 +1,363 @@
import { useMemo, useState } from 'react'
export interface AdminQuestion {
id: string
submittedAt: string
firstName: string
email: string
question: string
answer: string
answeredAt: string | null
isApproved: boolean
approvedAt: string | null
topic?: string
reference?: string
notifyOnAnswer?: boolean
notifiedAt?: string
upvotes?: number
pinned?: boolean
}
type Tab = 'new' | 'ready' | 'published' | 'all'
const hasAnswer = (q: AdminQuestion) => Boolean(q.answer?.trim())
const statusOf = (q: AdminQuestion): Exclude<Tab, 'all'> => (!hasAnswer(q) ? 'new' : q.isApproved ? 'published' : 'ready')
const TAB_LABELS: Record<Tab, string> = {
new: 'Needs answer',
ready: 'Ready to publish',
published: 'Published',
all: 'All',
}
function formatDate(value: string | null | undefined) {
if (!value) return ''
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
async function call(url: string, method: string, body?: unknown) {
const res = await fetch(url, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
})
const data = await res.json().catch(() => ({})) as { question?: AdminQuestion; message?: string }
if (!res.ok) throw new Error(data.message ?? 'Something went wrong.')
return data
}
interface Props {
questions: AdminQuestion[]
onChange: (questions: AdminQuestion[]) => void
}
export function QuestionsAdmin({ questions, onChange }: Props) {
const counts = useMemo(() => ({
new: questions.filter(q => statusOf(q) === 'new').length,
ready: questions.filter(q => statusOf(q) === 'ready').length,
published: questions.filter(q => statusOf(q) === 'published').length,
all: questions.length,
}), [questions])
const [tab, setTab] = useState<Tab>(counts.new > 0 ? 'new' : 'all')
const [search, setSearch] = useState('')
const [selectedId, setSelectedId] = useState('')
const [checked, setChecked] = useState<Set<string>>(new Set())
const [adding, setAdding] = useState(false)
const [message, setMessage] = useState('')
const [busy, setBusy] = useState(false)
// Edits live in a draft for the open question until saved
const [draft, setDraft] = useState<{ id: string; question: string; answer: string; topic: string; reference: string } | null>(null)
const topics = useMemo(() => {
const tally = new Map<string, number>()
for (const q of questions) if (q.topic?.trim()) tally.set(q.topic.trim(), (tally.get(q.topic.trim()) ?? 0) + 1)
return [...tally.entries()].sort((a, b) => b[1] - a[1]).map(([t]) => t)
}, [questions])
const visible = useMemo(() => {
const needle = search.trim().toLowerCase()
return questions
.filter(q => tab === 'all' || statusOf(q) === tab)
.filter(q => !needle || [q.firstName, q.question, q.answer, q.topic ?? '', q.reference ?? ''].some(v => v.toLowerCase().includes(needle)))
}, [questions, tab, search])
const selected = questions.find(q => q.id === selectedId) ?? null
const current = selected && draft?.id === selected.id
? draft
: selected
? { id: selected.id, question: selected.question, answer: selected.answer ?? '', topic: selected.topic ?? '', reference: selected.reference ?? '' }
: null
const dirty = Boolean(selected && current && (
current.question !== selected.question || current.answer !== (selected.answer ?? '') ||
current.topic !== (selected.topic ?? '') || current.reference !== (selected.reference ?? '')
))
const open = (id: string) => {
if (dirty && !confirm('You have unsaved changes to this question. Discard them?')) return
setSelectedId(id)
setDraft(null)
setMessage('')
}
const edit = (patch: Partial<NonNullable<typeof draft>>) => {
if (!selected || !current) return
setDraft({ ...current, ...patch })
}
const replace = (q: AdminQuestion) => onChange(questions.map(item => (item.id === q.id ? { ...item, ...q } : item)))
// After publishing, move on to the next question waiting in the same queue
const advance = (fromId: string) => {
const list = visible.filter(q => q.id !== fromId)
const idx = visible.findIndex(q => q.id === fromId)
const next = list[Math.min(idx, list.length - 1)]
setSelectedId(next?.id ?? '')
setDraft(null)
}
const run = async (label: string, fn: () => Promise<void>) => {
setBusy(true)
setMessage('')
try {
await fn()
} catch (err) {
setMessage(err instanceof Error ? err.message : `Could not ${label}.`)
} finally {
setBusy(false)
}
}
const saveFields = () => run('save', async () => {
if (!selected || !current) return
const d = await call(`/api/admin-questions/${selected.id}`, 'PATCH', current)
if (d.question) replace(d.question)
setDraft(null)
setMessage('Saved.')
})
const publish = () => run('publish', async () => {
if (!selected || !current) return
if (current.question !== selected.question) await call(`/api/admin-questions/${selected.id}`, 'PATCH', { question: current.question })
const d = await call(`/api/admin-questions/${selected.id}/publish`, 'POST', { answer: current.answer, topic: current.topic, reference: current.reference })
if (d.question) {
const id = selected.id
replace(d.question)
advance(tab === 'published' || tab === 'all' ? '' : id)
setMessage(selected.notifyOnAnswer && selected.email && !selected.notifiedAt ? 'Published, and the asker was emailed.' : 'Published.')
}
})
const unpublish = () => run('unpublish', async () => {
if (!selected) return
const d = await call(`/api/admin-questions/${selected.id}/approve`, 'POST', { approved: false })
if (d.question) replace(d.question)
setMessage('Taken off the site.')
})
const togglePin = () => run('pin', async () => {
if (!selected) return
const d = await call(`/api/admin-questions/${selected.id}/pin`, selected.pinned ? 'DELETE' : 'POST')
if (d.question) replace(d.question)
})
const remove = (ids: string[]) => {
if (ids.length === 0) return
if (!confirm(`Delete ${ids.length === 1 ? 'this question' : `${ids.length} questions`} permanently?`)) return
return run('delete', async () => {
const gone: string[] = []
for (const id of ids) {
try { await call(`/api/admin-questions/${id}`, 'DELETE'); gone.push(id) } catch { /* keep going */ }
}
onChange(questions.filter(q => !gone.includes(q.id)))
setChecked(new Set())
if (gone.includes(selectedId)) { setSelectedId(''); setDraft(null) }
if (gone.length < ids.length) setMessage(`Deleted ${gone.length} of ${ids.length}.`)
})
}
const [manual, setManual] = useState({ firstName: '', email: '', question: '', answer: '', topic: '', reference: '', publish: false })
const addManual = () => run('add the question', async () => {
const d = await call('/api/admin-questions', 'POST', { ...manual, approve: manual.publish && Boolean(manual.answer.trim()) })
if (d.question) {
onChange([d.question, ...questions])
setSelectedId(d.question.id)
setDraft(null)
}
setManual({ firstName: '', email: '', question: '', answer: '', topic: '', reference: '', publish: false })
setAdding(false)
setMessage('Question added.')
})
const status = selected ? statusOf(selected) : null
return (
<section className="admin-panel-section" aria-label="Questions and answers">
<div className="admin-panel-head">
<h2>Bible Questions &amp; Answers</h2>
<p>Work through questions from the top: write an answer, set a topic, and publish. Published answers appear on the public Q&amp;A page right away. Pinned questions show first there and are featured on the home page.</p>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12, alignItems: 'center' }}>
{(Object.keys(TAB_LABELS) as Tab[]).map(t => (
<button key={t} type="button" className={tab === t ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => setTab(t)}>
{TAB_LABELS[t]} ({counts[t]})
</button>
))}
<button type="button" className="btn-admin-add" style={{ marginLeft: 'auto' }} onClick={() => setAdding(a => !a)}>
{adding ? 'Cancel' : '+ Add question'}
</button>
</div>
{message && <p className="admin-stats-note" role="status">{message}</p>}
{adding && (
<div style={{ border: '1px solid var(--color-border, rgba(255,255,255,0.14))', borderRadius: 6, padding: 12, marginBottom: 16 }}>
<h3 style={{ marginTop: 0 }}>Add a question</h3>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
<label htmlFor="qa-new-name">First name</label>
<input id="qa-new-name" type="text" value={manual.firstName} onChange={e => setManual({ ...manual, firstName: e.target.value })} />
</div>
<div className="admin-field" style={{ flex: 1, minWidth: 200 }}>
<label htmlFor="qa-new-email">Email (optional)</label>
<input id="qa-new-email" type="email" value={manual.email} onChange={e => setManual({ ...manual, email: e.target.value })} />
</div>
</div>
<div className="admin-field">
<label htmlFor="qa-new-question">Question</label>
<textarea id="qa-new-question" rows={3} value={manual.question} onChange={e => setManual({ ...manual, question: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="qa-new-answer">Answer (optional)</label>
<textarea id="qa-new-answer" rows={4} value={manual.answer} onChange={e => setManual({ ...manual, answer: e.target.value })} />
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
<label htmlFor="qa-new-topic">Topic</label>
<input id="qa-new-topic" type="text" list="qa-topic-options" value={manual.topic} onChange={e => setManual({ ...manual, topic: e.target.value })} />
</div>
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
<label htmlFor="qa-new-ref">Scripture reference</label>
<input id="qa-new-ref" type="text" placeholder="e.g. Titus 1:5-9" value={manual.reference} onChange={e => setManual({ ...manual, reference: e.target.value })} />
</div>
</div>
<label style={{ display: 'block', margin: '8px 0' }}>
<input type="checkbox" checked={manual.publish} onChange={e => setManual({ ...manual, publish: e.target.checked })} /> Publish right away (needs an answer)
</label>
<button type="button" className="btn-admin-save" onClick={addManual} disabled={busy || !manual.firstName.trim() || manual.question.trim().length < 5}>Add question</button>
</div>
)}
<datalist id="qa-topic-options">{topics.map(t => <option key={t} value={t} />)}</datalist>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(240px, 340px) 1fr', gap: 20, alignItems: 'start' }}>
<div>
<div className="admin-field" style={{ marginBottom: 8 }}>
<input type="search" placeholder="Search questions, answers, topics…" value={search} onChange={e => setSearch(e.target.value)} aria-label="Search questions" />
</div>
{checked.size > 0 && (
<div className="admin-bulk-toolbar" style={{ marginBottom: 8 }}>
<span>{checked.size} selected</span>
<button type="button" className="btn-admin-remove" onClick={() => remove([...checked])}>Delete</button>
<button type="button" className="btn-admin-reset" onClick={() => setChecked(new Set())}>Clear</button>
</div>
)}
{visible.length === 0 && <p className="admin-stats-note">{tab === 'new' ? 'Nothing waiting for an answer. 🎉' : 'No questions here.'}</p>}
{visible.map(q => (
<div
key={q.id}
style={{
display: 'flex', gap: 8, alignItems: 'flex-start', padding: '8px 10px', marginBottom: 6, borderRadius: 6,
border: `1px solid ${q.id === selectedId ? '#8a6d2f' : 'var(--color-border, rgba(255,255,255,0.14))'}`,
}}
>
<input
type="checkbox"
aria-label={`Select question from ${q.firstName}`}
checked={checked.has(q.id)}
onChange={e => setChecked(prev => { const next = new Set(prev); if (e.target.checked) next.add(q.id); else next.delete(q.id); return next })}
style={{ marginTop: 4 }}
/>
<button
type="button"
onClick={() => open(q.id)}
style={{ flex: 1, textAlign: 'left', background: 'transparent', border: 0, padding: 0, cursor: 'pointer', color: 'inherit', font: 'inherit' }}
>
<strong>{q.pinned ? '📌 ' : ''}{q.firstName || 'Someone'}</strong>
<span style={{ fontSize: '0.76rem', opacity: 0.65 }}> · {formatDate(q.submittedAt)}</span>
<div style={{ fontSize: '0.84rem', opacity: 0.85, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{q.question}</div>
<div style={{ fontSize: '0.74rem', opacity: 0.7, marginTop: 2 }}>
{tab === 'all' && <>{TAB_LABELS[statusOf(q)]} · </>}
{q.topic ? `${q.topic} · ` : ''}{q.notifyOnAnswer && !q.notifiedAt && !q.isApproved ? '✉ wants an email · ' : ''}{(q.upvotes ?? 0) > 0 ? `▲ ${q.upvotes}` : ''}
</div>
</button>
</div>
))}
</div>
<div>
{!selected || !current ? (
<p className="admin-stats-note">Select a question to answer it.</p>
) : (
<>
<p style={{ margin: '0 0 8px', fontSize: '0.86rem' }}>
From <strong>{selected.firstName || 'Someone'}</strong>
{selected.email && <> · <a href={`mailto:${selected.email}`}>{selected.email}</a></>}
{' '}· {formatDate(selected.submittedAt)} · <em>{TAB_LABELS[status!]}</em>
</p>
{selected.notifyOnAnswer && selected.email && (
<p className="admin-stats-note" style={{ marginTop: 0 }}>
{selected.notifiedAt ? `They were emailed on ${formatDate(selected.notifiedAt)}.` : 'They asked to be emailed when this is answered. Publishing sends it.'}
</p>
)}
<div className="admin-field">
<label htmlFor="qa-edit-question">Question</label>
<textarea id="qa-edit-question" rows={3} value={current.question} onChange={e => edit({ question: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="qa-edit-answer">Answer</label>
<textarea id="qa-edit-answer" rows={9} value={current.answer} onChange={e => edit({ answer: e.target.value })} />
<p className="admin-field-help">{current.answer.length} / 5000</p>
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<div className="admin-field" style={{ flex: 1, minWidth: 180 }}>
<label htmlFor="qa-edit-topic">Topic</label>
<input id="qa-edit-topic" type="text" list="qa-topic-options" value={current.topic} onChange={e => edit({ topic: e.target.value })} placeholder="e.g. Salvation" />
</div>
<div className="admin-field" style={{ flex: 1, minWidth: 180 }}>
<label htmlFor="qa-edit-ref">Scripture reference</label>
<input id="qa-edit-ref" type="text" value={current.reference} onChange={e => edit({ reference: e.target.value })} placeholder="e.g. Titus 1:5-9" />
</div>
</div>
{topics.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', margin: '0 0 12px' }}>
{topics.slice(0, 8).map(t => (
<button key={t} type="button" className={current.topic === t ? 'btn-admin-save' : 'btn-admin-ghost'} onClick={() => edit({ topic: t })}>{t}</button>
))}
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{status === 'published' ? (
<>
<button type="button" className="btn-admin-save" onClick={saveFields} disabled={busy || !dirty}>Save changes</button>
<button type="button" className="btn-admin-ghost" onClick={unpublish} disabled={busy}>Unpublish</button>
</>
) : (
<>
<button type="button" className="btn-admin-save" onClick={publish} disabled={busy || !current.answer.trim()}>Save &amp; publish</button>
<button type="button" className="btn-admin-ghost" onClick={saveFields} disabled={busy || !dirty}>Save draft</button>
</>
)}
<button type="button" className="btn-admin-ghost" onClick={togglePin} disabled={busy} title="Pinned questions show first on the Q&A page and are featured on the home page">{selected.pinned ? 'Unpin' : 'Pin & feature'}</button>
<button type="button" className="btn-admin-remove" onClick={() => remove([selected.id])} disabled={busy}>Delete</button>
{dirty && <span className="admin-stats-note">Unsaved changes</span>}
</div>
</>
)}
</div>
</div>
</section>
)
}
+226
View File
@@ -0,0 +1,226 @@
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import type { SiteContent } from '../content'
import { StoreLink } from './StoreLink'
type TabKey = 'downloads' | 'books' | 'recommended'
interface Props {
content: SiteContent
downloadPath: (resourceId: string) => string
// Shown when there are no downloads, books or recommended resources at all
fallback: ReactNode
}
// A description that stays short in the grid; long ones get a Read more toggle
function ClampedText({ text, className, lines = 3, threshold = 150 }: { text: string; className: string; lines?: number; threshold?: number }) {
const [open, setOpen] = useState(false)
const long = text.length > threshold
return (
<>
<p
className={`${className}${long && !open ? ' res-clamped' : ''}`}
style={long && !open ? { WebkitLineClamp: lines } : undefined}
>
{text}
</p>
{long && (
<button type="button" className="res-more" onClick={() => setOpen(o => !o)} aria-expanded={open}>
{open ? 'Show less' : 'Read more'}
</button>
)}
</>
)
}
export function ResourcesHub({ content, downloadPath, fallback }: Props) {
const links = content.customLinks ?? []
// Ask for name and email before sending visitors to Amazon / Apple Books (on unless switched off in the admin)
const gated = content.gateStoreLinks !== false
const resources = links.filter(l => l.placement === 'resources')
const books = links.filter(l => l.placement === 'books')
const recommended = links.filter(l => l.placement === 'recommended')
const featured: { key: string; to: string; title: string; description: string; image: string; alt: string }[] = []
if (content.studyGuideTitle) {
featured.push({
key: 'primary',
to: '/downloads/primary-guide',
title: content.studyGuideTitle,
description: content.studyGuideDescription ?? '',
image: content.seriesImageUrl || '/images/titus-cover.png',
alt: content.seriesTitle,
})
}
if (content.secondSeries?.guide?.title) {
featured.push({
key: 'second',
to: '/downloads/second-guide',
title: content.secondSeries.guide.title,
description: content.secondSeries.guide.description ?? '',
image: content.secondSeries.imageUrl ?? '',
alt: content.secondSeries.title,
})
}
const counts: Record<TabKey, number> = {
downloads: featured.length + resources.length,
books: books.length,
recommended: recommended.length,
}
const available = (Object.keys(counts) as TabKey[]).filter(k => counts[k] > 0)
const fromHash = (): TabKey | null => {
const h = typeof window !== 'undefined' ? window.location.hash.replace('#', '') : ''
return h === 'downloads' || h === 'books' || h === 'recommended' ? h : null
}
const [picked, setPicked] = useState<TabKey | null>(fromHash)
const [activeTag, setActiveTag] = useState<string | null>(null)
// Links like /resources#books, and the browser's back/forward buttons, switch the tab too
useEffect(() => {
const onHash = () => setPicked(fromHash())
window.addEventListener('hashchange', onHash)
return () => window.removeEventListener('hashchange', onHash)
}, [])
if (available.length === 0) return <>{fallback}</>
const tab: TabKey = picked && counts[picked] > 0 ? picked : available[0]
const choose = (k: TabKey) => {
setPicked(k)
try { window.history.replaceState(null, '', `#${k}`) } catch { /* ignore */ }
}
const tags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
const shownResources = resources.filter(r => !activeTag || (r.tags ?? []).includes(activeTag))
const labels: Record<TabKey, string> = { downloads: 'Downloads', books: 'Reading List', recommended: 'Recommended' }
return (
<section className="res-hub" aria-label="Resources">
<div className="section-inner">
{available.length > 1 && (
<div className="res-tabs" role="tablist" aria-label="Resource types">
{available.map(k => (
<button
key={k}
type="button"
role="tab"
id={`res-tab-${k}`}
aria-selected={tab === k}
aria-controls="res-panel"
className={`res-tab${tab === k ? ' res-tab--on' : ''}`}
onClick={() => choose(k)}
>
{labels[k]} <span>{counts[k]}</span>
</button>
))}
</div>
)}
<div id="res-panel" role="tabpanel" aria-labelledby={`res-tab-${tab}`}>
{tab === 'downloads' && (
<>
{featured.length > 0 && (
<div className="res-featured">
{featured.map(f => (
<Link key={f.key} to={f.to} className="res-feature">
{f.image && <img src={f.image} alt={f.alt} className="res-feature-img" loading="lazy" />}
<div className="res-feature-body">
<span className="res-badge">Featured</span>
<span className="res-title">{f.title}</span>
{f.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 2 }}>{f.description}</p>}
<span className="res-action">Download free guide →</span>
</div>
</Link>
))}
</div>
)}
{tags.length > 0 && (
<div className="res-tags" role="group" aria-label="Filter by topic">
<button type="button" className={`res-chip${activeTag === null ? ' res-chip--on' : ''}`} onClick={() => setActiveTag(null)}>All topics</button>
{tags.map(t => (
<button key={t} type="button" className={`res-chip${activeTag === t ? ' res-chip--on' : ''}`} onClick={() => setActiveTag(prev => (prev === t ? null : t))}>{t}</button>
))}
</div>
)}
{shownResources.length > 0 ? (
<div className="res-grid">
{shownResources.map(r => (
<Link key={r.id} to={downloadPath(r.id)} className="res-card">
{r.imageUrl && <img src={r.imageUrl} alt={r.label} className="res-card-img" loading="lazy" />}
<div className="res-card-body">
<span className="res-title">{r.label}</span>
{r.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 3 }}>{r.description}</p>}
{(r.tags ?? []).length > 0 && <span className="res-card-tags">{(r.tags ?? []).join(' · ')}</span>}
<span className="res-action">Open download →</span>
</div>
</Link>
))}
</div>
) : (
resources.length > 0 && <p className="res-empty">No downloads tagged &quot;{activeTag}&quot;.</p>
)}
</>
)}
{tab === 'books' && (
<div className="res-grid">
{books.map(book => (
<div key={book.id} className="res-book">
{book.imageUrl && <img src={book.imageUrl} alt={book.label} className="res-book-cover" loading="lazy" />}
<div className="res-book-body">
<span className="res-title">{book.label}</span>
{book.description && <ClampedText text={book.description} className="res-desc" lines={3} />}
<div className="res-book-actions">
{(book.amazonUrl || book.url) && (
<StoreLink
source={`book:${book.id}`}
store="amazon"
href={book.amazonUrl || book.url}
gated={gated}
className="btn-primary res-btn"
>
{book.amazonUrl ? (book.amazonLabel || 'View on Amazon') : (book.amazonLabel || 'Learn More')}
</StoreLink>
)}
{book.appleBooksUrl && (
<StoreLink
source={`book:${book.id}`}
store="apple"
href={book.appleBooksUrl}
gated={gated}
className="btn-secondary res-btn"
>
{book.appleBooksLabel || 'Apple Books'}
</StoreLink>
)}
</div>
</div>
</div>
))}
</div>
)}
{tab === 'recommended' && (
<div className="res-grid">
{recommended.map(item => (
<a key={item.id} href={item.url} target="_blank" rel="noopener noreferrer" className="res-card">
{item.imageUrl && <img src={item.imageUrl} alt={item.label} className="res-card-img" loading="lazy" />}
<div className="res-card-body">
<span className="res-title">{item.label}</span>
{item.description && <p className="res-desc res-clamped" style={{ WebkitLineClamp: 3 }}>{item.description}</p>}
<span className="res-action">Visit →</span>
</div>
</a>
))}
</div>
)}
</div>
</div>
</section>
)
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
const SAVED_KEY = 'vbn-store-link-contact'
interface Saved { firstName: string; lastName: string; email: string }
function readSaved(): Saved {
try {
const raw = localStorage.getItem(SAVED_KEY)
if (raw) {
const d = JSON.parse(raw) as Partial<Saved>
return { firstName: d.firstName ?? '', lastName: d.lastName ?? '', email: d.email ?? '' }
}
} catch { /* private mode or bad data */ }
return { firstName: '', lastName: '', email: '' }
}
interface Props {
// Which guide or book this link belongs to (the server finds the real address from this)
source: string
store: 'amazon' | 'apple'
// The address used when the sign-up step is switched off
href: string
className?: string
children: ReactNode
gated: boolean
}
// A button that asks for name and email, then sends the visitor on to Amazon or Apple Books.
// With the gate switched off it is just a normal link.
export function StoreLink({ source, store, href, className, children, gated }: Props) {
const [open, setOpen] = useState(false)
const [fields, setFields] = useState<Saved>({ firstName: '', lastName: '', email: '' })
const [subscribe, setSubscribe] = useState(true)
const [honey, setHoney] = useState('')
const [status, setStatus] = useState<'idle' | 'sending' | 'error'>('idle')
const [error, setError] = useState('')
const firstRef = useRef<HTMLInputElement>(null)
const storeName = store === 'amazon' ? 'Amazon' : 'Apple Books'
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('keydown', onKey)
const t = window.setTimeout(() => firstRef.current?.focus(), 30)
return () => { document.removeEventListener('keydown', onKey); window.clearTimeout(t) }
}, [open])
if (!gated) {
return <a href={href} target="_blank" rel="noopener noreferrer" className={className}>{children}</a>
}
const openDialog = () => {
setFields(readSaved())
setStatus('idle')
setError('')
setOpen(true)
}
async function submit(e: React.FormEvent) {
e.preventDefault()
setStatus('sending')
setError('')
try {
const res = await fetch('/api/store-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source, store, ...fields, subscribe, _honey: honey }),
})
const data = (await res.json().catch(() => ({}))) as { url?: string; message?: string }
if (!res.ok || !data.url) {
setError(data.message ?? 'Something went wrong. Please try again.')
setStatus('error')
return
}
try { localStorage.setItem(SAVED_KEY, JSON.stringify(fields)) } catch { /* ignore */ }
window.location.assign(data.url)
} catch {
setError('Could not connect. Please try again later.')
setStatus('error')
}
}
const set = (name: keyof Saved) => (e: React.ChangeEvent<HTMLInputElement>) => setFields(f => ({ ...f, [name]: e.target.value }))
return (
<>
<button type="button" className={className} onClick={openDialog}>{children}</button>
{open && (
<div className="sl-overlay" onMouseDown={e => { if (e.target === e.currentTarget) setOpen(false) }}>
<div className="sl-dialog" role="dialog" aria-modal="true" aria-labelledby={`sl-title-${source}-${store}`}>
<h3 id={`sl-title-${source}-${store}`}>One quick step</h3>
<p className="sl-lead">Tell us where to send updates, then we&apos;ll take you straight to {storeName}.</p>
<form onSubmit={submit} noValidate>
<input type="text" className="contact-honeypot" tabIndex={-1} autoComplete="off" aria-hidden="true" value={honey} onChange={e => setHoney(e.target.value)} />
<div className="sl-row">
<label>
First name
<input ref={firstRef} type="text" required autoComplete="given-name" value={fields.firstName} onChange={set('firstName')} />
</label>
<label>
Last name
<input type="text" required autoComplete="family-name" value={fields.lastName} onChange={set('lastName')} />
</label>
</div>
<label>
Email
<input type="email" required autoComplete="email" value={fields.email} onChange={set('email')} />
</label>
<label className="sl-check">
<input type="checkbox" checked={subscribe} onChange={e => setSubscribe(e.target.checked)} />
<span>Subscribe me to updates from Verse by Verse with Nate. I can unsubscribe anytime.</span>
</label>
{status === 'error' && <p className="sl-error" role="alert">{error}</p>}
<div className="sl-actions">
<button type="submit" className="btn-primary" disabled={status === 'sending'}>
{status === 'sending' ? 'One moment…' : `Continue to ${storeName}`}
</button>
<button type="button" className="btn-secondary" onClick={() => setOpen(false)}>Cancel</button>
</div>
</form>
</div>
</div>
)}
</>
)
}
+23 -14
View File
@@ -11,6 +11,7 @@ interface CertificateStatus {
interface Props {
studySlug: string
onIssued?: () => void
}
function formatDate(iso: string | null) {
@@ -115,7 +116,7 @@ function drawCertificate(
ctx.fillText('versebyversewithnate.us', W / 2, 490)
}
export function StudyCertificate({ studySlug }: Props) {
export function StudyCertificate({ studySlug, onIssued }: Props) {
const [status, setStatus] = useState<CertificateStatus | null>(null)
const [loading, setLoading] = useState(true)
const [issuing, setIssuing] = useState(false)
@@ -124,20 +125,27 @@ export function StudyCertificate({ studySlug }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
fetch(`/api/study-certificate/${studySlug}`)
.then(r => r.json())
.then(data => {
let cancelled = false
async function load() {
try {
const res = await fetch(`/api/study-certificate/${studySlug}`)
if (!res.ok) throw new Error('unavailable')
const data = await res.json() as CertificateStatus
if (cancelled) return
setStatus(data)
// Try to get display name from account
return fetch('/api/study-account')
})
.then(r => r.json())
.then(data => {
if (data?.displayName) setDisplayName(data.displayName)
else if (data?.username) setDisplayName(data.username)
})
.catch(() => {})
.finally(() => setLoading(false))
// The name printed on the certificate comes from the signed-in account
const auth = await fetch('/api/study-auth/status').then(r => (r.ok ? r.json() : null)).catch(() => null)
if (cancelled) return
if (auth?.displayName) setDisplayName(auth.displayName)
else if (auth?.username) setDisplayName(auth.username)
} catch {
// leave status null; the panel renders nothing
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [studySlug])
useEffect(() => {
@@ -159,6 +167,7 @@ export function StudyCertificate({ studySlug }: Props) {
const data = await res.json()
if (!res.ok) { setError(data.message ?? 'Failed to issue certificate.'); return }
setStatus(prev => prev ? { ...prev, token: data.token, issuedAt: data.issuedAt } : prev)
onIssued?.()
} catch {
setError('Network error. Please try again.')
} finally {
+242
View File
@@ -0,0 +1,242 @@
import { useEffect, useRef, useState } from 'react'
// Turns scripture references ("Titus 1:5-9") in answer text into hover/tap verse popups and URLs into links.
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function renderHighlightedText(text: string, query: string) {
const trimmed = query.trim()
if (!trimmed) return text
const regex = new RegExp(`(${escapeRegExp(trimmed)})`, 'ig')
const parts = text.split(regex)
return parts.map((part, index) => {
if (part.toLowerCase() === trimmed.toLowerCase()) {
return <mark key={`mark-${index}`} className="qa-highlight">{part}</mark>
}
return <span key={`plain-${index}`}>{part}</span>
})
}
function renderTextWithLinks(text: string, highlightQuery = '') {
const BOOK_ID_MAP: Record<string, string> = {
gen: 'GEN', genesis: 'GEN', exo: 'EXO', exodus: 'EXO', lev: 'LEV', leviticus: 'LEV',
num: 'NUM', numbers: 'NUM', deut: 'DEU', deuteronomy: 'DEU', josh: 'JOS', joshua: 'JOS',
judg: 'JDG', judges: 'JDG', ruth: 'RUT', '1 sam': 'SA1', '2 sam': 'SA2',
'1 kgs': 'KI1', '1 kings': 'KI1', '2 kgs': 'KI2', '2 kings': 'KI2',
'1 chr': 'CH1', '1 chron': 'CH1', '1 chronicles': 'CH1', '2 chr': 'CH2', '2 chron': 'CH2', '2 chronicles': 'CH2',
ezra: 'EZR', neh: 'NEH', nehemiah: 'NEH', esth: 'EST', esther: 'EST',
job: 'JOB', ps: 'PSA', psalms: 'PSA', psalm: 'PSA', prov: 'PRO', proverbs: 'PRO',
eccl: 'ECC', ecclesiastes: 'ECC', song: 'SNG', 'song of sol': 'SNG', 'song of solomon': 'SNG',
isa: 'ISA', isaiah: 'ISA', jer: 'JER', jeremiah: 'JER', lam: 'LAM', lamentations: 'LAM',
ezek: 'EZK', ezekiel: 'EZK', dan: 'DAN', daniel: 'DAN', hos: 'HOS', hosea: 'HOS',
joel: 'JOL', amos: 'AMO', obad: 'OBA', obadiah: 'OBA', jonah: 'JNA', mic: 'MIC', micah: 'MIC',
nah: 'NAH', nahum: 'NAH', hab: 'HAB', habakkuk: 'HAB', zeph: 'ZEP', zephaniah: 'ZEP',
hag: 'HAG', haggai: 'HAG', zech: 'ZEC', zechariah: 'ZEC', mal: 'MAL', malachi: 'MAL',
matt: 'MAT', matthew: 'MAT', mark: 'MRK', luke: 'LUK', john: 'JHN', acts: 'ACT',
rom: 'ROM', romans: 'ROM', '1 cor': 'CO1', '1 corinthians': 'CO1', '2 cor': 'CO2', '2 corinthians': 'CO2',
gal: 'GAL', galatians: 'GAL', eph: 'EPH', ephesians: 'EPH', phil: 'PHP', philippians: 'PHP',
col: 'COL', colossians: 'COL', '1 thess': 'TH1', '1 thessalonians': 'TH1', '2 thess': 'TH2', '2 thessalonians': 'TH2',
'1 tim': 'TI1', '1 timothy': 'TI1', '2 tim': 'TI2', '2 timothy': 'TI2',
titus: 'TIT', philem: 'PHM', philemon: 'PHM', heb: 'HEB', hebrews: 'HEB',
jas: 'JAM', james: 'JAM', '1 pet': 'PE1', '1 peter': 'PE1', '2 pet': 'PE2', '2 peter': 'PE2',
'1 john': 'JO1', '2 john': 'JO2', '3 john': 'JO3', jude: 'JDE', rev: 'REV', revelation: 'REV',
}
function parseScriptureRef(refText: string): { bookId: string; chapter: number; verseStart: number; verseEnd: number } | null {
const match = refText.match(/^(.*?)\s+(\d+):(\d+)(?:-(\d+))?$/)
if (!match) return null
const [, bookRaw, chapterStr, verseStartStr, verseEndStr] = match
const bookKey = bookRaw.toLowerCase().replace(/\.\s*/g, ' ').trim()
const bookId = BOOK_ID_MAP[bookKey]
if (!bookId) return null
return {
bookId,
chapter: parseInt(chapterStr, 10),
verseStart: parseInt(verseStartStr, 10),
verseEnd: verseEndStr ? parseInt(verseEndStr, 10) : parseInt(verseStartStr, 10),
}
}
// Split the text by both scripture refs and URLs
const combined = /(https?:\/\/[^\s]+)|\b((?:(?:1|2|3)\s)?(?:Gen(?:esis)?|Exo(?:dus)?|Lev(?:iticus)?|Num(?:bers)?|Deut(?:eronomy)?|Josh(?:ua)?|Judg(?:es)?|Ruth|1\s?Sam|2\s?Sam|1\s?Kgs?|2\s?Kgs?|1\s?Chr(?:on)?|2\s?Chr(?:on)?|Ezra|Neh(?:emiah)?|Esth(?:er)?|Job|Ps(?:alms?)?|Prov(?:erbs)?|Eccl(?:esiastes)?|Song(?:\s?of\s?Sol(?:omon)?)?|Isa(?:iah)?|Jer(?:emiah)?|Lam(?:entations)?|Ezek(?:iel)?|Dan(?:iel)?|Hos(?:ea)?|Joel|Amos|Obad(?:iah)?|Jonah|Mic(?:ah)?|Nah(?:um)?|Hab(?:akkuk)?|Zeph(?:aniah)?|Hag(?:gai)?|Zech(?:ariah)?|Mal(?:achi)?|Matt(?:hew)?|Mark|Luke|John|Acts|Rom(?:ans)?|1\s?Cor(?:inthians)?|2\s?Cor(?:inthians)?|Gal(?:atians)?|Eph(?:esians)?|Phil(?:ippians)?|Col(?:ossians)?|1\s?Thess|2\s?Thess|1\s?Tim(?:othy)?|2\s?Tim(?:othy)?|Titus|Philem(?:on)?|Heb(?:rews)?|Jas(?:mes)?|1\s?Pet(?:er)?|2\s?Pet(?:er)?|1\s?John|2\s?John|3\s?John|Jude|Rev(?:elation)?)\.?\s+\d+:\d+(?:-\d+)?)\b/gi
const parts: string[] = []
let lastIndex = 0
let m: RegExpExecArray | null
// reset lastIndex for combined
combined.lastIndex = 0
while ((m = combined.exec(text)) !== null) {
if (m.index > lastIndex) parts.push(text.slice(lastIndex, m.index))
parts.push(m[0])
lastIndex = m.index + m[0].length
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex))
return parts.map((part, index) => {
if (/^https?:\/\//i.test(part)) {
const safeHref = part.replace(/[),.;!?]+$/g, '')
const trailing = part.slice(safeHref.length)
return (
<span key={`link-${index}`}>
<a href={safeHref} target="_blank" rel="noopener noreferrer">{safeHref}</a>
{trailing}
</span>
)
}
const parsed = parseScriptureRef(part.replace(/\.$/, ''))
if (parsed) {
return <ScriptureTooltip key={`scripture-${index}`} refText={part} bookId={parsed.bookId} chapter={parsed.chapter} verseStart={parsed.verseStart} verseEnd={parsed.verseEnd} />
}
return <span key={`text-${index}`}>{renderHighlightedText(part, highlightQuery)}</span>
})
}
interface VerseData {
verse: number
value: string
}
// Maps helloao API book IDs → bible.com book codes
const BIBLE_COM_BOOK_IDS: Record<string, string> = {
GEN: 'GEN', EXO: 'EXO', LEV: 'LEV', NUM: 'NUM', DEU: 'DEU', JOS: 'JOS', JDG: 'JDG', RUT: 'RUT',
SA1: '1SA', SA2: '2SA', KI1: '1KI', KI2: '2KI', CH1: '1CH', CH2: '2CH',
EZR: 'EZR', NEH: 'NEH', EST: 'EST', JOB: 'JOB', PSA: 'PSA', PRO: 'PRO', ECC: 'ECC', SNG: 'SNG',
ISA: 'ISA', JER: 'JER', LAM: 'LAM', EZK: 'EZK', DAN: 'DAN', HOS: 'HOS', JOL: 'JOL', AMO: 'AMO',
OBA: 'OBA', JNA: 'JON', MIC: 'MIC', NAH: 'NAH', HAB: 'HAB', ZEP: 'ZEP', HAG: 'HAG', ZEC: 'ZEC', MAL: 'MAL',
MAT: 'MAT', MRK: 'MRK', LUK: 'LUK', JHN: 'JHN', ACT: 'ACT', ROM: 'ROM',
CO1: '1CO', CO2: '2CO', GAL: 'GAL', EPH: 'EPH', PHP: 'PHP', COL: 'COL',
TH1: '1TH', TH2: '2TH', TI1: '1TI', TI2: '2TI', TIT: 'TIT', PHM: 'PHM', HEB: 'HEB',
JAM: 'JAS', PE1: '1PE', PE2: '2PE', JO1: '1JN', JO2: '2JN', JO3: '3JN', JDE: 'JUD', REV: 'REV',
}
function ScriptureTooltip({ refText, bookId, chapter, verseStart, verseEnd }: {
refText: string
bookId: string
chapter: number
verseStart: number
verseEnd: number
}) {
const [open, setOpen] = useState(false)
const [verses, setVerses] = useState<VerseData[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
const wrapRef = useRef<HTMLSpanElement>(null)
const btnRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
if (verses.length > 0) return
setLoading(true)
setError(false)
fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`)
.then(r => {
if (!r.ok) throw new Error('Not found')
return r.json() as Promise<{ chapter: { content: Array<{ type: string; number?: number; content?: Array<{ text?: string; poem?: number } | string> }> } }>
})
.then(data => {
const content = data?.chapter?.content ?? []
const found: VerseData[] = []
for (const item of content) {
if (item.type === 'verse' && item.number != null && item.number >= verseStart && item.number <= verseEnd) {
const text = (item.content ?? [])
.map(c => (typeof c === 'string' ? c : (c.text ?? '')))
.join('')
.trim()
if (text) found.push({ verse: item.number, value: text })
}
}
setVerses(found)
setLoading(false)
})
.catch(() => {
setError(true)
setLoading(false)
})
}, [open, bookId, chapter, verseStart, verseEnd, verses.length])
// Compute fixed position from the trigger button so the popup escapes overflow:hidden parents
useEffect(() => {
if (!open || !btnRef.current) { setPos(null); return }
const rect = btnRef.current.getBoundingClientRect()
const POPUP_W = 360
const left = Math.max(8, Math.min(rect.left, window.innerWidth - POPUP_W - 12))
setPos({ top: rect.bottom + 8, left })
}, [open])
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false)
}
function handleDismiss() { setOpen(false) }
document.addEventListener('mousedown', handleClickOutside)
window.addEventListener('scroll', handleDismiss, { passive: true, capture: true })
window.addEventListener('resize', handleDismiss)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
window.removeEventListener('scroll', handleDismiss, { capture: true })
window.removeEventListener('resize', handleDismiss)
}
}, [open])
return (
<span ref={wrapRef} className="scripture-ref-wrap">
<button
ref={btnRef}
type="button"
className="scripture-ref"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
title={`View ${refText}`}
>
{refText}
</button>
{open && pos && (
<span
className="scripture-popup"
role="tooltip"
style={{ position: 'fixed', top: pos.top, left: pos.left, bottom: 'auto' }}
>
<span className="scripture-popup-header">
<strong>{refText}</strong>
<button type="button" className="scripture-popup-close" onClick={() => setOpen(false)} aria-label="Close">✕</button>
</span>
{loading && <span className="scripture-popup-body">Loading…</span>}
{error && <span className="scripture-popup-body scripture-popup-error">Could not load verse.</span>}
{!loading && !error && verses.length === 0 && <span className="scripture-popup-body">Verse not found.</span>}
{!loading && !error && verses.map(v => (
<span key={v.verse} className="scripture-popup-body">
{verseStart !== verseEnd && <sup>{v.verse} </sup>}{v.value}
</span>
))}
<span className="scripture-popup-footer">
<a
href={`https://www.bible.com/bible/3034/${BIBLE_COM_BOOK_IDS[bookId] ?? bookId}.${chapter}.BSB`}
target="_blank"
rel="noopener noreferrer"
className="scripture-popup-link"
>
Read on Bible.com ↗
</a>
<span className="scripture-popup-attribution">Berean Standard Bible</span>
</span>
</span>
)}
</span>
)
}
export function AnswerText({ text, highlight = '' }: { text: string; highlight?: string }) {
return <>{renderTextWithLinks(text, highlight)}</>
}
+58 -1
View File
@@ -11,9 +11,14 @@ export interface StudyProgram {
description: string
bibleBook?: string
homepageEyebrow?: string
// Cover image for the study, used as the backdrop of the featured card on the homepage
imageUrl?: string
showOnHomepage?: boolean
showNewTag?: boolean
newTagLabel?: string
// Which companion guide the study offers for download: '' = automatic (the guide of the series or
// finished book linked to this study), 'none', 'primary', 'second', or 'book:<finished book id>'
guideSource?: string
// 'complete' = finished and self-paced (still open for enrollment). A study whose lessons
// have all been released is treated as complete even if left as 'active'.
status: 'active' | 'planned' | 'complete'
@@ -71,6 +76,9 @@ export interface PodcastFeaturedLink {
// Which series this highlight belongs to: Lane A (primary), Lane B (second), or general.
// Missing = primary (older highlights were all about the one running series).
lane?: 'primary' | 'second' | 'general'
// When true the highlight stays hidden on the public site until its season + episode number
// appear in the Spotify feed (the lane's season number is set on the Episode Workflow page).
holdUntilReleased?: boolean
title: string
episodeNumber?: string
summary: string
@@ -277,6 +285,10 @@ export interface SiteContent {
shareQrImageUrl: string
// ── PRISM Video ──
prismVideoUrl: string
// Audio narration of the /salvation page (served to the static page via /api/salvation-audio)
salvationAudioUrl: string
salvationAudioGuiltyUrl: string
salvationAudioNotGuiltyUrl: string
prismEyebrow: string
prismHeading: string
prismIntro: string
@@ -307,6 +319,7 @@ export interface SiteContent {
platformCreatorUrl: string
platformKofiUrl?: string
platformKofiQrUrl?: string
verseOfTheWeek?: string
// ── Welcome Email ──
welcomeEmailSubject: string
welcomeEmailGreetingPrefix: string
@@ -393,6 +406,8 @@ export interface SiteContent {
qaAnsweredEmailSignoff: string
// ── Header ──
headerFollowLabel: string
// Ask for name and email before sending visitors to Amazon or Apple Books (default on)
gateStoreLinks?: boolean
// ── Cookie banner ──
cookieBannerText: string
// ── Arrays ──
@@ -517,7 +532,10 @@ export const DEFAULTS: SiteContent = {
shareHeading: 'Help one more person hear the Word this week.',
shareP: 'Scan the QR code or text the show link to a friend who needs encouragement today.',
shareQrImageUrl: '/images/qr-code.jpg',
prismVideoUrl: '/images/PRISM.mp4',
prismVideoUrl: 'https://files.versebyversewithnate.us/images/PRISM.mp4',
salvationAudioUrl: '',
salvationAudioGuiltyUrl: '',
salvationAudioNotGuiltyUrl: '',
prismEyebrow: 'Featured Video',
prismHeading: 'PRISM',
prismIntro:
@@ -554,6 +572,7 @@ export const DEFAULTS: SiteContent = {
platformCreatorUrl: 'https://creators.spotify.com/pod/profile/nmemmert/',
platformKofiUrl: 'https://ko-fi.com/versebyversewithnate',
platformKofiQrUrl: '/images/kofi-qr.png',
verseOfTheWeek: '',
welcomeEmailSubject: 'Welcome to Verse by Verse with Nate',
welcomeEmailGreetingPrefix: 'Glad you\'re here',
welcomeEmailIntro: 'Thanks for subscribing to Verse by Verse with Nate - a Bible teaching podcast where we slow down, dig into the text, and pull out the nuggets God has for us word by word.',
@@ -695,3 +714,41 @@ export function getAllHighlights(content: Pick<SiteContent, 'podcastFeaturedLink
...(content.finishedBooks ?? []).flatMap(book => book.highlights ?? []),
]
}
export interface StudyGuideLink {
// Page id under /downloads/ (primary-guide, second-guide, book-guide--<id>)
pageId: string
title: string
// Direct file link, when the guide has one; otherwise the button opens the guide's page (store links)
downloadUrl?: string
}
// The companion guide a study offers, either chosen explicitly in admin or found through the
// series / finished book that is linked to the study.
export function getStudyGuideLink(
content: Pick<SiteContent, 'seriesStudySlug' | 'studyGuideTitle' | 'studyGuideUrl' | 'studyGuideDownloadUrl' | 'studyGuideAppleBooksUrl' | 'secondSeries' | 'finishedBooks'>,
study: Pick<StudyProgram, 'slug' | 'guideSource'>,
): StudyGuideLink | null {
const source = study.guideSource ?? ''
if (source === 'none') return null
const primary = (): StudyGuideLink | null =>
content.studyGuideTitle && (content.studyGuideUrl || content.studyGuideDownloadUrl || content.studyGuideAppleBooksUrl)
? { pageId: 'primary-guide', title: content.studyGuideTitle, downloadUrl: content.studyGuideDownloadUrl || undefined }
: null
const second = (): StudyGuideLink | null =>
content.secondSeries?.guide?.title ? { pageId: 'second-guide', title: content.secondSeries.guide.title, downloadUrl: content.secondSeries.guide.downloadUrl || undefined } : null
const book = (id: string): StudyGuideLink | null => {
const found = (content.finishedBooks ?? []).find(b => b.id === id)
return found?.guide?.title ? { pageId: `book-guide--${found.id}`, title: found.guide.title, downloadUrl: found.guide.downloadUrl || undefined } : null
}
if (source === 'primary') return primary()
if (source === 'second') return second()
if (source.startsWith('book:')) return book(source.slice('book:'.length))
if (content.seriesStudySlug && content.seriesStudySlug === study.slug) return primary()
if (content.secondSeries?.studySlug && content.secondSeries.studySlug === study.slug) return second()
const linkedBook = (content.finishedBooks ?? []).find(b => b.studySlug === study.slug && b.guide?.title)
return linkedBook ? book(linkedBook.id) : null
}
+716 -367
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { isKnownSitePath } from '../server/study-helpers.js'
test('real site pages are known paths', () => {
for (const p of ['/', '/study', '/study/titus/titus-abc', '/episodes/123', '/salvation', '/scripture', '/privacy']) {
assert.equal(isKnownSitePath(p), true, p)
}
})
test('scanner and probe paths are not known paths', () => {
for (const p of ['/.env', '/.git/config', '/wp-login', '/actuator/health', '/cgi-bin/x', '/random-path-123']) {
assert.equal(isKnownSitePath(p), false, p)
}
})
import { checkPassageReference } from '../shared/bible-books.js'
test('passage reference check accepts what the index can read and flags what it cannot', () => {
assert.equal(checkPassageReference('Titus 1:1-2', ''), null)
assert.equal(checkPassageReference('1:10–13a', 'titus'), null)
assert.match(checkPassageReference('1:1-2', ''), /Bible Book/)
assert.match(checkPassageReference('Foo 1:1', 'titus'), /not a recognized/)
assert.match(checkPassageReference('chapter one', 'titus'), /Use the form/)
})
+32
View File
@@ -0,0 +1,32 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { parseNoteEntries, serializeNoteEntries, noteEntriesToText } from '../shared/note-format.js'
test('legacy anchor map upgrades to entries and round-trips as v2', () => {
const entries = parseNoteEntries(JSON.stringify({ scripture: '<p>a</p>', general: '', commentary: '<p>b</p>' }))
assert.deepEqual(entries.map(e => e.anchor), ['scripture', 'commentary'])
const again = parseNoteEntries(serializeNoteEntries(entries))
assert.equal(again.length, 2)
assert.equal(JSON.parse(serializeNoteEntries(entries)).v, 2)
})
test('plain-text legacy notes become a general entry', () => {
const entries = parseNoteEntries('just some text')
assert.equal(entries.length, 1)
assert.equal(entries[0].anchor, 'general')
})
test('multiple entries per anchor are kept and empty ones dropped', () => {
const raw = serializeNoteEntries([
{ id: '1', anchor: 'general', html: '<p>one</p>', updatedAt: '2026-01-01T00:00:00.000Z' },
{ id: '2', anchor: 'general', html: '<p>two</p>', updatedAt: null },
{ id: '3', anchor: 'general', html: ' ', updatedAt: null },
])
assert.equal(parseNoteEntries(raw).length, 2)
assert.equal(serializeNoteEntries([]), '')
})
test('plain-text export strips markup', () => {
const text = noteEntriesToText(serializeNoteEntries([{ id: '1', anchor: 'scripture', html: '<p>Hi &amp; bye</p>', updatedAt: null }]))
assert.equal(text, 'Scripture Text\nHi & bye')
})
+80
View File
@@ -0,0 +1,80 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { state } from '../server/state.js'
import {
hasStudyAccess,
isStudyUserCompleted,
getAccessibleStudySlugs,
markStudyCompletedIfDone,
scheduleReengagementEmails,
} from '../server/study-helpers.js'
function setup() {
state.cachedSiteContent = {
studies: [{ slug: 'mini', sections: [{ id: 'a' }, { id: 'b' }] }, { slug: 'other', sections: [{ id: 'x' }] }],
}
}
test('completing every lesson marks the study completed and keeps access after leaving', () => {
setup()
const user = { id: 'u1', enrolledStudySlugs: ['mini'] }
assert.equal(markStudyCompletedIfDone(user, 'mini', ['a']), false)
assert.equal(markStudyCompletedIfDone(user, 'mini', ['a', 'b']), true)
assert.ok(isStudyUserCompleted(user, 'mini'))
user.enrolledStudySlugs = [] // student leaves
assert.ok(hasStudyAccess(user, 'mini'))
assert.ok(!hasStudyAccess(user, 'other'))
assert.deepEqual(getAccessibleStudySlugs(user), ['mini'])
})
test('completion is sticky when a lesson is added later, and not marked twice', () => {
setup()
const user = { id: 'u2', enrolledStudySlugs: ['mini'] }
markStudyCompletedIfDone(user, 'mini', ['a', 'b'])
state.cachedSiteContent.studies[0].sections.push({ id: 'c' })
assert.equal(markStudyCompletedIfDone(user, 'mini', ['a', 'b']), false)
assert.ok(isStudyUserCompleted(user, 'mini'))
assert.deepEqual(user.completedStudySlugs, ['mini'])
})
test('unknown or empty studies are never marked completed', () => {
setup()
const user = { id: 'u3', enrolledStudySlugs: [] }
assert.equal(markStudyCompletedIfDone(user, 'nope', ['a']), false)
state.cachedSiteContent.studies.push({ slug: 'empty', sections: [] })
assert.equal(markStudyCompletedIfDone(user, 'empty', []), false)
})
test('re-engagement emails skip students whose only study is finished', async () => {
setup()
const sent = []
state.studyUsers = [{
id: 'u4', username: 'a@b.co', studyRemindersEnabled: true, lastLoginAt: new Date(Date.now() - 40 * 864e5).toISOString(),
enrolledStudySlugs: ['mini'], completedStudySlugs: ['mini'], reengagementSentAt: {},
}, {
id: 'u5', username: 'c@d.co', studyRemindersEnabled: true, lastLoginAt: new Date(Date.now() - 40 * 864e5).toISOString(),
enrolledStudySlugs: ['mini'], reengagementSentAt: {},
}]
await scheduleReengagementEmails(async (email) => { sent.push(email) })
assert.deepEqual(sent, ['c@d.co'])
})
test('discussion is open for active and finished studies, closed only for planned ones', async () => {
const { isStudyDiscussionOpen } = await import('../server/study-helpers.js')
const past = '2020-01-01T00:00:00Z'
const future = '2099-01-01T00:00:00Z'
state.cachedSiteContent = {
studies: [
{ slug: 'live', status: 'active', sections: [{ id: 'a', releasedAt: past }, { id: 'b', releasedAt: future }] },
{ slug: 'done', status: 'complete', sections: [{ id: 'a', releasedAt: past }] },
{ slug: 'auto-done', sections: [{ id: 'a', releasedAt: past }] }, // every lesson released => complete
{ slug: 'soon', status: 'planned', sections: [] },
],
}
assert.equal(isStudyDiscussionOpen('live'), true)
assert.equal(isStudyDiscussionOpen('done'), true)
assert.equal(isStudyDiscussionOpen('auto-done'), true)
assert.equal(isStudyDiscussionOpen('soon'), false)
assert.equal(isStudyDiscussionOpen('missing'), false)
})
+3 -8
View File
@@ -25,10 +25,10 @@ export default defineConfig({
navigateFallback: '/index.html',
// Allowlist-only approach: SW only serves index.html for known SPA routes.
// Everything else (admin-configured redirects, /spotify, /apple, /amazon,
// feed.xml, robots.txt, etc.) goes to the server so server-side redirects work.
// feed.xml, robots.txt, the static /salvation page, etc.) goes to the server so server-side redirects work.
navigateFallbackAllowlist: [
/^\/$/, // exact root
/^\/(start-here|about|contact|resources|thanks|preview|privacy|terms|salvation)(\/.*)?$/,
/^\/(start-here|about|contact|resources|thanks|preview|privacy|terms)(\/.*)?$/,
/^\/(study|studys|episodes|finished|downloads|subscribe|certificate)(\/.*)?$/,
/^\/(questions|admin|email|contacts|calendar)(\/.*)?$/,
],
@@ -39,7 +39,7 @@ export default defineConfig({
options: { cacheName: 'api-content', expiration: { maxAgeSeconds: 3600 } },
},
{
urlPattern: /\/images\//,
urlPattern: /\/images\/.*\.(?!mp4|webm|mov)[a-z0-9]+(\?.*)?$/i,
handler: 'CacheFirst',
options: { cacheName: 'images', expiration: { maxEntries: 60, maxAgeSeconds: 86400 * 30 } },
},
@@ -47,11 +47,6 @@ export default defineConfig({
},
}),
],
resolve: {
alias: {
'react-simple-maps': 'react-simple-maps/dist/index.es.js',
},
},
server: {
proxy: {
'/api': 'http://localhost:4173',