88 Commits

Author SHA1 Message Date
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
nmemmert 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
Claude b3c266fa66 Add a read-only Bible reader page (BSB)
Adds a "Read Bible" button on the home screen that opens a simple
chapter browser using the existing helloao BSB API, with book/chapter
pickers and prev/next navigation, separate from study projects.

https://claude.ai/code/session_013GXQxSaTF6zeBsSYTeF3fN
2026-06-12 03:38:01 +00:00
nmemmert d1861ec5d9 Fix broken test suite and untrack runtime artifacts
- Bridge jsdom localStorage/sessionStorage over Node 22+'s experimental
  globals that vitest's jsdom environment doesn't override
- Update Greek lookup tests to mock the OpenScriptures Strong's
  dictionary instead of the retired bolls.life path
- Update migrateChunk no-op test for backfilled fields
- Match en-dash verse range labels in chunk creation test
- Untrack .api.log/.api.pid and gitignore them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:40:44 -04:00
nmemmert c0ceb68faa Avoid side effect in state updater for audio chapter advance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:28:12 -04:00
nmemmert 9a3ed73d20 Fix BSB audio player not advancing past first chapter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 16:22:23 -04:00
nmemmert 0f08721b48 Add BSB full-book audio player card to home page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 16:15:28 -04:00
nmemmert 603c3bcbf2 Fix commentary JSON parse error for chapters without coverage 2026-06-11 14:45:47 -04:00
nmemmert bb92095ba4 Add word-by-word Greek/Hebrew interlinear under Scripture panel
Adds a collapsible Interlinear section beneath the Scripture text
(stacked and split layouts) showing each original-language word with
its BSB gloss, transliteration, parsing, and Strong's number on hover.
Data is generated offline from the Berean Standard Bible translation
tables via scripts/build-interlinear.mjs into per-book JSON files.
2026-06-11 14:29:24 -04:00
nmemmert 6c4658e244 Apply brand kit colors and fonts to DOCX export
Use antique gold accent, rich black text, Cormorant Garamond
headings, and Lora body text. Remove fill backgrounds, keeping
only the gold left-border scripture callout and section divider.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:18:05 -04:00
nmemmert c41916b3ce Improve DOCX export styling and include missing fields
- Add background notes, tags, and final script to the export
- Use shaded section labels, accent color, and a callout-style scripture box
- Switch accent color from indigo to teal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:14:15 -04:00
nmemmert 6fb3bfbd82 Suggest existing tags when tagging chunks
Shows quick-add chips and a datalist of tags already used elsewhere
in this project or across all projects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:04:30 -04:00
nmemmert 3e84ea37be Add DOCX upload to populate Final Script from a document
Uses mammoth to extract raw text from an uploaded .docx file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:59:31 -04:00
nmemmert e59bc25ed0 Add chunk tagging, persisted Split View prefs, sticky tab bar, and Strong's hover badges
- Tag chunks with topics; aggregate into project index and add a Home page tag filter
- Persist studyLayout/activeStudyTab in localStorage
- Make the Split View tab bar sticky while scrolling
- Show a hoverable Strong's number badge with gloss on each word study card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:51:23 -04:00
nmemmert 65039d44a5 Show expandable definitions in word study suggestion list
Lets users preview a word's full Strong's definition before
deciding whether to add it.
2026-06-11 08:37:45 -04:00
nmemmert 5d0cc2934f Add 2-pane split study layout with tabbed reference panels
Scripture stays pinned in a sticky left column while Notes,
Cross-Refs, Word Study, Commentary, and Script become tabs in
the right column. Toggle via "Split View" / "Stacked View".
2026-06-11 08:33:52 -04:00
nmemmert 6f1a311ba9 Add commentary panel and smart cross-reference suggestions with hover preview
Adds a Commentary section (HelloAO commentaries) and a "Suggest from
passage" cross-reference feature using the open-cross-ref dataset, with
hover popups that show the referenced verse text.
2026-06-11 08:27:32 -04:00
nmemmert f95d5670ae Stop tracking SQLite database files
Local DB state shouldn't be versioned; only code, migrations, and schema belong in git.
2026-06-10 11:01:32 -04:00
nmemmert faf9ba9e2e Make episode info, general notes, and final script per-chunk fields
Each chunk is unique, so these fields no longer share a single project-level value.
2026-06-10 10:56:54 -04:00
nmemmert 91b01c3117 Revert restart.sh systemd auto-restart, keep dev setup.sh workflow 2026-06-10 09:35:28 -04:00
nmemmert b40f03846d fix install 2026-06-10 09:29:54 -04:00
nmemmert 33107cf7af install fix 2026-06-10 09:23:36 -04:00
nmemmert 7bfbe826a0 BIG UPGRADE 2026-06-10 09:12:47 -04:00
nmemmert caa781d039 Fix missing verses and add cross-chapter chunk spanning 2026-06-01 15:56:14 -04:00
nmemmert 587eedd192 Add typed and bulk chunk range entry 2026-06-01 15:47:54 -04:00
nmemmert e3b14fb25b Merge branch 'claude/greek-suggest-cross-device-sync' 2026-06-01 15:38:16 -04:00
nmemmert 7eadb8500b Add OT books and separate Greek/Hebrew lookup 2026-06-01 15:38:11 -04:00
nmemmert be62be0aa6 Merge pull request #15 from nmemmert/claude/greek-suggest-cross-device-sync
Fix sync: consistent lastEdited timestamp + persistent error + retry …
2026-05-28 15:00:41 -04:00
nmemmert 0b4bb46866 Merge pull request #14 from nmemmert/claude/greek-suggest-cross-device-sync
Add English word field to Greek word cards
2026-05-28 13:33:18 -04:00
nmemmert 7117f70e4b Merge pull request #13 from nmemmert/claude/greek-suggest-cross-device-sync
Use macula-greek English glosses in suggest modal instead of KJV kjv_def
2026-05-28 13:19:36 -04:00
nmemmert b6a277e16c Merge pull request #12 from nmemmert/claude/greek-suggest-cross-device-sync
Clean up English word display in suggest modal
2026-05-28 13:05:20 -04:00
nmemmert d5c9761584 Merge pull request #11 from nmemmert/claude/greek-suggest-cross-device-sync
Fix suggest modal: portal to body + English word first
2026-05-28 12:59:43 -04:00
nmemmert 1ae39118c9 Merge pull request #10 from nmemmert/claude/greek-suggest-cross-device-sync
Replace auto-add with word picker modal for Greek suggest
2026-05-28 12:55:26 -04:00
nmemmert 337006c72d Merge pull request #9 from nmemmert/claude/greek-suggest-cross-device-sync
Fix Greek suggest: use local macula-greek concordance instead of brok…
2026-05-28 12:47:15 -04:00
nmemmert 945ff15a4f Merge pull request #8 from nmemmert/claude/greek-suggest-cross-device-sync
Add passage-aware Greek word suggest and cross-device stale-project sync
2026-05-28 12:27:01 -04:00
110 changed files with 11248 additions and 1143 deletions
+14
View File
@@ -7,6 +7,20 @@
"runtimeArgs": ["run", "dev"], "runtimeArgs": ["run", "dev"],
"port": 5173, "port": 5173,
"autoPort": false "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
} }
] ]
} }
+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
+6
View File
@@ -1,6 +1,12 @@
node_modules/ node_modules/
*.db
*.db-wal
*.db-shm
dist/ dist/
coverage/ coverage/
*.local *.local
.vite.pid .vite.pid
.vite.log .vite.log
.api.pid
.api.log
.DS_Store
+2
View File
@@ -2,6 +2,7 @@
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
RUN apk add --no-cache python3 make g++ && npm install -g npm@11
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
@@ -12,6 +13,7 @@ RUN npm run build
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app 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 files and install production deps only
COPY package*.json ./ COPY package*.json ./
+134
View File
@@ -0,0 +1,134 @@
# 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 (Development)
```bash
git clone https://github.com/nmemmert/study-bible-project.git
cd study-bible-project
bash setup.sh
```
`setup.sh` installs dependencies, finds a free port, and starts both servers:
| Server | Default URL |
|--------|-------------|
| API (Express) | http://localhost:3001 |
| Frontend (Vite) | http://localhost:5173 |
**Stop the servers:**
```bash
kill $(cat .api.pid) $(cat .vite.pid)
```
**View logs:**
```bash
tail -f .api.log # API server
tail -f .vite.log # Vite dev server
```
---
## 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.
+28 -55
View File
@@ -1,95 +1,68 @@
# Study App Improvement Suggestions # Study App Improvement Suggestions
_Refreshed 2026-07-06 (multiple passes) — items already shipped have been removed; this reflects what's actually still open. Recent additions: multi-user auth with per-account data scoping, TOTP 2FA with backup codes, podcast terminology generalized to "Session" for study-only users (with a configurable podcast/show name), auto-restore on new devices, study templates (richer OIA guiding prompts), PDF/print export, Markdown export, a passage breadcrumb, whole-Bible search, better bookmark UX (always-visible SVG icons + a jump-to panel), read-only share links, and an admin panel (`server/auth.js` `ADMIN_EMAIL`, hardcoded to the site owner's account) showing every user/project with view/delete controls._
## Features ## Features
### Study Tools ### Study Tools
- **Bible comparison mode** — show two translations side-by-side (API already supports it) - **Bible comparison mode** — show two translations side-by-side. `availableTranslations` is already fetched and a translation is already selectable per project, but only one renders at a time — no split/parallel view
- **Verse-level notes** — annotate individual verses, not just chunks - **Verse-level notes** — annotations are still chunk-level only (OIA fields); no way to attach a note to a single verse within a chunk
- **Tagging / themes** — tag chunks with themes (e.g. "faith", "grace"), filter/search across projects - **Progress tracking** — no "in progress"/"complete" marker per chunk and no progress bar on the home project card
- **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 ### Export / Sharing
- **PDF export** — "Export PDF" button using `jsPDF` or `window.print()` - **Episode length estimate** — Final Script field exists per chunk; a word-count-based "~X minutes read aloud" estimate would help podcast planning
- **Share link** — read-only shareable URL pointing to a project ID on the server - **Share link is single-use-case** — one share token per project, all-or-nothing (whole project, all chunks). A per-chunk or per-chapter share might be worth it for someone who only wants to share one episode's notes rather than the whole series
- **Copy individual chunk** — "copy this chunk's notes" button alongside full "Prepare for Claude"
- **Markdown export** — useful for Obsidian and similar note-taking apps ### Chunk Builder (Setup Page)
- **Drag-to-select verses** — still click-then-shift-click; no click-and-drag range selection
- **Auto-chunk** — no "split by paragraph/section" button; every chunk boundary is manual or typed
--- ---
## UX / UI ## 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 ### Study Page
- **Collapsible sections** — collapse OIA, Cross-References, and Greek Word Studies independently
- **Word/character count** on each textarea to encourage note depth ### Reader
- **Inline verse reference popup** — hover popover on cross-references showing verse text (from HelloAO) - **Bookmark color picker is still an emoji button** (🎨) — the bookmark/copy icons became proper SVGs, but color-cycling didn't get the same treatment
- **Sticky chunk navigation** — Previous/Next chunk buttons should be sticky, not only at the bottom
### Home Page ### Home Page
- **Search/filter projects** — text filter on the project list - Search/filter/sort/rename are all implemented — nothing open here currently
- **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 ## Code Architecture
### State Management ### State Management
- **`App.jsx` is ~2,250 lines** — biggest maintainability issue. Split into: - **`App.jsx` is now ~5,700+ lines** — still one component. Splitting into `pages/HomePage.jsx`, `pages/SetupPage.jsx`, `pages/StudyPage.jsx`, `pages/BibleReaderPage.jsx`, plus extracted hooks (`useProject`, `useGreekLookup`, `useAutosave`) is more valuable now than ever given the continued size growth
- `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 ### 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 - No rate-limiting on `/api/auth/*` — a determined attacker could brute-force a weak password or 2FA code; worth adding if this is ever reachable beyond a small trusted group
- **Conflict resolution is basic** — only compares `lastEdited` timestamps. Add a "which version do you want to keep?" UI to prevent silent data loss - **Conflict resolution is still last-write-wins** — only `lastEdited` timestamps are compared; no "which version do you want to keep?" UI
- **Offline-first** — use a service worker / `workbox` so the app works offline and syncs when back online - **Offline-first** — still no service worker; app requires a live connection to `bible.helloao.org` for chapter/audio/commentary loads with no cached fallback if that API is down
### Security (OWASP) ### Security (OWASP)
- **XSS via `dangerouslySetInnerHTML`** — `word.definitionHtml` is rendered raw. Add DOMPurify sanitization - **No input validation on server** — still no max-length/character validation on `id`/`title` in `server/index.js`
- **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** — still no CORS headers configured
- **CORS** — server has no CORS headers; any origin can call the API in production - **Shared HTML view is sandboxed but not escaped** — `buildExportHtml` interpolates OIA notes into HTML without escaping `<`/`>`/`&`; the public share view mitigates this by rendering in a `sandbox="allow-popups"` iframe (no `allow-scripts`, so injected `<script>`/event handlers can't execute), but the underlying string-building still isn't defense-in-depth. Worth properly HTML-escaping user text in `buildExportHtml` itself
--- ---
## Performance ## 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 - **Verse data stored in project JSON** — still true; full verse text is saved per chapter in both localStorage and SQLite
- **JSON files cached in refs** — `nt-strongs-gloss.json` and `nt-strongs-concordance.json` should be served with proper `Cache-Control` headers - **Hardcoded external API, no fallback** — audio and commentary both call `bible.helloao.org` directly with no retry UI if the free API is briefly down; whole-Bible search now adds a third hard dependency on this API (`/complete.json`)
- **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 ## Testing
- Add tests for: - Missing: autosave debounce behavior, DOCX session-list import, cross-ref auto-suggest, commentary loading, and no coverage yet for the newer share-link/2FA/whole-Bible-search flows (all verified manually in-browser instead)
- `migrateProject` with the old flat format
- `buildClaudePrompt` output structure
- `parseBibleChapter` with both API response shapes
- Autosave debounce behavior
--- ---
## Developer Experience ## Developer Experience
- **No ESLint config** — add ESLint with `eslint-plugin-react` and `eslint-plugin-react-hooks` to catch missing `useEffect` deps - **No ESLint config** — still true; no `.eslintrc*` or `eslint.config.*` in the repo
- **No TypeScript** — JSDoc types or a TS migration would catch shape mismatches between old/new project formats at compile time - **No TypeScript** — still true
- **No `docker-compose.yml`** — Dockerfile exists but there's no compose file for one-command local dev with server + SQLite volume - **No `docker-compose.yml`** — still true; Dockerfile exists but no one-command local dev with server + SQLite volume
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Install Bible Study App as a systemd service on Rocky Linux (or any systemd distro).
#
# Usage:
# sudo ./deploy/install.sh [INSTALL_DIR]
#
# Defaults to /opt/study-app. Run from the project repo root.
set -euo pipefail
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (e.g. with sudo)." >&2
exit 1
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALL_DIR="${1:-/opt/study-app}"
SERVICE_USER="study-app"
SERVICE_NAME="study-app"
echo "=== Installing Bible Study App to $INSTALL_DIR ==="
# ── 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
if [ "$NODE_VERSION" -lt 18 ]; then
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 (build-essential + python3)..."
apt-get install -y build-essential python3
fi
# ── Create service user ─────────────────────────────────────────────────────
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
echo "Creating service user '$SERVICE_USER'..."
useradd --system --home-dir "$INSTALL_DIR" --shell /sbin/nologin "$SERVICE_USER"
fi
# ── Copy app files ────────────────────────────────────────────────────────────
echo "Copying application files to $INSTALL_DIR..."
mkdir -p "$INSTALL_DIR"
rsync -a --delete \
--exclude '.git' \
--exclude 'node_modules' \
--exclude 'dist' \
--exclude '*.pid' \
--exclude '*.log' \
"$ROOT_DIR"/ "$INSTALL_DIR"/
cd "$INSTALL_DIR"
# ── Install dependencies & build ─────────────────────────────────────────────
echo "Installing dependencies (this can take a while for better-sqlite3)..."
npm install
echo "Building production frontend..."
npx vite build
echo "Removing dev dependencies..."
npm prune --omit=dev
# ── Permissions ──────────────────────────────────────────────────────────────
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
# ── systemd unit ──────────────────────────────────────────────────────────────
echo "Installing systemd unit..."
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"
systemctl restart "$SERVICE_NAME"
echo ""
echo "=== Done ==="
echo "Service status: systemctl status $SERVICE_NAME"
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 ufw allow 3001/tcp && sudo ufw reload"
+27
View File
@@ -0,0 +1,27 @@
[Unit]
Description=Bible Study App
After=network.target
[Service]
Type=simple
# Edit these to match your deployment
User=study-app
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
# Hardening (relax if it causes issues)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/opt/study-app
[Install]
WantedBy=multi-user.target
+16 -1
View File
@@ -2,8 +2,23 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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> <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> </head>
<body class="bg-slate-50 text-slate-900"> <body class="bg-slate-50 text-slate-900">
<div id="root"></div> <div id="root"></div>
+2149 -18
View File
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -12,13 +12,22 @@
"start": "NODE_ENV=production node server/index.js", "start": "NODE_ENV=production node server/index.js",
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "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": { "dependencies": {
"better-sqlite3": "^9.4.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"docx": "^9.7.0", "docx": "^9.7.0",
"dompurify": "^3.4.9",
"express": "^4.19.2", "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": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
@@ -29,10 +38,11 @@
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^4.1.7", "@vitest/coverage-v8": "^4.1.7",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"csv-parse": "^6.2.1",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"vite": "^5.4.1", "vite": "^5.4.1",
"vitest": "^4.1.7" "vitest": "^4.1.7"
} }
} }
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

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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;
});
})
)
);
}
});
+5
View File
@@ -21,5 +21,10 @@ stop_pid_file() {
stop_pid_file "$ROOT_DIR/.api.pid" "API server" stop_pid_file "$ROOT_DIR/.api.pid" "API server"
stop_pid_file "$ROOT_DIR/.vite.pid" "Vite server" stop_pid_file "$ROOT_DIR/.vite.pid" "Vite server"
echo "Pulling latest changes from GitHub..."
cd "$ROOT_DIR"
git checkout -- package-lock.json 2>/dev/null || true
git pull
echo "Restarting via setup.sh..." echo "Restarting via setup.sh..."
exec "$ROOT_DIR/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>"
+96
View File
@@ -0,0 +1,96 @@
// One-time/offline data prep: convert the BSB Translation Tables (bereanbible.com/bsb_tables.tsv)
// into per-book JSON files of word-by-word interlinear data for /public/interlinear/.
//
// Usage: node scripts/build-interlinear.mjs /path/to/bsb_tables.tsv
import fs from 'fs';
import path from 'path';
import { parse } from 'csv-parse/sync';
const BOOK_ABBREV = {
Genesis: 'GEN', Exodus: 'EXO', Leviticus: 'LEV', Numbers: 'NUM', Deuteronomy: 'DEU',
Joshua: 'JOS', Judges: 'JDG', Ruth: 'RUT', '1 Samuel': '1SA', '2 Samuel': '2SA',
'1 Kings': '1KI', '2 Kings': '2KI', '1 Chronicles': '1CH', '2 Chronicles': '2CH',
Ezra: 'EZR', Nehemiah: 'NEH', Esther: 'EST', Job: 'JOB', Psalm: 'PSA', Proverbs: 'PRO',
Ecclesiastes: 'ECC', 'Song of Solomon': 'SNG', Isaiah: 'ISA', Jeremiah: 'JER',
Lamentations: 'LAM', Ezekiel: 'EZK', Daniel: 'DAN', Hosea: 'HOS', Joel: 'JOL',
Amos: 'AMO', Obadiah: 'OBA', Jonah: 'JON', Micah: 'MIC', Nahum: 'NAM', Habakkuk: 'HAB',
Zephaniah: 'ZEP', Haggai: 'HAG', Zechariah: 'ZEC', Malachi: 'MAL',
Matthew: 'MAT', Mark: 'MRK', Luke: 'LUK', John: 'JHN', Acts: 'ACT', Romans: 'ROM',
'1 Corinthians': '1CO', '2 Corinthians': '2CO', Galatians: 'GAL', Ephesians: 'EPH',
Philippians: 'PHP', Colossians: 'COL', '1 Thessalonians': '1TH', '2 Thessalonians': '2TH',
'1 Timothy': '1TI', '2 Timothy': '2TI', Titus: 'TIT', Philemon: 'PHM', Hebrews: 'HEB',
James: 'JAS', '1 Peter': '1PE', '2 Peter': '2PE', '1 John': '1JN', '2 John': '2JN',
'3 John': '3JN', Jude: 'JUD', Revelation: 'REV',
};
const inputPath = process.argv[2];
if (!inputPath) {
console.error('Usage: node scripts/build-interlinear.mjs /path/to/bsb_tables.tsv');
process.exit(1);
}
const outDir = path.resolve('public/interlinear');
fs.mkdirSync(outDir, { recursive: true });
const raw = fs.readFileSync(inputPath, 'utf-8');
const records = parse(raw, { delimiter: '\t', columns: false, relax_column_count: true });
const header = records[0];
const idx = Object.fromEntries(header.map((h, i) => [h.trim(), i]));
// books[ABBREV][chapter][verse] = [{ o, t, p, s, g }]
const books = {};
let curVerseId = '';
for (let i = 1; i < records.length; i++) {
const row = records[i];
const verseId = row[idx.VerseId];
if (verseId) curVerseId = verseId;
if (!curVerseId) continue;
const lang = row[idx.Language];
const original = (lang === 'Hebrew' || lang === 'Aramaic')
? row[idx['WLC / Nestle Base TR RP WH NE NA SBL']]
: row[idx['WLC / Nestle Base TR RP WH NE NA SBL']];
if (!original || !original.trim()) continue;
const strongs = row[idx['Str Heb']] || row[idx['Str Grk']] || '';
const gloss = (row[idx['BSB version']] || '').replace(/\s+/g, ' ').trim();
const translit = (row[idx.Translit] || '').trim();
const parsing = (row[idx.Parsing] || '').trim();
const sortKey = (lang === 'Hebrew' || lang === 'Aramaic')
? row[idx['Heb Sort']]
: row[idx['Greek Sort']];
const m = curVerseId.match(/^(.*) (\d+):(\d+)$/);
if (!m) continue;
const [, bookName, chapter, verse] = m;
const abbrev = BOOK_ABBREV[bookName];
if (!abbrev) continue;
books[abbrev] ??= {};
books[abbrev][chapter] ??= {};
books[abbrev][chapter][verse] ??= [];
books[abbrev][chapter][verse].push({
sort: Number(sortKey) || 0,
o: original.trim(),
t: translit,
p: parsing,
s: strongs ? `${(lang === 'Hebrew' || lang === 'Aramaic') ? 'H' : 'G'}${strongs}` : '',
g: gloss,
});
}
let fileCount = 0;
for (const [abbrev, chapters] of Object.entries(books)) {
for (const chapter of Object.values(chapters)) {
for (const verse of Object.values(chapter)) {
verse.sort((a, b) => a.sort - b.sort);
for (const w of verse) delete w.sort;
}
}
fs.writeFileSync(path.join(outDir, `${abbrev}.json`), JSON.stringify(chapters));
fileCount++;
}
console.log(`Wrote ${fileCount} book files to ${outDir}`);
+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; let db;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Init — create tables if they don't exist // Init — create tables if they don't exist, migrate older schemas
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function initDb() { export function initDb() {
mkdirSync(DATA_DIR, { recursive: true }); mkdirSync(DATA_DIR, { recursive: true });
@@ -27,6 +27,58 @@ export function initDb() {
chapter_summary TEXT, chapter_summary TEXT,
data TEXT NOT NULL 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}`); 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. * Does NOT return full project data to keep the response small.
*/ */
export function getAllProjects() { export function getAllProjects(userId) {
const rows = db.prepare(` const rows = db.prepare(`
SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary
FROM projects FROM projects
WHERE user_id = ?
ORDER BY last_edited DESC ORDER BY last_edited DESC
`).all(); `).all(userId);
return rows; return rows;
} }
/** /**
* Returns a single full project by id, or null if not found. * Returns a single full project by id, scoped to userId, or null if not found/not owned.
*/ */
export function getProject(id) { export function getProject(id, userId) {
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id); const row = db.prepare('SELECT data FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
if (!row) return null; if (!row) return null;
try { try {
return JSON.parse(row.data); 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 lastEdited = project.lastEdited ?? Date.now();
const chapterSummary = buildSummary(project); const chapterSummary = buildSummary(project);
const updated = { ...project, lastEdited }; const updated = { ...project, lastEdited };
db.prepare(` db.prepare(`
INSERT INTO projects (id, title, last_edited, chapter_summary, data) INSERT INTO projects (id, title, last_edited, chapter_summary, data, user_id)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
title = excluded.title, title = excluded.title,
last_edited = excluded.last_edited, last_edited = excluded.last_edited,
chapter_summary = excluded.chapter_summary, chapter_summary = excluded.chapter_summary,
data = excluded.data 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 }; 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); 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);
}
+450 -16
View File
@@ -1,13 +1,67 @@
import express from 'express'; import express from 'express';
import session from 'express-session';
import QRCode from 'qrcode';
import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; 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 __dirname = dirname(fileURLToPath(import.meta.url));
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; 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;
}
app.use(express.json({ limit: '10mb' })); 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,
secure: isProd,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
},
}));
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Health check // Health check
@@ -17,11 +71,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 { 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); res.json(projects);
} catch (err) { } catch (err) {
console.error('GET /api/projects error:', err); console.error('GET /api/projects error:', err);
@@ -30,11 +306,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 { 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.' }); if (!project) return res.status(404).json({ error: 'Project not found.' });
res.json(project); res.json(project);
} catch (err) { } catch (err) {
@@ -44,9 +320,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 { try {
const body = req.body; const body = req.body;
if (!body || typeof body !== 'object') { if (!body || typeof body !== 'object') {
@@ -58,7 +334,12 @@ app.put('/api/projects/:id', (req, res) => {
if (body.id !== req.params.id) { if (body.id !== req.params.id) {
return res.status(400).json({ error: 'URL id does not match body 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); res.json(saved);
} catch (err) { } catch (err) {
console.error('PUT /api/projects/:id error:', err); console.error('PUT /api/projects/:id error:', err);
@@ -67,11 +348,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 { try {
deleteProject(req.params.id); deleteProject(req.params.id, req.session.userId);
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {
console.error('DELETE /api/projects/:id error:', err); console.error('DELETE /api/projects/:id error:', err);
@@ -79,10 +360,163 @@ 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) // Serve Vite production build (when NODE_ENV=production)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
if (process.env.NODE_ENV === 'production') { if (isProd) {
const distPath = join(__dirname, '..', 'dist'); const distPath = join(__dirname, '..', 'dist');
app.use(express.static(distPath)); app.use(express.static(distPath));
app.get('*', (_req, res) => { app.get('*', (_req, res) => {
@@ -96,7 +530,7 @@ if (process.env.NODE_ENV === 'production') {
initDb(); initDb();
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Bible Study API running on http://localhost:${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'); 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);
}
}
+2783 -992
View File
File diff suppressed because it is too large Load Diff
+51 -38
View File
@@ -16,15 +16,15 @@ const mockChapterData = {
}, },
}; };
const mockGreekDefinition = [ // Mirrors the OpenScriptures Strong's Greek dictionary format loaded from jsdelivr.
{ const mockGreekDict = {
topic: 'G4102', G4102: {
lexeme: 'πίστις', lemma: 'πίστις',
transliteration: 'pistis', translit: 'pistis',
short_definition: 'faith, belief', kjv_def: 'faith, belief',
definition: '<p>Part(s) of speech: Noun</p><p>Faith or belief.</p>', strongs_def: 'persuasion, i.e. credence; moral conviction',
}, },
]; };
function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}) { function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}) {
return vi.fn((url) => { return vi.fn((url) => {
@@ -34,8 +34,11 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
if (url.includes('bible.helloao.org')) { if (url.includes('bible.helloao.org')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(chapterData) }); return Promise.resolve({ ok: true, json: () => Promise.resolve(chapterData) });
} }
if (url.includes('bolls.life') && greekData !== null) { if (url.includes('strongs-greek-dictionary') && greekData !== null) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(greekData) }); return Promise.resolve({
ok: true,
text: () => Promise.resolve(`var strongsGreekDictionary = ${JSON.stringify(greekData)};`),
});
} }
return Promise.resolve({ ok: false }); return Promise.resolve({ ok: false });
}); });
@@ -45,10 +48,11 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
// Test lifecycle // Test lifecycle
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('fetch', buildFetchMock());
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:mock'), revokeObjectURL: vi.fn() });
vi.spyOn(window, 'confirm').mockReturnValue(false);
localStorage.clear(); localStorage.clear();
vi.stubGlobal('fetch', buildFetchMock());
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
vi.spyOn(window, 'confirm').mockReturnValue(false);
}); });
afterEach(() => { afterEach(() => {
@@ -59,9 +63,18 @@ afterEach(() => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // 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() { async function loadChapter() {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i })); await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i); await screen.findByText(/Scripture & Chunks/i);
@@ -98,24 +111,24 @@ function findChunkCounter(n, total) {
// Initial render // Initial render
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('Initial render', () => { describe('Initial render', () => {
test('shows the home page with "My Studies" heading', () => { test('shows the home page with "My Studies" heading', async () => {
render(<App />); await renderApp();
expect(screen.getByText('My Studies')).toBeInTheDocument(); expect(screen.getByText('My Studies')).toBeInTheDocument();
}); });
test('shows "No projects yet" when storage is empty', () => { test('shows "No projects yet" when storage is empty', async () => {
render(<App />); await renderApp();
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument(); expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
}); });
test('shows "New Project" button on home page', () => { test('shows "New Project" button on home page', async () => {
render(<App />); await renderApp();
expect(screen.getAllByRole('button', { name: /new project/i }).length).toBeGreaterThan(0); expect(screen.getAllByRole('button', { name: /new project/i }).length).toBeGreaterThan(0);
}); });
test('clicking "New Project" shows the project setup form', async () => { test('clicking "New Project" shows the project setup form', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
expect(screen.getByText('Project Setup')).toBeInTheDocument(); expect(screen.getByText('Project Setup')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
@@ -123,7 +136,7 @@ describe('Initial render', () => {
test('setup form shows translation, book, and chapter inputs', async () => { test('setup form shows translation, book, and chapter inputs', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
expect(screen.getByText('Translation')).toBeInTheDocument(); expect(screen.getByText('Translation')).toBeInTheDocument();
expect(screen.getByText('Book')).toBeInTheDocument(); expect(screen.getByText('Book')).toBeInTheDocument();
@@ -158,7 +171,7 @@ describe('Loading a chapter', () => {
return Promise.resolve({ ok: false }); return Promise.resolve({ ok: false });
})); }));
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i })); await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/unable to load chapter/i); await screen.findByText(/unable to load chapter/i);
@@ -167,7 +180,7 @@ describe('Loading a chapter', () => {
test('shows an error message when chapter data contains no verses', async () => { test('shows an error message when chapter data contains no verses', async () => {
vi.stubGlobal('fetch', buildFetchMock({ chapterData: { chapter: { content: [] } } })); vi.stubGlobal('fetch', buildFetchMock({ chapterData: { chapter: { content: [] } } }));
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i })); await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/invalid bible data/i); await screen.findByText(/invalid bible data/i);
@@ -182,7 +195,7 @@ describe('Chunk management', () => {
test('creates a chunk by clicking a verse range', async () => { test('creates a chunk by clicking a verse range', async () => {
await loadChapter(); await loadChapter();
addChunk('Paul, a servant', 'Grace and peace'); addChunk('Paul, a servant', 'Grace and peace');
await screen.findByText(/1-3/); await screen.findByText(/1[-]3/);
}); });
test('chunk count increments after each addition', async () => { test('chunk count increments after each addition', async () => {
@@ -289,28 +302,28 @@ async function goToStudyAndAddGreekWord(fetchMock) {
fireEvent.click(screen.getByRole('button', { name: /begin studying/i })); fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
await screen.findByText(/chunk editor/i); await screen.findByText(/chunk editor/i);
fireEvent.click(screen.getByRole('button', { name: /add greek word/i })); fireEvent.click(screen.getByRole('button', { name: /add greek word/i }));
await screen.findByPlaceholderText(/G4102, 4102/i); await screen.findByPlaceholderText(/G4102, H7225, 4102/i);
} }
describe('Greek word lookup', () => { describe('Greek word lookup', () => {
test('adds a Greek word entry form', async () => { test('adds a Greek word entry form', async () => {
await goToStudyAndAddGreekWord(); await goToStudyAndAddGreekWord();
expect(screen.getByPlaceholderText(/G4102, 4102/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText(/G4102, H7225, 4102/i)).toBeInTheDocument();
}); });
test('populates fields after a successful lookup', async () => { test('populates fields after a successful lookup', async () => {
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: mockGreekDefinition })); await goToStudyAndAddGreekWord(buildFetchMock({ greekData: mockGreekDict }));
fireEvent.change(screen.getByPlaceholderText(/G4102, 4102/i), { target: { value: 'G4102' } }); fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up/i })); fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await screen.findByDisplayValue('πίστις'); await screen.findByDisplayValue('πίστις');
expect(screen.getByDisplayValue('pistis')).toBeInTheDocument(); expect(screen.getByDisplayValue('pistis')).toBeInTheDocument();
expect(screen.getByDisplayValue('faith, belief')).toBeInTheDocument(); expect(screen.getByDisplayValue('faith, belief')).toBeInTheDocument();
}); });
test('shows "No definition found." when the API returns an empty array', async () => { test('shows "No definition found." when the API returns an empty array', async () => {
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: [] })); await goToStudyAndAddGreekWord(buildFetchMock({ greekData: {} }));
fireEvent.change(screen.getByPlaceholderText(/G4102, 4102/i), { target: { value: 'G4102' } }); fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up/i })); fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await screen.findByDisplayValue('No definition found.'); await screen.findByDisplayValue('No definition found.');
}); });
@@ -326,8 +339,8 @@ describe('Greek word lookup', () => {
return Promise.reject(new Error('Network error')); return Promise.reject(new Error('Network error'));
}), }),
); );
fireEvent.change(screen.getByPlaceholderText(/G4102, 4102/i), { target: { value: 'G4102' } }); fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up/i })); fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await screen.findByDisplayValue('Lookup failed.'); await screen.findByDisplayValue('Lookup failed.');
}); });
@@ -335,7 +348,7 @@ describe('Greek word lookup', () => {
const fetchSpy = buildFetchMock(); const fetchSpy = buildFetchMock();
await goToStudyAndAddGreekWord(fetchSpy); await goToStudyAndAddGreekWord(fetchSpy);
const callCountBefore = fetchSpy.mock.calls.length; const callCountBefore = fetchSpy.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: /look up/i })); fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await waitFor(() => { await waitFor(() => {
expect(fetchSpy.mock.calls.length).toBe(callCountBefore); expect(fetchSpy.mock.calls.length).toBe(callCountBefore);
}); });
@@ -343,10 +356,10 @@ describe('Greek word lookup', () => {
test('removes a Greek word entry', async () => { test('removes a Greek word entry', async () => {
await goToStudyAndAddGreekWord(); await goToStudyAndAddGreekWord();
expect(screen.getByPlaceholderText(/G4102, 4102/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText(/G4102, H7225, 4102/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^delete$/i })); fireEvent.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByPlaceholderText(/G4102, 4102/i)).not.toBeInTheDocument(); expect(screen.queryByPlaceholderText(/G4102, H7225, 4102/i)).not.toBeInTheDocument();
}); });
}); });
}); });
+4
View File
@@ -0,0 +1,4 @@
import { createContext, useContext } from 'react';
export const AppContext = createContext(null);
export const useApp = () => useContext(AppContext);
+7
View File
@@ -30,6 +30,13 @@ select {
font: inherit; 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 { .scrollbar-thin::-webkit-scrollbar {
width: 9px; width: 9px;
height: 9px; height: 9px;
+4
View File
@@ -3,6 +3,10 @@ import ReactDOM from 'react-dom/client';
import App from './App.jsx'; import App from './App.jsx';
import './index.css'; import './index.css';
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<App /> <App />
+217
View File
@@ -0,0 +1,217 @@
import { useApp } from '../context/AppContext.js';
import { buildExportHtml } from '../App.jsx';
import {
adminDeleteUser,
adminDeleteProject,
adminGetProject,
adminResetPassword,
} from '../syncService.js';
export default function AdminPage() {
const {
authUser,
authStatus,
goHome,
adminTab, setAdminTab,
adminUsers,
adminProjects,
adminLoading,
adminError,
adminViewProject, setAdminViewProject,
adminResetResult, setAdminResetResult,
loadAdminData,
} = useApp();
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>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { useApp } from '../context/AppContext.js';
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
import { switchStorageUser } from '../App.jsx';
export default function AuthPage() {
const {
authMode, setAuthMode,
authForm, setAuthForm,
authError, setAuthError,
authBusy, setAuthBusy,
authMfaPending, setAuthMfaPending,
authMfaCode, setAuthMfaCode,
authMfaUseBackup, setAuthMfaUseBackup,
setAuthUser,
setProjectIndex,
} = useApp();
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;
}
setAuthUser(result.data);
setProjectIndex(switchStorageUser(result.data.id));
};
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('');
setAuthUser(result.data);
setProjectIndex(switchStorageUser(result.data.id));
};
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>
);
}
+446
View File
@@ -0,0 +1,446 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { useApp } from '../context/AppContext.js';
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
// 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, drawColor, setDrawColor, drawSize, setDrawSize } = useApp();
const { pageCount: initPages } = extractMeta(strokes);
const [pageCount, setPageCount] = useState(initPages);
// realStrokes = strokes without the metadata header
const { realStrokes } = extractMeta(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>
);
}
+465
View File
@@ -0,0 +1,465 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../App.jsx';
export default function HomePage() {
const {
authStatus,
autoRestoredCount,
staleLocalProjects,
projectIndex,
homeSearch, setHomeSearch,
homeSort, setHomeSort,
homeTagFilter, setHomeTagFilter,
homeFullTextResults,
readingPlan,
createReadingPlan,
clearReadingPlan,
audioBook, setAudioBook,
audioNarrator, setAudioNarrator,
audioState,
renamingId, setRenamingId,
renameValue, setRenameValue,
openBibleReader,
openImportProject,
openNewProject,
pullLatestFromServer,
resumeProject,
renameProjectInStorage,
deleteProject,
handlePlayBookAudio,
handleStopBookAudio,
handleToggleBookAudioPause,
} = useApp();
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>
);
}

Some files were not shown because too many files have changed in this diff Show More