Compare commits

..
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 5ab416930b Reader: compare button next to Aa
A button beside Aa in the reader's top bar opens a Compare translations
sheet (pick the Bible to compare with, show/hide the differing-word
highlights). It shows as active while a comparison is on. The controls
moved out of the Aa sheet; "C" opens it from the keyboard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:40:24 -04:00
nmemmertandClaude Sonnet 5.5 aa27a5bb18 Reader: show how much another translation differs from the one being read
- Word-level comparison (ignores case and punctuation): differing words are
  marked in both columns of the side-by-side view
- Chapter summary ("NET is 18% different from BSB · 37 of 39 verses differ")
  with links to the most different verses, and a percentage on each verse
- Compare tab on a verse shows each translation's difference from the one
  being read, with the differing words marked
- "Highlight differences" setting; your own highlights and search matches
  always win where they overlap a difference mark

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:36:46 -04:00
nmemmertandClaude Sonnet 5.5 7101be393e Merge outline-richer: per-chunk outlines, richer points, print and share
Build and Push Docker Image / build-and-push (push) Failing after 20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:24:49 -04:00
nmemmertandClaude Sonnet 5.5 820886e3ab Outline: per-chunk outlines, richer points, print and share
- One outline for the whole study plus one per chunk (picker at the top);
  chunk outlines can start from the chunk's verses, each carrying its words
- Points can have a type (illustration, application, quote, question,
  transition), minutes (with a running total), a longer note, verse text
  fetched from a passage, and cross-references / word studies attached from
  the chunk
- Share (system share sheet, or copy), Print / PDF, Copy as text, Word and
  Markdown downloads for any one outline
- All of it included in the Markdown, HTML/PDF and Word study exports,
  with each chunk's outline under that chunk

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:24:28 -04:00
nmemmertandClaude Sonnet 5.5 d273c8cb3e Merge ui-features-flow: Outline stage, parallel reading, offline, backup, sync conflicts
Build and Push Docker Image / build-and-push (push) Failing after 21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:09:07 -04:00
nmemmertandClaude Sonnet 5.5 b4f038b185 Show the current page in the browser tab title
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:09:06 -04:00
nmemmertandClaude Sonnet 5.5 d13f55faf7 Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more
Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:08:08 -04:00
nmemmertandClaude Sonnet 5.5 1edbd364ea Stop the page extending under the status bar; use visible-viewport units
Build and Push Docker Image / build-and-push (push) Failing after 19s
Drops viewport-fit=cover so the iPad/iPhone status bar is its own area and the system's
top-edge blur no longer covers the app bar. Fixed-height study stages are position:fixed so
they cannot scroll the document, and min-h-screen/100vh become dvh units.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:38:51 -04:00
nmemmertandClaude Sonnet 5.5 bb7b5bd6d8 Start every page at the top
Build and Push Docker Image / build-and-push (push) Successful in 4m27s
The scroll position of the previous page used to carry over, which on iPad makes the
system blur the app bar when content is scrolled under it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:32:26 -04:00
nmemmertandClaude Sonnet 5.5 7fc5e4b534 Match the browser/OS chrome to the theme and clear the status bar
Build and Push Docker Image / build-and-push (push) Successful in 4m30s
The app now sets theme-color, color-scheme and the page background from the chosen theme,
so the iPad status-bar area no longer shows a light band over a dark theme. Every top bar
leaves room for the status bar via a safe-top class (replacing a rule that padded every
<header>). Install defaults match the paper theme.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:24:58 -04:00
nmemmertandClaude Sonnet 5.5 ec59f7c82d Import: downloadable Word template for the session list
Build and Push Docker Image / build-and-push (push) Failing after 20s
A blank three-column session list (Session #, Title, Passage) with instructions and
example rows, built in the browser on demand. A test imports the generated file back
through the real importer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:18:24 -04:00
nmemmertandClaude Sonnet 5.5 7a010fcda8 Resizable right column in the Study stage; start on the chosen page without a flash
Build and Push Docker Image / build-and-push (push) Failing after 20s
The Study stage's right column has a drag handle (mouse, arrow keys, double-click
or Home to reset); the width is remembered per device. The app now starts on the
user's chosen home page from the first render instead of showing My Studies
first. Tests wait on page headings so a lazily loaded page can't be confused
with hidden text from the previous one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:01:56 -04:00
nmemmertandClaude Sonnet 5.5 8c4f54e033 Preferences in Settings and a home page the user chooses
Build and Push Docker Image / build-and-push (push) Failing after 20s
Settings gets a Preferences section: theme, default Bible, the Bible new reading
plans start in, verse layout, commentary, text size, reading width, audio
narrator (the last three stay per device; text size and narrator are now
remembered). A Home page setting (My Studies, Bible Reader or Reading Plans)
decides where the app opens and where every Home button goes; already being
there means My Studies. Back arrows are labelled Home. Tests cover every page
leading back.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:55:07 -04:00
nmemmertandClaude Sonnet 5.5 760c1e568b Sync bookmarks, highlights and display preferences to the account
Build and Push Docker Image / build-and-push (push) Failing after 19s
Bookmarks and text highlights are saved per user and merged across devices
(union on first contact, newer server copy when this device is clean). Theme,
default translation, verse layout, commentary source and reading position sync
too, each with its own timestamp so the newest change to each setting wins.
The Reader never moves under you while it is open. Signing out clears this
device's marks and forgets sync timestamps.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:45:23 -04:00
nmemmertandClaude Sonnet 5.5 297f2c7e84 Fix plan days with no chapters, add a way out of ink mode, harden ntfy reminders
Build and Push Docker Image / build-and-push (push) Failing after 19s
Plans can no longer have more days than chapters (existing ones heal), and
opening a plan never strands the Reader on an empty day. The Reader shows
Done drawing while inking (Escape also exits). Reminders use the user's own
timezone, send within a 3 hour grace window, and Settings has a Send test
notification button.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:34:46 -04:00
nmemmertandClaude Sonnet 5.5 ecb1c701fa Rework app bar, notes layout, Settings/Admin/login restyle
Build and Push Docker Image / build-and-push (push) Failing after 20s
App bar: brand, Studies / Read Bible / Reading Plans, and one account menu
(theme, Settings, Admin, Log out). Study stage: notes get their own box above
the resources in the right column. Settings, Admin and the login page use the
reader themes; add a theme-aware danger colour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:27:02 -04:00
nmemmertandClaude Sonnet 5.5 31abee0695 Redesign the study side: Outline, Study, Script and Export stages
Build and Push Docker Image / build-and-push (push) Successful in 4m15s
Studies now move through a stage bar in the reader's themes. Outline splits
chunks by clicking between verses (join merges notes) and suggests chunks from
section headings. Study is three panes: outline, passage with an Observe /
Interpret / Apply accordion, and commentary / word study / cross-refs / people
& places for the chunk; original words are added by clicking them in the
interlinear. Script drafts an outline from notes; Export holds files and the
share link. Home, Import and the Pronunciation guide are restyled. Study
projects are BSB only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:13:46 -04:00
nmemmertandClaude Sonnet 5.5 b1bc1c8ea4 Reading plans: integrated into the Reader, redesigned, with per-plan translation
Build and Push Docker Image / build-and-push (push) Failing after 19s
Reader: plan strip with a dot per chapter, plan sheet (day picker, chapter
ticks, translation, streak, other plans), end-of-day card that steps through
the day and rolls into the next, and chapter-level auto-marking.
Each plan remembers its own translation (BSB, ESV, NLT, CSB, NASB, NET) and the
Reader follows it while a plan is open. Plans home redesigned in the reader's
themes with a Continue card, in-progress plans, a filterable catalog, and
sheets for details and the custom-plan builder.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 09:47:51 -04:00
nmemmertandClaude Sonnet 5.5 c11b6a60bf Reader: whole-Bible search in every translation
Build and Push Docker Image / build-and-push (push) Failing after 20s
BSB and NET are indexed in the browser; ESV, NLT, CSB and NASB are searched
through a server proxy (keys stay server-side). Results page in 25 at a time
and jump to the verse. Also fixes a stray trailing comma in CSB chapter text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 09:25:00 -04:00
nmemmertandClaude Sonnet 5.5 3279dddae5 Reader: ESV chapter audio
Build and Push Docker Image / build-and-push (push) Failing after 19s
The Reader's player follows the translation on screen: ESV plays its own
recording via a server route that resolves the ESV API's audio redirect;
other translations keep the BSB narrators.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 09:20:15 -04:00
nmemmertandClaude Sonnet 5.5 e50a73d351 Study panel: follow the reader theme colours
Build and Push Docker Image / build-and-push (push) Successful in 4m54s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 09:12:17 -04:00
nmemmertandClaude Sonnet 5.5 9e722a296d Redesign the Reader; add ESV
Build and Push Docker Image / build-and-push (push) Failing after 20s
New reading surface with paper/sepia/dark themes, slim top and bottom bars,
and sheets for navigation, search, settings, audio and per-verse study tools.
Plan progress is built into the reader. Page-flip mode removed. ESV added via
the server-side proxy (ESV_API_KEY).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 09:11:47 -04:00
nmemmertandClaude Sonnet 5.5 9e8420a1ae Reader: verse study panel (commentaries, people & places) and NET Bible
Build and Push Docker Image / build-and-push (push) Failing after 20s
Adds a per-verse panel with seven HelloAO commentaries and Theographic
people/places/events, and NET as a fifth Reader translation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 08:57:51 -04:00
nmemmertandClaude Sonnet 5.5 84a5bb8838 Reader: note that audio is BSB-only for licensed translations
Build and Push Docker Image / build-and-push (push) Failing after 20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 08:42:05 -04:00
nmemmertandClaude Sonnet 5.5 22c80d2bc3 Add NLT, CSB and NASB via api.bible proxy; new app logo and icons
Build and Push Docker Image / build-and-push (push) Failing after 21s
Reader and Reading Plans get a translation picker. Licensed translations are
fetched through a server-side api.bible proxy so the key stays off the client.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 08:37:48 -04:00
nmemmertandClaude Sonnet 5 986d2e9331 Surface reading-plan pull failures too, fix StrictMode false sync
Build and Push Docker Image / build-and-push (push) Failing after 21s
The sign-in pull (GET /api/reading-plans -> merge) had the same silent-
failure gap the push side had before the last fix: a failed fetch just
left local state as-is with zero indication anything was wrong. Moved it
into the hook as pullServerReadingProgress(), which reports failures
through the same error banner/retry the push path already uses.

Also found and fixed a real bug while testing this: the "skip syncing on
the very first render" guard used a boolean ref, which React 18 StrictMode
defeats by double-invoking effects in dev — the second invocation saw the
flag already flipped and fired a spurious sync of stale localStorage data
before the app had even confirmed sign-in, occasionally producing a false
"couldn't sync" banner on fresh loads. Replaced it with a reference-equality
check against the state object captured at mount, which both of
StrictMode's synthetic invocations see identically. Caught this in
useReadingPlans.js's own test run, not manual testing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:47:51 -04:00
nmemmertandClaude Sonnet 5 b14dbb52cc Surface reading plan sync failures instead of failing silently
Build and Push Docker Image / build-and-push (push) Successful in 4m34s
Reading plan sync was truly fire-and-forget: a failed PUT to
/api/reading-plans left the change stuck on that device with no
indication anything was wrong, which is exactly what "doesn't sync
across devices" looks like from the outside.

Moved the sync call out of the setState updater and into a proper effect
keyed on state, so success/failure can be tracked. Both Home and Reading
Plans now show a red banner with the actual error (HTTP status or network
error) and a Retry button when a sync fails, mirroring how study project
sync already reports errors. Verified the full cycle locally: normal sync,
a killed-server failure showing the banner, and Retry recovering once the
server's back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:18:09 -04:00
nmemmertandClaude Sonnet 5 787edcbe73 Connect reading plans to study/audio, cache offline audio, add journal export
Build and Push Docker Image / build-and-push (push) Successful in 4m33s
Reading plan days now offer "Study this" (seeds a new chunk-builder project
with the day's full passage and jumps straight into the Study editor) and
"Listen" (starts book-mode audio at the right chapter via a new
handlePlayBookAudioFrom, avoiding a stale-closure bug the reader-state
approach would hit). Fixed the "Listen to BSB Audio" player only rendering
when a study project existed, leaving Listen with nowhere to show up.

The service worker now caches audio.bible.helloao.org (the actual host
serving narrator audio, not just the text/cross-ref API) and no longer
tries to cache 206 Partial Content responses, which the Cache API rejects
and was throwing unhandled rejections for every range-requested audio file.

Added a reading journal export (Settings > My reading journal): gathers
bookmarks, text highlights, and completed plans, fetches their verse text,
and offers a .docx download or a print-to-PDF page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 13:41:40 -04:00
nmemmertandClaude Sonnet 5 1dd6aef6a5 Add streaks, today view, custom plan editing/chapter ranges, and ntfy reminders
Build and Push Docker Image / build-and-push (push) Failing after 21s
Reading plans now track a cross-plan activity log for streaks, surface a
combined "today's reading" view on both Home and the Reading Plans page,
and let custom plans be edited after creation (including narrowing a book
to a specific chapter range). The reader auto-marks a plan's day read after
a dwell period, active plans that fall behind their pace get a "catch me
up" banner, and a new Settings section wires up daily reading reminders
via ntfy.sh, checked once a minute by a small server-side scheduler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 13:16:27 -04:00
nmemmertandClaude Sonnet 5 600b3d3ab9 Add reading plans: catalog, progress tracking, and custom plans
Replaces the old single-book reading goal with a full Reading Plans page:
9 built-in plans (Whole Bible, OT/NT, topical) with cover photos and
color-themed cards, day-by-day checklists synced to the server per user,
inline "mark today read" in the Bible reader, and user-created plans with
their own book picker and an optional uploaded cover photo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:51:28 -04:00
nmemmertandClaude Sonnet 5 f1026dc194 Redesign audio controls into a real media player
Build and Push Docker Image / build-and-push (push) Successful in 5m5s
Adds a shared AudioPlayer component with a seekable progress bar,
big play/pause button, and icon transport controls, replacing the
row of text buttons on both the book and chapter audio players.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:59:42 -04:00
nmemmertandClaude Sonnet 5 b9f9b97cba Add chapter skip and 10s seek controls to audio players
Build and Push Docker Image / build-and-push (push) Successful in 5m42s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:49:32 -04:00
nmemmertandClaude Opus 5.5 5287b471d0 Redesign project header and study navigation
Build and Push Docker Image / build-and-push (push) Failing after 20s
- Header: navigation buttons grouped; Study Guide, Podcast, and
  Pronunciation Guide move into a Tools menu (disabled until a chunk
  exists), set apart from Export and Share by a divider.
- Only one header dropdown is open at a time, and it closes on an
  outside click or Escape.
- Study page: drop the duplicate Prev/Next and counter at the top; the
  fixed bottom bar is the single chunk navigation and now shows a
  readable "Chunk N of M".
- Mobile empty-state hint points to Back to Chunks.
- Tests: Tools menu, one-dropdown-at-a-time, Escape/outside click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 15:37:29 -04:00
nmemmertandClaude Opus 5.5 33e3f2bdc6 Fix Greek lookup test expecting a single 'faith, belief' field
Build and Push Docker Image / build-and-push (push) Successful in 4m7s
With no gloss-map entry, the English gloss falls back to the KJV
definition, so both English Gloss and Short Definition show it. Assert
both instead of failing on the duplicate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 15:35:32 -04:00
nmemmertandClaude Opus 5.5 d08c701698 Fix Pronunciation page Speak buttons; tidy App.jsx
Build and Push Docker Image / build-and-push (push) Successful in 7m53s
PronunciationPage still read speakOriginalWord from context after it
moved to utils/wordStudy, so Speak would have thrown. Import it directly.
App.jsx: consolidate imports, update comments, list all pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 15:04:55 -04:00
nmemmertandClaude Opus 5.5 0f1a9c3d5b Move chunk builder into SetupPage via useChunkBuilder
- The setup form, active chapter tab, verse range selection, typed/bulk/
  cross-chapter chunk creation, reorder, and delete move out of App.
- Setup now opens on the chapter holding the chunk last studied (e.g.
  after Back to Chunks), and the Add Chapter form suggests the chapter
  after the project's last one instead of repeating the current one.
- openNewProject/goHome/resume/import no longer reset setup state by hand;
  SetupPage starts fresh each time it opens.
- Tests: add-chapter default, Back to Chunks chapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 15:01:53 -04:00
nmemmertandClaude Opus 5.5 bf02e071fa Move study editor state into StudyPage and hooks
- StudyPage owns layout/tabs/collapsed sections, tags, DOCX script
  import, chunk navigation, keyboard shortcuts, and the selected-chunk
  values derived from the project.
- useStudyResources: commentary + interlinear for the selected chunk's
  chapter. These now load only on the study page (they previously also
  fetched while on Setup).
- useCrossRefs: typed refs, dataset suggestions, verse text.
- useWordStudy: word entries, Strong's lookups, suggestion picker.
- Fix: study-page messages (word suggestions, DOCX import, lookups) set a
  status that only SetupPage displayed, so they were never seen. They
  now show as a toast on the study page.
- copyVerse moves into ReaderPage.
- Tests: toast, arrow-key navigation, cross-refs, tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:59:22 -04:00
nmemmertandClaude Opus 5.5 4b14b3fbe6 Extract project header, share link, exports, and auth status
- ProjectHeader component replaces the headerButtons JSX in context; it
  owns the export menu and share panel state.
- useShareLink (share status/enable/revoke/copy) and useSharedView
  (?share= read-only view, now used directly by the router).
- utils/projectExport: HTML/Markdown/DOCX download and print-to-PDF.
- AuthStatus component replaces the authStatus JSX in context.
- Pages import wordStudy helpers directly instead of via context.
- Remove dead copyPronunciationGuide (PronunciationPage has its own).
- Tests: Markdown export, enabling a share link, the ?share= view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:56:31 -04:00
nmemmertandClaude Opus 5.5 365fb1b567 Keep draw pen settings inside DrawCanvas
Only the Reader's canvas uses them now. A module-level lastPen keeps the
chosen tool/color/size when Draw mode is toggled off and on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:53:17 -04:00
nmemmertandClaude Opus 5.5 a7e00a76b5 Split Home: local UI state, useProjectLibrary, useReadingPlan
- Search, tag filter, full-text note search, and rename state live in
  HomePage. The sort choice is remembered in localStorage.
- useProjectLibrary owns opening/renaming/deleting projects and the
  server reconcile (auto-restore + newer-on-server list).
- useReadingPlan owns the reading plan shared by Home and Reader.
- Tests: note search, rename, delete, sort memory, reading plan. Test
  setup now resets the storage namespace between tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:52:46 -04:00
nmemmertandClaude Opus 5.5 8bb3b104c8 Move import state into ImportPage; share translation list via hook
The session-list import form, preview, and runEpisodeImport now live in
ImportPage. useAvailableTranslations fetches the translation list once per
visit for both Import and Setup. Drops the never-read importFile state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:50:21 -04:00
nmemmertandClaude Opus 5.5 d7bbbe60de Move word-study helpers into utils/wordStudy
Strong's dictionary loaders, lexicon lookup, gloss helpers, cross-ref
formatting, and speech move out of App. Dictionaries are now cached at
module level (still one download per visit), and a failed download is no
longer cached forever, so the next lookup retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:49:21 -04:00
nmemmertandClaude Opus 5.5 8c55765ae6 Move admin state into AdminPage
Build and Push Docker Image / build-and-push (push) Failing after 21s
All admin state (tab, users, projects, loading/error, preview, reset
result) and the data loader were only used by AdminPage, so they now live
there as local state instead of in App's context. The page loads its data
when it opens (still gated on authUser.isAdmin; the server enforces it too).

Add tests: admin sees users and projects, deleting a user reloads the
list, and non-admins get no Admin button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:46:35 -04:00
nmemmertandClaude Opus 5.5 49ffefed54 Extract auth into hooks/useAuth; move form state into its pages
Build and Push Docker Image / build-and-push (push) Successful in 6m28s
- useAuth owns authUser/authServerDown, the startup session check, and
  signIn/logout, so every sign-in path runs the same follow-up (switch
  local storage to the account, load reader ink). Previously reader ink
  only loaded on a page refresh, not right after logging in.
- Login/2FA form state now lives in AuthPage, and 2FA setup, password
  change, and podcast name state in SettingsPage (23 fewer context
  values; typing in these forms no longer re-renders all of App).
- Add tests for form sign-in, 2FA, session restore + logout, and the
  Settings podcast-name prefill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:44:52 -04:00
nmemmertandClaude Opus 5.5 009fa1e78f Extract shared audio player into hooks/useAudio
Build and Push Docker Image / build-and-push (push) Failing after 2m25s
The Home page's whole-book player and the Reader's chapter player share one
<audio> element, so they move together. App now just calls
useAudio(readerBook, readerChapter) and spreads the result into context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:36:59 -04:00
nmemmertandClaude Opus 5.5 ade7f1af6f Speed up the app, run production build, remove chunk ink, start App.jsx split
Performance:
- Lazy-load pages, docx, and mammoth (initial JS 1,298 KB -> ~290 KB)
- Gzip responses and cache hashed assets for a year
- Memoize DrawCanvas strokes so ink isn't redrawn on every render
- Precompute whole-Bible search index and defer the query while typing
- Reload once if a lazy chunk is missing after a deploy

Deployment:
- setup.sh builds and runs one production server on APP_PORT (default
  5173); --dev keeps the old Vite dev setup
- Session cookie uses secure: 'auto' so login works behind any proxy

Chunk editor:
- Remove the Draw layout, Ink Notes panel, and ink in HTML export
  (existing inkStrokes data is left in saved projects)

Refactor:
- Move module-level helpers out of App.jsx into src/utils/*
- Extract Bible reader state and logic into hooks/useBibleReader

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:34:13 -04:00
nmemmertandClaude Sonnet 4.6 00932dee4a Update restart.sh to reference Gitea instead of GitHub
Build and Push Docker Image / build-and-push (push) Successful in 2m36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 09:39:49 -04:00
nmemmertandClaude Sonnet 4.6 85fc566ade Add Pronunciation Guide page with word cards and TTS
Build and Push Docker Image / build-and-push (push) Successful in 3m37s
Replace the copy-to-clipboard button with a dedicated page that shows
all collected Greek/Hebrew words as cards grouped by language. Each
card displays the original script large, transliteration, Strong's
number, English gloss, definition, part of speech, and a Speak button.
Includes a search/filter and a "Copy all" fallback for the old behavior.

Move buildPronunciationGuide to bibleUtils.jsx to avoid circular imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 09:27:52 -04:00
nmemmertandClaude Sonnet 4.6 7b2920b9e6 Default study layout to split view
Build and Push Docker Image / build-and-push (push) Successful in 3m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 09:22:02 -04:00
nmemmertandClaude Sonnet 4.6 9b9de74084 Fix Vite parsing git objects as JS modules on dev server
Build and Push Docker Image / build-and-push (push) Successful in 3m2s
- Add server.watch.ignored for .git/** so chokidar doesn't pick up
  changes to git object files during git pull/fetch on the server
- Add .dockerignore to exclude .git, node_modules, and dist from
  the Docker build context

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-02 09:20:26 -04:00
nmemmertandClaude Sonnet 4.6 b98aae741f Fix circular imports causing React dispatcher null error
Build and Push Docker Image / build-and-push (push) Successful in 4m0s
Extract shared utilities (constants, helpers, components) from App.jsx
into src/utils/bibleUtils.jsx so page components can import from there
instead of from App.jsx. This breaks the App.jsx <-> pages circular
dependency that was triggering vite:import-analysis warnings and causing
the React hook dispatcher to be null at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 18:36:21 -04:00
nmemmertandClaude Sonnet 4.6 c804aa2ccc Fix Docker build: upgrade npm to 11 to match lockfile format
Build and Push Docker Image / build-and-push (push) Successful in 11m51s
package-lock.json was generated with npm 11; node:20-alpine ships with
npm 10 which can't resolve optional esbuild platform packages from the
newer lockfile format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 12:49:37 -04:00
nmemmertandClaude Sonnet 4.6 cc234e8192 Fix Docker build: add native build tools for better-sqlite3
Build and Push Docker Image / build-and-push (push) Failing after 2m14s
better-sqlite3 is a native C++ addon that requires python3, make, and g++
to compile on Alpine Linux. Added to both builder and production stages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 12:30:05 -04:00
nmemmertandClaude Sonnet 4.6 2d659c4fe9 Add Gitea Actions workflow to build and push Docker image
Build and Push Docker Image / build-and-push (push) Failing after 4m9s
Builds on push to main and version tags, pushes to git.necloud.us container registry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-26 12:23:44 -04:00
nmemmertandClaude Sonnet 4.6 2106595976 Show passage text in inline Ink Notes canvas
Passes selectedChunkVerses as headerContent to the inline DrawCanvas so
highlights overlay the actual scripture text, matching annotate mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 10:40:47 -04:00
nmemmertandClaude Sonnet 4.6 902fb5da02 Add Apple Pencil support, reading plan, full-text search, and inline ink
- Apple Pencil hover cursor: previews brush size/shape before contact
- Tilt shading: tilted pen strokes widen and reduce thinning via tiltX/Y
- Two-finger undo in draw mode (non-stylus two-finger tap)
- Copy & open Claude buttons: copies study/podcast prompt then opens claude.ai/new
- Inline ink canvas in study notes (DrawCanvas embedded in Notes tab)
- Full-text search across all project notes with 300ms debounce
- Reading plan: set a book + week goal, mark chapters read from the reader

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-20 10:15:45 -04:00
nmemmertandClaude Sonnet 4.6 837b81b038 Add page-flip reader mode — fixed-height CSS-columns panel
Adds a "Page" toggle in the reader tool strip. When enabled, the verse
panel becomes a fixed-height container using CSS column-width to create
one column per page-width. Content flows naturally into successive pages;
long chapters simply have more pages. Bottom nav shows ← Back/Prev ch.
and Next/Next ch. → with a page counter (e.g. Genesis 1 · 2/3). Swipe
left/right and arrow keys also navigate pages before crossing chapter
boundaries. Preference persisted to localStorage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-13 16:38:25 -04:00
nmemmertandClaude Sonnet 4.6 708f293939 Auto-hide toolbar in wide reader mode with focus bar restore
Wide mode now enters focus state automatically: the full toolbar collapses
and a slim frosted bar appears at the top showing the current chapter name,
Prev/Next buttons, and a ⚙ Tools button to restore the toolbar. A ✕ Hide
button inside the toolbar lets you re-enter focus mode without toggling wide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-13 16:21:08 -04:00
nmemmertandClaude Sonnet 4.6 f2de985190 Add reader navigation: swipe, keyboard, side arrows, and wide layout
- Swipe left/right on mobile to go between chapters (60px threshold,
  ignores vertical-dominant gestures so scrolling still works)
- Left/right arrow keys on desktop navigate chapters (skips inputs)
- Fixed side ‹ › buttons at viewport midpoint for one-tap chapter nav;
  disappear when at the first/last chapter, hidden in draw mode
- Wide toggle (⊞) switches between narrow reading column (max-w-3xl)
  and full-width two-column layout (max-w-7xl, md:columns-2); persisted
  to localStorage so the preference survives page reloads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-13 16:17:41 -04:00
nmemmertandClaude Sonnet 4.6 90fd5c4e9e Add fixed bottom chunk nav and IndexedDB Bible search cache
- Replace non-functional sticky nav with a fixed bottom bar (position: fixed)
  that stays visible while scrolling long study notes; hides in draw mode
- Add pb-20 to main so the bar never covers the last study section
- Cache whole-Bible search index (BSB complete.json, ~7MB) in IndexedDB
  with a 7-day TTL so repeated searches skip the network fetch entirely

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-13 16:08:25 -04:00
nmemmertandClaude Sonnet 4.6 22ad12e677 Add text highlighting in reader — select text to color, visible in read and draw mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-13 15:03:08 -04:00
nmemmertandClaude Sonnet 4.6 b4ccc15f98 Add XS pen size and make it the default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 13:43:19 -04:00
nmemmertandClaude Sonnet 4.6 1a586db785 Sync reader draw ink to server for cross-device persistence
Previously reader ink was localStorage-only so it never followed the
user to a different browser or device.

- New reader_ink table (user_id, book_abbrev, chapter, strokes JSON)
- GET /api/reader/ink  — loads all pages for the signed-in user
- PUT /api/reader/ink/:book/:chapter — upserts one page
- On startup, client fetches server ink and merges it over localStorage
  (server wins so the most-recently saved version always wins)
- updateReaderPageInk debounces a PUT call 1 s after the last stroke,
  so rapid Apple Pencil strokes don't flood the server

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 12:16:21 -04:00
nmemmertandClaude Sonnet 4.6 5668686f59 Fix PWA header buttons unreachable on iPad
black-translucent status bar overlays page content, pushing the top
nav buttons behind the status bar where they can't be tapped. Switch
to black (status bar takes its own space, content starts below).

Also add env(safe-area-inset-top) padding on <header> elements when
running in standalone/PWA mode so notched iPhones are handled too.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 11:46:35 -04:00
nmemmertandClaude Sonnet 4.6 692595c629 Add PWA support and expandable draw canvas
PWA:
- manifest.json with name, icons, theme/background color, standalone display
- PNG icons (192, 512, 180 apple-touch) generated via pure Node.js (no deps)
- index.html: manifest link, theme-color, apple-mobile-web-app-* meta tags,
  viewport-fit=cover for iPhone notch/Dynamic Island
- service worker extended to cache app shell (static assets + index.html)
  with network-first for navigation, cache-first for hashed assets,
  network-first-with-cache-fallback for external Bible APIs

Expandable canvas:
- "Add More Space" button below each draw canvas adds a full notebook page
- Existing ink rescales to preserve visual pixel positions (no stretching)
- Page count embedded as metadata in the strokes array so it survives
  navigation without a separate state key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 11:28:23 -04:00
nmemmertandClaude Sonnet 4.6 a947b128b9 Keep draw mode active when navigating chapters in reader
Previously draw mode was closed whenever the book or chapter changed.
Now it stays open so the user can flip between chapters while drawing;
each chapter has its own canvas (ink is keyed per bookAbbrev_chapter).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 11:17:10 -04:00
nmemmertandClaude Sonnet 4.6 9a99247f8d Fix Apple Pencil double-tap bug: switch to Touch Events API on iOS
iPadOS Pointer Events fire pointercancel immediately after pointerdown
for Apple Pencil strokes (triggered by palm detection or canvas-width
resets from React re-renders), forcing the user to tap twice to start
every stroke. The Touch Events API with touchType === 'stylus' bypasses
this issue entirely — Touch Events have no equivalent cancellation
problem and reliably identify Pencil vs finger input. Pointer Events
remain as the fallback for non-iOS platforms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 10:08:32 -04:00
nmemmertandClaude Sonnet 4.6 717a9b3da0 Switch canvas pointer events to native addEventListener
React's synthetic event system delegates to the document root, which
on iOS Safari causes pointermove passive:true to block preventDefault
and introduces timing issues that drop every other stroke.

Attaching directly to the canvas element with {passive: false} on
pointermove gives iOS the explicit signal it needs to suppress scroll
and deliver all pen events reliably.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 09:23:16 -04:00
nmemmertandClaude Sonnet 4.6 cf040ff8dd Fix alternating stroke drop on iPadOS with Apple Pencil
setPointerCapture/hasPointerCapture is unreliable on iPadOS — Safari
sometimes fires pointercancel which releases capture, causing every other
stroke's pointermove events to be discarded. Replace with a simple
isDrawingRef boolean that is set on pointerdown and cleared on pointerup,
avoiding the capture API entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 09:14:33 -04:00
nmemmertandClaude Sonnet 4.6 0a2def9b60 Prevent text selection during Apple Pencil drawing
Add user-select: none (with WebKit prefix) to the DrawCanvas wrapper so
the browser does not start a text-selection gesture when the pencil drags
across the component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 09:09:02 -04:00
nmemmertandClaude Sonnet 4.6 250d59290d Fix Apple Pencil scrolling during draw by setting touchAction none
touchAction: 'pan-y pinch-zoom' let the browser intercept vertical
pencil movement as a scroll gesture before JavaScript could prevent it.
Setting 'none' hands all pointer input to our handlers exclusively.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 09:07:02 -04:00
nmemmertandClaude Sonnet 4.6 edc5f3dae1 Auto git pull on restart
Discards any local package-lock.json drift (cross-platform regeneration)
then pulls latest from GitHub before setup.sh runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 09:04:28 -04:00
nmemmertandClaude Sonnet 4.6 9013dc4fc4 Expand reader draw mode to full screen width
Use max-w-full on main when draw mode is active so the layout fills the
viewport. Scripture panel is a fixed 288px column; notebook canvas takes
all remaining space.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 09:00:03 -04:00
nmemmertandClaude Sonnet 4.6 42203c038e Reader draw mode: Bible text left, notebook right
Replace stacked scripture-overlay layout with a side-by-side flex row:
the chapter text panel sits on the left (sticky, scrollable) and the
ruled notebook canvas sits on the right. Stacks vertically on mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 08:57:52 -04:00
nmemmertandClaude Sonnet 4.6 31fa4b3f77 Show scripture text with drawable overlay in draw mode
DrawCanvas now accepts a headerContent prop — when provided, a transparent
canvas extends over the scripture text so you can draw directly on the
verses, with ruled notebook space below for additional notes.

Study page draw mode shows the full chunk passage this way instead of a
compact unwritable strip. Reader page draw mode replaces the verse list
with the same combined layout (all chapter verses + notebook canvas),
hiding the verse list while active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 08:55:36 -04:00
nmemmertandClaude Sonnet 4.6 ea44ff142d Add notebook draw mode to study and reader pages
Draw mode in the study page replaces all note panels with a full ruled
notebook canvas (no cross-refs, observations, or text boxes) so the user
has a clean writing surface. Ink strokes are saved per chunk and exported
as SVG in the HTML export.

The reader page gets a Draw toggle button that reveals the same notebook
canvas below the verse list, with strokes persisted per book+chapter in
localStorage via readerInkByPage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 08:45:20 -04:00
nmemmertandClaude Sonnet 4.6 791e809222 Add Draw mode for Apple Pencil annotation on scripture panel
Adds a third layout option alongside Stacked and Split. In Draw mode a
canvas overlay covers the scripture panel; the Pointer Events API routes
Apple Pencil input to perfect-freehand strokes while letting finger
touches fall through for normal scrolling. A floating toolbar provides
pen, highlighter, and eraser tools, six color swatches, S/M/L sizes,
undo, clear, and a Done button to return to Stacked view.

Strokes are stored as inkStrokes[] on each chunk and rendered as a
read-only transparent canvas overlay (InkLayer) in Stacked/Split modes.
migrateChunk is updated so all existing chunks get inkStrokes: [] on
first load.

Also adds a simple in-memory rate limiter on auth endpoints (10 attempts
per 15 min per IP).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 08:29:20 -04:00
nmemmertandClaude Sonnet 4.6 96b6e840a3 Split App.jsx into pages + add service worker for offline support
App.jsx reduced from ~6,500 to ~3,900 lines by extracting each route into its
own page component (AdminPage, AuthPage, HomePage, ImportPage, ReaderPage,
SettingsPage, SetupPage, StudyPage). State and handlers are shared via
AppContext (React Context API); AppRouter handles routing at module level.

Also adds a network-first service worker (public/sw.js) that caches the last-
fetched Bible chapter data from bible.helloao.org and bolls.life so the app
stays usable when those APIs are temporarily unreachable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-12 08:06:48 -04:00
nmemmertandClaude Sonnet 4.6 8e8e814775 Add tabbed layout for study page on mobile to reduce scrolling
On screens below sm (640px), a horizontal tab bar replaces the
stacked-scroll layout: Scripture | Notes | Refs | Words | Commentary | Script.
Each tab shows only one section at a time via max-sm:hidden on the others.
The chunk sidebar and split-view toggle are also hidden on mobile since
Prev/Next navigation in the header is sufficient there.

Desktop stacked and split-view layouts are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 14:40:57 -04:00
nmemmertandClaude Sonnet 4.6 42247b9eae Improve mobile layout: compact header buttons and scrollable admin tables
- Shorten header action button labels on mobile (<sm) so they don't wrap
  into multiple rows: show "Claude", "🎙", "🗣" icons/short text; full
  labels restored on sm+ screens
- Wrap admin Users and Projects tables in overflow-x-auto so they scroll
  horizontally on narrow phones instead of being clipped by the outer
  overflow-hidden container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 14:18:14 -04:00
nmemmertandClaude Sonnet 4.6 085043c991 Fix production restore: add --production flag to restore into /opt/study-app and restart service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 10:26:32 -04:00
nmemmertandClaude Sonnet 4.6 092a81718d Use npm install instead of npm ci to handle cross-platform lock file differences
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 08:23:36 -04:00
nmemmertandClaude Sonnet 4.6 da7cc10851 Auto-upgrade Node.js in install.sh if version is below v18
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 08:19:24 -04:00
nmemmertandClaude Sonnet 4.6 52d157be69 Update install docs and script for Ubuntu (apt/ufw instead of dnf/firewall-cmd)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 08:16:31 -04:00
nmemmertandClaude Sonnet 4.6 f9283de190 Add README with install, development, production, and migration instructions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 08:14:21 -04:00
nmemmertandClaude Sonnet 4.6 6bbe045ea6 Add backup/restore npm scripts to package.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 08:05:42 -04:00
nmemmertandClaude Sonnet 4.6 eb066bc8cc Add backup and restore scripts for migrating user data between PCs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 08:00:49 -04:00
nmemmertandClaude Sonnet 5 19d3a6b822 Add self-service "change my password" option in Account Settings
New section in Settings: current password + new password + confirm,
verified against the existing hash before accepting. On success it
signs out every other session for that account (in case one was
compromised) but keeps the current session logged in, so changing
your password doesn't immediately kick you back to the login screen.

Renamed the underlying db.js function (adminSetPassword ->
setUserPassword) since it's now shared by both this and the existing
admin-assisted reset.

Verified live: old password rejected after change, new password
works, current session stayed logged in throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 14:42:28 -04:00
nmemmertandClaude Sonnet 5 9e57f8088a Add admin-assisted password reset
There's no email infrastructure in this app, so a self-service
"forgot password" flow isn't feasible yet. Adds a "Reset Password"
button per user in the admin panel instead: generates a random
temporary password (shown once, for the admin to relay out-of-band),
overwrites the user's password hash, and signs them out of every
existing session so a stolen session can't outlive the reset.

Verified live: old password rejected after reset, new temporary
password logs in successfully.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 14:31:06 -04:00
nmemmertandClaude Sonnet 5 f853d5e301 Fix login quote to BSB wording, consolidate exports into one dropdown
The 2 Timothy 2:15 quote on the login screen was KJV phrasing ("shew
thyself", "needeth not") in an app that otherwise uses BSB throughout
— replaced with the actual BSB text (fetched from the same
bible.helloao.org API the app already calls) and labeled it as such.

The study page header had four separate export buttons (HTML, DOCX,
Markdown, Print/PDF) crowding the toolbar. Consolidated into a single
"Export" dropdown, same pattern as the existing Share panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 14:01:10 -04:00
nmemmertandClaude Sonnet 5 60f3e4d489 Fix huge dead space on the login screen at wide viewports
The branding panel and sign-in card were two full-width grid columns
stretched edge to edge, so on a wide monitor they ended up far apart
with empty space between them, and the panel's content was pinned to
the very top/bottom of the viewport instead of grouped together.
Wraps both in a shared max-w-5xl container that's centered as a unit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 13:56:56 -04:00
nmemmertandClaude Sonnet 5 5006d0bfbe Add a branding/feature panel next to the sign-in form
The login screen was just a bare card on a dark background. Adds a
feature-highlight panel alongside it (tagline, what the app does, a
scripture quote) on wider screens, using only colors already in the
app's palette (slate-900/300/400, white, sky-500) — no new theme.
Collapses to the original single-card layout on mobile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 13:50:06 -04:00
nmemmertandClaude Sonnet 5 7006fbf544 Add admin panel restricted to a single designated account
Adds a full admin view (users + projects, with view/delete) gated
server-side by ADMIN_EMAIL in server/auth.js (defaults to the site
owner's account, overridable via env var for other deployments). The
gate is enforced on every /api/admin/* route, not just hidden in the
UI — verified a non-admin session gets 403 even when it hits the
endpoints directly. Deleting a user leaves their projects in place
(not cascade-deleted) so admin cleanup can't accidentally destroy
someone's study data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 13:07:54 -04:00
nmemmertandClaude Sonnet 5 37cfcd55a0 Add study templates, exports, breadcrumb, whole-Bible search, bookmarks UX, and share links
- Study templates: richer multi-line guiding questions in the OIA placeholders
- PDF/print export: reuses buildExportHtml in a new tab + window.print()
- Markdown export: new buildMarkdownExport() with matching tests
- Breadcrumb: current chunk's passage reference shown in the Study page header
- Reader bookmarks: SVG icons instead of ambiguous emoji, always visible
  (not hover-only, so it works on touch devices), plus a Bookmarks panel
  that lists all saved verses across every book and jumps + scrolls to them
- Whole-Bible search: no server-side search endpoint exists, so this fetches
  the full translation once (~7MB) and searches an in-memory flat verse
  index client-side, with results linking back into the reader
- Read-only share links: per-project share token, a public unauthenticated
  /api/share/:token endpoint, and a ?share=TOKEN view that bypasses the auth
  gate entirely and renders the export HTML in a script-sandboxed iframe

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 10:12:00 -04:00
nmemmertandClaude Sonnet 5 2735ef216c Generalize podcast-specific UI for study-only users
Other people using this app just want to do personal Bible study, not
produce a podcast episode, so the always-visible chrome (chunk
metadata, DOCX import, exports) now says "Session" instead of
"Episode" and marks podcast-only fields as optional. The "Prepare for
Podcast" prompt no longer hardcodes "Verse by Verse with Nate" for
every user — it now pulls from a new "Podcast / show name" field in
Account Settings, so it still works exactly as before once you set
yours, but produces a sensible generic prompt for anyone who hasn't.
Also added an explanatory blurb to the session-list import page and
loosened its docx parser to accept "Session N — Title" in addition to
"Ep. N — Title".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 09:25:28 -04:00
nmemmertandClaude Sonnet 5 89e701bbda Auto-restore projects when signing in on a new device
Projects that exist on the server but not in this browser's local
cache (e.g. a fresh device) are now pulled down automatically, instead
of requiring a manual "Restore" click per project. Shows a brief
"synced N projects" toast instead. Conflict handling for projects that
exist locally but are stale is unchanged (still a manual "Pull latest"
action), since silently overwriting local edits there would risk data
loss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 09:05:53 -04:00
nmemmertandClaude Sonnet 5 5140958305 Add multi-user accounts with per-user data scoping and 2FA
Projects were previously global to anyone who could reach the server.
Adds email/password accounts with httpOnly cookie sessions, scopes
every project (both SQLite and localStorage) to the signed-in user,
and auto-claims pre-existing unowned projects for whoever registers
first. Also adds optional TOTP two-factor auth with backup codes,
managed from a new Account Settings page, since there's no
password-reset flow to fall back on otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 08:49:33 -04:00
nmemmertandClaude Sonnet 4.6 bf83dc7cc4 Add docx episode-list import to auto-create chunks
Upload a Word doc with an Ep./Title/Passage table (or "Ep. N — Title"
intro paragraphs) and it parses episodes, fetches the needed chapters,
and creates chunks with episode metadata and verse ranges pre-filled,
including cross-chapter spillover and zero-length markers for
passage-less rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 16:38:42 -04:00
nmemmertandClaude Sonnet 4.6 103e20c6b4 Add top Prev/Next chunk navigation and improve speak audio click fix
- Add ‹ Prev / Chunk N of M / Next › buttons at top of chunk editor
  header so you can navigate without scrolling to the bottom
- Fix speak audio click: only cancel() when speech is active, append a
  period for a natural trailing pause, slow rate to 0.85, and chain a
  zero-volume tail utterance so the audio session fades out cleanly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:18:17 -04:00
nmemmertandClaude Sonnet 4.6 7b350d3683 Fix audio click at end of speech synthesis
Append trailing spaces so the audio session stays open briefly before
closing, preventing the hardware click/pop on webkit/macOS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:12:59 -04:00
nmemmertandClaude Sonnet 4.6 01de7281a8 Fix Hebrew word picker showing verbose KJV definition instead of short gloss
Add shortHebrewGloss() helper that strips [idiom]/[phrase] prefixes and
takes the first word before any comma/semicolon/paren, matching the clean
one-word display Greek already had. Full kjv_def is preserved in
shortDefinition; englishGloss and the modal bold label now show one word.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:12:23 -04:00
nmemmertandClaude Sonnet 4.6 145b840bf1 Fix Hebrew word lookup not populating English gloss field
The NT Strong's gloss file only contains G#### keys, so Hebrew lookups
always returned an empty englishGloss. Now falls back to short_definition
(kjv_def from the BDBT API) so Hebrew words match Greek behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:05:35 -04:00
nmemmertandClaude Sonnet 4.6 c30405b42d Enhance Bible reader with interlinear audio, bookmarks, cross-refs, search, and font size
- Add 🔊 Speak buttons on Greek/Hebrew word cards in both the reader interlinear panel and study-side word entries (Web Speech API, he-IL / el-GR)
- Click verse number to expand inline interlinear panel with original script, transliteration, gloss, parsing, and Strong's number
- Verse bookmarking with 5 highlight colors, persisted to localStorage
- 🔗 Cross-Refs toggle fetches open-cross-ref dataset and shows amber reference chips per verse
- 🔍 In-chapter search filters verses live with yellow keyword highlights
- S/M/L/XL font size controls
- 📋 Copy verse button outputs formatted citation (Book Ch:V BSB — text)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:01:03 -04:00
nmemmert 405cf7218b Add chapter audio playback to Bible reader, auto-matching selected book/chapter 2026-06-15 08:32:15 -04:00
nmemmert 1733df7879 Fix missing verse text for poetic OT content (Psalms etc.) 2026-06-15 08:27:18 -04:00
nmemmert ce93b42b83 Merge pull request #16 from nmemmert/claude/bible-reader-feature-qn0pz6 2026-06-11 23:41:09 -04:00
180 changed files with 19923 additions and 4504 deletions

No files matched your search

+14
View File
@@ -7,6 +7,20 @@
"runtimeArgs": ["run", "dev"],
"port": 5173,
"autoPort": false
},
{
"name": "study-app-alt",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "--", "--port", "5174"],
"port": 5174,
"autoPort": false
},
{
"name": "study-app-server",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev:server"],
"port": 3001,
"autoPort": false
}
]
}
+4
View File
@@ -0,0 +1,4 @@
.git
node_modules
dist
*.log
+57
View File
@@ -0,0 +1,57 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
tags:
- "v*"
pull_request:
branches:
- main
env:
REGISTRY: git.necloud.us
IMAGE_NAME: nate/study-bible-project
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea container registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: nate@versebyversewithnate.us
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
+2
View File
@@ -9,3 +9,5 @@ coverage/
.vite.log
.api.pid
.api.log
.DS_Store
.env
+2
View File
@@ -2,6 +2,7 @@
FROM node:20-alpine AS builder
WORKDIR /app
RUN apk add --no-cache python3 make g++ && npm install -g npm@11
COPY package*.json ./
RUN npm ci
@@ -12,6 +13,7 @@ RUN npm run build
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache python3 make g++ && npm install -g npm@11
# Copy package files and install production deps only
COPY package*.json ./
+136
View File
@@ -0,0 +1,136 @@
# Bible Study App
A self-hosted Bible study tool with notes, projects, and user accounts.
---
## Requirements
- **Node.js v18 or higher** — [nodejs.org](https://nodejs.org/)
- **npm** (bundled with Node.js)
- **GCC / make** (only needed for production/server installs — required to build the `better-sqlite3` native module)
---
## Quick Start
```bash
git clone https://github.com/nmemmert/study-bible-project.git
cd study-bible-project
bash setup.sh
```
`setup.sh` installs dependencies, builds the optimized frontend, and runs one
production server (frontend + API) on **http://localhost:5173**. Set `APP_PORT`
to use a different port. If the build fails, the previously running server is
left untouched. `bash restart.sh` pulls the latest code and does the same.
**Stop / logs:**
```bash
kill $(cat .api.pid)
tail -f .api.log
```
### Development mode (hot reload)
```bash
bash setup.sh --dev
```
Starts the API on http://localhost:3001 and the Vite dev server on
http://localhost:5173. Dev mode is much slower, so don't use it for real users.
---
## Production Install (Ubuntu / systemd)
Installs the app as a persistent systemd service under `/opt/study-app`.
```bash
# Install Node.js 20 (if not already installed)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
# Install build tools (required for better-sqlite3)
sudo apt-get install -y build-essential python3
# Clone and install
git clone https://github.com/nmemmert/study-bible-project.git
cd study-bible-project
sudo bash deploy/install.sh
```
The installer will:
1. Install Node.js dependencies and build the frontend
2. Create a `study-app` system user
3. Register and start a systemd service
**Useful commands after install:**
```bash
systemctl status study-app # Check service status
journalctl -u study-app -f # Stream logs
systemctl restart study-app # Restart after an update
```
**Open the firewall port (if needed):**
```bash
sudo ufw allow 3001/tcp
sudo ufw reload
```
The app listens on **port 3001** by default. Set the `PORT` environment variable in the systemd unit to change it.
---
## Updating (Production)
```bash
cd /path/to/study-bible-project
git pull
sudo bash deploy/install.sh
```
The installer preserves the session secret across updates so logged-in users are not signed out.
---
## Migrating to a New Machine
**On the old machine — create a backup:**
```bash
npm run backup
```
This creates `study-app-backup_<timestamp>.zip` in the project root.
**On the new machine — restore after cloning and installing:**
Development:
```bash
npm run restore study-app-backup_<timestamp>.zip
```
Production (after `sudo bash deploy/install.sh` completes):
```bash
sudo bash scripts/restore-data.sh study-app-backup_<timestamp>.zip --production
```
See [scripts/backup-data.sh](scripts/backup-data.sh) and [scripts/restore-data.sh](scripts/restore-data.sh) for details.
---
## Project Structure
```
├── server/ # Express API + SQLite database
│ └── data/ # projects.db lives here (gitignored)
├── src/ # React frontend (Vite)
├── deploy/ # systemd service unit + install script
├── scripts/ # backup / restore helpers
└── setup.sh # Development quick-start
```
---
## License
Private / personal use.
-95
View File
@@ -1,95 +0,0 @@
# Study App Improvement Suggestions
## Features
### Study Tools
- **Bible comparison mode** — show two translations side-by-side (API already supports it)
- **Verse-level notes** — annotate individual verses, not just chunks
- **Tagging / themes** — tag chunks with themes (e.g. "faith", "grace"), filter/search across projects
- **Progress tracking** — mark chunks as "in progress" / "complete"; show progress bar on home card
- **Study templates** — pre-fill OIA fields with guiding prompts for new users
- **Print view** — clean print-optimized CSS layout
- **Old Testament support** — only NT books listed; HelloAO API supports OT. Greek suggest is NT-only but the rest could support OT with Hebrew lookup
- **Verse search** — search bar to find a verse by keyword across the loaded chapter
### Export / Sharing
- **PDF export** — "Export PDF" button using `jsPDF` or `window.print()`
- **Share link** — read-only shareable URL pointing to a project ID on the server
- **Copy individual chunk** — "copy this chunk's notes" button alongside full "Prepare for Claude"
- **Markdown export** — useful for Obsidian and similar note-taking apps
---
## UX / UI
### Navigation
- **Keyboard shortcuts** — `←`/`→` to navigate chunks; `Ctrl+S` to save; `Escape` to close modals
- **"Jump to chunk" dropdown** — for projects with many chunks, a select menu is faster than scrolling
- **Breadcrumb in header** — show `Book Chapter:Verse range` so users always know where they are
### Chunk Builder (Setup Page)
- **Drag-to-select verses** — click-and-drag instead of click then shift-click
- **Auto-chunk** — button to split chapter into chunks by paragraph/section breaks
- **Visual overlap indicator** — already-chunked verses are shaded but there's no tooltip explaining why you can't select them
### Study Page
- **Collapsible sections** — collapse OIA, Cross-References, and Greek Word Studies independently
- **Word/character count** on each textarea to encourage note depth
- **Inline verse reference popup** — hover popover on cross-references showing verse text (from HelloAO)
- **Sticky chunk navigation** — Previous/Next chunk buttons should be sticky, not only at the bottom
### Home Page
- **Search/filter projects** — text filter on the project list
- **Sort options** — sort by name, date, or passage
- **Project rename** — title is only set at creation; allow renaming from home card
- **Last opened chunk** — resume directly to the study page, not the setup page
---
## Code Architecture
### State Management
- **`App.jsx` is ~2,250 lines** — biggest maintainability issue. Split into:
- `pages/HomePage.jsx`
- `pages/SetupPage.jsx`
- `pages/StudyPage.jsx`
- `components/ChunkEditor.jsx`
- `components/GreekWordStudy.jsx`
- `components/SuggestModal.jsx`
- **Custom hooks** — extract logic into `useProject()`, `useGreekLookup()`, `useAutosave()`
### Sync / Persistence
- **No auth** — the server has zero authentication. Any user who can reach the server can read/overwrite/delete any project. Add at minimum an API key (env var in middleware) or user accounts
- **Conflict resolution is basic** — only compares `lastEdited` timestamps. Add a "which version do you want to keep?" UI to prevent silent data loss
- **Offline-first** — use a service worker / `workbox` so the app works offline and syncs when back online
### Security (OWASP)
- **XSS via `dangerouslySetInnerHTML`** — `word.definitionHtml` is rendered raw. Add DOMPurify sanitization
- **No input validation on server** — add max-length and character validation on `id`/`title` fields (SQL injection is prevented by parameterized queries, but still)
- **CORS** — server has no CORS headers; any origin can call the API in production
---
## Performance
- **Verse data stored in project JSON** — full verse text is saved in localStorage and SQLite for every chapter. For multi-chapter projects this grows large. Consider storing only the chapter reference and re-fetching verses on load
- **JSON files cached in refs** — `nt-strongs-gloss.json` and `nt-strongs-concordance.json` should be served with proper `Cache-Control` headers
- **Autosave fires on all state changes** — the `[project]` dependency is too broad; it fires even when just selecting a chunk. Debounce only on content field changes
---
## Testing
- Add tests for:
- `migrateProject` with the old flat format
- `buildClaudePrompt` output structure
- `parseBibleChapter` with both API response shapes
- Autosave debounce behavior
---
## Developer Experience
- **No ESLint config** — add ESLint with `eslint-plugin-react` and `eslint-plugin-react-hooks` to catch missing `useEffect` deps
- **No TypeScript** — JSDoc types or a TS migration would catch shape mismatches between old/new project formats at compile time
- **No `docker-compose.yml`** — Dockerfile exists but there's no compose file for one-command local dev with server + SQLite volume
+22 -17
View File
@@ -19,26 +19,23 @@ SERVICE_NAME="study-app"
echo "=== Installing Bible Study App to $INSTALL_DIR ==="
# ── Node check ────────────────────────────────────────────────────────────────
if ! command -v node >/dev/null 2>&1; then
echo "ERROR: Node.js is not installed."
echo "On Rocky Linux, install via NodeSource, e.g.:"
echo " curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -"
echo " sudo dnf install -y nodejs"
exit 1
# ── Node check / auto-upgrade ─────────────────────────────────────────────────
NODE_VERSION=0
if command -v node >/dev/null 2>&1; then
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
fi
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
if [ "$NODE_VERSION" -lt 18 ]; then
echo "ERROR: Node.js v18+ required (found $(node -v))."
exit 1
echo "Node.js v18+ required (found v${NODE_VERSION}). Installing Node.js 20 via NodeSource..."
apt-get install -y curl ca-certificates
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
fi
echo "Node.js $(node -v) found."
# build tools needed for better-sqlite3 native module
if ! command -v gcc >/dev/null 2>&1 || ! command -v make >/dev/null 2>&1; then
echo "Installing build tools (Development Tools group + python3)..."
dnf groupinstall -y "Development Tools"
dnf install -y python3
echo "Installing build tools (build-essential + python3)..."
apt-get install -y build-essential python3
fi
# ── Create service user ─────────────────────────────────────────────────────
@@ -62,7 +59,7 @@ cd "$INSTALL_DIR"
# ── Install dependencies & build ─────────────────────────────────────────────
echo "Installing dependencies (this can take a while for better-sqlite3)..."
npm ci || npm install
npm install
echo "Building production frontend..."
npx vite build
@@ -75,8 +72,16 @@ chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
# ── systemd unit ──────────────────────────────────────────────────────────────
echo "Installing systemd unit..."
sed "s#/opt/study-app#$INSTALL_DIR#g; s#User=study-app#User=$SERVICE_USER#; s#Group=study-app#Group=$SERVICE_USER#" \
"$ROOT_DIR/deploy/study-app.service" > "/etc/systemd/system/${SERVICE_NAME}.service"
EXISTING_UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
# Reuse the existing session secret across re-installs (upgrades) so signed-in
# users aren't logged out; only generate a new one on first install.
if [ -f "$EXISTING_UNIT" ] && grep -q '^Environment=SESSION_SECRET=' "$EXISTING_UNIT"; then
SESSION_SECRET="$(grep '^Environment=SESSION_SECRET=' "$EXISTING_UNIT" | head -1 | cut -d= -f3-)"
else
SESSION_SECRET="$(openssl rand -hex 32)"
fi
sed "s#/opt/study-app#$INSTALL_DIR#g; s#User=study-app#User=$SERVICE_USER#; s#Group=study-app#Group=$SERVICE_USER#; s#__SESSION_SECRET__#$SESSION_SECRET#" \
"$ROOT_DIR/deploy/study-app.service" > "$EXISTING_UNIT"
systemctl daemon-reload
systemctl enable "$SERVICE_NAME"
@@ -89,4 +94,4 @@ echo "Logs: journalctl -u $SERVICE_NAME -f"
echo "App listens on: http://0.0.0.0:\${PORT:-3001}"
echo ""
echo "If you have a firewall enabled, allow the port, e.g.:"
echo " sudo firewall-cmd --add-port=3001/tcp --permanent && sudo firewall-cmd --reload"
echo " sudo ufw allow 3001/tcp && sudo ufw reload"
+3
View File
@@ -10,6 +10,9 @@ Group=study-app
WorkingDirectory=/opt/study-app
Environment=NODE_ENV=production
Environment=PORT=3001
# Replaced with a generated value by install.sh — keep this secret and stable
# across deploys, or every existing login session gets invalidated.
Environment=SESSION_SECRET=__SESSION_SECRET__
ExecStart=/usr/bin/node server/index.js
Restart=on-failure
RestartSec=5
+16 -1
View File
@@ -4,8 +4,23 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bible Study Project</title>
<!-- PWA manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Theme / status bar -->
<meta name="theme-color" content="#f1ece0" />
<!-- iOS PWA -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Bible Study" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- Fallback icon for browsers -->
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
</head>
<body class="bg-slate-50 text-slate-900">
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
+476
View File
@@ -8,12 +8,18 @@
"name": "bible-study-app",
"version": "0.0.1",
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"compression": "^1.8.2",
"concurrently": "^8.2.2",
"docx": "^9.7.0",
"dompurify": "^3.4.9",
"express": "^4.19.2",
"express-session": "^1.19.0",
"mammoth": "^1.12.0",
"otplib": "^12.0.1",
"perfect-freehand": "^1.2.3",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
@@ -1107,6 +1113,56 @@
"node": ">= 8"
}
},
"node_modules/@otplib/core": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
"license": "MIT"
},
"node_modules/@otplib/plugin-crypto": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1"
}
},
"node_modules/@otplib/plugin-thirty-two": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"thirty-two": "^1.0.2"
}
},
"node_modules/@otplib/preset-default": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@otplib/preset-v11": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/plugin-crypto": "^12.0.1",
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.132.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
@@ -2325,6 +2381,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/better-sqlite3": {
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
@@ -2550,6 +2615,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -2708,6 +2782,85 @@
"node": ">= 6"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"destroy": "1.2.0",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression/node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
@@ -2908,6 +3061,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -2984,6 +3146,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
@@ -3297,6 +3465,64 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-session": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
"license": "MIT",
"dependencies": {
"cookie": "~0.7.2",
"cookie-signature": "~1.0.7",
"debug": "~2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.1.0",
"parseurl": "~1.3.3",
"safe-buffer": "~5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express-session/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express-session/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/express-session/node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -3424,6 +3650,19 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -4256,6 +4495,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -4663,6 +4914,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -4678,6 +4938,53 @@
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/otplib": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
"license": "MIT",
"dependencies": {
"@otplib/core": "^12.0.1",
"@otplib/preset-default": "^12.0.1",
"@otplib/preset-v11": "^12.0.1"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
@@ -4706,6 +5013,15 @@
"node": ">= 0.8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -4735,6 +5051,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/perfect-freehand": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.3.tgz",
"integrity": "sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -4775,6 +5097,15 @@
"node": ">= 6"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -5020,6 +5351,104 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@@ -5056,6 +5485,15 @@
],
"license": "MIT"
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -5205,6 +5643,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -5461,6 +5905,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
@@ -5868,6 +6318,14 @@
"node": ">=0.8"
}
},
"node_modules/thirty-two": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
"engines": {
"node": ">=0.2.6"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -6058,6 +6516,18 @@
"node": ">= 0.6"
}
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"license": "MIT",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
@@ -6460,6 +6930,12 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+9 -1
View File
@@ -12,15 +12,23 @@
"start": "NODE_ENV=production node server/index.js",
"test": "vitest",
"test:run": "vitest run",
"coverage": "vitest run --coverage"
"coverage": "vitest run --coverage",
"backup": "bash scripts/backup-data.sh",
"restore": "bash scripts/restore-data.sh"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"compression": "^1.8.2",
"concurrently": "^8.2.2",
"docx": "^9.7.0",
"dompurify": "^3.4.9",
"express": "^4.19.2",
"express-session": "^1.19.0",
"mammoth": "^1.12.0",
"otplib": "^12.0.1",
"perfect-freehand": "^1.2.3",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Bible Study Project">
<defs>
<radialGradient id="glow" cx="50%" cy="34%" r="45%">
<stop offset="0" stop-color="#fbbf24" stop-opacity=".35"/>
<stop offset="1" stop-color="#fbbf24" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="512" height="512" rx="112" fill="#0f172a"/>
<rect width="512" height="512" rx="112" fill="url(#glow)"/>
<!-- cross -->
<rect x="238" y="82" width="36" height="170" rx="6" fill="#fbbf24"/>
<rect x="200" y="122" width="112" height="34" rx="6" fill="#fbbf24"/>
<!-- open book -->
<path d="M256 392 C206 356 146 350 80 366 L80 262 C146 246 206 252 256 286 Z" fill="#f8fafc"/>
<path d="M256 392 C306 356 366 350 432 366 L432 262 C366 246 306 252 256 286 Z" fill="#e2e8f0"/>
<path d="M60 384 C136 366 206 374 256 414 C306 374 376 366 452 384" fill="none" stroke="#fbbf24" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M256 286 V392" stroke="#0f172a" stroke-width="6" opacity=".35"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+31
View File
@@ -0,0 +1,31 @@
{
"name": "Bible Study Project",
"short_name": "Bible Study",
"description": "Your personal verse-by-verse Bible study companion",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#faf7f0",
"theme_color": "#f1ece0",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
Drop a cover photo here for each built-in reading plan, named exactly:
bible-year.jpg Whole Bible in a Year
bible-90.jpg Whole Bible in 90 Days
nt-90.jpg New Testament in 90 Days
nt-30.jpg New Testament in 30 Days
gospels-30.jpg The Gospels in 30 Days
psalms-30.jpg Psalms in 30 Days
proverbs-31.jpg Proverbs in 31 Days
pentateuch-60.jpg The Pentateuch in 60 Days
wisdom-40.jpg Wisdom Literature in 40 Days
Landscape orientation, ~1200px wide, works best (cards crop to a 16:9 box).
Any plan without a matching file just shows its colorful gradient + icon —
nothing breaks if some are missing.
See src/utils/readingPlans.js -> PIXABAY_SEARCH_LINKS for a Pixabay search
link per plan to help pick one.
Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

+97
View File
@@ -0,0 +1,97 @@
const SHELL = 'bible-shell-v2';
const API = 'bible-api-v1';
// Chapter text, interlinear cross-refs, and (BSB) chapter audio all come from
// these origins — cache them so the reader, reading plans, and audio all keep
// working offline once you've visited a passage before.
const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life', 'https://audio.bible.helloao.org'];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(SHELL)
.then(c => c.addAll(['/', '/index.html']))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then(keys => Promise.all(
keys.filter(k => k !== SHELL && k !== API).map(k => caches.delete(k))
))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
const { request } = e;
const url = request.url;
// Never intercept this app's own API routes — let Express handle them. (Only same-origin ones: the
// Bible text at bible.helloao.org/api/... must pass through to the offline cache below.)
const { origin, pathname } = new URL(url);
if (origin === self.location.origin && pathname.startsWith('/api/')) return;
// External Bible API (text, cross-refs, audio): network-first, fall back to cache.
// Audio elements often issue Range requests, which come back as 206 Partial
// Content — the Cache API throws on those, so only cache full (200) responses.
if (BIBLE_ORIGINS.some(o => url.startsWith(o))) {
e.respondWith(
fetch(request)
.then(res => {
// Clone *now*: once the page starts reading the body a later clone() throws and nothing is saved.
if (res.status === 200) {
const copy = res.clone();
e.waitUntil(caches.open(API).then(c => c.put(request, copy)).catch(() => {}));
}
return res;
})
.catch(() =>
caches.open(API).then(c => c.match(request)).then(
hit => hit ?? new Response('{"error":"offline"}', {
status: 503, headers: { 'Content-Type': 'application/json' },
})
)
)
);
return;
}
// App-shell navigation: network-first, fall back to cached index.html
if (request.mode === 'navigate') {
e.respondWith(
fetch(request)
.then(res => {
if (res.ok) {
const copy = res.clone();
e.waitUntil(caches.open(SHELL).then(c => c.put(request, copy)).catch(() => {}));
}
return res;
})
.catch(() =>
caches.open(SHELL).then(c =>
c.match('/index.html').then(hit => hit ?? c.match('/'))
)
)
);
return;
}
// Same-origin static assets (hashed JS/CSS/images): cache-first
if (url.startsWith(self.location.origin)) {
e.respondWith(
caches.open(SHELL).then(c =>
c.match(request).then(hit => {
if (hit) return hit;
return fetch(request).then(res => {
if (res.ok) {
const copy = res.clone();
e.waitUntil(c.put(request, copy).catch(() => {}));
}
return res;
});
})
)
);
}
});
+8 -19
View File
@@ -3,23 +3,12 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
stop_pid_file() {
local pid_file="$1"
local label="$2"
if [ -f "$pid_file" ]; then
local old_pid
old_pid=$(cat "$pid_file")
if kill -0 "$old_pid" 2>/dev/null; then
echo "Stopping $label (PID $old_pid)..."
kill "$old_pid"
sleep 1
fi
rm -f "$pid_file"
fi
}
echo "Pulling latest changes from Gitea..."
cd "$ROOT_DIR"
git checkout -- package-lock.json 2>/dev/null || true
git pull
stop_pid_file "$ROOT_DIR/.api.pid" "API server"
stop_pid_file "$ROOT_DIR/.vite.pid" "Vite server"
echo "Restarting via setup.sh..."
exec "$ROOT_DIR/setup.sh"
# setup.sh builds first and only then stops the old server, so the site
# stays up during the build (and if the build fails). Pass --dev through.
echo "Rebuilding and restarting via setup.sh..."
exec "$ROOT_DIR/setup.sh" "$@"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# Backs up user data to a timestamped zip file you can copy to your new PC.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="$(dirname "$SCRIPT_DIR")"
DB_FILE="$APP_DIR/server/data/projects.db"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
BACKUP_FILE="$APP_DIR/study-app-backup_$TIMESTAMP.zip"
if [ ! -f "$DB_FILE" ]; then
echo "ERROR: Database not found at $DB_FILE"
exit 1
fi
zip -j "$BACKUP_FILE" "$DB_FILE"
echo ""
echo "Backup created: $BACKUP_FILE"
echo ""
echo "Copy this file to your new PC, then run:"
echo " scripts/restore-data.sh <path-to-backup-file>"
+115
View File
@@ -0,0 +1,115 @@
// Generates PWA icon PNGs using only Node.js built-ins (no dependencies).
import { deflateSync } from 'zlib';
import { writeFileSync, mkdirSync } from 'fs';
function crc32(buf) {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
table[i] = c;
}
let crc = 0xffffffff;
for (const b of buf) crc = table[(crc ^ b) & 0xff] ^ (crc >>> 8);
return ((crc ^ 0xffffffff) >>> 0);
}
function u32(n) {
const b = Buffer.alloc(4);
b.writeUInt32BE(n, 0);
return b;
}
function chunk(type, data) {
const t = Buffer.from(type, 'ascii');
return Buffer.concat([u32(data.length), t, data, u32(crc32(Buffer.concat([t, data])))]);
}
function makePNG(size, draw) {
const px = new Uint8ClampedArray(size * size * 4); // RGBA
draw(px, size);
const rows = [];
for (let y = 0; y < size; y++) {
rows.push(0); // PNG filter byte: None
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
rows.push(px[i], px[i + 1], px[i + 2], px[i + 3]);
}
}
const raw = Buffer.from(rows);
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = chunk('IHDR', Buffer.concat([u32(size), u32(size), Buffer.from([8, 6, 0, 0, 0])]));
const idat = chunk('IDAT', deflateSync(raw, { level: 9 }));
const iend = chunk('IEND', Buffer.alloc(0));
return Buffer.concat([sig, ihdr, idat, iend]);
}
function setPixel(px, size, x, y, r, g, b, a = 255) {
if (x < 0 || x >= size || y < 0 || y >= size) return;
const i = (y * size + x) * 4;
px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a;
}
function fillRect(px, size, x1, y1, x2, y2, r, g, b, a = 255) {
for (let y = Math.max(0, y1); y < Math.min(size, y2); y++)
for (let x = Math.max(0, x1); x < Math.min(size, x2); x++)
setPixel(px, size, x, y, r, g, b, a);
}
function drawIcon(px, S) {
const rad = Math.round(S * 0.22); // corner radius
// Rounded background: #0f172a (15, 23, 42)
for (let y = 0; y < S; y++) {
for (let x = 0; x < S; x++) {
const cx = Math.min(x, S - 1 - x);
const cy = Math.min(y, S - 1 - y);
if (cx < rad && cy < rad) {
const dx = rad - cx - 1;
const dy = rad - cy - 1;
if (dx * dx + dy * dy > rad * rad) { setPixel(px, S, x, y, 0, 0, 0, 0); continue; }
}
setPixel(px, S, x, y, 15, 23, 42);
}
}
// Cross: vertical bar (center-ish, top-of-cross higher than center)
const cw = Math.round(S * 0.12); // cross bar thickness
const cx = Math.round(S / 2 - cw / 2);
const vTop = Math.round(S * 0.18);
const vBot = Math.round(S * 0.82);
fillRect(px, S, cx, vTop, cx + cw, vBot, 255, 255, 255);
// Horizontal bar (slightly above center)
const hh = Math.round(S * 0.12);
const hy = Math.round(S * 0.36 - hh / 2);
const hLeft = Math.round(S * 0.22);
const hRight = Math.round(S * 0.78);
fillRect(px, S, hLeft, hy, hRight, hy + hh, 255, 255, 255);
// Subtle glow/shine at cross intersection (slightly lighter center)
const glowR = Math.round(S * 0.07);
const gcx = Math.round(S / 2);
const gcy = Math.round(S * 0.36);
for (let y = gcy - glowR; y <= gcy + glowR; y++) {
for (let x = gcx - glowR; x <= gcx + glowR; x++) {
const d2 = (x - gcx) ** 2 + (y - gcy) ** 2;
if (d2 <= glowR * glowR) {
const i = (Math.max(0, Math.min(S - 1, y)) * S + Math.max(0, Math.min(S - 1, x))) * 4;
if (px[i + 3] === 255) {
px[i] = Math.min(255, px[i] + 20);
px[i + 1] = Math.min(255, px[i + 1] + 20);
px[i + 2] = Math.min(255, px[i + 2] + 20);
}
}
}
}
}
mkdirSync('public', { recursive: true });
writeFileSync('public/icon-192.png', makePNG(192, drawIcon));
writeFileSync('public/icon-512.png', makePNG(512, drawIcon));
writeFileSync('public/apple-touch-icon.png', makePNG(180, drawIcon));
console.log('Icons written to public/');
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# Restores user data from a backup zip created by backup-data.sh.
#
# Development usage:
# npm run restore <path-to-backup-file>
#
# Production usage (restores into the running service directory):
# sudo bash scripts/restore-data.sh <path-to-backup-file> --production
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="$(dirname "$SCRIPT_DIR")"
BACKUP_FILE="$1"
MODE="${2:-}"
if [ -z "$BACKUP_FILE" ]; then
echo "Usage: scripts/restore-data.sh <path-to-backup-file> [--production]"
exit 1
fi
if [ ! -f "$BACKUP_FILE" ]; then
echo "ERROR: Backup file not found: $BACKUP_FILE"
exit 1
fi
if [ "$MODE" = "--production" ]; then
DATA_DIR="/opt/study-app/server/data"
SERVICE_USER="study-app"
if [ "$EUID" -ne 0 ]; then
echo "ERROR: Production restore must be run as root (sudo)."
exit 1
fi
else
DATA_DIR="$APP_DIR/server/data"
SERVICE_USER=""
fi
mkdir -p "$DATA_DIR"
# Warn if a database already exists
if [ -f "$DATA_DIR/projects.db" ]; then
echo "WARNING: An existing database was found at $DATA_DIR/projects.db"
read -p "Overwrite it? (y/N): " CONFIRM
if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then
echo "Restore cancelled."
exit 0
fi
cp "$DATA_DIR/projects.db" "$DATA_DIR/projects.db.bak"
echo "Old database backed up to projects.db.bak"
fi
unzip -o "$BACKUP_FILE" -d "$DATA_DIR"
if [ -n "$SERVICE_USER" ]; then
chown "$SERVICE_USER":"$SERVICE_USER" "$DATA_DIR/projects.db"
echo "Restarting study-app service..."
systemctl restart study-app
fi
echo ""
echo "Restore complete. Database is at: $DATA_DIR/projects.db"
if [ "$MODE" = "--production" ]; then
echo "Check service status: systemctl status study-app"
else
echo "You can now start the app normally."
fi
+94
View File
@@ -0,0 +1,94 @@
import bcrypt from 'bcryptjs';
import { authenticator } from 'otplib';
import { randomBytes } from 'crypto';
import { getUserById } from './db.js';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// The single admin account for this deployment. Override via env var if you
// redeploy this app for someone else — don't hardcode your own email into a
// fork without changing this.
const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || 'nmemmert@duck.com').toLowerCase();
export function isValidEmail(email) {
return typeof email === 'string' && email.length <= 254 && EMAIL_RE.test(email);
}
export function isValidPassword(password) {
return typeof password === 'string' && password.length >= 8 && password.length <= 200;
}
export function hashPassword(password) {
return bcrypt.hash(password, 10);
}
export function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
/** Blocks the request unless a logged-in session is present. */
export function requireAuth(req, res, next) {
if (!req.session?.userId) {
return res.status(401).json({ error: 'Not signed in.' });
}
next();
}
export function isAdminEmail(email) {
return typeof email === 'string' && email.toLowerCase() === ADMIN_EMAIL;
}
/** Blocks the request unless the signed-in account is the designated admin. */
export function requireAdmin(req, res, next) {
const user = req.session?.userId ? getUserById(req.session.userId) : null;
if (!user || !isAdminEmail(user.email)) {
return res.status(403).json({ error: 'Admin access only.' });
}
next();
}
// ---------------------------------------------------------------------------
// Two-factor auth (TOTP, RFC 6238 — compatible with any authenticator app)
// ---------------------------------------------------------------------------
export function generateTotpSecret() {
return authenticator.generateSecret();
}
export function totpKeyUri(email, secret) {
return authenticator.keyuri(email, 'Bible Study Project', secret);
}
export function verifyTotpToken(token, secret) {
if (typeof token !== 'string' || !/^\d{6}$/.test(token)) return false;
try {
return authenticator.verify({ token, secret });
} catch {
return false;
}
}
/** Returns { codes: string[] } plaintext codes to show the user once, for hashBackupCodes(). */
export function generateBackupCodes(count = 8) {
return Array.from({ length: count }, () => randomBytes(5).toString('hex'));
}
export async function hashBackupCodes(codes) {
return Promise.all(codes.map((code) => bcrypt.hash(code, 10)));
}
/** Checks a submitted backup code against stored hashes; returns the remaining hashes if it matched, else null. */
export async function consumeBackupCode(submitted, hashes) {
if (typeof submitted !== 'string' || !Array.isArray(hashes)) return null;
const normalized = submitted.trim().toLowerCase();
for (let i = 0; i < hashes.length; i++) {
if (await bcrypt.compare(normalized, hashes[i])) {
return [...hashes.slice(0, i), ...hashes.slice(i + 1)];
}
}
return null;
}
/** A random temporary password for admin-assisted resets — shown once, relayed to the user out-of-band. */
export function generateTemporaryPassword() {
return randomBytes(6).toString('hex');
}
+206
View File
@@ -0,0 +1,206 @@
// Proxy for licensed translations (NLT, CSB, NASB) served by api.bible.
// The API key stays on the server (API_BIBLE_KEY, loaded from .env if present);
// the browser only ever talks to /api/bible/...
import { existsSync, readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Minimal .env loader so the key doesn't have to be exported in every launcher
// (setup.sh, systemd, docker). Real environment variables win.
const envPath = join(__dirname, '..', '.env');
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
}
}
const API_BASE = 'https://rest.api.bible/v1';
// Canonical book order (index + 1 = the book number the ESV API uses in ranges).
const BOOK_ORDER = ['GEN','EXO','LEV','NUM','DEU','JOS','JDG','RUT','1SA','2SA','1KI','2KI','1CH','2CH','EZR','NEH','EST','JOB','PSA','PRO','ECC','SNG','ISA','JER','LAM','EZK','DAN','HOS','JOL','AMO','OBA','JON','MIC','NAM','HAB','ZEP','HAG','ZEC','MAL','MAT','MRK','LUK','JHN','ACT','ROM','1CO','2CO','GAL','EPH','PHP','COL','1TH','2TH','1TI','2TI','TIT','PHM','HEB','JAS','1PE','2PE','1JN','2JN','3JN','JUD','REV'];
// ESV search returns "Psalm 23:1"-style references; map the names back to abbreviations.
const ESV_NAMES = ['Genesis','Exodus','Leviticus','Numbers','Deuteronomy','Joshua','Judges','Ruth','1 Samuel','2 Samuel','1 Kings','2 Kings','1 Chronicles','2 Chronicles','Ezra','Nehemiah','Esther','Job','Psalm','Proverbs','Ecclesiastes','Song of Solomon','Isaiah','Jeremiah','Lamentations','Ezekiel','Daniel','Hosea','Joel','Amos','Obadiah','Jonah','Micah','Nahum','Habakkuk','Zephaniah','Haggai','Zechariah','Malachi','Matthew','Mark','Luke','John','Acts','Romans','1 Corinthians','2 Corinthians','Galatians','Ephesians','Philippians','Colossians','1 Thessalonians','2 Thessalonians','1 Timothy','2 Timothy','Titus','Philemon','Hebrews','James','1 Peter','2 Peter','1 John','2 John','3 John','Jude','Revelation'];
const ABBREV_BY_NAME = new Map(ESV_NAMES.map((n, i) => [n.toLowerCase(), BOOK_ORDER[i]]));
ABBREV_BY_NAME.set('psalms', 'PSA');
ABBREV_BY_NAME.set('song of songs', 'SNG');
export function parseEsvReference(ref) {
const m = String(ref).match(/^(.+?)\s+(\d+):(\d+)/);
const bookAbbrev = m && ABBREV_BY_NAME.get(m[1].toLowerCase());
return bookAbbrev ? { bookAbbrev, chapter: Number(m[2]), verse: Number(m[3]) } : null;
}
export const LICENSED_TRANSLATIONS = {
ESV: { provider: 'esv', name: 'English Standard Version' },
NLT: { provider: 'api.bible', bibleId: 'd6e14a625393b4da-01', name: 'New Living Translation' },
CSB: { provider: 'api.bible', bibleId: 'a556c5305ee15c3f-01', name: 'Christian Standard Bible' },
NASB: { provider: 'api.bible', bibleId: 'b8ee27bcd1cae43a-01', name: 'New American Standard Bible (1995)' },
};
const CACHE_TTL = 24 * 60 * 60 * 1000;
const CACHE_MAX = 1000;
// Crossway limits how much ESV text an app may store, so ESV chapters are only
// held briefly and in small numbers (enough to absorb repeat loads).
const ESV_CACHE_TTL = 60 * 60 * 1000;
const ESV_CACHE_MAX = 20;
const cache = new Map(); // "NLT/PSA/23" -> { at, payload }
// api.bible returns plain text with inline "[12]" verse markers, poetry line
// breaks, and stray "#" / nbsp characters. Split it back into { number, text }.
export function parseChapterText(content) {
const clean = String(content ?? '')
.replace(/ /g, ' ')
.replace(/#/g, '');
const parts = clean.split(/\[(\d+)\]/);
const verses = [];
for (let i = 1; i < parts.length; i += 2) {
const text = parts[i + 1].replace(/\s+/g, ' ').trim().replace(/\s+,$/, '');
if (text) verses.push({ number: Number(parts[i]), text });
}
return verses;
}
export function registerBibleRoutes(app, requireAuth) {
app.get('/api/bible/translations', requireAuth, (_req, res) => {
res.json(Object.entries(LICENSED_TRANSLATIONS).map(([id, t]) => ({
id, name: t.name, available: Boolean(t.provider === 'esv' ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY),
})));
});
// Whole-Bible search for the licensed translations, proxied so the keys stay
// server-side. Results are { bookAbbrev, chapter, verse, text }; nothing is cached.
const SEARCH_PAGE_SIZE = 25;
app.get('/api/bible/:translation/search', requireAuth, async (req, res) => {
const id = req.params.translation.toUpperCase();
const translation = LICENSED_TRANSLATIONS[id];
const q = String(req.query.q ?? '').trim();
const page = Math.max(1, Math.min(200, parseInt(req.query.page, 10) || 1));
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
if (q.length < 2 || q.length > 100) return res.status(400).json({ error: 'Search must be 2 to 100 characters.' });
const isEsv = translation.provider === 'esv';
const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY;
if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` });
try {
let results;
let total;
if (isEsv) {
const qs = new URLSearchParams({ q, 'page-size': String(SEARCH_PAGE_SIZE), page: String(page) });
const upstream = await fetch(`https://api.esv.org/v3/passage/search/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } });
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
const body = await upstream.json();
total = body.total_results ?? 0;
results = (body.results ?? []).map((r) => {
const ref = parseEsvReference(r.reference);
return ref && { ...ref, text: String(r.content ?? '').replace(/<[^>]+>/g, '').trim() };
}).filter(Boolean);
} else {
const qs = new URLSearchParams({ query: q, limit: String(SEARCH_PAGE_SIZE), offset: String((page - 1) * SEARCH_PAGE_SIZE), sort: 'canonical' });
const upstream = await fetch(`${API_BASE}/bibles/${translation.bibleId}/search?${qs}`, { headers: { 'api-key': apiKey } });
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
const body = await upstream.json();
total = body.data?.total ?? 0;
results = (body.data?.verses ?? []).map((v) => ({
bookAbbrev: v.bookId,
chapter: Number(String(v.chapterId).split('.')[1]),
verse: Number(String(v.id).split('.')[2]),
text: String(v.text ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(),
})).filter((r) => r.bookAbbrev && r.chapter && r.verse);
}
res.json({ translation: id, query: q, page, total, hasMore: page * SEARCH_PAGE_SIZE < total, results });
} catch {
res.status(502).json({ error: 'Could not reach the Bible provider.' });
}
});
// ESV chapter audio (read by David Cochran Heath). The ESV API answers with a
// redirect to a public mp3; hand that URL to the browser's <audio> element.
app.get('/api/bible/ESV/audio/:book/:chapter', requireAuth, async (req, res) => {
const { book, chapter } = req.params;
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
if (!bookNum || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' });
if (!process.env.ESV_API_KEY) return res.status(503).json({ error: 'ESV_API_KEY is not configured on the server.' });
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
try {
const upstream = await fetch(`https://api.esv.org/v3/passage/audio/?q=${prefix}001-${prefix}999`, {
headers: { Authorization: `Token ${process.env.ESV_API_KEY}` }, redirect: 'manual',
});
const url = upstream.headers.get('location');
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
if (!url) return res.status(502).json({ error: 'ESV audio is not available for this chapter.' });
res.json({ url });
} catch {
res.status(502).json({ error: 'Could not reach the ESV audio service.' });
}
});
app.get('/api/bible/:translation/:book/:chapter', requireAuth, async (req, res) => {
const translation = LICENSED_TRANSLATIONS[req.params.translation.toUpperCase()];
const { book, chapter } = req.params;
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
if (!/^[1-3A-Z]{3}$/i.test(book) || !/^\d{1,3}$/.test(chapter)) {
return res.status(400).json({ error: 'Invalid book or chapter.' });
}
const id = req.params.translation.toUpperCase();
const isEsv = translation.provider === 'esv';
const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY;
if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` });
const key = `${id}/${book.toUpperCase()}/${chapter}`;
const ttl = isEsv ? ESV_CACHE_TTL : CACHE_TTL;
const hit = cache.get(key);
if (hit && Date.now() - hit.at < ttl) return res.json(hit.payload);
try {
let payload;
if (isEsv) {
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
if (!bookNum) return res.status(400).json({ error: 'Invalid book.' });
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
const qs = new URLSearchParams({
q: `${prefix}001-${prefix}999`,
'include-passage-references': 'false', 'include-verse-numbers': 'true', 'include-first-verse-numbers': 'true',
'include-footnotes': 'false', 'include-headings': 'false', 'include-short-copyright': 'false',
'include-passage-horizontal-lines': 'false', 'include-heading-horizontal-lines': 'false',
});
const upstream = await fetch(`https://api.esv.org/v3/passage/text/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } });
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
const body = await upstream.json();
const verses = parseChapterText(body.passages?.[0]);
if (verses.length === 0) return res.status(404).json({ error: 'Chapter not found.' });
payload = { translation: id, verses, copyright: '', fumsToken: null };
} else {
const qs = new URLSearchParams({
'content-type': 'text', 'include-notes': 'false', 'include-titles': 'false',
'include-chapter-numbers': 'false', 'include-verse-numbers': 'true', 'include-verse-spans': 'false',
});
const upstream = await fetch(
`${API_BASE}/bibles/${translation.bibleId}/chapters/${book.toUpperCase()}.${chapter}?${qs}`,
{ headers: { 'api-key': apiKey } },
);
if (upstream.status === 404) return res.status(404).json({ error: 'Chapter not found.' });
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
const body = await upstream.json();
const verses = parseChapterText(body.data?.content);
if (verses.length === 0) return res.status(502).json({ error: 'Bible provider returned no verses.' });
payload = { translation: id, verses, copyright: body.data?.copyright ?? '', fumsToken: body.meta?.fumsToken ?? null };
}
const max = isEsv ? ESV_CACHE_MAX : CACHE_MAX;
if (cache.size >= CACHE_MAX + ESV_CACHE_MAX) cache.delete(cache.keys().next().value);
if (isEsv) {
const esvKeys = [...cache.keys()].filter((k) => k.startsWith('ESV/'));
if (esvKeys.length >= max) cache.delete(esvKeys[0]);
}
cache.set(key, { at: Date.now(), payload });
res.json(payload);
} catch {
res.status(502).json({ error: 'Could not reach the Bible provider.' });
}
});
}
+23
View File
@@ -0,0 +1,23 @@
import { test, expect } from 'vitest';
import { parseChapterText, parseEsvReference } from './bibleApi.js';
test('parseChapterText splits inline verse markers and cleans provider artifacts', () => {
const text = ' [1] The Lord is my shepherd;\n I have what I need.\n [2] He lets me lie down #— in pastures, ,\n';
expect(parseChapterText(text)).toEqual([
{ number: 1, text: 'The Lord is my shepherd; I have what I need.' },
{ number: 2, text: 'He lets me lie down — in pastures,' },
]);
});
test('parseChapterText ignores headings before the first verse', () => {
expect(parseChapterText('Psalm 23\n\nA Psalm of David.\n\n[1] The LORD is my shepherd;')).toEqual([
{ number: 1, text: 'The LORD is my shepherd;' },
]);
});
test('parseEsvReference maps ESV book names back to abbreviations', () => {
expect(parseEsvReference('Psalm 23:1')).toEqual({ bookAbbrev: 'PSA', chapter: 23, verse: 1 });
expect(parseEsvReference('1 John 3:16-18')).toEqual({ bookAbbrev: '1JN', chapter: 3, verse: 16 });
expect(parseEsvReference('Song of Solomon 2:4')).toEqual({ bookAbbrev: 'SNG', chapter: 2, verse: 4 });
expect(parseEsvReference('Not a book 1:1')).toBeNull();
});
+464 -32
View File
@@ -10,7 +10,7 @@ const DB_PATH = join(DATA_DIR, 'projects.db');
let db;
// ---------------------------------------------------------------------------
// Init — create tables if they don't exist
// Init — create tables if they don't exist, migrate older schemas
// ---------------------------------------------------------------------------
export function initDb() {
mkdirSync(DATA_DIR, { recursive: true });
@@ -27,6 +27,103 @@ export function initDb() {
chapter_summary TEXT,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY,
sess TEXT NOT NULL,
expires INTEGER NOT NULL
);
`);
// Older databases predate multi-user support — add the ownership column.
const projectCols = db.prepare('PRAGMA table_info(projects)').all();
if (!projectCols.some((c) => c.name === 'user_id')) {
db.exec('ALTER TABLE projects ADD COLUMN user_id TEXT REFERENCES users(id)');
}
// Older databases predate 2FA support — add the TOTP columns.
const userCols = db.prepare('PRAGMA table_info(users)').all();
if (!userCols.some((c) => c.name === 'totp_secret')) {
db.exec(`
ALTER TABLE users ADD COLUMN totp_secret TEXT;
ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN backup_codes TEXT;
`);
}
// Older databases predate the podcast-name profile setting.
if (!userCols.some((c) => c.name === 'podcast_name')) {
db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT');
}
// Older databases predate ntfy.sh daily reading reminders.
if (!userCols.some((c) => c.name === 'ntfy_url')) {
db.exec(`
ALTER TABLE users ADD COLUMN ntfy_url TEXT;
ALTER TABLE users ADD COLUMN ntfy_topic TEXT;
ALTER TABLE users ADD COLUMN reminder_time TEXT;
ALTER TABLE users ADD COLUMN reminder_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN last_reminder_date TEXT;
`);
}
// Reminders fire at the user's own local time, so remember their timezone.
if (!userCols.some((c) => c.name === 'reminder_tz')) {
db.exec('ALTER TABLE users ADD COLUMN reminder_tz TEXT');
}
// Older databases predate shareable read-only links.
if (!projectCols.some((c) => c.name === 'share_token')) {
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
// Reader ink — one row per user + book + chapter, stored as JSON
db.exec(`
CREATE TABLE IF NOT EXISTS reader_ink (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
book_abbrev TEXT NOT NULL,
chapter INTEGER NOT NULL,
strokes TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, book_abbrev, chapter)
);
`);
// Display preferences (theme, default translation, layout, ...) — one row per user.
db.exec(`
CREATE TABLE IF NOT EXISTS user_prefs (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Reader bookmarks + text highlights — one row per user (small JSON), so they follow the
// user across devices. updated_at lets a device tell whether the server copy is newer.
db.exec(`
CREATE TABLE IF NOT EXISTS reader_marks (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Reading plan progress — one row per user, holding the whole list of
// in-progress/completed plans as JSON (small; no need to normalize).
db.exec(`
CREATE TABLE IF NOT EXISTS reading_progress (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
console.log(`SQLite database ready at ${DB_PATH}`);
@@ -42,26 +139,325 @@ function buildSummary(project) {
}
// ---------------------------------------------------------------------------
// Queries
// Users
// ---------------------------------------------------------------------------
export function countUsers() {
return db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
}
export function createUser({ id, email, passwordHash }) {
const createdAt = Date.now();
db.prepare(`
INSERT INTO users (id, email, password_hash, created_at)
VALUES (?, ?, ?, ?)
`).run(id, email, passwordHash, createdAt);
return { id, email, createdAt };
}
function parseUserRow(row) {
if (!row) return null;
return {
...row,
totpEnabled: !!row.totpEnabled,
reminderEnabled: !!row.reminderEnabled,
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
};
}
const USER_SELECT = `
SELECT id, email, password_hash AS passwordHash, created_at AS createdAt,
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
podcast_name AS podcastName,
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz
FROM users
`;
export function getUserByEmail(email) {
return parseUserRow(db.prepare(`${USER_SELECT} WHERE email = ?`).get(email));
}
export function getUserById(id) {
return parseUserRow(db.prepare(`${USER_SELECT} WHERE id = ?`).get(id));
}
/** Persists a confirmed TOTP secret + one-time backup code hashes, turning 2FA on. */
export function enableTotp(userId, secret, backupCodeHashes) {
db.prepare(`
UPDATE users SET totp_secret = ?, totp_enabled = 1, backup_codes = ? WHERE id = ?
`).run(secret, JSON.stringify(backupCodeHashes), userId);
}
/** Turns 2FA off and forgets the secret/backup codes entirely. */
export function disableTotp(userId) {
db.prepare(`
UPDATE users SET totp_secret = NULL, totp_enabled = 0, backup_codes = NULL WHERE id = ?
`).run(userId);
}
/** Sets or clears the show name used in the "Prepare for Podcast" prompt. */
export function setPodcastName(userId, podcastName) {
db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId);
}
// ---------------------------------------------------------------------------
// Daily reading reminders (sent via ntfy.sh — see server/reminders.js)
// ---------------------------------------------------------------------------
/** Saves a user's ntfy reminder settings ({ ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }). */
export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime, reminderEnabled, reminderTz }) {
db.prepare(`
UPDATE users SET ntfy_url = ?, ntfy_topic = ?, reminder_time = ?, reminder_enabled = ?, reminder_tz = ?
WHERE id = ?
`).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, reminderTz || null, userId);
}
/** Every account with reminders turned on and a topic configured — checked once a minute. */
export function getUsersWithRemindersEnabled() {
return db.prepare(`
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz
FROM users
WHERE reminder_enabled = 1 AND ntfy_topic IS NOT NULL AND reminder_time IS NOT NULL
`).all();
}
/** Marks that a user's reminder was sent today, so it isn't sent twice. */
export function setLastReminderDate(userId, dateString) {
db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId);
}
/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
export function setBackupCodeHashes(userId, backupCodeHashes) {
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
}
// ---------------------------------------------------------------------------
// Reader ink — cross-device sync for draw-mode annotations in the reader
// ---------------------------------------------------------------------------
/** Returns all saved reader ink pages for a user as { "BOOK_CH": strokes[] }. */
export function getAllReaderInk(userId) {
const rows = db.prepare(
'SELECT book_abbrev AS book, chapter, strokes FROM reader_ink WHERE user_id = ?'
).all(userId);
return Object.fromEntries(
rows.map((r) => [`${r.book}_${r.chapter}`, JSON.parse(r.strokes)])
);
}
/** The user's synced preferences: { key: { v, at } } (empty when none saved yet). */
export function getUserPrefs(userId) {
const row = db.prepare('SELECT data FROM user_prefs WHERE user_id = ?').get(userId);
return row ? JSON.parse(row.data) : {};
}
/** Replaces the user's stored preferences with `prefs` ({ key: { v, at } }). */
export function setUserPrefs(userId, prefs) {
db.prepare(`
INSERT INTO user_prefs (user_id, data, updated_at) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(userId, JSON.stringify(prefs), Date.now());
}
/** The user's bookmarks + highlights: { bookmarks, highlights, updatedAt } (updatedAt 0 = never saved). */
export function getReaderMarks(userId) {
const row = db.prepare('SELECT data, updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId);
if (!row) return { bookmarks: {}, highlights: [], updatedAt: 0 };
const data = JSON.parse(row.data);
return { bookmarks: data.bookmarks ?? {}, highlights: data.highlights ?? [], updatedAt: row.updatedAt };
}
/** Replaces the user's bookmarks + highlights; returns the new updatedAt (always moves forward). */
export function setReaderMarks(userId, { bookmarks, highlights }) {
const prev = db.prepare('SELECT updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId);
const updatedAt = Math.max(Date.now(), (prev?.updatedAt ?? 0) + 1);
db.prepare(`
INSERT INTO reader_marks (user_id, data, updated_at) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(userId, JSON.stringify({ bookmarks, highlights }), updatedAt);
return updatedAt;
}
/** Upserts the ink strokes for one reader page. */
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
db.prepare(`
INSERT INTO reader_ink (user_id, book_abbrev, chapter, strokes, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, book_abbrev, chapter)
DO UPDATE SET strokes = excluded.strokes, updated_at = excluded.updated_at
`).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now());
}
// ---------------------------------------------------------------------------
// Reading plan progress — cross-device sync for started/completed reading plans
// ---------------------------------------------------------------------------
/** Returns the user's reading-plan data ({ progress, customPlans }, parsed JSON), or an empty shell. */
export function getReadingProgress(userId) {
const row = db.prepare('SELECT data FROM reading_progress WHERE user_id = ?').get(userId);
if (!row) return { progress: [], customPlans: [] };
try {
return JSON.parse(row.data);
} catch {
return { progress: [], customPlans: [] };
}
}
/** Overwrites the user's whole reading-plan progress list. */
export function setReadingProgress(userId, progress) {
db.prepare(`
INSERT INTO reading_progress (user_id, data, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(userId, JSON.stringify(progress), Date.now());
}
/**
* Assigns any pre-existing, unowned projects (from before multi-user support)
* to the given user. Intended to run once, right after the first account is created.
*/
export function claimOrphanProjects(userId) {
db.prepare('UPDATE projects SET user_id = ? WHERE user_id IS NULL').run(userId);
}
// ---------------------------------------------------------------------------
// Project queries — all scoped to the owning user
// ---------------------------------------------------------------------------
/**
* Returns all project summaries (id, title, lastEdited, chapterSummary).
* Returns all project summaries owned by userId (id, title, lastEdited, chapterSummary).
* Does NOT return full project data to keep the response small.
*/
export function getAllProjects() {
export function getAllProjects(userId) {
const rows = db.prepare(`
SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary
FROM projects
WHERE user_id = ?
ORDER BY last_edited DESC
`).all();
`).all(userId);
return rows;
}
/**
* Returns a single full project by id, or null if not found.
* Returns a single full project by id, scoped to userId, or null if not found/not owned.
*/
export function getProject(id) {
export function getProject(id, userId) {
const row = db.prepare('SELECT data FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
if (!row) return null;
try {
return JSON.parse(row.data);
} catch {
return null;
}
}
/** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */
export function getProjectEdited(id, userId) {
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
return row ? row.lastEdited : null;
}
/**
* Insert or replace a project owned by userId.
* Returns the summary, or null if the id already belongs to a different user.
*/
export function upsertProject(project, userId) {
const existing = db.prepare('SELECT user_id AS userId FROM projects WHERE id = ?').get(project.id);
if (existing && existing.userId !== userId) {
return null;
}
const lastEdited = project.lastEdited ?? Date.now();
const chapterSummary = buildSummary(project);
const updated = { ...project, lastEdited };
db.prepare(`
INSERT INTO projects (id, title, last_edited, chapter_summary, data, user_id)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
last_edited = excluded.last_edited,
chapter_summary = excluded.chapter_summary,
data = excluded.data
`).run(project.id, project.title, lastEdited, chapterSummary, JSON.stringify(updated), userId);
return { id: project.id, title: project.title, lastEdited, chapterSummary };
}
/**
* Delete a project by id, scoped to userId. No-op if not found/not owned.
*/
export function deleteProject(id, userId) {
db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId);
}
// ---------------------------------------------------------------------------
// Read-only share links
// ---------------------------------------------------------------------------
/** Returns the current share token for a project owned by userId, or null. */
export function getShareToken(id, userId) {
const row = db.prepare('SELECT share_token AS shareToken FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
return row?.shareToken ?? null;
}
/** Sets a project's share token (enabling its public read-only link), scoped to userId. */
export function setShareToken(id, userId, token) {
const result = db.prepare('UPDATE projects SET share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId);
return result.changes > 0;
}
/** Revokes a project's share link, scoped to userId. */
export function clearShareToken(id, userId) {
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
}
/** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */
export function getProjectByShareToken(token) {
const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token);
if (!row) return null;
try {
return JSON.parse(row.data);
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Admin — unscoped views across every user/project. Callers must gate access
// themselves (see requireAdmin in server/auth.js); nothing here checks who's asking.
// ---------------------------------------------------------------------------
/** Every account, with a project count, for the admin users list. */
export function adminGetAllUsers() {
return db.prepare(`
SELECT u.id, u.email, u.created_at AS createdAt, u.totp_enabled AS totpEnabled,
(SELECT COUNT(*) FROM projects p WHERE p.user_id = u.id) AS projectCount
FROM users u
ORDER BY u.created_at ASC
`).all().map((r) => ({ ...r, totpEnabled: !!r.totpEnabled }));
}
/** Deletes a user account. Their projects are left in place (orphaned, not cascade-deleted) so data isn't lost by accident. */
export function adminDeleteUser(userId) {
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
}
/** Every project across every user, with the owner's email, for the admin projects list. */
export function adminGetAllProjects() {
return db.prepare(`
SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary,
p.share_token AS shareToken, u.email AS ownerEmail
FROM projects p
LEFT JOIN users u ON u.id = p.user_id
ORDER BY p.last_edited DESC
`).all();
}
/** Full project data by id, regardless of owner — for admin inspection. */
export function adminGetProject(id) {
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id);
if (!row) return null;
try {
@@ -71,30 +467,66 @@ export function getProject(id) {
}
}
/**
* Insert or replace a project. Returns the summary.
*/
export function upsertProject(project) {
const lastEdited = project.lastEdited ?? Date.now();
const chapterSummary = buildSummary(project);
const updated = { ...project, lastEdited };
db.prepare(`
INSERT INTO projects (id, title, last_edited, chapter_summary, data)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
last_edited = excluded.last_edited,
chapter_summary = excluded.chapter_summary,
data = excluded.data
`).run(project.id, project.title, lastEdited, chapterSummary, JSON.stringify(updated));
return { id: project.id, title: project.title, lastEdited, chapterSummary };
}
/**
* Delete a project by id. No-op if not found.
*/
export function deleteProject(id) {
/** Deletes any project by id, regardless of owner. */
export function adminDeleteProject(id) {
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
}
/** Overwrites a user's password hash directly — used for both self-service and admin-assisted resets. */
export function setUserPassword(userId, passwordHash) {
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, userId);
}
// ---------------------------------------------------------------------------
// Session store backing (used by server/sessionStore.js)
// ---------------------------------------------------------------------------
export function getSession(sid) {
const row = db.prepare('SELECT sess, expires FROM sessions WHERE sid = ?').get(sid);
if (!row || row.expires < Date.now()) return null;
try {
return JSON.parse(row.sess);
} catch {
return null;
}
}
export function setSession(sid, sess, expires) {
db.prepare(`
INSERT INTO sessions (sid, sess, expires)
VALUES (?, ?, ?)
ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expires = excluded.expires
`).run(sid, JSON.stringify(sess), expires);
}
export function destroySession(sid) {
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
}
export function pruneExpiredSessions() {
db.prepare('DELETE FROM sessions WHERE expires < ?').run(Date.now());
}
/**
* Logs a user out everywhere by deleting every session that belongs to them.
* Sessions don't have an indexed user_id column (they're just an opaque JSON
* blob to express-session), so this scans and parses — fine at this app's scale.
* Pass exceptSid to keep one session alive (e.g. the one completing a self-service
* password change, so the user isn't immediately logged out of their own action).
*/
export function destroyAllSessionsForUser(userId, exceptSid = null) {
const rows = db.prepare('SELECT sid, sess FROM sessions').all();
const staleSids = rows
.filter((row) => row.sid !== exceptSid)
.filter((row) => {
try {
return JSON.parse(row.sess)?.userId === userId;
} catch {
return false;
}
})
.map((row) => row.sid);
if (staleSids.length === 0) return;
const placeholders = staleSids.map(() => '?').join(',');
db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...staleSids);
}
+632 -16
View File
@@ -1,13 +1,90 @@
import express from 'express';
import compression from 'compression';
import session from 'express-session';
import QRCode from 'qrcode';
import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { initDb, getAllProjects, getProject, upsertProject, deleteProject } from './db.js';
import {
initDb, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
setUserPassword, destroyAllSessionsForUser,
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
getReadingProgress, setReadingProgress,
setReminderSettings,
} from './db.js';
import { SqliteSessionStore } from './sessionStore.js';
import {
isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail,
generateTotpSecret, totpKeyUri, verifyTotpToken,
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
} from './auth.js';
import { startReminderScheduler, sendTestReminder, isValidTimeZone } from './reminders.js';
import { registerBibleRoutes } from './bibleApi.js';
import { cleanMarks } from './readerMarks.js';
import { cleanPrefsPatch, mergePrefs } from './prefs.js';
import { isSaveConflict, parseBase } from './projectSync.js';
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
function userPayload(user) {
return {
id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null,
isAdmin: isAdminEmail(user.email),
reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null,
reminderTime: user.reminderTime ?? null,
};
}
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3001;
const isProd = process.env.NODE_ENV === 'production';
if (isProd && !process.env.SESSION_SECRET) {
console.warn('WARNING: SESSION_SECRET is not set. Set it to a long random string in production.');
}
// Trust the reverse proxy (needed for secure cookies to work behind nginx/etc).
app.set('trust proxy', 1);
// Simple in-memory rate limiter for auth endpoints — 10 attempts per 15 min per IP.
const _authAttempts = new Map();
setInterval(() => {
const now = Date.now();
for (const [ip, e] of _authAttempts) if (now > e.resetAt) _authAttempts.delete(ip);
}, 60 * 60 * 1000);
function checkAuthRateLimit(ip) {
const now = Date.now();
const window = 15 * 60 * 1000;
const e = _authAttempts.get(ip) ?? { count: 0, resetAt: now + window };
if (now > e.resetAt) { e.count = 0; e.resetAt = now + window; }
e.count += 1;
_authAttempts.set(ip, e);
return e.count > 10;
}
// Gzip responses — the interlinear JSON files shrink ~10x, and JS/CSS/API payloads too.
app.use(compression());
app.use(express.json({ limit: '10mb' }));
app.use(session({
store: new SqliteSessionStore(),
secret: process.env.SESSION_SECRET || 'dev-only-secret-change-me',
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
httpOnly: true,
// 'auto' = Secure when the request arrived over HTTPS (via the proxy's
// X-Forwarded-Proto). A hard `true` silently breaks login if the proxy
// doesn't send that header, or when the app is opened over plain http.
secure: isProd ? 'auto' : false,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
},
}));
// ---------------------------------------------------------------------------
// Health check
@@ -17,11 +94,273 @@ app.get('/api/health', (_req, res) => {
});
// ---------------------------------------------------------------------------
// GET /api/projects — list all project summaries (no full data)
// Auth
// ---------------------------------------------------------------------------
app.get('/api/projects', (_req, res) => {
app.post('/api/auth/register', async (req, res) => {
try {
const projects = getAllProjects();
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
const email = String(req.body?.email ?? '').trim().toLowerCase();
const password = String(req.body?.password ?? '');
if (!isValidEmail(email)) {
return res.status(400).json({ error: 'Enter a valid email address.' });
}
if (!isValidPassword(password)) {
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
}
if (getUserByEmail(email)) {
return res.status(409).json({ error: 'An account with that email already exists.' });
}
const passwordHash = await hashPassword(password);
const user = createUser({ id: randomUUID(), email, passwordHash });
// The very first account inherits any projects created before multi-user support existed.
if (countUsers() === 1) {
claimOrphanProjects(user.id);
}
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' });
req.session.userId = user.id;
res.json({
id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email),
reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null,
});
});
} catch (err) {
console.error('POST /api/auth/register error:', err);
res.status(500).json({ error: 'Failed to register.' });
}
});
app.post('/api/auth/login', async (req, res) => {
try {
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
const email = String(req.body?.email ?? '').trim().toLowerCase();
const password = String(req.body?.password ?? '');
const user = getUserByEmail(email);
const valid = user && await verifyPassword(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: 'Incorrect email or password.' });
}
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' });
if (user.totpEnabled) {
// Password is correct, but the session stays unauthenticated (no userId)
// until a valid TOTP/backup code lands on /api/auth/mfa/verify.
req.session.pendingUserId = user.id;
return res.json({ mfaRequired: true });
}
req.session.userId = user.id;
res.json(userPayload(user));
});
} catch (err) {
console.error('POST /api/auth/login error:', err);
res.status(500).json({ error: 'Failed to log in.' });
}
});
app.post('/api/auth/mfa/verify', async (req, res) => {
try {
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
const pendingUserId = req.session?.pendingUserId;
if (!pendingUserId) {
return res.status(400).json({ error: 'No sign-in in progress.' });
}
const user = getUserById(pendingUserId);
if (!user || !user.totpEnabled) {
return res.status(400).json({ error: 'No sign-in in progress.' });
}
const token = req.body?.token;
const backupCode = req.body?.backupCode;
let ok = token ? verifyTotpToken(String(token), user.totpSecret) : false;
if (!ok && backupCode) {
const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes);
if (remaining) {
setBackupCodeHashes(user.id, remaining);
ok = true;
}
}
if (!ok) {
return res.status(401).json({ error: 'Invalid code.' });
}
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' });
req.session.userId = user.id;
res.json(userPayload(user));
});
} catch (err) {
console.error('POST /api/auth/mfa/verify error:', err);
res.status(500).json({ error: 'Failed to verify code.' });
}
});
app.post('/api/auth/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('connect.sid');
res.json({ ok: true });
});
});
app.get('/api/auth/me', (req, res) => {
const user = req.session?.userId ? getUserById(req.session.userId) : null;
if (!user) return res.status(401).json({ error: 'Not signed in.' });
res.json(userPayload(user));
});
// ---------------------------------------------------------------------------
// PATCH /api/auth/profile — update account-level settings (currently just podcastName)
// ---------------------------------------------------------------------------
app.patch('/api/auth/profile', requireAuth, (req, res) => {
try {
const podcastName = String(req.body?.podcastName ?? '').trim().slice(0, 200);
setPodcastName(req.session.userId, podcastName || null);
res.json({ podcastName: podcastName || null });
} catch (err) {
console.error('PATCH /api/auth/profile error:', err);
res.status(500).json({ error: 'Failed to save profile.' });
}
});
// ---------------------------------------------------------------------------
// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh
// ---------------------------------------------------------------------------
app.patch('/api/auth/reminder-settings', requireAuth, (req, res) => {
try {
const reminderEnabled = !!req.body?.reminderEnabled;
const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim();
const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim();
const reminderTime = String(req.body?.reminderTime ?? '').trim();
const reminderTz = isValidTimeZone(req.body?.reminderTz) ? String(req.body.reminderTz) : null;
if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
}
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
}
if (reminderEnabled) {
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' });
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) {
return res.status(400).json({ error: 'Reminder time must be in HH:MM format.' });
}
}
setReminderSettings(req.session.userId, {
ntfyUrl: ntfyUrl || null,
ntfyTopic: ntfyTopic || null,
reminderTime: reminderTime || null,
reminderEnabled,
reminderTz,
});
res.json({
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
});
} catch (err) {
console.error('PATCH /api/auth/reminder-settings error:', err);
res.status(500).json({ error: 'Failed to save reminder settings.' });
}
});
// ---------------------------------------------------------------------------
// POST /api/auth/change-password — self-service password change (Account Settings)
// ---------------------------------------------------------------------------
app.post('/api/auth/change-password', requireAuth, async (req, res) => {
try {
const user = getUserById(req.session.userId);
const currentPassword = String(req.body?.currentPassword ?? '');
const newPassword = String(req.body?.newPassword ?? '');
const valid = await verifyPassword(currentPassword, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: 'Current password is incorrect.' });
}
if (!isValidPassword(newPassword)) {
return res.status(400).json({ error: 'New password must be at least 8 characters.' });
}
const passwordHash = await hashPassword(newPassword);
setUserPassword(user.id, passwordHash);
// Sign out every other session (e.g. a stolen one) but keep this one logged in.
destroyAllSessionsForUser(user.id, req.sessionID);
res.json({ ok: true });
} catch (err) {
console.error('POST /api/auth/change-password error:', err);
res.status(500).json({ error: 'Failed to change password.' });
}
});
// ---------------------------------------------------------------------------
// Two-factor auth setup (requires an already-authenticated session)
// ---------------------------------------------------------------------------
app.post('/api/auth/mfa/setup', requireAuth, (req, res) => {
try {
const user = getUserById(req.session.userId);
const secret = generateTotpSecret();
// Held only in the session until confirmed with a real code — never written
// to the DB (and 2FA never turned on) unless /mfa/enable succeeds below.
req.session.pendingTotpSecret = secret;
QRCode.toDataURL(totpKeyUri(user.email, secret), (err, qrCodeDataUrl) => {
if (err) return res.status(500).json({ error: 'Failed to generate QR code.' });
res.json({ secret, qrCodeDataUrl });
});
} catch (err) {
console.error('POST /api/auth/mfa/setup error:', err);
res.status(500).json({ error: 'Failed to start 2FA setup.' });
}
});
app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => {
try {
const secret = req.session.pendingTotpSecret;
if (!secret) {
return res.status(400).json({ error: 'Start 2FA setup first.' });
}
if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) {
return res.status(401).json({ error: 'That code didn\'t match. Check your authenticator app and try again.' });
}
const backupCodes = generateBackupCodes();
const backupCodeHashes = await hashBackupCodes(backupCodes);
enableTotp(req.session.userId, secret, backupCodeHashes);
delete req.session.pendingTotpSecret;
res.json({ backupCodes });
} catch (err) {
console.error('POST /api/auth/mfa/enable error:', err);
res.status(500).json({ error: 'Failed to enable 2FA.' });
}
});
app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => {
try {
const user = getUserById(req.session.userId);
const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash);
if (!valid) {
return res.status(401).json({ error: 'Incorrect password.' });
}
disableTotp(user.id);
res.json({ ok: true });
} catch (err) {
console.error('POST /api/auth/mfa/disable error:', err);
res.status(500).json({ error: 'Failed to disable 2FA.' });
}
});
// ---------------------------------------------------------------------------
// GET /api/projects — list all project summaries owned by the current user
// ---------------------------------------------------------------------------
app.get('/api/projects', requireAuth, (req, res) => {
try {
const projects = getAllProjects(req.session.userId);
res.json(projects);
} catch (err) {
console.error('GET /api/projects error:', err);
@@ -30,11 +369,11 @@ app.get('/api/projects', (_req, res) => {
});
// ---------------------------------------------------------------------------
// GET /api/projects/:id — fetch a single full project
// GET /api/projects/:id — fetch a single full project owned by the current user
// ---------------------------------------------------------------------------
app.get('/api/projects/:id', (req, res) => {
app.get('/api/projects/:id', requireAuth, (req, res) => {
try {
const project = getProject(req.params.id);
const project = getProject(req.params.id, req.session.userId);
if (!project) return res.status(404).json({ error: 'Project not found.' });
res.json(project);
} catch (err) {
@@ -44,9 +383,9 @@ app.get('/api/projects/:id', (req, res) => {
});
// ---------------------------------------------------------------------------
// PUT /api/projects/:id — create or update a project
// PUT /api/projects/:id — create or update a project owned by the current user
// ---------------------------------------------------------------------------
app.put('/api/projects/:id', (req, res) => {
app.put('/api/projects/:id', requireAuth, (req, res) => {
try {
const body = req.body;
if (!body || typeof body !== 'object') {
@@ -58,7 +397,17 @@ app.put('/api/projects/:id', (req, res) => {
if (body.id !== req.params.id) {
return res.status(400).json({ error: 'URL id does not match body id.' });
}
const saved = upsertProject(body);
if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' });
if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' });
// Refuse to overwrite a copy another device saved after the one this device last saw
const stored = getProjectEdited(body.id, req.session.userId);
if (isSaveConflict({ stored, base: parseBase(req.get('X-Base-Edited')), force: req.query.force === '1' })) {
return res.status(409).json({ error: 'This study was changed on another device.', code: 'conflict', serverEdited: stored });
}
const saved = upsertProject(body, req.session.userId);
if (!saved) {
return res.status(403).json({ error: 'That project belongs to a different account.' });
}
res.json(saved);
} catch (err) {
console.error('PUT /api/projects/:id error:', err);
@@ -67,11 +416,11 @@ app.put('/api/projects/:id', (req, res) => {
});
// ---------------------------------------------------------------------------
// DELETE /api/projects/:id — remove a project
// DELETE /api/projects/:id — remove a project owned by the current user
// ---------------------------------------------------------------------------
app.delete('/api/projects/:id', (req, res) => {
app.delete('/api/projects/:id', requireAuth, (req, res) => {
try {
deleteProject(req.params.id);
deleteProject(req.params.id, req.session.userId);
res.json({ ok: true });
} catch (err) {
console.error('DELETE /api/projects/:id error:', err);
@@ -79,13 +428,279 @@ app.delete('/api/projects/:id', (req, res) => {
}
});
// ---------------------------------------------------------------------------
// Reader ink — cross-device draw annotations on Bible chapters
// ---------------------------------------------------------------------------
// GET /api/reader/ink — all saved ink pages for the current user
app.get('/api/reader/ink', requireAuth, (req, res) => {
try {
res.json(getAllReaderInk(req.session.userId));
} catch (err) {
console.error('GET /api/reader/ink error:', err);
res.status(500).json({ error: 'Failed to load reader ink.' });
}
});
// PUT /api/reader/ink/:book/:chapter — save (upsert) one page's ink
app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
try {
const { book, chapter } = req.params;
const { strokes } = req.body;
if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' });
setReaderInkPage(req.session.userId, book, chapter, strokes);
res.json({ ok: true });
} catch (err) {
console.error('PUT /api/reader/ink error:', err);
res.status(500).json({ error: 'Failed to save reader ink.' });
}
});
// GET /api/prefs — the user's synced display preferences ({ prefs: { key: { v, at } } })
app.get('/api/prefs', requireAuth, (req, res) => {
try {
res.json({ prefs: getUserPrefs(req.session.userId) });
} catch (err) {
console.error('GET /api/prefs error:', err);
res.status(500).json({ error: 'Failed to load preferences.' });
}
});
// PUT /api/prefs — merge changed preferences in (newest change per key wins); returns the result
app.put('/api/prefs', requireAuth, (req, res) => {
try {
const merged = mergePrefs(getUserPrefs(req.session.userId), cleanPrefsPatch(req.body));
setUserPrefs(req.session.userId, merged);
res.json({ prefs: merged });
} catch (err) {
console.error('PUT /api/prefs error:', err);
res.status(500).json({ error: 'Failed to save preferences.' });
}
});
// GET /api/reader/marks — the user's bookmarks + highlights, with an updatedAt to compare against
app.get('/api/reader/marks', requireAuth, (req, res) => {
try {
res.json(getReaderMarks(req.session.userId));
} catch (err) {
console.error('GET /api/reader/marks error:', err);
res.status(500).json({ error: 'Failed to load bookmarks and highlights.' });
}
});
// PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights })
app.put('/api/reader/marks', requireAuth, (req, res) => {
try {
const marks = cleanMarks(req.body);
if (!marks) return res.status(400).json({ error: 'Body must be an object.' });
res.json({ ok: true, updatedAt: setReaderMarks(req.session.userId, marks) });
} catch (err) {
console.error('PUT /api/reader/marks error:', err);
res.status(500).json({ error: 'Failed to save bookmarks and highlights.' });
}
});
// ---------------------------------------------------------------------------
// Reading plan progress — cross-device sync for started/completed reading plans
// ---------------------------------------------------------------------------
// GET /api/reading-plans — the current user's whole progress list
registerBibleRoutes(app, requireAuth);
app.get('/api/reading-plans', requireAuth, (req, res) => {
try {
res.json(getReadingProgress(req.session.userId));
} catch (err) {
console.error('GET /api/reading-plans error:', err);
res.status(500).json({ error: 'Failed to load reading plan progress.' });
}
});
// PUT /api/reading-plans — overwrite the current user's whole progress list
// (an object: { progress: [...], customPlans: [...] })
app.put('/api/reading-plans', requireAuth, (req, res) => {
try {
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
return res.status(400).json({ error: 'Body must be an object.' });
}
setReadingProgress(req.session.userId, req.body);
res.json({ ok: true });
} catch (err) {
console.error('PUT /api/reading-plans error:', err);
res.status(500).json({ error: 'Failed to save reading plan progress.' });
}
});
// POST /api/auth/reminder-test — sends a test notification to the topic in the request
// body (so it can be tried before saving), or to the saved settings.
const _lastReminderTest = new Map(); // userId -> timestamp, to keep test sends from being spammed
app.post('/api/auth/reminder-test', requireAuth, async (req, res) => {
const userId = req.session.userId;
if (Date.now() - (_lastReminderTest.get(userId) ?? 0) < 5000) {
return res.status(429).json({ error: 'Wait a few seconds before sending another test.' });
}
_lastReminderTest.set(userId, Date.now());
const saved = getUserById(userId);
const ntfyTopic = String(req.body?.ntfyTopic ?? saved?.ntfyTopic ?? '').trim();
const ntfyUrl = String(req.body?.ntfyUrl ?? saved?.ntfyUrl ?? '').trim();
if (!ntfyTopic) return res.status(400).json({ error: 'Enter a topic first.' });
if (!/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
}
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
}
try {
await sendTestReminder({ ntfyTopic, ntfyUrl });
res.json({ ok: true });
} catch (err) {
res.status(502).json({ error: err.message });
}
});
// ---------------------------------------------------------------------------
// Read-only share links
// ---------------------------------------------------------------------------
// GET /api/projects/:id/share — current share status for the project owner
app.get('/api/projects/:id/share', requireAuth, (req, res) => {
try {
const project = getProject(req.params.id, req.session.userId);
if (!project) return res.status(404).json({ error: 'Project not found.' });
res.json({ shareToken: getShareToken(req.params.id, req.session.userId) });
} catch (err) {
console.error('GET /api/projects/:id/share error:', err);
res.status(500).json({ error: 'Failed to load share status.' });
}
});
// POST /api/projects/:id/share — enable sharing, returns the (new or existing) token
app.post('/api/projects/:id/share', requireAuth, (req, res) => {
try {
const existing = getShareToken(req.params.id, req.session.userId);
const token = existing || randomUUID().replace(/-/g, '');
const ok = setShareToken(req.params.id, req.session.userId, token);
if (!ok) return res.status(404).json({ error: 'Project not found.' });
res.json({ shareToken: token });
} catch (err) {
console.error('POST /api/projects/:id/share error:', err);
res.status(500).json({ error: 'Failed to enable sharing.' });
}
});
// DELETE /api/projects/:id/share — revoke the share link
app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
try {
clearShareToken(req.params.id, req.session.userId);
res.json({ ok: true });
} catch (err) {
console.error('DELETE /api/projects/:id/share error:', err);
res.status(500).json({ error: 'Failed to revoke sharing.' });
}
});
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only
app.get('/api/share/:token', (req, res) => {
try {
const project = getProjectByShareToken(req.params.token);
if (!project) return res.status(404).json({ error: 'This share link is invalid or has been revoked.' });
res.json(project);
} catch (err) {
console.error('GET /api/share/:token error:', err);
res.status(500).json({ error: 'Failed to load shared project.' });
}
});
// ---------------------------------------------------------------------------
// Admin — restricted to the single designated admin account (see ADMIN_EMAIL
// in server/auth.js). Full visibility/control over every user and project.
// ---------------------------------------------------------------------------
app.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => {
try {
res.json(adminGetAllUsers());
} catch (err) {
console.error('GET /api/admin/users error:', err);
res.status(500).json({ error: 'Failed to list users.' });
}
});
app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
try {
if (req.params.id === req.session.userId) {
return res.status(400).json({ error: "Can't delete your own admin account." });
}
adminDeleteUser(req.params.id);
res.json({ ok: true });
} catch (err) {
console.error('DELETE /api/admin/users/:id error:', err);
res.status(500).json({ error: 'Failed to delete user.' });
}
});
// POST /api/admin/users/:id/reset-password — sets a random temporary password and
// signs the user out everywhere, since there's no self-service "forgot password" flow yet.
app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async (req, res) => {
try {
const temporaryPassword = generateTemporaryPassword();
const passwordHash = await hashPassword(temporaryPassword);
setUserPassword(req.params.id, passwordHash);
destroyAllSessionsForUser(req.params.id);
res.json({ temporaryPassword });
} catch (err) {
console.error('POST /api/admin/users/:id/reset-password error:', err);
res.status(500).json({ error: 'Failed to reset password.' });
}
});
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
try {
res.json(adminGetAllProjects());
} catch (err) {
console.error('GET /api/admin/projects error:', err);
res.status(500).json({ error: 'Failed to list projects.' });
}
});
app.get('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
try {
const project = adminGetProject(req.params.id);
if (!project) return res.status(404).json({ error: 'Project not found.' });
res.json(project);
} catch (err) {
console.error('GET /api/admin/projects/:id error:', err);
res.status(500).json({ error: 'Failed to load project.' });
}
});
app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
try {
adminDeleteProject(req.params.id);
res.json({ ok: true });
} catch (err) {
console.error('DELETE /api/admin/projects/:id error:', err);
res.status(500).json({ error: 'Failed to delete project.' });
}
});
// ---------------------------------------------------------------------------
// Serve Vite production build (when NODE_ENV=production)
// ---------------------------------------------------------------------------
if (process.env.NODE_ENV === 'production') {
if (isProd) {
const distPath = join(__dirname, '..', 'dist');
app.use(express.static(distPath));
// Vite's /assets files have content hashes in their names, so they can be
// cached forever. Other static files (interlinear JSON, icons) cache for a
// day; index.html and the service worker always revalidate so deploys show up.
const noCache = (res) => res.setHeader('Cache-Control', 'no-cache');
app.use('/assets', express.static(join(distPath, 'assets'), { immutable: true, maxAge: '1y' }));
app.use(express.static(distPath, {
maxAge: '1d',
setHeaders: (res, path) => {
if (path.endsWith('index.html') || path.endsWith('sw.js')) noCache(res);
},
}));
app.get('*', (_req, res) => {
noCache(res);
res.sendFile(join(distPath, 'index.html'));
});
}
@@ -94,9 +709,10 @@ if (process.env.NODE_ENV === 'production') {
// Start
// ---------------------------------------------------------------------------
initDb();
startReminderScheduler();
app.listen(PORT, () => {
console.log(`Bible Study API running on http://localhost:${PORT}`);
if (process.env.NODE_ENV === 'production') {
if (isProd) {
console.log('Serving Vite build from /dist');
}
});
+57
View File
@@ -0,0 +1,57 @@
// A user's synced display preferences: theme, default Reader translation, verse layout, the
// commentary they last chose, and where they left off in the Reader. Each preference carries
// its own timestamp ({ v, at }) so the most recent change to each one wins, device by device.
const ENUMS = {
theme: ['paper', 'sepia', 'dark'],
translation: ['BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'],
layout: ['flow', 'verses'],
startPage: ['studies', 'reader', 'plans'], // where the app opens and where Home buttons go
planTranslation: ['same', 'BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'], // which Bible new reading plans start in
commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'],
};
const DAY_MS = 86_400_000;
const COLOR = /^#[0-9a-fA-F]{3,8}$/;
// A cleaned value, or undefined when it isn't acceptable for that preference.
export function cleanPrefValue(key, value) {
if (ENUMS[key]) return ENUMS[key].includes(value) ? value : undefined;
if (key === 'colorLabels') {
// What each highlight colour means to the reader: { "#fef08a": "Promises", ... }
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const out = {};
for (const [color, label] of Object.entries(value).slice(0, 12)) {
if (COLOR.test(color) && typeof label === 'string' && label.trim()) out[color] = label.trim().slice(0, 30);
}
return out;
}
if (key === 'position') {
if (!value || typeof value !== 'object') return undefined;
const book = String(value.book ?? '').toUpperCase();
if (!/^[1-3A-Z]{3}$/.test(book) || !Number.isInteger(value.chapter) || value.chapter < 1 || value.chapter > 200) return undefined;
return { book, chapter: value.chapter };
}
return undefined;
}
// Body -> { key: { v, at } } with only known keys, valid values and sane timestamps.
export function cleanPrefsPatch(body, now = Date.now()) {
const out = {};
const prefs = body?.prefs;
if (!prefs || typeof prefs !== 'object' || Array.isArray(prefs)) return out;
for (const [key, entry] of Object.entries(prefs)) {
const v = cleanPrefValue(key, entry?.v);
const at = entry?.at;
if (v === undefined || !Number.isFinite(at) || at < 0 || at > now + DAY_MS) continue;
out[key] = { v, at };
}
return out;
}
// Per key, the entry with the newer timestamp wins (the stored one on a tie).
export function mergePrefs(existing = {}, patch = {}) {
const merged = { ...existing };
for (const [key, entry] of Object.entries(patch)) {
if (!merged[key] || entry.at > merged[key].at) merged[key] = entry;
}
return merged;
}
+69
View File
@@ -0,0 +1,69 @@
// @vitest-environment node
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test, expect } from 'vitest';
import { cleanPrefValue, cleanPrefsPatch, mergePrefs } from './prefs.js';
const NOW = 1_800_000_000_000;
test('only known preferences with valid values are accepted', () => {
expect(cleanPrefValue('theme', 'dark')).toBe('dark');
expect(cleanPrefValue('theme', 'neon')).toBeUndefined();
expect(cleanPrefValue('translation', 'ESV')).toBe('ESV');
expect(cleanPrefValue('translation', 'KJV')).toBeUndefined();
expect(cleanPrefValue('layout', 'flow')).toBe('flow');
expect(cleanPrefValue('commentarySource', 'tyndale')).toBe('tyndale');
expect(cleanPrefValue('startPage', 'reader')).toBe('reader');
expect(cleanPrefValue('startPage', 'admin')).toBeUndefined();
expect(cleanPrefValue('planTranslation', 'same')).toBe('same');
expect(cleanPrefValue('planTranslation', 'NASB')).toBe('NASB');
expect(cleanPrefValue('planTranslation', 'KJV')).toBeUndefined();
expect(cleanPrefValue('fontSize', 2)).toBeUndefined(); // not a synced preference
});
test('position needs a real book and chapter, and the book is normalised', () => {
expect(cleanPrefValue('position', { book: 'jhn', chapter: 3 })).toEqual({ book: 'JHN', chapter: 3 });
expect(cleanPrefValue('position', { book: 'JOHN', chapter: 3 })).toBeUndefined();
expect(cleanPrefValue('position', { book: 'JHN', chapter: 0 })).toBeUndefined();
expect(cleanPrefValue('position', { book: 'JHN', chapter: '3' })).toBeUndefined();
expect(cleanPrefValue('position', null)).toBeUndefined();
});
test('cleanPrefsPatch drops unknown keys, bad values and silly timestamps', () => {
const patch = cleanPrefsPatch({ prefs: {
theme: { v: 'sepia', at: NOW },
layout: { v: 'nope', at: NOW },
evil: { v: 'x', at: NOW },
translation: { v: 'NLT', at: 'yesterday' },
commentarySource: { v: 'tyndale', at: NOW + 5 * 86_400_000 }, // far in the future
position: { v: { book: 'GEN', chapter: 2 }, at: NOW - 10 },
} }, NOW);
expect(patch).toEqual({ theme: { v: 'sepia', at: NOW }, position: { v: { book: 'GEN', chapter: 2 }, at: NOW - 10 } });
expect(cleanPrefsPatch(null)).toEqual({});
expect(cleanPrefsPatch({ prefs: [] })).toEqual({});
});
test('the newest change to each preference wins, independently', () => {
const existing = { theme: { v: 'dark', at: 100 }, layout: { v: 'flow', at: 500 } };
const patch = { theme: { v: 'paper', at: 200 }, layout: { v: 'verses', at: 300 }, translation: { v: 'ESV', at: 50 } };
expect(mergePrefs(existing, patch)).toEqual({
theme: { v: 'paper', at: 200 }, // patch is newer
layout: { v: 'flow', at: 500 }, // stored is newer
translation: { v: 'ESV', at: 50 }, // new key
});
expect(mergePrefs({ theme: { v: 'dark', at: 100 } }, { theme: { v: 'paper', at: 100 } }).theme.v).toBe('dark'); // tie keeps stored
});
test('the database keeps one row per user', async () => {
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'prefs-'));
const db = await import('./db.js');
db.initDb();
db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' });
db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' });
expect(db.getUserPrefs('u1')).toEqual({});
db.setUserPrefs('u1', { theme: { v: 'dark', at: 1 } });
db.setUserPrefs('u1', { theme: { v: 'sepia', at: 2 } });
expect(db.getUserPrefs('u1')).toEqual({ theme: { v: 'sepia', at: 2 } });
expect(db.getUserPrefs('u2')).toEqual({});
});
+18
View File
@@ -0,0 +1,18 @@
// Stops one device from silently overwriting a study another device has saved since. The browser sends
// the `lastEdited` of the server copy it last saw (X-Base-Edited); if the server's copy has moved past
// that, the save is refused with 409 and the person chooses whose version to keep.
// stored: the server copy's lastEdited (null when there is none); base: what the client last saw
// (null/undefined when it never synced this study); force: the person chose "keep mine".
export function isSaveConflict({ stored, base, force = false }) {
if (force || stored == null) return false;
if (!Number.isFinite(base)) return false; // a device that never saw the server's copy has nothing to compare
return stored > base;
}
// Reads the base header; anything that isn't a plain number is treated as "unknown".
export function parseBase(header) {
if (header == null || header === '') return null;
const n = Number(header);
return Number.isFinite(n) ? n : null;
}
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from 'vitest';
import { isSaveConflict, parseBase } from './projectSync.js';
test('a save is fine when the server copy is no newer than what the device last saw', () => {
expect(isSaveConflict({ stored: 100, base: 100 })).toBe(false);
expect(isSaveConflict({ stored: 90, base: 100 })).toBe(false);
});
test('a save conflicts when another device saved after the one this device last saw', () => {
expect(isSaveConflict({ stored: 200, base: 100 })).toBe(true);
});
test('first saves, unknown bases and "keep mine" never conflict', () => {
expect(isSaveConflict({ stored: null, base: 100 })).toBe(false);
expect(isSaveConflict({ stored: 200, base: null })).toBe(false);
expect(isSaveConflict({ stored: 200, base: 100, force: true })).toBe(false);
});
test('reads the base header', () => {
expect(parseBase('1700000000000')).toBe(1700000000000);
expect(parseBase('')).toBeNull();
expect(parseBase(undefined)).toBeNull();
expect(parseBase('abc')).toBeNull();
});
+43
View File
@@ -0,0 +1,43 @@
// Validation for a user's synced Reader bookmarks and text highlights. The data comes
// from the browser, so everything is checked and malformed entries are dropped.
const COLOR = /^#[0-9a-fA-F]{3,8}$/;
const BOOKMARK_KEY = /^[1-3]?[A-Za-z]{2,3}-\d{1,3}-\d{1,3}$/; // e.g. "JHN-3-16", "1SA-3-5"
const BOOK = /^[1-3A-Za-z]{3}$/;
const MAX_BOOKMARKS = 5000;
const MAX_HIGHLIGHTS = 20000;
const int = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
// Returns { bookmarks, highlights } with only well-formed entries, or null when the body is
// not an object at all.
export function cleanMarks(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
const bookmarks = {};
if (body.bookmarks && typeof body.bookmarks === 'object' && !Array.isArray(body.bookmarks)) {
for (const [key, color] of Object.entries(body.bookmarks).slice(0, MAX_BOOKMARKS)) {
if (BOOKMARK_KEY.test(key) && typeof color === 'string' && COLOR.test(color)) bookmarks[key] = color;
}
}
const highlights = [];
if (Array.isArray(body.highlights)) {
const seen = new Set();
for (const h of body.highlights.slice(0, MAX_HIGHLIGHTS)) {
if (!h || typeof h !== 'object') continue;
if (typeof h.id !== 'string' || h.id.length === 0 || h.id.length > 64 || seen.has(h.id)) continue;
if (!BOOK.test(String(h.book)) || !int(h.chapter, 1, 200) || !int(h.verse, 1, 200)) continue;
if (!int(h.startOffset, 0, 5000) || !int(h.endOffset, 1, 5000) || h.endOffset <= h.startOffset) continue;
if (typeof h.color !== 'string' || !COLOR.test(h.color)) continue;
seen.add(h.id);
const entry = {
id: h.id, book: String(h.book).toUpperCase(), chapter: h.chapter, verse: h.verse,
startOffset: h.startOffset, endOffset: h.endOffset, color: h.color,
};
// The highlighted words, kept so lists of highlights can show them without loading the chapter
if (typeof h.text === 'string' && h.text.length > 0) entry.text = h.text.slice(0, 500);
highlights.push(entry);
}
}
return { bookmarks, highlights };
}
+60
View File
@@ -0,0 +1,60 @@
// @vitest-environment node
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test, expect } from 'vitest';
import { cleanMarks } from './readerMarks.js';
const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 4, endOffset: 20, color: '#fef08a', ...over });
test('a non-object body is rejected', () => {
expect(cleanMarks(null)).toBeNull();
expect(cleanMarks([])).toBeNull();
expect(cleanMarks('x')).toBeNull();
});
test('good bookmarks and highlights pass through unchanged', () => {
const body = { bookmarks: { 'JHN-3-16': '#fef08a', '1SA-3-5': '#bbf7d0' }, highlights: [hl()] };
expect(cleanMarks(body)).toEqual(body);
});
test('malformed bookmarks are dropped, good ones kept', () => {
const out = cleanMarks({ bookmarks: { 'JHN-3-16': '#fef08a', 'bad key': '#fef08a', 'GEN-1-1': 'red', 'GEN-1-2': 5, '../x-1-1': '#fff' } });
expect(out.bookmarks).toEqual({ 'JHN-3-16': '#fef08a' });
});
test('highlights are validated field by field and de-duplicated by id', () => {
const out = cleanMarks({ highlights: [
hl(),
hl({ id: 'h1', verse: 17 }), // duplicate id
hl({ id: '' }), hl({ id: 'x'.repeat(65) }),
hl({ id: 'a', book: 'JOHN' }), hl({ id: 'b', chapter: 0 }), hl({ id: 'c', verse: 201 }),
hl({ id: 'd', startOffset: 10, endOffset: 10 }), hl({ id: 'e', startOffset: -1 }), hl({ id: 'f', color: 'yellow' }),
'nope', null,
hl({ id: 'ok', book: 'gen' }),
] });
expect(out.highlights.map((h) => h.id)).toEqual(['h1', 'ok']);
expect(out.highlights[1].book).toBe('GEN'); // normalised
});
test('missing sections become empty ones, and unknown fields are not stored', () => {
expect(cleanMarks({})).toEqual({ bookmarks: {}, highlights: [] });
expect(cleanMarks({ highlights: [{ ...hl(), evil: '<script>' }] }).highlights[0]).not.toHaveProperty('evil');
});
test('the database keeps one row per user and updatedAt only moves forward', async () => {
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'marks-'));
const db = await import('./db.js');
db.initDb();
db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' });
db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' });
expect(db.getReaderMarks('u1')).toEqual({ bookmarks: {}, highlights: [], updatedAt: 0 });
const t1 = db.setReaderMarks('u1', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] });
const t2 = db.setReaderMarks('u1', { bookmarks: {}, highlights: [hl()] });
expect(t2).toBeGreaterThan(t1);
const saved = db.getReaderMarks('u1');
expect(saved.updatedAt).toBe(t2);
expect(saved.bookmarks).toEqual({});
expect(saved.highlights).toHaveLength(1);
expect(db.getReaderMarks('u2').updatedAt).toBe(0); // other users are untouched
});
+83
View File
@@ -0,0 +1,83 @@
// Daily "don't forget to read" reminders, sent via ntfy.sh (https://ntfy.sh) —
// a free pub/sub push service: POST a message to a topic URL, anyone
// subscribed to that topic (via the ntfy app or a browser) gets notified.
// No API keys or push-subscription plumbing needed.
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js';
// A reminder still goes out if the server was briefly down (or restarting) at the
// exact minute — as long as it's within this window after the chosen time.
const GRACE_MINUTES = 180;
const SEND_TIMEOUT_MS = 10_000;
export function isValidTimeZone(tz) {
if (!tz) return false;
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
}
// The calendar date ("YYYY-MM-DD") and minutes-since-midnight in a timezone; the
// server's own timezone when none (or an invalid one) is saved.
export function zonedNow(date = new Date(), tz) {
const timeZone = isValidTimeZone(tz) ? tz : undefined;
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
}).formatToParts(date).map((p) => [p.type, p.value]),
);
return { date: `${parts.year}-${parts.month}-${parts.day}`, minutes: Number(parts.hour) * 60 + Number(parts.minute) };
}
// Is this user's reminder due right now (and not already sent today)?
export function isDue(user, now = new Date()) {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(user.reminderTime ?? '');
if (!match) return false;
const target = Number(match[1]) * 60 + Number(match[2]);
const local = zonedNow(now, user.reminderTz);
if (user.lastReminderDate === local.date) return false;
return local.minutes >= target && local.minutes < target + GRACE_MINUTES;
}
// POSTs one notification to the user's ntfy topic. Throws with a readable message on failure.
export async function sendNtfy(user, { title, body, tags = 'open_book' }) {
const base = (user.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '');
const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`;
const headers = { Title: title, Tags: tags };
if (process.env.APP_URL) headers.Click = process.env.APP_URL; // tapping the notification opens the app
let res;
try {
res = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
} catch (err) {
throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`);
}
if (!res.ok) throw new Error(`ntfy responded ${res.status}`);
}
export function reminderBody(progress) {
const active = (Array.isArray(progress?.progress) ? progress.progress : []).filter((p) => !p.completedAt).length;
return active > 1 ? `Don't forget today's reading! You have ${active} plans in progress.` : "Don't forget today's reading!";
}
/** Sends a one-off notification so the user can confirm their topic/server work. */
export function sendTestReminder(user) {
return sendNtfy(user, { title: 'Reading reminders are working', body: 'This is a test from Bible Study Project. Your daily reminder will arrive like this.', tags: 'white_check_mark' });
}
export async function checkReminders(now = new Date()) {
for (const user of getUsersWithRemindersEnabled().filter((u) => isDue(u, now))) {
try {
const progress = getReadingProgress(user.id);
const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt);
if (!hasActivePlan) continue; // nothing to nudge them about
await sendNtfy(user, { title: 'Reading Plan Reminder', body: reminderBody(progress) });
setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date);
} catch (err) {
console.error(`Reading reminder failed for user ${user.id}:`, err.message);
}
}
}
/** Checks once a minute whether any user's reminder time has arrived. */
export function startReminderScheduler() {
setInterval(() => {
checkReminders().catch((err) => console.error('Reminder scheduler error:', err));
}, 60 * 1000);
}
+86
View File
@@ -0,0 +1,86 @@
// @vitest-environment node
import http from 'node:http';
import { test, expect, afterEach } from 'vitest';
import { isDue, isValidTimeZone, reminderBody, sendNtfy, sendTestReminder, zonedNow } from './reminders.js';
const at = (iso) => new Date(iso);
test('zonedNow reads the date and time in the user\'s timezone, not the server\'s', () => {
// 2026-09-30T03:30Z is 23:30 the day before in New York, and 12:30 the same day in Tokyo
const d = at('2026-09-30T03:30:00Z');
expect(zonedNow(d, 'America/New_York')).toEqual({ date: '2026-09-29', minutes: 23 * 60 + 30 });
expect(zonedNow(d, 'Asia/Tokyo')).toEqual({ date: '2026-09-30', minutes: 12 * 60 + 30 });
expect(zonedNow(d, 'Not/AZone')).toEqual(zonedNow(d)); // bad timezone falls back to the server's
});
test('timezone validation', () => {
expect(isValidTimeZone('America/Chicago')).toBe(true);
expect(isValidTimeZone('Mars/Base')).toBe(false);
expect(isValidTimeZone('')).toBe(false);
});
test('a reminder is due at its time in the user\'s timezone', () => {
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: null };
expect(isDue(user, at('2026-09-29T11:59:00Z'))).toBe(false); // 06:59 in Chicago (CDT)
expect(isDue(user, at('2026-09-29T12:00:00Z'))).toBe(true); // 07:00
});
test('a late server still sends today\'s reminder, but only within the grace window', () => {
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: null };
expect(isDue(user, at('2026-09-29T12:40:00Z'))).toBe(true); // 07:40 — server was down at 07:00
expect(isDue(user, at('2026-09-29T15:30:00Z'))).toBe(false); // 10:30 — too late to be useful
});
test('it is not sent twice on the same local day', () => {
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: '2026-09-29' };
expect(isDue(user, at('2026-09-29T12:05:00Z'))).toBe(false);
expect(isDue({ ...user, lastReminderDate: '2026-09-28' }, at('2026-09-29T12:05:00Z'))).toBe(true);
});
test('a missing or malformed time is never due', () => {
expect(isDue({ reminderTime: null }, new Date())).toBe(false);
expect(isDue({ reminderTime: '7am' }, new Date())).toBe(false);
});
test('the message mentions how many plans are in progress', () => {
expect(reminderBody({ progress: [{ completedAt: null }] })).toBe("Don't forget today's reading!");
expect(reminderBody({ progress: [{ completedAt: null }, { completedAt: null }, { completedAt: 1 }] })).toContain('2 plans');
});
// --- delivery, against a local stand-in for an ntfy server ------------------------
let server;
afterEach(() => new Promise((resolve) => (server ? server.close(resolve) : resolve())));
const startNtfy = (handler) => new Promise((resolve) => {
const seen = [];
server = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => { seen.push({ method: req.method, url: req.url, headers: req.headers, body }); handler(res); });
});
server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, seen }));
});
test('sendNtfy POSTs the message to <server>/<topic> with a title', async () => {
const ntfy = await startNtfy((res) => res.end('{}'));
await sendNtfy({ ntfyUrl: `${ntfy.url}/`, ntfyTopic: 'my-topic_1' }, { title: 'Hello', body: 'Body text' });
expect(ntfy.seen).toHaveLength(1);
expect(ntfy.seen[0]).toMatchObject({ method: 'POST', url: '/my-topic_1', body: 'Body text' });
expect(ntfy.seen[0].headers.title).toBe('Hello');
});
test('the test notification reaches the topic', async () => {
const ntfy = await startNtfy((res) => res.end('{}'));
await sendTestReminder({ ntfyUrl: ntfy.url, ntfyTopic: 'abc' });
expect(ntfy.seen[0].url).toBe('/abc');
expect(ntfy.seen[0].headers.title).toMatch(/working/i);
});
test('an ntfy error response becomes a readable failure', async () => {
const ntfy = await startNtfy((res) => { res.statusCode = 403; res.end('nope'); });
await expect(sendNtfy({ ntfyUrl: ntfy.url, ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow('ntfy responded 403');
});
test('an unreachable server says so instead of hanging', async () => {
await expect(sendNtfy({ ntfyUrl: 'http://127.0.0.1:9', ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow(/Could not reach/);
});
+48
View File
@@ -0,0 +1,48 @@
import session from 'express-session';
import { getSession, setSession, destroySession, pruneExpiredSessions } from './db.js';
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* express-session store backed by the same SQLite database as everything else,
* so logins survive a server restart without adding another dependency.
*/
export class SqliteSessionStore extends session.Store {
constructor() {
super();
// Sweep expired sessions periodically instead of on every request.
this._interval = setInterval(() => pruneExpiredSessions(), DAY_MS);
this._interval.unref?.();
}
get(sid, cb) {
try {
cb(null, getSession(sid));
} catch (err) {
cb(err);
}
}
set(sid, sessionData, cb) {
try {
const maxAge = sessionData.cookie?.maxAge ?? DAY_MS * 30;
setSession(sid, sessionData, Date.now() + maxAge);
cb?.(null);
} catch (err) {
cb?.(err);
}
}
destroy(sid, cb) {
try {
destroySession(sid);
cb?.(null);
} catch (err) {
cb?.(err);
}
}
touch(sid, sessionData, cb) {
this.set(sid, sessionData, cb);
}
}
+44 -8
View File
@@ -1,6 +1,20 @@
#!/usr/bin/env bash
# Installs dependencies, builds the frontend, and runs the app.
#
# bash setup.sh Production: one Express server serves the optimized
# build + API on $APP_PORT (default 5173).
# bash setup.sh --dev Development: Express API on 3001 + Vite dev server
# with hot reload (slower; not for real users).
set -euo pipefail
MODE="prod"
if [ "${1:-}" = "--dev" ]; then
MODE="dev"
fi
# Production serves on the port the Vite dev server used to use, so an
# existing reverse proxy pointed at it keeps working. Override with APP_PORT=.
APP_PORT="${APP_PORT:-5173}"
PREFERRED_VITE_PORT=5173
MAX_VITE_PORT=5200
API_PORT=3001
@@ -41,7 +55,7 @@ stop_pid_file() {
fi
}
echo "=== Bible Study App Setup ==="
echo "=== Bible Study App Setup ($MODE) ==="
echo ""
# ── Node check ───────────────────────────────────────────────────────────────
@@ -68,7 +82,35 @@ echo ""
echo "Installing dependencies..."
npm install
# ── Find a free Vite port ─────────────────────────────────────────────────────
# ── Production ────────────────────────────────────────────────────────────────
if [ "$MODE" = "prod" ]; then
# Build before stopping anything, so a failed build leaves the running site up.
echo ""
echo "Building frontend..."
npm run build
stop_pid_file "$API_PID_FILE" "server"
stop_pid_file "$VITE_PID_FILE" "Vite dev server"
echo ""
echo "Starting production server on http://localhost:$APP_PORT"
echo "Logs: $API_LOG_FILE"
NODE_ENV=production PORT="$APP_PORT" \
nohup node "$ROOT_DIR/server/index.js" > "$API_LOG_FILE" 2>&1 &
echo $! > "$API_PID_FILE"
echo "Server PID: $(cat "$API_PID_FILE")"
echo ""
echo "To stop: kill \$(cat .api.pid)"
echo "To view logs: tail -f .api.log"
exit 0
fi
# ── Development ───────────────────────────────────────────────────────────────
stop_pid_file "$API_PID_FILE" "API server"
stop_pid_file "$VITE_PID_FILE" "Vite server"
echo ""
VITE_PORT=$(find_port)
if [ -z "$VITE_PORT" ]; then
@@ -82,11 +124,6 @@ else
echo "Port $VITE_PORT is available for Vite."
fi
# ── Stop any previously running instances ─────────────────────────────────────
stop_pid_file "$API_PID_FILE" "API server"
stop_pid_file "$VITE_PID_FILE" "Vite server"
# ── Start Express API server ──────────────────────────────────────────────────
echo ""
echo "Starting API server on http://localhost:$API_PORT"
echo "API logs: $API_LOG_FILE"
@@ -98,7 +135,6 @@ echo "API server PID: $(cat "$API_PID_FILE")"
# Give the API a moment to initialise before Vite starts
sleep 1
# ── Start Vite dev server ─────────────────────────────────────────────────────
echo ""
echo "Starting Vite dev server on http://localhost:$VITE_PORT"
echo "Vite logs: $VITE_LOG_FILE"
+320
View File
@@ -0,0 +1,320 @@
// How the app moves and recovers: the Back button, the ⌘K palette, Undo, sync conflicts, backups.
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
import App from './App';
import { resetLexiconCaches } from './utils/wordStudy.js';
import { switchStorageUser } from './utils/bibleUtils.jsx';
import { resetSyncBases } from './utils/syncBase.js';
const chapterData = {
chapter: { content: [
{ type: 'verse', number: 1, content: ['In the beginning God created.'] },
{ type: 'verse', number: 2, content: ['The earth was without form.'] },
] },
};
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
// A fake server: signed in as `user`, a settable answer for saving studies, and a log of every call.
function stubServer({ user = null, putStatus = 200 } = {}) {
const calls = [];
const state = { putStatus };
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`;
calls.push({ route, url, opts });
if (url.includes('bible.helloao.org')) return json(200, chapterData);
if (!url.startsWith('/api/')) return Promise.resolve({ ok: false });
if (route === 'GET /api/auth/me') return user ? json(200, user) : json(401, { error: 'Not signed in' });
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
if (route.startsWith('PUT /api/projects/')) {
return state.putStatus === 409
? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 })
: json(200, { id: url.split('/').pop().split('?')[0], title: 'x', lastEdited: 12345, chapterSummary: '' });
}
return json(404, { error: 'not mocked' });
}));
return { calls, state };
}
beforeEach(() => {
localStorage.clear();
resetLexiconCaches();
resetSyncBases();
switchStorageUser(null);
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false })));
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
});
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
async function renderApp() {
render(<App />);
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
}
function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) {
const project = {
id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null,
chapters: [{
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }],
chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '', crossReferences: [], greekWords: [], tags: [] }],
}],
};
const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]');
index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] });
localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project));
localStorage.setItem('bible-study-index', JSON.stringify(index));
}
describe('Back button', () => {
test('moves back through the pages you visited instead of leaving the app', async () => {
const user = userEvent.setup();
await renderApp();
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /reading plans/i }));
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
window.history.back();
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
window.history.forward();
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
});
});
const paletteBox = async () => within(await screen.findByRole('dialog', { name: /search and go to/i })).getByRole('combobox');
describe('Search palette (⌘K)', () => {
test('a typed passage goes straight to the Bible reader', async () => {
const user = userEvent.setup();
await renderApp();
await user.keyboard('{Control>}k{/Control}');
await user.type(await paletteBox(), 'john 3:16');
expect(await screen.findByRole('option', { name: /John 3:16/ })).toBeInTheDocument();
await user.keyboard('{Enter}');
expect(await screen.findByRole('heading', { name: /John 3/ })).toBeInTheDocument();
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
});
test('finds a note you wrote and opens that study', async () => {
seedProject({ observation: 'Grace abounds to all.' });
const user = userEvent.setup();
await renderApp();
await user.keyboard('{Control>}k{/Control}');
await user.type(await paletteBox(), 'abounds');
const hit = await screen.findByRole('option', { name: /Titus 1:1/ });
await user.click(hit);
expect(await screen.findByText(/Your notes/i)).toBeInTheDocument();
});
test('Escape closes it', async () => {
const user = userEvent.setup();
await renderApp();
await user.keyboard('{Control>}k{/Control}');
await paletteBox();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
});
test('? lists the keyboard shortcuts', async () => {
const user = userEvent.setup();
await renderApp();
await user.keyboard('?');
expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument();
});
});
describe('Undo', () => {
test('deleting a project, then Undo, keeps its notes', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /^delete$/i }));
expect(await screen.findByRole('status')).toHaveTextContent(/deleted/i);
await user.click(screen.getByRole('button', { name: /undo/i }));
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem('bible-study-project-p1')).chapters[0].chunks[0].observation).toBe('Grace abounds to all.');
});
});
describe('Sync conflict', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
async function startStudy(u) {
await renderApp();
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
}
test('a save the server refuses shows the choice instead of overwriting', async () => {
const server = stubServer({ user, putStatus: 409 });
const u = userEvent.setup();
await startStudy(u);
const banner = await screen.findByRole('alert', {}, { timeout: 4000 });
expect(banner).toHaveTextContent(/changed on another device/i);
expect(within(banner).getByRole('button', { name: /keep this version/i })).toBeInTheDocument();
expect(within(banner).getByRole('button', { name: /use the other version/i })).toBeInTheDocument();
// "Keep this version" sends the save again, this time as an overwrite
server.state.putStatus = 200;
await u.click(within(banner).getByRole('button', { name: /keep this version/i }));
await waitFor(() => expect(server.calls.some((c) => c.url.includes('?force=1'))).toBe(true));
await waitFor(() => expect(screen.queryByText(/changed on another device/i)).not.toBeInTheDocument());
}, 15000);
test('opening a study without editing it does not touch the server', async () => {
seedProject();
const server = stubServer({ user });
const u = userEvent.setup();
await renderApp();
// signed in: the namespaced storage starts empty, so seed under the user's keys by resuming nothing
expect(server.calls.filter((c) => c.route.startsWith('PUT')).length).toBe(0);
await u.click(await screen.findByRole('button', { name: /read bible/i }));
expect(server.calls.filter((c) => c.route.startsWith('PUT /api/projects')).length).toBe(0);
});
});
describe('Outline stage', () => {
async function openOutline() {
// local-only mode (the account server is unreachable), with chapter text available
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
const user = userEvent.setup();
await renderApp();
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
// one chunk covering verses 1-2
const verses = within(screen.getByTestId('verse-list'));
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^outline$/i }));
await screen.findByRole('heading', { name: 'Outline' });
return user;
}
test('starts empty, and "Start from my chunks" makes a main point for each chunk', async () => {
const user = await openOutline();
expect(screen.getByText(/no outline yet/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
const list = await screen.findByRole('list', { name: 'Outline' });
expect(within(list).getAllByRole('textbox')).toHaveLength(1);
expect(within(list).getByText(/Open chunk in Study/i)).toBeInTheDocument();
});
test('Enter adds a point, Tab makes it a sub-point, and they are numbered I. and A.', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
const first = await screen.findByRole('textbox', { name: 'Point I.' });
await user.type(first, 'Creation{Enter}');
const second = await screen.findByRole('textbox', { name: 'Point II.' });
await user.type(second, 'Light{Tab}');
expect(await screen.findByRole('textbox', { name: 'Point A.' })).toHaveValue('Light');
expect(screen.getByRole('textbox', { name: 'Point I.' })).toHaveValue('Creation');
});
test('deleting a point can be undone', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'Keep me');
await user.click(screen.getByRole('button', { name: /delete i\./i }));
expect(screen.queryByRole('textbox', { name: 'Point I.' })).not.toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: /undo/i }));
expect(await screen.findByRole('textbox', { name: 'Point I.' })).toHaveValue('Keep me');
});
});
describe('Outline: per chunk, details, print and share', () => {
async function openOutline() {
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
const user = userEvent.setup();
await renderApp();
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
const verses = within(screen.getByTestId('verse-list'));
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^outline$/i }));
await screen.findByRole('heading', { name: 'Outline' });
return user;
}
test('each chunk has its own outline, separate from the whole study\'s', async () => {
const user = await openOutline();
const picker = screen.getByRole('combobox', { name: 'Outline for' });
await user.selectOptions(picker, picker.querySelectorAll('option')[1]); // the chunk
expect(await screen.findByText(/no outline yet for/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /start from the verses/i }));
const list = await screen.findByRole('list', { name: 'Outline' });
expect(within(list).getAllByRole('textbox')).toHaveLength(2); // one point per verse
expect(within(list).getByText(/In the beginning God created/)).toBeInTheDocument(); // with its words
await user.selectOptions(screen.getByRole('combobox', { name: 'Outline for' }), 'study');
expect(await screen.findByText(/^No outline yet$/)).toBeInTheDocument(); // the whole study is untouched
expect(screen.getByRole('option', { name: /· 2/ })).toBeInTheDocument(); // the chunk shows its count
});
test('a point can be given a type and minutes, and the total shows', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'The story');
await user.click(screen.getByRole('button', { name: /details for i\./i }));
await user.selectOptions(screen.getByLabelText('Type'), 'illustration');
await user.type(screen.getByLabelText('Minutes'), '5');
expect(await screen.findByText('About 5 minutes')).toBeInTheDocument();
await user.type(screen.getByRole('textbox', { name: 'Notes for I.' }), 'Tell it slowly');
await user.click(screen.getByRole('button', { name: /details for i\./i })); // close: the note shows as a line
expect(await screen.findByText('Tell it slowly')).toBeInTheDocument();
expect(screen.getByText('Illustration')).toBeInTheDocument();
});
test('"Add verse text" puts the passage\'s words into the point', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
await user.click(await screen.findByRole('button', { name: /details for i\./i }));
await user.type(screen.getByLabelText('Passage'), 'Titus 1:1');
await user.click(screen.getByRole('button', { name: /add verse text/i }));
expect(await screen.findByText(/In the beginning God created/)).toBeInTheDocument();
await user.clear(screen.getByLabelText('Passage'));
await user.type(screen.getByLabelText('Passage'), 'nonsense');
await user.click(screen.getByRole('button', { name: /replace verse text/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i);
});
test('Share copies the outline when there is no share sheet; Print opens a page of it', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
await screen.findByRole('list', { name: 'Outline' });
await user.click(screen.getByRole('button', { name: /^share$/i }));
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
const doc = { write: vi.fn(), close: vi.fn() };
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
await user.click(screen.getByRole('button', { name: /print \/ pdf/i }));
expect(doc.write.mock.calls[0][0]).toContain('class="outline"');
});
});
describe('Study cards on Home', () => {
test('a long run of chapters and many tags stay short', async () => {
const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', ');
const tags = ['A', 'B', 'C', 'D', 'E', 'F'];
localStorage.setItem('bible-study-index', JSON.stringify([{ id: 'p', title: 'Luke', lastEdited: Date.now(), chapterSummary: chapters, tags }]));
await renderApp();
expect(await screen.findByText('Luke 1\u201324')).toBeInTheDocument();
expect(screen.getByText('+3 more')).toBeInTheDocument();
expect(screen.queryByText('F', { selector: 'span' })).not.toBeInTheDocument(); // only in the Tag filter
});
});
describe('Window title', () => {
test('names the page you are on, including the chapter in the reader', async () => {
const user = userEvent.setup();
await renderApp();
expect(document.title).toBe('My Studies · Bible Study');
await user.click(screen.getByRole('button', { name: /reading plans/i }));
await waitFor(() => expect(document.title).toBe('Reading Plans · Bible Study'));
await user.click(screen.getByRole('button', { name: /read bible/i }));
await waitFor(() => expect(document.title).toMatch(/^[A-Za-z0-9 ]+ \d+ · Bible Study$/));
});
});
+426 -4281
View File
File diff suppressed because it is too large. Load diff
+1106 -21
View File
File diff suppressed because it is too large. Load diff
+177
View File
@@ -0,0 +1,177 @@
// A compact media-player control: big play/pause button, seekable progress
// bar, and transport buttons. Presentation only — playback state and handlers
// come from useAudio.js so the Home (whole book) and Reader (chapter) players
// can share this UI.
function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${String(secs).padStart(2, '0')}`;
}
function PlayIcon() {
return (
<svg viewBox="0 0 20 20" width="20" height="20" fill="currentColor">
<path d="M5.5 3.6c0-.9 1-1.45 1.76-.97l9.4 5.9c.72.45.72 1.5 0 1.94l-9.4 5.9c-.76.48-1.76-.07-1.76-.97V3.6z" />
</svg>
);
}
function PauseIcon() {
return (
<svg viewBox="0 0 20 20" width="20" height="20" fill="currentColor">
<rect x="4.5" y="3.5" width="4" height="13" rx="1" />
<rect x="11.5" y="3.5" width="4" height="13" rx="1" />
</svg>
);
}
function StopIcon() {
return (
<svg viewBox="0 0 20 20" width="16" height="16" fill="currentColor">
<rect x="4.5" y="4.5" width="11" height="11" rx="1.5" />
</svg>
);
}
function Seek10Icon({ direction }) {
const flip = direction === 'back' ? 'scale(-1,1) translate(-20,0)' : undefined;
return (
<svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6">
<g transform={flip}>
<path d="M4 8.5a6.5 6.5 0 1 1 1.2 5.3" strokeLinecap="round" />
<path d="M3 5.5v3.3h3.3" strokeLinecap="round" strokeLinejoin="round" />
</g>
<text x="10" y="12.2" fontSize="6.5" fontFamily="sans-serif" stroke="none" fill="currentColor" textAnchor="middle">10</text>
</svg>
);
}
function NextTrackIcon() {
return (
<svg viewBox="0 0 20 20" width="16" height="16" fill="currentColor">
<path d="M5 4.3c0-.8.9-1.3 1.6-.9l7.4 4.7c.7.4.7 1.4 0 1.8l-7.4 4.7c-.7.4-1.6-.1-1.6-.9V4.3z" />
<rect x="14" y="3.7" width="1.6" height="12.6" rx="0.5" />
</svg>
);
}
export default function AudioPlayer({
title,
subtitle,
errorMessage,
status, // 'idle' | 'playing' | 'paused' | 'error'
currentTime = 0,
duration = 0,
onPlay,
onTogglePause,
onSeekBack,
onSeekForward,
onSeekTo,
onNext,
nextDisabled,
onStop,
controls, // optional extra controls (e.g. book/narrator selects), rendered top-right
}) {
const isActive = status === 'playing' || status === 'paused';
const progressPct = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
return (
<div className="rounded-3xl border border-[var(--r-line,#e2e8f0)] bg-[var(--r-bar,#ffffff)] p-4 sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-[10rem] flex-1">
<h3 className="truncate text-sm font-semibold text-[var(--r-text,#0f172a)] sm:text-base">{title}</h3>
<p className="mt-0.5 truncate text-xs text-[var(--r-sub,#64748b)] sm:text-sm">{subtitle}</p>
{status === 'error' && errorMessage && (
<p className="mt-1 text-xs text-rose-600">{errorMessage}</p>
)}
</div>
{controls && <div className="flex flex-wrap items-center gap-2">{controls}</div>}
</div>
{isActive && (
<div className="mt-4">
<input
type="range"
min={0}
max={duration || 0}
step={0.1}
value={Math.min(currentTime, duration || 0)}
onChange={(e) => onSeekTo?.(Number(e.target.value))}
disabled={!duration}
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-[var(--r-line,#e2e8f0)] accent-[var(--r-accent,#0284c7)] disabled:cursor-default"
style={{ background: `linear-gradient(to right, var(--r-accent, rgb(2 132 199)) ${progressPct}%, var(--r-line, rgb(226 232 240)) ${progressPct}%)` }}
aria-label="Seek"
/>
<div className="mt-1 flex justify-between text-xs tabular-nums text-[var(--r-sub,#64748b)]">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
)}
<div className="mt-3 flex items-center justify-center gap-3">
{isActive ? (
<>
<button
type="button"
onClick={onSeekBack}
title="Rewind 10 seconds"
aria-label="Rewind 10 seconds"
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
>
<Seek10Icon direction="back" />
</button>
<button
type="button"
onClick={onTogglePause}
aria-label={status === 'paused' ? 'Resume' : 'Pause'}
className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--r-accent,#0284c7)] text-[var(--r-on,#ffffff)] hover:opacity-90"
>
{status === 'paused' ? <PlayIcon /> : <PauseIcon />}
</button>
<button
type="button"
onClick={onSeekForward}
title="Fast-forward 10 seconds"
aria-label="Fast-forward 10 seconds"
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
>
<Seek10Icon direction="forward" />
</button>
{onNext && (
<button
type="button"
onClick={onNext}
disabled={nextDisabled}
title="Next chapter"
aria-label="Next chapter"
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent"
>
<NextTrackIcon />
</button>
)}
<button
type="button"
onClick={onStop}
title="Stop"
aria-label="Stop"
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
>
<StopIcon />
</button>
</>
) : (
<button
type="button"
onClick={onPlay}
className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--r-accent,#0284c7)] text-[var(--r-on,#ffffff)] hover:opacity-90"
aria-label="Play"
>
<PlayIcon />
</button>
)}
</div>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
// Press ⌘K (Ctrl+K) anywhere: type a passage ("john 3:16") to go straight to it, or a few letters to
// find a study, a note you wrote, a bookmark or highlight, a page, or to search the Bible's text.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars } from './reader/readerThemes.js';
import { SearchIcon } from './reader/icons.jsx';
import { formatPassage, parsePassageRef } from '../utils/passageRef.js';
import { searchMarks, searchNotes, searchProjects } from '../utils/personalSearch.js';
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
import { loadProjectById } from '../utils/projectStorage.js';
export default function CommandPalette({ onClose }) {
const {
readerTheme, authUser, authServerDown, projectIndex, readerBookmarks, readerTextHighlights, readerColorLabels,
jumpToReaderVerse, setCurrentPage, setProject, resumeProject, openNewProject, goStudies, openReadingPlans,
openBibleReader, requestReaderSearch, openBackup,
} = useApp();
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const listRef = useRef(null);
const inputRef = useRef(null);
const opener = useRef(document.activeElement);
useEffect(() => {
inputRef.current?.focus();
const from = opener.current;
return () => { if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
}, []);
const items = useMemo(() => {
const out = [];
const q = query.trim();
const go = (fn) => () => { onClose(); fn(); };
// 1. A passage typed in
for (const r of parsePassageRef(q)) {
out.push({ group: 'Go to', label: formatPassage(r), hint: 'Open in the Bible', run: go(() => { jumpToReaderVerse(r.book, r.chapter, r.verse ?? 1); openBibleReader(); }) });
}
// 2. Pages and actions
const pages = [
['Studies', 'My Studies', goStudies], ['Read Bible', 'The Bible reader', openBibleReader], ['Reading Plans', 'Plans and today’s reading', openReadingPlans],
['New project', 'Start a Bible study', openNewProject], ['Settings', 'Account and preferences', () => setCurrentPage('settings')],
['Back up my data', 'Download everything as a file', openBackup],
];
const lower = q.toLowerCase();
for (const [label, hint, fn] of pages) {
if (!lower || label.toLowerCase().includes(lower)) out.push({ group: lower ? 'Pages' : 'Go to', label, hint, run: go(fn) });
}
if (q.length >= 2) {
// 3. Studies
for (const e of searchProjects(projectIndex, q)) {
out.push({ group: 'Studies', label: e.title, hint: e.chapterSummary, run: go(() => resumeProject(e.id)) });
}
// 4. Notes you wrote
const projects = loadProjectIndex().map((e) => loadProjectById(e.id)).filter(Boolean);
for (const h of searchNotes(projects, q)) {
out.push({
group: 'Your notes', label: `${h.ref} — ${h.projectTitle}`, hint: h.snippet,
run: go(() => {
const full = loadProjectById(h.projectId);
if (!full) return;
setProject({ ...full, selectedChunkId: h.chunkId });
setCurrentPage('study');
}),
});
}
// 5. Bookmarks and highlights
for (const m of searchMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights, labels: readerColorLabels ?? {} }, q)) {
out.push({
group: 'Bookmarks & highlights', label: `${m.bookName} ${m.chapter}:${m.verse}`, color: m.color,
hint: m.kind === 'highlight' ? `${m.label ? `${m.label}: ` : ''}${m.text ?? 'Highlight'}` : 'Bookmark',
run: go(() => { jumpToReaderVerse(m.book, m.chapter, m.verse); openBibleReader(); }),
});
}
// 6. The Bible's own text
out.push({ group: 'Search', label: `Search the Bible for “${q}”`, hint: 'Whole-Bible search in the reader', run: go(() => { requestReaderSearch(q); openBibleReader(); }) });
}
return out;
}, [query]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { setActive(0); }, [query]);
useEffect(() => { listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest' }); }, [active]);
const onKeyDown = (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(a + 1, items.length - 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
else if (e.key === 'Enter') { e.preventDefault(); items[active]?.run(); }
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); }
else if (e.key === 'Tab') { e.preventDefault(); } // the box and the list are one control
};
if (authUser === null && !authServerDown) return null; // the sign-in screen has nothing to search
let lastGroup = null;
return (
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-start justify-center bg-black/40 px-3 pt-[12vh] font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div role="dialog" aria-modal="true" aria-label="Search and go to" className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] shadow-2xl">
<div className="flex items-center gap-2 border-b border-[var(--r-line)] px-4 py-3">
<span className="text-[var(--r-sub)]"><SearchIcon /></span>
<input
ref={inputRef} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKeyDown}
role="combobox" aria-expanded="true" aria-controls="palette-list" aria-autocomplete="list"
aria-activedescendant={items[active] ? `palette-item-${active}` : undefined}
placeholder="Go to a passage, or search your studies and notes…"
className="min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-[var(--r-sub)]"
/>
<kbd className="hidden rounded border border-[var(--r-line)] px-1.5 py-0.5 text-[10px] text-[var(--r-sub)] sm:block">esc</kbd>
</div>
<ul id="palette-list" role="listbox" ref={listRef} className="max-h-[50vh] overflow-y-auto p-2">
{items.length === 0 && <li className="px-3 py-6 text-center text-sm text-[var(--r-sub)]">Nothing found. Try a book and chapter, like “romans 8”.</li>}
{items.map((item, i) => {
const header = item.group !== lastGroup ? item.group : null;
lastGroup = item.group;
return (
<li key={i} role="presentation">
{header && <p className="px-3 pb-1 pt-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{header}</p>}
<div id={`palette-item-${i}`} role="option" aria-selected={i === active} onMouseMove={() => setActive(i)} onClick={item.run}
className={`flex cursor-pointer items-baseline gap-2 rounded-lg px-3 py-2 text-sm ${i === active ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : ''}`}>
{item.color && <span className="h-2.5 w-2.5 shrink-0 translate-y-px rounded-full border border-black/10" style={{ backgroundColor: item.color }} />}
<span className="shrink-0 font-medium">{item.label}</span>
{item.hint && <span className={`min-w-0 truncate text-xs ${i === active ? 'opacity-80' : 'text-[var(--r-sub)]'}`}>{item.hint}</span>}
</div>
</li>
);
})}
</ul>
<p className="border-t border-[var(--r-line)] px-4 py-2 text-[11px] text-[var(--r-sub)]">↑↓ to move · Enter to open · Esc to close</p>
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
// The "Save for offline" control: a button, or progress with a Stop button while a download runs.
import { canSaveOffline } from '../utils/offline.js';
export function DownloadProgress({ job, onCancel }) {
const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
return (
<div role="status" className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span>Saving {job.label}… {job.done} of {job.total}</span>
<button type="button" onClick={onCancel} className="text-[var(--r-accent)] hover:underline">Stop</button>
</div>
<div className="h-1 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)] transition-[width]" style={{ width: `${pct}%` }} /></div>
</div>
);
}
// One book of a translation.
export function SaveBookOffline({ offline, translation, book, name }) {
if (!canSaveOffline(translation)) {
return <p className="mt-4 text-[11px] text-[var(--r-sub)]">Saving for offline works with the BSB and NET. The {translation}’s licence doesn’t allow it.</p>;
}
if (offline.job) return <div className="mt-4"><DownloadProgress job={offline.job} onCancel={offline.cancel} /></div>;
const saved = offline.isSaved(translation, book);
return (
<div className="mt-4 flex items-center justify-between gap-3 rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm">
<span className={saved ? '' : 'text-[var(--r-sub)]'}>{saved ? `✓ ${name} (${translation}) is saved for offline` : `Read ${name} without a connection`}</span>
{!saved && (
<button type="button" onClick={() => offline.saveBook(translation, book, name)} className="shrink-0 rounded-full border border-[var(--r-line)] px-3 py-1 text-xs font-medium hover:bg-black/5">Save for offline</button>
)}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
// The "?" cheat sheet: every keyboard shortcut for the page you are on, plus the ones that work everywhere.
import { useEffect, useRef } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars } from './reader/readerThemes.js';
const GLOBAL = [
['⌘K / Ctrl K', 'Search and go to a passage'],
['?', 'Show this list'],
['Esc', 'Close whatever is open'],
];
const BY_PAGE = {
reader: [
['← →', 'Previous / next chapter'],
['/', 'Search this chapter or the whole Bible'],
['G', 'Pick a book and chapter'],
['B', 'Bookmark the selected verse'],
['C', 'Compare translations'],
[',', 'Reading settings'],
['F', 'Hide or show the controls'],
],
study: [
['← →', 'Previous / next chunk'],
],
};
export default function ShortcutsHelp({ onClose }) {
const { readerTheme, currentPage } = useApp();
const closeRef = useRef(null);
useEffect(() => {
const from = document.activeElement;
closeRef.current?.focus();
const onKey = (e) => { if (e.key === 'Escape' || e.key === '?') { e.preventDefault(); e.stopPropagation(); onClose(); } };
document.addEventListener('keydown', onKey, true);
return () => { document.removeEventListener('keydown', onKey, true); if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
}, [onClose]);
const page = BY_PAGE[currentPage] ?? [];
const section = (title, rows) => (
<div className="mb-4 last:mb-0">
<h3 className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{title}</h3>
<dl className="space-y-1.5">
{rows.map(([keys, what]) => (
<div key={keys} className="flex items-center justify-between gap-4 text-sm">
<dt><kbd className="rounded-md border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 font-mono text-xs">{keys}</kbd></dt>
<dd className="text-right text-[var(--r-sub)]">{what}</dd>
</div>
))}
</dl>
</div>
);
return (
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-3 font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" className="max-h-[80vh] w-full max-w-sm overflow-y-auto rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5 text-[var(--r-text)] shadow-2xl">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-medium">Keyboard shortcuts</h2>
<button ref={closeRef} type="button" onClick={onClose} className="rounded-lg px-2 py-1 text-sm text-[var(--r-sub)] hover:bg-black/5">Close</button>
</div>
{page.length > 0 && section(currentPage === 'reader' ? 'In the Bible' : 'In this study', page)}
{section('Everywhere', GLOBAL)}
</div>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
// Inline study panel shown under a verse in the Reader: commentary from several
// public-domain / open sources, plus the people, places and events tied to the verse.
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import {
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
} from '../hooks/useVerseStudy.js';
export function CommentaryTab({ book, chapter, verse }) {
// The chosen commentary is a synced preference, shared with the Study stage
const { readerCommentary: sourceId, setReaderCommentary: changeSource } = useApp();
const source = COMMENTARIES.find((c) => c.id === sourceId) ?? COMMENTARIES[0];
const { status, data } = useAsync(() => loadCommentary(source.id, book, chapter), [source.id, book, chapter]);
const entry = pickCommentaryEntry(data, verse);
return (
<div>
<label className="text-xs text-[var(--r-sub,#64748b)]">
Commentary{' '}
<select value={source.id} onChange={(e) => changeSource(e.target.value)}
className="rounded-lg border border-[var(--r-line,#cbd5e1)] bg-[var(--r-bg,#ffffff)] px-2 py-1 text-xs">
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</label>
<div className="mt-2">
{status === 'loading' && <p className="text-xs text-[var(--r-sub,#64748b)]">Loading…</p>}
{status === 'empty' && <p className="text-xs text-[var(--r-sub,#64748b)]">{source.name} has no notes for this chapter.</p>}
{status === 'ready' && !entry && <p className="text-xs text-[var(--r-sub,#64748b)]">No notes at or before this verse in {source.name}.</p>}
{status === 'ready' && entry && (
<>
{entry.number !== verse && (
<p className="mb-1 text-[11px] text-[var(--r-sub,#64748b)]">Notes on the passage starting at verse {entry.number}.</p>
)}
<div className="max-h-96 overflow-y-auto whitespace-pre-line rounded-xl bg-[var(--r-bg,#ffffff)] p-3 text-[13px] leading-relaxed text-[var(--r-text,#334155)]">
{entry.text}
</div>
</>
)}
</div>
<p className="mt-2 text-[10px] text-[var(--r-sub,#64748b)]">{source.credit}</p>
</div>
);
}
function PersonDetails({ id }) {
const { status, data } = useAsync(() => loadPerson(id), [id]);
if (status === 'loading') return <p className="mt-1 text-xs text-[var(--r-sub,#64748b)]">Loading…</p>;
if (status === 'empty') return <p className="mt-1 text-xs text-[var(--r-sub,#64748b)]">No details available.</p>;
const era = (y) => `${Math.abs(y)}${y < 0 ? ' BC' : ' AD'}`;
return (
<div className="mt-1 space-y-1 text-xs leading-relaxed text-[var(--r-sub,#64748b)]">
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
{!data.description?.length && <p>No description available.</p>}
{data.minYear != null && (
<p className="text-[var(--r-sub,#64748b)]">
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
</p>
)}
</div>
);
}
export function PersonCard({ person }) {
const [open, setOpen] = useState(false);
return (
<li className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2">
<button type="button" onClick={() => setOpen((o) => !o)} className="w-full text-left text-sm font-semibold text-[var(--r-text,#334155)]">
{person.name} <span className="text-xs font-normal text-[var(--r-sub,#64748b)]">{open ? '▾' : '▸'}</span>
</button>
{open && <PersonDetails id={person.id} />}
</li>
);
}
export function PeoplePlacesTab({ book, chapter, verse }) {
const { status, data } = useAsync(() => loadTheographic(book, chapter), [book, chapter]);
if (status === 'loading') return <p className="text-xs text-[var(--r-sub,#64748b)]">Loading…</p>;
if (status === 'empty') return <p className="text-xs text-[var(--r-sub,#64748b)]">No people or places data for this chapter.</p>;
const here = (list) => list.filter((x) => x.verses?.includes(verse));
// Theographic lists some places twice (variant spellings) — collapse by name.
const places = [...new Map(here(data.places).map((p) => [p.name, p])).values()];
const people = here(data.people);
const events = here(data.events);
if (!people.length && !places.length && !events.length) {
return <p className="text-xs text-[var(--r-sub,#64748b)]">Nothing tagged on verse {verse}.</p>;
}
return (
<div className="space-y-3">
{people.length > 0 && (
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub,#64748b)]">People</p>
<ul className="space-y-1">{people.map((p) => <PersonCard key={p.id} person={p} />)}</ul>
</div>
)}
{places.length > 0 && (
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub,#64748b)]">Places</p>
<ul className="space-y-1">
{places.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-2 rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2 text-sm text-[var(--r-text,#334155)]">
<span>{p.name}{p.featureType && <span className="ml-1 text-xs text-[var(--r-sub,#64748b)]">{p.featureType}</span>}</span>
{p.latitude != null && p.longitude != null && (
<a href={`https://www.openstreetmap.org/?mlat=${p.latitude}&mlon=${p.longitude}#map=8/${p.latitude}/${p.longitude}`}
target="_blank" rel="noopener noreferrer" className="text-xs font-semibold text-sky-600 hover:underline">
Map ↗
</a>
)}
</li>
))}
</ul>
</div>
)}
{events.length > 0 && (
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub,#64748b)]">Events</p>
<ul className="space-y-1">
{events.map((e) => (
<li key={e.id} className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2 text-sm text-[var(--r-text,#334155)]">
{e.name}{e.startDate && <span className="ml-1 text-xs text-[var(--r-sub,#64748b)]">approx. AD {e.startDate}</span>}
</li>
))}
</ul>
</div>
)}
<p className="text-[10px] text-[var(--r-sub,#64748b)]">
People, places and events: Theographic Bible Metadata, licensed CC BY-SA 4.0.
</p>
</div>
);
}
export default function StudyPanel({ book, chapter, verse, onClose }) {
const [tab, setTab] = useState('commentary');
const tabBtn = (id, label) => (
<button type="button" onClick={() => setTab(id)}
className={`rounded-lg px-3 py-1 text-xs font-semibold ${tab === id ? 'bg-slate-900 text-white' : 'bg-[var(--r-bg,#ffffff)] text-[var(--r-sub,#64748b)] hover:bg-slate-100'}`}>
{label}
</button>
);
return (
<div className="mt-2 mb-1 ml-6 rounded-2xl border border-indigo-100 bg-indigo-50 p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex gap-1">
{tabBtn('commentary', 'Commentary')}
{tabBtn('people', 'People & Places')}
</div>
<button type="button" onClick={onClose} className="rounded p-1 text-xs text-[var(--r-sub,#64748b)] hover:bg-[var(--r-bg,#ffffff)]" title="Close">✕</button>
</div>
{tab === 'commentary'
? <CommentaryTab book={book} chapter={chapter} verse={verse} />
: <PeoplePlacesTab book={book} chapter={chapter} verse={verse} />}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
// App-wide toasts: short messages at the bottom of the screen, optionally with one
// action button (usually "Undo"). Used instead of alert()/confirm() so a slip is
// reversible and nothing blocks the page.
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { themeVars } from './reader/readerThemes.js';
const ToastContext = createContext(null);
// toast(message, { action: { label, onClick }, ms, tone: 'info' | 'error' }) -> id
// Outside a provider (a page rendered on its own in a test) it quietly does nothing.
const NOOP = { toast: () => 0, dismiss: () => {}, setTheme: () => {} };
export const useToast = () => useContext(ToastContext) ?? NOOP;
let nextId = 1;
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const [theme, setTheme] = useState('paper'); // set by the app so toasts match the reading theme
const timers = useRef(new Map());
const dismiss = useCallback((id) => {
window.clearTimeout(timers.current.get(id));
timers.current.delete(id);
setToasts((list) => list.filter((t) => t.id !== id));
}, []);
const toast = useCallback((message, { action, ms, tone = 'info' } = {}) => {
const id = nextId++;
// Messages with an action stay long enough to read and react to.
const duration = ms ?? (action ? 8000 : tone === 'error' ? 6000 : 3500);
setToasts((list) => [...list.slice(-2), { id, message, action, tone }]);
timers.current.set(id, window.setTimeout(() => dismiss(id), duration));
return id;
}, [dismiss]);
useEffect(() => {
const live = timers.current;
return () => live.forEach((t) => window.clearTimeout(t));
}, []);
const value = useMemo(() => ({ toast, dismiss, setTheme }), [toast, dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<div style={themeVars(theme)} className="pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] font-sans">
{toasts.map((t) => (
<div key={t.id} role={t.tone === 'error' ? 'alert' : 'status'}
className={`pointer-events-auto flex max-w-md items-center gap-3 rounded-full px-4 py-2.5 text-sm shadow-lg ${t.tone === 'error' ? 'bg-[var(--r-danger)] text-[var(--r-bg)]' : 'bg-[var(--r-text)] text-[var(--r-bg)]'}`}>
<span>{t.message}</span>
{t.action && (
<button type="button" onClick={() => { t.action.onClick(); dismiss(t.id); }}
className="shrink-0 rounded-full px-2 py-0.5 text-sm font-semibold underline underline-offset-2 hover:opacity-80">
{t.action.label}
</button>
)}
</div>
))}
</div>
</ToastContext.Provider>
);
}
+275
View File
@@ -0,0 +1,275 @@
// Form for creating or editing a custom reading plan (title, days, cover photo,
// books and chapter ranges). Themed with the reader's --r-* colour variables.
import { useRef, useState } from 'react';
import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx';
import {
bookChapterCount, clampPlanDays, ALL_BOOKS, OT_BOOKS, NT_BOOKS, GOSPELS, PENTATEUCH, WISDOM_BOOKS,
} from '../../utils/readingPlans.js';
// Downscales a picked image file to a reasonable cover-photo size and returns
// a data URL — no server upload endpoint needed, it's stored right on the plan.
export function resizeImageToDataUrl(file, maxWidth = 960, quality = 0.82) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Could not read file.'));
reader.onload = () => {
const img = new Image();
img.onerror = () => reject(new Error('Could not decode image.'));
img.onload = () => {
const scale = Math.min(1, maxWidth / img.width);
const canvas = document.createElement('canvas');
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.src = reader.result;
};
reader.readAsDataURL(file);
});
}
const BOOK_PRESETS = [
['All', ALL_BOOKS],
['None', []],
['Old Testament', OT_BOOKS],
['New Testament', NT_BOOKS],
['Gospels', GOSPELS],
['Pentateuch', PENTATEUCH],
['Psalms', ['PSA']],
['Proverbs', ['PRO']],
['Wisdom', WISDOM_BOOKS],
];
export default function CustomPlanBuilder({ initialValues, onSubmit, onCancel }) {
const [title, setTitle] = useState(initialValues?.title ?? '');
const [description, setDescription] = useState(initialValues?.description ?? '');
const [days, setDays] = useState(initialValues?.days ?? 30);
const [books, setBooks] = useState(initialValues?.books ?? []);
const [chapterRanges, setChapterRanges] = useState(initialValues?.chapterRanges ?? {});
const [image, setImage] = useState(initialValues?.image ?? null);
const [imageError, setImageError] = useState('');
const fileInputRef = useRef(null);
const isEditing = !!initialValues;
const toggleBook = (abbrev) => {
const isOn = books.includes(abbrev);
setBooks((prev) => (isOn ? prev.filter((b) => b !== abbrev) : [...prev, abbrev]));
if (isOn) {
setChapterRanges((prev) => {
if (!(abbrev in prev)) return prev;
const next = { ...prev };
delete next[abbrev];
return next;
});
}
};
const setChapterRange = (abbrev, key, value) => {
const total = CHAPTER_COUNTS[abbrev] ?? 1;
const clamped = Math.min(total, Math.max(1, Math.round(value) || 1));
setChapterRanges((prev) => ({
...prev,
[abbrev]: { start: prev[abbrev]?.start ?? 1, end: prev[abbrev]?.end ?? total, [key]: clamped },
}));
};
const applyPreset = (list) => {
setBooks(list);
setChapterRanges({});
};
const pickImage = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
try {
setImageError('');
setImage(await resizeImageToDataUrl(file));
} catch {
setImageError("Couldn't use that image.");
}
};
const totalChapters = books.reduce((sum, b) => sum + bookChapterCount(b, chapterRanges), 0);
const canCreate = title.trim().length > 0 && totalChapters > 0 && days >= 1;
return (
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
<h3 className="mb-3 text-sm font-semibold text-[var(--r-text)]">{isEditing ? 'Edit reading plan' : 'New reading plan'}</h3>
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm text-[var(--r-text)]">
Title
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Advent Readings"
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/>
</label>
<label className="text-sm text-[var(--r-text)]">
Days
<input
type="number"
min="1"
max="365"
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/>
</label>
</div>
<label className="mt-3 block text-sm text-[var(--r-text)]">
Description <span className="text-[var(--r-sub)]">(optional)</span>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
/>
</label>
<div className="mt-3">
<span className="text-sm text-[var(--r-text)]">Cover photo <span className="text-[var(--r-sub)]">(optional)</span></span>
<div className="mt-1 flex items-center gap-3">
{image ? (
<img src={image} alt="" className="h-16 w-28 rounded-lg object-cover" />
) : (
<div className="flex h-16 w-28 items-center justify-center rounded-lg bg-[var(--r-bg)] text-2xl">🖼️</div>
)}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-1.5 text-sm font-semibold text-[var(--r-text)] hover:bg-black/5"
>
{image ? 'Change photo' : 'Choose photo'}
</button>
{image && (
<button type="button" onClick={() => setImage(null)} className="text-sm text-[var(--r-sub)] hover:text-rose-500">
Remove
</button>
)}
<input ref={fileInputRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
</div>
{imageError && <p className="mt-1 text-xs text-rose-500">{imageError}</p>}
</div>
<div className="mt-4">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-[var(--r-text)]">Books</span>
{BOOK_PRESETS.map(([label, list]) => (
<button
key={label}
type="button"
onClick={() => applyPreset(list)}
className="rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-2.5 py-1 text-xs font-semibold text-[var(--r-text)] hover:bg-black/5"
>
{label}
</button>
))}
</div>
<p className="mb-2 text-xs text-[var(--r-sub)]">
Check a book to include the whole thing, or narrow it to a chapter range with the fields that appear.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="max-h-56 overflow-y-auto rounded-xl border border-[var(--r-line)] p-2">
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub)]">Old Testament</p>
{bookOptions.filter((b) => OT_BOOKS.includes(b.abbrev)).map((b) => (
<BookRow
key={b.abbrev}
book={b}
checked={books.includes(b.abbrev)}
range={chapterRanges[b.abbrev]}
onToggle={() => toggleBook(b.abbrev)}
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
/>
))}
</div>
<div className="max-h-56 overflow-y-auto rounded-xl border border-[var(--r-line)] p-2">
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub)]">New Testament</p>
{bookOptions.filter((b) => NT_BOOKS.includes(b.abbrev)).map((b) => (
<BookRow
key={b.abbrev}
book={b}
checked={books.includes(b.abbrev)}
range={chapterRanges[b.abbrev]}
onToggle={() => toggleBook(b.abbrev)}
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
/>
))}
</div>
</div>
<p className="mt-2 text-xs text-[var(--r-sub)]">
{books.length} book{books.length !== 1 ? 's' : ''} selected · {totalChapters} chapters
{days > 0 && totalChapters > 0 && ` · ~${(totalChapters / Math.min(days, totalChapters)).toFixed(1)} ch/day`}
</p>
{totalChapters > 0 && Number(days) > totalChapters && (
<p className="mt-1 text-xs text-[var(--r-accent)]">
That’s more days than chapters, so the plan will be {totalChapters} days, one chapter a day.
</p>
)}
{isEditing && (
books.join() !== initialValues.books.join()
|| Number(days) !== initialValues.days
|| JSON.stringify(chapterRanges) !== JSON.stringify(initialValues.chapterRanges ?? {})
) && (
<p className="mt-2 text-xs text-amber-600">
Changing the books, chapters, or day count restarts progress for any run of this plan already in your list.
</p>
)}
</div>
<div className="mt-4 flex items-center gap-3">
<button
type="button"
disabled={!canCreate}
onClick={() => onSubmit({ title, description, days: clampPlanDays(books, chapterRanges, days), books, chapterRanges, image })}
className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-semibold text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{isEditing ? 'Save changes' : 'Create plan'}
</button>
<button type="button" onClick={onCancel} className="text-sm text-[var(--r-sub)] hover:text-[var(--r-text)]">Cancel</button>
</div>
</div>
);
}
// One book row in the custom-plan builder: a checkbox, and — once checked — a
// "ch. X–Y" chapter-range pair that defaults to the whole book.
function BookRow({ book, checked, range, onToggle, onRangeChange }) {
const total = CHAPTER_COUNTS[book.abbrev] ?? 1;
return (
<label className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-black/5">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
className="h-4 w-4 shrink-0 rounded border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] accent-[var(--r-accent)]"
/>
<span className="flex-1">{book.name}</span>
{checked && total > 1 && (
<span className="flex shrink-0 items-center gap-1 text-xs text-[var(--r-sub)]" onClick={(e) => e.preventDefault()}>
ch.
<input
type="number"
min={1}
max={total}
value={range?.start ?? 1}
onChange={(e) => onRangeChange('start', e.target.value)}
className="w-11 rounded border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-1 py-0.5 text-center"
/>
–
<input
type="number"
min={1}
max={total}
value={range?.end ?? total}
onChange={(e) => onRangeChange('end', e.target.value)}
className="w-11 rounded border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-1 py-0.5 text-center"
/>
<span className="text-[var(--r-sub)]">/{total}</span>
</span>
)}
</label>
);
}
+135
View File
@@ -0,0 +1,135 @@
// Detail for one plan. Two modes: an in-progress plan (days to tick or jump into,
// its translation, catch-up / restart / remove) and a catalog plan (preview of the
// first days, pick a translation, start it).
import { useRef, useState } from 'react';
import Sheet from '../reader/Sheet.jsx';
import TranslationSelect from './TranslationSelect.jsx';
import { resizeImageToDataUrl } from './CustomPlanBuilder.jsx';
import { formatDayReadings, getPlanSchedule, daysBehindPace, nextUnreadDayIndex } from '../../utils/readingPlans.js';
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
import { DownloadProgress } from '../OfflineSave.jsx';
import { canSaveOffline } from '../../utils/offline.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm hover:bg-black/5';
const primary = 'w-full rounded-xl bg-[var(--r-accent)] py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50';
function ActiveBody({ plan, progress, onClose, actions }) {
const schedule = getPlanSchedule(plan.id);
const behind = daysBehindPace(plan, progress);
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
const offline = useOfflineSave();
const translation = progress.translation ?? actions.defaultTranslation;
// The next week of readings, so a trip or a bad connection doesn't interrupt the plan
const upcoming = (() => {
const from = nextUnreadDayIndex(plan, progress.completedDays);
return from == null ? [] : schedule.slice(from, from + 7).flat();
})();
return (
<>
<div className="mb-3 flex items-center justify-between text-sm">
<span>{progress.completedDays.length} of {plan.days} days · {pct}%</span>
<span className="flex items-center gap-2">
<span className="text-xs text-[var(--r-sub)]">Read in</span>
<TranslationSelect value={progress.translation ?? actions.defaultTranslation} onChange={(t) => actions.setTranslation(progress.id, t)} />
</span>
</div>
<div className="mb-3 h-1.5 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)]" style={{ width: `${pct}%` }} /></div>
{behind >= 2 && (
<div className="mb-3 flex items-center justify-between rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
<span className="text-[var(--r-sub)]">{behind} days behind pace</span>
<button type="button" onClick={() => actions.recalibrate(progress.id)} className="font-medium text-[var(--r-accent)] hover:underline">Catch me up</button>
</div>
)}
<div className="max-h-[38vh] overflow-y-auto rounded-xl border border-[var(--r-line)]">
{schedule.map((refs, i) => {
const done = progress.completedDays.includes(i);
return (
<div key={i} className="flex items-center gap-3 border-b border-[var(--r-line)] px-3 py-2 last:border-0">
<input type="checkbox" checked={done} onChange={() => actions.toggleDay(progress.id, i, plan.days)} className="h-4 w-4 shrink-0 accent-[var(--r-accent)]" aria-label={`Day ${i + 1} read`} />
<span className={`flex-1 text-sm ${done ? 'text-[var(--r-sub)] line-through' : ''}`}>Day {i + 1} · {formatDayReadings(refs)}</span>
<button type="button" onClick={() => { onClose(); actions.read(progress.id, i); }} className="shrink-0 text-xs font-medium text-[var(--r-accent)] hover:underline">Read</button>
</div>
);
})}
</div>
{upcoming.length > 0 && canSaveOffline(translation) && (
<div className="mt-3">
{offline.job
? <DownloadProgress job={offline.job} onCancel={offline.cancel} />
: (
<button type="button" onClick={() => offline.saveRefs('the next 7 days', translation, upcoming)}
className="w-full rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs text-[var(--r-sub)] hover:bg-black/5">
Save the next 7 days for offline ({translation})
</button>
)}
</div>
)}
<div className="mt-4 flex items-center justify-between text-xs">
<button type="button" onClick={() => { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
<button type="button" onClick={() => { actions.remove(progress.id); onClose(); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
</div>
</>
);
}
function CatalogBody({ plan, active, completedCount, onClose, actions }) {
const [translation, setTranslation] = useState(actions.defaultTranslation);
const [imageError, setImageError] = useState('');
const fileRef = useRef(null);
const schedule = getPlanSchedule(plan.id);
const chapters = schedule.reduce((n, d) => n + d.length, 0);
const pickImage = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
try { setImageError(''); actions.updateCover(plan.id, await resizeImageToDataUrl(file)); } catch { setImageError('Couldn’t use that image.'); }
};
return (
<>
{plan.image && <img src={plan.image} alt="" onError={(e) => { e.currentTarget.style.display = 'none'; }} className="mb-3 h-32 w-full rounded-xl object-cover" style={{ objectPosition: plan.imageFocus ?? 'center' }} />}
<p className="text-sm text-[var(--r-sub)]">{plan.description || 'A custom reading plan.'}</p>
<p className="mt-2 text-xs text-[var(--r-sub)]">{plan.days} days · {chapters} chapters · about {(chapters / plan.days).toFixed(1)} a day{completedCount > 0 ? ` · completed ${completedCount}×` : ''}</p>
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">First days</p>
<div className="rounded-xl border border-[var(--r-line)]">
{schedule.slice(0, 5).map((refs, i) => (
<div key={i} className="border-b border-[var(--r-line)] px-3 py-1.5 text-sm last:border-0">Day {i + 1} · {formatDayReadings(refs)}</div>
))}
</div>
{!active && (
<div className="mt-4 flex items-center justify-between text-sm">
<span>Read in</span>
<TranslationSelect value={translation} onChange={setTranslation} />
</div>
)}
<div className="mt-3">
{active
? <button type="button" className={primary} onClick={() => { onClose(); actions.read(active.id, actions.nextDay(plan, active)); }}>Continue reading</button>
: <button type="button" className={primary} onClick={() => { actions.start(plan.id, translation); onClose(); }}>{completedCount > 0 ? 'Start again' : 'Start plan'}</button>}
</div>
{plan.custom && (
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-[var(--r-line)] pt-3 text-xs">
<button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button>
<button type="button" onClick={() => fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}</button>
{plan.image && <button type="button" onClick={() => actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove cover</button>}
<button type="button" onClick={() => { actions.delete(plan.id); onClose(); }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete</button>
<input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
{imageError && <p className="w-full text-[var(--r-danger,#b42318)]">{imageError}</p>}
</div>
)}
</>
);
}
export default function PlanDetailSheet({ plan, progress, active, completedCount, onClose, actions }) {
if (!plan) return null;
return (
<Sheet open onClose={onClose} title={<span>{plan.icon ? `${plan.icon} ` : ''}{plan.title}</span>}>
{progress
? <ActiveBody plan={plan} progress={progress} onClose={onClose} actions={actions} />
: <CatalogBody plan={plan} active={active} completedCount={completedCount} onClose={onClose} actions={actions} />}
</Sheet>
);
}
@@ -0,0 +1,11 @@
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
// Every Bible the app can read in — used for a plan's own translation.
export default function TranslationSelect({ value, onChange, className = '' }) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)}
className={`rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-sm text-[var(--r-text)] ${className}`}>
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
</select>
);
}
+39
View File
@@ -0,0 +1,39 @@
import Sheet from './Sheet.jsx';
import AudioPlayer from '../AudioPlayer.jsx';
// Wraps the existing player. Audio is BSB-only, so licensed translations get a note.
export default function AudioSheet({ open, onClose, translation, bookName, chapter, audio, narrator, onNarrator, onNext, nextDisabled }) {
return (
<Sheet open={open} onClose={onClose} title="Listen">
<AudioPlayer
title={`${bookName} ${chapter}`}
subtitle={audio.state.status === 'error'
? "Couldn't load audio for this chapter/narrator."
: translation === 'ESV'
? 'ESV audio, read by David Cochran Heath. It follows the chapter as you browse.'
: translation !== 'BSB'
? `Audio is BSB only — no ${translation} audio is available, so it won't match the ${translation} text on screen.`
: 'Audio follows the chapter as you browse.'}
status={audio.state.status}
currentTime={audio.progress.currentTime}
duration={audio.progress.duration}
onPlay={audio.play}
onTogglePause={audio.togglePause}
onSeekBack={() => audio.seek(-10)}
onSeekForward={() => audio.seek(10)}
onSeekTo={audio.seekTo}
onNext={onNext}
nextDisabled={nextDisabled}
onStop={audio.stop}
controls={translation === 'ESV' ? null : (
<select value={narrator} onChange={(e) => onNarrator(e.target.value)}
className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
</select>
)}
/>
</Sheet>
);
}
+35
View File
@@ -0,0 +1,35 @@
// Compare translations: pick a second Bible to read beside the one you're reading, and whether the
// words that differ are highlighted. The chapter shows how different the two are.
import Sheet from './Sheet.jsx';
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
export default function CompareSheet({ open, onClose, translation, parallel, onParallel, showDiff, onShowDiff }) {
const chip = (active) => `rounded-full border px-3 py-1.5 text-sm font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
const options = READER_TRANSLATIONS.filter((t) => t.id !== translation);
return (
<Sheet open={open} onClose={onClose} title="Compare translations">
<p className="mb-3 text-sm text-[var(--r-sub)]">
Read another Bible beside the {translation}. You’ll see how different its wording is for the whole chapter and for each verse.
</p>
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Compare {translation} with</span>
<div role="group" aria-label="Translation to compare with" className="mb-4 flex flex-wrap gap-1.5">
{options.map((t) => (
<button key={t.id} type="button" title={t.name} aria-pressed={parallel === t.id} onClick={() => { onParallel(parallel === t.id ? '' : t.id); if (parallel !== t.id) onClose(); }} className={chip(parallel === t.id)}>{t.id}</button>
))}
<button type="button" aria-pressed={!parallel} onClick={() => onParallel('')} className={chip(!parallel)}>Off</button>
</div>
{parallel && (
<>
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Highlight the words that differ</span>
<div role="group" aria-label="Highlight differences" className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{[[true, 'Show'], [false, 'Hide']].map(([id, text]) => (
<button key={String(id)} type="button" aria-pressed={showDiff === id} onClick={() => onShowDiff(id)}
className={`flex-1 px-3 py-2 text-sm ${showDiff === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{text}</button>
))}
</div>
</>
)}
<p className="mt-4 text-[11px] text-[var(--r-sub)]">The wording is compared word by word, ignoring capitals and punctuation. It shows how the wording differs, not which translation is more accurate. Tap any verse and open Compare to see all six translations of it.</p>
</Sheet>
);
}
+13
View File
@@ -0,0 +1,13 @@
// Text with the words that differ from the other translation marked.
export function DiffText({ text, ranges = [] }) {
if (!ranges.length) return text;
const parts = [];
let cursor = 0;
ranges.forEach((r, i) => {
if (r.start > cursor) parts.push(text.slice(cursor, r.start));
parts.push(<span key={i} className="diff-hl">{text.slice(r.start, r.end)}</span>);
cursor = r.end;
});
if (cursor < text.length) parts.push(text.slice(cursor));
return <>{parts}</>;
}
+94
View File
@@ -0,0 +1,94 @@
// Everything you have marked in the Bible: highlights (grouped and filtered by colour, each colour
// named by you — "Promises", "Commands") and bookmarks. Opens from the bookmark button in the top bar.
import { useState } from 'react';
import Sheet from './Sheet.jsx';
import { CloseIcon } from './icons.jsx';
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
export default function MarksSheet({
open, onClose, bookmarks, highlights, colorLabels, onSetLabel, onJump, onRemoveBookmark, onRemoveHighlight,
}) {
const [tab, setTab] = useState('highlights');
const [filter, setFilter] = useState(null); // a highlight colour, or null for all
const [editing, setEditing] = useState(false);
const counts = Object.fromEntries(HIGHLIGHT_COLORS.map((c) => [c, highlights.filter((h) => h.color === c).length]));
const shown = filter ? highlights.filter((h) => h.color === filter) : highlights;
// Colours in use that aren't one of the picker's (older data) still get listed
const total = highlights.length + bookmarks.length;
return (
<Sheet open={open} onClose={onClose} label="Bookmarks and highlights" title={`Bookmarks & highlights${total ? ` (${total})` : ''}`}>
<div role="tablist" aria-label="What to show" className="mb-3 flex gap-1.5">
<button type="button" role="tab" aria-selected={tab === 'highlights'} onClick={() => setTab('highlights')} className={tabBtn(tab === 'highlights')}>Highlights ({highlights.length})</button>
<button type="button" role="tab" aria-selected={tab === 'bookmarks'} onClick={() => setTab('bookmarks')} className={tabBtn(tab === 'bookmarks')}>Bookmarks ({bookmarks.length})</button>
</div>
{tab === 'highlights' && (
<>
{/* Colour key: tap a colour to show only that one; "Name colours" lets you say what each means */}
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by colour">
<button type="button" onClick={() => setFilter(null)} aria-pressed={filter === null} className={tabBtn(filter === null)}>All</button>
{HIGHLIGHT_COLORS.map((c) => (
<button key={c} type="button" onClick={() => setFilter(filter === c ? null : c)} aria-pressed={filter === c}
title={colorTitle(c, colorLabels)}
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${filter === c ? 'border-[var(--r-text)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
<span className="h-3 w-3 rounded-full border border-black/10" style={{ backgroundColor: c }} />
<span>{colorLabels[c] || COLOR_NAMES[c]}</span>
<span className="text-[var(--r-sub)]">{counts[c]}</span>
</button>
))}
<button type="button" onClick={() => setEditing((e) => !e)} className="ml-auto text-xs text-[var(--r-accent)] hover:underline">{editing ? 'Done' : 'Name colours'}</button>
</div>
{editing && (
<div className="mb-3 grid gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 sm:grid-cols-2">
{HIGHLIGHT_COLORS.map((c) => (
<label key={c} className="flex items-center gap-2 text-xs text-[var(--r-sub)]">
<span className="h-4 w-4 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: c }} />
<span className="sr-only">{COLOR_NAMES[c]} means</span>
<input value={colorLabels[c] ?? ''} maxLength={30} onChange={(e) => onSetLabel(c, e.target.value)} placeholder={COLOR_SUGGESTIONS[c]}
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-transparent px-2 py-1 text-sm text-[var(--r-text)] outline-none focus:border-[var(--r-accent)]" />
</label>
))}
</div>
)}
{highlights.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No highlights yet. Select some words in a verse and pick a colour to save them here.</p>
) : shown.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">Nothing highlighted in that colour yet.</p>
) : shown.map((h) => (
<div key={h.id} className="flex items-start gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="mt-1 h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} title={colorTitle(h.color, colorLabels)} />
<button type="button" onClick={() => onJump(h.book, h.chapter, h.verse)} className="min-w-0 flex-1 text-left text-sm">
<span className="font-medium">{h.bookName} {h.chapter}:{h.verse}</span>
{colorLabels[h.color] && <span className="ml-1.5 text-[11px] text-[var(--r-sub)]">{colorLabels[h.color]}</span>}
{h.text && <span className="mt-0.5 block font-serif text-[var(--r-sub)]">“{h.text}”</span>}
</button>
<button type="button" onClick={() => onRemoveHighlight(h)} aria-label={`Remove highlight on ${h.bookName} ${h.chapter}:${h.verse}`}
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
))}
</>
)}
{tab === 'bookmarks' && (
bookmarks.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
) : bookmarks.map((e) => (
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
{e.bookName} {e.chapter}:{e.verse}
</button>
<button type="button" onClick={() => onRemoveBookmark(e)} aria-label={`Remove bookmark on ${e.bookName} ${e.chapter}:${e.verse}`}
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
))
)}
</Sheet>
);
}
+59
View File
@@ -0,0 +1,59 @@
// One place to pick translation, book and chapter (replaces three dropdowns).
import { useState } from 'react';
import Sheet from './Sheet.jsx';
import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx';
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
import { SaveBookOffline } from '../OfflineSave.jsx';
const NT_START = bookOptions.findIndex((b) => b.abbrev === 'MAT');
const chip = 'rounded-lg border px-2 py-2 text-center text-sm transition';
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo, offline }) {
const [pickedBook, setPickedBook] = useState(null); // book abbrev whose chapters are showing
const close = () => { setPickedBook(null); onClose(); };
const book = pickedBook ? bookOptions.find((b) => b.abbrev === pickedBook) : null;
const bookButton = (b) => (
<button key={b.abbrev} type="button" onClick={() => setPickedBook(b.abbrev)}
className={`${chip} truncate ${b.abbrev === bookAbbrev ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
{b.name}
</button>
);
return (
<Sheet open={open} onClose={close} label="Go to" title={book ? (
<button type="button" onClick={() => setPickedBook(null)} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">‹ All books · <span className="text-[var(--r-text)]">{book.name}</span></button>
) : 'Go to'}>
{!book && (
<>
<div className="mb-3 flex flex-wrap gap-1.5">
{READER_TRANSLATIONS.map((t) => (
<button key={t.id} type="button" onClick={() => onTranslation(t.id)} title={t.name}
className={`rounded-full border px-3 py-1 text-xs font-medium ${t.id === translation ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{t.id}
</button>
))}
</div>
{translationNote && <p className="-mt-1 mb-3 text-[11px] text-[var(--r-sub)]">{translationNote}</p>}
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Old Testament</p>
<div className="mb-4 grid grid-cols-3 gap-1.5 sm:grid-cols-4">{bookOptions.slice(0, NT_START).map(bookButton)}</div>
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">New Testament</p>
<div className="grid grid-cols-3 gap-1.5 sm:grid-cols-4">{bookOptions.slice(NT_START).map(bookButton)}</div>
</>
)}
{book && (
<>
<div className="grid grid-cols-6 gap-1.5 sm:grid-cols-8">
{Array.from({ length: CHAPTER_COUNTS[book.abbrev] ?? 1 }, (_, i) => i + 1).map((n) => (
<button key={n} type="button" onClick={() => { onGo(book.abbrev, n); close(); }}
className={`${chip} ${book.abbrev === bookAbbrev && n === chapter ? 'border-[var(--r-accent)] bg-[var(--r-accent)] text-[var(--r-on)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
{n}
</button>
))}
</div>
{offline && <SaveBookOffline offline={offline} translation={translation} book={book.abbrev} name={book.name} />}
</>
)}
</Sheet>
);
}
+32
View File
@@ -0,0 +1,32 @@
// Card at the end of the chapter while reading a plan: step to the next chapter of
// today's reading, or finish the day and roll into the next one.
export default function PlanFooter({ info, label, onNext, onNextDay, onFinish }) {
if (!info || info.position === 0) return null;
const last = info.position === info.total;
const nextRef = !last ? info.refs[info.position] : null;
const btn = 'rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)]';
return (
<div className="mx-auto mt-10 max-w-md rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4 text-center font-sans">
<p className="text-xs text-[var(--r-sub)]">{info.plan.title} · Day {info.dayIndex + 1}</p>
{!last && (
<>
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">{info.total - info.position} more chapter{info.total - info.position === 1 ? '' : 's'} today</p>
<button type="button" onClick={onNext} className={btn}>Next: {label(nextRef)}</button>
</>
)}
{last && info.nextDayRefs?.length > 0 && (
<>
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">That’s Day {info.dayIndex + 1}</p>
<button type="button" onClick={onNextDay} className={btn}>Mark read · Day {info.dayIndex + 2}: {label(info.nextDayRefs[0])}</button>
<div><button type="button" onClick={onFinish} className="mt-2 text-xs text-[var(--r-sub)] underline">Mark read and stop for today</button></div>
</>
)}
{last && !info.nextDayRefs?.length && (
<>
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">Last day of the plan</p>
<button type="button" onClick={onFinish} className={btn}>{info.done ? 'Plan complete' : 'Finish plan'}</button>
</>
)}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
// The plan's control room, one tap from the reader: pick the day, tick chapters,
// choose the Bible this plan is read in, and jump to another active plan.
import Sheet from './Sheet.jsx';
import { ChevronLeft, ChevronRight, CheckIcon } from './icons.jsx';
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
import { formatDayReadings } from '../../utils/readingPlans.js';
export default function PlanSheet({
open, onClose, info, translation, currentKey, streak, otherPlans, studyBusy,
onDay, onToggleChapter, onGoChapter, onTranslation, onToggleDay, onSwitchPlan, onListen, onStudy, onLeave,
}) {
if (!info) return null;
const label = (r) => formatDayReadings([r]);
return (
<Sheet open={open} onClose={onClose} title={info.plan.title}>
<div className="mb-1 flex items-center justify-between">
<button type="button" onClick={() => onDay(-1)} disabled={info.dayIndex <= 0} aria-label="Previous day"
className="rounded-lg p-1.5 text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-30"><ChevronLeft /></button>
<span className="text-sm font-medium">Day {info.dayIndex + 1} of {info.plan.days}{info.done ? ' · read' : ''}</span>
<button type="button" onClick={() => onDay(1)} disabled={info.dayIndex >= info.plan.days - 1} aria-label="Next day"
className="rounded-lg p-1.5 text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-30"><ChevronRight /></button>
</div>
<p className="mb-1 mt-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Reading</p>
{info.refs.map((r) => {
const key = `${r.book}.${r.chapter}`;
const read = info.readSet.has(key);
return (
<div key={key} className="flex items-center gap-3 border-b border-[var(--r-line)] py-2">
<button type="button" onClick={() => onToggleChapter(key)} aria-label={read ? 'Mark unread' : 'Mark read'}
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-[1.5px] ${read ? 'border-[var(--r-accent)] bg-[var(--r-accent)] text-[var(--r-on)]' : 'border-[var(--r-sub)] text-transparent'}`}>
<span className="scale-75"><CheckIcon /></span>
</button>
<button type="button" onClick={() => onGoChapter(r)} className={`flex-1 text-left text-sm ${key === currentKey ? 'font-medium' : ''}`}>{label(r)}</button>
{key === currentKey && <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[10px] text-[var(--r-sub)]">reading</span>}
</div>
);
})}
<button type="button" onClick={onToggleDay}
className="mt-3 w-full rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5">
{info.done ? 'Mark this day unread' : 'Mark the whole day read'}
</button>
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">This plan</p>
<div className="flex items-center justify-between py-1.5 text-sm">
<span>Read in</span>
<select value={translation} onChange={(e) => onTranslation(e.target.value)}
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-sm text-[var(--r-text)]">
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
</select>
</div>
<div className="flex items-center justify-between py-1.5 text-sm">
<span>Streak</span><span className="text-[var(--r-sub)]">{streak.current} day{streak.current === 1 ? '' : 's'}</span>
</div>
<div className="mt-2 flex gap-2">
<button type="button" onClick={onListen} className="flex-1 rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5">Listen</button>
<button type="button" onClick={onStudy} disabled={studyBusy} className="flex-1 rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5 disabled:opacity-50">{studyBusy ? 'Building…' : 'Study this day'}</button>
</div>
{otherPlans.length > 0 && (
<>
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Other plans</p>
{otherPlans.map((o) => (
<button key={o.progress.id} type="button" onClick={() => onSwitchPlan(o)}
className="flex w-full items-center justify-between rounded-lg px-1 py-2 text-left text-sm hover:bg-black/5">
<span className="truncate">{o.plan.title}</span>
<span className="ml-3 shrink-0 text-xs text-[var(--r-sub)]">Day {o.dayIndex + 1}</span>
</button>
))}
</>
)}
<button type="button" onClick={onLeave} className="mt-4 w-full text-center text-xs text-[var(--r-sub)] underline">Leave plan (keep reading on my own)</button>
</Sheet>
);
}
+39
View File
@@ -0,0 +1,39 @@
// Thin strip under the top bar while reading a plan: which plan and day, one dot
// per chapter of today's reading (filled = read, ringed = the chapter on screen),
// and the translation. Tapping it opens the plan sheet. When you've wandered off
// today's chapters (e.g. following a cross-reference) it offers a way back.
export default function PlanStrip({ info, translation, inPlan, notice, onOpen, onJoin, onDot, onBackToDay }) {
if (!info && !notice) return null;
return (
<div className="mx-auto flex max-w-2xl flex-col gap-1 px-3 pt-2 font-sans">
{notice && <p className="rounded-lg bg-[var(--r-bar)] px-3 py-1.5 text-xs text-[var(--r-accent)]">{notice}</p>}
{info && (
<div className="rounded-xl bg-[var(--r-bar)] px-3 py-2 text-xs text-[var(--r-sub)]">
<button type="button" onClick={inPlan ? onOpen : onJoin} className="flex w-full items-center justify-between gap-3 text-left">
<span className="truncate font-medium text-[var(--r-text)]">{info.plan.title} · Day {info.dayIndex + 1}</span>
<span className="shrink-0">{inPlan ? translation : 'Open plan'}</span>
</button>
{inPlan && info.position === 0 && (
<button type="button" onClick={onBackToDay} className="mt-1.5 rounded-full border border-[var(--r-line)] px-3 py-1 text-[11px] text-[var(--r-accent)] hover:bg-black/5">
Back to today’s reading
</button>
)}
{inPlan && info.position > 0 && (
<div className="mt-1.5 flex items-center gap-1.5">
{info.refs.map((r, i) => {
const key = `${r.book}.${r.chapter}`;
const read = info.readSet.has(key);
const current = i + 1 === info.position;
return (
<button key={key} type="button" onClick={() => onDot(r)} aria-label={`Go to chapter ${r.chapter}`}
className={`h-2.5 w-2.5 rounded-full border-[1.5px] ${read ? 'border-[var(--r-accent)] bg-[var(--r-accent)]' : 'border-[var(--r-sub)]'} ${current ? 'ring-2 ring-[var(--r-line)]' : ''}`} />
);
})}
<span className="ml-auto">{info.done ? 'Day read' : `${info.position} of ${info.total}`}</span>
</div>
)}
</div>
)}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
// Top and bottom chrome for the reader. Everything stays one tap away; focus
// mode hides both.
import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon } from './icons.jsx';
import { BookmarkIcon } from '../../utils/bibleUtils.jsx';
const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent';
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onCompare, compareActive, onSettings, onDoneDrawing }) {
return (
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
<button type="button" onClick={onBack} className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] transition hover:bg-black/5" aria-label="Back to home"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
<button type="button" onClick={onOpenNavigator}
className="mx-auto flex min-w-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--r-text)] hover:bg-black/5">
<span className="truncate">{title}</span><ChevronDown />
</button>
{onDoneDrawing ? (
<button type="button" onClick={onDoneDrawing}
className="mr-1 flex items-center gap-1.5 rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-xs font-medium text-[var(--r-on)]">
<CheckIcon />Done drawing
</button>
) : (
<>
<button type="button" onClick={onSearch} className={iconBtn} aria-label="Search"><SearchIcon /></button>
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks">
<BookmarkIcon filled={bookmarkCount > 0} />
</button>
<button type="button" onClick={onCompare} className={`${iconBtn} ${compareActive ? 'text-[var(--r-accent)]' : ''}`} aria-label="Compare translations" aria-pressed={!!compareActive} title="Compare translations"><CompareIcon /></button>
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button>
</>
)}
</div>
</div>
);
}
export function BottomBar({
onPrev, onNext, prevDisabled, nextDisabled,
onAudio, audioActive, onPen, onCrossRefs, crossRefsOn, onFocus, primary,
}) {
const active = 'text-[var(--r-accent)]';
return (
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)] font-sans">
<div className="mx-auto flex h-14 max-w-4xl items-center gap-0.5 px-2">
<button type="button" onClick={onPrev} disabled={prevDisabled} className={iconBtn} aria-label="Previous chapter"><ChevronLeft /></button>
<button type="button" onClick={onAudio} className={`${iconBtn} ${audioActive ? active : ''}`} aria-label="Listen"><HeadphonesIcon /></button>
<button type="button" onClick={onPen} className={iconBtn} aria-label="Draw notes"><PenIcon /></button>
<button type="button" onClick={onCrossRefs} className={`${iconBtn} ${crossRefsOn ? active : ''}`} aria-label="Show cross-references under verses"><LinkIcon /></button>
<button type="button" onClick={onFocus} className={iconBtn} aria-label="Hide controls"><FocusIcon /></button>
<div className="mx-auto" />
{primary && (
<button type="button" onClick={primary.onClick}
className="mr-1 flex items-center gap-1.5 rounded-full bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">
{primary.done ? null : <CheckIcon />}{primary.label}
</button>
)}
<button type="button" onClick={onNext} disabled={nextDisabled} className={iconBtn} aria-label="Next chapter"><ChevronRight /></button>
</div>
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
// The page itself: chapter heading, translation notice, and verses set in a
// serif face. Tap a verse to open the verse sheet; select text to highlight.
import { useMemo } from 'react';
import { formatCrossRef } from '../../utils/wordStudy.js';
import { diffChapter, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
// How each typeface choice is set. "Easy to read" is a sans face with extra letter and word spacing.
const FONT_STYLES = {
serif: { fontFamily: undefined },
sans: { fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif" },
easy: { fontFamily: "'Atkinson Hyperlegible', Verdana, 'Trebuchet MS', ui-sans-serif, sans-serif", letterSpacing: '0.02em', wordSpacing: '0.12em' },
};
const LINE_HEIGHTS = { snug: 1.6, normal: 1.85, airy: 2.15 };
// `parallel` ({ translation, name, notice, verses, loading, error }) shows a second Bible beside the first.
export default function ReaderText({
title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide,
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null, showDiff = true,
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
crossRefs, showCrossRefs, onSelectionEnd, footer,
}) {
// How much the second Bible's wording differs from the first, over the chapter and verse by verse
const diff = useMemo(
() => (parallel && showDiff && !parallel.loading && !parallel.error && parallel.verses?.length ? diffChapter(verses, parallel.verses) : null),
[parallel, showDiff, verses],
);
const diffByVerse = useMemo(() => new Map((diff?.verses ?? []).map((d) => [d.number, d])), [diff]);
const jumpToVerse = (n) => document.getElementById(`reader-verse-${n}`)?.scrollIntoView?.({ block: 'center', behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
const handleTap = (n) => {
const sel = window.getSelection();
if (sel && !sel.isCollapsed && sel.toString().trim()) return; // user is selecting text, not tapping
onSelectVerse(selectedVerse === n ? null : n);
};
const verseProps = (v) => {
const bm = bookmarks[`${bookKey}-${chapter}-${v.number}`];
const selected = selectedVerse === v.number;
return {
id: `reader-verse-${v.number}`,
'data-verse-num': v.number,
onClick: () => handleTap(v.number),
// Verses can be reached and chosen from the keyboard too
role: 'button',
tabIndex: 0,
'aria-pressed': selected,
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleTap(v.number); } },
className: 'cursor-pointer rounded-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--r-accent)]',
style: selected
? { backgroundColor: 'var(--r-hl)' }
: bm ? { backgroundColor: `${bm}66`, boxShadow: `0 0 0 2px ${bm}66` } : undefined,
};
};
const chips = (v) => showCrossRefs && crossRefs?.[v.number]?.length > 0 && (
<span className="ml-1 inline-flex flex-wrap gap-1 align-baseline font-sans">
{crossRefs[v.number].slice(0, 4).map((ref, i) => (
<span key={i} className="rounded-full border border-[var(--r-line)] px-1.5 py-px text-[10px] text-[var(--r-sub)]">{formatCrossRef(ref)}</span>
))}
</span>
);
const num = (n) => (showVerseNumbers ? <sup className="mr-1 select-none font-sans text-[0.6em] text-[var(--r-sub)]">{n}</sup> : null);
// Both Bibles by verse number, so a verse one translation leaves out still lines up with the other.
const parallelRows = parallel && !parallel.error ? (() => {
const second = new Map(parallel.verses.map((v) => [v.number, v]));
const first = new Map(verses.map((v) => [v.number, v]));
return [...new Set([...first.keys(), ...second.keys()])].sort((a, b) => a - b)
.map((n) => ({ number: n, first: first.get(n), second: second.get(n) }));
})() : null;
const sideBySide = !!parallel;
const bodyStyle = {
...FONT_STYLES[font],
lineHeight: LINE_HEIGHTS[spacing] ?? LINE_HEIGHTS.normal,
};
// The page grows with the screen: type is 18px on a phone, ~20px on an iPad and ~24px on a big
// monitor (times your text-size choice), and the column is measured in the type's own width (em), so
// the line length stays comfortable (~70 characters) instead of becoming a narrow strip on a wide
// screen. Two columns / side-by-side take more room.
const pageStyle = {
fontSize: `calc(clamp(1.125rem, 0.9rem + 0.7vw, 1.55rem) * ${fontSize})`,
maxWidth: wide || sideBySide ? 'min(100%, 72em)' : 'min(100%, 40em)',
};
return (
<article
className="mx-auto px-5 pb-32 pt-6 text-[var(--r-text)] md:px-8"
style={pageStyle}
onPointerUp={onSelectionEnd}
>
<header className="mb-5 text-center">
<h1 className="font-serif text-3xl font-normal tracking-tight">{title}</h1>
<p className="mt-1 font-sans text-xs text-[var(--r-sub)]">{translation}{parallel ? ` · beside ${parallel.name}` : ''}</p>
</header>
{loading && <ChapterSkeleton />}
{error && <p role="alert" className="text-center font-sans text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{!loading && !error && parallel?.error && (
<p role="alert" className="mb-4 text-center font-sans text-sm text-[var(--r-danger,#b42318)]">Couldn’t load {parallel.name}: {parallel.error}</p>
)}
{!loading && !error && sideBySide && !parallel.error && (
<div style={bodyStyle} className="font-serif">
{diff && (
<div className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 font-sans text-sm" style={{ fontSize: '0.875rem' }}>
<p>
<strong>{parallel.translation}</strong>{diff.similarity === 1 ? ' uses the same words as ' : <> is <strong>{differenceLabel(diff.similarity)}</strong> from </>}{translationId} in this chapter
{diff.total > 0 && <span className="text-[var(--r-sub)]"> · {diff.differing} of {diff.total} verses differ</span>}
</p>
{diff.mostDifferent.length > 0 && (
<p className="mt-1 text-xs text-[var(--r-sub)]">
Most different:{' '}
{diff.mostDifferent.map((v, i) => (
<span key={v.number}>{i > 0 && ', '}<button type="button" onClick={() => jumpToVerse(v.number)} className="font-medium text-[var(--r-accent)] hover:underline">verse {v.number}</button> ({Math.round((1 - v.similarity) * 100)}%)</span>
))}
</p>
)}
<p className="mt-1 text-xs text-[var(--r-sub)]"><span className="diff-hl">Marked words</span> aren’t in the other wording.</p>
</div>
)}
<div className="mb-3 hidden grid-cols-2 gap-x-10 border-b border-[var(--r-line)] pb-1 font-sans text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid">
<span>{translationId}</span><span>{parallel.translation}</span>
</div>
{parallelRows.map((row) => (
<div key={row.number} className="mb-4 grid gap-x-10 gap-y-1 md:grid-cols-2">
<p>
{row.first
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower, diffByVerse.get(row.number)?.a)}{chips(row.first)}</span>
: <span className="text-[var(--r-sub)]">{num(row.number)}—</span>}
</p>
<p className="border-l-2 border-[var(--r-line)] pl-3 text-[0.95em] text-[var(--r-sub)] md:border-l-0 md:pl-0 md:text-[var(--r-text)]">
<span className="mr-1.5 font-sans text-[10px] uppercase tracking-wide md:hidden">{parallel.translation}</span>
{row.second ? <>{num(row.second.number)}<DiffText text={row.second.text} ranges={diffByVerse.get(row.number)?.b} />{diffByVerse.get(row.number) && !diffByVerse.get(row.number).same && (
<span className="ml-2 whitespace-nowrap font-sans text-[10px] text-[var(--r-sub)]">{differenceLabel(diffByVerse.get(row.number).similarity)}</span>
)}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
</p>
</div>
))}
</div>
)}
{!loading && !error && !sideBySide && (
<div
className={`font-serif ${wide ? 'md:columns-2 md:gap-x-12' : ''}`}
style={bodyStyle}
>
{layout === 'flow' ? (
<p className="text-pretty">
{verses.map((v) => (
<span key={v.number} {...verseProps(v)}>
{num(v.number)}{renderVerseText(v.number, v.text, searchLower)}{chips(v)}{' '}
</span>
))}
</p>
) : (
verses.map((v) => (
<p key={v.number} className="mb-3 break-inside-avoid">
<span {...verseProps(v)}>{num(v.number)}{renderVerseText(v.number, v.text, searchLower)}{chips(v)}</span>
</p>
))
)}
</div>
)}
{!loading && !error && <div style={{ fontSize: '1rem' }}>{footer}</div>}
{notice && !loading && <p className="mt-10 border-t border-[var(--r-line)] pt-3 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{notice}</p>}
{parallel?.notice && !loading && !parallel.loading && <p className="mt-2 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{parallel.notice}</p>}
</article>
);
}
// Grey bars the shape of a page, so a slow chapter load doesn't just show the word "Loading".
function ChapterSkeleton() {
return (
<div role="status" aria-label="Loading chapter" className="animate-pulse motion-reduce:animate-none space-y-3 pt-2">
{[100, 96, 92, 98, 84, 100, 90, 70].map((w, i) => <div key={i} className="h-4 rounded bg-[var(--r-line)]" style={{ width: `${w}%` }} />)}
<span className="sr-only">Loading…</span>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { vi, test, expect } from 'vitest';
import ReaderText from './ReaderText.jsx';
const verses = [
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
{ number: 2, text: 'Now the earth was formless and void.' },
];
const second = [
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
{ number: 2, text: 'Now the earth was without shape and empty.' },
];
const base = {
title: 'Genesis 1', translation: 'Berean Standard Bible', translationId: 'BSB', notice: '', verses, loading: false, error: '',
layout: 'flow', fontSize: 1, wide: false, bookmarks: {}, bookKey: 'GEN', chapter: 1, selectedVerse: null,
onSelectVerse: vi.fn(), renderVerseText: (_n, text) => text, searchLower: '', crossRefs: null, showCrossRefs: false, onSelectionEnd: vi.fn(),
};
const parallel = (over = {}) => ({ translation: 'NET', name: 'NET Bible', notice: '', verses: second, loading: false, error: '', ...over });
test('side by side, it says how different the other Bible is and marks the differing words', () => {
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff />);
expect(screen.getByText(/is/, { selector: 'p' })).toHaveTextContent(/NET is \d+% different from BSB in this chapter/);
expect(screen.getByText(/1 of 2 verses differ/)).toBeInTheDocument();
const marked = [...container.querySelectorAll('.diff-hl')].map((n) => n.textContent);
expect(marked).toContain('without shape'); // the other Bible's side
expect(screen.getByRole('button', { name: /verse 2/i })).toBeInTheDocument(); // the most different verse
expect(screen.getByText(/% different/, { selector: 'span.ml-2' })).toBeInTheDocument(); // per-verse figure
});
test('identical wording is reported as the same words', () => {
render(<ReaderText {...base} parallel={parallel({ verses })} showDiff />);
expect(screen.getByText(/uses the same words as/)).toBeInTheDocument();
});
test('with highlighting off there is no summary and no marks', () => {
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff={false} />);
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
expect(container.querySelectorAll('.diff-hl')).toHaveLength(0);
});
test('while the other Bible loads there is no comparison yet, and the first Bible still shows', () => {
render(<ReaderText {...base} parallel={parallel({ verses: [], loading: true })} showDiff />);
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
expect(screen.getAllByText(/In the beginning God created/).length).toBeGreaterThan(0);
});
test('the left Bible gets its own differing words passed to the verse renderer', () => {
const renderVerseText = vi.fn((_n, text) => text);
render(<ReaderText {...base} renderVerseText={renderVerseText} parallel={parallel()} showDiff />);
const call = renderVerseText.mock.calls.find(([n]) => n === 2);
expect(call[3].map((r) => verses[1].text.slice(r.start, r.end))).toEqual(['formless', 'void']);
fireEvent.click(screen.getByRole('button', { name: /verse 2/i })); // jumping must not throw even if the element is absent
});
+111
View File
@@ -0,0 +1,111 @@
// Search this chapter, or the whole Bible in the translation being read.
// BSB and NET are downloaded once and searched in the browser; ESV, NLT, CSB and
// NASB are searched through the server (their licences don't allow a bulk copy).
import { useMemo } from 'react';
import Sheet from './Sheet.jsx';
import { SearchIcon } from './icons.jsx';
import { bookOptions } from '../../utils/bibleUtils.jsx';
const nameFor = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Wraps each searched word (quotes ignored) in <mark>.
function highlight(text, query) {
const words = [...new Set(query.toLowerCase().replace(/["“”]/g, ' ').split(/\s+/).filter((w) => w.length > 1))];
if (!words.length) return text;
const re = new RegExp(`(${words.map(escapeRegExp).join('|')})`, 'gi');
return text.split(re).map((part, i) => (i % 2 === 1
? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-[var(--r-text)]">{part}</mark>
: part));
}
export default function SearchSheet({
open, onClose, query, onQuery, scope, onScope, verses, bookName, chapter, currentBook, translation,
isLocal, indexStatus, onLoadIndex, bibleMatches, deferredBibleQuery, remote, onJump,
}) {
const q = query.trim().toLowerCase();
const chapterMatches = useMemo(
() => (scope === 'chapter' && q ? verses.filter((v) => v.text.toLowerCase().includes(q)) : []),
[scope, q, verses],
);
const scopeBtn = (id, label, onClick) => (
<button type="button" onClick={onClick}
className={`px-3 py-1.5 text-xs ${scope === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{label}</button>
);
const note = (text) => <p className="text-sm text-[var(--r-sub)]">{text}</p>;
const resultRow = (r) => (
<button key={`${r.bookAbbrev}-${r.chapter}-${r.verse}`} type="button" onClick={() => onJump(r.bookAbbrev, r.chapter, r.verse)}
className="block w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-black/5">
<span className="mr-1.5 font-medium">{nameFor(r.bookAbbrev)} {r.chapter}:{r.verse}</span>{highlight(r.text, query)}
</button>
);
return (
<Sheet open={open} onClose={onClose} title="Search">
<div className="mb-3 flex items-center gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2">
<span className="text-[var(--r-sub)]"><SearchIcon /></span>
<input autoFocus value={query} onChange={(e) => onQuery(e.target.value)}
placeholder={scope === 'bible' ? `Search the whole Bible (${translation})…` : `Search ${bookName} ${chapter}…`}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--r-text)] outline-none placeholder:text-[var(--r-sub)]" />
</div>
<div className="mb-3 inline-flex overflow-hidden rounded-lg border border-[var(--r-line)]">
{scopeBtn('chapter', 'This chapter', () => onScope('chapter'))}
{scopeBtn('bible', `Whole Bible (${translation})`, () => { onScope('bible'); if (isLocal && indexStatus === 'idle') onLoadIndex(); })}
</div>
{scope === 'chapter' && (
!q ? note('Type a word to find it in this chapter.')
: chapterMatches.length === 0 ? note(`No verses match “${query}”.`)
: chapterMatches.map((v) => (
<button key={v.number} type="button" onClick={() => onJump(currentBook, chapter, v.number)}
className="block w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-black/5">
<span className="mr-1.5 font-medium">{v.number}</span>{highlight(v.text, query)}
</button>
))
)}
{scope === 'bible' && isLocal && (
<>
{indexStatus === 'loading' && note('Loading the full Bible for search. This happens once per visit (about 7 MB).')}
{indexStatus === 'error' && <p className="text-sm text-[var(--r-danger,#b42318)]">Couldn’t load the full Bible. <button type="button" onClick={onLoadIndex} className="underline">Try again</button></p>}
{indexStatus === 'ready' && (!deferredBibleQuery
? note('Type a word to search all 66 books.')
: bibleMatches.length === 0
? note(`No verses match “${query}”.`)
: (
<>
<p className="mb-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
{bibleMatches.length} match{bibleMatches.length === 1 ? '' : 'es'}{bibleMatches.length > 100 ? ' (showing first 100)' : ''}
</p>
{bibleMatches.slice(0, 100).map(resultRow)}
</>
))}
</>
)}
{scope === 'bible' && !isLocal && (
<>
{remote.tooShort && note('Type at least two letters. Use quotes for an exact phrase, like “good shepherd”.')}
{!remote.tooShort && remote.error && <p className="text-sm text-[var(--r-danger,#b42318)]">{remote.error}</p>}
{!remote.tooShort && !remote.error && remote.loading && remote.results.length === 0 && note('Searching…')}
{!remote.tooShort && !remote.error && !remote.loading && remote.total === 0 && remote.page > 0 && note(`No verses match “${query}”.`)}
{remote.results.length > 0 && (
<>
<p className="mb-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
{remote.total} match{remote.total === 1 ? '' : 'es'} in {translation}
</p>
{remote.results.map(resultRow)}
{remote.hasMore && (
<button type="button" onClick={remote.loadMore} disabled={remote.loading}
className="mt-2 w-full rounded-lg border border-[var(--r-line)] py-2 text-sm text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-50">
{remote.loading ? 'Loading…' : 'Show more'}
</button>
)}
</>
)}
</>
)}
</Sheet>
);
}
@@ -0,0 +1,30 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { test, expect, vi } from 'vitest';
import SearchSheet from './SearchSheet.jsx';
const base = {
open: true, onClose: () => {}, query: 'shepherd', onQuery: () => {}, scope: 'bible', onScope: () => {},
verses: [], bookName: 'John', chapter: 3, currentBook: 'JHN', translation: 'NLT', isLocal: false,
indexStatus: 'idle', onLoadIndex: () => {}, bibleMatches: [], deferredBibleQuery: '',
};
test('server-searched translations list results with highlights and jump on tap', () => {
const onJump = vi.fn();
const remote = {
tooShort: false, loading: false, error: '', page: 1, total: 2, hasMore: true, loadMore: vi.fn(),
results: [{ bookAbbrev: 'GEN', chapter: 4, verse: 2, text: 'Abel became a shepherd.' }, { bookAbbrev: 'PSA', chapter: 23, verse: 1, text: 'The Lord is my shepherd.' }],
};
render(<SearchSheet {...base} remote={remote} onJump={onJump} />);
expect(screen.getByText('2 matches in NLT')).toBeTruthy();
expect(screen.getAllByText('shepherd')[0].tagName).toBe('MARK');
fireEvent.click(screen.getByText(/Genesis 4:2/));
expect(onJump).toHaveBeenCalledWith('GEN', 4, 2);
fireEvent.click(screen.getByText('Show more'));
expect(remote.loadMore).toHaveBeenCalled();
});
test('shows the server error, e.g. a rate limit', () => {
const remote = { tooShort: false, loading: false, error: 'ESV rate limit reached. Try again in a minute.', page: 0, total: 0, hasMore: false, loadMore: () => {}, results: [] };
render(<SearchSheet {...base} translation="ESV" remote={remote} onJump={() => {}} />);
expect(screen.getByText(/rate limit/)).toBeTruthy();
});
+59
View File
@@ -0,0 +1,59 @@
// "Aa" sheet: text size, typeface, spacing, theme, how verses are laid out, and a second Bible
// to read beside the first.
import Sheet from './Sheet.jsx';
import { READER_THEMES } from './readerThemes.js';
const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]];
function Segmented({ options, value, onChange, label }) {
return (
<div role="group" aria-label={label} className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{options.map(([id, text]) => (
<button key={String(id)} type="button" onClick={() => onChange(id)} aria-pressed={value === id}
className={`flex-1 px-3 py-2 text-sm ${value === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
{text}
</button>
))}
</div>
);
}
export default function SettingsSheet({
open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide,
font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers,
}) {
const row = 'mb-4';
const label = 'mb-1.5 block text-xs text-[var(--r-sub)]';
return (
<Sheet open={open} onClose={onClose} title="Reading settings">
<div className={row}>
<span className={label}>Text size</span>
<Segmented label="Text size" options={SIZES.map(([l, s]) => [s, l])} value={fontSize} onChange={onFontSize} />
</div>
<div className={row}>
<span className={label}>Typeface</span>
<Segmented label="Typeface" options={[['serif', 'Serif'], ['sans', 'Sans'], ['easy', 'Easy to read']]} value={font} onChange={onFont} />
</div>
<div className={row}>
<span className={label}>Line spacing</span>
<Segmented label="Line spacing" options={[['snug', 'Snug'], ['normal', 'Normal'], ['airy', 'Airy']]} value={spacing} onChange={onSpacing} />
</div>
<div className={row}>
<span className={label}>Theme</span>
<Segmented label="Theme" options={Object.entries(READER_THEMES).map(([id, t]) => [id, t.label])} value={theme} onChange={onTheme} />
</div>
<div className={row}>
<span className={label}>Verses</span>
<Segmented label="Verse layout" options={[['flow', 'Flowing'], ['verses', 'One per line']]} value={layout} onChange={onLayout} />
</div>
<div className={row}>
<span className={label}>Verse numbers</span>
<Segmented label="Verse numbers" options={[[true, 'Show'], [false, 'Hide']]} value={verseNumbers} onChange={onVerseNumbers} />
</div>
<div className={row}>
<span className={label}>Width</span>
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
</div>
</Sheet>
);
}
+67
View File
@@ -0,0 +1,67 @@
// Slide-up sheet used for every reader tool. `modal` sheets dim the page and
// close on outside tap; non-modal ones (the verse sheet) leave the text
// tappable. With `pane`, screens >= lg show it as a right-hand side pane
// instead of a bottom sheet (tablet landscape / desktop).
import { useEffect, useRef } from 'react';
import { CloseIcon } from './icons.jsx';
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export default function Sheet({ open, onClose, title, label, headerRight, modal = true, pane = false, children }) {
const dialogRef = useRef(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Modal sheets behave like dialogs: focus moves in, Tab stays inside, Escape closes, and focus
// returns to whatever opened the sheet. (The verse pane is not modal, so it leaves focus alone.)
useEffect(() => {
if (!open || !modal) return undefined;
const opener = document.activeElement;
const dialog = dialogRef.current;
if (dialog && !dialog.contains(document.activeElement)) dialog.focus({ preventScroll: true });
const onKey = (e) => {
if (e.key === 'Escape') { e.stopPropagation(); onCloseRef.current?.(); return; }
if (e.key !== 'Tab' || !dialog) return;
const items = [...dialog.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null || el === document.activeElement);
if (items.length === 0) { e.preventDefault(); return; }
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
if (opener instanceof HTMLElement && document.contains(opener)) opener.focus({ preventScroll: true });
};
}, [open, modal]);
if (!open) return null;
return (
<>
{modal && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />}
<div
ref={dialogRef}
role="dialog"
aria-modal={modal ? 'true' : undefined}
aria-label={typeof title === 'string' ? title : label}
tabIndex={-1}
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl focus:outline-none border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[24rem] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2'
}`}
>
<div className="mx-auto mt-2 h-1 w-9 shrink-0 rounded-full bg-[var(--r-line)] lg:hidden" />
<div className="flex items-center justify-between gap-2 px-4 pb-2 pt-2">
<div className="min-w-0 truncate text-sm font-medium">{title}</div>
<div className="flex items-center gap-1 text-[var(--r-sub)]">
{headerRight}
<button type="button" onClick={onClose} aria-label="Close" className="rounded-lg p-1.5 hover:bg-black/5">
<CloseIcon />
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6">{children}</div>
</div>
</>
);
}
+152
View File
@@ -0,0 +1,152 @@
// The single home for per-verse tools: original words, cross-references,
// commentary, and people & places, plus bookmark / copy.
import { useEffect, useState } from 'react';
import Sheet from './Sheet.jsx';
import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js';
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
function OriginalTab({ words }) {
if (!words) return <p className="text-sm text-[var(--r-sub)]">No original-language data for this verse.</p>;
return (
<div className="flex flex-wrap gap-2">
{words.map((w, i) => {
const isHebrew = w.s?.startsWith('H');
const canSpeak = isHebrew || w.s?.startsWith('G');
return (
<div key={i} className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-2 text-center" style={{ minWidth: '4.5rem', maxWidth: '9rem' }}>
<div className={`text-lg leading-tight ${isHebrew ? 'font-serif' : ''}`} dir={isHebrew ? 'rtl' : 'ltr'}>{w.o}</div>
<div className="mt-0.5 text-xs italic text-[var(--r-sub)]">{w.t}</div>
<div className="mt-1 text-xs font-medium">{w.g}</div>
{w.p && <div className="mt-0.5 text-[10px] leading-tight text-[var(--r-sub)]">{w.p}</div>}
{w.s && <div className="mt-0.5 text-[10px] text-[var(--r-sub)]">{w.s}</div>}
{canSpeak && (
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
className="mt-1.5 rounded-lg bg-[var(--r-bar)] px-2 py-0.5 text-[11px] hover:bg-black/5"
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>Speak</button>
)}
</div>
);
})}
</div>
);
}
function RefsTab({ refs, loading, onJump }) {
if (loading && !refs) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
if (!refs?.length) return <p className="text-sm text-[var(--r-sub)]">No cross-references for this verse.</p>;
return (
<div className="flex flex-wrap gap-1.5">
{refs.slice(0, 20).map((ref, i) => (
<button key={i} type="button" onClick={() => onJump(ref.book, ref.chapter, ref.verse)}
className="rounded-full border border-[var(--r-line)] px-2.5 py-1 text-xs hover:bg-black/5">
{formatCrossRef(ref)}
</button>
))}
</div>
);
}
// This verse in every translation, one under the other. Each Bible loads the chapter it is in
// (BSB and NET come from the free source; the rest through the server) and shows this verse.
function CompareTab({ book, chapter, verse, current }) {
const [rows, setRows] = useState({}); // id -> { text } | { error } | undefined while loading
useEffect(() => {
const controller = new AbortController();
setRows({});
READER_TRANSLATIONS.forEach((t) => {
fetchChapter(t.id, book, chapter, { signal: controller.signal })
.then(({ verses }) => {
const text = verses.find((v) => v.number === verse)?.text;
setRows((r) => ({ ...r, [t.id]: text ? { text } : { error: 'No verse here in this translation.' } }));
})
.catch((err) => { if (!controller.signal.aborted) setRows((r) => ({ ...r, [t.id]: { error: err.message || 'Unavailable.' } })); });
});
return () => controller.abort();
}, [book, chapter, verse]);
const mine = rows[current]?.text;
return (
<div className="space-y-3">
{READER_TRANSLATIONS.map((t) => {
const row = rows[t.id];
// How this wording differs from the Bible being read, word by word
const d = row?.text && mine && t.id !== current ? diffVerses(mine, row.text) : null;
return (
<div key={t.id}>
<p className="mb-0.5 flex items-center gap-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
<span>{t.id}{t.id === current ? ' · reading' : ''}</span>
{d && <span className="normal-case tracking-normal">{differenceLabel(d.similarity)}</span>}
</p>
{!row ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
: row.error ? <p className="text-sm text-[var(--r-sub)]">{row.error}</p>
: <p className="font-serif text-[15px] leading-relaxed"><DiffText text={row.text} ranges={d?.b} /></p>}
</div>
);
})}
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">
{mine ? <><span className="diff-hl">Marked words</span> aren’t in the {current} wording. </> : null}
Copyright notices for each translation appear at the foot of the chapter when you read it.
</p>
</div>
);
}
export default function VerseSheet({
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose,
}) {
const [tab, setTab] = useState('commentary');
const [copied, setCopied] = useState(false);
useEffect(() => { if (verse && tab === 'refs') onLoadCrossRefs(); }, [verse?.number, tab]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { setCopied(false); }, [verse?.number]);
if (!verse) return null;
const copy = () => {
navigator.clipboard?.writeText(`${bookName} ${chapter}:${verse.number} ${translation} — ${verse.text}`);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};
const actionBtn = 'rounded-lg p-1.5 hover:bg-black/5';
return (
<Sheet
open modal={false} pane onClose={onClose}
title={`${bookName} ${chapter}:${verse.number}`}
headerRight={(
<>
<button type="button" onClick={onToggleBookmark} className={`${actionBtn} ${bookmarkColor ? 'text-[var(--r-accent)]' : ''}`} aria-label={bookmarkColor ? 'Remove bookmark' : 'Bookmark verse'}>
<BookmarkIcon filled={!!bookmarkColor} />
</button>
{bookmarkColor && (
<button type="button" onClick={onCycleColor} className={actionBtn} aria-label="Change bookmark colour">
<span className="block h-4 w-4 rounded-full border border-black/10" style={{ backgroundColor: bookmarkColor }} />
</button>
)}
<button type="button" onClick={copy} className={actionBtn} aria-label="Copy verse">
{copied ? <span className="px-1 text-xs">Copied</span> : <CopyIcon />}
</button>
</>
)}
>
<div role="tablist" aria-label="About this verse" className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
{TABS.map(([id, label]) => (
<button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}
className={`shrink-0 rounded-full border px-3 py-1 text-xs ${tab === id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{label}
</button>
))}
</div>
{tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />}
{tab === 'compare' && <CompareTab book={book} chapter={chapter} verse={verse.number} current={translation} />}
{tab === 'original' && <OriginalTab words={words} />}
{tab === 'refs' && <RefsTab refs={crossRefs} loading={crossRefsLoading} onJump={onJump} />}
{tab === 'people' && <PeoplePlacesTab book={book} chapter={chapter} verse={verse.number} />}
</Sheet>
);
}
+14
View File
@@ -0,0 +1,14 @@
// Small stroke icons for the reader chrome (currentColor, 20px grid).
const base = { viewBox: '0 0 20 20', width: 20, height: 20, fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true };
export const ChevronLeft = () => <svg {...base}><path d="M12 4l-6 6 6 6" /></svg>;
export const ChevronRight = () => <svg {...base}><path d="M8 4l6 6-6 6" /></svg>;
export const ChevronDown = ({ size = 14 }) => <svg {...base} width={size} height={size}><path d="M5 8l5 5 5-5" /></svg>;
export const SearchIcon = () => <svg {...base}><circle cx="9" cy="9" r="5.5" /><path d="M13.5 13.5L17 17" /></svg>;
export const CloseIcon = () => <svg {...base}><path d="M5 5l10 10M15 5L5 15" /></svg>;
export const LinkIcon = () => <svg {...base}><path d="M8.5 11.5a3 3 0 004.2 0l2.3-2.3a3 3 0 00-4.2-4.2L9.7 6.1" /><path d="M11.5 8.5a3 3 0 00-4.2 0L5 10.8A3 3 0 009.2 15l1.1-1.1" /></svg>;
export const PenIcon = () => <svg {...base}><path d="M13.5 3.5l3 3L7 16l-4 1 1-4 9.5-9.5z" /></svg>;
export const HeadphonesIcon = () => <svg {...base}><path d="M3.5 12V10a6.5 6.5 0 0113 0v2" /><rect x="2.5" y="11.5" width="3.5" height="5" rx="1.2" /><rect x="14" y="11.5" width="3.5" height="5" rx="1.2" /></svg>;
export const FocusIcon = () => <svg {...base}><path d="M3 7V4h3M17 7V4h-3M3 13v3h3M17 13v3h-3" /></svg>;
export const CheckIcon = () => <svg {...base}><path d="M4.5 10.5l3.5 3.5 7.5-8" /></svg>;
export const CompareIcon = () => <svg {...base}><rect x="2.5" y="3.5" width="6.5" height="13" rx="1.3" /><rect x="11" y="3.5" width="6.5" height="13" rx="1.3" /><path d="M4.5 7h2.5M4.5 10h2.5M13 7h2.5M13 10h2.5" /></svg>;
+18
View File
@@ -0,0 +1,18 @@
// Reader colour themes, applied as CSS variables on the reader root so every
// reader component can use bg-[var(--r-bg)] etc. and stay in sync.
export const READER_THEMES = {
paper: {
label: 'Paper',
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#6b6458', '--r-accent': '#a84e27', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' },
},
sepia: {
label: 'Sepia',
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#635539', '--r-accent': '#93471d', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
},
dark: {
label: 'Dark',
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#b8b2a4', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
},
};
export const themeVars = (id) => (READER_THEMES[id] ?? READER_THEMES.paper).vars;
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'vitest';
import { READER_THEMES } from './readerThemes.js';
// WCAG relative luminance and contrast ratio
const luminance = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (a, b) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
// Small text needs 4.5:1. These are the pairings the app actually draws.
describe.each(Object.entries(READER_THEMES))('%s theme contrast', (_id, { vars }) => {
const v = (name) => vars[`--r-${name}`];
test.each([
['text', 'bg'], ['text', 'bar'], ['text', 'hl'],
['sub', 'bg'], ['sub', 'bar'], ['sub', 'hl'],
['accent', 'bg'], ['accent', 'bar'],
['danger', 'bg'], ['danger', 'bar'],
['on', 'accent'],
])('%s on %s is at least 4.5:1', (fg, bg) => {
expect(contrast(v(fg), v(bg))).toBeGreaterThanOrEqual(4.5);
});
});
+113
View File
@@ -0,0 +1,113 @@
// Settings → "Your data": download everything as one file, restore from one, and save Bibles for offline.
import { useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { useToast } from '../Toasts.jsx';
import { DownloadProgress } from '../OfflineSave.jsx';
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
import { backupFileName, buildBackup, restoreProjects, validateBackup } from '../../utils/backup.js';
import { saveRemoteProject } from '../../syncService.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
export default function DataSection() {
const {
authUser, readerBookmarks, readerTextHighlights, readerColorLabels, readerInkByPage, readingPlansState,
importReaderMarks, importReadingPlans, refreshProjectIndex,
} = useApp();
const { toast } = useToast();
const offline = useOfflineSave();
const fileRef = useRef(null);
const [pending, setPending] = useState(null); // { name, backup, summary } waiting for "Restore"
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const download = () => {
const backup = buildBackup({
email: authUser?.email ?? null,
reader: { bookmarks: readerBookmarks, highlights: readerTextHighlights, colorLabels: readerColorLabels, ink: readerInkByPage },
readingPlans: readingPlansState,
});
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = backupFileName();
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
toast(`Backup downloaded: ${plural(backup.projects.length, 'study', 'studies')} and your reading marks`);
};
const choose = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
setError('');
setPending(null);
if (!file) return;
let data;
try { data = JSON.parse(await file.text()); } catch { setError('That file couldn’t be read as a backup.'); return; }
const checked = validateBackup(data);
if (!checked.ok) { setError(checked.error); return; }
setPending({ name: file.name, ...checked });
};
const restore = async () => {
setBusy(true);
const { backup } = pending;
const written = restoreProjects(backup.projects);
importReaderMarks(backup.reader);
importReadingPlans(backup.readingPlans);
refreshProjectIndex();
// Studies restored here also go to the account, so they follow you to other devices
if (authUser) await Promise.all(written.map((p) => saveRemoteProject(p)));
setBusy(false);
setPending(null);
toast(`Restored ${plural(written.length, 'study', 'studies')}, plus your bookmarks, highlights and plans`);
};
const s = pending?.summary;
return (
<section id="backup" aria-label="Your data" className="scroll-mt-16 space-y-5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
<div>
<h2 className="text-base font-medium">Your data</h2>
<p className="mt-1 text-sm text-[var(--r-sub)]">Everything you’ve made — studies and notes, bookmarks, highlights, ink and reading plans — in one file you keep.</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={download} className={btn}>Download backup</button>
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
</div>
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{pending && (
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 text-sm">
<p className="font-medium">{pending.name}</p>
<p className="mt-1 text-[var(--r-sub)]">
Contains {plural(s.projects, 'study', 'studies')}, {plural(s.bookmarks, 'bookmark')}, {plural(s.highlights, 'highlight')}, {plural(s.inkPages, 'ink page')} and {plural(s.plans, 'reading plan')}.
Restoring adds these to what you have — nothing is deleted, and a study on this device is only replaced if the backup’s copy is newer.
</p>
<div className="mt-3 flex gap-2">
<button type="button" onClick={restore} disabled={busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{busy ? 'Restoring…' : 'Restore'}</button>
<button type="button" onClick={() => setPending(null)} className={btn}>Cancel</button>
</div>
</div>
)}
<div className="border-t border-[var(--r-line)] pt-4">
<h3 className="text-sm font-medium">Read offline</h3>
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.</p>
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
<div className="flex flex-wrap gap-3">
{['BSB', 'NET'].map((t) => (
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
</button>
))}
</div>
)}
</div>
</section>
);
}
@@ -0,0 +1,56 @@
// Commentary for the chunk's verse range, from seven open sources. Some sources
// comment on whole passages, so when nothing starts inside the range we show the
// closest earlier entry. "Add to notes" quotes a paragraph, with its citation, into
// the chunk's background notes.
import { COMMENTARIES } from '../../hooks/useVerseStudy.js';
import { formatChunkReference } from '../../utils/bibleUtils.jsx';
export default function CommentaryChunkTab({ resources, chunk, project, chapterIndex, updateChunk, notify }) {
const { commentarySource, setCommentarySource, commentaryData, commentaryLoading, commentaryError } = resources;
const source = COMMENTARIES.find((c) => c.id === commentarySource) ?? COMMENTARIES[0];
const items = (commentaryData?.chapter?.content ?? []).filter((i) => i.type === 'verse').sort((a, b) => a.number - b.number);
let entries = items.filter((i) => i.number >= chunk.startVerse && i.number <= chunk.endVerse);
let earlier = false;
if (entries.length === 0) {
const before = [...items].reverse().find((i) => i.number <= chunk.startVerse);
if (before) { entries = [before]; earlier = true; }
}
const ref = formatChunkReference(project, chapterIndex, chunk, '–');
const quote = (text, verse) => {
const citation = `${text} (${source.name}, ${ref.split(':')[0]}:${verse})`;
const existing = chunk.generalNotes ?? '';
updateChunk(chunk.id, { generalNotes: existing ? `${existing}\n\n${citation}` : citation });
notify('Added to background notes.', 1600);
};
return (
<div>
<select aria-label="Commentary source" value={source.id} onChange={(e) => setCommentarySource(e.target.value)}
className="mb-3 w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
{commentaryLoading && <p className="text-sm text-[var(--r-sub)]">Loading commentary…</p>}
{commentaryError && <p className="text-sm text-[var(--r-sub)]">{commentaryError}</p>}
{!commentaryLoading && !commentaryError && commentaryData && entries.length === 0 && (
<p className="text-sm text-[var(--r-sub)]">No commentary found for {ref}.</p>
)}
{!commentaryLoading && !commentaryError && entries.map((v) => (
<div key={v.number} className="mb-4">
<p className="mb-1 text-xs font-medium text-[var(--r-sub)]">{earlier ? `Notes on the passage starting at verse ${v.number}` : `Verse ${v.number}`}</p>
{(v.content ?? []).map((p, i) => {
const text = typeof p === 'string' ? p : '';
if (!text) return null;
return (
<div key={i} className="group mb-2">
<p className="whitespace-pre-line text-[13px] leading-relaxed">{text}</p>
<button type="button" onClick={() => quote(text, v.number)} className="mt-0.5 text-[11px] text-[var(--r-accent)] opacity-70 hover:underline hover:opacity-100">Add to notes</button>
</div>
);
})}
</div>
))}
<p className="mt-2 text-[10px] text-[var(--r-sub)]">{source.credit}</p>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
// Cross-references for the chunk: typed, suggested from the Open Cross Ref
// dataset, and a preview of each reference's verse text.
import { useState } from 'react';
function RefChip({ label, onRemove, loadVerseText }) {
const [text, setText] = useState(null);
const [open, setOpen] = useState(false);
const toggle = () => {
setOpen((o) => !o);
if (text == null) loadVerseText(label).then((t) => setText(t || 'Verse text unavailable.')).catch(() => setText('Verse text unavailable.'));
};
return (
<div className="rounded-xl border border-[var(--r-line)]">
<div className="flex items-center justify-between gap-2 px-3 py-1.5">
<button type="button" onClick={toggle} aria-expanded={open} className="flex-1 text-left text-sm">{label}</button>
<button type="button" onClick={onRemove} aria-label={`Remove ${label}`} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
</div>
{open && <p className="border-t border-[var(--r-line)] px-3 py-2 font-serif text-[13px] leading-relaxed text-[var(--r-sub)]">{text ?? 'Loading…'}</p>}
</div>
);
}
export default function CrossRefsTab({ crossRefs, chunk }) {
const { crossRefInput, setCrossRefInput, suggestingCrossRefs, crossRefSuggestions, addCrossRef, removeCrossRef,
suggestCrossRefsForChunk, addSuggestedCrossRef, loadVerseText } = crossRefs;
const refs = chunk.crossReferences ?? [];
return (
<div>
<div className="flex gap-2">
<input value={crossRefInput} onChange={(e) => setCrossRefInput(e.target.value)} placeholder="e.g. John 1:1 or Rom 3:23"
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCrossRef(chunk.id); } }}
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
<button type="button" onClick={() => addCrossRef(chunk.id)} className="rounded-lg bg-[var(--r-text)] px-3 py-1.5 text-sm text-[var(--r-bg)]">Add</button>
</div>
<button type="button" onClick={() => suggestCrossRefsForChunk(chunk.id)} disabled={suggestingCrossRefs}
className="mt-2 rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-50">
{suggestingCrossRefs ? 'Suggesting…' : 'Suggest from passage'}
</button>
{crossRefSuggestions.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{crossRefSuggestions.map((ref) => (
<button key={ref} type="button" onClick={() => addSuggestedCrossRef(chunk.id, ref)}
className="rounded-full border border-[var(--r-accent)] px-2.5 py-0.5 text-xs text-[var(--r-accent)] hover:bg-black/5">+ {ref}</button>
))}
</div>
)}
<div className="mt-3 space-y-1.5">
{refs.map((ref) => <RefChip key={ref} label={ref} onRemove={() => removeCrossRef(chunk.id, ref)} loadVerseText={loadVerseText} />)}
{refs.length === 0 && <p className="text-sm text-[var(--r-sub)]">No cross-references yet.</p>}
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
// area for background notes and tags so the page stays quiet.
import { useEffect, useState } from 'react';
const SECTIONS = [
{ field: 'observation', label: 'Observe', hint: 'What does the text say?',
placeholder: "What does the text actually say?\n• Who is speaking, and to whom?\n• What key words or phrases repeat?\n• What's the tone, structure, or literary style?" },
{ field: 'interpretation', label: 'Interpret', hint: 'What does it mean?',
placeholder: "What did this mean to its original audience?\n• What's the historical/cultural context?\n• How does it fit the surrounding argument?\n• What does it reveal about God's character?" },
{ field: 'application', label: 'Apply', hint: 'What do I do with it?',
placeholder: 'How should this shape your life today?\n• What attitude or action does this call for?\n• Is there a promise to trust or a warning to heed?\n• Who could you share this with?' },
];
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
export default function NotesPane({ chunk, updateChunk, allTags }) {
const [open, setOpen] = useState(() => {
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
});
const [more, setMore] = useState(false);
const [tagInput, setTagInput] = useState('');
useEffect(() => { setTagInput(''); }, [chunk.id]);
useEffect(() => { try { localStorage.setItem('studyNotesOpen', open || ''); } catch { /* storage unavailable */ } }, [open]);
const tags = chunk.tags ?? [];
const addTag = (tag) => {
const t = tag.trim();
if (!t || tags.some((x) => x.toLowerCase() === t.toLowerCase())) { setTagInput(''); return; }
updateChunk(chunk.id, { tags: [...tags, t] });
setTagInput('');
};
const suggestions = allTags.filter((t) => !tags.some((x) => x.toLowerCase() === t.toLowerCase()));
return (
<section aria-label="Study notes" className="space-y-2">
{SECTIONS.map(({ field, label, hint, placeholder }) => {
const isOpen = open === field;
const n = words(chunk[field]);
return (
<div key={field} className={`overflow-hidden rounded-2xl border ${isOpen ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
<button type="button" onClick={() => setOpen(isOpen ? null : field)} aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left">
<span className="flex items-center gap-2.5">
<span aria-hidden="true" className={`h-2.5 w-2.5 rounded-full border ${n ? 'border-[var(--r-accent)] bg-[var(--r-accent)]' : 'border-[var(--r-sub)]'}`} />
<span className="text-sm font-medium">{label}</span>
<span className="text-xs text-[var(--r-sub)]">{hint}</span>
</span>
<span className="text-xs text-[var(--r-sub)]">{n ? `${n} word${n === 1 ? '' : 's'}` : ''}</span>
</button>
{isOpen && (
<div className="px-4 pb-4">
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={placeholder}
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
</div>
)}
</div>
);
})}
<div className="rounded-2xl border border-[var(--r-line)]">
<button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm">
<span><span className="font-medium">More</span> <span className="text-xs text-[var(--r-sub)]">background{tags.length ? ` · ${tags.length} tag${tags.length === 1 ? '' : 's'}` : ' and tags'}</span></span>
<span className="text-xs text-[var(--r-sub)]">{more ? '▾' : '▸'}</span>
</button>
{more && (
<div className="space-y-4 px-4 pb-4">
<label className="block text-xs text-[var(--r-sub)]">
Background / general notes
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
</label>
<div>
<p className="mb-1.5 text-xs text-[var(--r-sub)]">Tags</p>
<div className="flex flex-wrap items-center gap-1.5">
{tags.map((tag) => (
<span key={tag} className="inline-flex items-center gap-1 rounded-full bg-[var(--r-bar)] px-2.5 py-1 text-xs">
{tag}
<button type="button" aria-label={`Remove tag ${tag}`} onClick={() => updateChunk(chunk.id, { tags: tags.filter((t) => t !== tag) })} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">×</button>
</span>
))}
<input value={tagInput} onChange={(e) => setTagInput(e.target.value)} list="study-tag-suggestions"
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(tagInput); } }}
placeholder="Add a tag and press Enter"
className="min-w-[10rem] flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1 text-xs text-[var(--r-text)] focus:outline-none" />
</div>
{suggestions.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-[var(--r-sub)]">Suggestions:</span>
{suggestions.slice(0, 12).map((t) => (
<button key={t} type="button" onClick={() => addTag(t)} className="rounded-full border border-[var(--r-line)] px-2.5 py-0.5 text-[var(--r-sub)] hover:bg-black/5">+ {t}</button>
))}
</div>
)}
<datalist id="study-tag-suggestions">{allTags.map((t) => <option key={t} value={t} />)}</datalist>
</div>
</div>
)}
</div>
</section>
);
}
+43
View File
@@ -0,0 +1,43 @@
// Left rail of the Study stage: every chunk of the project as an outline, with
// three dots (Observe / Interpret / Apply) showing which notes are written.
import { formatChunkReference } from '../../utils/bibleUtils.jsx';
const filled = (s) => (s ?? '').trim().length > 0;
export default function OutlineRail({ project, selectedId, onSelect, onEditOutline }) {
return (
<nav aria-label="Chunks" className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between px-3 pb-2 pt-3">
<span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Chunks</span>
<button type="button" onClick={onEditOutline} className="text-[11px] text-[var(--r-accent)] hover:underline">Edit</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4">
{project.chapters.map((ch, chapterIndex) => (
<div key={`${ch.bookAbbrev}-${ch.chapter}`} className="mb-3">
{project.chapters.length > 1 && <p className="px-2 pb-1 text-[11px] text-[var(--r-sub)]">{ch.book} {ch.chapter}</p>}
{ch.chunks.length === 0 && <p className="px-2 text-xs text-[var(--r-sub)]">No chunks in this chapter.</p>}
{ch.chunks.map((chunk) => {
const selected = chunk.id === selectedId;
const first = ch.verses.find((v) => v.number === chunk.startVerse)?.text ?? '';
return (
<button key={chunk.id} type="button" onClick={() => onSelect(chunk.id)} aria-current={selected ? 'true' : undefined}
className={`mb-1 block w-full rounded-xl border px-2.5 py-2 text-left transition ${selected ? 'border-[var(--r-line)] bg-[var(--r-bg)]' : 'border-transparent hover:bg-black/5'}`}>
<span className="flex items-center justify-between gap-2">
<span className="text-[13px] font-medium">{formatChunkReference(project, chapterIndex, chunk, '–')}</span>
<span className="flex gap-1" aria-hidden="true">
{['observation', 'interpretation', 'application'].map((f) => (
<span key={f} className={`h-2 w-2 rounded-full border ${filled(chunk[f]) ? 'border-[var(--r-accent)] bg-[var(--r-accent)]' : 'border-[var(--r-sub)]'}`} />
))}
</span>
</span>
{chunk.episodeTitle && <span className="mt-0.5 block truncate text-[11px] text-[var(--r-accent)]">{chunk.episodeNumber ? `${chunk.episodeNumber} · ` : ''}{chunk.episodeTitle}</span>}
<span className="mt-0.5 block truncate font-serif text-xs text-[var(--r-sub)]">{first}</span>
</button>
);
})}
</div>
))}
</div>
</nav>
);
}
+107
View File
@@ -0,0 +1,107 @@
// Themed frame for pages outside a study project (Home, Import, Settings, Admin):
// a calm app bar (brand, three destinations, account menu) and a page heading row.
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft, SearchIcon } from '../reader/icons.jsx';
// `short` is what a phone shows; the full label is still the button's accessible name.
const NAV = [
{ id: 'studies', label: 'Studies', short: 'Studies' },
{ id: 'reader', label: 'Read Bible', short: 'Bible' },
{ id: 'plans', label: 'Reading Plans', short: 'Plans' },
];
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
function AccountMenu() {
const { authUser, readerTheme, setReaderTheme, setCurrentPage, logout } = useApp();
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return undefined;
const onDown = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
}, [open]);
const go = (page) => { setOpen(false); setCurrentPage(page); };
const item = 'block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5';
return (
<div ref={ref} className="relative">
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Account menu" aria-expanded={open}
className="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-xs font-medium uppercase hover:bg-black/5">
{authUser?.email?.[0] ?? '·'}
</button>
{open && (
<div role="menu" className="absolute right-0 top-full z-40 mt-2 w-60 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-2 shadow-xl">
{authUser && <p className="truncate px-3 pb-1 pt-2 text-xs text-[var(--r-sub)]">{authUser.email}</p>}
<div className="px-3 pb-2 pt-1">
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Theme</p>
<div className="flex overflow-hidden rounded-lg border border-[var(--r-line)]">
{Object.entries(READER_THEMES).map(([id, t]) => (
<button key={id} type="button" onClick={() => setReaderTheme(id)} aria-pressed={readerTheme === id}
className={`flex-1 px-2 py-1.5 text-xs ${readerTheme === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{t.label}</button>
))}
</div>
</div>
{authUser && (
<div className="border-t border-[var(--r-line)] pt-1">
<button type="button" role="menuitem" onClick={() => go('settings')} className={item}>Settings</button>
{authUser.isAdmin && <button type="button" role="menuitem" onClick={() => go('admin')} className={item}>Admin</button>}
<button type="button" role="menuitem" onClick={() => { setOpen(false); logout(); }} className={item}>Log out</button>
</div>
)}
</div>
)}
</div>
);
}
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPalette } = useApp();
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans };
return (
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
<a href="#content" className="skip-link">Skip to content</a>
<header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-2 px-3 sm:gap-4 sm:px-4`}>
<button type="button" onClick={goHome} className="flex shrink-0 items-center gap-2" aria-label="Bible Study home">
<img src="/logo.svg" alt="" className="h-6 w-6" />
<span className="hidden text-sm font-medium sm:inline">Bible Study</span>
</button>
<nav aria-label="Main" className="flex items-center gap-0.5">
{NAV.map((n) => (
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
className={`whitespace-nowrap rounded-full px-2.5 py-1.5 text-sm sm:px-3 ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
<span className="sm:hidden" aria-hidden="true">{n.short}</span><span className="hidden sm:inline" aria-hidden="true">{n.label}</span>
</button>
))}
</nav>
<div className="ml-auto flex items-center gap-2">
{openPalette && (
<button type="button" onClick={openPalette} aria-label="Search and go to"
className="flex items-center gap-2 rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1 text-xs text-[var(--r-sub)] hover:bg-black/5">
<SearchIcon /><span className="hidden sm:inline">Search</span><kbd className="hidden rounded border border-[var(--r-line)] px-1 text-[10px] md:inline">⌘K</kbd>
</button>
)}
<AccountMenu />
</div>
</div>
</header>
<div id="content" tabIndex={-1} className="focus:outline-none">
<div className={`mx-auto flex ${maxWidth} flex-wrap items-center gap-3 px-4 pt-6`}>
{onBack && (
<button type="button" onClick={onBack} aria-label="Back" className="-ml-2 flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
)}
<h1 className="text-2xl font-medium tracking-tight">{title}</h1>
{actions && <div className="ml-auto flex flex-wrap items-center gap-2">{actions}</div>}
</div>
{children}
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
// People, places and events (Theographic data) tied to the chunk's verses.
import { loadTheographic, useAsync } from '../../hooks/useVerseStudy.js';
import { PersonCard } from '../StudyPanel.jsx';
export default function PeopleChunkTab({ book, chapter, chunk }) {
const { status, data } = useAsync(() => loadTheographic(book, chapter), [book, chapter]);
if (status === 'loading') return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
if (status === 'empty') return <p className="text-sm text-[var(--r-sub)]">No people or places data for this chapter.</p>;
const inChunk = (x) => x.verses?.some((n) => n >= chunk.startVerse && n <= chunk.endVerse);
const people = data.people.filter(inChunk);
const places = [...new Map(data.places.filter(inChunk).map((p) => [p.name, p])).values()];
const events = data.events.filter(inChunk);
if (!people.length && !places.length && !events.length) return <p className="text-sm text-[var(--r-sub)]">Nothing tagged in these verses.</p>;
const head = 'mb-1 text-[11px] uppercase tracking-wide text-[var(--r-sub)]';
return (
<div className="space-y-4">
{people.length > 0 && <div><p className={head}>People</p><ul className="space-y-1">{people.map((p) => <PersonCard key={p.id} person={p} />)}</ul></div>}
{places.length > 0 && (
<div><p className={head}>Places</p>
<ul className="space-y-1">{places.map((p) => (
<li key={p.id} className="flex items-center justify-between rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm">
<span>{p.name}{p.featureType && <span className="ml-1 text-xs text-[var(--r-sub)]">{p.featureType}</span>}</span>
{p.latitude != null && <a href={`https://www.openstreetmap.org/?mlat=${p.latitude}&mlon=${p.longitude}#map=8/${p.latitude}/${p.longitude}`} target="_blank" rel="noopener noreferrer" className="text-xs text-[var(--r-accent)] hover:underline">Map ↗</a>}
</li>))}</ul></div>
)}
{events.length > 0 && <div><p className={head}>Events</p><ul className="space-y-1">{events.map((e) => <li key={e.id} className="rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm">{e.name}{e.startDate && <span className="ml-1 text-xs text-[var(--r-sub)]">approx. AD {e.startDate}</span>}</li>)}</ul></div>}
<p className="text-[10px] text-[var(--r-sub)]">People, places and events: Theographic Bible Metadata, CC BY-SA 4.0.</p>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
// Right-hand pane of the Study stage: commentary, word study, cross-references and
// people & places, all driven by the selected chunk.
import { useState } from 'react';
import CommentaryChunkTab from './CommentaryChunkTab.jsx';
import WordStudyTab from './WordStudyTab.jsx';
import CrossRefsTab from './CrossRefsTab.jsx';
import PeopleChunkTab from './PeopleChunkTab.jsx';
export const RESOURCE_TABS = [
{ id: 'commentary', label: 'Commentary' },
{ id: 'words', label: 'Word study' },
{ id: 'refs', label: 'Cross-refs' },
{ id: 'people', label: 'People & places' },
];
export default function ResourcePane({ tab, setTab, chunk, chapter, chapterIndex, project, updateChunk, notify, resources, wordStudy, crossRefs, openWordId, setOpenWordId }) {
const counts = { words: chunk.greekWords.length, refs: (chunk.crossReferences ?? []).length };
return (
<div className="flex min-h-0 flex-1 flex-col">
<div role="tablist" aria-label="Study resources" className="flex shrink-0 flex-wrap gap-1.5 border-b border-[var(--r-line)] px-3 py-2.5">
{RESOURCE_TABS.map((t) => (
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id} onClick={() => setTab(t.id)}
className={`rounded-full border px-3 py-1 text-xs ${tab === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{t.label}{counts[t.id] ? ` · ${counts[t.id]}` : ''}
</button>
))}
</div>
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{tab === 'commentary' && <CommentaryChunkTab resources={resources} chunk={chunk} project={project} chapterIndex={chapterIndex} updateChunk={updateChunk} notify={notify} />}
{tab === 'words' && <WordStudyTab wordStudy={wordStudy} chunk={chunk} openWordId={openWordId} setOpenWordId={setOpenWordId} />}
{tab === 'refs' && <CrossRefsTab crossRefs={crossRefs} chunk={chunk} />}
{tab === 'people' && <PeopleChunkTab book={chapter.bookAbbrev} chapter={chapter.chapter} chunk={chunk} />}
</div>
</div>
);
}
export function useResourceTab() {
const [tab, setTabState] = useState(() => {
try {
const saved = localStorage.getItem('studyResourceTab');
return RESOURCE_TABS.some((t) => t.id === saved) ? saved : 'commentary';
} catch { return 'commentary'; }
});
const setTab = (id) => {
setTabState(id);
try { localStorage.setItem('studyResourceTab', id); } catch { /* storage unavailable */ }
};
return [tab, setTab];
}
+46
View File
@@ -0,0 +1,46 @@
// The chunk's passage, set like the Reader. Click a verse number to open its
// interlinear words; click an original word to add it to the chunk's word study.
import { useState } from 'react';
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord }) {
const [openVerse, setOpenVerse] = useState(null);
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
return (
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
<div className="font-serif text-[1.05rem] leading-[1.85]">
{verses.map((v) => {
const words = v.chapter === chapter?.chapter ? interlinear?.[String(v.number)] : null;
const open = openVerse === `${v.chapter}:${v.number}`;
return (
<div key={`${v.chapter}-${v.number}`} className="mb-1.5">
<button type="button" disabled={!words} onClick={() => setOpenVerse(open ? null : `${v.chapter}:${v.number}`)}
title={words ? 'Show original words' : undefined}
className={`mr-1.5 align-super font-sans text-[0.6em] ${words ? 'text-[var(--r-accent)] hover:underline' : 'cursor-default text-[var(--r-sub)]'} ${open ? 'font-bold' : ''}`}>
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
</button>
<span>{v.text}</span>
{open && words && (
<div dir={isHebrew ? 'rtl' : 'ltr'} className="mt-2 flex flex-wrap gap-2 rounded-xl bg-[var(--r-bg)] p-2.5 font-sans">
{words.map((w, i) => {
const added = w.s && addedStrongs.has(w.s.toUpperCase());
return (
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
<span className="text-[11px] leading-tight text-[var(--r-sub)]">{w.g || '—'}</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
{interlinearLoading && <p className="mt-2 font-sans text-xs text-[var(--r-sub)]">Loading original words…</p>}
{!interlinearLoading && interlinear && <p className="mt-2 font-sans text-[11px] text-[var(--r-sub)]">Click a verse number to see its original words; click a word to study it.</p>}
</section>
);
}
+70
View File
@@ -0,0 +1,70 @@
// Shared frame for every stage of a study project (Outline, Study, Script,
// Export): themed like the Reader, with a slim bar that carries the project title,
// the stage switcher, and save/sync status.
import { useApp } from '../../context/AppContext.js';
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft } from '../reader/icons.jsx';
export const STAGES = [
{ id: 'setup', label: 'Chunks' },
{ id: 'study', label: 'Study' },
{ id: 'outline', label: 'Outline' },
{ id: 'script', label: 'Script' },
{ id: 'export', label: 'Export' },
];
const THEME_ORDER = ['paper', 'sepia', 'dark'];
export default function StudioShell({ stage, loading = false, fixedHeight = false, children }) {
const {
goHome, setCurrentPage, project, allChunks,
saveStatus, syncStatus, retrySync, syncConflict, keepMyVersion, useServerVersion,
readerTheme, setReaderTheme,
} = useApp();
const noChunks = allChunks.length === 0;
const cycleTheme = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
return (
<div className={`bg-[var(--r-bg)] font-sans text-[var(--r-text)] ${fixedHeight ? 'fixed inset-0 flex flex-col overflow-hidden' : 'min-h-dvh'}`} style={themeVars(readerTheme)}>
<div className="safe-top z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3">
<button type="button" onClick={goHome} aria-label="Back to home"
className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
<div className="min-w-0 max-w-[16rem] truncate text-sm font-medium">{project?.title ?? ''}</div>
<nav aria-label="Study stages" className="mx-auto flex gap-0.5">
{STAGES.map((s) => {
const disabled = s.id !== 'setup' && (!project || noChunks);
return (
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
title={disabled ? 'Add a chunk in Chunks first' : undefined}
onClick={() => setCurrentPage(s.id)}
className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
{s.label}
</button>
);
})}
</nav>
<div className="flex min-w-[7rem] items-center justify-end gap-2 text-xs text-[var(--r-sub)]" aria-live="polite">
{loading ? <span>Loading…</span>
: saveStatus ? <span className="text-[var(--r-accent)]">{saveStatus}</span>
: syncStatus === 'syncing' ? <span>Syncing…</span>
: syncStatus === 'synced' ? <span>Saved</span>
: syncStatus === 'error' ? (
<span className="text-[var(--r-danger,#b42318)]">Sync failed <button type="button" onClick={retrySync} className="underline">Retry</button></span>
) : null}
<button type="button" onClick={cycleTheme} className="rounded-lg px-2 py-1 hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button>
</div>
</div>
</div>
{syncConflict && project && syncConflict.id === project.id && (
<div role="alert" className="flex shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-2 border-b border-[var(--r-line)] bg-[var(--r-hl)] px-4 py-2.5 text-sm">
<span>This study was changed on another device. Your edits here are saved on this device, but not yet synced.</span>
<span className="flex gap-2">
<button type="button" onClick={keepMyVersion} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] hover:opacity-90">Keep this version</button>
<button type="button" onClick={useServerVersion} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs font-medium hover:bg-black/5">Use the other version</button>
</span>
</div>
)}
{children}
</div>
);
}
@@ -0,0 +1,50 @@
// Picker for "suggest words from this passage": checkbox list of candidate words.
import DOMPurify from 'dompurify';
import Sheet from '../reader/Sheet.jsx';
import { buildGreekDefinitionHtml } from '../../utils/wordStudy.js';
export default function SuggestWordsSheet({ wordStudy }) {
const { suggestModal, setSuggestModal, suggestSelection, setSuggestSelection, confirmSuggestWords } = wordStudy;
if (!suggestModal) return null;
const close = () => { setSuggestModal(null); setSuggestSelection(new Set()); };
const n = suggestSelection.size;
return (
<Sheet open onClose={close} title={`Select ${suggestModal.language === 'hebrew' ? 'Hebrew' : 'Greek'} words to add`}>
<p className="mb-2 text-xs text-[var(--r-sub)]">{suggestModal.helperText || "Content words are pre-checked. Uncheck any you don't need."}</p>
<div className="mb-2 flex items-center gap-3 text-xs">
<button type="button" onClick={() => setSuggestSelection(new Set(suggestModal.words.map((w) => w.strongKey)))} className="text-[var(--r-accent)] hover:underline">Select all</button>
<button type="button" onClick={() => setSuggestSelection(new Set())} className="text-[var(--r-accent)] hover:underline">Select none</button>
<span className="ml-auto text-[var(--r-sub)]">{n} selected</span>
</div>
<ul className="max-h-[40vh] divide-y divide-[var(--r-line)] overflow-y-auto rounded-xl border border-[var(--r-line)] px-3">
{suggestModal.words.map((w) => (
<li key={w.strongKey}>
<label className="flex cursor-pointer items-center gap-3 py-2.5">
<input type="checkbox" checked={suggestSelection.has(w.strongKey)} className="h-4 w-4 shrink-0 accent-[var(--r-accent)]"
onChange={(e) => setSuggestSelection((prev) => { const next = new Set(prev); if (e.target.checked) next.add(w.strongKey); else next.delete(w.strongKey); return next; })} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-2">
<span className="text-sm font-medium">{w.def || w.strongKey}</span>
<span className="text-sm text-[var(--r-sub)]">{w.lexeme}</span>
{w.translit && <span className="text-xs italic text-[var(--r-sub)]">{w.translit}</span>}
<span className="font-mono text-[10px] text-[var(--r-sub)]">{w.strongKey}</span>
</div>
{w.entry && (w.entry.strongs_def || w.entry.kjv_def || w.entry.derivation) && (
<details className="mt-1">
<summary className="cursor-pointer text-xs text-[var(--r-accent)]" onClick={(e) => e.stopPropagation()}>Definition</summary>
<div className="mt-1 text-xs leading-5" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(buildGreekDefinitionHtml(w.strongKey, w.entry)) }} />
</details>
)}
</div>
</label>
</li>
))}
</ul>
<div className="mt-3 flex gap-2">
<button type="button" onClick={close} className="flex-1 rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5">Cancel</button>
<button type="button" onClick={confirmSuggestWords} disabled={n === 0}
className="flex-1 rounded-xl bg-[var(--r-accent)] py-2 text-sm font-medium text-[var(--r-on)] disabled:opacity-40">Add {n > 0 ? `${n} word${n > 1 ? 's' : ''}` : 'words'}</button>
</div>
</Sheet>
);
}
+93
View File
@@ -0,0 +1,93 @@
// Greek/Hebrew word study for the chunk: compact cards (one open for editing at a
// time), Strong's lookup, and suggestions from the passage.
import { useState } from 'react';
import DOMPurify from 'dompurify';
import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js';
const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
const { updateChunkWord, lookupWord, removeGreekWord } = wordStudy;
const set = (patch) => updateChunkWord(chunkId, word.id, patch);
const field = (label, key, placeholder) => (
<label className="block text-xs text-[var(--r-sub)]">{label}
<input type="text" value={word[key] ?? ''} placeholder={placeholder} onChange={(e) => set({ [key]: e.target.value })} className={inputCls} />
</label>
);
const notFound = (word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query.trim();
return (
<div className={`rounded-xl border ${open ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)]'}`}>
<button type="button" onClick={onToggle} aria-expanded={open} className="flex w-full items-baseline gap-2 px-3 py-2 text-left">
<span className="font-serif text-lg leading-tight">{word.lexeme || <span className="font-sans text-sm text-[var(--r-sub)]">New word</span>}</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--r-sub)]">{[word.transliteration, word.englishGloss || word.shortDefinition].filter(Boolean).join(' · ')}</span>
{word.strongNumber && <span className="shrink-0 rounded-full bg-[var(--r-bar)] px-2 py-0.5 text-[10px]">{word.strongNumber}</span>}
</button>
{open && (
<div className="space-y-3 border-t border-[var(--r-line)] px-3 py-3">
<label className="block text-xs text-[var(--r-sub)]">Strong’s number or English gloss
<input type="text" value={word.query} onChange={(e) => set({ query: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); lookupWord(chunkId, word.id, 'greek'); } }}
placeholder="G4102, H7225, 4102, or a word" className={inputCls} />
</label>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'greek')} className={`${btn} bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>{word.loading ? 'Looking up...' : 'Look up Greek'}</button>
<button type="button" onClick={() => lookupWord(chunkId, word.id, 'hebrew')} className={btn}>{word.loading ? 'Looking up...' : 'Look up Hebrew'}</button>
{word.lexeme && <button type="button" onClick={() => speakOriginalWord(word.lexeme, word.strongNumber)} className={btn} title="Hear pronunciation">Speak</button>}
<button type="button" onClick={() => removeGreekWord(chunkId, word.id)} className={`${btn} ml-auto text-[var(--r-danger,#b42318)]`}>Delete</button>
</div>
<div className="grid grid-cols-2 gap-2">
{field(word.strongNumber?.startsWith('H') ? 'Hebrew word' : 'Greek word', 'lexeme')}
{field('Transliteration', 'transliteration')}
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
{field('English word', 'englishGloss', 'e.g. grace')}
</div>
{field('Short definition', 'shortDefinition')}
{word.definitionHtml && (
<details className="rounded-lg border border-[var(--r-line)] p-2.5">
<summary className="cursor-pointer text-xs font-medium">Extended definition</summary>
<div className="mt-2 text-[13px] leading-relaxed" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(word.definitionHtml) }} />
</details>
)}
{notFound ? (
<div className="rounded-lg border border-[var(--r-line)] p-2.5">
<p className="mb-1.5 text-xs text-[var(--r-sub)]">Not found in BDBT — look it up manually:</p>
<div className="flex flex-wrap gap-1.5">
{externalLookupLinks(word.query).map(({ label, url }) => (
<a key={label} href={url} target="_blank" rel="noreferrer" className={btn}>{label} ↗</a>
))}
<button type="button" onClick={() => navigator.clipboard.writeText(word.query.trim())} className={btn}>Copy “{word.query.trim()}”</button>
</div>
</div>
) : null}
</div>
)}
</div>
);
}
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy;
return (
<div>
<div className="mb-3 flex flex-wrap gap-2">
<button type="button" onClick={() => suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn}
title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}</button>
<button type="button" onClick={() => suggestHebrewWordsForChunk(chunk.id)} disabled={suggestingHebrewForChunkId === chunk.id} className={btn}
title="Suggest Hebrew words for Old Testament passages">{suggestingHebrewForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Hebrew'}</button>
<button type="button" onClick={() => setOpenWordId(addGreekWord(chunk.id))} className={`${btn} ml-auto bg-[var(--r-text)] text-[var(--r-bg)] hover:opacity-90`}>Add word</button>
</div>
<div className="space-y-2">
{chunk.greekWords.length === 0 && (
<p className="rounded-xl border border-dashed border-[var(--r-line)] p-4 text-sm text-[var(--r-sub)]">
No words yet. Open a verse’s original words in the text and click one, or add a word to look it up.
</p>
)}
{chunk.greekWords.map((w) => (
<WordCard key={w.id} word={w} open={openWordId === w.id} chunkId={chunk.id} wordStudy={wordStudy}
onToggle={() => setOpenWordId(openWordId === w.id ? null : w.id)} />
))}
</div>
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { createContext, useContext } from 'react';
export const AppContext = createContext(null);
export const useApp = () => useContext(AppContext);
+212
View File
@@ -0,0 +1,212 @@
// Chapter audio playback. The Home page's whole-book player and the Reader's
// chapter player share one <audio> element; audioModeRef tracks which owns it.
import { useEffect, useRef, useState } from 'react';
import { bookOptions, CHAPTER_COUNTS } from '../utils/bibleUtils.jsx';
export function useAudio(readerBookAbbrev, readerChapter, readerTranslation = 'BSB') {
const [audioBook, setAudioBook] = useState(bookOptions[0].abbrev);
const [audioNarrator, setAudioNarratorState] = useState(() => {
try {
const saved = localStorage.getItem('audio-narrator');
return ['david', 'hays', 'souer'].includes(saved) ? saved : 'souer';
} catch { return 'souer'; }
});
const setAudioNarrator = (id) => {
setAudioNarratorState(id);
try { localStorage.setItem('audio-narrator', id); } catch { /* storage unavailable */ }
};
const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 });
const [readerAudioState, setReaderAudioState] = useState({ status: 'idle', chapter: 0 });
const [audioProgress, setAudioProgress] = useState({ currentTime: 0, duration: 0 });
const audioRef = useRef(null);
const audioModeRef = useRef('book'); // 'book' | 'reader' — which player owns audioRef
const audioBookRef = useRef(audioBook);
const audioNarratorRef = useRef(audioNarrator);
const audioStateRef = useRef(audioState);
audioBookRef.current = audioBook;
audioNarratorRef.current = audioNarrator;
audioStateRef.current = audioState;
// The Reader's player follows the translation on screen: ESV has its own
// recording; every other translation falls back to the BSB narrators.
const readerTranslationRef = useRef(readerTranslation);
readerTranslationRef.current = readerTranslation;
const readerAudioSource = () => (readerTranslationRef.current === 'ESV' ? 'ESV' : 'BSB');
const playAudioChapter = async (abbrev, chapterNum, narrator) => {
const source = audioModeRef.current === 'reader' ? readerAudioSource() : 'BSB';
let url;
let total;
if (source === 'ESV') {
const res = await fetch(`/api/bible/ESV/audio/${abbrev}/${chapterNum}`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to load ESV audio');
url = (await res.json()).url;
total = CHAPTER_COUNTS[abbrev] ?? chapterNum;
} else {
const res = await fetch(`https://bible.helloao.org/api/BSB/${abbrev}/${chapterNum}.json`);
if (!res.ok) throw new Error('Failed to load chapter audio');
const data = await res.json();
total = data.book?.numberOfChapters ?? chapterNum;
url = data.thisChapterAudioLinks?.[narrator];
}
if (!url) throw new Error('Audio not available for this narrator');
if (!audioRef.current) {
audioRef.current = new Audio();
audioRef.current.addEventListener('timeupdate', () => {
setAudioProgress({ currentTime: audioRef.current.currentTime, duration: audioRef.current.duration || 0 });
});
audioRef.current.addEventListener('loadedmetadata', () => {
setAudioProgress((prev) => ({ ...prev, duration: audioRef.current.duration || 0 }));
});
audioRef.current.addEventListener('ended', () => {
if (audioModeRef.current === 'reader') {
setReaderAudioState({ status: 'idle', chapter: 0 });
return;
}
const nextChapter = audioStateRef.current.chapter + 1;
if (nextChapter > audioStateRef.current.total) {
setAudioState({ status: 'idle', chapter: 0, total: 0 });
return;
}
playAudioChapter(audioBookRef.current, nextChapter, audioNarratorRef.current).catch(() =>
setAudioState({ status: 'error', chapter: 0, total: 0 }),
);
});
}
const audioEl = audioRef.current;
audioEl.src = url;
await audioEl.play();
if (audioModeRef.current === 'reader') {
setReaderAudioState({ status: 'playing', chapter: chapterNum, source });
} else {
setAudioState({ status: 'playing', chapter: chapterNum, total });
}
};
const handlePlayBookAudio = async () => {
audioModeRef.current = 'book';
try {
await playAudioChapter(audioBook, 1, audioNarrator);
} catch {
setAudioState({ status: 'error', chapter: 0, total: 0 });
}
};
// Starts book-mode playback at an arbitrary chapter (e.g. a reading plan's
// "today" chapter) instead of always chapter 1. Explicit args avoid relying
// on reader state that may not have re-rendered yet if called right after
// navigating there in the same event handler.
const handlePlayBookAudioFrom = async (abbrev, startChapter) => {
audioModeRef.current = 'book';
setAudioBook(abbrev);
try {
await playAudioChapter(abbrev, startChapter, audioNarrator);
} catch {
setAudioState({ status: 'error', chapter: 0, total: 0 });
}
};
const handleStopBookAudio = () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
setAudioState({ status: 'idle', chapter: 0, total: 0 });
setReaderAudioState({ status: 'idle', chapter: 0 });
setAudioProgress({ currentTime: 0, duration: 0 });
};
const handleToggleBookAudioPause = () => {
if (!audioRef.current) return;
if (audioRef.current.paused) {
audioRef.current.play();
setAudioState((prev) => ({ ...prev, status: 'playing' }));
} else {
audioRef.current.pause();
setAudioState((prev) => ({ ...prev, status: 'paused' }));
}
};
const handleSeekAudio = (deltaSeconds) => {
if (!audioRef.current) return;
const el = audioRef.current;
const duration = Number.isFinite(el.duration) ? el.duration : Infinity;
el.currentTime = Math.min(Math.max(el.currentTime + deltaSeconds, 0), duration);
};
const handleSeekAudioTo = (seconds) => {
if (!audioRef.current) return;
const el = audioRef.current;
const duration = Number.isFinite(el.duration) ? el.duration : 0;
el.currentTime = Math.min(Math.max(seconds, 0), duration);
};
const handleSkipBookChapter = () => {
const nextChapter = audioStateRef.current.chapter + 1;
if (nextChapter > audioStateRef.current.total) return;
audioModeRef.current = 'book';
playAudioChapter(audioBookRef.current, nextChapter, audioNarratorRef.current).catch(() =>
setAudioState({ status: 'error', chapter: 0, total: 0 }),
);
};
const handlePlayReaderAudio = async () => {
audioModeRef.current = 'reader';
try {
await playAudioChapter(readerBookAbbrev, readerChapter, audioNarrator);
} catch {
setReaderAudioState({ status: 'error', chapter: 0 });
}
};
const handleToggleReaderAudioPause = () => {
if (!audioRef.current) return;
if (audioRef.current.paused) {
audioRef.current.play();
setReaderAudioState((prev) => ({ ...prev, status: 'playing' }));
} else {
audioRef.current.pause();
setReaderAudioState((prev) => ({ ...prev, status: 'paused' }));
}
};
// Auto-switch the reader's audio to match the book/chapter currently being read.
useEffect(() => {
if (audioModeRef.current !== 'reader') return;
if (readerAudioState.status !== 'playing' && readerAudioState.status !== 'paused') return;
if (readerAudioState.chapter === readerChapter && readerAudioState.source === readerAudioSource()) return;
const wasPaused = readerAudioState.status === 'paused';
playAudioChapter(readerBookAbbrev, readerChapter, audioNarrator)
.then(() => {
if (wasPaused && audioRef.current) {
audioRef.current.pause();
setReaderAudioState((prev) => ({ ...prev, status: 'paused' }));
}
})
.catch(() => setReaderAudioState({ status: 'error', chapter: 0 }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readerBookAbbrev, readerChapter, readerTranslation]);
useEffect(() => () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
}, []);
return {
audioBook, setAudioBook,
audioNarrator, setAudioNarrator,
audioState,
audioProgress,
handlePlayBookAudio,
handlePlayBookAudioFrom,
handleStopBookAudio,
handleToggleBookAudioPause,
handleSkipBookChapter,
handleSeekAudio,
handleSeekAudioTo,
readerAudioState,
handlePlayReaderAudio,
handleToggleReaderAudioPause,
};
}
+44
View File
@@ -0,0 +1,44 @@
// Who is signed in. Checks the session on startup and exposes signIn/logout so
// every sign-in path (startup, login form, 2FA) runs the same follow-up work.
import { useEffect, useRef, useState } from 'react';
import { getCurrentUser, logoutUser } from '../syncService.js';
// onSignIn(user) runs right after a user becomes signed in (switch local
// storage to their account, load their data). onSignOut() runs after logout.
export function useAuth({ onSignIn, onSignOut }) {
// authUser: undefined = still checking · null = signed out · object = signed in
const [authUser, setAuthUser] = useState(undefined);
const [authServerDown, setAuthServerDown] = useState(false);
// Callbacks come from App and close over its latest state; read them through
// a ref so signIn/logout never call a stale copy.
const callbacksRef = useRef({ onSignIn, onSignOut });
callbacksRef.current = { onSignIn, onSignOut };
const signIn = (user) => {
setAuthUser(user);
callbacksRef.current.onSignIn(user);
};
const logout = async () => {
await logoutUser();
setAuthUser(null);
callbacksRef.current.onSignOut();
};
useEffect(() => {
getCurrentUser().then((result) => {
if (result.ok) {
if (result.user) signIn(result.user);
else setAuthUser(null);
} else {
// Genuine network failure (server unreachable) — fall back to local-only mode.
setAuthServerDown(true);
setAuthUser(null);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { authUser, setAuthUser, authServerDown, signIn, logout };
}
+571
View File
@@ -0,0 +1,571 @@
// State, data loading, and helpers for the read-only Bible reader page.
// Audio playback stays in App because the study page's book player shares
// the same <audio> element.
import { useCallback, useEffect, useRef, useState } from 'react';
import { bookOptions, CHAPTER_COUNTS, parseBibleChapter } from '../utils/bibleUtils.jsx';
import { useReaderMarksSync } from './useReaderMarksSync.js';
import { usePrefsSync } from './usePrefsSync.js';
import { COMMENTARIES } from './useVerseStudy.js';
import { READER_TRANSLATIONS, fetchChapter } from '../utils/bibleText.js';
import { mergeMarks, normalizeMarks } from '../utils/readerMarks.js';
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
export { READER_TRANSLATIONS };
// A per-device choice kept in localStorage: useStoredChoice('reader-font', 'serif', ['serif', 'sans']).
function useStoredChoice(key, fallback, allowed) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
return saved !== null && allowed.includes(saved) ? saved : fallback;
} catch { return fallback; }
});
const set = (next) => {
setValue(next);
try { localStorage.setItem(key, next); } catch {}
};
return [value, set];
}
export const READER_FONTS = ['serif', 'sans', 'easy'];
export const READER_SPACINGS = ['snug', 'normal', 'airy'];
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
// `translationOverride` lets a reading plan pin the Bible it is read in while it is
// open; otherwise the reader's own (remembered) translation is used.
export function useBibleReader(currentPage, translationOverride = null) {
// Where you left off (saved on this device, and synced to the account)
const [savedPosition] = useState(() => {
try {
const p = JSON.parse(localStorage.getItem('reader-position') || 'null');
return p && CHAPTER_COUNTS[p.book] && Number.isInteger(p.chapter) && p.chapter >= 1 && p.chapter <= CHAPTER_COUNTS[p.book] ? p : null;
} catch { return null; }
});
const [readerBookAbbrev, setReaderBookAbbrev] = useState(savedPosition?.book ?? bookOptions[0].abbrev);
const [readerChapter, setReaderChapter] = useState(savedPosition?.chapter ?? 1);
const [readerCommentary, setReaderCommentaryState] = useState(() => {
try {
const saved = localStorage.getItem('reader-commentary');
return COMMENTARIES.some((c) => c.id === saved) ? saved : COMMENTARIES[0].id;
} catch { return COMMENTARIES[0].id; }
});
useEffect(() => {
try { localStorage.setItem('reader-position', JSON.stringify({ book: readerBookAbbrev, chapter: readerChapter })); } catch {}
}, [readerBookAbbrev, readerChapter]);
const pageRef = useRef(currentPage);
pageRef.current = currentPage;
const [baseTranslation, setReaderTranslationState] = useState(() => {
try {
const saved = localStorage.getItem('reader-translation');
return READER_TRANSLATIONS.some((t) => t.id === saved) ? saved : 'BSB';
} catch { return 'BSB'; }
});
const readerTranslation = translationOverride ?? baseTranslation;
const [readerCopyright, setReaderCopyright] = useState('');
const [readerTheme, setReaderThemeState] = useState(() => {
try {
const saved = localStorage.getItem('reader-theme');
return ['paper', 'sepia', 'dark'].includes(saved) ? saved : 'paper';
} catch { return 'paper'; }
});
// 'flow' = verses run together like a printed page; 'verses' = one verse per line
const [readerLayout, setReaderLayoutState] = useState(() => {
try { return localStorage.getItem('reader-layout') === 'verses' ? 'verses' : 'flow'; } catch { return 'flow'; }
});
const [readerVerses, setReaderVerses] = useState([]);
const [readerTotalChapters, setReaderTotalChapters] = useState(1);
const [readerLoading, setReaderLoading] = useState(false);
const [readerError, setReaderError] = useState('');
const [readerInterlinear, setReaderInterlinear] = useState(null); // full book interlinear
const [readerInterlinearLoading, setReaderInterlinearLoading] = useState(false);
const [readerSelectedVerse, setReaderSelectedVerse] = useState(null); // verse number with open interlinear panel
const _readerInterlinearCacheRef = useRef({});
// Text size and reading width are per-device (a phone and a tablet want different ones)
const [readerFontSize, setReaderFontSizeState] = useState(() => {
try {
const n = Number(localStorage.getItem('reader-font-size'));
return [0.875, 1, 1.125, 1.25, 1.5].includes(n) ? n : 1;
} catch { return 1; }
}); // em multiplier: 0.875 | 1 | 1.125 | 1.25 | 1.5
const setReaderFontSize = (n) => {
setReaderFontSizeState(n);
try { localStorage.setItem('reader-font-size', String(n)); } catch {}
};
const [readerWide, setReaderWideState] = useState(() => {
try { return localStorage.getItem('reader-wide') === '1'; } catch { return false; }
});
const setReaderWide = (on) => {
setReaderWideState(!!on);
try { localStorage.setItem('reader-wide', on ? '1' : '0'); } catch {}
};
// Typography and a second Bible shown beside the first — all per-device, like text size.
const [readerFont, setReaderFont] = useStoredChoice('reader-font', 'serif', READER_FONTS);
const [readerSpacing, setReaderSpacing] = useStoredChoice('reader-spacing', 'normal', READER_SPACINGS);
const [readerVerseNumbers, setReaderVerseNumbersRaw] = useStoredChoice('reader-verse-numbers', 'on', ['on', 'off']);
const [readerShowDiff, setReaderShowDiffRaw] = useStoredChoice('reader-show-diff', 'on', ['on', 'off']);
const [readerParallel, setReaderParallel] = useStoredChoice('reader-parallel', '', ['', ...READER_TRANSLATIONS.map((t) => t.id)]);
// The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans'
const [startPagePref, setStartPagePrefState] = useState(() => {
try {
const saved = localStorage.getItem('start-page');
return ['studies', 'reader', 'plans'].includes(saved) ? saved : 'studies';
} catch { return 'studies'; }
});
const setStartPagePref = (id) => {
setStartPagePrefState(id);
try { localStorage.setItem('start-page', id); } catch {}
};
// Which Bible a newly started reading plan begins in: 'same' = whatever the default Bible is
const [planTranslationPref, setPlanTranslationPrefState] = useState(() => {
try {
const saved = localStorage.getItem('plan-translation');
return saved === 'same' || READER_TRANSLATIONS.some((t) => t.id === saved) ? saved : 'same';
} catch { return 'same'; }
});
const setPlanTranslationPref = (id) => {
setPlanTranslationPrefState(id);
try { localStorage.setItem('plan-translation', id); } catch {}
};
const [readerBookmarks, setReaderBookmarks] = useState(() => {
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
});
const [readerBookmarksPanelOpen, setReaderBookmarksPanelOpen] = useState(false);
const [readerJumpVerse, setReaderJumpVerse] = useState(null); // verse number to scroll to + flash after a jump
const [readerCrossRefs, setReaderCrossRefs] = useState(null);
const [readerCrossRefsLoading, setReaderCrossRefsLoading] = useState(false);
const [readerShowCrossRefs, setReaderShowCrossRefs] = useState(false);
const _readerCrossRefCacheRef = useRef({});
const [readerSearch, setReaderSearch] = useState('');
const [readerSearchActive, setReaderSearchActive] = useState(false);
const [readerSearchScope, setReaderSearchScope] = useState('chapter'); // 'chapter' | 'bible'
const _bibleIndexCacheRef = useRef({}); // translation -> flat verse array
const [readerInkByPage, setReaderInkByPage] = useState(() => {
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
catch { return {}; }
});
const [readerTextHighlights, setReaderTextHighlights] = useState(() => {
try {
const val = JSON.parse(localStorage.getItem('readerTextHighlights') || '[]');
return Array.isArray(val) ? val : [];
} catch { return []; }
});
// What each highlight colour means to this reader ({ '#fef08a': 'Promises' }); follows the account.
const [readerColorLabels, setReaderColorLabelsState] = useState(() => {
try {
const val = JSON.parse(localStorage.getItem('reader-color-labels') || '{}');
return val && typeof val === 'object' && !Array.isArray(val) ? val : {};
} catch { return {}; }
});
const setReaderColorLabel = (color, label) => {
setReaderColorLabelsState((prev) => {
const next = { ...prev };
if (label.trim()) next[color] = label.slice(0, 30); else delete next[color];
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
return next;
});
};
// Debounce timer ref so rapid stroke updates don't flood the server
const inkSaveTimerRef = useRef({});
// The HelloAO API has no search endpoint, so whole-Bible search for the free
// translations (BSB, NET) fetches the entire translation once (~7MB) and searches
// an in-memory flat verse index client-side. Cached per translation for the rest of
// the visit, and in IndexedDB for a while. Licensed translations use the server
// search proxy instead (see useBibleSearch).
const [bibleIndexState, setBibleIndexState] = useState({ translation: null, status: 'idle' });
const bibleIndexStatusFor = (translation) => {
if (_bibleIndexCacheRef.current[translation]) return 'ready';
return bibleIndexState.translation === translation ? bibleIndexState.status : 'idle';
};
const loadBibleIndex = async (translation = 'BSB') => {
const set = (status) => setBibleIndexState({ translation, status });
if (_bibleIndexCacheRef.current[translation]) { set('ready'); return; }
const source = READER_TRANSLATIONS.find((t) => t.id === translation);
if (!source || source.licensed) { set('error'); return; }
set('loading');
try {
// Check IndexedDB cache before hitting the network (~7 MB fetch)
const cached = await getBibleIndexFromIDB(translation);
if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
_bibleIndexCacheRef.current[translation] = cached.flat;
set('ready');
return;
}
const res = await fetch(`https://bible.helloao.org/api/${source.helloao ?? source.id}/complete.json`);
if (!res.ok) throw new Error('Unable to load the full Bible for search.');
const data = await res.json();
const flat = [];
for (const book of data.books ?? []) {
for (const ch of book.chapters ?? []) {
const verses = parseBibleChapter({ chapter: ch.chapter });
for (const v of verses) {
if (v.text) {
flat.push({ bookAbbrev: book.id, bookName: book.commonName || book.name, chapter: ch.chapter.number, verse: v.number, text: v.text });
}
}
}
}
_bibleIndexCacheRef.current[translation] = flat;
set('ready');
// Persist for future visits — non-blocking
saveBibleIndexToIDB(translation, flat);
} catch {
set('error');
}
};
const setReaderTheme = (id) => {
setReaderThemeState(id);
try { localStorage.setItem('reader-theme', id); } catch {}
};
const setReaderLayout = (id) => {
setReaderLayoutState(id);
try { localStorage.setItem('reader-layout', id); } catch {}
};
const setReaderCommentary = (id) => {
setReaderCommentaryState(id);
try { localStorage.setItem('reader-commentary', id); } catch {}
};
const setReaderTranslation = (id) => {
setReaderTranslationState(id);
try { localStorage.setItem('reader-translation', id); } catch {}
};
// BSB comes straight from HelloAO; licensed translations (NLT/CSB/NASB) go
// through the server's api.bible proxy so the API key never reaches the browser.
// The most recent request wins: flipping chapters quickly (or changing translation) must never let a
// slow earlier response replace the page you are now on.
const chapterRequestRef = useRef(0);
const loadReaderChapter = async (bookAbbrev, chapterNumber, translation = readerTranslation) => {
const requestId = ++chapterRequestRef.current;
setReaderLoading(true);
setReaderError('');
try {
const { verses, copyright, totalChapters } = await fetchChapter(translation, bookAbbrev, chapterNumber);
if (requestId !== chapterRequestRef.current) return;
setReaderVerses(verses);
setReaderCopyright(copyright);
setReaderTotalChapters(totalChapters);
} catch (err) {
if (requestId !== chapterRequestRef.current) return;
setReaderError(err.message || 'Failed to load chapter.');
setReaderVerses([]);
} finally {
if (requestId === chapterRequestRef.current) setReaderLoading(false);
}
};
useEffect(() => {
if (currentPage !== 'reader') return;
loadReaderChapter(readerBookAbbrev, readerChapter, readerTranslation);
}, [currentPage, readerBookAbbrev, readerChapter, readerTranslation]);
// After navigating to a verse (from the bookmarks panel or a whole-Bible search
// result), scroll to it and briefly flash its background so it's easy to spot.
useEffect(() => {
if (readerJumpVerse == null || readerLoading) return;
const el = document.getElementById(`reader-verse-${readerJumpVerse}`);
if (!el) return;
el.scrollIntoView({ behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' });
const prevTransition = el.style.transition;
const prevBackground = el.style.backgroundColor;
el.style.transition = 'background-color 0.4s';
el.style.backgroundColor = '#fef08a';
const t = window.setTimeout(() => {
el.style.backgroundColor = prevBackground;
window.setTimeout(() => { el.style.transition = prevTransition; }, 400);
}, 1200);
setReaderJumpVerse(null);
return () => window.clearTimeout(t);
}, [readerVerses, readerLoading, readerJumpVerse]);
// Navigates the reader to a specific verse (bookmarks panel, search results) and
// scrolls/flashes it once that chapter's verses have loaded.
const jumpToReaderVerse = (bookAbbrev, chapter, verseNumber) => {
setReaderBookAbbrev(bookAbbrev);
setReaderChapter(chapter);
setReaderSelectedVerse(null);
setReaderInterlinear(null);
setReaderCrossRefs(null);
setReaderSearch('');
setReaderSearchActive(false);
setReaderBookmarksPanelOpen(false);
setReaderJumpVerse(verseNumber);
};
const readerGoToPreviousChapter = () => {
if (readerChapter > 1) { setReaderChapter((c) => c - 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
};
const readerGoToNextChapter = () => {
if (readerChapter < readerTotalChapters) { setReaderChapter((c) => c + 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
};
const handleReaderBookChange = (abbrev) => {
setReaderBookAbbrev(abbrev);
setReaderChapter(1);
setReaderSelectedVerse(null);
setReaderInterlinear(null);
setReaderCrossRefs(null);
setReaderSearch('');
setReaderSearchActive(false);
};
const loadReaderInterlinear = async (bookAbbrev) => {
if (_readerInterlinearCacheRef.current[bookAbbrev]) {
setReaderInterlinear(_readerInterlinearCacheRef.current[bookAbbrev]);
return;
}
setReaderInterlinearLoading(true);
try {
const res = await fetch(`/interlinear/${bookAbbrev}.json`);
if (!res.ok) throw new Error('no interlinear');
const data = await res.json();
_readerInterlinearCacheRef.current[bookAbbrev] = data;
setReaderInterlinear(data);
} catch {
setReaderInterlinear(null);
} finally {
setReaderInterlinearLoading(false);
}
};
useEffect(() => {
if (currentPage !== 'reader') return;
loadReaderInterlinear(readerBookAbbrev);
setReaderSelectedVerse(null);
}, [currentPage, readerBookAbbrev]);
useEffect(() => {
if (currentPage !== 'reader' || !readerShowCrossRefs) return;
loadReaderCrossRefs(readerBookAbbrev, readerChapter);
}, [currentPage, readerBookAbbrev, readerChapter, readerShowCrossRefs]);
// Bookmarks, ink, highlights, cross-refs
const toggleReaderBookmark = (verseKey) => {
setReaderBookmarks((prev) => {
const next = { ...prev };
if (next[verseKey]) {
delete next[verseKey];
} else {
const usedColors = Object.values(next);
const color = BOOKMARK_COLORS.find((c) => !usedColors.includes(c)) ?? BOOKMARK_COLORS[0];
next[verseKey] = color;
}
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
return next;
});
};
// Puts a removed bookmark back with its colour (used by "Undo").
const restoreReaderBookmark = (verseKey, color) => {
setReaderBookmarks((prev) => {
if (prev[verseKey]) return prev;
const next = { ...prev, [verseKey]: color };
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
return next;
});
};
const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
const key = `${bookAbbrev}_${chapter}`;
setReaderInkByPage((prev) => {
const next = { ...prev, [key]: strokes };
try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {}
return next;
});
// Debounce server sync — wait 1 s after last stroke before sending
clearTimeout(inkSaveTimerRef.current[key]);
inkSaveTimerRef.current[key] = setTimeout(() => {
fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ strokes }),
}).catch(() => {}); // silent — localStorage already saved locally
}, 1000);
}, []);
const cycleBookmarkColor = (verseKey) => {
setReaderBookmarks((prev) => {
const cur = prev[verseKey];
if (!cur) return prev;
const idx = BOOKMARK_COLORS.indexOf(cur);
const next = { ...prev, [verseKey]: BOOKMARK_COLORS[(idx + 1) % BOOKMARK_COLORS.length] };
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
return next;
});
};
const addTextHighlight = (highlight) => {
const id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
setReaderTextHighlights(prev => {
const next = [...prev, { id, ...highlight }];
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
return next;
});
};
// Puts a removed highlight back exactly as it was (used by "Undo").
const restoreTextHighlight = (highlight) => {
setReaderTextHighlights((prev) => {
if (prev.some((h) => h.id === highlight.id)) return prev;
const next = [...prev, highlight];
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
return next;
});
};
const removeTextHighlight = (id) => {
setReaderTextHighlights(prev => {
const next = prev.filter(h => h.id !== id);
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
return next;
});
};
const loadReaderCrossRefs = async (bookAbbrev, chapterNumber) => {
const cacheKey = `${bookAbbrev}/${chapterNumber}`;
if (_readerCrossRefCacheRef.current[cacheKey]) {
setReaderCrossRefs(_readerCrossRefCacheRef.current[cacheKey]);
return;
}
setReaderCrossRefsLoading(true);
try {
const res = await fetch(`https://bible.helloao.org/api/d/open-cross-ref/${bookAbbrev}/${chapterNumber}.json`);
if (!res.ok) throw new Error('no cross-refs');
const data = await res.json();
const byVerse = {};
for (const item of data.chapter?.content ?? []) {
if (item.references?.length) byVerse[item.verse] = item.references;
}
_readerCrossRefCacheRef.current[cacheKey] = byVerse;
setReaderCrossRefs(byVerse);
} catch {
setReaderCrossRefs(null);
} finally {
setReaderCrossRefsLoading(false);
}
};
// Bookmarks, highlights, colour names and ink from a backup file are added to what is here (nothing is
// removed, and where both have something the copy already here wins).
const importReaderMarks = ({ bookmarks, highlights, colorLabels, ink }) => {
const merged = mergeMarks(normalizeMarks({ bookmarks, highlights }), normalizeMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights }));
setReaderBookmarks(merged.bookmarks);
setReaderTextHighlights(merged.highlights);
try {
localStorage.setItem('reader-bookmarks', JSON.stringify(merged.bookmarks));
localStorage.setItem('readerTextHighlights', JSON.stringify(merged.highlights));
} catch {}
if (colorLabels && Object.keys(colorLabels).length > 0) {
setReaderColorLabelsState((prev) => {
const next = { ...colorLabels, ...prev };
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
return next;
});
}
// Ink goes through the normal save so it also reaches the account
for (const [key, strokes] of Object.entries(ink ?? {})) {
const [book, chapter] = key.split('_');
if (!readerInkByPage[key] && book && chapter && Array.isArray(strokes)) updateReaderPageInk(book, chapter, strokes);
}
};
// Merges reader ink loaded from the server at sign-in; server wins over local.
const mergeServerInk = useCallback((serverInk) => {
setReaderInkByPage((local) => {
const merged = { ...local, ...serverInk };
try { localStorage.setItem('readerInkByPage', JSON.stringify(merged)); } catch {}
return merged;
});
}, []);
// Bookmarks and highlights follow the signed-in user across devices.
const marksSync = useReaderMarksSync({
bookmarks: readerBookmarks, highlights: readerTextHighlights,
setBookmarks: setReaderBookmarks, setHighlights: setReaderTextHighlights,
});
// Theme, default translation, layout, commentary and reading position follow the account too.
const prefsSync = usePrefsSync({
values: {
theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary,
planTranslation: planTranslationPref, startPage: startPagePref,
position: { book: readerBookAbbrev, chapter: readerChapter },
colorLabels: readerColorLabels,
},
apply: (key, v) => {
if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} }
else if (key === 'translation') { setReaderTranslationState(v); try { localStorage.setItem('reader-translation', v); } catch {} }
else if (key === 'layout') { setReaderLayoutState(v); try { localStorage.setItem('reader-layout', v); } catch {} }
else if (key === 'startPage') { setStartPagePrefState(v); try { localStorage.setItem('start-page', v); } catch {} }
else if (key === 'planTranslation') { setPlanTranslationPrefState(v); try { localStorage.setItem('plan-translation', v); } catch {} }
else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} }
else if (key === 'colorLabels') { setReaderColorLabelsState(v); try { localStorage.setItem('reader-color-labels', JSON.stringify(v)); } catch {} }
else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); }
},
canApplyPosition: () => pageRef.current !== 'reader',
});
return {
readerBookAbbrev, setReaderBookAbbrev,
readerCommentary, setReaderCommentary,
readerChapter, setReaderChapter,
readerTranslation, setReaderTranslation,
readerCopyright,
readerTheme, setReaderTheme,
readerLayout, setReaderLayout,
readerVerses,
readerTotalChapters,
readerLoading,
readerError,
readerInterlinear,
readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize,
readerWide, setReaderWide,
readerFont, setReaderFont,
readerSpacing, setReaderSpacing,
readerVerseNumbers: readerVerseNumbers === 'on', setReaderVerseNumbers: (on) => setReaderVerseNumbersRaw(on ? 'on' : 'off'),
readerParallel, setReaderParallel,
readerShowDiff: readerShowDiff === 'on', setReaderShowDiff: (on) => setReaderShowDiffRaw(on ? 'on' : 'off'),
planTranslationPref, setPlanTranslationPref,
startPagePref, setStartPagePref,
readerBookmarks, setReaderBookmarks,
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading,
readerShowCrossRefs, setReaderShowCrossRefs,
readerSearch, setReaderSearch,
readerSearchActive, setReaderSearchActive,
readerSearchScope, setReaderSearchScope,
readerInkByPage,
updateReaderPageInk,
mergeServerInk,
importReaderMarks,
readerTextHighlights,
...marksSync,
...prefsSync,
addTextHighlight,
removeTextHighlight,
restoreTextHighlight,
readerColorLabels, setReaderColorLabel,
bibleIndexStatusFor,
loadBibleIndex,
_bibleIndexCacheRef,
loadReaderChapter,
handleReaderBookChange,
readerGoToPreviousChapter,
readerGoToNextChapter,
jumpToReaderVerse,
toggleReaderBookmark,
restoreReaderBookmark,
cycleBookmarkColor,
loadReaderCrossRefs,
};
}
+49
View File
@@ -0,0 +1,49 @@
// Whole-Bible search for translations that can't be downloaded and indexed in the
// browser (ESV, NLT, CSB, NASB): queries go through the server's search proxy.
// Debounced, cancels stale requests, and pages in 25 verses at a time.
import { useCallback, useEffect, useRef, useState } from 'react';
const DEBOUNCE_MS = 450;
const EMPTY = { results: [], total: 0, page: 0, hasMore: false, loading: false, error: '' };
export function useBibleSearch(translation, query, enabled) {
const [state, setState] = useState(EMPTY);
const controllerRef = useRef(null);
const q = query.trim();
const run = useCallback(async (page) => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setState((s) => ({ ...s, loading: true, error: '' }));
try {
const res = await fetch(`/api/bible/${translation}/search?q=${encodeURIComponent(q)}&page=${page}`, {
credentials: 'include', signal: controller.signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Search failed.');
setState((s) => ({
results: page === 1 ? data.results : [...s.results, ...data.results],
total: data.total, page: data.page, hasMore: data.hasMore, loading: false, error: '',
}));
} catch (err) {
if (err.name === 'AbortError') return;
setState((s) => ({ ...s, loading: false, error: err.message || 'Search failed.' }));
}
}, [translation, q]);
useEffect(() => {
if (!enabled || q.length < 2) {
controllerRef.current?.abort();
setState(EMPTY);
return undefined;
}
const timer = window.setTimeout(() => run(1), DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [enabled, q, run]);
useEffect(() => () => controllerRef.current?.abort(), []);
const loadMore = () => { if (!state.loading && state.hasMore) run(state.page + 1); };
return { ...state, loadMore, tooShort: q.length < 2 };
}
+691
View File
@@ -0,0 +1,691 @@
// The Setup page's chunk builder: the new-project / add-chapter form, the
// active chapter tab, and every way of creating chunks (click, shift-click,
// typed range, bulk ranges, cross-chapter spans), plus reorder and delete.
import { useEffect, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
// Every chunk starts blank; all note fields exist from the start.
export function blankChunk(start, end, spilloverEndVerse = null) {
return {
id: makeId(),
startVerse: start,
endVerse: end,
spilloverEndVerse,
observation: '',
interpretation: '',
application: '',
crossReferences: [],
greekWords: [],
generalNotes: '',
episodeNumber: '',
episodeTitle: '',
finalScript: '',
tags: [],
};
}
const joinText = (a, b) => [a, b].filter((s) => (s ?? '').trim()).join('\n\n');
// Merges chunk `b` (the one after `a`) into `a`, keeping a's id and combining notes,
// words, cross-references and tags so joining never loses anything.
export function mergeChunks(a, b) {
const uniq = (list) => [...new Set(list)];
return {
...a,
endVerse: b.endVerse,
spilloverEndVerse: b.spilloverEndVerse ?? null,
observation: joinText(a.observation, b.observation),
interpretation: joinText(a.interpretation, b.interpretation),
application: joinText(a.application, b.application),
generalNotes: joinText(a.generalNotes, b.generalNotes),
finalScript: joinText(a.finalScript, b.finalScript),
crossReferences: uniq([...(a.crossReferences ?? []), ...(b.crossReferences ?? [])]),
greekWords: [...(a.greekWords ?? []), ...(b.greekWords ?? [])],
tags: uniq([...(a.tags ?? []), ...(b.tags ?? [])]),
episodeNumber: a.episodeNumber || b.episodeNumber || '',
episodeTitle: a.episodeTitle || b.episodeTitle || '',
};
}
// Verse numbers where a section heading starts, from a HelloAO chapter payload.
export function headingStarts(chapterData) {
const starts = [];
let pending = false;
for (const item of chapterData?.chapter?.content ?? []) {
if (item.type === 'heading') pending = true;
else if (item.type === 'verse') {
if (pending) starts.push(item.number);
pending = false;
}
}
return starts;
}
// Uncovered runs of verses inside [from, to], given the chunks already there.
export function uncoveredRuns(chunks, from, to) {
const covered = new Set();
chunks.forEach((c) => { for (let v = c.startVerse; v <= c.endVerse; v += 1) covered.add(v); });
const runs = [];
let start = null;
for (let v = from; v <= to + 1; v += 1) {
const free = v <= to && !covered.has(v);
if (free && start === null) start = v;
if (!free && start !== null) { runs.push([start, v - 1]); start = null; }
}
return runs;
}
// A new project starts on Titus 1. When adding to an open project, start on
// the chapter after its last one (same book and translation).
function initialSetupForm(project) {
const last = project?.chapters?.at(-1);
if (!last) {
return { translation: 'BSB', book: 'Titus', bookAbbrev: 'TIT', chapter: '1', title: 'Titus 1 Study' };
}
const next = Math.min(Number(last.chapter) + 1, CHAPTER_COUNTS[last.bookAbbrev] ?? Number(last.chapter));
return {
translation: project.translation ?? 'BSB',
book: last.book,
bookAbbrev: last.bookAbbrev,
chapter: String(next),
title: project.title,
};
}
// Open on the chapter holding the chunk last studied (e.g. after "Back to Chunks").
function initialChapterIndex(project) {
const index = project?.chapters?.findIndex((ch) => ch.chunks.some((c) => c.id === project.selectedChunkId)) ?? -1;
return index >= 0 ? index : 0;
}
export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) {
const { toast } = useToast();
const [setup, setSetup] = useState(() => initialSetupForm(project));
const [titleEdited, setTitleEdited] = useState(false);
const [activeChapterIndex, setActiveChapterIndex] = useState(() => initialChapterIndex(project));
const [showAddChapterForm, setShowAddChapterForm] = useState(false);
const [rangeStart, setRangeStart] = useState(null);
const [rangeEnd, setRangeEnd] = useState(null);
const [loadingChapter, setLoadingChapter] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [typedChunkStart, setTypedChunkStart] = useState('');
const [typedChunkEnd, setTypedChunkEnd] = useState('');
const [typedChunkNextEnd, setTypedChunkNextEnd] = useState('');
const [clickedSpanNextEnd, setClickedSpanNextEnd] = useState('');
const [typedChunkBulk, setTypedChunkBulk] = useState('');
const [verseSearch, setVerseSearch] = useState('');
useEffect(() => {
if (!titleEdited) {
setSetup((current) => ({
...current,
title: `${current.book} ${current.chapter} Study`,
}));
}
}, [setup.book, setup.chapter, titleEdited]);
// Each time the add-chapter form opens, suggest the chapter after the last one.
useEffect(() => {
if (showAddChapterForm) setSetup(initialSetupForm(project));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showAddChapterForm]);
const activeChapter = project?.chapters[activeChapterIndex] ?? null;
const handleSetupField = (field, value) => {
if (field === 'book') {
const selected = bookOptions.find((book) => book.abbrev === value) ?? bookOptions[0];
setSetup((current) => ({
...current,
book: selected.name,
bookAbbrev: selected.abbrev,
}));
} else {
setSetup((current) => ({ ...current, [field]: value }));
}
};
const handleLoadChapter = async () => {
if (!setup.bookAbbrev || !setup.chapter) {
setErrorMessage('Please choose a book and chapter.');
return;
}
setErrorMessage('');
setLoadingChapter(true);
try {
const response = await fetch(
`https://bible.helloao.org/api/${setup.translation}/${setup.bookAbbrev}/${setup.chapter}.json`,
);
if (!response.ok) throw new Error('Unable to load chapter.');
const data = await response.json();
const verses = parseBibleChapter(data);
if (!data || !Array.isArray(verses) || verses.length === 0) {
throw new Error('Invalid Bible data returned.');
}
if (project) {
// Adding a chapter to an existing project
const alreadyExists = project.chapters.some(
(ch) => ch.bookAbbrev === setup.bookAbbrev && ch.chapter === setup.chapter,
);
if (alreadyExists) {
setErrorMessage('That chapter is already in this project.');
setLoadingChapter(false);
return;
}
const newChapter = {
book: setup.book,
bookAbbrev: setup.bookAbbrev,
chapter: setup.chapter,
verses,
chunks: [],
};
updateProject((current) => {
const updatedChapters = [...current.chapters, newChapter];
return { ...current, chapters: updatedChapters };
});
setActiveChapterIndex(project.chapters.length);
setShowAddChapterForm(false);
setErrorMessage('');
} else {
// Creating a brand-new project
const newProject = {
id: makeId(),
title: setup.title,
translation: setup.translation,
lastEdited: Date.now(),
selectedChunkId: null,
chapters: [
{
book: setup.book,
bookAbbrev: setup.bookAbbrev,
chapter: setup.chapter,
verses,
chunks: [],
},
],
};
setProject(newProject);
setActiveChapterIndex(0);
setCurrentPage('setup');
}
} catch (error) {
setErrorMessage(error.message || 'Failed to load chapter.');
} finally {
setLoadingChapter(false);
}
};
const beginStudying = () => {
if (!project || allChunks.length === 0) return;
updateProject((current) => ({
...current,
selectedChunkId: current.selectedChunkId || allChunks[0]?.id || null,
}));
setCurrentPage('study');
};
const addChunkToChapter = (chapterIndex, start, end, spilloverEndVerse = null) => {
updateProject((current) => {
const chapters = current.chapters.map((ch, idx) => {
if (idx !== chapterIndex) return ch;
const overlap = ch.chunks.find(
(c) => c.startVerse === start
&& c.endVerse === end
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
);
if (overlap) return ch;
const newChunk = {
id: makeId(),
startVerse: start,
endVerse: end,
spilloverEndVerse,
observation: '',
interpretation: '',
application: '',
crossReferences: [],
greekWords: [],
generalNotes: '',
episodeNumber: '',
episodeTitle: '',
finalScript: '',
tags: [],
};
return { ...ch, chunks: [...ch.chunks, newChunk] };
});
// Find the newly added chunk id
const newChunk = chapters[chapterIndex].chunks.at(-1);
return { ...current, chapters, selectedChunkId: newChunk?.id ?? current.selectedChunkId };
});
};
const addChunk = (start, end, spilloverEndVerse = null) => {
if (!project) return;
const chapter = activeChapter;
if (!chapter) return;
const overlap = chapter.chunks.find(
(c) => c.startVerse === start
&& c.endVerse === end
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
);
if (overlap) {
setStatusMessage('That chunk already exists.');
window.setTimeout(() => setStatusMessage(''), 1800);
return;
}
addChunkToChapter(activeChapterIndex, start, end, spilloverEndVerse);
};
const addChunkRanges = (ranges) => {
if (!project || !activeChapter || ranges.length === 0) {
return { addedCount: 0, skippedCount: 0 };
}
let addedCount = 0;
let skippedCount = 0;
let lastAddedChunkId = null;
updateProject((current) => {
const chapters = current.chapters.map((ch, idx) => {
if (idx !== activeChapterIndex) return ch;
const existing = new Set(
ch.chunks.map((c) => `${c.startVerse}-${c.endVerse}-${c.spilloverEndVerse ?? ''}`),
);
const newChunks = [];
ranges.forEach(({ start, end, spilloverEndVerse = null }) => {
const key = `${start}-${end}-${spilloverEndVerse ?? ''}`;
if (existing.has(key)) {
skippedCount += 1;
return;
}
const newChunk = blankChunk(start, end, spilloverEndVerse);
existing.add(key);
newChunks.push(newChunk);
addedCount += 1;
lastAddedChunkId = newChunk.id;
});
return newChunks.length > 0
? { ...ch, chunks: [...ch.chunks, ...newChunks] }
: ch;
});
return {
...current,
chapters,
selectedChunkId: lastAddedChunkId ?? current.selectedChunkId,
};
});
return { addedCount, skippedCount };
};
const addTypedChunk = () => {
if (!activeChapter) return;
const start = Number.parseInt(typedChunkStart, 10);
const end = Number.parseInt(typedChunkEnd, 10);
const nextEndRaw = typedChunkNextEnd.trim();
const nextEnd = nextEndRaw ? Number.parseInt(nextEndRaw, 10) : null;
const maxVerse = activeChapter.verses.at(-1)?.number ?? 0;
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0;
if (!Number.isInteger(start) || !Number.isInteger(end)) {
setStatusMessage('Type a valid start and end verse.');
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
if (start < 1 || end < 1 || start > maxVerse || end > maxVerse) {
setStatusMessage(`Verse range must be between 1 and ${maxVerse}.`);
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
if (start > end) {
setStatusMessage('Start verse must be less than or equal to end verse.');
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
if (nextEndRaw) {
if (!nextChapter) {
setStatusMessage('Add the next chapter first to span chunks across chapters.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) {
setStatusMessage('Chapter spanning only works into the next chapter of the same book.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
if (!Number.isInteger(nextEnd) || nextEnd < 1 || nextEnd > nextMaxVerse) {
setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`);
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
if (end !== maxVerse) {
setStatusMessage('To span chapters, set End to the last verse of this chapter.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
}
const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse: nextEnd }]);
if (addedCount === 0) {
setStatusMessage('That chunk already exists.');
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
setTypedChunkStart('');
setTypedChunkEnd('');
setTypedChunkNextEnd('');
};
const addBulkTypedChunks = () => {
if (!activeChapter) return;
const raw = typedChunkBulk.trim();
if (!raw) {
setStatusMessage('Type chunk ranges first. Example: 1-6, 7-12');
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
const maxVerse = activeChapter.verses.at(-1)?.number ?? 0;
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0;
const parts = raw
.split(/[\n,;]+/)
.map((part) => part.trim())
.filter(Boolean);
const validRanges = [];
let invalidCount = 0;
parts.forEach((part) => {
const single = /^(\d+)$/.exec(part);
if (single) {
const v = Number.parseInt(single[1], 10);
if (v >= 1 && v <= maxVerse) {
validRanges.push({ start: v, end: v });
} else {
invalidCount += 1;
}
return;
}
const range = /^(\d+)\s*-\s*(\d+)(?:\s*:\s*(\d+))?$/.exec(part);
if (!range) {
invalidCount += 1;
return;
}
const start = Number.parseInt(range[1], 10);
const end = Number.parseInt(range[2], 10);
const spilloverEndVerse = range[3] ? Number.parseInt(range[3], 10) : null;
if (start < 1 || end < 1 || start > end || start > maxVerse || end > maxVerse) {
invalidCount += 1;
return;
}
if (spilloverEndVerse !== null) {
if (!nextChapter
|| nextChapter.bookAbbrev !== activeChapter.bookAbbrev
|| spilloverEndVerse < 1
|| spilloverEndVerse > nextMaxVerse
|| end !== maxVerse) {
invalidCount += 1;
return;
}
}
validRanges.push({ start, end, spilloverEndVerse });
});
if (validRanges.length === 0) {
setStatusMessage(`No valid ranges found. Use 1-${maxVerse} and format like 1-6, 7-12.`);
window.setTimeout(() => setStatusMessage(''), 2600);
return;
}
const { addedCount, skippedCount } = addChunkRanges(validRanges);
const notes = [];
if (addedCount > 0) notes.push(`Added ${addedCount} chunk${addedCount > 1 ? 's' : ''}`);
if (skippedCount > 0) notes.push(`skipped ${skippedCount} duplicate${skippedCount > 1 ? 's' : ''}`);
if (invalidCount > 0) notes.push(`ignored ${invalidCount} invalid`);
setStatusMessage(notes.join(' • '));
window.setTimeout(() => setStatusMessage(''), 2800);
if (addedCount > 0) {
setTypedChunkBulk('');
}
};
const addClickSpanChunk = () => {
if (!activeChapter || rangeStart === null) {
setStatusMessage('Click a start verse first, then set next chapter end.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
if (!nextChapter) {
setStatusMessage('Add the next chapter first to span into it.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) {
setStatusMessage('Click-based spanning only works into the next chapter of the same book.');
window.setTimeout(() => setStatusMessage(''), 2600);
return;
}
const nextMaxVerse = nextChapter.verses.at(-1)?.number ?? 0;
const spilloverEndVerse = Number.parseInt(clickedSpanNextEnd, 10);
if (!Number.isInteger(spilloverEndVerse) || spilloverEndVerse < 1 || spilloverEndVerse > nextMaxVerse) {
setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`);
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
const start = rangeStart;
const end = activeChapter.verses.at(-1)?.number ?? start;
const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse }]);
if (addedCount === 0) {
setStatusMessage('That cross-chapter chunk already exists.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
setClickedSpanNextEnd('');
setRangeStart(null);
setRangeEnd(null);
setStatusMessage('Cross-chapter chunk added.');
window.setTimeout(() => setStatusMessage(''), 1800);
};
const handleVerseClick = (verseNumber, event) => {
if (event.shiftKey && rangeStart !== null) {
const start = Math.min(rangeStart, verseNumber);
const end = Math.max(rangeStart, verseNumber);
addChunk(start, end);
setRangeStart(null);
setRangeEnd(null);
return;
}
setRangeStart(verseNumber);
setRangeEnd(verseNumber);
};
const moveChunk = (chunkId, direction) => {
updateProject((current) => ({
...current,
chapters: current.chapters.map((ch) => {
const index = ch.chunks.findIndex((c) => c.id === chunkId);
if (index < 0) return ch;
const nextIndex = index + direction;
if (nextIndex < 0 || nextIndex >= ch.chunks.length) return ch;
const updated = [...ch.chunks];
const [removed] = updated.splice(index, 1);
updated.splice(nextIndex, 0, removed);
return { ...ch, chunks: updated };
}),
}));
};
// Deleting a chunk also deletes its notes, so it can be taken back for a few seconds.
const deleteChunk = (chunkId) => {
let removed = null;
(project?.chapters ?? []).forEach((ch, chapterIndex) => {
const index = ch.chunks.findIndex((c) => c.id === chunkId);
if (index >= 0) removed = { chunk: ch.chunks[index], chapterIndex, index, wasSelected: project.selectedChunkId === chunkId };
});
if (removed) {
toast('Chunk deleted', {
action: {
label: 'Undo',
onClick: () => updateProject((current) => {
if (current.chapters.some((ch) => ch.chunks.some((c) => c.id === chunkId))) return current;
return {
...current,
selectedChunkId: removed.wasSelected ? chunkId : current.selectedChunkId,
chapters: current.chapters.map((ch, i) => {
if (i !== removed.chapterIndex) return ch;
const chunks = [...ch.chunks];
chunks.splice(Math.min(removed.index, chunks.length), 0, removed.chunk);
return { ...ch, chunks };
}),
};
}),
},
});
}
updateProject((current) => {
const newAllChunks = current.chapters
.flatMap((ch) => ch.chunks)
.filter((c) => c.id !== chunkId);
const nextSelected =
current.selectedChunkId === chunkId
? newAllChunks[0]?.id ?? null
: current.selectedChunkId;
return {
...current,
selectedChunkId: nextSelected,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.filter((c) => c.id !== chunkId),
})),
};
});
};
// Splits the chunk containing verse `n` into [start..n] and [n+1..end] (the first keeps
// its notes). If `n` isn't in any chunk yet, makes one from the last covered verse to `n`.
const splitAfterVerse = (n) => {
if (!activeChapter) return;
updateProject((current) => {
let selected = current.selectedChunkId;
const chapters = current.chapters.map((ch, idx) => {
if (idx !== activeChapterIndex) return ch;
const chunks = [...ch.chunks];
const i = chunks.findIndex((c) => c.startVerse <= n && n < c.endVerse && !c.spilloverEndVerse);
if (i >= 0) {
const c = chunks[i];
chunks[i] = { ...c, endVerse: n, spilloverEndVerse: null };
chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null));
return { ...ch, chunks };
}
if (chunks.some((c) => c.startVerse <= n && n <= c.endVerse)) return ch; // already a boundary
const start = Math.max(0, ...chunks.filter((c) => c.endVerse < n).map((c) => c.endVerse)) + 1;
const created = blankChunk(start, n);
selected = created.id;
const at = chunks.findIndex((c) => c.startVerse > n);
chunks.splice(at < 0 ? chunks.length : at, 0, created);
return { ...ch, chunks };
});
return { ...current, chapters, selectedChunkId: selected };
});
};
// Joins a chunk with the one right after it (adjacent verses), combining their notes.
const joinWithNext = (chunkId) => {
updateProject((current) => {
let selected = current.selectedChunkId;
const chapters = current.chapters.map((ch) => {
const i = ch.chunks.findIndex((c) => c.id === chunkId);
if (i < 0 || i >= ch.chunks.length - 1) return ch;
const a = ch.chunks[i];
const b = ch.chunks[i + 1];
if (a.endVerse + 1 !== b.startVerse) return ch;
if (selected === b.id) selected = a.id;
const chunks = [...ch.chunks];
chunks.splice(i, 2, mergeChunks(a, b));
return { ...ch, chunks };
});
return { ...current, chapters, selectedChunkId: selected };
});
};
// Fills verses that aren't in a chunk yet: one chunk per section heading (or every 8
// verses when the text has no headings). Chunks you already made are left alone.
const [suggesting, setSuggesting] = useState(false);
const suggestChunks = async () => {
if (!activeChapter) return;
const notify = (message, ms = 2400) => { setStatusMessage(message); window.setTimeout(() => setStatusMessage(''), ms); };
const last = activeChapter.verses.at(-1)?.number ?? 0;
setSuggesting(true);
try {
let starts = [];
try {
const res = await fetch(`https://bible.helloao.org/api/${project?.translation ?? 'BSB'}/${activeChapter.bookAbbrev}/${activeChapter.chapter}.json`);
if (res.ok) starts = headingStarts(await res.json());
} catch { /* headings are a nicety — fall back to even chunks */ }
const usedHeadings = starts.length > 0;
const first = activeChapter.verses[0]?.number ?? 1;
if (!usedHeadings) for (let v = first; v <= last; v += 8) starts.push(v);
const bounds = [...new Set([first, ...starts])].sort((a, b) => a - b);
const ranges = [];
bounds.forEach((from, k) => {
const to = (bounds[k + 1] ?? last + 1) - 1;
uncoveredRuns(activeChapter.chunks, from, to).forEach(([s, e]) => ranges.push({ start: s, end: e }));
});
if (ranges.length === 0) { notify('Every verse is already in a chunk.'); return; }
const { addedCount } = addChunkRanges(ranges);
notify(`Added ${addedCount} chunk${addedCount === 1 ? '' : 's'} ${usedHeadings ? 'from section headings' : 'of about 8 verses'}.`);
} finally {
setSuggesting(false);
}
};
return {
setup,
titleEdited, setTitleEdited,
activeChapterIndex, setActiveChapterIndex,
activeChapter,
showAddChapterForm, setShowAddChapterForm,
rangeStart, setRangeStart,
rangeEnd, setRangeEnd,
verseSearch, setVerseSearch,
typedChunkStart, setTypedChunkStart,
typedChunkEnd, setTypedChunkEnd,
typedChunkNextEnd, setTypedChunkNextEnd,
typedChunkBulk, setTypedChunkBulk,
clickedSpanNextEnd, setClickedSpanNextEnd,
loadingChapter,
errorMessage,
statusMessage,
handleSetupField,
handleLoadChapter,
beginStudying,
handleVerseClick,
addTypedChunk,
addBulkTypedChunks,
addClickSpanChunk,
moveChunk,
deleteChunk,
splitAfterVerse,
joinWithNext,
suggestChunks,
suggesting,
};
}
+109
View File
@@ -0,0 +1,109 @@
import { renderHook, act } from '@testing-library/react';
import { useState } from 'react';
import { vi, test, expect, beforeEach } from 'vitest';
import { blankChunk, headingStarts, mergeChunks, uncoveredRuns, useChunkBuilder } from './useChunkBuilder.js';
const c = (s, e) => ({ startVerse: s, endVerse: e });
test('uncoveredRuns lists the verse runs not yet in any chunk', () => {
expect(uncoveredRuns([c(1, 3), c(6, 7)], 1, 10)).toEqual([[4, 5], [8, 10]]);
expect(uncoveredRuns([], 5, 8)).toEqual([[5, 8]]);
expect(uncoveredRuns([c(1, 10)], 1, 10)).toEqual([]);
});
test('headingStarts finds the verse each section heading precedes', () => {
const data = { chapter: { content: [
{ type: 'heading' }, { type: 'verse', number: 1 }, { type: 'verse', number: 2 },
{ type: 'heading' }, { type: 'line_break' }, { type: 'verse', number: 22 },
] } };
expect(headingStarts(data)).toEqual([1, 22]);
});
test('mergeChunks keeps the first id and combines notes, words, refs and tags', () => {
const a = { ...blankChunk(1, 3), observation: 'A', tags: ['x'], crossReferences: ['John 1:1'], greekWords: [{ id: 'w1' }] };
const b = { ...blankChunk(4, 6), observation: 'B', tags: ['x', 'y'], crossReferences: ['John 1:1', 'Rom 3:23'], greekWords: [{ id: 'w2' }], episodeTitle: 'Ep' };
const m = mergeChunks(a, b);
expect([m.id, m.startVerse, m.endVerse]).toEqual([a.id, 1, 6]);
expect(m.observation).toBe('A\n\nB');
expect(m.tags).toEqual(['x', 'y']);
expect(m.crossReferences).toEqual(['John 1:1', 'Rom 3:23']);
expect(m.greekWords.map((w) => w.id)).toEqual(['w1', 'w2']);
expect(m.episodeTitle).toBe('Ep');
});
// --- hook operations against a small in-memory project -----------------------
const verses = Array.from({ length: 12 }, (_, i) => ({ number: i + 1, text: `verse ${i + 1}` }));
const makeProject = (chunks = []) => ({
id: 'p', title: 'T', translation: 'BSB', selectedChunkId: null,
chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses, chunks }],
});
function setup(initialChunks) {
return renderHook(() => {
const [project, setProject] = useState(makeProject(initialChunks));
const updateProject = (fn) => setProject((cur) => fn(cur));
const allChunks = project.chapters.flatMap((ch) => ch.chunks);
const builder = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage: () => {} });
return { project, builder };
});
}
const ranges = (h) => h.result.current.project.chapters[0].chunks.map((k) => [k.startVerse, k.endVerse]);
beforeEach(() => { vi.restoreAllMocks(); });
test('splitting between uncovered verses builds chunks one after another', () => {
const h = setup();
act(() => h.result.current.builder.splitAfterVerse(4));
act(() => h.result.current.builder.splitAfterVerse(9));
expect(ranges(h)).toEqual([[1, 4], [5, 9]]);
});
test('splitting inside a chunk keeps the notes on the first part', () => {
const original = { ...blankChunk(1, 8), observation: 'keep me' };
const h = setup([original]);
act(() => h.result.current.builder.splitAfterVerse(3));
const [first, second] = h.result.current.project.chapters[0].chunks;
expect([first.id, first.startVerse, first.endVerse, first.observation]).toEqual([original.id, 1, 3, 'keep me']);
expect([second.startVerse, second.endVerse, second.observation]).toEqual([4, 8, '']);
});
test('splitting where a boundary already exists changes nothing', () => {
const h = setup([blankChunk(1, 4), blankChunk(5, 8)]);
act(() => h.result.current.builder.splitAfterVerse(4));
expect(ranges(h)).toEqual([[1, 4], [5, 8]]);
});
test('joining merges a chunk with the next one and re-selects the survivor', () => {
const a = { ...blankChunk(1, 4), observation: 'one' };
const b = { ...blankChunk(5, 8), observation: 'two' };
const h = setup([a, b]);
act(() => h.result.current.builder.joinWithNext(a.id));
const [only] = h.result.current.project.chapters[0].chunks;
expect([only.id, only.startVerse, only.endVerse, only.observation]).toEqual([a.id, 1, 8, 'one\n\ntwo']);
});
test('joining is refused when the chunks are not adjacent', () => {
const a = blankChunk(1, 3);
const h = setup([a, blankChunk(6, 8)]);
act(() => h.result.current.builder.joinWithNext(a.id));
expect(ranges(h)).toEqual([[1, 3], [6, 8]]);
});
test('suggest from headings fills only the uncovered verses, one chunk per section', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({
ok: true,
json: () => Promise.resolve({ chapter: { content: [
{ type: 'heading' }, { type: 'verse', number: 1 }, { type: 'heading' }, { type: 'verse', number: 5 }, { type: 'heading' }, { type: 'verse', number: 9 },
] } }),
})));
const h = setup([blankChunk(5, 6)]); // verses 5-6 already done
await act(async () => { await h.result.current.builder.suggestChunks(); });
expect(ranges(h).sort((x, y) => x[0] - y[0])).toEqual([[1, 4], [5, 6], [7, 8], [9, 12]]);
});
test('suggest falls back to even chunks when the chapter has no headings', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ chapter: { content: [{ type: 'verse', number: 1 }] } }) })));
const h = setup();
await act(async () => { await h.result.current.builder.suggestChunks(); });
expect(ranges(h)).toEqual([[1, 8], [9, 12]]);
});
+129
View File
@@ -0,0 +1,129 @@
// Cross-references for the selected chunk: typed entry, suggestions from the
// Open Cross Ref dataset, and loading a reference's verse text.
import { useEffect, useState } from 'react';
import { parseBibleChapter } from '../utils/bibleUtils.jsx';
import { parseCrossRefString } from '../utils/importParsers.js';
import { formatCrossRef } from '../utils/wordStudy.js';
const crossRefDatasetCache = {}; // "BOOK/chapter" -> Open Cross Ref chapter data
const verseChapterCache = {}; // "translation/BOOK/chapter" -> parsed verses
// notify(message, ms) shows a short status message on the study page.
export function useCrossRefs({ project, allChunks, selectedChunk, updateChunk, notify }) {
const [crossRefInput, setCrossRefInput] = useState('');
const [suggestingCrossRefs, setSuggestingCrossRefs] = useState(false);
const [crossRefSuggestions, setCrossRefSuggestions] = useState([]);
useEffect(() => {
setCrossRefSuggestions([]);
}, [selectedChunk?.id]);
useEffect(() => {
setCrossRefInput('');
}, [project?.selectedChunkId]);
const addCrossRef = (chunkId) => {
const ref = crossRefInput.trim();
if (!ref) return;
updateChunk(chunkId, {
crossReferences: [...(selectedChunk?.crossReferences ?? []), ref],
});
setCrossRefInput('');
};
const removeCrossRef = (chunkId, ref) => {
updateChunk(chunkId, {
crossReferences: (selectedChunk?.crossReferences ?? []).filter((r) => r !== ref),
});
};
const suggestCrossRefsForChunk = async (chunkId) => {
const chunk = allChunks.find((c) => c.id === chunkId);
const chapterIndex = project?.chapters.findIndex((ch) =>
ch.chunks.some((c) => c.id === chunkId)
) ?? -1;
const chapter = chapterIndex >= 0 ? project.chapters[chapterIndex] : null;
if (!chunk || !chapter) return;
setSuggestingCrossRefs(true);
setCrossRefSuggestions([]);
try {
const cacheKey = `${chapter.bookAbbrev}/${chapter.chapter}`;
let chapterData = crossRefDatasetCache[cacheKey];
if (!chapterData) {
const res = await fetch(`https://bible.helloao.org/api/d/open-cross-ref/${chapter.bookAbbrev}/${chapter.chapter}.json`);
if (!res.ok) throw new Error('No cross-reference data found for this chapter.');
chapterData = await res.json();
crossRefDatasetCache[cacheKey] = chapterData;
}
const verseEntries = (chapterData.chapter?.content ?? []).filter(
(item) => item.verse >= chunk.startVerse
&& item.verse <= chunk.endVerse,
);
const existing = new Set(chunk.crossReferences ?? []);
const seen = new Set();
const refs = [];
for (const v of verseEntries) {
for (const ref of v.references ?? []) {
const formatted = formatCrossRef(ref);
if (existing.has(formatted) || seen.has(formatted)) continue;
seen.add(formatted);
refs.push({ formatted, score: ref.score ?? 0 });
}
}
refs.sort((a, b) => b.score - a.score);
if (refs.length === 0) {
notify('No new cross-reference suggestions found for this passage.', 2500);
return;
}
setCrossRefSuggestions(refs.slice(0, 12).map((r) => r.formatted));
} catch (err) {
notify(`Cross-reference suggest failed: ${err.message}`, 3000);
} finally {
setSuggestingCrossRefs(false);
}
};
const loadVerseText = async (refString) => {
const parsed = parseCrossRefString(refString);
if (!parsed) throw new Error('Could not parse reference.');
const translation = project?.translation ?? 'BSB';
const cacheKey = `${translation}/${parsed.bookAbbrev}/${parsed.chapter}`;
let verses = verseChapterCache[cacheKey];
if (!verses) {
const res = await fetch(`https://bible.helloao.org/api/${translation}/${parsed.bookAbbrev}/${parsed.chapter}.json`);
if (!res.ok) throw new Error('Could not load verse text.');
const data = await res.json();
verses = parseBibleChapter(data);
verseChapterCache[cacheKey] = verses;
}
const text = verses
.filter((v) => v.number >= parsed.verse && v.number <= parsed.endVerse)
.map((v) => v.text)
.join(' ');
return text;
};
const addSuggestedCrossRef = (chunkId, ref) => {
updateChunk(chunkId, {
crossReferences: [...(selectedChunk?.crossReferences ?? []), ref],
});
setCrossRefSuggestions((cur) => cur.filter((r) => r !== ref));
};
return {
crossRefInput, setCrossRefInput,
suggestingCrossRefs,
crossRefSuggestions,
addCrossRef,
removeCrossRef,
suggestCrossRefsForChunk,
addSuggestedCrossRef,
loadVerseText,
};
}
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff. Show more