Compare commits

...
Author SHA1 Message Date
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
67 changed files with 11040 additions and 4489 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
+1
View File
@@ -9,3 +9,4 @@ coverage/
.vite.log
.api.pid
.api.log
.DS_Store
+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
@@ -2,8 +2,23 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Bible Study Project</title>
<!-- PWA manifest -->
<link rel="manifest" href="/manifest.json" />
<!-- Theme / status bar -->
<meta name="theme-color" content="#0f172a" />
<!-- 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">
<div id="root"></div>
+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: 694 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"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": "#f8fafc",
"theme_color": "#0f172a",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+80
View File
@@ -0,0 +1,80 @@
const SHELL = 'bible-shell-v1';
const API = 'bible-api-v1';
const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life'];
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 API routes — let Express handle them
if (new URL(url).pathname.startsWith('/api/')) return;
// External Bible API: network-first, fall back to cache
if (BIBLE_ORIGINS.some(o => url.startsWith(o))) {
e.respondWith(
fetch(request)
.then(res => {
if (res.ok) caches.open(API).then(c => c.put(request, res.clone()));
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) caches.open(SHELL).then(c => c.put(request, res.clone()));
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) c.put(request, res.clone());
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');
}
+310 -16
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,58 @@ 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 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)
);
`);
console.log(`SQLite database ready at ${DB_PATH}`);
@@ -42,27 +94,125 @@ 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,
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
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);
}
/** 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)])
);
}
/** 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());
}
/**
* 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) {
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(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);
@@ -72,29 +222,173 @@ export function getProject(id) {
}
/**
* Insert or replace a project. Returns the summary.
* 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) {
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)
VALUES (?, ?, ?, ?, ?)
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));
`).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. No-op if not found.
* Delete a project by id, scoped to userId. No-op if not found/not owned.
*/
export function deleteProject(id) {
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 {
return JSON.parse(row.data);
} catch {
return null;
}
}
/** 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);
}
+468 -17
View File
@@ -1,13 +1,73 @@
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, upsertProject, deleteProject,
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
setUserPassword, destroyAllSessionsForUser,
getAllReaderInk, setReaderInkPage,
} 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';
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 +77,233 @@ 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) });
});
} 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({ id: user.id, email: user.email, totpEnabled: false, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
});
} 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({ id: user.id, email: user.email, totpEnabled: true, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
});
} 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({
id: user.id, email: user.email, totpEnabled: user.totpEnabled,
podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email),
});
});
// ---------------------------------------------------------------------------
// 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.' });
}
});
// ---------------------------------------------------------------------------
// 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 +312,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 +326,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 +340,12 @@ 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.' });
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 +354,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 +366,177 @@ 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.' });
}
});
// ---------------------------------------------------------------------------
// 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'));
});
}
@@ -96,7 +547,7 @@ if (process.env.NODE_ENV === 'production') {
initDb();
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');
}
});
});
+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);
}
}
+45 -9
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"
@@ -112,4 +148,4 @@ echo "Both servers are running."
echo ""
echo "To stop both: kill \$(cat .api.pid) \$(cat .vite.pid)"
echo "To view API logs: tail -f .api.log"
echo "To view Vite logs: tail -f .vite.log"
echo "To view Vite logs: tail -f .vite.log"
+164 -4294
View File
File diff suppressed because it is too large. Load diff
+359 -13
View File
@@ -2,6 +2,8 @@ import { render, screen, fireEvent, waitFor, within } from '@testing-library/rea
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';
// ---------------------------------------------------------------------------
// Shared mock data
@@ -49,6 +51,8 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
// ---------------------------------------------------------------------------
beforeEach(() => {
localStorage.clear();
resetLexiconCaches();
switchStorageUser(null); // a signed-in test leaves the storage namespace switched
vi.stubGlobal('fetch', buildFetchMock());
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
@@ -63,11 +67,21 @@ afterEach(() => {
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// App checks /api/auth/me on mount before deciding whether to show the app or
// a login gate. In tests the mock fetch reports the server as unreachable, so
// it falls back to local-only mode — but that still takes a tick to resolve.
async function renderApp() {
render(<App />);
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
}
async function loadChapter() {
const user = userEvent.setup();
render(<App />);
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i }));
// SetupPage is lazy-loaded, so wait for it on first open
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
return user;
}
@@ -102,32 +116,32 @@ function findChunkCounter(n, total) {
// Initial render
// ---------------------------------------------------------------------------
describe('Initial render', () => {
test('shows the home page with "My Studies" heading', () => {
render(<App />);
test('shows the home page with "My Studies" heading', async () => {
await renderApp();
expect(screen.getByText('My Studies')).toBeInTheDocument();
});
test('shows "No projects yet" when storage is empty', () => {
render(<App />);
test('shows "No projects yet" when storage is empty', async () => {
await renderApp();
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
test('shows "New Project" button on home page', () => {
render(<App />);
test('shows "New Project" button on home page', async () => {
await renderApp();
expect(screen.getAllByRole('button', { name: /new project/i }).length).toBeGreaterThan(0);
});
test('clicking "New Project" shows the project setup form', async () => {
const user = userEvent.setup();
render(<App />);
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
expect(screen.getByText('Project Setup')).toBeInTheDocument();
expect(await screen.findByText('Project Setup')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
});
test('setup form shows translation, book, and chapter inputs', async () => {
const user = userEvent.setup();
render(<App />);
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
expect(screen.getByText('Translation')).toBeInTheDocument();
expect(screen.getByText('Book')).toBeInTheDocument();
@@ -162,7 +176,7 @@ describe('Loading a chapter', () => {
return Promise.resolve({ ok: false });
}));
const user = userEvent.setup();
render(<App />);
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/unable to load chapter/i);
@@ -171,7 +185,7 @@ describe('Loading a chapter', () => {
test('shows an error message when chapter data contains no verses', async () => {
vi.stubGlobal('fetch', buildFetchMock({ chapterData: { chapter: { content: [] } } }));
const user = userEvent.setup();
render(<App />);
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/invalid bible data/i);
@@ -354,3 +368,335 @@ describe('Greek word lookup', () => {
});
});
});
// ---------------------------------------------------------------------------
// Authentication (useAuth + AuthPage/SettingsPage local form state)
// ---------------------------------------------------------------------------
describe('Authentication', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: 'My Show', isAdmin: false };
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
// A tiny fake API: `session` decides what /api/auth/me returns; login can
// optionally require a 2FA code first.
function stubApi({ session = null, mfaRequired = false } = {}) {
const fallback = buildFetchMock();
const fetchMock = vi.fn((url, opts = {}) => {
if (!url.startsWith('/api/')) return fallback(url, opts);
const route = `${opts.method ?? 'GET'} ${url}`;
if (route === 'GET /api/auth/me') return session ? json(200, session) : json(401, { error: 'Not signed in' });
if (route === 'POST /api/auth/login') return json(200, mfaRequired ? { mfaRequired: true } : user);
if (route === 'POST /api/auth/mfa/verify') return json(200, user);
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
return json(404, { error: 'not mocked' });
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
const calledWith = (fetchMock, method, url) =>
fetchMock.mock.calls.some(([u, o = {}]) => u === url && (o.method ?? 'GET') === method);
async function signInWithForm(u) {
await u.type(await screen.findByLabelText(/email/i), user.email);
await u.type(screen.getByLabelText(/password/i), 'correct-horse');
await u.click(screen.getByRole('button', { name: /^sign in$/i }));
}
test('signing in with the form shows the app and loads reader ink', async () => {
const fetchMock = stubApi();
const u = userEvent.setup();
render(<App />);
await signInWithForm(u);
expect(await screen.findByText(user.email)).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/login')).toBe(true);
await waitFor(() => expect(calledWith(fetchMock, 'GET', '/api/reader/ink')).toBe(true));
});
test('2FA: a correct code after the password signs the user in', async () => {
const fetchMock = stubApi({ mfaRequired: true });
const u = userEvent.setup();
render(<App />);
await signInWithForm(u);
await u.type(await screen.findByPlaceholderText('123456'), '123456');
await u.click(screen.getByRole('button', { name: /verify/i }));
expect(await screen.findByText(user.email)).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/mfa/verify')).toBe(true);
});
test('an existing session skips the login screen, and Log out returns to it', async () => {
const fetchMock = stubApi({ session: user });
const u = userEvent.setup();
render(<App />);
expect(await screen.findByText(user.email)).toBeInTheDocument();
await u.click(screen.getByRole('button', { name: /log out/i }));
expect(await screen.findByRole('button', { name: /^sign in$/i })).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/logout')).toBe(true);
});
test('Settings pre-fills the podcast name from the signed-in account', async () => {
stubApi({ session: user });
const u = userEvent.setup();
render(<App />);
await u.click(await screen.findByRole('button', { name: /settings/i }));
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Admin page (state is local to AdminPage)
// ---------------------------------------------------------------------------
describe('Admin page', () => {
const admin = { id: 'a1', email: 'owner@example.com', totpEnabled: true, podcastName: null, isAdmin: true };
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
function stubAdminApi(sessionUser) {
let users = [
{ id: 'a1', email: 'owner@example.com', totpEnabled: true, projectCount: 1 },
{ id: 'u2', email: 'student@example.com', totpEnabled: false, projectCount: 0 },
];
const fallback = buildFetchMock();
const fetchMock = vi.fn((url, opts = {}) => {
if (!url.startsWith('/api/')) return fallback(url, opts);
const route = `${opts.method ?? 'GET'} ${url}`;
if (route === 'GET /api/auth/me') return json(200, sessionUser);
if (route === 'GET /api/admin/users') return json(200, users);
if (route === 'GET /api/admin/projects') return json(200, [{ id: 'p1', title: 'Romans Study', ownerEmail: 'owner@example.com', chapterSummary: 'Romans 1' }]);
if (route === 'DELETE /api/admin/users/u2') { users = users.filter((u) => u.id !== 'u2'); return json(200, { ok: true }); }
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
return json(404, { error: 'not mocked' });
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
test('an admin can open the page and see users and projects', async () => {
stubAdminApi(admin);
const u = userEvent.setup();
render(<App />);
await u.click(await screen.findByRole('button', { name: /admin/i }));
expect(await screen.findByText('student@example.com')).toBeInTheDocument();
await u.click(screen.getByRole('button', { name: /projects \(1\)/i }));
expect(await screen.findByText('Romans Study')).toBeInTheDocument();
});
test('deleting a user removes them after the list reloads', async () => {
const fetchMock = stubAdminApi(admin);
window.confirm.mockReturnValue(true);
const u = userEvent.setup();
render(<App />);
await u.click(await screen.findByRole('button', { name: /admin/i }));
const row = (await screen.findByText('student@example.com')).closest('tr');
await u.click(within(row).getByRole('button', { name: /delete/i }));
await waitFor(() => expect(screen.queryByText('student@example.com')).not.toBeInTheDocument());
expect(fetchMock.mock.calls.some(([url, o = {}]) => url === '/api/admin/users/u2' && o.method === 'DELETE')).toBe(true);
});
test('non-admins get no Admin button', async () => {
stubAdminApi({ ...admin, isAdmin: false });
render(<App />);
expect(await screen.findByText(admin.email)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /admin/i })).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Import page (state is local to ImportPage)
// ---------------------------------------------------------------------------
describe('Import page', () => {
test('opens from Home, lists fetched translations, and goes back', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /import session list/i }));
expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument();
expect(await screen.findByRole('option', { name: 'KJV' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /back/i }));
expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Home page (search/sort/rename local to HomePage; library + reading plan hooks)
// ---------------------------------------------------------------------------
describe('Home page', () => {
// Tests run in local-only mode (the mock API is unreachable), which uses the
// pre-account localStorage keys.
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));
}
test('searching 3+ characters finds matches inside study notes', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.type(screen.getByPlaceholderText(/search projects/i), 'grace');
expect(await screen.findByText(/notes matching "grace"/i)).toBeInTheDocument();
});
test('renaming a project updates its title and saved index', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /rename/i }));
const input = screen.getByDisplayValue('Titus Notes');
await user.clear(input);
await user.type(input, 'Titus Deep Dive');
await user.click(screen.getByRole('button', { name: /^save$/i }));
expect(await screen.findByText('Titus Deep Dive')).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive');
});
test('deleting a project removes it after confirmation', async () => {
seedProject();
window.confirm.mockReturnValue(true);
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument());
expect(localStorage.getItem('bible-study-project-p1')).toBeNull();
});
test('sort choice is remembered after leaving Home', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.selectOptions(screen.getByDisplayValue('Last edited'), 'title');
await user.click(screen.getByRole('button', { name: /read bible/i }));
await user.click(await screen.findByRole('button', { name: /back to studies/i }));
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
});
test('a reading plan can be started from Home', async () => {
seedProject(); // the plan card shows once there's at least one project
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /set goal/i }));
await user.click(screen.getByRole('button', { name: /start plan/i }));
await waitFor(() => expect(JSON.parse(localStorage.getItem('readingPlan'))?.bookAbbrev).toBe('GEN'));
});
});
// ---------------------------------------------------------------------------
// Project header (ProjectHeader + useShareLink) and the shared read-only view
// ---------------------------------------------------------------------------
describe('Project header and sharing', () => {
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
test('Export → Markdown downloads a file', async () => {
const user = await loadChapter();
await user.click(screen.getByRole('button', { name: /export/i }));
await user.click(screen.getByRole('button', { name: /export markdown/i }));
expect(URL.createObjectURL).toHaveBeenCalled();
});
test('enabling sharing shows a copyable link', async () => {
const fallback = buildFetchMock();
const fetchMock = vi.fn((url, opts = {}) => {
if (url.startsWith('/api/projects/') && url.endsWith('/share')) {
return (opts.method ?? 'GET') === 'POST' ? json(200, { shareToken: 'tok123' }) : json(200, { shareToken: null });
}
return fallback(url, opts);
});
vi.stubGlobal('fetch', fetchMock);
const user = await loadChapter();
await user.click(screen.getByRole('button', { name: /share/i }));
await user.click(screen.getByRole('button', { name: /enable sharing/i }));
expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument();
});
test('a ?share= link shows the read-only view without signing in', async () => {
window.history.pushState({}, '', '/?share=abc');
const fallback = buildFetchMock();
vi.stubGlobal('fetch', vi.fn((url, opts) => (url === '/api/share/abc'
? json(200, { id: 's1', title: 'Shared Titus', translation: 'BSB', chapters: [] })
: fallback(url, opts))));
try {
render(<App />);
expect(await screen.findByText(/read-only shared view/i)).toBeInTheDocument();
expect(screen.getByTitle('Shared study')).toBeInTheDocument();
} finally {
window.history.pushState({}, '', '/');
}
});
});
// ---------------------------------------------------------------------------
// Study editor (StudyPage local state + useCrossRefs / useWordStudy hooks)
// ---------------------------------------------------------------------------
describe('Study editor', () => {
test('study-page messages now show as a status toast', async () => {
await goToStudyWith3Chunks();
// Titus is a New Testament book, so the Hebrew picker explains why it can't help.
fireEvent.click(screen.getByRole('button', { name: /suggest hebrew/i }));
expect(await screen.findByRole('status')).toHaveTextContent(/old testament/i);
});
test('arrow keys move between chunks', async () => {
await goToStudyWith3Chunks();
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(await findChunkCounter(2, 3)).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'ArrowLeft' });
expect(await findChunkCounter(1, 3)).toBeInTheDocument();
});
test('a typed cross-reference is added, and the input clears on chunk change', async () => {
await goToStudyWith3Chunks();
const input = screen.getByPlaceholderText(/john 1:1 or rom 3:23/i);
fireEvent.change(input, { target: { value: 'Rom 3:23' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(await screen.findByText('Rom 3:23')).toBeInTheDocument();
fireEvent.change(input, { target: { value: 'half typed' } });
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
await waitFor(() => expect(input).toHaveValue(''));
});
test('a tag can be added with Enter', async () => {
await goToStudyWith3Chunks();
const input = screen.getByPlaceholderText(/add a tag/i);
fireEvent.change(input, { target: { value: 'grace' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(await screen.findByText(/#?grace/)).toBeInTheDocument();
expect(input).toHaveValue('');
});
});
// ---------------------------------------------------------------------------
// Chunk builder (useChunkBuilder, local to SetupPage)
// ---------------------------------------------------------------------------
describe('Chunk builder', () => {
async function addSecondChapter(user) {
await user.click(screen.getByRole('button', { name: /add chapter/i }));
// The add-chapter form starts on the chapter after the project's last one.
expect(screen.getByDisplayValue('2')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /load chapter/i }));
return screen.findByRole('button', { name: 'Titus 2' });
}
test('"Add Chapter" starts on the next chapter and adds it as a tab', async () => {
const user = await loadChapter();
expect(await addSecondChapter(user)).toHaveClass('bg-slate-900');
});
test('"Back to Chunks" reopens the chapter of the chunk being studied', async () => {
const user = await loadChapter();
await addSecondChapter(user);
addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected)
await user.click(screen.getByRole('button', { name: 'Titus 1' }));
await user.click(screen.getByRole('button', { name: /begin studying/i }));
await screen.findByText(/chunk editor/i);
await user.click(screen.getByRole('button', { name: /back to chunks/i }));
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveClass('bg-slate-900');
});
});
+35
View File
@@ -0,0 +1,35 @@
// Signed-in email plus Admin / Settings / Log out buttons for page headers.
import { useApp } from '../context/AppContext.js';
export default function AuthStatus() {
const { authUser, setCurrentPage, logout } = useApp();
if (!authUser) return null;
return (
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="hidden sm:inline">{authUser.email}</span>
{authUser.isAdmin && (
<button
type="button"
onClick={() => setCurrentPage('admin')}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
>
🛡 Admin
</button>
)}
<button
type="button"
onClick={() => setCurrentPage('settings')}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
>
⚙ Settings
</button>
<button
type="button"
onClick={logout}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
>
Log out
</button>
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
// Header for an open project (Setup and Study pages): navigation, Claude
// prompts, pronunciation guide, export menu, share link, and save/sync status.
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useShareLink } from '../hooks/useShareLink.js';
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
import {
exportProjectDocx,
exportProjectHtml,
exportProjectMarkdown,
printProjectPdf,
} from '../utils/projectExport.js';
import AuthStatus from './AuthStatus.jsx';
// loading: true while the Setup page is fetching a chapter.
export default function ProjectHeader({ loading = false }) {
const {
authUser,
currentPage, setCurrentPage,
goHome,
project,
allChunks,
saveStatus, setSaveStatus,
syncStatus,
retrySync,
} = useApp();
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const [sharePanelOpen, setSharePanelOpen] = useState(false);
const share = useShareLink(project?.id);
// Copies a prompt, then opens claude.ai so it can be pasted straight in.
const copyPrompt = (prompt) => {
navigator.clipboard.writeText(prompt).then(() => {
setSaveStatus('Copied! Opening Claude…');
window.setTimeout(() => setSaveStatus(''), 3000);
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
});
};
return (
<div className="flex flex-wrap items-center gap-3">
{currentPage !== 'home' && (
<button
type="button"
onClick={goHome}
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
>
← Home
</button>
)}
{project && currentPage === 'study' && (
<button
type="button"
onClick={() => setCurrentPage('setup')}
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
>
← Back to Chunks
</button>
)}
{project && (
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => copyPrompt(buildClaudePrompt(project))}
disabled={allChunks.length === 0}
className="rounded-md bg-violet-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-violet-400 disabled:cursor-not-allowed disabled:bg-slate-500"
title="Copies your study notes as a Claude prompt, then opens claude.ai so you can paste and go"
>
<span className="hidden sm:inline">Study Guide → </span>Claude ↗
</button>
<button
type="button"
onClick={() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName))}
disabled={allChunks.length === 0}
title="Copies a podcast script prompt for Claude, then opens claude.ai — set your show name in Account Settings first"
className="rounded-md bg-fuchsia-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-fuchsia-400 disabled:cursor-not-allowed disabled:bg-slate-500"
>
🎙<span className="hidden sm:inline"> Podcast → Claude ↗</span>
</button>
<button
type="button"
onClick={() => setCurrentPage('pronunciation')}
disabled={allChunks.length === 0}
title="Open the pronunciation guide — all Greek & Hebrew words with transliterations"
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:bg-slate-500"
>
🗣<span className="hidden sm:inline"> Pronunciation Guide</span>
</button>
<div className="relative">
<button
type="button"
onClick={() => setExportMenuOpen((v) => !v)}
className="flex items-center gap-1.5 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400"
>
⬇ Export
</button>
{exportMenuOpen && (
<div className="absolute left-0 top-full z-20 mt-2 w-56 overflow-hidden rounded-2xl border border-slate-200 bg-white py-1.5 text-left shadow-lg">
<button type="button" onClick={() => { exportProjectHtml(project); setExportMenuOpen(false); }}
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export HTML
</button>
<button type="button" onClick={() => { exportProjectDocx(project); setExportMenuOpen(false); }}
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export DOCX
</button>
<button type="button" onClick={() => { exportProjectMarkdown(project); setExportMenuOpen(false); }}
title="Handy for Obsidian, Notion, or any Markdown-based notes app"
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export Markdown
</button>
<button type="button" onClick={() => { printProjectPdf(project); setExportMenuOpen(false); }}
title="Opens a print-friendly version in a new tab — choose 'Save as PDF' in the print dialog"
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
🖨 Print / Save PDF
</button>
</div>
)}
</div>
<div className="relative">
<button
type="button"
onClick={() => setSharePanelOpen((v) => !v)}
className={`rounded-md px-4 py-2 text-sm font-medium shadow-sm transition ${share.shareToken ? 'bg-teal-600 text-white hover:bg-teal-500' : 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'}`}
>
🔗 {share.shareToken ? 'Shared' : 'Share'}
</button>
{sharePanelOpen && (
<div className="absolute right-0 top-full z-20 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-lg">
<h3 className="text-sm font-semibold text-slate-900">Read-only share link</h3>
<p className="mt-1 text-xs text-slate-500">
Anyone with this link can view (not edit) this study — no account needed. Good for a co-teacher
or a listener who wants to follow along.
</p>
{share.shareToken ? (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<input
readOnly
value={share.shareUrl}
onFocus={(e) => e.target.select()}
className="w-full flex-1 truncate rounded-lg border border-slate-300 bg-slate-50 px-2 py-1.5 text-xs text-slate-600"
/>
<button type="button" onClick={share.copy}
className="shrink-0 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-400">
{share.shareCopied ? 'Copied!' : 'Copy'}
</button>
</div>
<button type="button" onClick={share.revoke} disabled={share.shareBusy}
className="w-full rounded-lg border border-rose-200 px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50">
{share.shareBusy ? 'Revoking…' : 'Revoke link'}
</button>
</div>
) : (
<button type="button" onClick={share.enable} disabled={share.shareBusy}
className="mt-3 w-full rounded-lg bg-teal-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-teal-500 disabled:cursor-not-allowed disabled:opacity-50">
{share.shareBusy ? 'Enabling…' : 'Enable sharing'}
</button>
)}
</div>
)}
</div>
</div>
)}
<div className="text-right text-sm text-slate-300 space-y-0.5">
{loading ? (
<span>Loading…</span>
) : saveStatus ? (
<span className="text-emerald-300">{saveStatus}</span>
) : (
<span>&nbsp;</span>
)}
{syncStatus === 'syncing' && <div className="text-xs text-slate-400">Syncing…</div>}
{syncStatus === 'synced' && <div className="text-xs text-emerald-400">Synced ✓</div>}
{syncStatus === 'error' && (
<div className="flex items-center gap-2">
<span className="text-xs text-rose-400">⚠ Sync failed</span>
<button
type="button"
onClick={retrySync}
className="text-xs text-slate-300 underline hover:text-white"
>
Retry
</button>
</div>
)}
</div>
<AuthStatus />
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { createContext, useContext } from 'react';
export const AppContext = createContext(null);
export const useApp = () => useContext(AppContext);
+138
View File
@@ -0,0 +1,138 @@
// 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 } from '../utils/bibleUtils.jsx';
export function useAudio(readerBookAbbrev, readerChapter) {
const [audioBook, setAudioBook] = useState(bookOptions[0].abbrev);
const [audioNarrator, setAudioNarrator] = useState('souer');
const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 });
const [readerAudioState, setReaderAudioState] = useState({ status: 'idle', chapter: 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;
const playAudioChapter = async (abbrev, chapterNum, narrator) => {
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();
const total = data.book?.numberOfChapters ?? chapterNum;
const 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('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 });
} 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 });
}
};
const handleStopBookAudio = () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
setAudioState({ status: 'idle', chapter: 0, total: 0 });
setReaderAudioState({ status: 'idle', chapter: 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 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) 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]);
useEffect(() => () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
}, []);
return {
audioBook, setAudioBook,
audioNarrator, setAudioNarrator,
audioState,
handlePlayBookAudio,
handleStopBookAudio,
handleToggleBookAudioPause,
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 };
}
+25
View File
@@ -0,0 +1,25 @@
// The list of Bible translations offered by the HelloAO API, fetched once per
// visit and shared by every page that needs it (Setup, Import).
import { useEffect, useState } from 'react';
let cached = null;
let pending = null;
export function useAvailableTranslations() {
const [translations, setTranslations] = useState(cached ?? ['BSB']);
useEffect(() => {
if (cached) return;
pending ??= fetch('https://bible.helloao.org/api/available_translations.json')
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data) && data.length > 0) cached = data;
return cached;
})
.catch(() => null)
.finally(() => { pending = null; });
let active = true;
pending.then((list) => { if (active && list) setTranslations(list); });
return () => { active = false; };
}, []);
return translations;
}
+330
View File
@@ -0,0 +1,330 @@
// 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, parseBibleChapter } from '../utils/bibleUtils.jsx';
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
export function useBibleReader(currentPage) {
const [readerBookAbbrev, setReaderBookAbbrev] = useState(bookOptions[0].abbrev);
const [readerChapter, setReaderChapter] = useState(1);
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({});
const [readerFontSize, setReaderFontSize] = useState(1); // em multiplier: 0.875 | 1 | 1.125 | 1.25
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 [bibleIndexStatus, setBibleIndexStatus] = useState('idle'); // 'idle' | 'loading' | 'ready' | 'error'
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 []; }
});
// 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 fetches the
// entire translation once (~7MB) and searches an in-memory flat verse index
// client-side. Cached per translation for the rest of the session (not persisted
// across reloads, to avoid eating into localStorage/IndexedDB quota for a cache
// that's cheap enough to rebuild once per visit).
const loadBibleIndex = async () => {
if (_bibleIndexCacheRef.current.BSB) {
setBibleIndexStatus('ready');
return;
}
setBibleIndexStatus('loading');
try {
// Check IndexedDB cache before hitting the network (~7 MB fetch)
const cached = await getBibleIndexFromIDB('BSB');
if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
_bibleIndexCacheRef.current.BSB = cached.flat;
setBibleIndexStatus('ready');
return;
}
const res = await fetch('https://bible.helloao.org/api/BSB/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.BSB = flat;
setBibleIndexStatus('ready');
// Persist for future visits — non-blocking
saveBibleIndexToIDB('BSB', flat);
} catch {
setBibleIndexStatus('error');
}
};
const loadReaderChapter = async (bookAbbrev, chapterNumber) => {
setReaderLoading(true);
setReaderError('');
try {
const res = await fetch(`https://bible.helloao.org/api/BSB/${bookAbbrev}/${chapterNumber}.json`);
if (!res.ok) throw new Error('Unable to load chapter.');
const data = await res.json();
const verses = parseBibleChapter(data);
if (!Array.isArray(verses) || verses.length === 0) {
throw new Error('Invalid Bible data returned.');
}
setReaderVerses(verses);
setReaderTotalChapters(data.book?.numberOfChapters ?? chapterNumber);
} catch (err) {
setReaderError(err.message || 'Failed to load chapter.');
setReaderVerses([]);
} finally {
setReaderLoading(false);
}
};
useEffect(() => {
if (currentPage !== 'reader') return;
loadReaderChapter(readerBookAbbrev, readerChapter);
}, [currentPage, readerBookAbbrev, readerChapter]);
// 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: '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;
});
};
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;
});
};
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);
}
};
// 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;
});
}, []);
return {
readerBookAbbrev, setReaderBookAbbrev,
readerChapter, setReaderChapter,
readerVerses,
readerTotalChapters,
readerLoading,
readerError,
readerInterlinear,
readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize,
readerBookmarks, setReaderBookmarks,
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading,
readerShowCrossRefs, setReaderShowCrossRefs,
readerSearch, setReaderSearch,
readerSearchActive, setReaderSearchActive,
readerSearchScope, setReaderSearchScope,
readerInkByPage,
updateReaderPageInk,
mergeServerInk,
readerTextHighlights,
addTextHighlight,
removeTextHighlight,
bibleIndexStatus,
loadBibleIndex,
_bibleIndexCacheRef,
loadReaderChapter,
handleReaderBookChange,
readerGoToPreviousChapter,
readerGoToNextChapter,
jumpToReaderVerse,
toggleReaderBookmark,
cycleBookmarkColor,
loadReaderCrossRefs,
};
}
+521
View File
@@ -0,0 +1,521 @@
// 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 { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
// 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 [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 = {
id: makeId(),
startVerse: start,
endVerse: end,
spilloverEndVerse,
observation: '',
interpretation: '',
application: '',
crossReferences: [],
greekWords: [],
tags: [],
};
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 };
}),
}));
};
const deleteChunk = (chunkId) => {
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),
})),
};
});
};
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,
};
}
+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,
};
}
+113
View File
@@ -0,0 +1,113 @@
// The signed-in user's saved projects: opening, renaming, deleting, and keeping
// this device's copies in step with the server.
import { useEffect, useState } from 'react';
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
import {
saveProjectToStorage,
deleteProjectFromStorage,
loadProjectById,
} from '../utils/projectStorage.js';
import {
saveRemoteProject,
deleteRemoteProject,
listRemoteProjects,
loadRemoteProject,
} from '../syncService.js';
export function useProjectLibrary({
authUser,
project,
setProject,
setProjectIndex,
setCurrentPage,
}) {
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
// Once we know who's signed in, reconcile the local project index against the server.
// Runs on every authUser change (including logout -> different login) so a previous
// account's stale suggestions never linger after switching users. Projects that exist
// on the server but not on this device (e.g. a brand new browser) are pulled down
// automatically instead of requiring a manual "Restore" click per project.
useEffect(() => {
if (!authUser) {
setStaleLocalProjects([]);
return;
}
let cancelled = false;
const localIndex = loadProjectIndex();
listRemoteProjects().then(async (result) => {
if (!result.ok || cancelled) return;
const localMap = new Map(localIndex.map((e) => [e.id, e]));
const missing = result.data.filter((e) => !localMap.has(e.id));
if (missing.length > 0) {
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
fetched.filter((r) => r.ok).forEach((r) => saveProjectToStorage(r.data));
if (cancelled) return;
const restoredCount = fetched.filter((r) => r.ok).length;
if (restoredCount > 0) {
setProjectIndex(loadProjectIndex());
setAutoRestoredCount(restoredCount);
window.setTimeout(() => setAutoRestoredCount(null), 4000);
}
}
const stale = result.data.filter((e) => {
const local = localMap.get(e.id);
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0);
});
if (!cancelled) setStaleLocalProjects(stale);
});
return () => { cancelled = true; };
}, [authUser]);
const resumeProject = (id) => {
const loaded = loadProjectById(id);
if (!loaded) return;
setProject(loaded);
const loadedChunks = loaded.chapters.flatMap((ch) => ch.chunks);
const hasSelectedChunk = loaded.selectedChunkId
&& loadedChunks.some((c) => c.id === loaded.selectedChunkId);
setCurrentPage(hasSelectedChunk ? 'study' : 'setup');
};
const renameProjectInStorage = (id, title) => {
const loaded = loadProjectById(id);
if (!loaded) return;
const updated = { ...loaded, title, lastEdited: Date.now() };
saveProjectToStorage(updated);
setProjectIndex(loadProjectIndex());
saveRemoteProject(updated);
if (project?.id === id) {
setProject((current) => (current ? { ...current, title } : current));
}
};
const deleteProject = (id) => {
if (!window.confirm('Delete this project? This cannot be undone.')) return;
deleteProjectFromStorage(id);
setProjectIndex(loadProjectIndex());
deleteRemoteProject(id); // fire-and-forget
};
const pullLatestFromServer = async (id) => {
const result = await loadRemoteProject(id);
if (!result.ok) {
alert('Could not pull latest version from server.');
return;
}
saveProjectToStorage(result.data);
setProjectIndex(loadProjectIndex());
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
};
return {
staleLocalProjects,
autoRestoredCount,
resumeProject,
renameProjectInStorage,
deleteProject,
pullLatestFromServer,
};
}
+35
View File
@@ -0,0 +1,35 @@
// A simple "read this book in N weeks" plan, stored per browser.
import { useState } from 'react';
import { CHAPTER_COUNTS } from '../utils/bibleUtils.jsx';
export function useReadingPlan() {
const [readingPlan, setReadingPlan] = useState(() => {
try { return JSON.parse(localStorage.getItem('readingPlan') || 'null'); } catch { return null; }
});
const createReadingPlan = (bookAbbrev, bookName, weeks) => {
const totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? 1;
const plan = { bookAbbrev, bookName, totalChapters, startDate: Date.now(), targetDate: Date.now() + weeks * 7 * 24 * 60 * 60 * 1000, chaptersRead: [] };
setReadingPlan(plan);
try { localStorage.setItem('readingPlan', JSON.stringify(plan)); } catch {}
};
const markChapterRead = (chapter) => {
setReadingPlan((prev) => {
if (!prev) return prev;
const chaptersRead = prev.chaptersRead.includes(chapter)
? prev.chaptersRead.filter((c) => c !== chapter)
: [...prev.chaptersRead, chapter].sort((a, b) => a - b);
const next = { ...prev, chaptersRead };
try { localStorage.setItem('readingPlan', JSON.stringify(next)); } catch {}
return next;
});
};
const clearReadingPlan = () => {
setReadingPlan(null);
try { localStorage.removeItem('readingPlan'); } catch {}
};
return { readingPlan, createReadingPlan, markChapterRead, clearReadingPlan };
}
+48
View File
@@ -0,0 +1,48 @@
// Read-only share link for the open project: status, enable, revoke, copy.
import { useEffect, useState } from 'react';
import { disableSharing, enableSharing, getShareStatus } from '../syncService.js';
export function useShareLink(projectId) {
const [shareToken, setShareToken] = useState(null);
const [shareBusy, setShareBusy] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
useEffect(() => {
setShareToken(null);
if (!projectId) return;
let active = true;
getShareStatus(projectId).then((result) => {
if (active && result.ok) setShareToken(result.data.shareToken);
});
return () => { active = false; };
}, [projectId]);
const shareUrl = shareToken
? `${window.location.origin}${window.location.pathname}?share=${shareToken}`
: '';
const enable = async () => {
setShareBusy(true);
const result = await enableSharing(projectId);
setShareBusy(false);
if (result.ok) setShareToken(result.data.shareToken);
};
const revoke = async () => {
if (!window.confirm('Revoke this share link? Anyone using it will lose access immediately.')) return;
setShareBusy(true);
const result = await disableSharing(projectId);
setShareBusy(false);
if (result.ok) setShareToken(null);
};
const copy = () => {
if (!shareUrl) return;
navigator.clipboard.writeText(shareUrl).then(() => {
setShareCopied(true);
window.setTimeout(() => setShareCopied(false), 2000);
});
};
return { shareToken, shareUrl, shareBusy, shareCopied, enable, revoke, copy };
}
+20
View File
@@ -0,0 +1,20 @@
// A ?share=TOKEN URL shows a read-only view of someone else's project. No
// login is needed, so this runs independently of auth.
import { useEffect, useState } from 'react';
import { getSharedProject } from '../syncService.js';
export function useSharedView() {
const [token] = useState(() => new URLSearchParams(window.location.search).get('share'));
const [project, setProject] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
if (!token) return;
getSharedProject(token).then((result) => {
if (result.ok) setProject(result.data);
else setError(result.error ?? 'This share link is invalid or has been revoked.');
});
}, [token]);
return { token, project, error };
}
+76
View File
@@ -0,0 +1,76 @@
// Commentary and interlinear text for the chapter of the selected chunk.
// Each loads only while its section is expanded; results are cached for the visit.
import { useEffect, useState } from 'react';
const commentaryCache = {}; // "commentaryId/BOOK/chapter" -> data
const interlinearCache = {}; // BOOK -> whole-book interlinear data
export function useStudyResources({ project, selectedChunk, selectedChunkChapterIndex, collapsedSections }) {
const [commentarySource, setCommentarySource] = useState('matthew-henry');
const [commentaryData, setCommentaryData] = useState(null); // { content: [...] } for current chapter
const [commentaryLoading, setCommentaryLoading] = useState(false);
const [commentaryError, setCommentaryError] = useState('');
const [interlinearData, setInterlinearData] = useState(null);
const [interlinearLoading, setInterlinearLoading] = useState(false);
const [interlinearError, setInterlinearError] = useState('');
const loadInterlinearChapter = async (bookAbbrev, chapterNumber) => {
if (interlinearCache[bookAbbrev]) return interlinearCache[bookAbbrev];
const res = await fetch(`/interlinear/${bookAbbrev}.json`);
if (!res.ok) throw new Error('No interlinear data for this book.');
const data = await res.json();
interlinearCache[bookAbbrev] = data;
return data;
};
const loadCommentaryChapter = async (commentaryId, bookAbbrev, chapterNumber) => {
const cacheKey = `${commentaryId}/${bookAbbrev}/${chapterNumber}`;
if (commentaryCache[cacheKey]) return commentaryCache[cacheKey];
const res = await fetch(`https://bible.helloao.org/api/c/${commentaryId}/${bookAbbrev}/${chapterNumber}.json`);
const contentType = res.headers.get('content-type') || '';
if (!res.ok || !contentType.includes('json')) throw new Error(`No ${commentaryId} commentary for this chapter.`);
const data = await res.json();
commentaryCache[cacheKey] = data;
return data;
};
useEffect(() => {
if (collapsedSections.commentary) return;
if (selectedChunkChapterIndex < 0) return;
const chapter = project.chapters[selectedChunkChapterIndex];
setCommentaryLoading(true);
setCommentaryError('');
loadCommentaryChapter(commentarySource, chapter.bookAbbrev, chapter.chapter)
.then((data) => setCommentaryData(data))
.catch((err) => {
setCommentaryData(null);
setCommentaryError(err.message);
})
.finally(() => setCommentaryLoading(false));
}, [commentarySource, selectedChunk?.id, selectedChunkChapterIndex, collapsedSections.commentary]);
useEffect(() => {
if (collapsedSections.interlinear) return;
if (selectedChunkChapterIndex < 0) return;
const chapter = project.chapters[selectedChunkChapterIndex];
setInterlinearLoading(true);
setInterlinearError('');
loadInterlinearChapter(chapter.bookAbbrev, chapter.chapter)
.then((data) => setInterlinearData(data?.[String(chapter.chapter)] ?? null))
.catch((err) => {
setInterlinearData(null);
setInterlinearError(err.message);
})
.finally(() => setInterlinearLoading(false));
}, [selectedChunk?.id, selectedChunkChapterIndex, collapsedSections.interlinear]);
return {
commentarySource, setCommentarySource,
commentaryData,
commentaryLoading,
commentaryError,
interlinearData,
interlinearLoading,
interlinearError,
};
}
+341
View File
@@ -0,0 +1,341 @@
// Greek/Hebrew word studies on a chunk: word entries, Strong's lookups, and the
// "suggest words from this passage" picker.
import { useState } from 'react';
import {
NT_BOOK_NUMBER,
chunkSpansNextChapter,
extractPartOfSpeech,
getChunkVerseEntries,
makeId,
} from '../utils/bibleUtils.jsx';
import {
ENGLISH_STOP_WORDS,
GREEK_FUNCTION_WORDS,
buildGreekDefinitionHtml,
fetchBollsDefinition,
loadGreekDict,
loadHebrewDict,
loadNtConcordance,
loadNtGloss,
shortHebrewGloss,
} from '../utils/wordStudy.js';
// notify(message, ms) shows a short status message on the study page.
export function useWordStudy({ project, allChunks, updateProject, notify }) {
const [suggestingGreekForChunkId, setSuggestingGreekForChunkId] = useState(null);
const [suggestingHebrewForChunkId, setSuggestingHebrewForChunkId] = useState(null);
// suggestModal: null | { chunkId, words: [{ strongKey, lexeme, translit, def }] }
const [suggestModal, setSuggestModal] = useState(null);
const [suggestSelection, setSuggestSelection] = useState(new Set());
const updateChunkWord = (chunkId, wordId, patch) => {
updateProject((current) => ({
...current,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((chunk) => {
if (chunk.id !== chunkId) return chunk;
return {
...chunk,
greekWords: chunk.greekWords.map((word) =>
word.id === wordId ? { ...word, ...patch } : word,
),
};
}),
})),
}));
};
const addGreekWord = (chunkId) => {
const newWord = {
id: makeId(),
query: '',
strongNumber: '',
englishGloss: '',
lexeme: '',
transliteration: '',
partOfSpeech: '',
shortDefinition: '',
definitionHtml: '',
loading: false,
};
updateProject((current) => ({
...current,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((chunk) =>
chunk.id === chunkId
? { ...chunk, greekWords: [...chunk.greekWords, newWord] }
: chunk,
),
})),
}));
};
const removeGreekWord = (chunkId, wordId) => {
updateProject((current) => ({
...current,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((chunk) =>
chunk.id === chunkId
? { ...chunk, greekWords: chunk.greekWords.filter((w) => w.id !== wordId) }
: chunk,
),
})),
}));
};
const suggestGreekWordsForChunk = 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;
if (!NT_BOOK_NUMBER[chapter.bookAbbrev]) {
notify('Auto-suggest only works for New Testament books.', 2500);
return;
}
setSuggestingGreekForChunkId(chunkId);
try {
const [concordance, dict, gloss] = await Promise.all([loadNtConcordance(), loadGreekDict(), loadNtGloss()]);
const bookData = concordance[chapter.bookAbbrev] ?? {};
const chapterData = bookData[chapter.chapter] ?? {};
const nextChapterData = chunkSpansNextChapter(project, chapterIndex, chunk)
? bookData[project.chapters[chapterIndex + 1].chapter] ?? {}
: {};
const strongsInRange = new Set();
for (let v = chunk.startVerse; v <= chunk.endVerse; v++) {
for (const s of chapterData[String(v)] ?? []) strongsInRange.add(s);
}
if (chunkSpansNextChapter(project, chapterIndex, chunk)) {
for (let v = 1; v <= chunk.spilloverEndVerse; v++) {
for (const s of nextChapterData[String(v)] ?? []) strongsInRange.add(s);
}
}
if (strongsInRange.size === 0) {
notify("No Strong's data found for this passage.", 3000);
return;
}
const existingNumbers = new Set(
chunk.greekWords.map((w) => w.strongNumber.toUpperCase()).filter(Boolean)
);
const modalWords = [];
for (const strongKey of strongsInRange) {
if (existingNumbers.has(strongKey)) continue;
const entry = dict[strongKey];
modalWords.push({
strongKey,
lexeme: entry?.lemma ?? '',
translit: entry?.translit ?? '',
def: gloss[strongKey] ?? entry?.kjv_def ?? 'No definition found.',
entry,
});
}
if (modalWords.length === 0) {
notify('All words from this passage are already added.', 2000);
return;
}
// Pre-select content words; pre-uncheck known function words
const preSelected = new Set(
modalWords
.filter((w) => !GREEK_FUNCTION_WORDS.has(w.strongKey))
.map((w) => w.strongKey)
);
setSuggestModal({
chunkId,
language: 'greek',
helperText: "Content words are pre-checked. Uncheck any you don't need.",
words: modalWords,
});
setSuggestSelection(preSelected);
} catch (err) {
notify(`Suggest failed: ${err.message}`, 3000);
} finally {
setSuggestingGreekForChunkId(null);
}
};
const suggestHebrewWordsForChunk = 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;
if (NT_BOOK_NUMBER[chapter.bookAbbrev]) {
notify('Hebrew suggestions are for Old Testament passages.', 2500);
return;
}
setSuggestingHebrewForChunkId(chunkId);
try {
const dict = await loadHebrewDict();
const versesInChunk = getChunkVerseEntries(project, chapterIndex, chunk)
.map((v) => v.text)
.join(' ')
.toLowerCase();
const candidateWords = Array.from(new Set(
versesInChunk
.replace(/[^a-z\s]/g, ' ')
.split(/\s+/)
.map((w) => w.trim())
.filter((w) => w.length >= 4 && !ENGLISH_STOP_WORDS.has(w)),
));
if (candidateWords.length === 0) {
notify('No Hebrew suggestion candidates found in this passage.', 2500);
return;
}
const existingNumbers = new Set(
chunk.greekWords.map((w) => (w.strongNumber || '').toUpperCase()).filter(Boolean)
);
const seen = new Set();
const modalWords = [];
const entries = Object.entries(dict);
for (const token of candidateWords) {
const match = entries.find(([, entry]) => {
const defs = `${entry.kjv_def || ''} ${entry.strongs_def || ''}`.toLowerCase();
return defs.split(/[,;()\s]+/).includes(token);
});
if (!match) continue;
const [strongKey, entry] = match;
if (!/^H\d+$/i.test(strongKey)) continue;
const normalized = strongKey.toUpperCase();
if (existingNumbers.has(normalized) || seen.has(normalized)) continue;
seen.add(normalized);
modalWords.push({
strongKey: normalized,
lexeme: entry?.lemma ?? '',
translit: entry?.xlit ?? '',
def: shortHebrewGloss(entry?.kjv_def) || 'No definition found.',
fullDef: entry?.kjv_def ?? '',
entry,
});
if (modalWords.length >= 20) break;
}
if (modalWords.length === 0) {
notify('No Hebrew suggestions found from this passage text.', 2500);
return;
}
setSuggestModal({
chunkId,
language: 'hebrew',
helperText: 'Heuristic matches from passage text; uncheck anything not useful.',
words: modalWords,
});
setSuggestSelection(new Set(modalWords.map((w) => w.strongKey)));
} catch (err) {
notify(`Hebrew suggest failed: ${err.message}`, 3000);
} finally {
setSuggestingHebrewForChunkId(null);
}
};
const confirmSuggestWords = () => {
if (!suggestModal) return;
const { chunkId, words } = suggestModal;
const newWords = words
.filter((w) => suggestSelection.has(w.strongKey))
.map((w) => ({
id: makeId(),
query: w.strongKey,
strongNumber: w.strongKey,
englishGloss: w.def,
lexeme: w.lexeme,
transliteration: w.translit,
partOfSpeech: '',
shortDefinition: w.fullDef || w.def,
definitionHtml: w.entry ? buildGreekDefinitionHtml(w.strongKey, w.entry) : '',
loading: false,
}));
if (newWords.length > 0) {
updateProject((current) => ({
...current,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((c) =>
c.id === chunkId ? { ...c, greekWords: [...c.greekWords, ...newWords] } : c
),
})),
}));
const label = suggestModal?.language === 'hebrew' ? 'Hebrew' : 'Greek';
notify(`Added ${newWords.length} ${label} word${newWords.length > 1 ? 's' : ''}.`, 2000);
}
setSuggestModal(null);
setSuggestSelection(new Set());
};
const lookupWord = async (chunkId, wordId, language = 'greek') => {
const chunk = allChunks.find((c) => c.id === chunkId);
const word = chunk?.greekWords.find((w) => w.id === wordId);
if (!word || !word.query.trim()) return;
updateChunkWord(chunkId, wordId, { loading: true });
try {
// Normalize bare numbers based on selected lookup language.
const raw = word.query.trim();
const inferredPrefix = language === 'hebrew' ? 'H' : 'G';
const normalized = /^\d+$/.test(raw)
? `${inferredPrefix}${raw}`
: /^[gGhH]\d+$/.test(raw)
? raw.toUpperCase()
: raw;
const [definitions, gloss] = await Promise.all([
fetchBollsDefinition(normalized),
loadNtGloss().catch(() => ({})),
]);
if (!definitions || definitions.length === 0) {
updateChunkWord(chunkId, wordId, {
strongNumber: '',
shortDefinition: 'No definition found.',
definitionHtml: '',
});
return;
}
const first = definitions[0];
const strongKey = (first.topic || normalized).toUpperCase();
const extractedPartOfSpeech = extractPartOfSpeech(first.definition || '');
updateChunkWord(chunkId, wordId, {
strongNumber: first.topic || normalized,
englishGloss: gloss[strongKey] || first.short_definition || '',
lexeme: first.lexeme || '',
transliteration: first.transliteration || '',
partOfSpeech: extractedPartOfSpeech || word.partOfSpeech || '',
shortDefinition: first.short_definition || '',
definitionHtml: first.definition || '',
});
} catch {
updateChunkWord(chunkId, wordId, { shortDefinition: 'Lookup failed.' });
} finally {
updateChunkWord(chunkId, wordId, { loading: false });
}
};
return {
suggestingGreekForChunkId,
suggestingHebrewForChunkId,
suggestModal, setSuggestModal,
suggestSelection, setSuggestSelection,
updateChunkWord,
addGreekWord,
removeGreekWord,
lookupWord,
suggestGreekWordsForChunk,
suggestHebrewWordsForChunk,
confirmSuggestWords,
};
}
+7
View File
@@ -30,6 +30,13 @@ select {
font: inherit;
}
/* When installed as a PWA, push header content below the status bar */
@media all and (display-mode: standalone) {
header {
padding-top: env(safe-area-inset-top, 0px);
}
}
.scrollbar-thin::-webkit-scrollbar {
width: 9px;
height: 9px;
+13
View File
@@ -3,6 +3,19 @@ import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
// Pages are lazy-loaded chunks. If a deploy happened while this tab was open,
// the old chunk files are gone — reload once to pick up the new build.
window.addEventListener('vite:preloadError', () => {
if (sessionStorage.getItem('chunk-reload')) return;
sessionStorage.setItem('chunk-reload', '1');
window.location.reload();
});
window.addEventListener('load', () => sessionStorage.removeItem('chunk-reload'));
ReactDOM.createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import AuthStatus from '../components/AuthStatus.jsx';
import { buildExportHtml } from '../utils/bibleUtils.jsx';
import {
adminListUsers,
adminListProjects,
adminDeleteUser,
adminDeleteProject,
adminGetProject,
adminResetPassword,
} from '../syncService.js';
export default function AdminPage() {
const { authUser, goHome } = useApp();
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects'
const [adminUsers, setAdminUsers] = useState([]);
const [adminProjects, setAdminProjects] = useState([]);
const [adminLoading, setAdminLoading] = useState(false);
const [adminError, setAdminError] = useState('');
const [adminViewProject, setAdminViewProject] = useState(null); // full project data being previewed
const [adminResetResult, setAdminResetResult] = useState(null); // { email, temporaryPassword } shown once
const loadAdminData = () => {
setAdminLoading(true);
setAdminError('');
Promise.all([adminListUsers(), adminListProjects()]).then(([usersResult, projectsResult]) => {
setAdminLoading(false);
if (!usersResult.ok || !projectsResult.ok) {
setAdminError(usersResult.error ?? projectsResult.error ?? 'Failed to load admin data.');
return;
}
setAdminUsers(usersResult.data);
setAdminProjects(projectsResult.data);
});
};
useEffect(() => {
if (authUser?.isAdmin) loadAdminData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authUser?.isAdmin]);
const handleDeleteUserAdmin = async (id, email) => {
if (!window.confirm(`Delete account "${email}"? Their projects are kept, not deleted, but become inaccessible until reassigned.`)) return;
const result = await adminDeleteUser(id);
if (result.ok) loadAdminData();
else alert(result.error ?? 'Failed to delete user.');
};
const handleDeleteProjectAdmin = async (id, title) => {
if (!window.confirm(`Permanently delete project "${title}"? This cannot be undone.`)) return;
const result = await adminDeleteProject(id);
if (result.ok) loadAdminData();
else alert(result.error ?? 'Failed to delete project.');
};
const handleViewProjectAdmin = async (id) => {
const result = await adminGetProject(id);
if (result.ok) setAdminViewProject(result.data);
else alert(result.error ?? 'Failed to load project.');
};
const handleResetPasswordAdmin = async (id, email) => {
if (!window.confirm(`Reset the password for "${email}"? They'll be signed out everywhere and need the new temporary password to log back in.`)) return;
const result = await adminResetPassword(id);
if (result.ok) setAdminResetResult({ email, temporaryPassword: result.data.temporaryPassword });
else alert(result.error ?? 'Failed to reset password.');
};
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">🛡 Admin</h1>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={goHome}
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
← Back
</button>
<AuthStatus />
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
<div className="flex gap-2">
<button type="button" onClick={() => setAdminTab('users')}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'users' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
Users ({adminUsers.length})
</button>
<button type="button" onClick={() => setAdminTab('projects')}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'projects' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
Projects ({adminProjects.length})
</button>
</div>
{adminLoading && <p className="text-sm text-slate-500">Loading…</p>}
{adminError && <p className="text-sm text-rose-600">{adminError}</p>}
{!adminLoading && !adminError && adminTab === 'users' && (
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel">
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-sm">
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th className="px-4 py-3">Email</th>
<th className="px-4 py-3">Joined</th>
<th className="px-4 py-3">2FA</th>
<th className="px-4 py-3">Projects</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{adminUsers.map((u) => (
<tr key={u.id} className="border-t border-slate-100">
<td className="px-4 py-3 font-medium text-slate-800">
{u.email}{u.id === authUser.id && <span className="ml-2 text-xs font-normal text-slate-400">(you)</span>}
</td>
<td className="px-4 py-3 text-slate-500">{new Date(u.createdAt).toLocaleDateString()}</td>
<td className="px-4 py-3 text-slate-500">{u.totpEnabled ? '✓' : '—'}</td>
<td className="px-4 py-3 text-slate-500">{u.projectCount}</td>
<td className="px-4 py-3 text-right">
{u.id !== authUser.id && (
<div className="flex justify-end gap-2">
<button type="button" onClick={() => handleResetPasswordAdmin(u.id, u.email)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
Reset Password
</button>
<button type="button" onClick={() => handleDeleteUserAdmin(u.id, u.email)}
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
Delete
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!adminLoading && !adminError && adminTab === 'projects' && (
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel">
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm">
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th className="px-4 py-3">Title</th>
<th className="px-4 py-3">Owner</th>
<th className="px-4 py-3">Passage</th>
<th className="px-4 py-3">Last edited</th>
<th className="px-4 py-3">Shared</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{adminProjects.map((p) => (
<tr key={p.id} className="border-t border-slate-100">
<td className="px-4 py-3 font-medium text-slate-800">{p.title}</td>
<td className="px-4 py-3 text-slate-500">
{p.ownerEmail ?? <span className="italic text-slate-400">orphaned</span>}
</td>
<td className="px-4 py-3 text-slate-500">{p.chapterSummary}</td>
<td className="px-4 py-3 text-slate-500">{new Date(p.lastEdited).toLocaleDateString()}</td>
<td className="px-4 py-3 text-slate-500">{p.shareToken ? '🔗' : '—'}</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-2">
<button type="button" onClick={() => handleViewProjectAdmin(p.id)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
View
</button>
<button type="button" onClick={() => handleDeleteProjectAdmin(p.id, p.title)}
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</main>
{adminViewProject && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setAdminViewProject(null)}>
<div className="h-full w-full max-w-4xl overflow-hidden rounded-2xl bg-white shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<p className="text-sm font-semibold text-slate-700">{adminViewProject.title}</p>
<button type="button" onClick={() => setAdminViewProject(null)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs text-slate-600 hover:bg-slate-50">
Close
</button>
</div>
<iframe
title="Project preview"
srcDoc={buildExportHtml(adminViewProject)}
sandbox="allow-popups"
className="h-[calc(100%-3rem)] w-full border-0"
/>
</div>
</div>
)}
{adminResetResult && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
<h3 className="text-sm font-semibold text-slate-900">Password reset</h3>
<p className="mt-1 text-xs text-slate-500">
Relay this to <span className="font-medium text-slate-700">{adminResetResult.email}</span> yourself
(text, call, in person) — it won't be shown again. They're signed out everywhere until they log in with it.
</p>
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-center font-mono text-lg tracking-wider text-amber-800">
{adminResetResult.temporaryPassword}
</p>
<button type="button" onClick={() => setAdminResetResult(null)}
className="mt-4 w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400">
Done
</button>
</div>
</div>
)}
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
export default function AuthPage() {
const { signIn } = useApp();
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register'
const [authForm, setAuthForm] = useState({ email: '', password: '' });
const [authError, setAuthError] = useState('');
const [authBusy, setAuthBusy] = useState(false);
const [authMfaPending, setAuthMfaPending] = useState(false); // password ok, waiting on TOTP/backup code
const [authMfaCode, setAuthMfaCode] = useState('');
const [authMfaUseBackup, setAuthMfaUseBackup] = useState(false);
const submitAuth = async (e) => {
e.preventDefault();
setAuthError('');
setAuthBusy(true);
const action = authMode === 'login' ? loginUser : registerUser;
const result = await action(authForm.email.trim(), authForm.password);
setAuthBusy(false);
if (!result.ok) {
setAuthError(result.error ?? 'Something went wrong.');
return;
}
if (result.data?.mfaRequired) {
setAuthMfaPending(true);
return;
}
signIn(result.data);
};
const submitMfa = async (e) => {
e.preventDefault();
setAuthError('');
setAuthBusy(true);
const result = await verifyMfaLogin(
authMfaUseBackup ? { backupCode: authMfaCode.trim() } : { token: authMfaCode.trim() },
);
setAuthBusy(false);
if (!result.ok) {
setAuthError(result.error ?? 'Invalid code.');
return;
}
setAuthMfaPending(false);
setAuthMfaCode('');
signIn(result.data);
};
if (authMfaPending) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4">
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 shadow-panel">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
<h1 className="mt-2 text-xl font-semibold text-slate-900">Two-factor verification</h1>
<p className="mt-2 text-sm text-slate-500">
{authMfaUseBackup
? 'Enter one of your saved backup codes.'
: 'Enter the 6-digit code from your authenticator app.'}
</p>
<form onSubmit={submitMfa} className="mt-6 space-y-4">
<input
type="text"
required
autoFocus
inputMode={authMfaUseBackup ? 'text' : 'numeric'}
placeholder={authMfaUseBackup ? 'xxxxxxxxxx' : '123456'}
value={authMfaCode}
onChange={(e) => setAuthMfaCode(e.target.value)}
className="block w-full rounded-xl border border-slate-300 px-3 py-2 text-center text-lg tracking-widest shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
{authError && <p className="text-sm text-rose-600">{authError}</p>}
<button
type="submit"
disabled={authBusy}
className="w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{authBusy ? 'Verifying…' : 'Verify'}
</button>
</form>
<button
type="button"
onClick={() => {
setAuthMfaUseBackup((v) => !v);
setAuthMfaCode('');
setAuthError('');
}}
className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700"
>
{authMfaUseBackup ? 'Use your authenticator app instead' : "Lost your device? Use a backup code"}
</button>
<button
type="button"
onClick={async () => {
await logoutUser();
setAuthMfaPending(false);
setAuthMfaCode('');
setAuthError('');
}}
className="mt-2 w-full text-center text-sm text-slate-400 underline hover:text-slate-600"
>
Cancel
</button>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4 py-12">
<div className="grid w-full max-w-5xl items-center gap-16 lg:grid-cols-2">
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */}
<div className="hidden lg:block">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
<h1 className="mt-4 text-3xl font-semibold leading-tight text-white">
Deep Bible study,<br />organized chunk by chunk.
</h1>
<p className="mt-4 max-w-sm text-slate-300">
Split any passage into chunks and work through Observation, Interpretation, and
Application notes alongside Greek &amp; Hebrew word studies, cross-references, and commentary.
</p>
<ul className="mt-8 space-y-4 text-sm text-slate-300">
<li className="flex items-center gap-3"><span className="text-lg">📖</span> Chunk-by-chunk OIA notes on any passage</li>
<li className="flex items-center gap-3"><span className="text-lg">🔤</span> Greek &amp; Hebrew word studies with pronunciation</li>
<li className="flex items-center gap-3"><span className="text-lg">🔗</span> Cross-references and commentary, one click away</li>
<li className="flex items-center gap-3"><span className="text-lg">🎙</span> Turn your notes into a podcast-ready script</li>
<li className="flex items-center gap-3"><span className="text-lg">🔒</span> Your studies stay private to your account, with optional 2FA</li>
</ul>
<blockquote className="mt-8 border-l-2 border-slate-700 pl-4 text-sm italic text-slate-400">
"Make every effort to present yourself approved to God, an unashamed workman who accurately
handles the word of truth."
<footer className="mt-1 not-italic text-slate-500">— 2 Timothy 2:15 (BSB)</footer>
</blockquote>
</div>
{/* Sign in / register form */}
<div className="flex justify-center lg:justify-start">
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 shadow-panel">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400 lg:hidden">Bible Study Project</p>
<h1 className="mt-2 text-xl font-semibold text-slate-900">
{authMode === 'login' ? 'Sign in' : 'Create an account'}
</h1>
<form onSubmit={submitAuth} className="mt-6 space-y-4">
<label className="block text-sm font-medium text-slate-700">
Email
<input
type="email"
required
autoComplete="email"
value={authForm.email}
onChange={(e) => setAuthForm((f) => ({ ...f, email: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<label className="block text-sm font-medium text-slate-700">
Password
<input
type="password"
required
minLength={8}
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
value={authForm.password}
onChange={(e) => setAuthForm((f) => ({ ...f, password: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
{authError && <p className="text-sm text-rose-600">{authError}</p>}
<button
type="submit"
disabled={authBusy}
className="w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
</button>
</form>
<button
type="button"
onClick={() => {
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
setAuthError('');
}}
className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700"
>
{authMode === 'login' ? "Need an account? Sign up" : 'Already have an account? Sign in'}
</button>
</div>
</div>
</div>
</div>
);
}
+452
View File
@@ -0,0 +1,452 @@
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
import { renderInkToCanvas } from '../utils/inkRender.js';
const INK_COLORS = ['#0f172a', '#ef4444', '#3b82f6', '#16a34a', '#f59e0b', '#a855f7'];
const INK_SIZES = [
{ label: 'XS', value: 0.003 },
{ label: 'S', value: 0.006 },
{ label: 'M', value: 0.012 },
{ label: 'L', value: 0.025 },
];
const PAGE_HEIGHT = 640; // px per notebook page
// The last pen settings, so toggling Draw mode off and on keeps your choice.
let lastPen = { tool: 'pen', color: INK_COLORS[0], size: INK_SIZES[0].value };
// Page count is persisted as a hidden metadata stroke at strokes[0]
// so the canvas size survives navigation without a separate state key.
function extractMeta(strokes) {
if (strokes.length > 0 && strokes[0]?._meta) {
return { pageCount: strokes[0].pageCount ?? 1, realStrokes: strokes.slice(1) };
}
return { pageCount: 1, realStrokes: strokes };
}
function packStrokes(realStrokes, pageCount) {
return [{ _meta: true, pageCount }, ...realStrokes];
}
// Standalone notebook-style draw canvas.
// strokes: array of saved stroke objects (may include a leading metadata object)
// onStrokesChange: (newStrokes) => void
// onDone: optional () => void — shows "Done" button when provided
// headerContent: optional JSX rendered above the notebook area.
// The canvas extends over it so you can draw directly on the content.
export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerContent }) {
const [drawTool, setDrawTool] = useState(lastPen.tool);
const [drawColor, setDrawColor] = useState(lastPen.color);
const [drawSize, setDrawSize] = useState(lastPen.size);
useEffect(() => { lastPen = { tool: drawTool, color: drawColor, size: drawSize }; }, [drawTool, drawColor, drawSize]);
const { pageCount: initPages } = extractMeta(strokes);
const [pageCount, setPageCount] = useState(initPages);
// realStrokes = strokes without the metadata header. Memoized so the canvas
// only redraws when the strokes actually change, not on every parent re-render.
const realStrokes = useMemo(() => extractMeta(strokes).realStrokes, [strokes]);
const canvasRef = useRef(null);
const hoverRef = useRef(null);
const activeStrokeRef = useRef([]);
const isDrawingRef = useRef(false);
// Refs prevent stale closures in stable callbacks
const strokesRef = useRef(realStrokes);
const onStrokesChangeRef = useRef(onStrokesChange);
const drawToolRef = useRef(drawTool);
const drawColorRef = useRef(drawColor);
const drawSizeRef = useRef(drawSize);
strokesRef.current = realStrokes;
onStrokesChangeRef.current = onStrokesChange;
const pageCountRef = useRef(pageCount);
pageCountRef.current = pageCount;
drawToolRef.current = drawTool;
drawColorRef.current = drawColor;
drawSizeRef.current = drawSize;
const render = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
renderInkToCanvas(
canvas,
strokesRef.current,
activeStrokeRef.current,
drawToolRef.current,
drawColorRef.current,
drawSizeRef.current,
);
}, []);
useEffect(() => { render(); }, [realStrokes, drawTool, drawColor, drawSize, render]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const obs = new ResizeObserver(render);
obs.observe(canvas);
return () => obs.disconnect();
}, [render]);
// Hover cursor — reads refs so no stale-closure risk; no React re-renders on each move
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
function onMove(e) {
if (e.pointerType !== 'pen') return;
const el = hoverRef.current;
if (!el) return;
if (e.pressure > 0) { el.style.display = 'none'; return; }
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const sizePx = Math.max(6, drawSizeRef.current * rect.height);
const tool = drawToolRef.current;
const color = tool === 'eraser' ? '#94a3b8' : drawColorRef.current;
el.style.display = 'block';
el.style.width = `${sizePx}px`;
el.style.height = `${sizePx}px`;
el.style.left = `${x}px`;
el.style.top = `${y}px`;
el.style.borderColor = color;
el.style.borderRadius = tool === 'eraser' ? '2px' : '50%';
}
function onLeave() {
if (hoverRef.current) hoverRef.current.style.display = 'none';
}
canvas.addEventListener('pointermove', onMove);
canvas.addEventListener('pointerleave', onLeave);
return () => {
canvas.removeEventListener('pointermove', onMove);
canvas.removeEventListener('pointerleave', onLeave);
};
}, []);
const getPoint = useCallback((e) => {
const canvas = canvasRef.current;
if (!canvas) return [0, 0, 0.5, 0];
const rect = canvas.getBoundingClientRect();
// tiltX/tiltY range ±90°; normalize to 0-1 where 1 = fully flat (60° threshold)
const tilt = e.pointerType === 'pen'
? Math.min(Math.hypot(e.tiltX || 0, e.tiltY || 0) / 60, 1)
: 0;
return [
(e.clientX - rect.left) / rect.width,
(e.clientY - rect.top) / rect.height,
e.pressure ?? 0.5,
tilt,
];
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
function commitStroke() {
const pts = activeStrokeRef.current;
if (drawToolRef.current !== 'eraser' && pts.length > 1) {
onStrokesChangeRef.current(packStrokes(
[
...strokesRef.current,
{
id: crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`,
tool: drawToolRef.current,
color: drawColorRef.current,
size: drawSizeRef.current,
points: pts,
},
],
pageCountRef.current,
));
}
activeStrokeRef.current = [];
isDrawingRef.current = false;
render();
}
function eraseAt(pt) {
const [ex, ey] = pt;
const r = drawSizeRef.current * 3;
const remaining = strokesRef.current.filter(
(s) => !s.points.some(([sx, sy]) => Math.hypot(sx - ex, sy - ey) < r),
);
if (remaining.length !== strokesRef.current.length)
onStrokesChangeRef.current(packStrokes(remaining, pageCountRef.current));
}
// iOS Safari: Apple Pencil fires Touch Events with touchType === 'stylus'.
// Pointer Events on iPadOS emit pointercancel immediately after pointerdown
// (palm detection or canvas-width resets from React re-renders), which forces
// a double-tap to start every stroke. Touch Events bypass this entirely.
if (typeof Touch !== 'undefined' && 'touchType' in Touch.prototype) {
function pointFromTouch(t) {
const rect = canvas.getBoundingClientRect();
// radiusX grows as the pencil tilts flat; ~10px radius ≈ fully tilted
const tilt = Math.min((t.radiusX || 0) / 10, 1);
return [
(t.clientX - rect.left) / rect.width,
(t.clientY - rect.top) / rect.height,
t.force ?? 0.5,
tilt,
];
}
function tStart(e) {
// Two-finger tap (both non-stylus) = undo last stroke
if (e.touches.length === 2 && Array.from(e.touches).every((t) => t.touchType !== 'stylus')) {
e.preventDefault();
if (strokesRef.current.length > 0) {
onStrokesChangeRef.current(packStrokes(strokesRef.current.slice(0, -1), pageCountRef.current));
render();
}
return;
}
for (const t of e.changedTouches) {
if (t.touchType !== 'stylus') continue;
e.preventDefault();
if (hoverRef.current) hoverRef.current.style.display = 'none';
isDrawingRef.current = true;
activeStrokeRef.current = [pointFromTouch(t)];
render();
return;
}
}
function tMove(e) {
if (!isDrawingRef.current) return;
for (const t of e.changedTouches) {
if (t.touchType !== 'stylus') continue;
e.preventDefault();
const pt = pointFromTouch(t);
activeStrokeRef.current = [...activeStrokeRef.current, pt];
if (drawToolRef.current === 'eraser') eraseAt(pt);
render();
return;
}
}
function tEnd(e) {
if (!isDrawingRef.current) return;
for (const t of e.changedTouches) {
if (t.touchType !== 'stylus') continue;
e.preventDefault();
commitStroke();
return;
}
}
canvas.addEventListener('touchstart', tStart, { passive: false });
canvas.addEventListener('touchmove', tMove, { passive: false });
canvas.addEventListener('touchend', tEnd, { passive: false });
canvas.addEventListener('touchcancel', tEnd, { passive: false });
return () => {
canvas.removeEventListener('touchstart', tStart);
canvas.removeEventListener('touchmove', tMove);
canvas.removeEventListener('touchend', tEnd);
canvas.removeEventListener('touchcancel', tEnd);
};
}
// Non-iOS: Pointer Events (mouse, Windows pen, etc.)
function down(e) {
if (e.pointerType === 'touch') return;
isDrawingRef.current = true;
activeStrokeRef.current = [getPoint(e)];
render();
}
function move(e) {
if (e.pointerType === 'touch') return;
if (!isDrawingRef.current) return;
e.preventDefault();
const pt = getPoint(e);
activeStrokeRef.current = [...activeStrokeRef.current, pt];
if (drawToolRef.current === 'eraser') eraseAt(pt);
render();
}
function up(e) {
if (e.pointerType === 'touch') return;
if (!isDrawingRef.current) return;
commitStroke();
}
canvas.addEventListener('pointerdown', down);
canvas.addEventListener('pointermove', move, { passive: false });
canvas.addEventListener('pointerup', up);
canvas.addEventListener('pointercancel', up);
return () => {
canvas.removeEventListener('pointerdown', down);
canvas.removeEventListener('pointermove', move);
canvas.removeEventListener('pointerup', up);
canvas.removeEventListener('pointercancel', up);
};
}, [getPoint, render]);
const undo = () =>
realStrokes.length > 0 &&
onStrokesChange(packStrokes(realStrokes.slice(0, -1), pageCount));
const clear = () =>
realStrokes.length > 0 &&
window.confirm('Clear all ink notes?') &&
onStrokesChange(packStrokes([], pageCount));
// Add another notebook page below existing content.
// Rescales all stroke y-coords so existing ink stays at the same pixel position.
const addSpace = () => {
const newCount = pageCount + 1;
const ratio = pageCount / newCount;
const rescaled = realStrokes.map((s) => ({
...s,
points: s.points.map(([x, y, p]) => [x, y * ratio, p]),
}));
onStrokesChange(packStrokes(rescaled, newCount));
setPageCount(newCount);
};
return (
<div className="overflow-hidden rounded-3xl border border-slate-200 shadow-sm select-none" style={{ WebkitUserSelect: 'none' }}>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-1 border-b border-slate-200 bg-white px-3 py-2">
{[
{ id: 'pen', label: '✒', title: 'Pen' },
{ id: 'highlighter', label: '▐', title: 'Highlighter' },
{ id: 'eraser', label: '⌫', title: 'Eraser' },
].map((t) => (
<button
key={t.id}
type="button"
title={t.title}
onClick={() => setDrawTool(t.id)}
className={`flex h-8 w-8 items-center justify-center rounded-lg text-sm font-bold transition ${
drawTool === t.id ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
}`}
>
{t.label}
</button>
))}
<div className="mx-1 h-5 w-px bg-slate-200" />
{INK_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => { setDrawColor(c); if (drawTool === 'eraser') setDrawTool('pen'); }}
style={{ background: c }}
className={`h-5 w-5 rounded-full border-2 transition ${
drawColor === c && drawTool !== 'eraser' ? 'scale-125 border-slate-900' : 'border-white shadow-sm'
}`}
/>
))}
<div className="mx-1 h-5 w-px bg-slate-200" />
{INK_SIZES.map((s) => (
<button
key={s.label}
type="button"
onClick={() => setDrawSize(s.value)}
className={`flex h-8 w-8 items-center justify-center rounded-lg text-xs font-bold transition ${
drawSize === s.value ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
}`}
>
{s.label}
</button>
))}
<div className="mx-1 h-5 w-px bg-slate-200" />
<button
type="button"
onClick={undo}
disabled={realStrokes.length === 0}
className="rounded-lg px-2.5 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-100 disabled:opacity-40"
>
Undo
</button>
<button
type="button"
onClick={clear}
disabled={realStrokes.length === 0}
className="rounded-lg px-2.5 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-40"
>
Clear
</button>
{onDone && (
<>
<div className="mx-1 h-5 w-px bg-slate-200" />
<button
type="button"
onClick={onDone}
className="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-slate-800"
>
Done ✓
</button>
</>
)}
</div>
{/* Draw surface: optional scripture header + ruled notebook, one canvas over both */}
<div className="relative">
{/* Scripture content — canvas sits on top so you can draw directly on the text */}
{headerContent && (
<div
className="border-b border-slate-200 bg-slate-50 p-4 font-serif text-sm leading-relaxed text-slate-800"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{headerContent}
</div>
)}
{/* Ruled notebook area — height grows with pageCount */}
<div
style={{
minHeight: PAGE_HEIGHT * pageCount,
background: 'white',
backgroundImage: 'repeating-linear-gradient(transparent 0px, transparent 31px, #dde3ec 31px, #dde3ec 32px)',
}}
/>
{/* Red margin line — only in pure-notebook mode */}
{!headerContent && (
<div className="absolute bottom-0 left-10 top-0 w-px bg-rose-200" style={{ zIndex: 1 }} />
)}
{/* Hover cursor — positioned by the pointermove handler, never by React */}
<div
ref={hoverRef}
style={{
display: 'none',
position: 'absolute',
pointerEvents: 'none',
zIndex: 3,
border: '1.5px solid',
boxSizing: 'border-box',
transform: 'translate(-50%, -50%)',
opacity: 0.7,
}}
/>
{/* Single canvas spanning the entire area (scripture + notebook) */}
<canvas
ref={canvasRef}
className="absolute inset-0 h-full w-full"
style={{
zIndex: 2,
cursor: drawTool === 'eraser' ? 'cell' : 'crosshair',
touchAction: 'none',
}}
/>
</div>
{/* Add more notebook space */}
<button
type="button"
onClick={addSpace}
className="w-full border-t border-slate-200 bg-slate-50 py-3 text-xs font-semibold text-slate-500 transition hover:bg-slate-100 hover:text-slate-700"
>
+ Add More Space
</button>
</div>
);
}
+511
View File
@@ -0,0 +1,511 @@
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import AuthStatus from '../components/AuthStatus.jsx';
import {
bookOptions,
CHAPTER_COUNTS,
formatChunkReference,
formatRelativeDate,
loadProjectIndex,
} from '../utils/bibleUtils.jsx';
import { loadProjectById } from '../utils/projectStorage.js';
export default function HomePage() {
const {
autoRestoredCount,
staleLocalProjects,
projectIndex,
readingPlan,
createReadingPlan,
clearReadingPlan,
audioBook, setAudioBook,
audioNarrator, setAudioNarrator,
audioState,
openBibleReader,
openImportProject,
openNewProject,
pullLatestFromServer,
resumeProject,
renameProjectInStorage,
deleteProject,
handlePlayBookAudio,
handleStopBookAudio,
handleToggleBookAudioPause,
} = useApp();
const [homeSearch, setHomeSearch] = useState('');
const [homeSort, setHomeSort] = useState(() => {
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
}); // 'recent' | 'title' | 'passage'
const [homeTagFilter, setHomeTagFilter] = useState('');
const [homeFullTextResults, setHomeFullTextResults] = useState(null); // null=idle, []=no match, [...]= results
const fullTextTimerRef = useRef(null);
const [renamingId, setRenamingId] = useState(null);
const [renameValue, setRenameValue] = useState('');
useEffect(() => {
try { localStorage.setItem('homeSort', homeSort); } catch {}
}, [homeSort]);
// Full-text search across all project notes (debounced, synchronous localStorage read)
useEffect(() => {
clearTimeout(fullTextTimerRef.current);
const q = homeSearch.trim().toLowerCase();
if (q.length < 3) { setHomeFullTextResults(null); return; }
fullTextTimerRef.current = window.setTimeout(() => {
const index = loadProjectIndex();
const results = [];
for (const entry of index) {
const proj = loadProjectById(entry.id);
if (!proj) continue;
const matches = [];
for (const [chIdx, ch] of (proj.chapters ?? []).entries()) {
for (const chunk of (ch.chunks ?? [])) {
for (const field of ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript']) {
const text = (chunk[field] ?? '').toLowerCase();
if (!text.includes(q)) continue;
const idx = text.indexOf(q);
const raw = chunk[field] ?? '';
const start = Math.max(0, idx - 40);
const snippet = (start > 0 ? '…' : '') + raw.slice(start, idx + q.length + 60) + (idx + q.length + 60 < raw.length ? '…' : '');
matches.push({ chunkId: chunk.id, ref: formatChunkReference(proj, chIdx, chunk, '–'), field, snippet });
break; // one match per chunk
}
}
}
if (matches.length > 0) results.push({ projectId: entry.id, projectTitle: entry.title, matches });
}
setHomeFullTextResults(results);
}, 300);
}, [homeSearch]);
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-1 text-2xl font-semibold">My Studies</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={openBibleReader}
className="rounded-xl border border-slate-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
>
📖 Read Bible
</button>
<button
type="button"
onClick={openImportProject}
className="rounded-xl border border-slate-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
>
<span className="hidden sm:inline">📥 Import Session List</span>
<span className="sm:hidden">📥 Import</span>
</button>
<button
type="button"
onClick={openNewProject}
className="rounded-xl bg-slate-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-600"
>
+ New Project
</button>
<AuthStatus />
</div>
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-4 sm:py-8 sm:px-6 lg:px-8">
{autoRestoredCount !== null && (
<div className="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
<p className="text-sm font-semibold text-emerald-800">
📥 Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.
</p>
</div>
)}
{staleLocalProjects.length > 0 && (
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-4">
<p className="mb-3 text-sm font-semibold text-amber-800">
☁️ {staleLocalProjects.length} project{staleLocalProjects.length > 1 ? 's have' : ' has'} a newer version on the server:
</p>
<div className="flex flex-wrap gap-2">
{staleLocalProjects.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => pullLatestFromServer(entry.id)}
className="rounded-xl bg-amber-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-amber-600"
>
Pull latest "{entry.title}"
</button>
))}
</div>
</div>
)}
{projectIndex.length === 0 ? (
<div className="mx-auto max-w-xl rounded-3xl border border-dashed border-slate-300 bg-white p-10 text-center shadow-panel">
<p className="text-lg font-semibold text-slate-700">No projects yet</p>
<p className="mt-2 text-sm text-slate-500">Start a new Bible study to get going.</p>
<button
type="button"
onClick={openNewProject}
className="mt-6 rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800"
>
+ New Project
</button>
</div>
) : (
<>
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<input
type="text"
value={homeSearch}
onChange={(e) => setHomeSearch(e.target.value)}
placeholder="Search projects by title or passage…"
className="w-full max-w-sm rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
<label className="text-sm text-slate-600">
Sort by{' '}
<select
value={homeSort}
onChange={(e) => setHomeSort(e.target.value)}
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
<option value="recent">Last edited</option>
<option value="title">Title</option>
<option value="passage">Passage</option>
</select>
</label>
{(() => {
const allTags = Array.from(
new Set(projectIndex.flatMap((entry) => entry.tags ?? [])),
).sort((a, b) => a.localeCompare(b));
if (allTags.length === 0) return null;
return (
<label className="text-sm text-slate-600">
Tag{' '}
<select
value={homeTagFilter}
onChange={(e) => setHomeTagFilter(e.target.value)}
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
<option value="">All tags</option>
{allTags.map((tag) => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
</label>
);
})()}
</div>
{/* Full-text note search results */}
{homeFullTextResults !== null && (
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
<p className="mb-3 text-sm font-semibold text-slate-700">
{homeFullTextResults.length === 0
? 'No notes match your search.'
: `Notes matching "${homeSearch.trim()}" — ${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0)} result${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0) !== 1 ? 's' : ''} across ${homeFullTextResults.length} project${homeFullTextResults.length !== 1 ? 's' : ''}`}
</p>
<div className="space-y-4">
{homeFullTextResults.map(({ projectId, projectTitle, matches }) => (
<div key={projectId}>
<button
type="button"
onClick={() => resumeProject(projectId)}
className="mb-1.5 text-sm font-semibold text-violet-700 hover:underline"
>
{projectTitle} →
</button>
<div className="space-y-1.5">
{matches.map(({ chunkId, ref, field, snippet }) => (
<div key={chunkId} className="rounded-2xl bg-slate-50 px-3 py-2">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">{ref} · {field}</span>
<p className="mt-0.5 text-sm text-slate-700 leading-snug">{snippet}</p>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* Reading plan card */}
<ReadingPlanCard readingPlan={readingPlan} createReadingPlan={createReadingPlan} clearReadingPlan={clearReadingPlan} openBibleReader={openBibleReader} />
<div className="mb-4 flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1">
<h3 className="text-base font-semibold text-slate-900">Listen to BSB Audio</h3>
<p className="mt-1 text-sm text-slate-600">
{audioState.status === 'idle' || audioState.status === 'error'
? 'Play a full book of the Berean Standard Bible.'
: `Playing ${bookOptions.find((b) => b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}`}
</p>
{audioState.status === 'error' && (
<p className="mt-1 text-sm text-rose-600">Couldn't load audio for this book/narrator.</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={audioBook}
onChange={(e) => setAudioBook(e.target.value)}
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
>
{bookOptions.map((book) => (
<option key={book.abbrev} value={book.abbrev}>{book.name}</option>
))}
</select>
<select
value={audioNarrator}
onChange={(e) => setAudioNarrator(e.target.value)}
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
>
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
</select>
{audioState.status === 'playing' || audioState.status === 'paused' ? (
<>
<button
type="button"
onClick={handleToggleBookAudioPause}
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500"
>
{audioState.status === 'paused' ? 'Resume' : 'Pause'}
</button>
<button
type="button"
onClick={handleStopBookAudio}
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"
>
Stop
</button>
</>
) : (
<button
type="button"
onClick={handlePlayBookAudio}
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500"
>
Play book
</button>
)}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projectIndex
.slice()
.filter((entry) => {
if (homeTagFilter && !(entry.tags ?? []).includes(homeTagFilter)) return false;
const q = homeSearch.trim().toLowerCase();
if (!q) return true;
return entry.title?.toLowerCase().includes(q)
|| entry.chapterSummary?.toLowerCase().includes(q);
})
.sort((a, b) => {
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
})
.map((entry) => (
<div
key={entry.id}
className="flex flex-col gap-4 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel"
>
<div className="flex-1">
{renamingId === entry.id ? (
<div className="flex items-center gap-2">
<input
type="text"
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const trimmed = renameValue.trim();
if (trimmed) renameProjectInStorage(entry.id, trimmed);
setRenamingId(null);
} else if (e.key === 'Escape') {
setRenamingId(null);
}
}}
className="flex-1 rounded-lg border border-slate-300 px-2 py-1 text-base font-semibold text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
<button
type="button"
onClick={() => {
const trimmed = renameValue.trim();
if (trimmed) renameProjectInStorage(entry.id, trimmed);
setRenamingId(null);
}}
className="text-sm font-semibold text-emerald-600 hover:text-emerald-700"
>
Save
</button>
<button
type="button"
onClick={() => setRenamingId(null)}
className="text-sm text-slate-400 hover:text-slate-600"
>
×
</button>
</div>
) : (
<div className="flex items-start justify-between gap-2">
<h2 className="text-base font-semibold text-slate-900">{entry.title}</h2>
<button
type="button"
onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }}
className="shrink-0 text-xs text-slate-400 hover:text-slate-600"
title="Rename project"
>
✎ Rename
</button>
</div>
)}
{entry.chapterSummary && (
<p className="mt-1 text-sm text-slate-500">{entry.chapterSummary}</p>
)}
{entry.tags?.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{entry.tags.map((tag) => (
<span key={tag} className="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-semibold text-indigo-700">
{tag}
</span>
))}
</div>
)}
<p className="mt-1 text-xs text-slate-400">{formatRelativeDate(entry.lastEdited)}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => resumeProject(entry.id)}
className="flex-1 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
>
Resume
</button>
<button
type="button"
onClick={() => deleteProject(entry.id)}
className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 transition hover:bg-rose-100"
>
Delete
</button>
</div>
</div>
))}
</div>
</>
)}
</main>
</div>
);
}
function ReadingPlanCard({ readingPlan, createReadingPlan, clearReadingPlan, openBibleReader }) {
const [showForm, setShowForm] = useState(false);
const [planBook, setPlanBook] = useState(bookOptions[0].abbrev);
const [planWeeks, setPlanWeeks] = useState(4);
if (readingPlan) {
const pct = readingPlan.totalChapters > 0
? Math.round((readingPlan.chaptersRead.length / readingPlan.totalChapters) * 100)
: 0;
const daysLeft = Math.max(0, Math.ceil((readingPlan.targetDate - Date.now()) / 86400000));
const chapLeft = readingPlan.totalChapters - readingPlan.chaptersRead.length;
const paceNeeded = daysLeft > 0 ? (chapLeft / daysLeft).toFixed(1) : '—';
return (
<div className="mb-4 rounded-3xl border border-emerald-200 bg-emerald-50 p-5 shadow-panel">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-600">Reading Plan</p>
<h3 className="mt-1 text-base font-semibold text-slate-900">{readingPlan.bookName}</h3>
<p className="mt-0.5 text-sm text-slate-600">
{readingPlan.chaptersRead.length} / {readingPlan.totalChapters} chapters · {daysLeft} day{daysLeft !== 1 ? 's' : ''} left · {paceNeeded} ch/day needed
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={openBibleReader}
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
>
Read
</button>
<button
type="button"
onClick={() => { if (window.confirm('Clear reading plan?')) clearReadingPlan(); }}
className="rounded-xl border border-emerald-300 px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
>
Clear
</button>
</div>
</div>
<div className="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-emerald-200">
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} />
</div>
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
{readingPlan.chaptersRead.length > 0 && (
<p className="mt-1 text-xs text-slate-500">
Read: ch. {readingPlan.chaptersRead.slice(0, 12).join(', ')}{readingPlan.chaptersRead.length > 12 ? '…' : ''}
</p>
)}
</div>
);
}
return (
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
{showForm ? (
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm font-semibold text-slate-700">Read</span>
<select
value={planBook}
onChange={(e) => setPlanBook(e.target.value)}
className="rounded-xl border border-slate-300 bg-slate-50 px-2 py-1.5 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{bookOptions.map((b) => (
<option key={b.abbrev} value={b.abbrev}>{b.name} ({CHAPTER_COUNTS[b.abbrev] ?? '?'} ch)</option>
))}
</select>
<span className="text-sm text-slate-600">in</span>
<select
value={planWeeks}
onChange={(e) => setPlanWeeks(Number(e.target.value))}
className="rounded-xl border border-slate-300 bg-slate-50 px-2 py-1.5 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{[1,2,3,4,6,8,12,16,26,52].map((w) => <option key={w} value={w}>{w} week{w !== 1 ? 's' : ''}</option>)}
</select>
<button
type="button"
onClick={() => {
const book = bookOptions.find((b) => b.abbrev === planBook);
createReadingPlan(planBook, book?.name ?? planBook, planWeeks);
setShowForm(false);
}}
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
>
Start plan
</button>
<button type="button" onClick={() => setShowForm(false)} className="text-sm text-slate-400 hover:text-slate-600">Cancel</button>
</div>
) : (
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-700">Reading Plan</p>
<p className="text-xs text-slate-500">Set a goal to read through a book, track chapters as you go.</p>
</div>
<button
type="button"
onClick={() => setShowForm(true)}
className="shrink-0 rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"
>
Set goal
</button>
</div>
)}
</div>
);
}
+281
View File
@@ -0,0 +1,281 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import AuthStatus from '../components/AuthStatus.jsx';
import { useAvailableTranslations } from '../hooks/useAvailableTranslations.js';
import { bookOptions, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
import { parseEpisodeListDocx, parseEpisodePassage } from '../utils/importParsers.js';
export default function ImportPage() {
const { setCurrentPage, setProject } = useApp();
const availableTranslations = useAvailableTranslations();
const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev);
const [importTranslation, setImportTranslation] = useState('BSB');
const [importTitle, setImportTitle] = useState('');
const [importPreview, setImportPreview] = useState(null); // parsed episode specs, before fetching chapters
const [importBusy, setImportBusy] = useState(false);
const [importError, setImportError] = useState('');
const handleImportFileChange = async (file) => {
setImportPreview(null);
setImportError('');
if (!file) return;
setImportBusy(true);
try {
const episodes = await parseEpisodeListDocx(file);
if (episodes.length === 0) {
throw new Error('No "Session # / Title / Passage" table found in that document.');
}
const specs = episodes.map((ep) => ({ ...ep, parsed: parseEpisodePassage(ep.passage) }));
setImportPreview(specs);
} catch (error) {
setImportError(error.message || 'Failed to read that document.');
} finally {
setImportBusy(false);
}
};
const runEpisodeImport = async () => {
if (!importPreview || importPreview.length === 0) {
setImportError('Choose a .docx file first.');
return;
}
setImportBusy(true);
setImportError('');
try {
const selectedBook = bookOptions.find((b) => b.abbrev === importBookAbbrev) ?? bookOptions[0];
const chapterNumbersNeeded = new Set();
importPreview.forEach((spec) => {
if (spec.parsed && spec.parsed !== 'invalid') {
chapterNumbersNeeded.add(spec.parsed.startChapter);
chapterNumbersNeeded.add(spec.parsed.endChapter);
}
});
const sortedChapters = Array.from(chapterNumbersNeeded).sort((a, b) => a - b);
if (sortedChapters.length === 0) {
throw new Error('No verse passages found to import.');
}
const versesByChapter = {};
for (const chNum of sortedChapters) {
const response = await fetch(
`https://bible.helloao.org/api/${importTranslation}/${selectedBook.abbrev}/${chNum}.json`,
);
if (!response.ok) throw new Error(`Unable to load ${selectedBook.name} ${chNum}.`);
const data = await response.json();
const verses = parseBibleChapter(data);
if (!verses.length) throw new Error(`No verses returned for ${selectedBook.name} ${chNum}.`);
versesByChapter[chNum] = verses;
}
const chapterIndexByNum = new Map(sortedChapters.map((n, idx) => [n, idx]));
const chapters = sortedChapters.map((chNum) => ({
book: selectedBook.name,
bookAbbrev: selectedBook.abbrev,
chapter: chNum,
verses: versesByChapter[chNum],
chunks: [],
}));
// Markers (rows with no passage) attach to the nearest upcoming chapter with a
// real passage, or fall back to the previous one for trailing markers.
const anchorChapterFor = importPreview.map((spec, i) => {
if (spec.parsed && spec.parsed !== 'invalid') return spec.parsed.startChapter;
for (let j = i + 1; j < importPreview.length; j += 1) {
const next = importPreview[j].parsed;
if (next && next !== 'invalid') return next.startChapter;
}
for (let j = i - 1; j >= 0; j -= 1) {
const prev = importPreview[j].parsed;
if (prev && prev !== 'invalid') return prev.startChapter;
}
return sortedChapters[0];
});
importPreview.forEach((spec, i) => {
if (spec.parsed === 'invalid') return; // skip unparseable rows entirely
const chIdx = chapterIndexByNum.get(anchorChapterFor[i]);
if (chIdx == null) return;
const target = chapters[chIdx];
const baseChunk = {
id: makeId(),
observation: '',
interpretation: '',
application: '',
crossReferences: [],
greekWords: [],
generalNotes: '',
episodeNumber: spec.episodeNumber,
episodeTitle: spec.title,
finalScript: '',
tags: [],
};
if (!spec.parsed) {
// No passage (e.g. a part intro/review) -> zero-length marker chunk.
target.chunks.push({ ...baseChunk, startVerse: 0, endVerse: 0, spilloverEndVerse: null });
return;
}
const { startChapter, startVerse, endChapter, endVerse } = spec.parsed;
if (endChapter === startChapter) {
target.chunks.push({ ...baseChunk, startVerse, endVerse, spilloverEndVerse: null });
} else {
const lastVerseOfStartChapter = versesByChapter[startChapter]?.at(-1)?.number ?? endVerse;
target.chunks.push({
...baseChunk,
startVerse,
endVerse: lastVerseOfStartChapter,
spilloverEndVerse: endVerse,
});
}
});
const newProject = {
id: makeId(),
title: importTitle.trim() || `${selectedBook.name} Sessions`,
translation: importTranslation,
lastEdited: Date.now(),
selectedChunkId: null,
chapters,
};
setProject(newProject);
setCurrentPage('setup');
} catch (error) {
setImportError(error.message || 'Import failed.');
} finally {
setImportBusy(false);
}
};
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">Import Session List</h1>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setCurrentPage('home')}
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
← Back
</button>
<AuthStatus />
</div>
</div>
</header>
<main className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-5">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600 space-y-2">
<p className="font-semibold text-slate-700">How this works</p>
<p>
This is a shortcut for setting up a multi-part study — a teaching series, sermon series, class
curriculum, or podcast — all at once, instead of building each chapter and chunk by hand.
</p>
<p>
Upload a .docx containing a table with three columns: session number, title, and passage
(e.g. <span className="font-mono text-xs">1:1–2</span>). Each row becomes one chunk, grouped
automatically by chapter. If you don't have a document like this, just use{' '}
<span className="font-semibold">+ New Project</span> on the home page instead — this import
step is entirely optional.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block text-sm font-medium text-slate-700">
Book
<select
value={importBookAbbrev}
onChange={(e) => setImportBookAbbrev(e.target.value)}
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{bookOptions.map((b) => (
<option key={b.abbrev} value={b.abbrev}>{b.name}</option>
))}
</select>
</label>
<label className="block text-sm font-medium text-slate-700">
Translation
<select
value={importTranslation}
onChange={(e) => setImportTranslation(e.target.value)}
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{availableTranslations.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</label>
</div>
<label className="block text-sm font-medium text-slate-700">
Project title
<input
type="text"
value={importTitle}
onChange={(e) => setImportTitle(e.target.value)}
placeholder={`${bookOptions.find((b) => b.abbrev === importBookAbbrev)?.name ?? ''} Sessions`}
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<label className="block text-sm font-medium text-slate-700">
Session list (.docx)
<input
type="file"
accept=".docx"
onChange={(e) => handleImportFileChange(e.target.files?.[0] ?? null)}
className="mt-1 block w-full text-sm text-slate-600 file:mr-4 file:rounded-xl file:border-0 file:bg-slate-700 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-white hover:file:bg-slate-600"
/>
</label>
{importBusy && <p className="text-sm text-slate-500">Working…</p>}
{importError && <p className="text-sm text-rose-600">{importError}</p>}
{importPreview && !importBusy && (
<div className="space-y-3">
<p className="text-sm font-semibold text-slate-700">
{importPreview.length} session{importPreview.length === 1 ? '' : 's'} found
{' · '}
{importPreview.filter((s) => s.parsed && s.parsed !== 'invalid').length} with passages
</p>
<div className="max-h-80 overflow-y-auto rounded-xl border border-slate-200">
<table className="w-full text-sm">
<tbody>
{importPreview.map((spec) => (
<tr key={spec.episodeNumber} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-1.5 text-slate-500">#{spec.episodeNumber}</td>
<td className="px-3 py-1.5 text-slate-900">{spec.title}</td>
<td className="px-3 py-1.5 text-right text-slate-500">
{spec.parsed === 'invalid'
? <span className="text-rose-600">unrecognized — skipped</span>
: spec.parsed
? spec.passage
: <span className="text-slate-400">marker (no passage)</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
onClick={runEpisodeImport}
disabled={importBusy}
className="rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:opacity-50"
>
Create Project from Import
</button>
</div>
)}
</section>
</main>
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { buildPronunciationGuide } from '../utils/bibleUtils.jsx';
import { speakOriginalWord } from '../utils/wordStudy.js';
export default function PronunciationPage() {
const { project, setCurrentPage } = useApp();
const [search, setSearch] = useState('');
const [copied, setCopied] = useState(false);
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
// Collect all words with chapter/chunk context, deduplicated by Strong's number
const seen = new Set();
const entries = [];
chapters.forEach((ch) => {
ch.chunks.forEach((chunk) => {
(chunk.greekWords ?? []).forEach((word) => {
if (!word.lexeme && !word.transliteration) return;
const key = word.strongNumber || `${word.lexeme}__${word.transliteration}`;
if (seen.has(key)) return;
seen.add(key);
entries.push({ word, chapterRef: `${ch.book} ${ch.chapter}` });
});
});
});
const q = search.trim().toLowerCase();
const filtered = q
? entries.filter(({ word }) =>
(word.englishGloss ?? '').toLowerCase().includes(q) ||
(word.shortDefinition ?? '').toLowerCase().includes(q) ||
(word.lexeme ?? '').toLowerCase().includes(q) ||
(word.transliteration ?? '').toLowerCase().includes(q) ||
(word.strongNumber ?? '').toLowerCase().includes(q),
)
: entries;
const greek = filtered.filter(({ word }) => !word.strongNumber?.startsWith('H'));
const hebrew = filtered.filter(({ word }) => word.strongNumber?.startsWith('H'));
const copyAll = () => {
const guide = buildPronunciationGuide(project);
navigator.clipboard.writeText(guide).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="min-h-screen bg-slate-50">
<div className="mx-auto max-w-4xl px-4 py-8">
{/* Page header */}
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">Pronunciation Guide</h1>
{project?.title && (
<p className="mt-1 text-sm text-slate-500">{project.title}</p>
)}
<p className="mt-1 text-xs text-slate-400">
{entries.length} unique word{entries.length !== 1 ? 's' : ''} across all chapters
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={copyAll}
className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
>
{copied ? '✓ Copied!' : '📋 Copy all'}
</button>
<button
type="button"
onClick={() => setCurrentPage('study')}
className="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-slate-400"
>
← Back to Study
</button>
</div>
</div>
{/* Search */}
<div className="mb-6">
<input
type="text"
placeholder="Filter by word, gloss, Strong's number…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</div>
{entries.length === 0 ? (
<div className="rounded-3xl border border-dashed border-slate-300 bg-white p-12 text-center">
<p className="text-2xl">🔤</p>
<p className="mt-3 font-semibold text-slate-700">No words yet</p>
<p className="mt-1 text-sm text-slate-500">
Add Greek or Hebrew words in the Word Studies panel on the Study page.
</p>
</div>
) : filtered.length === 0 ? (
<p className="py-8 text-center text-sm text-slate-500">No words match "{search}"</p>
) : (
<div className="space-y-10">
{greek.length > 0 && <WordGroup title="Greek (New Testament)" color="sky" words={greek} onSpeak={speakOriginalWord} />}
{hebrew.length > 0 && <WordGroup title="Hebrew (Old Testament)" color="emerald" words={hebrew} onSpeak={speakOriginalWord} />}
</div>
)}
</div>
</div>
);
}
function WordGroup({ title, color, words, onSpeak }) {
const accent = {
sky: { badge: 'bg-sky-100 text-sky-700 border-sky-200', speak: 'bg-sky-100 text-sky-700 hover:bg-sky-200', heading: 'text-sky-800 border-sky-200 bg-sky-50' },
emerald: { badge: 'bg-emerald-100 text-emerald-700 border-emerald-200', speak: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200', heading: 'text-emerald-800 border-emerald-200 bg-emerald-50' },
}[color];
return (
<section>
<h2 className={`mb-4 inline-block rounded-full border px-4 py-1.5 text-sm font-semibold ${accent.heading}`}>
{title} · {words.length} word{words.length !== 1 ? 's' : ''}
</h2>
<div className="grid gap-3 sm:grid-cols-2">
{words.map(({ word, chapterRef }) => (
<WordCard key={word.id ?? word.lexeme} word={word} chapterRef={chapterRef} accent={accent} onSpeak={onSpeak} />
))}
</div>
</section>
);
}
function WordCard({ word, chapterRef, accent, onSpeak }) {
return (
<div className="flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
{/* Top row: lexeme + speak */}
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{word.lexeme && (
<p className="text-3xl font-light leading-tight tracking-wide text-slate-900 break-words">
{word.lexeme}
</p>
)}
{word.transliteration && (
<p className="mt-1 text-base font-medium italic text-slate-500">
{word.transliteration}
</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-2">
{word.lexeme && (
<button
type="button"
onClick={() => onSpeak(word.lexeme, word.strongNumber)}
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${accent.speak} border-current/20`}
title="Hear pronunciation"
>
🔊 Speak
</button>
)}
{word.strongNumber && (
<span className={`rounded-full border px-2.5 py-0.5 text-xs font-semibold ${accent.badge}`}>
{word.strongNumber}
</span>
)}
</div>
</div>
{/* Gloss + definition */}
<div className="space-y-0.5">
{word.englishGloss && (
<p className="text-sm font-semibold text-slate-800">{word.englishGloss}</p>
)}
{word.shortDefinition && word.shortDefinition !== word.englishGloss && (
<p className="text-sm text-slate-500 leading-snug">{word.shortDefinition}</p>
)}
</div>
{/* Footer: part of speech + source passage */}
<div className="flex flex-wrap items-center gap-2 border-t border-slate-100 pt-3 text-xs text-slate-400">
{word.partOfSpeech && (
<span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-slate-600">{word.partOfSpeech}</span>
)}
<span className="ml-auto">{chapterRef}</span>
</div>
</div>
);
}
+940
View File
@@ -0,0 +1,940 @@
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
import { useApp } from '../context/AppContext.js';
import { formatCrossRef, speakOriginalWord } from '../utils/wordStudy.js';
import AuthStatus from '../components/AuthStatus.jsx';
import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
import DrawCanvas from './DrawCanvas.jsx';
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
// Copies "Book 3:16 BSB — text" to the clipboard.
const copyVerse = (bookName, chapterNum, verseNum, text) => {
navigator.clipboard?.writeText(`${bookName} ${chapterNum}:${verseNum} BSB — ${text}`);
};
export default function ReaderPage() {
const {
goHome,
readerBookAbbrev,
readerChapter, setReaderChapter,
readerVerses,
readerTotalChapters,
readerLoading,
readerError,
readerInterlinear,
readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize,
readerBookmarks,
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
readerCrossRefs,
readerCrossRefsLoading,
readerShowCrossRefs, setReaderShowCrossRefs,
readerSearch, setReaderSearch,
readerSearchActive, setReaderSearchActive,
readerSearchScope, setReaderSearchScope,
readerAudioState,
bibleIndexStatus,
audioNarrator, setAudioNarrator,
_bibleIndexCacheRef,
handleReaderBookChange,
readerGoToPreviousChapter,
readerGoToNextChapter,
jumpToReaderVerse,
toggleReaderBookmark,
cycleBookmarkColor,
loadReaderCrossRefs,
loadBibleIndex,
handlePlayReaderAudio,
handleToggleReaderAudioPause,
handleStopBookAudio,
setReaderCrossRefs,
readerInkByPage,
updateReaderPageInk,
readerTextHighlights,
addTextHighlight,
removeTextHighlight,
readingPlan,
markChapterRead,
} = useApp();
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
const [readerDrawMode, setReaderDrawMode] = useState(false);
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
const [selectionPicker, setSelectionPicker] = useState(null);
// Whole-Bible search: lowercase the ~31k-verse index once (not per keystroke),
// and let typing stay responsive while results catch up via useDeferredValue.
const bibleIndex = bibleIndexStatus === 'ready' ? (_bibleIndexCacheRef.current.BSB ?? null) : null;
const bibleIndexLower = useMemo(
() => (bibleIndex ? bibleIndex.map((v) => v.text.toLowerCase()) : null),
[bibleIndex],
);
const deferredBibleQuery = useDeferredValue(
readerSearchActive && readerSearchScope === 'bible' ? readerSearch.trim().toLowerCase() : '',
);
const bibleMatches = useMemo(() => {
if (!bibleIndex || !deferredBibleQuery) return [];
const out = [];
for (let i = 0; i < bibleIndex.length; i++) {
if (bibleIndexLower[i].includes(deferredBibleQuery)) out.push(bibleIndex[i]);
}
return out;
}, [bibleIndex, bibleIndexLower, deferredBibleQuery]);
const swipeTouchRef = useRef(null);
const [toolbarVisible, setToolbarVisible] = useState(true);
const [readerPageMode, setReaderPageMode] = useState(() => localStorage.getItem('reader-page-mode') === '1');
const [flipPage, setFlipPage] = useState(0);
const [flipTotalPages, setFlipTotalPages] = useState(1);
const [flipPageWidth, setFlipPageWidth] = useState(0);
const flipContainerRef = useRef(null);
const flipContentRef = useRef(null);
useEffect(() => {
localStorage.setItem('reader-wide', readerWideLayout ? '1' : '0');
// Auto-collapse toolbar when entering wide mode, restore when leaving
setToolbarVisible(!readerWideLayout);
}, [readerWideLayout]);
useEffect(() => {
localStorage.setItem('reader-page-mode', readerPageMode ? '1' : '0');
}, [readerPageMode]);
// Reset to page 0 when navigating chapters
useEffect(() => {
setFlipPage(0);
}, [readerChapter, readerBookAbbrev]);
// Track container width for CSS column sizing
useEffect(() => {
if (!readerPageMode || !flipContainerRef.current) return;
const el = flipContainerRef.current;
const update = () => setFlipPageWidth(el.clientWidth);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [readerPageMode, readerVerses]);
// Count pages after content renders
useEffect(() => {
if (!readerPageMode || !flipContentRef.current || !flipPageWidth) return;
const t = setTimeout(() => {
const sw = flipContentRef.current?.scrollWidth ?? 0;
setFlipTotalPages(Math.max(1, Math.round(sw / flipPageWidth)));
}, 120);
return () => clearTimeout(t);
}, [readerPageMode, readerVerses, readerFontSize, flipPageWidth]);
// Swipe left/right to navigate chapters
const handleTouchStart = (e) => {
if (readerDrawMode) return;
const t = e.changedTouches[0];
swipeTouchRef.current = { x: t.clientX, y: t.clientY };
};
const handleTouchEnd = (e) => {
if (readerDrawMode || !swipeTouchRef.current) return;
const t = e.changedTouches[0];
const dx = t.clientX - swipeTouchRef.current.x;
const dy = t.clientY - swipeTouchRef.current.y;
swipeTouchRef.current = null;
if (Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx) * 0.8) return;
if (dx < 0) {
if (readerPageMode && flipPage < flipTotalPages - 1) setFlipPage((p) => p + 1);
else readerGoToNextChapter();
} else {
if (readerPageMode && flipPage > 0) setFlipPage((p) => p - 1);
else readerGoToPreviousChapter();
}
};
// Arrow keys for desktop
useEffect(() => {
const handleKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
if (e.key === 'ArrowLeft') {
if (readerPageMode && flipPage > 0) setFlipPage((p) => p - 1);
else readerGoToPreviousChapter();
} else if (e.key === 'ArrowRight') {
if (readerPageMode && flipPage < flipTotalPages - 1) setFlipPage((p) => p + 1);
else readerGoToNextChapter();
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [readerGoToPreviousChapter, readerGoToNextChapter, readerPageMode, flipPage, flipTotalPages]);
// Dismiss the highlight picker when tapping outside it
useEffect(() => {
if (!selectionPicker) return;
const dismiss = () => setSelectionPicker(null);
document.addEventListener('pointerdown', dismiss);
return () => document.removeEventListener('pointerdown', dismiss);
}, [selectionPicker]);
// Render verse text with colored highlights and optional search match highlight
function renderVerseText(verseNum, text, searchLow) {
const spans = [];
for (const h of (readerTextHighlights || [])) {
if (h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum) {
spans.push({ start: h.startOffset, end: h.endOffset, type: 'hl', id: h.id, color: h.color });
}
}
if (searchLow) {
const lower = text.toLowerCase();
let i = 0;
while ((i = lower.indexOf(searchLow, i)) !== -1) {
spans.push({ start: i, end: i + searchLow.length, type: 'search' });
i += searchLow.length;
}
}
if (!spans.length) return text;
spans.sort((a, b) => a.start - b.start);
const parts = [];
let cursor = 0;
for (const s of spans) {
if (s.start < cursor) continue;
if (s.start > cursor) parts.push(text.slice(cursor, s.start));
if (s.type === 'hl') {
parts.push(
<mark key={s.id} style={{ backgroundColor: s.color, borderRadius: '2px', cursor: 'pointer' }}
onClick={() => removeTextHighlight(s.id)} title="Click to remove highlight">
{text.slice(s.start, s.end)}
</mark>
);
} else {
parts.push(
<mark key={`srch${s.start}`} className="bg-yellow-200 rounded px-0.5">
{text.slice(s.start, s.end)}
</mark>
);
}
cursor = s.end;
}
if (cursor < text.length) parts.push(text.slice(cursor));
return <>{parts}</>;
}
// Detect text selection and show the highlight color picker
function handleSelectionEnd() {
setTimeout(() => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (!rect.width) return;
let node = range.commonAncestorContainer;
if (node.nodeType !== 1) node = node.parentNode;
while (node && !(node.dataset && node.dataset.verseNum)) node = node.parentElement;
if (!node) return;
const verseNum = Number(node.dataset.verseNum);
const verse = readerVerses.find(v => v.number === verseNum);
if (!verse) return;
const selectedText = sel.toString();
const startOffset = verse.text.indexOf(selectedText);
if (startOffset === -1) return;
setSelectionPicker({
x: (rect.left + rect.right) / 2,
y: rect.top,
verse: verseNum,
startOffset,
endOffset: startOffset + selectedText.length,
});
}, 20);
}
const bookmarkEntries = Object.entries(readerBookmarks).map(([key, color]) => {
const [bAbbrev, chapterStr, verseStr] = key.split('-');
const bookIndex = bookOptions.findIndex((b) => b.abbrev === bAbbrev);
return {
key, color,
bookAbbrev: bAbbrev,
bookName: bookOptions[bookIndex]?.name ?? bAbbrev,
chapter: Number(chapterStr),
verse: Number(verseStr),
bookIndex,
};
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse);
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">Read the Bible (BSB)</h1>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={goHome}
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
← Back to Studies
</button>
<AuthStatus />
</div>
</div>
</header>
<main
className={`mx-auto px-4 py-8 sm:px-6 lg:px-8 ${readerDrawMode ? 'max-w-full' : readerWideLayout ? 'max-w-7xl' : 'max-w-3xl'}`}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Collapsible toolbar — hidden in wide focus mode */}
<div className={`transition-all duration-200 ${!toolbarVisible ? 'hidden' : ''}`}>
{/* Navigation + tools bar */}
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel">
<label className="text-sm text-slate-600">
Book{' '}
<select
value={readerBookAbbrev}
onChange={(e) => handleReaderBookChange(e.target.value)}
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{bookOptions.map((b) => (
<option key={b.abbrev} value={b.abbrev}>{b.name}</option>
))}
</select>
</label>
<label className="text-sm text-slate-600">
Chapter{' '}
<select
value={readerChapter}
onChange={(e) => { setReaderChapter(Number(e.target.value)); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }}
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{Array.from({ length: readerTotalChapters }, (_, i) => i + 1).map((num) => (
<option key={num} value={num}>{num}</option>
))}
</select>
</label>
<div className="ml-auto flex items-center gap-2">
{readingPlan && readingPlan.bookAbbrev === readerBookAbbrev && (() => {
const done = readingPlan.chaptersRead.includes(readerChapter);
return (
<button
type="button"
onClick={() => markChapterRead(readerChapter)}
className={`rounded-xl px-3 py-1.5 text-sm font-semibold transition ${done ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'border border-emerald-300 bg-white text-emerald-700 hover:bg-emerald-50'}`}
title={done ? 'Click to unmark' : 'Mark this chapter as read in your reading plan'}
>
{done ? '✓ Read' : 'Mark as read'}
</button>
);
})()}
<button type="button" onClick={readerGoToPreviousChapter} disabled={readerChapter <= 1}
className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
‹ Prev
</button>
<button type="button" onClick={readerGoToNextChapter} disabled={readerChapter >= readerTotalChapters}
className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
Next ›
</button>
</div>
</div>
{/* Tool strip: font size · cross-refs · search */}
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-3xl border border-slate-200 bg-white px-4 py-3 shadow-panel">
<span className="text-xs text-slate-500 mr-1">Text size</span>
{[['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25]].map(([label, size]) => (
<button key={label} type="button"
onClick={() => setReaderFontSize(size)}
className={`rounded-lg px-2.5 py-1 text-xs font-semibold transition ${readerFontSize === size ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
{label}
</button>
))}
<div className="mx-2 h-4 w-px bg-slate-200" />
<button type="button"
onClick={() => { setReaderShowCrossRefs((v) => !v); if (!readerShowCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerShowCrossRefs ? 'bg-amber-100 text-amber-800' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
{readerCrossRefsLoading ? 'Loading refs…' : '🔗 Cross-Refs'}
</button>
<div className="mx-2 h-4 w-px bg-slate-200" />
<div className="relative">
<button type="button"
onClick={() => setReaderBookmarksPanelOpen((v) => !v)}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1 text-xs font-semibold transition ${readerBookmarksPanelOpen ? 'bg-sky-100 text-sky-800' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
<BookmarkIcon filled={bookmarkEntries.length > 0} />
Bookmarks{bookmarkEntries.length > 0 ? ` (${bookmarkEntries.length})` : ''}
</button>
{readerBookmarksPanelOpen && (
<div className="absolute left-0 top-full z-20 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-3 shadow-lg">
{bookmarkEntries.length === 0 ? (
<p className="p-2 text-sm text-slate-500">
No bookmarks yet. Tap the bookmark icon next to any verse to save it here.
</p>
) : (
<div className="max-h-80 space-y-1 overflow-y-auto">
{bookmarkEntries.map((entry) => (
<div key={entry.key} className="flex items-center gap-2 rounded-xl px-2 py-1.5 hover:bg-slate-50">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: entry.color }} />
<button type="button"
onClick={() => jumpToReaderVerse(entry.bookAbbrev, entry.chapter, entry.verse)}
className="flex-1 truncate text-left text-sm font-medium text-slate-700 hover:text-sky-700">
{entry.bookName} {entry.chapter}:{entry.verse}
</button>
<button type="button"
onClick={() => toggleReaderBookmark(entry.key)}
title="Remove bookmark"
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-rose-600">
✕
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
<div className="mx-2 h-4 w-px bg-slate-200" />
{readerSearchActive ? (
<div className="flex items-center gap-1">
<div className="flex overflow-hidden rounded-lg border border-slate-300">
<button type="button"
onClick={() => setReaderSearchScope('chapter')}
className={`px-2 py-1 text-xs font-semibold transition ${readerSearchScope === 'chapter' ? 'bg-slate-900 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}>
This chapter
</button>
<button type="button"
onClick={() => { setReaderSearchScope('bible'); if (bibleIndexStatus === 'idle') loadBibleIndex(); }}
className={`px-2 py-1 text-xs font-semibold transition ${readerSearchScope === 'bible' ? 'bg-slate-900 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}>
Whole Bible
</button>
</div>
<input
autoFocus
type="text"
value={readerSearch}
onChange={(e) => setReaderSearch(e.target.value)}
placeholder={readerSearchScope === 'bible' ? 'Search the whole Bible…' : 'Search this chapter…'}
className="rounded-xl border border-slate-300 bg-slate-50 px-3 py-1 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
<button type="button" onClick={() => { setReaderSearch(''); setReaderSearchActive(false); }}
className="rounded-lg border border-slate-300 px-2 py-1 text-xs text-slate-500 hover:bg-slate-50">✕</button>
</div>
) : (
<button type="button" onClick={() => setReaderSearchActive(true)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
🔍 Search
</button>
)}
<div className="mx-2 h-4 w-px bg-slate-200" />
<button
type="button"
onClick={() => setReaderDrawMode((v) => !v)}
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerDrawMode ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
>
✏ Draw
</button>
<button
type="button"
onClick={() => setReaderWideLayout((v) => !v)}
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerWideLayout ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
>
⊞ Wide
</button>
{readerWideLayout && (
<button
type="button"
onClick={() => setToolbarVisible(false)}
className="rounded-lg px-3 py-1 text-xs font-semibold border border-slate-300 text-slate-600 hover:bg-slate-50 transition"
>
✕ Hide
</button>
)}
{!readerDrawMode && (
<button
type="button"
onClick={() => setReaderPageMode((v) => !v)}
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerPageMode ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
>
⎕ Page
</button>
)}
</div>
</div>{/* end collapsible toolbar */}
{/* Whole-Bible search results */}
{readerSearchActive && readerSearchScope === 'bible' && (
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
{bibleIndexStatus === 'loading' && (
<p className="text-sm text-slate-500">Loading the full Bible for search — this happens once per visit (~7MB)…</p>
)}
{bibleIndexStatus === 'error' && (
<p className="text-sm text-rose-600">Couldn't load the full Bible for search. <button type="button" onClick={loadBibleIndex} className="underline">Try again</button></p>
)}
{bibleIndexStatus === 'ready' && (() => {
const q = deferredBibleQuery;
if (!q) return <p className="text-sm text-slate-500">Type at least a word to search all 66 books.</p>;
const matches = bibleMatches;
if (matches.length === 0) return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
const shown = matches.slice(0, 100);
return (
<div className="space-y-1">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
{matches.length} match{matches.length === 1 ? '' : 'es'}{matches.length > shown.length ? ` (showing first ${shown.length})` : ''}
</p>
<div className="max-h-96 space-y-1 overflow-y-auto">
{shown.map((v) => {
const idx = v.text.toLowerCase().indexOf(q);
return (
<button key={`${v.bookAbbrev}-${v.chapter}-${v.verse}`} type="button"
onClick={() => jumpToReaderVerse(v.bookAbbrev, v.chapter, v.verse)}
className="block w-full rounded-xl px-3 py-2 text-left text-sm hover:bg-slate-50">
<span className="font-semibold text-slate-700">{v.bookName} {v.chapter}:{v.verse}</span>{' '}
<span className="text-slate-600">
{v.text.slice(0, idx)}
<mark className="rounded bg-yellow-200 px-0.5">{v.text.slice(idx, idx + q.length)}</mark>
{v.text.slice(idx + q.length)}
</span>
</button>
);
})}
</div>
</div>
);
})()}
</div>
)}
{/* Audio player */}
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel">
<div className="flex-1">
<h3 className="text-sm font-semibold text-slate-900">Listen to this chapter</h3>
<p className="mt-1 text-xs text-slate-500">
{readerAudioState.status === 'error'
? "Couldn't load audio for this chapter/narrator."
: `Audio follows ${readerBook?.name} ${readerChapter} as you browse.`}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
</select>
{readerAudioState.status === 'playing' || readerAudioState.status === 'paused' ? (
<>
<button type="button" onClick={handleToggleReaderAudioPause}
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500">
{readerAudioState.status === 'paused' ? 'Resume' : 'Pause'}
</button>
<button type="button" onClick={handleStopBookAudio}
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">
Stop
</button>
</>
) : (
<button type="button" onClick={handlePlayReaderAudio}
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500">
Play
</button>
)}
</div>
</div>
{/* Verses — normal scroll mode */}
{!readerDrawMode && !readerPageMode && (
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
<h2 className="mb-1 text-xl font-semibold text-slate-900">
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">(BSB)</span>
</h2>
{readerInterlinear && (
<p className="mb-4 text-xs text-slate-400">Click a verse number to see original words &amp; pronunciation · bookmark icon to save · copy icon to copy</p>
)}
{!readerInterlinear && (
<p className="mb-4 text-xs text-slate-400">Hover a verse for actions</p>
)}
{readerLoading && <p className="text-sm text-slate-500">Loading…</p>}
{readerError && <p className="text-sm text-rose-600">{readerError}</p>}
{!readerLoading && !readerError && (() => {
const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
const filtered = searchLower
? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower))
: readerVerses;
if (searchLower && filtered.length === 0) {
return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
}
return (
<div className={`leading-relaxed text-slate-800 ${readerWideLayout ? 'md:columns-2 md:gap-x-10 space-y-0' : 'space-y-3'}`}
style={{ fontSize: `${readerFontSize}em` }}
onPointerUp={handleSelectionEnd}>
{filtered.map((verse) => {
const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
const verseWords = chapterInterlinear?.[String(verse.number)];
const isOpen = readerSelectedVerse === verse.number;
const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`;
const bmColor = readerBookmarks[verseKey];
const crossRefs = readerCrossRefs?.[verse.number];
return (
<div key={verse.number} id={`reader-verse-${verse.number}`} data-verse-num={verse.number}
className="group break-inside-avoid rounded-xl transition mb-3"
style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: '0.5rem' } : {}}>
<div className="flex items-start gap-1">
<button type="button"
onClick={() => setReaderSelectedVerse(isOpen ? null : verse.number)}
className={`mt-0.5 shrink-0 rounded px-1 text-xs font-bold transition ${
verseWords
? isOpen ? 'bg-sky-600 text-white' : 'text-sky-600 hover:bg-sky-50'
: 'cursor-default text-slate-400'
}`}
title={verseWords ? 'Show original words' : undefined}>
{verse.number}
</button>
<p className="flex-1">{renderVerseText(verse.number, verse.text, searchLower)}</p>
<span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1">
<button type="button"
onClick={() => toggleReaderBookmark(verseKey)}
className={`rounded p-1 leading-none hover:bg-slate-100 ${bmColor ? 'text-amber-600' : 'text-slate-400'}`}
title={bmColor ? 'Remove bookmark' : 'Bookmark this verse'}>
<BookmarkIcon filled={!!bmColor} />
</button>
{bmColor && (
<button type="button"
onClick={() => cycleBookmarkColor(verseKey)}
className="rounded p-1 text-sm leading-none hover:bg-slate-100"
title="Change highlight colour">
🎨
</button>
)}
<button type="button"
onClick={() => copyVerse(readerBook?.name, readerChapter, verse.number, verse.text)}
className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
title="Copy verse">
<CopyIcon />
</button>
</span>
</div>
{readerShowCrossRefs && crossRefs && (
<div className="mt-1 ml-6 flex flex-wrap gap-1">
{crossRefs.slice(0, 8).map((ref, i) => {
const label = formatCrossRef(ref);
return (
<span key={i} className="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800 cursor-default" title={`Score: ${ref.score ?? '?'}`}>
{label}
</span>
);
})}
</div>
)}
{isOpen && verseWords && (
<div className="mt-2 mb-1 ml-6 rounded-2xl border border-sky-100 bg-sky-50 p-3">
<div className="flex flex-wrap gap-2">
{verseWords.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-sky-200 bg-white p-2 text-center shadow-sm"
style={{ minWidth: '4.5rem', maxWidth: '9rem' }}>
<div className={`text-lg font-medium leading-tight ${isHebrew ? 'font-serif' : ''}`} dir={isHebrew ? 'rtl' : 'ltr'}>
{w.o}
</div>
<div className="mt-0.5 text-xs text-slate-500 italic">{w.t}</div>
<div className="mt-1 text-xs font-semibold text-slate-800">{w.g}</div>
{w.p && <div className="mt-0.5 text-[10px] text-slate-400 leading-tight">{w.p}</div>}
{w.s && <div className="mt-0.5 text-[10px] text-slate-400">{w.s}</div>}
{canSpeak && (
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
className="mt-1.5 rounded-lg bg-sky-100 px-2 py-0.5 text-[11px] font-medium text-sky-700 hover:bg-sky-200 transition"
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>
🔊 Speak
</button>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
);
})()}
</div>
)}
{/* Verses — page-flip mode */}
{!readerDrawMode && readerPageMode && (
<div ref={flipContainerRef}
className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel"
style={{ height: toolbarVisible ? 'calc(100dvh - 380px)' : 'calc(100dvh - 120px)' }}>
{readerLoading && <p className="p-6 text-sm text-slate-500">Loading…</p>}
{readerError && <p className="p-6 text-sm text-rose-600">{readerError}</p>}
{!readerLoading && !readerError && (() => {
const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
const filtered = searchLower
? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower))
: readerVerses;
if (searchLower && filtered.length === 0) {
return <p className="p-6 text-sm text-slate-500">No verses match "{readerSearch}".</p>;
}
return (
<div ref={flipContentRef}
className="h-full leading-relaxed text-slate-800"
style={{
fontSize: `${readerFontSize}em`,
columnWidth: flipPageWidth ? `${flipPageWidth}px` : '100%',
columnGap: 0,
paddingBottom: '3.5rem',
transform: flipPageWidth ? `translateX(${-flipPage * flipPageWidth}px)` : undefined,
transition: 'transform 0.3s ease',
}}
onPointerUp={handleSelectionEnd}>
<div className="px-6 pt-6 pb-3 break-inside-avoid">
<h2 className="mb-1 text-xl font-semibold text-slate-900">
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">(BSB)</span>
</h2>
{readerInterlinear
? <p className="text-xs text-slate-400">Click a verse number to see original words &amp; pronunciation</p>
: <p className="text-xs text-slate-400">Hover a verse for actions</p>
}
</div>
{filtered.map((verse) => {
const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
const verseWords = chapterInterlinear?.[String(verse.number)];
const isOpen = readerSelectedVerse === verse.number;
const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`;
const bmColor = readerBookmarks[verseKey];
const crossRefs = readerCrossRefs?.[verse.number];
return (
<div key={verse.number} id={`reader-verse-${verse.number}`} data-verse-num={verse.number}
className="group break-inside-avoid rounded-xl transition mb-3 px-6"
style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: 'calc(1.5rem + 0.5rem)' } : {}}>
<div className="flex items-start gap-1">
<button type="button"
onClick={() => setReaderSelectedVerse(isOpen ? null : verse.number)}
className={`mt-0.5 shrink-0 rounded px-1 text-xs font-bold transition ${
verseWords
? isOpen ? 'bg-sky-600 text-white' : 'text-sky-600 hover:bg-sky-50'
: 'cursor-default text-slate-400'
}`}
title={verseWords ? 'Show original words' : undefined}>
{verse.number}
</button>
<p className="flex-1">{renderVerseText(verse.number, verse.text, searchLower)}</p>
<span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1">
<button type="button"
onClick={() => toggleReaderBookmark(verseKey)}
className={`rounded p-1 leading-none hover:bg-slate-100 ${bmColor ? 'text-amber-600' : 'text-slate-400'}`}
title={bmColor ? 'Remove bookmark' : 'Bookmark this verse'}>
<BookmarkIcon filled={!!bmColor} />
</button>
{bmColor && (
<button type="button"
onClick={() => cycleBookmarkColor(verseKey)}
className="rounded p-1 text-sm leading-none hover:bg-slate-100"
title="Change highlight colour">
🎨
</button>
)}
<button type="button"
onClick={() => copyVerse(readerBook?.name, readerChapter, verse.number, verse.text)}
className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
title="Copy verse">
<CopyIcon />
</button>
</span>
</div>
{readerShowCrossRefs && crossRefs && (
<div className="mt-1 ml-6 flex flex-wrap gap-1">
{crossRefs.slice(0, 8).map((ref, i) => {
const label = formatCrossRef(ref);
return (
<span key={i} className="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800 cursor-default" title={`Score: ${ref.score ?? '?'}`}>
{label}
</span>
);
})}
</div>
)}
{isOpen && verseWords && (
<div className="mt-2 mb-1 ml-6 rounded-2xl border border-sky-100 bg-sky-50 p-3">
<div className="flex flex-wrap gap-2">
{verseWords.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-sky-200 bg-white p-2 text-center shadow-sm"
style={{ minWidth: '4.5rem', maxWidth: '9rem' }}>
<div className={`text-lg font-medium leading-tight ${isHebrew ? 'font-serif' : ''}`} dir={isHebrew ? 'rtl' : 'ltr'}>
{w.o}
</div>
<div className="mt-0.5 text-xs text-slate-500 italic">{w.t}</div>
<div className="mt-1 text-xs font-semibold text-slate-800">{w.g}</div>
{w.p && <div className="mt-0.5 text-[10px] text-slate-400 leading-tight">{w.p}</div>}
{w.s && <div className="mt-0.5 text-[10px] text-slate-400">{w.s}</div>}
{canSpeak && (
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
className="mt-1.5 rounded-lg bg-sky-100 px-2 py-0.5 text-[11px] font-medium text-sky-700 hover:bg-sky-200 transition"
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>
🔊 Speak
</button>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
);
})()}
{/* Page navigation bar */}
<div className="absolute bottom-0 inset-x-0 flex items-center justify-between border-t border-slate-100 bg-white/95 px-6 py-2.5 backdrop-blur-sm">
<button
onClick={() => flipPage > 0 ? setFlipPage((p) => p - 1) : readerGoToPreviousChapter()}
disabled={flipPage === 0 && readerChapter <= 1}
className="rounded-xl border border-slate-200 bg-white px-4 py-1.5 text-sm font-semibold text-slate-600 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
{flipPage === 0 ? '← Prev chapter' : '← Back'}
</button>
<span className="text-xs text-slate-400">
{readerBook?.name} {readerChapter} · {flipPage + 1} / {flipTotalPages}
</span>
<button
onClick={() => flipPage < flipTotalPages - 1 ? setFlipPage((p) => p + 1) : readerGoToNextChapter()}
disabled={flipPage === flipTotalPages - 1 && readerChapter >= readerTotalChapters}
className="rounded-xl border border-slate-200 bg-white px-4 py-1.5 text-sm font-semibold text-slate-600 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
{flipPage === flipTotalPages - 1 ? 'Next chapter →' : 'Next →'}
</button>
</div>
</div>
)}
{/* Draw mode — Bible text on left, notebook canvas on right */}
{readerDrawMode && (
<div className="flex flex-col items-start gap-4 sm:flex-row">
{/* Scripture panel — sticky so it stays in view while notebook scrolls */}
<div className="w-full rounded-3xl border border-slate-200 bg-slate-50 p-5 font-serif text-sm leading-relaxed text-slate-800 sm:w-72 sm:shrink-0 sm:sticky sm:top-6 sm:max-h-[80vh] sm:overflow-y-auto"
onPointerUp={handleSelectionEnd}>
<div className="mb-3">
<span className="font-semibold text-slate-900">{readerBook?.name} {readerChapter}</span>
<span className="ml-2 text-xs text-slate-400">BSB</span>
<p className="mt-1 text-xs text-slate-400 font-sans">Select text to highlight</p>
</div>
{!readerLoading && readerVerses.map((v) => (
<p key={v.number} className="mb-2" data-verse-num={v.number}>
<span className="font-semibold text-slate-700">{v.number}.</span>{' '}
{renderVerseText(v.number, v.text, '')}
</p>
))}
</div>
{/* Notebook canvas */}
<div className="min-w-0 flex-1">
<DrawCanvas
strokes={readerPageInkStrokes}
onStrokesChange={(s) => updateReaderPageInk(readerBookAbbrev, readerChapter, s)}
onDone={() => setReaderDrawMode(false)}
/>
</div>
</div>
)}
</main>
{/* Wide focus mode — fixed top bar to restore toolbar */}
{readerWideLayout && !toolbarVisible && (
<div className="fixed top-0 inset-x-0 z-30 flex items-center justify-between gap-4 border-b border-slate-200/60 bg-white/80 px-4 py-2 backdrop-blur-sm">
<span className="text-sm font-semibold text-slate-700">
{readerBook?.name} {readerChapter}
</span>
<div className="flex items-center gap-2">
<button type="button" onClick={readerGoToPreviousChapter} disabled={readerChapter <= 1}
className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40">
‹ Prev
</button>
<button type="button" onClick={readerGoToNextChapter} disabled={readerChapter >= readerTotalChapters}
className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40">
Next ›
</button>
<button type="button" onClick={() => setToolbarVisible(true)}
className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
⚙ Tools
</button>
</div>
</div>
)}
{/* Side chapter nav — fixed left/right arrows */}
{!readerDrawMode && (
<>
<button
type="button"
onClick={readerGoToPreviousChapter}
disabled={readerChapter <= 1}
aria-label="Previous chapter"
className="fixed left-3 top-1/2 -translate-y-1/2 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-500 shadow-md backdrop-blur-sm transition hover:bg-white hover:text-slate-900 disabled:opacity-0 disabled:pointer-events-none"
>
‹
</button>
<button
type="button"
onClick={readerGoToNextChapter}
disabled={readerChapter >= readerTotalChapters}
aria-label="Next chapter"
className="fixed right-3 top-1/2 -translate-y-1/2 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-500 shadow-md backdrop-blur-sm transition hover:bg-white hover:text-slate-900 disabled:opacity-0 disabled:pointer-events-none"
>
›
</button>
</>
)}
{/* Highlight color picker — appears above text selection */}
{selectionPicker && (
<div
className="fixed z-50 flex items-center gap-1.5 rounded-2xl bg-white px-2.5 py-2 shadow-2xl border border-slate-200"
style={{
left: Math.max(8, Math.min(selectionPicker.x - 105, window.innerWidth - 220)),
top: selectionPicker.y > 80 ? selectionPicker.y - 56 : selectionPicker.y + 28,
}}
onPointerDown={(e) => e.stopPropagation()}
>
{['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa'].map(color => (
<button
key={color}
className="h-7 w-7 rounded-full border-2 border-white shadow-sm hover:scale-110 transition-transform active:scale-95"
style={{ backgroundColor: color }}
onPointerDown={(e) => {
e.preventDefault();
addTextHighlight({
book: readerBookAbbrev,
chapter: readerChapter,
verse: selectionPicker.verse,
startOffset: selectionPicker.startOffset,
endOffset: selectionPicker.endOffset,
color,
});
window.getSelection()?.removeAllRanges();
setSelectionPicker(null);
}}
/>
))}
<button
className="ml-1 h-7 w-7 rounded-full bg-slate-100 text-slate-500 text-xs flex items-center justify-center hover:bg-slate-200 transition"
onPointerDown={(e) => {
e.preventDefault();
window.getSelection()?.removeAllRanges();
setSelectionPicker(null);
}}
>
✕
</button>
</div>
)}
</div>
);
}
+324
View File
@@ -0,0 +1,324 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import AuthStatus from '../components/AuthStatus.jsx';
import {
startMfaSetup,
confirmMfaSetup,
disableMfa,
changePassword,
updateProfile,
} from '../syncService.js';
export default function SettingsPage() {
const {
authUser, setAuthUser,
goHome,
} = useApp();
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
const [mfaSetupCode, setMfaSetupCode] = useState('');
const [mfaSetupError, setMfaSetupError] = useState('');
const [mfaSetupBusy, setMfaSetupBusy] = useState(false);
const [mfaBackupCodes, setMfaBackupCodes] = useState(null); // shown once, right after enabling
const [mfaDisablePassword, setMfaDisablePassword] = useState('');
const [mfaDisableError, setMfaDisableError] = useState('');
const [podcastNameInput, setPodcastNameInput] = useState(authUser?.podcastName ?? '');
const [podcastNameSaving, setPodcastNameSaving] = useState(false);
const [podcastNameSaved, setPodcastNameSaved] = useState(false);
const [changePasswordForm, setChangePasswordForm] = useState({ current: '', next: '', confirm: '' });
const [changePasswordBusy, setChangePasswordBusy] = useState(false);
const [changePasswordError, setChangePasswordError] = useState('');
const [changePasswordSaved, setChangePasswordSaved] = useState(false);
const startSetup = async () => {
setMfaSetupError('');
setMfaSetupBusy(true);
const result = await startMfaSetup();
setMfaSetupBusy(false);
if (!result.ok) {
setMfaSetupError(result.error ?? 'Could not start 2FA setup.');
return;
}
setMfaSetup(result.data);
setMfaSetupCode('');
};
const confirmSetup = async (e) => {
e.preventDefault();
setMfaSetupError('');
setMfaSetupBusy(true);
const result = await confirmMfaSetup(mfaSetupCode.trim());
setMfaSetupBusy(false);
if (!result.ok) {
setMfaSetupError(result.error ?? 'Invalid code.');
return;
}
setMfaSetup(null);
setMfaSetupCode('');
setMfaBackupCodes(result.data.backupCodes);
setAuthUser((u) => ({ ...u, totpEnabled: true }));
};
const cancelSetup = () => {
setMfaSetup(null);
setMfaSetupCode('');
setMfaSetupError('');
};
const submitDisable = async (e) => {
e.preventDefault();
setMfaDisableError('');
const result = await disableMfa(mfaDisablePassword);
if (!result.ok) {
setMfaDisableError(result.error ?? 'Incorrect password.');
return;
}
setMfaDisablePassword('');
setAuthUser((u) => ({ ...u, totpEnabled: false }));
};
const submitChangePassword = async (e) => {
e.preventDefault();
setChangePasswordError('');
if (changePasswordForm.next !== changePasswordForm.confirm) {
setChangePasswordError("New passwords don't match.");
return;
}
setChangePasswordBusy(true);
const result = await changePassword(changePasswordForm.current, changePasswordForm.next);
setChangePasswordBusy(false);
if (!result.ok) {
setChangePasswordError(result.error ?? 'Failed to change password.');
return;
}
setChangePasswordForm({ current: '', next: '', confirm: '' });
setChangePasswordSaved(true);
window.setTimeout(() => setChangePasswordSaved(false), 3000);
};
const submitPodcastName = async (e) => {
e.preventDefault();
setPodcastNameSaving(true);
setPodcastNameSaved(false);
const result = await updateProfile({ podcastName: podcastNameInput.trim() });
setPodcastNameSaving(false);
if (!result.ok) return;
setAuthUser((u) => ({ ...u, podcastName: result.data.podcastName }));
setPodcastNameSaved(true);
window.setTimeout(() => setPodcastNameSaved(false), 2500);
};
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">Account Settings</h1>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={goHome}
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
← Back
</button>
<AuthStatus />
</div>
</div>
</header>
<main className="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-2">
<h2 className="text-lg font-semibold text-slate-900">Account</h2>
<p className="text-sm text-slate-500">
Signed in as <span className="font-medium text-slate-700">{authUser.email}</span>
</p>
</section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Change password</h2>
<p className="text-sm text-slate-500">Changing your password signs you out of every other device — this one stays signed in.</p>
</div>
<form onSubmit={submitChangePassword} className="space-y-3">
<label className="block text-sm font-medium text-slate-700">
Current password
<input
type="password"
required
autoComplete="current-password"
value={changePasswordForm.current}
onChange={(e) => setChangePasswordForm((f) => ({ ...f, current: e.target.value }))}
className="mt-1 block w-full max-w-xs rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<label className="block text-sm font-medium text-slate-700">
New password
<input
type="password"
required
minLength={8}
autoComplete="new-password"
value={changePasswordForm.next}
onChange={(e) => setChangePasswordForm((f) => ({ ...f, next: e.target.value }))}
className="mt-1 block w-full max-w-xs rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<label className="block text-sm font-medium text-slate-700">
Confirm new password
<input
type="password"
required
minLength={8}
autoComplete="new-password"
value={changePasswordForm.confirm}
onChange={(e) => setChangePasswordForm((f) => ({ ...f, confirm: e.target.value }))}
className="mt-1 block w-full max-w-xs rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
{changePasswordError && <p className="text-sm text-rose-600">{changePasswordError}</p>}
{changePasswordSaved && <p className="text-sm text-emerald-600">Password changed.</p>}
<button
type="submit"
disabled={changePasswordBusy}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{changePasswordBusy ? 'Saving…' : 'Change password'}
</button>
</form>
</section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Podcast / show name</h2>
<p className="text-sm text-slate-500">
Only needed if you use "🎙 Prepare for Podcast" on the study page — it fills in your show's
name when asking Claude to write an episode script. Leave blank if you're just doing personal
study; that button still works, it just won't name a specific show.
</p>
</div>
<form onSubmit={submitPodcastName} className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="text"
value={podcastNameInput}
onChange={(e) => setPodcastNameInput(e.target.value)}
placeholder="e.g. Verse by Verse with Nate: A Journey Through Scripture"
className="w-full flex-1 rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
<button
type="submit"
disabled={podcastNameSaving}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{podcastNameSaving ? 'Saving…' : 'Save'}
</button>
</form>
{podcastNameSaved && <p className="text-sm text-emerald-600">Saved.</p>}
</section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Two-factor authentication</h2>
<p className="text-sm text-slate-500">
{authUser.totpEnabled
? "Enabled — you'll need a code from your authenticator app to sign in."
: 'Add a 6-digit code from an authenticator app (Google Authenticator, Authy, 1Password, etc.) as a second step at sign-in.'}
</p>
</div>
{mfaBackupCodes ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 space-y-3">
<p className="text-sm font-semibold text-amber-800">
Save these backup codes now — each works once if you ever lose your device. They won't be shown again.
</p>
<div className="grid grid-cols-2 gap-2 font-mono text-sm text-slate-800">
{mfaBackupCodes.map((code) => (
<div key={code} className="rounded-lg border border-amber-200 bg-white px-3 py-1.5">{code}</div>
))}
</div>
<button
type="button"
onClick={() => setMfaBackupCodes(null)}
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-amber-400"
>
I've saved these codes
</button>
</div>
) : authUser.totpEnabled ? (
<form onSubmit={submitDisable} className="space-y-3">
<label className="block max-w-xs text-sm font-medium text-slate-700">
Enter your password to disable 2FA
<input
type="password"
required
value={mfaDisablePassword}
onChange={(e) => setMfaDisablePassword(e.target.value)}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
{mfaDisableError && <p className="text-sm text-rose-600">{mfaDisableError}</p>}
<button
type="submit"
className="rounded-md bg-rose-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-rose-400"
>
Disable 2FA
</button>
</form>
) : mfaSetup ? (
<form onSubmit={confirmSetup} className="space-y-4">
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
<img src={mfaSetup.qrCodeDataUrl} alt="2FA QR code" className="h-40 w-40 rounded-xl border border-slate-200" />
<div className="space-y-1 text-sm text-slate-600">
<p>Scan this with your authenticator app, or enter the code manually:</p>
<p className="break-all rounded-lg bg-slate-100 px-2 py-1 font-mono text-xs">{mfaSetup.secret}</p>
</div>
</div>
<label className="block max-w-xs text-sm font-medium text-slate-700">
Enter the 6-digit code it shows
<input
type="text"
required
inputMode="numeric"
placeholder="123456"
value={mfaSetupCode}
onChange={(e) => setMfaSetupCode(e.target.value)}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-center text-lg tracking-widest shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
{mfaSetupError && <p className="text-sm text-rose-600">{mfaSetupError}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={mfaSetupBusy}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{mfaSetupBusy ? 'Verifying…' : 'Confirm & enable'}
</button>
<button
type="button"
onClick={cancelSetup}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50"
>
Cancel
</button>
</div>
</form>
) : (
<div className="space-y-2">
<button
type="button"
onClick={startSetup}
disabled={mfaSetupBusy}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{mfaSetupBusy ? 'Starting…' : 'Enable 2FA'}
</button>
{mfaSetupError && <p className="text-sm text-rose-600">{mfaSetupError}</p>}
</div>
)}
</section>
</main>
</div>
);
}
+438
View File
@@ -0,0 +1,438 @@
import { useApp } from '../context/AppContext.js';
import { useChunkBuilder } from '../hooks/useChunkBuilder.js';
import ProjectHeader from '../components/ProjectHeader.jsx';
import { useAvailableTranslations } from '../hooks/useAvailableTranslations.js';
import { bookOptions, formatChunkReference } from '../utils/bibleUtils.jsx';
function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter, errorMessage, hideTitle, onField, onTitleChange, onLoad }) {
return (
<div className="space-y-6">
{!hideTitle && (
<div>
<h2 className="text-xl font-semibold text-slate-900">Project Setup</h2>
<p className="mt-2 text-sm text-slate-600">
Pick a translation, chapter, and title. Load the chapter to begin structuring your study into chunks.
</p>
</div>
)}
<div className="grid gap-5 sm:grid-cols-2">
<label className="block text-sm font-medium text-slate-700">
Translation
<select
value={setup.translation}
onChange={(e) => onField('translation', e.target.value)}
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{availableTranslations.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</label>
<label className="block text-sm font-medium text-slate-700">
Book
<select
value={setup.bookAbbrev}
onChange={(e) => onField('book', e.target.value)}
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{bookOptions.map((book) => (
<option key={book.abbrev} value={book.abbrev}>{book.name}</option>
))}
</select>
</label>
<label className="block text-sm font-medium text-slate-700">
Chapter
<input
type="number"
min="1"
value={setup.chapter}
onChange={(e) => onField('chapter', e.target.value)}
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
{!hideTitle && (
<label className="block text-sm font-medium text-slate-700 sm:col-span-2">
Project title
<input
type="text"
value={setup.title}
onChange={(e) => onTitleChange(e.target.value)}
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
)}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-slate-600">
{errorMessage
? <span className="text-rose-500">{errorMessage}</span>
: 'Start by loading the chapter text from HelloAO.'}
</div>
<button
type="button"
onClick={onLoad}
disabled={loadingChapter}
className="inline-flex items-center justify-center rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-500"
>
{loadingChapter ? 'Loading...' : 'Load Chapter'}
</button>
</div>
</div>
);
}
export default function SetupPage() {
const { project, setProject, allChunks, updateProject, setCurrentPage } = useApp();
const {
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,
} = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage });
const availableTranslations = useAvailableTranslations();
const chapterTabs = project?.chapters ?? [];
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">
{project ? project.title : 'New Study'}
</h1>
</div>
<ProjectHeader loading={loadingChapter} />
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-4 sm:py-8 sm:px-6 lg:px-8 space-y-8">
{/* Project setup form — shown when no project loaded yet */}
{!project && (
<section className="mx-auto max-w-3xl rounded-3xl border border-slate-200 bg-white p-8 shadow-panel">
<SetupForm
setup={setup}
availableTranslations={availableTranslations}
titleEdited={titleEdited}
loadingChapter={loadingChapter}
errorMessage={errorMessage}
onField={handleSetupField}
onTitleChange={(val) => { setTitleEdited(true); handleSetupField('title', val); }}
onLoad={handleLoadChapter}
/>
</section>
)}
{/* Chapter tabs + verse/chunk editors */}
{project && (
<section className="space-y-6">
{/* Chapter tabs */}
<div className="flex flex-wrap items-center gap-2">
{chapterTabs.map((ch, idx) => (
<button
key={`${ch.bookAbbrev}-${ch.chapter}`}
type="button"
onClick={() => { setActiveChapterIndex(idx); setRangeStart(null); setRangeEnd(null); }}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition ${
activeChapterIndex === idx
? 'bg-slate-900 text-white'
: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'
}`}
>
{ch.book} {ch.chapter}
</button>
))}
<button
type="button"
onClick={() => setShowAddChapterForm((v) => !v)}
className="rounded-full border border-dashed border-slate-400 px-4 py-1.5 text-sm text-slate-500 transition hover:border-slate-600 hover:text-slate-700"
>
+ Add Chapter
</button>
</div>
{/* Add-chapter form */}
{showAddChapterForm && (
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
<h3 className="mb-4 text-sm font-semibold text-slate-900">Add another chapter</h3>
<SetupForm
setup={setup}
availableTranslations={availableTranslations}
titleEdited={titleEdited}
loadingChapter={loadingChapter}
errorMessage={errorMessage}
hideTitle
onField={handleSetupField}
onTitleChange={(val) => { setTitleEdited(true); handleSetupField('title', val); }}
onLoad={handleLoadChapter}
/>
</div>
)}
{/* Verse + chunk panel for active chapter */}
{activeChapter && (
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-slate-500">Scripture & Chunks</p>
<h2 className="mt-2 text-xl font-semibold text-slate-900">
{activeChapter.book} {activeChapter.chapter} ({project.translation})
</h2>
</div>
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-700">
{statusMessage || 'Click/shift-click, or type a verse range, to create a chunk.'}
</div>
</div>
<div className="mt-6 grid min-w-0 gap-6 lg:grid-cols-[1.25fr_0.75fr]">
<div className="min-w-0 rounded-3xl border border-slate-200 bg-slate-50 p-4 shadow-sm">
<div className="mb-4 flex items-center justify-between gap-3">
<span className="text-sm font-medium text-slate-600">Chapter verses</span>
<span className="text-xs text-slate-500">Click a verse, then shift-click an end verse.</span>
</div>
<input
type="text"
value={verseSearch}
onChange={(e) => setVerseSearch(e.target.value)}
placeholder="Search verses in this chapter…"
className="mb-3 block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
<div data-testid="verse-list" className="max-h-[520px] overflow-y-auto rounded-3xl border border-slate-200 bg-white p-4 scrollbar-thin">
{activeChapter.verses
.filter((verse) => verse.text.toLowerCase().includes(verseSearch.trim().toLowerCase()))
.map((verse) => {
const inRange =
rangeStart !== null &&
verse.number >= Math.min(rangeStart, rangeEnd) &&
verse.number <= Math.max(rangeStart, rangeEnd);
const inOwnChapterChunk = activeChapter.chunks.some(
(chunk) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse,
);
const prevChapter = project?.chapters?.[activeChapterIndex - 1] ?? null;
const inPrevChapterSpillover = prevChapter
? prevChapter.chunks.some((chunk) =>
Number.isInteger(chunk.spilloverEndVerse)
&& prevChapter.bookAbbrev === activeChapter.bookAbbrev
&& verse.number <= chunk.spilloverEndVerse
)
: false;
const inChunk = inOwnChapterChunk || inPrevChapterSpillover;
return (
<button
key={verse.number}
type="button"
onClick={(event) => handleVerseClick(verse.number, event)}
className={`group mb-2 w-full rounded-3xl px-4 py-3 text-left transition ${
inRange ? 'bg-sky-100 ring-1 ring-sky-200' : inChunk ? 'bg-slate-100' : 'bg-white hover:bg-slate-50'
}`}
>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-200 text-sm font-semibold text-slate-700 transition group-hover:bg-slate-300">
{verse.number}
</span>
<span className="ml-3 text-sm leading-relaxed text-slate-700">{verse.text}</span>
</button>
);
})}
</div>
</div>
<div className="min-w-0 space-y-4 rounded-3xl border border-slate-200 bg-slate-50 p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-slate-900">Chunks</h3>
<span className="text-xs text-slate-500">{activeChapter.chunks.length} created</span>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Type chunk range</p>
<div className="mt-2 flex flex-wrap items-end gap-2">
<label className="min-w-0 flex-1 text-xs text-slate-500">
Start
<input
type="number"
min="1"
max={activeChapter.verses.at(-1)?.number ?? 1}
value={typedChunkStart}
onChange={(e) => setTypedChunkStart(e.target.value)}
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<label className="min-w-0 flex-1 text-xs text-slate-500">
End
<input
type="number"
min="1"
max={activeChapter.verses.at(-1)?.number ?? 1}
value={typedChunkEnd}
onChange={(e) => setTypedChunkEnd(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTypedChunk();
}
}}
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<label className="min-w-0 flex-1 text-xs text-slate-500">
Next ch end (optional)
<input
type="number"
min="1"
max={project?.chapters?.[activeChapterIndex + 1]?.verses?.at(-1)?.number ?? 1}
value={typedChunkNextEnd}
onChange={(e) => setTypedChunkNextEnd(e.target.value)}
placeholder="e.g. 5"
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<button
type="button"
onClick={addTypedChunk}
className="rounded-xl bg-slate-900 px-3 py-2 text-xs font-semibold text-white transition hover:bg-slate-800"
>
Add
</button>
</div>
<div className="mt-3">
<label className="text-xs text-slate-500">
Bulk ranges (comma/new line; use `start-end:nextEnd` to span)
<textarea
rows={2}
value={typedChunkBulk}
onChange={(e) => setTypedChunkBulk(e.target.value)}
placeholder="1-6, 7-31:5, 6"
className="mt-1 block w-full resize-y rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<button
type="button"
onClick={addBulkTypedChunks}
className="mt-2 rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
>
Add All Ranges
</button>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Click-based chapter span</p>
<p className="mt-1 text-xs text-slate-500">Click a start verse in this chapter, then choose where to end in the next chapter.</p>
<div className="mt-2 flex items-end gap-2">
<div className="flex-1 text-xs text-slate-500">
Start
<div className="mt-1 rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-2 text-sm text-slate-900">
{rangeStart ?? 'Not selected'}
</div>
</div>
<label className="flex-1 text-xs text-slate-500">
Next ch end
<input
type="number"
min="1"
max={project?.chapters?.[activeChapterIndex + 1]?.verses?.at(-1)?.number ?? 1}
value={clickedSpanNextEnd}
onChange={(e) => setClickedSpanNextEnd(e.target.value)}
placeholder="e.g. 5"
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<button
type="button"
onClick={addClickSpanChunk}
className="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
>
Span Into Next
</button>
</div>
</div>
<div className="space-y-3 max-h-[520px] overflow-y-auto scrollbar-thin">
{activeChapter.chunks.length === 0 ? (
<div className="rounded-3xl border border-dashed border-slate-300 bg-white p-4 text-sm text-slate-500">
No chunks yet. Select verse ranges to add sections.
</div>
) : (
activeChapter.chunks.map((chunk, index) => (
<div
key={chunk.id}
className={`rounded-3xl border p-4 ${project.selectedChunkId === chunk.id ? 'border-sky-300 bg-sky-50' : 'border-slate-200 bg-white'} shadow-sm`}
>
<button
type="button"
onClick={() => updateProject((current) => ({ ...current, selectedChunkId: chunk.id }))}
className="mb-3 w-full text-left"
>
<p className="text-sm font-semibold text-slate-900">
{formatChunkReference(project, activeChapterIndex, chunk, '–')}
</p>
<p className="mt-1 text-sm text-slate-600 truncate">
{activeChapter.verses.find((v) => v.number === chunk.startVerse)?.text || ''}
</p>
</button>
<div className="flex items-center gap-2 text-sm text-slate-500">
<button
type="button"
onClick={() => moveChunk(chunk.id, -1)}
disabled={index === 0}
className="rounded-full border border-slate-300 bg-white px-2 py-1 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
↑
</button>
<button
type="button"
onClick={() => moveChunk(chunk.id, 1)}
disabled={index === activeChapter.chunks.length - 1}
className="rounded-full border border-slate-300 bg-white px-2 py-1 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
↓
</button>
<button
type="button"
onClick={() => deleteChunk(chunk.id)}
className="rounded-full border border-rose-300 bg-rose-50 px-2 py-1 text-rose-600 transition hover:bg-rose-100"
>
× Delete
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
)}
<div className="flex justify-end">
<button
type="button"
onClick={beginStudying}
disabled={allChunks.length === 0}
className="inline-flex items-center justify-center rounded-xl bg-slate-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-500"
>
Begin Studying →
</button>
</div>
</section>
)}
</main>
</div>
);
}
File diff suppressed because it is too large. Load diff
+118 -2
View File
@@ -15,16 +15,17 @@ async function request(method, path, body) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
};
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts);
const data = await res.json().catch(() => null);
if (!res.ok) {
return { ok: false, error: data?.error ?? `HTTP ${res.status}` };
return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}` };
}
return { ok: true, data };
} catch (err) {
return { ok: false, error: err?.message ?? 'Network error' };
return { ok: false, status: null, error: err?.message ?? 'Network error' };
}
}
@@ -64,6 +65,30 @@ export async function deleteRemoteProject(id) {
return request('DELETE', `/projects/${id}`);
}
// ---------------------------------------------------------------------------
// Read-only share links
// ---------------------------------------------------------------------------
/** Returns { shareToken } — the project's current share token, or null if sharing is off. */
export async function getShareStatus(id) {
return request('GET', `/projects/${id}/share`);
}
/** Enables sharing (or returns the existing token if already enabled). Returns { shareToken }. */
export async function enableSharing(id) {
return request('POST', `/projects/${id}/share`);
}
/** Revokes a project's share link. */
export async function disableSharing(id) {
return request('DELETE', `/projects/${id}/share`);
}
/** Public lookup — no session required. Returns the full project for a valid share token. */
export async function getSharedProject(token) {
return request('GET', `/share/${token}`);
}
/**
* Check whether the server is reachable.
* Returns true / false.
@@ -75,4 +100,95 @@ export async function isServerReachable() {
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
/**
* Returns the signed-in user, or null if not signed in.
* Returns { ok: false } (with status: null) only on a genuine network failure,
* so callers can distinguish "not logged in" from "server unreachable".
*/
export async function getCurrentUser() {
const result = await request('GET', '/auth/me');
if (result.ok) return { ok: true, user: result.data };
if (result.status === 401) return { ok: true, user: null };
return result;
}
export async function registerUser(email, password) {
return request('POST', '/auth/register', { email, password });
}
export async function loginUser(email, password) {
return request('POST', '/auth/login', { email, password });
}
export async function logoutUser() {
return request('POST', '/auth/logout');
}
/** Submits the code from the auth gate's post-password MFA step. Pass token or backupCode. */
export async function verifyMfaLogin({ token, backupCode }) {
return request('POST', '/auth/mfa/verify', { token, backupCode });
}
/** Updates account-level profile settings (currently just the podcast/show name). */
export async function updateProfile({ podcastName }) {
return request('PATCH', '/auth/profile', { podcastName });
}
/** Self-service password change. Requires the current password; signs out every other session. */
export async function changePassword(currentPassword, newPassword) {
return request('POST', '/auth/change-password', { currentPassword, newPassword });
}
// ---------------------------------------------------------------------------
// Two-factor auth setup (Account Settings page)
// ---------------------------------------------------------------------------
/** Starts 2FA setup: returns { secret, qrCodeDataUrl } for the user to scan. */
export async function startMfaSetup() {
return request('POST', '/auth/mfa/setup');
}
/** Confirms the scanned code and turns 2FA on. Returns { backupCodes } (shown once). */
export async function confirmMfaSetup(token) {
return request('POST', '/auth/mfa/enable', { token });
}
/** Turns 2FA off. Requires the current password as a safety check. */
export async function disableMfa(password) {
return request('POST', '/auth/mfa/disable', { password });
}
// ---------------------------------------------------------------------------
// Admin (restricted server-side to the designated admin account)
// ---------------------------------------------------------------------------
export async function adminListUsers() {
return request('GET', '/admin/users');
}
export async function adminDeleteUser(id) {
return request('DELETE', `/admin/users/${id}`);
}
/** Sets a random temporary password for a user and signs them out everywhere. Returns { temporaryPassword }. */
export async function adminResetPassword(id) {
return request('POST', `/admin/users/${id}/reset-password`);
}
export async function adminListProjects() {
return request('GET', '/admin/projects');
}
export async function adminGetProject(id) {
return request('GET', `/admin/projects/${id}`);
}
export async function adminDeleteProject(id) {
return request('DELETE', `/admin/projects/${id}`);
}
+44 -5
View File
@@ -7,11 +7,10 @@ import {
parseBibleChapter,
wordTableHtml,
buildExportHtml,
buildClaudePrompt,
createParagraphsFromText,
migrateChunk,
migrateProject,
} from './App.jsx';
} from './utils/bibleUtils.jsx';
import { buildMarkdownExport, buildClaudePrompt } from './utils/exportBuilders.js';
import { migrateChunk, migrateProject } from './utils/projectStorage.js';
import { createParagraphsFromText } from './utils/docxExport.js';
// ---------------------------------------------------------------------------
// storageKey
@@ -469,6 +468,46 @@ describe('buildExportHtml', () => {
});
});
// ---------------------------------------------------------------------------
// buildMarkdownExport
// ---------------------------------------------------------------------------
describe('buildMarkdownExport', () => {
test('includes the project title and translation as Markdown headers', () => {
const md = buildMarkdownExport(baseProject);
expect(md).toContain('# Titus 1 Study');
expect(md).toContain('*BSB*');
});
test('includes a chapter heading and chunk reference', () => {
const md = buildMarkdownExport(baseProject);
expect(md).toContain('## Titus 1');
expect(md).toContain('### Titus 1:1-2');
});
test('includes verse text and OIA notes', () => {
const md = buildMarkdownExport(baseProject);
expect(md).toContain('Paul, a servant of God.');
expect(md).toContain('Key observations.');
expect(md).toContain('Theological meaning.');
expect(md).toContain('Live it out.');
});
test('includes cross-references and Greek word data', () => {
const md = buildMarkdownExport(baseProject);
expect(md).toContain('John 1:1');
expect(md).toContain('G1401');
expect(md).toContain('δοῦλος');
});
test('shows placeholder text when observation is empty', () => {
const project = {
...baseProject,
chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, observation: '', interpretation: '', application: '' }] }],
};
expect(buildMarkdownExport(project)).toContain('_No observation._');
});
});
// ---------------------------------------------------------------------------
// createParagraphsFromText
// ---------------------------------------------------------------------------
+46
View File
@@ -0,0 +1,46 @@
// IndexedDB cache for the whole-Bible search index.
const BIBLE_IDB_NAME = 'study-app-bible-index';
const BIBLE_IDB_STORE = 'bible-index';
export const BIBLE_IDB_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
function openBibleIndexDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(BIBLE_IDB_NAME, 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(BIBLE_IDB_STORE)) {
db.createObjectStore(BIBLE_IDB_STORE, { keyPath: 'id' });
}
};
req.onsuccess = (e) => resolve(e.target.result);
req.onerror = () => reject(req.error);
});
}
export async function getBibleIndexFromIDB(translation) {
try {
const db = await openBibleIndexDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(BIBLE_IDB_STORE, 'readonly');
const req = tx.objectStore(BIBLE_IDB_STORE).get(translation);
req.onsuccess = () => resolve(req.result ?? null);
req.onerror = () => reject(req.error);
});
} catch {
return null;
}
}
export async function saveBibleIndexToIDB(translation, flat) {
try {
const db = await openBibleIndexDB();
await new Promise((resolve, reject) => {
const tx = db.transaction(BIBLE_IDB_STORE, 'readwrite');
const req = tx.objectStore(BIBLE_IDB_STORE).put({ id: translation, flat, cachedAt: Date.now() });
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
} catch {
// Non-fatal; in-memory cache still works for this session
}
}
+534
View File
@@ -0,0 +1,534 @@
import { useState, useEffect } from 'react';
// ---------------------------------------------------------------------------
// Study constants
// ---------------------------------------------------------------------------
export const COMMENTARY_OPTIONS = [
{ id: 'matthew-henry', name: 'Matthew Henry' },
{ id: 'john-gill', name: 'John Gill' },
{ id: 'jamieson-fausset-brown', name: 'Jamieson-Fausset-Brown' },
{ id: 'adam-clarke', name: 'Adam Clarke' },
{ id: 'keil-delitzsch', name: 'Keil & Delitzsch (OT)' },
{ id: 'tyndale', name: 'Tyndale Open Study Notes' },
];
export const STUDY_TABS = [
{ id: 'notes', label: 'Notes' },
{ id: 'crossRefs', label: 'Cross-Refs' },
{ id: 'wordStudy', label: 'Word Study' },
{ id: 'commentary', label: 'Commentary' },
{ id: 'script', label: 'Script' },
];
export const CHAPTER_COUNTS = {
GEN:50,EXO:40,LEV:27,NUM:36,DEU:34,JOS:24,JDG:21,RUT:4,
'1SA':31,'2SA':24,'1KI':22,'2KI':25,'1CH':29,'2CH':36,
EZR:10,NEH:13,EST:10,JOB:42,PSA:150,PRO:31,ECC:12,SNG:8,
ISA:66,JER:52,LAM:5,EZK:48,DAN:12,HOS:14,JOL:3,AMO:9,
OBA:1,JON:4,MIC:7,NAM:3,HAB:3,ZEP:3,HAG:2,ZEC:14,MAL:4,
MAT:28,MRK:16,LUK:24,JHN:21,ACT:28,ROM:16,'1CO':16,'2CO':13,
GAL:6,EPH:6,PHP:4,COL:4,'1TH':5,'2TH':3,'1TI':6,'2TI':4,
TIT:3,PHM:1,HEB:13,JAS:5,'1PE':5,'2PE':3,'1JN':5,'2JN':1,
'3JN':1,JUD:1,REV:22,
};
export const bookOptions = [
{ name: 'Genesis', abbrev: 'GEN' },
{ name: 'Exodus', abbrev: 'EXO' },
{ name: 'Leviticus', abbrev: 'LEV' },
{ name: 'Numbers', abbrev: 'NUM' },
{ name: 'Deuteronomy', abbrev: 'DEU' },
{ name: 'Joshua', abbrev: 'JOS' },
{ name: 'Judges', abbrev: 'JDG' },
{ name: 'Ruth', abbrev: 'RUT' },
{ name: '1 Samuel', abbrev: '1SA' },
{ name: '2 Samuel', abbrev: '2SA' },
{ name: '1 Kings', abbrev: '1KI' },
{ name: '2 Kings', abbrev: '2KI' },
{ name: '1 Chronicles', abbrev: '1CH' },
{ name: '2 Chronicles', abbrev: '2CH' },
{ name: 'Ezra', abbrev: 'EZR' },
{ name: 'Nehemiah', abbrev: 'NEH' },
{ name: 'Esther', abbrev: 'EST' },
{ name: 'Job', abbrev: 'JOB' },
{ name: 'Psalms', abbrev: 'PSA' },
{ name: 'Proverbs', abbrev: 'PRO' },
{ name: 'Ecclesiastes', abbrev: 'ECC' },
{ name: 'Song', abbrev: 'SNG' },
{ name: 'Isaiah', abbrev: 'ISA' },
{ name: 'Jeremiah', abbrev: 'JER' },
{ name: 'Lamentations', abbrev: 'LAM' },
{ name: 'Ezekiel', abbrev: 'EZK' },
{ name: 'Daniel', abbrev: 'DAN' },
{ name: 'Hosea', abbrev: 'HOS' },
{ name: 'Joel', abbrev: 'JOL' },
{ name: 'Amos', abbrev: 'AMO' },
{ name: 'Obadiah', abbrev: 'OBA' },
{ name: 'Jonah', abbrev: 'JON' },
{ name: 'Micah', abbrev: 'MIC' },
{ name: 'Nahum', abbrev: 'NAM' },
{ name: 'Habakkuk', abbrev: 'HAB' },
{ name: 'Zephaniah', abbrev: 'ZEP' },
{ name: 'Haggai', abbrev: 'HAG' },
{ name: 'Zechariah', abbrev: 'ZEC' },
{ name: 'Malachi', abbrev: 'MAL' },
{ name: 'Matthew', abbrev: 'MAT' },
{ name: 'Mark', abbrev: 'MRK' },
{ name: 'Luke', abbrev: 'LUK' },
{ name: 'John', abbrev: 'JHN' },
{ name: 'Acts', abbrev: 'ACT' },
{ name: 'Romans', abbrev: 'ROM' },
{ name: '1 Corinthians', abbrev: '1CO' },
{ name: '2 Corinthians', abbrev: '2CO' },
{ name: 'Galatians', abbrev: 'GAL' },
{ name: 'Ephesians', abbrev: 'EPH' },
{ name: 'Philippians', abbrev: 'PHP' },
{ name: 'Colossians', abbrev: 'COL' },
{ name: '1 Thessalonians', abbrev: '1TH' },
{ name: '2 Thessalonians', abbrev: '2TH' },
{ name: '1 Timothy', abbrev: '1TI' },
{ name: '2 Timothy', abbrev: '2TI' },
{ name: 'Titus', abbrev: 'TIT' },
{ name: 'Philemon', abbrev: 'PHM' },
{ name: 'Hebrews', abbrev: 'HEB' },
{ name: 'James', abbrev: 'JAS' },
{ name: '1 Peter', abbrev: '1PE' },
{ name: '2 Peter', abbrev: '2PE' },
{ name: '1 John', abbrev: '1JN' },
{ name: '2 John', abbrev: '2JN' },
{ name: '3 John', abbrev: '3JN' },
{ name: 'Jude', abbrev: 'JUD' },
{ name: 'Revelation', abbrev: 'REV' },
];
export const NT_BOOK_NUMBER = {
MAT: 40, MRK: 41, LUK: 42, JHN: 43, ACT: 44,
ROM: 45, '1CO': 46, '2CO': 47, GAL: 48, EPH: 49,
PHP: 50, COL: 51, '1TH': 52, '2TH': 53, '1TI': 54,
'2TI': 55, TIT: 56, PHM: 57, HEB: 58, JAS: 59,
'1PE': 60, '2PE': 61, '1JN': 62, '2JN': 63, '3JN': 64,
JUD: 65, REV: 66,
};
export const storageKey = (translation, book, chapter) => `bible-study-${translation}-${book}-${chapter}`;
export const makeId = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
// ---------------------------------------------------------------------------
// Format helpers
// ---------------------------------------------------------------------------
export function formatRelativeDate(ts) {
if (!ts) return '';
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function chunkSpansNextChapter(project, startChapterIndex, chunk) {
if (!project || !Array.isArray(project.chapters)) return false;
if (!Number.isInteger(chunk?.spilloverEndVerse)) return false;
if (startChapterIndex < 0 || startChapterIndex >= project.chapters.length - 1) return false;
const startChapter = project.chapters[startChapterIndex];
const nextChapter = project.chapters[startChapterIndex + 1];
return startChapter?.bookAbbrev && startChapter.bookAbbrev === nextChapter?.bookAbbrev;
}
export function formatChunkReference(project, startChapterIndex, chunk, separator = '-') {
if (!project || !chunk) return '';
const startChapter = project.chapters?.[startChapterIndex];
if (!startChapter) return '';
if (chunkSpansNextChapter(project, startChapterIndex, chunk)) {
const nextChapter = project.chapters[startChapterIndex + 1];
return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${nextChapter.chapter}:${chunk.spilloverEndVerse}`;
}
if (chunk.startVerse === chunk.endVerse) {
return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}`;
}
return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${chunk.endVerse}`;
}
export function getChunkVerseEntries(project, startChapterIndex, chunk) {
if (!project || !chunk) return [];
const startChapter = project.chapters?.[startChapterIndex];
if (!startChapter) return [];
const startVerses = (startChapter.verses ?? [])
.filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse)
.map((verse) => ({
book: startChapter.book,
chapter: startChapter.chapter,
...verse,
}));
if (!chunkSpansNextChapter(project, startChapterIndex, chunk)) {
return startVerses;
}
const nextChapter = project.chapters[startChapterIndex + 1];
const spilloverVerses = (nextChapter.verses ?? [])
.filter((verse) => verse.number >= 1 && verse.number <= chunk.spilloverEndVerse)
.map((verse) => ({
book: nextChapter.book,
chapter: nextChapter.chapter,
...verse,
}));
return [...startVerses, ...spilloverVerses];
}
// ---------------------------------------------------------------------------
// HTML export utilities
// ---------------------------------------------------------------------------
const escHtml = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
export function buildExportHtml(project) {
const style = `
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 32px; }
.page { max-width: 900px; margin: auto; }
h1, h2 { font-family: Georgia, serif; }
h1 { margin-bottom: 0.5rem; }
.chapter-heading { margin: 2rem 0 0.5rem; font-size: 1.2rem; font-weight: 700; border-bottom: 1px solid #cbd5e1; padding-bottom: 0.25rem; }
.chunk { margin-bottom: 2rem; padding: 1.25rem 1.5rem; border: 1px solid #cbd5e1; border-radius: 0.75rem; background: #ffffff; }
.verse { margin: 0 0 0.75rem; line-height: 1.7; }
.scripture-ref { font-weight: 700; margin-bottom: 0.75rem; }
.oia, .cross-refs, .greek { margin-top: 1rem; }
.oia-section { margin-bottom: 0.75rem; }
.greek table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
.greek th, .greek td { border: 1px solid #d1d5db; padding: 0.65rem; text-align: left; }
.greek th { background: #f8fafc; }
.definition { margin-top: 0.75rem; font-size: 0.95rem; line-height: 1.6; }
.definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; }
@media print {
body { padding: 0; }
.chunk { break-inside: avoid; border-color: #cbd5e1; }
.chapter-heading { break-after: avoid; }
}
`;
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
const chunksHtml = chapters.map((ch, chapterIndex) => {
const chapterHeader = `<h2 class="chapter-heading">${escHtml(ch.book)} ${escHtml(ch.chapter)}</h2>`;
const chunkSections = ch.chunks.map((chunk) => {
const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
.map((verse) => `<p class="verse"><strong>${escHtml(verse.chapter)}:${escHtml(verse.number)}</strong> ${escHtml(verse.text)}</p>`)
.join('');
const observation = escHtml((chunk.observation ?? '').trim()).replace(/\n/g, '<br />') || '<em>No observation.</em>';
const interpretation = escHtml((chunk.interpretation ?? '').trim()).replace(/\n/g, '<br />') || '<em>No interpretation.</em>';
const application = escHtml((chunk.application ?? '').trim()).replace(/\n/g, '<br />') || '<em>No application.</em>';
const crossRefsHtml = (chunk.crossReferences ?? []).length > 0
? `<div class="cross-refs"><strong>CROSS-REFERENCES:</strong> ${chunk.crossReferences.map(escHtml).join(', ')}</div>`
: '';
const greekRows = chunk.greekWords.map((word) => `
<tr>
<td><strong>${escHtml(word.strongNumber)}</strong></td>
<td>${escHtml(word.lexeme)}</td>
<td>${escHtml(word.transliteration)}</td>
<td>${escHtml(word.partOfSpeech)}</td>
<td>${escHtml(word.shortDefinition)}</td>
</tr>
`).join('');
const greekTable = wordTableHtml(greekRows);
const extendedDefinitions = chunk.greekWords
.map((word) => {
if (word.definitionHtml) {
return `
<div class="definition-block">
<p><strong>${escHtml(word.strongNumber)} — ${escHtml(word.lexeme)}</strong></p>
<div class="definition">${word.definitionHtml}</div>
</div>
`;
}
if ((word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query) {
const raw = word.query.trim();
const normalized = /^\d+$/.test(raw) ? `G${raw}` : raw.toUpperCase();
const isStrongs = /^G\d+$/.test(normalized);
const num = isStrongs ? normalized.slice(1) : null;
const links = isStrongs && num
? `<a href="https://biblehub.com/greek/${num}.htm">BibleHub</a> · <a href="https://www.blueletterbible.org/lexicon/g${num}/esv/0-1/">Blue Letter Bible</a> · <a href="https://www.studylight.org/lexicons/eng/greek/${num}.html">StudyLight</a>`
: `<a href="https://biblehub.com/search.php?q=${encodeURIComponent(raw)}">BibleHub</a> · <a href="https://www.blueletterbible.org/search/search.cfm?Criteria=${encodeURIComponent(raw)}&t=KJV#s=s_lexiconc">Blue Letter Bible</a>`;
return `
<div class="definition-block" style="border-color:#fcd34d;background:#fffbeb;">
<p><strong>${escHtml(raw)}</strong> — definition not found in auto-lookup</p>
<p style="margin-top:0.5rem;font-size:0.9rem;">Look it up manually: ${links}</p>
</div>
`;
}
return '';
})
.join('');
return `
<section class="chunk">
<div class="scripture-ref">${escHtml(scripture)}</div>
${versesText}
<div class="oia">
<div class="oia-section"><strong>OBSERVATION:</strong><p>${observation}</p></div>
<div class="oia-section"><strong>INTERPRETATION:</strong><p>${interpretation}</p></div>
<div class="oia-section"><strong>APPLICATION:</strong><p>${application}</p></div>
</div>
${crossRefsHtml}
<div class="greek"><strong>GREEK WORDS:</strong>
${greekRows ? greekTable : '<p><em>No Greek word notes.</em></p>'}
${extendedDefinitions}
</div>
</section>
`;
}).join('');
return chapterHeader + chunkSections;
}).join('');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escHtml(project.title)}</title>
<style>${style}</style>
</head>
<body>
<div class="page">
<h1>${escHtml(project.title)}</h1>
<p>${escHtml(project.translation)}</p>
${chunksHtml}
</div>
</body>
</html>`;
}
export function wordTableHtml(rows) {
return `
<table>
<thead>
<tr>
<th>Strong's</th>
<th>Greek</th>
<th>Transliteration</th>
<th>Part of Speech</th>
<th>Short Definition</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
}
export function extractPartOfSpeech(definitionHtml) {
const match = /Part\(s\) of speech:\s*([^<]+)/i.exec(definitionHtml || '');
if (match && match[1]) {
return match[1].trim();
}
return '';
}
export function htmlToPlainText(html = '') {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const lines = [];
doc.body.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
lines.push(node.textContent || '');
return;
}
if (node.nodeName === 'BR' || node.nodeName === 'P') {
lines.push(node.textContent || '');
lines.push('\n');
return;
}
lines.push(node.textContent || '');
});
return lines.join('').replace(/\n{2,}/g, '\n').trim();
}
export function renderVerseContent(content) {
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content.map(renderVerseContent).join('');
}
if (content && typeof content === 'object') {
if (content.lineBreak) {
return '\n';
}
if (content.type === 'line_break') {
return '\n';
}
if (Array.isArray(content.content)) {
return renderVerseContent(content.content);
}
if (typeof content.text === 'string') {
return content.text;
}
return '';
}
return '';
}
export function parseBibleChapter(data) {
if (data?.verses && Array.isArray(data.verses)) {
return data.verses.map((verse) => ({
number: verse.number,
text: verse.text,
}));
}
const chapterContents = data?.chapter?.content;
if (!Array.isArray(chapterContents)) {
return [];
}
return chapterContents
.filter((item) => item?.type === 'verse')
.map((item) => ({
number: item.number,
text: renderVerseContent(item.content).replace(/\s+/g, ' ').trim(),
}));
}
// ---------------------------------------------------------------------------
// Storage helpers
// ---------------------------------------------------------------------------
const INDEX_KEY_LEGACY = 'bible-study-index';
const projectKeyLegacy = (id) => `bible-study-project-${id}`;
let activeStorageUserId = null;
export function indexKey() {
return activeStorageUserId ? `bible-study-index:${activeStorageUserId}` : INDEX_KEY_LEGACY;
}
export function projectKey(id) {
return activeStorageUserId ? `bible-study-project:${activeStorageUserId}:${id}` : projectKeyLegacy(id);
}
export function loadProjectIndex() {
try {
const raw = window.localStorage.getItem(indexKey());
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function migrateLegacyLocalDataToUser(userId) {
if (!userId || window.localStorage.getItem(`bible-study-index:${userId}`)) return;
const legacyRaw = window.localStorage.getItem(INDEX_KEY_LEGACY);
if (!legacyRaw) return;
try {
const legacyIndex = JSON.parse(legacyRaw) ?? [];
legacyIndex.forEach((entry) => {
const raw = window.localStorage.getItem(projectKeyLegacy(entry.id));
if (raw != null) {
window.localStorage.setItem(`bible-study-project:${userId}:${entry.id}`, raw);
window.localStorage.removeItem(projectKeyLegacy(entry.id));
}
});
window.localStorage.setItem(`bible-study-index:${userId}`, legacyRaw);
window.localStorage.removeItem(INDEX_KEY_LEGACY);
} catch {
// Leave legacy data in place if anything goes wrong
}
}
export function switchStorageUser(userId) {
activeStorageUserId = userId ?? null;
if (userId) migrateLegacyLocalDataToUser(userId);
return loadProjectIndex();
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
export function BookmarkIcon({ filled }) {
return (
<svg viewBox="0 0 20 20" width="16" height="16" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.6">
<path d="M5 3.5C5 2.67 5.67 2 6.5 2h7c.83 0 1.5.67 1.5 1.5v14l-5-3.5-5 3.5v-14z" strokeLinejoin="round" />
</svg>
);
}
export function CopyIcon() {
return (
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6">
<rect x="7" y="7" width="10" height="11" rx="1.5" />
<path d="M4.5 13.5h-1A1.5 1.5 0 0 1 2 12V3.5A1.5 1.5 0 0 1 3.5 2H12a1.5 1.5 0 0 1 1.5 1.5v1" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function CrossRefChip({ label, onRemove, loadVerseText }) {
const [hovered, setHovered] = useState(false);
const [verseText, setVerseText] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
if (!hovered || verseText || error) return;
let cancelled = false;
loadVerseText(label)
.then((text) => { if (!cancelled) setVerseText(text || 'Verse text unavailable.'); })
.catch(() => { if (!cancelled) setError('Verse text unavailable.'); });
return () => { cancelled = true; };
}, [hovered]);
return (
<span
className="relative inline-flex items-center gap-1 rounded-full bg-slate-200 px-3 py-1 text-sm text-slate-800"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{label}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="ml-1 text-slate-500 hover:text-rose-600"
aria-label={`Remove ${label}`}
>
×
</button>
)}
{hovered && (
<span className="absolute bottom-full left-0 z-10 mb-2 w-64 rounded-xl border border-slate-300 bg-white p-3 text-xs font-normal normal-case text-slate-700 shadow-lg">
{error || verseText || 'Loading...'}
</span>
)}
</span>
);
}
export function buildPronunciationGuide(project) {
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
const lines = [];
chapters.forEach((ch) => {
ch.chunks.forEach((chunk) => {
(chunk.greekWords ?? []).forEach((word) => {
const gloss = word.shortDefinition || word.englishGloss || '';
const lexeme = word.lexeme || '';
const translit = word.transliteration || '';
if (!lexeme && !translit) return;
const label = gloss ? `${gloss} — ` : '';
lines.push(`${label}${lexeme}${translit ? ` (${translit})` : ''}`);
});
});
});
if (lines.length === 0) return 'No Greek/Hebrew words collected yet.';
return lines.join('\n');
}
+217
View File
@@ -0,0 +1,217 @@
// DOCX export — kept in its own module so the (large) `docx` library is only
// downloaded when the user actually exports, instead of on every page load.
import {
BorderStyle,
Document,
HeadingLevel,
Packer,
Paragraph,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
} from 'docx';
import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx';
export function createParagraphsFromText(text) {
return text
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => new Paragraph({ children: [new TextRun({ text: line })] }));
}
const DOCX_ACCENT = 'C9A84C'; // antique gold
const DOCX_HEADING_FONT = 'Cormorant Garamond';
const DOCX_BODY_FONT = 'Lora';
export function sectionLabel(text) {
return new Paragraph({
spacing: { before: 240, after: 80 },
children: [
new TextRun({
text: text.toUpperCase(),
bold: true,
color: DOCX_ACCENT,
font: DOCX_BODY_FONT,
size: 20,
characterSpacing: 20,
}),
],
});
}
export async function buildStudyDocxBlob(project, chapterSummary) {
const children = [
new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }),
new Paragraph({
children: [
new TextRun({
text: `${project.translation} — ${chapterSummary}`,
italics: true,
color: '7A7060',
}),
],
spacing: { after: 300 },
}),
];
const noBorder = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' };
project.chapters.forEach((ch, chapterIndex) => {
children.push(new Paragraph({
text: `${ch.book} ${ch.chapter}`,
heading: HeadingLevel.HEADING_1,
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: DOCX_ACCENT, space: 4 } },
spacing: { before: 360, after: 160 },
}));
ch.chunks.forEach((chunk) => {
const scriptureHeading = formatChunkReference(project, chapterIndex, chunk, '-');
const headingRuns = [new TextRun({ text: scriptureHeading, bold: true, size: 28, color: DOCX_ACCENT, font: DOCX_HEADING_FONT })];
if (chunk.episodeNumber || chunk.episodeTitle) {
headingRuns.push(new TextRun({
text: ` (Session ${chunk.episodeNumber || '—'}${chunk.episodeTitle ? `: ${chunk.episodeTitle}` : ''})`,
italics: true,
size: 22,
color: '7A7060',
}));
}
children.push(new Paragraph({ children: headingRuns, spacing: { before: 320, after: 120 } }));
// Scripture text in a left-bordered table cell for a "callout" look (no fill)
const scriptureParas = getChunkVerseEntries(project, chapterIndex, chunk).map((verse) => new Paragraph({
children: [
new TextRun({ text: `${verse.chapter}:${verse.number} `, bold: true, color: DOCX_ACCENT }),
new TextRun({ text: verse.text, italics: true }),
],
spacing: { after: 80 },
}));
children.push(new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows: [
new TableRow({
children: [
new TableCell({
margins: { top: 120, bottom: 120, left: 180, right: 180 },
borders: {
...{ top: noBorder, right: noBorder, bottom: noBorder },
left: { style: BorderStyle.SINGLE, size: 24, color: DOCX_ACCENT },
},
children: scriptureParas,
}),
],
}),
],
}));
children.push(new Paragraph({ text: '', spacing: { after: 120 } }));
if (chunk.generalNotes?.trim()) {
children.push(sectionLabel('Background / General Notes'));
children.push(...createParagraphsFromText(chunk.generalNotes));
}
children.push(sectionLabel('Observation'));
children.push(...createParagraphsFromText(chunk.observation || 'No observation.'));
children.push(sectionLabel('Interpretation'));
children.push(...createParagraphsFromText(chunk.interpretation || 'No interpretation.'));
children.push(sectionLabel('Application'));
children.push(...createParagraphsFromText(chunk.application || 'No application.'));
if ((chunk.tags ?? []).length > 0) {
children.push(sectionLabel('Tags'));
children.push(new Paragraph({
children: chunk.tags.map((tag, i) => new TextRun({
text: i === 0 ? `#${tag}` : ` #${tag}`,
bold: true,
color: DOCX_ACCENT,
})),
}));
}
if ((chunk.crossReferences ?? []).length > 0) {
children.push(sectionLabel('Cross-References'));
children.push(new Paragraph({ text: chunk.crossReferences.join(' • ') }));
}
if (chunk.greekWords.length > 0) {
children.push(sectionLabel('Word Studies'));
const headerCellStyle = (label) => new TableCell({
width: { size: 20, type: WidthType.PERCENTAGE },
borders: { bottom: { style: BorderStyle.SINGLE, size: 8, color: DOCX_ACCENT } },
children: [new Paragraph({ children: [new TextRun({ text: label, bold: true, color: DOCX_ACCENT })] })],
});
const tableRows = [
new TableRow({
tableHeader: true,
children: ['Strong', 'Greek', 'Transliteration', 'Part of Speech', 'Short Definition'].map(headerCellStyle),
}),
...chunk.greekWords.map((word) => new TableRow({
children: [
new TableCell({ children: [new Paragraph(word.strongNumber || '')] }),
new TableCell({ children: [new Paragraph(word.lexeme || '')] }),
new TableCell({ children: [new Paragraph(word.transliteration || '')] }),
new TableCell({ children: [new Paragraph(word.partOfSpeech || '')] }),
new TableCell({ children: [new Paragraph(word.shortDefinition || '')] }),
],
})),
];
children.push(new Table({ rows: tableRows, width: { size: 100, type: WidthType.PERCENTAGE } }));
chunk.greekWords.forEach((word) => {
if (word.definitionHtml) {
children.push(new Paragraph({
spacing: { before: 180, after: 80 },
children: [new TextRun({ text: `${word.strongNumber} — ${word.lexeme || ''}`, bold: true, color: DOCX_ACCENT })],
}));
children.push(...createParagraphsFromText(htmlToPlainText(word.definitionHtml)));
}
});
}
if (chunk.finalScript?.trim()) {
children.push(sectionLabel('Final Script'));
children.push(...createParagraphsFromText(chunk.finalScript));
}
children.push(new Paragraph({
text: '',
spacing: { after: 200 },
border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: '2A2518', space: 8 } },
}));
});
});
const doc = new Document({
styles: {
default: {
document: { run: { font: DOCX_BODY_FONT, color: '0A0A08' } },
},
paragraphStyles: [
{
id: 'Title',
name: 'Title',
basedOn: 'Normal',
next: 'Normal',
run: { font: DOCX_HEADING_FONT, size: 56, bold: true, color: '0A0A08' },
},
{
id: 'Heading1',
name: 'Heading 1',
basedOn: 'Normal',
next: 'Normal',
run: { font: DOCX_HEADING_FONT, size: 36, bold: true, color: '0A0A08' },
},
{
id: 'Heading2',
name: 'Heading 2',
basedOn: 'Normal',
next: 'Normal',
run: { font: DOCX_HEADING_FONT, size: 30, bold: true, color: '0A0A08' },
},
],
},
sections: [{ children }],
});
return Packer.toBlob(doc);
}
+213
View File
@@ -0,0 +1,213 @@
// Markdown export and Claude prompt builders.
import {
formatChunkReference,
getChunkVerseEntries,
htmlToPlainText,
} from './bibleUtils.jsx';
// ---------------------------------------------------------------------------
// Export / prompt builders (exported for testing)
// ---------------------------------------------------------------------------
export function buildMarkdownExport(project) {
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
const chapterSections = chapters.map((ch, chapterIndex) => {
const chunkSections = ch.chunks.map((chunk) => {
const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
.map((verse) => `> **${verse.chapter}:${verse.number}** ${verse.text}`)
.join('\n>\n');
const observation = (chunk.observation ?? '').trim() || '_No observation._';
const interpretation = (chunk.interpretation ?? '').trim() || '_No interpretation._';
const application = (chunk.application ?? '').trim() || '_No application._';
const generalNotes = (chunk.generalNotes ?? '').trim();
const crossRefsLine = (chunk.crossReferences ?? []).length > 0
? `**Cross-References:** ${chunk.crossReferences.join(', ')}\n\n`
: '';
const greekSection = chunk.greekWords.length === 0
? ''
: `**Greek/Hebrew Words**\n\n${chunk.greekWords.map((word) => {
const summary = [
word.strongNumber,
word.lexeme,
word.transliteration && `(${word.transliteration})`,
word.partOfSpeech,
word.shortDefinition,
].filter(Boolean).join(' — ');
const definition = word.definitionHtml ? `\n ${htmlToPlainText(word.definitionHtml)}` : '';
return `- ${summary}${definition}`;
}).join('\n')}\n\n`;
return `### ${scripture}\n\n${versesText}\n\n${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
}).join('---\n\n');
return `## ${ch.book} ${ch.chapter}\n\n${chunkSections}`;
}).join('\n');
return `# ${project.title}\n\n*${project.translation}*\n\n${chapterSections}`;
}
export function buildChunkBodies(project) {
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
let chunkIndex = 0;
return chapters.map((ch, chapterIndex) => {
return ch.chunks.map((chunk) => {
chunkIndex += 1;
const ref = formatChunkReference(project, chapterIndex, chunk, '–');
const verses = getChunkVerseEntries(project, chapterIndex, chunk)
.map((v) => `${v.chapter}:${v.number} ${v.text}`)
.join('\n');
const observation = (chunk.observation ?? '').trim() || 'No observation.';
const interpretation = (chunk.interpretation ?? '').trim() || 'No interpretation.';
const application = (chunk.application ?? '').trim() || 'No application.';
const crossRefs = (chunk.crossReferences ?? []).length > 0
? chunk.crossReferences.join(', ')
: 'None.';
const generalNotes = (chunk.generalNotes ?? '').trim();
const episodeLabelLine = (chunk.episodeNumber ?? '').trim() || (chunk.episodeTitle ?? '').trim()
? `Session: ${[chunk.episodeNumber?.trim(), chunk.episodeTitle?.trim()].filter(Boolean).join(' — ')}\n`
: '';
const greekWords = chunk.greekWords.length === 0
? 'None.'
: chunk.greekWords.map((word) => {
const summary = [
word.strongNumber,
word.lexeme,
word.transliteration && `(${word.transliteration})`,
word.partOfSpeech,
word.shortDefinition,
].filter(Boolean).join(' | ');
const definition = word.definitionHtml
? `\n ${htmlToPlainText(word.definitionHtml)}`
: '';
return `• ${summary}${definition}`;
}).join('\n');
return `===
CHUNK ${chunkIndex} — ${ref}
${episodeLabelLine}
Scripture:
${verses}
${generalNotes ? `\nBackground / General Notes:\n${generalNotes}\n` : ''}
Observation:
${observation}
Interpretation:
${interpretation}
Application:
${application}
Cross-References: ${crossRefs}
Greek Words:
${greekWords}
`;
}).join('\n');
}).join('\n');
}
export function buildClaudePrompt(project) {
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', ');
const header = `I've prepared a Bible study on ${chapterLabel} (${project.translation}) and need your help turning my notes into a polished study guide.
Below is my work organised by passage chunk, including my OIA notes and Greek word research. Please create a clear, structured study guide that:
- Synthesises my notes into coherent teaching points
- Naturally integrates the Greek word insights
- Includes 2–3 reflection questions per chunk
- Preserves the passage-by-passage structure
---
PROJECT: ${project.title}
TRANSLATION: ${project.translation}
PASSAGE: ${chapterLabel}
`;
return header + buildChunkBodies(project);
}
export function buildPodcastPrompt(project, podcastName) {
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', ');
const episodeLabel = 'EPISODE';
const showName = (podcastName ?? '').trim();
const showLine = showName || 'MY BIBLE STUDY PODCAST';
const signOffLine = showName
? `Introduce yourself, then sign off with: "...and this is ${showName}."`
: 'Introduce yourself and the name of the show as a sign-off.';
const header = `I'm recording an episode of my Bible study podcast${showName ? ` "${showName}"` : ''} and need a full script written from my study notes below.
Please write the script in this exact structure, using the section markers and tone shown:
${showLine.toUpperCase()}
${episodeLabel}
${chapterLabel} (${project.translation}) · [estimate XX–XX minutes based on content]
✝ OPENING PRAYER
[Pause before opening the text — invite God into the study]
A short prayer (4-6 sentences) tying into the themes of this passage.
— ✦ —
🎙️ COLD OPEN
[Co-host delivers the cold open solo, if there is one — hands off to the host at the end]
A few short punchy lines previewing the passage and its hook, ending with a hand-off line introducing the host and the show.
— ✦ —
📖 SEGMENT [N] — [SEGMENT TITLE]
[Brief stage direction in brackets]
One segment per passage chunk (use the chunk reference, observation, interpretation, and application notes below as the raw material). Conversational, spoken-word style — not academic prose. Work through the text the way the host would talk it through out loud, weaving in the OIA notes and cross-references naturally.
— ✦ —
📖 SEGMENT [N] — GREEK WORD STUDY
[Work through the key terms — keep it vivid, help the words come alive]
For each Greek/Hebrew word collected below, a block in this format:
[English Gloss] — [English meaning]
[DO NOT READ: original-language word (transliteration) — part of speech]
2-4 sentences unpacking the word's meaning and why it matters for this passage, conversational tone.
— ✦ —
💬 DISCUSSION QUESTIONS
[From the study guide — ${chapterLabel}]
3 reflection questions drawn from the application notes, numbered.
— ✦ —
✦ CLOSING
[Grounded and direct — send them away with something to carry]
A closing reflection that ties the segments together, a short pull-quote from the passage with its reference, then close out. ${signOffLine}
"Until next time — keep studying verse by verse."
— End of Episode —
${showLine} · ${episodeLabel} · ${chapterLabel}
---
Here are my study notes, organised by passage chunk, including my OIA notes, cross-references, and Greek/Hebrew word research:
PROJECT: ${project.title}
TRANSLATION: ${project.translation}
PASSAGE: ${chapterLabel}
`;
return header + buildChunkBodies(project);
}
+104
View File
@@ -0,0 +1,104 @@
// Parsers for typed cross-references and imported DOCX session lists.
import {
bookOptions,
} from './bibleUtils.jsx';
// Common short-form aliases for typed-in cross-references (e.g. "Rom 3:23")
export const BOOK_NAME_ALIASES = {
'gen': 'GEN', 'genesis': 'GEN', 'exo': 'EXO', 'exod': 'EXO', 'exodus': 'EXO',
'lev': 'LEV', 'leviticus': 'LEV', 'num': 'NUM', 'numbers': 'NUM',
'deut': 'DEU', 'deuteronomy': 'DEU', 'josh': 'JOS', 'joshua': 'JOS',
'judg': 'JDG', 'judges': 'JDG', 'ruth': 'RUT',
'1 sam': '1SA', '1 samuel': '1SA', '2 sam': '2SA', '2 samuel': '2SA',
'1 kgs': '1KI', '1 kings': '1KI', '2 kgs': '2KI', '2 kings': '2KI',
'1 chr': '1CH', '1 chronicles': '1CH', '2 chr': '2CH', '2 chronicles': '2CH',
'ezra': 'EZR', 'neh': 'NEH', 'nehemiah': 'NEH', 'esth': 'EST', 'esther': 'EST',
'job': 'JOB', 'ps': 'PSA', 'psa': 'PSA', 'psalm': 'PSA', 'psalms': 'PSA',
'prov': 'PRO', 'proverbs': 'PRO', 'eccl': 'ECC', 'ecclesiastes': 'ECC',
'song': 'SNG', 'isa': 'ISA', 'isaiah': 'ISA', 'jer': 'JER', 'jeremiah': 'JER',
'lam': 'LAM', 'lamentations': 'LAM', 'ezek': 'EZK', 'ezekiel': 'EZK',
'dan': 'DAN', 'daniel': 'DAN', 'hos': 'HOS', 'hosea': 'HOS', 'joel': 'JOL',
'amos': 'AMO', 'obad': 'OBA', 'obadiah': 'OBA', 'jonah': 'JON', 'mic': 'MIC',
'micah': 'MIC', 'nah': 'NAM', 'nahum': 'NAM', 'hab': 'HAB', 'habakkuk': 'HAB',
'zeph': 'ZEP', 'zephaniah': 'ZEP', 'hag': 'HAG', 'haggai': 'HAG',
'zech': 'ZEC', 'zechariah': 'ZEC', 'mal': 'MAL', 'malachi': 'MAL',
'matt': 'MAT', 'mt': 'MAT', 'matthew': 'MAT', 'mark': 'MRK', 'mk': 'MRK',
'luke': 'LUK', 'lk': 'LUK', 'john': 'JHN', 'jn': 'JHN', 'acts': 'ACT',
'rom': 'ROM', 'romans': 'ROM', '1 cor': '1CO', '1 corinthians': '1CO',
'2 cor': '2CO', '2 corinthians': '2CO', 'gal': 'GAL', 'galatians': 'GAL',
'eph': 'EPH', 'ephesians': 'EPH', 'phil': 'PHP', 'philippians': 'PHP',
'col': 'COL', 'colossians': 'COL', '1 thess': '1TH', '1 thessalonians': '1TH',
'2 thess': '2TH', '2 thessalonians': '2TH', '1 tim': '1TI', '1 timothy': '1TI',
'2 tim': '2TI', '2 timothy': '2TI', 'titus': 'TIT', 'phlm': 'PHM', 'philemon': 'PHM',
'heb': 'HEB', 'hebrews': 'HEB', 'jas': 'JAS', 'james': 'JAS',
'1 pet': '1PE', '1 peter': '1PE', '2 pet': '2PE', '2 peter': '2PE',
'1 jn': '1JN', '1 john': '1JN', '2 jn': '2JN', '2 john': '2JN',
'3 jn': '3JN', '3 john': '3JN', 'jude': 'JUD', 'rev': 'REV', 'revelation': 'REV',
};
bookOptions.forEach((b) => {
BOOK_NAME_ALIASES[b.name.toLowerCase()] = b.abbrev;
});
// Parses strings like "Romans 3:23", "1 Timothy 3:2-7" into { bookAbbrev, chapter, verse, endVerse }
export function parseCrossRefString(ref) {
const match = ref.trim().match(/^(.+?)\s+(\d+):(\d+)(?:-(\d+))?$/);
if (!match) return null;
const [, bookPart, chapter, verse, endVerse] = match;
const abbrev = BOOK_NAME_ALIASES[bookPart.trim().toLowerCase()];
if (!abbrev) return null;
return {
bookAbbrev: abbrev,
chapter: Number(chapter),
verse: Number(verse),
endVerse: endVerse ? Number(endVerse) : Number(verse),
};
}
// Parses a "Passage" cell like "1:1–2", "9:32–10:23", "9:1–19a", or "—" (no specific passage)
export function parseEpisodePassage(raw) {
const str = (raw ?? '').trim().replace(/(\d)[a-z]\b/g, '$1'); // strip "19a"/"19b" verse-letter suffixes
if (!str || /^[-–—]+$/.test(str)) return null;
const match = str.match(/^(\d+):(\d+)\s*[-–—]\s*(?:(\d+):)?(\d+)$/)
?? str.match(/^(\d+):(\d+)$/);
if (!match) return 'invalid';
const [, ch1, v1, ch2, v2raw] = match;
const endChapter = ch2 ? Number(ch2) : Number(ch1);
const endVerse = v2raw !== undefined ? Number(v2raw) : Number(v1);
return {
startChapter: Number(ch1),
startVerse: Number(v1),
endChapter,
endVerse,
};
}
// Extracts a "Session # / Title / Passage" list from a docx, including both
// table rows and standalone "Ep. N — Title" / "Session N — Title" paragraphs (e.g. part intros).
export async function parseEpisodeListDocx(file) {
const arrayBuffer = await file.arrayBuffer();
const { default: mammoth } = await import('mammoth');
const { value: html } = await mammoth.convertToHtml({ arrayBuffer });
const parsedDoc = new DOMParser().parseFromString(html, 'text/html');
const episodes = new Map(); // episodeNumber -> { episodeNumber, title, passage }
parsedDoc.querySelectorAll('table tr').forEach((row) => {
const cells = Array.from(row.querySelectorAll('td,th')).map((c) => c.textContent.trim());
if (cells.length < 3) return;
const [epRaw, title, passage] = cells;
if (!/^\d+$/.test(epRaw)) return; // header row
episodes.set(epRaw, { episodeNumber: epRaw, title, passage });
});
parsedDoc.querySelectorAll('p').forEach((p) => {
const match = p.textContent.trim().match(/^(?:Ep\.|Episode|Session)\s*(\d+)\s*[-–—]\s*(.+)$/i);
if (!match) return;
const [, epRaw, title] = match;
if (!episodes.has(epRaw)) {
episodes.set(epRaw, { episodeNumber: epRaw, title: title.trim(), passage: '—' });
}
});
return Array.from(episodes.values()).sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber));
}
+66
View File
@@ -0,0 +1,66 @@
import { getStroke } from 'perfect-freehand';
export function pathFromStroke(points) {
if (!points.length) return '';
const d = [`M ${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}`];
for (let i = 1; i < points.length - 1; i++) {
const mx = ((points[i][0] + points[i + 1][0]) / 2).toFixed(2);
const my = ((points[i][1] + points[i + 1][1]) / 2).toFixed(2);
d.push(`Q ${points[i][0].toFixed(2)} ${points[i][1].toFixed(2)} ${mx} ${my}`);
}
d.push('Z');
return d.join(' ');
}
// Renders saved strokes + optional active stroke onto a canvas element.
// Coordinates are stored as fractions [0,1] of the draw canvas dimensions.
// size is stored as a fraction of canvas height (e.g. 0.012 ≈ 8px on a 600px canvas).
export function renderInkToCanvas(
canvas,
strokes,
activeStroke = [],
activeTool = 'pen',
activeColor = '#0f172a',
activeSize = 0.012,
) {
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (!w || !h) return;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
function strokeOpts(tool, baseSize, points) {
const avgTilt = points.length
? points.reduce((s, p) => s + (p[3] ?? 0), 0) / points.length
: 0;
return {
// Tilt widens the stroke and reduces pressure-thinning (pencil-shading feel)
size: tool === 'pen' ? baseSize * (1 + avgTilt * 2.5) : baseSize,
thinning: tool === 'highlighter' ? 0 : 0.5 * (1 - (tool === 'pen' ? avgTilt * 0.8 : 0)),
smoothing: 0.5,
streamline: 0.4,
};
}
for (const s of strokes) {
const pts = s.points.map(([x, y, p]) => [x * w, y * h, p]);
const outline = getStroke(pts, strokeOpts(s.tool, s.size * h, s.points));
ctx.globalAlpha = s.tool === 'highlighter' ? 0.35 : 1;
ctx.fillStyle = s.color;
ctx.fill(new Path2D(pathFromStroke(outline)));
}
if (activeStroke.length > 1 && activeTool !== 'eraser') {
const pts = activeStroke.map(([x, y, p]) => [x * w, y * h, p]);
const outline = getStroke(pts, strokeOpts(activeTool, activeSize * h, activeStroke));
ctx.globalAlpha = activeTool === 'highlighter' ? 0.35 : 1;
ctx.fillStyle = activeColor;
ctx.fill(new Path2D(pathFromStroke(outline)));
}
ctx.globalAlpha = 1;
}
+49
View File
@@ -0,0 +1,49 @@
// File exports of a study project: HTML, Markdown, DOCX, and print-to-PDF.
import { buildExportHtml } from './bibleUtils.jsx';
import { buildMarkdownExport } from './exportBuilders.js';
import { buildChapterSummary } from './projectStorage.js';
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
const fileBase = (project) => `${project.title.replace(/\s+/g, '-')}-study`;
export function exportProjectHtml(project) {
const html = buildExportHtml(project);
downloadBlob(new Blob([html], { type: 'text/html;charset=utf-8' }), `${fileBase(project)}.html`);
}
export function exportProjectMarkdown(project) {
const markdown = buildMarkdownExport(project);
downloadBlob(new Blob([markdown], { type: 'text/markdown;charset=utf-8' }), `${fileBase(project)}.md`);
}
export async function exportProjectDocx(project) {
// docx is large, so it's only downloaded when someone actually exports.
const { buildStudyDocxBlob } = await import('./docxExport.js');
const blob = await buildStudyDocxBlob(project, buildChapterSummary(project));
downloadBlob(blob, `${fileBase(project)}.docx`);
}
export function printProjectPdf(project) {
const html = buildExportHtml(project);
const printWindow = window.open('', '_blank');
if (!printWindow) {
alert('Please allow pop-ups for this site to print or save as PDF.');
return;
}
printWindow.document.write(html);
printWindow.document.close();
printWindow.onload = () => {
printWindow.focus();
printWindow.print();
};
}
+144
View File
@@ -0,0 +1,144 @@
// Project persistence in localStorage, plus migrations from older data shapes.
import {
makeId,
indexKey,
projectKey,
loadProjectIndex,
} from './bibleUtils.jsx';
// ---------------------------------------------------------------------------
// Migration helpers (exported for testing)
// ---------------------------------------------------------------------------
export function migrateChunk(chunk) {
if (chunk.observation !== undefined) {
return {
...chunk,
spilloverEndVerse: Number.isInteger(chunk.spilloverEndVerse) ? chunk.spilloverEndVerse : null,
generalNotes: chunk.generalNotes ?? '',
episodeNumber: chunk.episodeNumber ?? '',
episodeTitle: chunk.episodeTitle ?? '',
finalScript: chunk.finalScript ?? '',
tags: chunk.tags ?? [],
}; // already new format
}
return {
...chunk,
observation: chunk.notes ?? '',
interpretation: '',
application: '',
crossReferences: [],
tags: [],
spilloverEndVerse: null,
generalNotes: '',
episodeNumber: '',
episodeTitle: '',
finalScript: '',
notes: undefined,
};
}
export function migrateProject(raw) {
if (!raw) return null;
// Already new format
if (Array.isArray(raw.chapters)) {
return {
...raw,
chapters: raw.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map(migrateChunk),
})),
};
}
// Old flat format → wrap in chapters array
const id = raw.id ?? makeId();
return {
id,
title: raw.title ?? '',
translation: raw.translation ?? 'BSB',
lastEdited: raw.lastEdited ?? Date.now(),
selectedChunkId: raw.selectedChunkId ?? null,
chapters: [
{
book: raw.book ?? '',
bookAbbrev: raw.bookAbbrev ?? '',
chapter: raw.chapter ?? '1',
verses: raw.verses ?? [],
chunks: (raw.chunks ?? []).map(migrateChunk),
},
],
};
}
// ---------------------------------------------------------------------------
// Storage helpers
// ---------------------------------------------------------------------------
export function saveProjectToStorage(project) {
// Honour an already-stamped lastEdited (e.g. from autosave) so localStorage
// and the server always receive the identical timestamp.
const updated = { ...project, lastEdited: project.lastEdited ?? Date.now() };
window.localStorage.setItem(projectKey(updated.id), JSON.stringify(updated));
const index = loadProjectIndex();
const existing = index.findIndex((e) => e.id === updated.id);
const tags = Array.from(
new Set(
(updated.chapters ?? [])
.flatMap((ch) => ch.chunks ?? [])
.flatMap((chunk) => chunk.tags ?? []),
),
);
const summary = {
id: updated.id,
title: updated.title,
lastEdited: updated.lastEdited,
chapterSummary: buildChapterSummary(updated),
tags,
};
if (existing >= 0) {
index[existing] = summary;
} else {
index.push(summary);
}
window.localStorage.setItem(indexKey(), JSON.stringify(index));
return updated; // caller can use this exact object for the server PUT
}
export function deleteProjectFromStorage(id) {
window.localStorage.removeItem(projectKey(id));
const index = loadProjectIndex().filter((e) => e.id !== id);
window.localStorage.setItem(indexKey(), JSON.stringify(index));
}
export function loadProjectById(id) {
try {
const raw = window.localStorage.getItem(projectKey(id));
return raw ? migrateProject(JSON.parse(raw)) : null;
} catch {
return null;
}
}
export function migrateOldStorageKeys() {
const keys = Object.keys(window.localStorage);
keys.forEach((key) => {
if (!key.startsWith('bible-study-') || key.startsWith('bible-study-index') || key.startsWith('bible-study-project')) return;
try {
const raw = JSON.parse(window.localStorage.getItem(key));
if (!raw || !raw.book) return;
const migrated = migrateProject(raw);
if (!migrated) return;
saveProjectToStorage(migrated);
window.localStorage.removeItem(key);
} catch {
// skip corrupt entries
}
});
}
export function buildChapterSummary(project) {
if (!Array.isArray(project.chapters)) return '';
return project.chapters
.map((ch) => `${ch.book} ${ch.chapter}`)
.join(', ');
}
+197
View File
@@ -0,0 +1,197 @@
// Word-study helpers: Strong's dictionaries, lexicon lookups, and speech.
// Dictionaries are cached at module level so they download once per visit,
// no matter how often the study page is opened.
import { bookOptions } from './bibleUtils.jsx';
// Loads a JSON-ish resource once. Concurrent callers share one request; a
// failed request is forgotten so the next call can retry.
function cachedLoader(load) {
let value = null;
let pending = null;
const get = () => {
if (value) return Promise.resolve(value);
if (!pending) {
pending = load()
.then((data) => { value = data; return data; })
.finally(() => { pending = null; });
}
return pending;
};
get.reset = () => { value = null; pending = null; };
return get;
}
// The OpenScriptures dictionaries are JS files wrapping one big object literal.
const parseStrongsJs = (text) => JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1));
// NT Strong's→English gloss map (from the macula-greek dataset)
export const loadNtGloss = cachedLoader(() => fetch('/nt-strongs-gloss.json').then((res) => res.json()));
// NT Strong's concordance (verse → Strong's numbers)
export const loadNtConcordance = cachedLoader(() => fetch('/nt-strongs-concordance.json').then((res) => res.json()));
export const loadHebrewDict = cachedLoader(() =>
fetch('https://cdn.jsdelivr.net/gh/openscriptures/strongs@master/hebrew/strongs-hebrew-dictionary.js')
.then((res) => res.text())
.then(parseStrongsJs));
// Greek Strong's dictionary (~1.2 MB)
export const loadGreekDict = cachedLoader(() =>
fetch('https://cdn.jsdelivr.net/gh/openscriptures/strongs@master/greek/strongs-greek-dictionary.js')
.then((res) => res.text())
.then(parseStrongsJs));
// Tests mock fetch differently per case, so they need a clean cache each time.
export function resetLexiconCaches() {
[loadNtGloss, loadNtConcordance, loadHebrewDict, loadGreekDict].forEach((load) => load.reset());
}
// Common Greek NT function words to pre-uncheck in the picker
// (articles, prepositions, conjunctions, particles, common pronouns)
export const GREEK_FUNCTION_WORDS = new Set([
'G3588','G3739','G3748', // ὁ (the), ὅς (who/which), ὅστις
'G2532','G1161','G1063','G235','G3767','G2443','G5037','G3303', // καί, δέ, γάρ, ἀλλά, οὖν, ἵνα, τέ, μέν
'G1487','G1437','G3754','G5613','G2531','G5618', // εἰ, ἐάν, ὅτι, ὡς, καθώς, ὥσπερ
'G1722','G1519','G1537','G575','G4314','G3326','G2596','G1223', // ἐν, εἰς, ἐκ, ἀπό, πρός, μετά, κατά, διά
'G1909','G5228','G5259','G4862','G3844','G4253','G4012','G1722', // ἐπί, ὑπέρ, ὑπό, σύν, παρά, πρό, περί
'G3756','G3361','G3780','G3762','G3763', // οὐ, μή, οὐχί, οὐδείς, οὐδέποτε
'G846','G1473','G4771','G2249','G5210', // αὐτός, ἐγώ, σύ, ἡμεῖς, ὑμεῖς
'G3778','G1565','G3588', // οὗτος, ἐκεῖνος
'G5100','G5101','G3956','G3745','G3748', // τις, τίς, πᾶς, ὅσος
'G3779','G3568','G5119','G1161','G3767', // οὕτως, νῦν, τότε
]);
// Extract a single short English gloss from a verbose kjv_def string.
// e.g. "[idiom] burn (incense), be mindful..." → "burn"
// "another, [idiom] (blood-) thirsty..." → "another"
export const shortHebrewGloss = (kjvDef) => {
if (!kjvDef) return '';
let s = kjvDef.trim();
// strip leading [tag] tokens like [idiom], [phrase]
s = s.replace(/^(\[[^\]]+\]\s*)+/, '').trim();
// take first chunk before comma, semicolon, or opening paren
s = s.split(/[,;(]/)[0].trim();
// strip trailing punctuation
s = s.replace(/[.\-]+$/, '').trim();
return s;
};
export const ENGLISH_STOP_WORDS = new Set([
'the', 'and', 'for', 'that', 'with', 'this', 'from', 'were', 'was', 'have', 'has',
'had', 'are', 'but', 'not', 'you', 'your', 'his', 'her', 'their', 'they', 'them',
'our', 'out', 'into', 'over', 'under', 'upon', 'then', 'than', 'who', 'what', 'when',
'where', 'why', 'how', 'also', 'there', 'here', 'all', 'any', 'one', 'two', 'three',
'he', 'she', 'it', 'we', 'i', 'me', 'my', 'mine', 'ours', 'theirs', 'its',
]);
export const isGreekStrongNumber = (query) => /^G\d+$/i.test(query.trim());
export const isHebrewStrongNumber = (query) => /^H\d+$/i.test(query.trim());
export const externalLookupLinks = (query) => {
const raw = query.trim();
const normalized = raw.toUpperCase();
const isGreek = /^G\d+$/.test(normalized);
const isHebrew = /^H\d+$/.test(normalized);
const num = (isGreek || isHebrew) ? normalized.slice(1) : null;
if (isGreek && num) {
return [
{ label: 'BibleHub', url: `https://biblehub.com/greek/${num}.htm` },
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/lexicon/g${num}/esv/0-1/` },
{ label: 'StudyLight', url: `https://www.studylight.org/lexicons/eng/greek/${num}.html` },
];
}
if (isHebrew && num) {
return [
{ label: 'BibleHub', url: `https://biblehub.com/hebrew/${num}.htm` },
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/lexicon/h${num}/kjv/wlc/0-1/` },
{ label: 'StudyLight', url: `https://www.studylight.org/lexicons/eng/hebrew/${num}.html` },
];
}
const enc = encodeURIComponent(raw);
return [
{ label: 'BibleHub', url: `https://biblehub.com/search.php?q=${enc}` },
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/search/search.cfm?Criteria=${enc}&t=KJV#s=s_lexiconc` },
];
};
export const buildGreekDefinitionHtml = (key, entry) => {
let html = '';
if (entry.strongs_def) html += `<p>${entry.strongs_def.trim()}</p>`;
if (entry.derivation) html += `<p><em>Derivation:</em> ${entry.derivation}</p>`;
if (entry.kjv_def) html += `<p><em>KJV uses:</em> ${entry.kjv_def}</p>`;
return html;
};
export const fetchBollsDefinition = async (query) => {
const encoded = encodeURIComponent(query.trim());
if (isHebrewStrongNumber(query)) {
const response = await fetch(`https://bolls.life/dictionary-definition/BDBT/${encoded}/`);
if (!response.ok) return null;
let defs;
try { defs = await response.json(); } catch { return null; }
return Array.isArray(defs) && defs.length > 0 ? defs : null;
}
// Greek — look up in the cached OpenScriptures Strong's dictionary.
const dict = await loadGreekDict();
const key = isGreekStrongNumber(query) ? query.trim().toUpperCase() : null;
if (key) {
const entry = dict[key];
if (!entry) return null;
return [{
topic: key,
lexeme: entry.lemma || '',
transliteration: entry.translit || '',
short_definition: entry.kjv_def || '',
definition: buildGreekDefinitionHtml(key, entry),
}];
}
// English word search — scan kjv_def and strongs_def for the query term.
const lowerQ = query.trim().toLowerCase();
const match = Object.entries(dict).find(([, entry]) =>
(entry.kjv_def || '').toLowerCase().split(/[,\s]+/).some((w) => w === lowerQ) ||
(entry.strongs_def || '').toLowerCase().includes(lowerQ),
);
if (match) {
const [matchKey, entry] = match;
return [{
topic: matchKey,
lexeme: entry.lemma || '',
transliteration: entry.translit || '',
short_definition: entry.kjv_def || '',
definition: buildGreekDefinitionHtml(matchKey, entry),
}];
}
return null;
};
export const formatCrossRef = (ref) => {
const book = bookOptions.find((b) => b.abbrev === ref.book);
const bookName = book?.name ?? ref.book;
const verseRange = ref.endVerse && ref.endVerse !== ref.verse
? `${ref.verse}-${ref.endVerse}`
: `${ref.verse}`;
return `${bookName} ${ref.chapter}:${verseRange}`;
};
export const speakOriginalWord = (word, strongsNum) => {
if (!window.speechSynthesis) return;
if (window.speechSynthesis.speaking) window.speechSynthesis.cancel();
const lang = strongsNum?.startsWith('H') ? 'he-IL' : 'el-GR';
const voices = window.speechSynthesis.getVoices();
const match = voices.find((v) => v.lang.startsWith(lang.split('-')[0]));
const utt = new SpeechSynthesisUtterance(word + '.');
utt.lang = lang;
utt.rate = 0.85;
if (match) utt.voice = match;
// Chain a silent tail so the audio session fades rather than clicks shut
utt.onend = () => {
const tail = new SpeechSynthesisUtterance('     ');
tail.volume = 0;
tail.lang = lang;
if (match) tail.voice = match;
window.speechSynthesis.speak(tail);
};
window.speechSynthesis.speak(utt);
};
+3
View File
@@ -6,6 +6,9 @@ export default defineConfig({
server: {
host: true,
allowedHosts: ['study.necloud.us', 'localhost', 'study.versebyversewithnate.us'],
watch: {
ignored: ['**/.git/**'],
},
// Proxy /api calls to the Express backend during development
proxy: {
'/api': {