Compare commits
64
Commits
No files matched your search
@@ -7,6 +7,20 @@
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173,
|
||||
"autoPort": false
|
||||
},
|
||||
{
|
||||
"name": "study-app-alt",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5174"],
|
||||
"port": 5174,
|
||||
"autoPort": false
|
||||
},
|
||||
{
|
||||
"name": "study-app-server",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev:server"],
|
||||
"port": 3001,
|
||||
"autoPort": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.git
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
@@ -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
|
||||
@@ -9,3 +9,4 @@ coverage/
|
||||
.vite.log
|
||||
.api.pid
|
||||
.api.log
|
||||
.DS_Store
|
||||
@@ -2,6 +2,7 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++ && npm install -g npm@11
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
@@ -12,6 +13,7 @@ RUN npm run build
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++ && npm install -g npm@11
|
||||
|
||||
# Copy package files and install production deps only
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Bible Study App
|
||||
|
||||
A self-hosted Bible study tool with notes, projects, and user accounts.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js v18 or higher** — [nodejs.org](https://nodejs.org/)
|
||||
- **npm** (bundled with Node.js)
|
||||
- **GCC / make** (only needed for production/server installs — required to build the `better-sqlite3` native module)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nmemmert/study-bible-project.git
|
||||
cd study-bible-project
|
||||
bash setup.sh
|
||||
```
|
||||
|
||||
`setup.sh` installs dependencies, builds the optimized frontend, and runs one
|
||||
production server (frontend + API) on **http://localhost:5173**. Set `APP_PORT`
|
||||
to use a different port. If the build fails, the previously running server is
|
||||
left untouched. `bash restart.sh` pulls the latest code and does the same.
|
||||
|
||||
**Stop / logs:**
|
||||
```bash
|
||||
kill $(cat .api.pid)
|
||||
tail -f .api.log
|
||||
```
|
||||
|
||||
### Development mode (hot reload)
|
||||
|
||||
```bash
|
||||
bash setup.sh --dev
|
||||
```
|
||||
|
||||
Starts the API on http://localhost:3001 and the Vite dev server on
|
||||
http://localhost:5173. Dev mode is much slower, so don't use it for real users.
|
||||
|
||||
---
|
||||
|
||||
## Production Install (Ubuntu / systemd)
|
||||
|
||||
Installs the app as a persistent systemd service under `/opt/study-app`.
|
||||
|
||||
```bash
|
||||
# Install Node.js 20 (if not already installed)
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# Install build tools (required for better-sqlite3)
|
||||
sudo apt-get install -y build-essential python3
|
||||
|
||||
# Clone and install
|
||||
git clone https://github.com/nmemmert/study-bible-project.git
|
||||
cd study-bible-project
|
||||
sudo bash deploy/install.sh
|
||||
```
|
||||
|
||||
The installer will:
|
||||
1. Install Node.js dependencies and build the frontend
|
||||
2. Create a `study-app` system user
|
||||
3. Register and start a systemd service
|
||||
|
||||
**Useful commands after install:**
|
||||
```bash
|
||||
systemctl status study-app # Check service status
|
||||
journalctl -u study-app -f # Stream logs
|
||||
systemctl restart study-app # Restart after an update
|
||||
```
|
||||
|
||||
**Open the firewall port (if needed):**
|
||||
```bash
|
||||
sudo ufw allow 3001/tcp
|
||||
sudo ufw reload
|
||||
```
|
||||
|
||||
The app listens on **port 3001** by default. Set the `PORT` environment variable in the systemd unit to change it.
|
||||
|
||||
---
|
||||
|
||||
## Updating (Production)
|
||||
|
||||
```bash
|
||||
cd /path/to/study-bible-project
|
||||
git pull
|
||||
sudo bash deploy/install.sh
|
||||
```
|
||||
|
||||
The installer preserves the session secret across updates so logged-in users are not signed out.
|
||||
|
||||
---
|
||||
|
||||
## Migrating to a New Machine
|
||||
|
||||
**On the old machine — create a backup:**
|
||||
```bash
|
||||
npm run backup
|
||||
```
|
||||
This creates `study-app-backup_<timestamp>.zip` in the project root.
|
||||
|
||||
**On the new machine — restore after cloning and installing:**
|
||||
|
||||
Development:
|
||||
```bash
|
||||
npm run restore study-app-backup_<timestamp>.zip
|
||||
```
|
||||
|
||||
Production (after `sudo bash deploy/install.sh` completes):
|
||||
```bash
|
||||
sudo bash scripts/restore-data.sh study-app-backup_<timestamp>.zip --production
|
||||
```
|
||||
|
||||
See [scripts/backup-data.sh](scripts/backup-data.sh) and [scripts/restore-data.sh](scripts/restore-data.sh) for details.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── server/ # Express API + SQLite database
|
||||
│ └── data/ # projects.db lives here (gitignored)
|
||||
├── src/ # React frontend (Vite)
|
||||
├── deploy/ # systemd service unit + install script
|
||||
├── scripts/ # backup / restore helpers
|
||||
└── setup.sh # Development quick-start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Private / personal use.
|
||||
+28
-55
@@ -1,95 +1,68 @@
|
||||
# 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
|
||||
|
||||
### Study Tools
|
||||
- **Bible comparison mode** — show two translations side-by-side (API already supports it)
|
||||
- **Verse-level notes** — annotate individual verses, not just chunks
|
||||
- **Tagging / themes** — tag chunks with themes (e.g. "faith", "grace"), filter/search across projects
|
||||
- **Progress tracking** — mark chunks as "in progress" / "complete"; show progress bar on home card
|
||||
- **Study templates** — pre-fill OIA fields with guiding prompts for new users
|
||||
- **Print view** — clean print-optimized CSS layout
|
||||
- **Old Testament support** — only NT books listed; HelloAO API supports OT. Greek suggest is NT-only but the rest could support OT with Hebrew lookup
|
||||
- **Verse search** — search bar to find a verse by keyword across the loaded chapter
|
||||
- **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** — annotations are still chunk-level only (OIA fields); no way to attach a note to a single verse within a chunk
|
||||
- **Progress tracking** — no "in progress"/"complete" marker per chunk and no progress bar on the home project card
|
||||
|
||||
### Export / Sharing
|
||||
- **PDF export** — "Export PDF" button using `jsPDF` or `window.print()`
|
||||
- **Share link** — read-only shareable URL pointing to a project ID on the server
|
||||
- **Copy individual chunk** — "copy this chunk's notes" button alongside full "Prepare for Claude"
|
||||
- **Markdown export** — useful for Obsidian and similar note-taking apps
|
||||
- **Episode length estimate** — Final Script field exists per chunk; a word-count-based "~X minutes read aloud" estimate would help podcast planning
|
||||
- **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
|
||||
|
||||
### 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
|
||||
|
||||
### Navigation
|
||||
- **Keyboard shortcuts** — `←`/`→` to navigate chunks; `Ctrl+S` to save; `Escape` to close modals
|
||||
- **"Jump to chunk" dropdown** — for projects with many chunks, a select menu is faster than scrolling
|
||||
- **Breadcrumb in header** — show `Book Chapter:Verse range` so users always know where they are
|
||||
|
||||
### Chunk Builder (Setup Page)
|
||||
- **Drag-to-select verses** — click-and-drag instead of click then shift-click
|
||||
- **Auto-chunk** — button to split chapter into chunks by paragraph/section breaks
|
||||
- **Visual overlap indicator** — already-chunked verses are shaded but there's no tooltip explaining why you can't select them
|
||||
|
||||
### Study Page
|
||||
- **Collapsible sections** — collapse OIA, Cross-References, and Greek Word Studies independently
|
||||
- **Word/character count** on each textarea to encourage note depth
|
||||
- **Inline verse reference popup** — hover popover on cross-references showing verse text (from HelloAO)
|
||||
- **Sticky chunk navigation** — Previous/Next chunk buttons should be sticky, not only at the bottom
|
||||
|
||||
### Reader
|
||||
- **Bookmark color picker is still an emoji button** (🎨) — the bookmark/copy icons became proper SVGs, but color-cycling didn't get the same treatment
|
||||
|
||||
### Home Page
|
||||
- **Search/filter projects** — text filter on the project list
|
||||
- **Sort options** — sort by name, date, or passage
|
||||
- **Project rename** — title is only set at creation; allow renaming from home card
|
||||
- **Last opened chunk** — resume directly to the study page, not the setup page
|
||||
- Search/filter/sort/rename are all implemented — nothing open here currently
|
||||
|
||||
---
|
||||
|
||||
## Code Architecture
|
||||
|
||||
### State Management
|
||||
- **`App.jsx` is ~2,250 lines** — biggest maintainability issue. Split into:
|
||||
- `pages/HomePage.jsx`
|
||||
- `pages/SetupPage.jsx`
|
||||
- `pages/StudyPage.jsx`
|
||||
- `components/ChunkEditor.jsx`
|
||||
- `components/GreekWordStudy.jsx`
|
||||
- `components/SuggestModal.jsx`
|
||||
- **Custom hooks** — extract logic into `useProject()`, `useGreekLookup()`, `useAutosave()`
|
||||
- **`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
|
||||
|
||||
### Sync / Persistence
|
||||
- **No auth** — the server has zero authentication. Any user who can reach the server can read/overwrite/delete any project. Add at minimum an API key (env var in middleware) or user accounts
|
||||
- **Conflict resolution is basic** — only compares `lastEdited` timestamps. Add a "which version do you want to keep?" UI to prevent silent data loss
|
||||
- **Offline-first** — use a service worker / `workbox` so the app works offline and syncs when back online
|
||||
- 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 still last-write-wins** — only `lastEdited` timestamps are compared; no "which version do you want to keep?" UI
|
||||
- **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)
|
||||
- **XSS via `dangerouslySetInnerHTML`** — `word.definitionHtml` is rendered raw. Add DOMPurify sanitization
|
||||
- **No input validation on server** — add max-length and character validation on `id`/`title` fields (SQL injection is prevented by parameterized queries, but still)
|
||||
- **CORS** — server has no CORS headers; any origin can call the API in production
|
||||
- **No input validation on server** — still no max-length/character validation on `id`/`title` in `server/index.js`
|
||||
- **CORS** — still no CORS headers configured
|
||||
- **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
|
||||
|
||||
- **Verse data stored in project JSON** — full verse text is saved in localStorage and SQLite for every chapter. For multi-chapter projects this grows large. Consider storing only the chapter reference and re-fetching verses on load
|
||||
- **JSON files cached in refs** — `nt-strongs-gloss.json` and `nt-strongs-concordance.json` should be served with proper `Cache-Control` headers
|
||||
- **Autosave fires on all state changes** — the `[project]` dependency is too broad; it fires even when just selecting a chunk. Debounce only on content field changes
|
||||
- **Verse data stored in project JSON** — still true; full verse text is saved per chapter in both localStorage and SQLite
|
||||
- **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`)
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- Add tests for:
|
||||
- `migrateProject` with the old flat format
|
||||
- `buildClaudePrompt` output structure
|
||||
- `parseBibleChapter` with both API response shapes
|
||||
- Autosave debounce behavior
|
||||
- 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)
|
||||
|
||||
---
|
||||
|
||||
## Developer Experience
|
||||
|
||||
- **No ESLint config** — add ESLint with `eslint-plugin-react` and `eslint-plugin-react-hooks` to catch missing `useEffect` deps
|
||||
- **No TypeScript** — JSDoc types or a TS migration would catch shape mismatches between old/new project formats at compile time
|
||||
- **No `docker-compose.yml`** — Dockerfile exists but there's no compose file for one-command local dev with server + SQLite volume
|
||||
- **No ESLint config** — still true; no `.eslintrc*` or `eslint.config.*` in the repo
|
||||
- **No TypeScript** — still true
|
||||
- **No `docker-compose.yml`** — still true; Dockerfile exists but no one-command local dev with server + SQLite volume
|
||||
+22
-17
@@ -19,26 +19,23 @@ SERVICE_NAME="study-app"
|
||||
|
||||
echo "=== Installing Bible Study App to $INSTALL_DIR ==="
|
||||
|
||||
# ── Node check ────────────────────────────────────────────────────────────────
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "ERROR: Node.js is not installed."
|
||||
echo "On Rocky Linux, install via NodeSource, e.g.:"
|
||||
echo " curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -"
|
||||
echo " sudo dnf install -y nodejs"
|
||||
exit 1
|
||||
# ── Node check / auto-upgrade ─────────────────────────────────────────────────
|
||||
NODE_VERSION=0
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
|
||||
fi
|
||||
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
|
||||
if [ "$NODE_VERSION" -lt 18 ]; then
|
||||
echo "ERROR: Node.js v18+ required (found $(node -v))."
|
||||
exit 1
|
||||
echo "Node.js v18+ required (found v${NODE_VERSION}). Installing Node.js 20 via NodeSource..."
|
||||
apt-get install -y curl ca-certificates
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
fi
|
||||
echo "Node.js $(node -v) found."
|
||||
|
||||
# build tools needed for better-sqlite3 native module
|
||||
if ! command -v gcc >/dev/null 2>&1 || ! command -v make >/dev/null 2>&1; then
|
||||
echo "Installing build tools (Development Tools group + python3)..."
|
||||
dnf groupinstall -y "Development Tools"
|
||||
dnf install -y python3
|
||||
echo "Installing build tools (build-essential + python3)..."
|
||||
apt-get install -y build-essential python3
|
||||
fi
|
||||
|
||||
# ── Create service user ─────────────────────────────────────────────────────
|
||||
@@ -62,7 +59,7 @@ cd "$INSTALL_DIR"
|
||||
|
||||
# ── Install dependencies & build ─────────────────────────────────────────────
|
||||
echo "Installing dependencies (this can take a while for better-sqlite3)..."
|
||||
npm ci || npm install
|
||||
npm install
|
||||
|
||||
echo "Building production frontend..."
|
||||
npx vite build
|
||||
@@ -75,8 +72,16 @@ chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
||||
|
||||
# ── systemd unit ──────────────────────────────────────────────────────────────
|
||||
echo "Installing systemd unit..."
|
||||
sed "s#/opt/study-app#$INSTALL_DIR#g; s#User=study-app#User=$SERVICE_USER#; s#Group=study-app#Group=$SERVICE_USER#" \
|
||||
"$ROOT_DIR/deploy/study-app.service" > "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
EXISTING_UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
# Reuse the existing session secret across re-installs (upgrades) so signed-in
|
||||
# users aren't logged out; only generate a new one on first install.
|
||||
if [ -f "$EXISTING_UNIT" ] && grep -q '^Environment=SESSION_SECRET=' "$EXISTING_UNIT"; then
|
||||
SESSION_SECRET="$(grep '^Environment=SESSION_SECRET=' "$EXISTING_UNIT" | head -1 | cut -d= -f3-)"
|
||||
else
|
||||
SESSION_SECRET="$(openssl rand -hex 32)"
|
||||
fi
|
||||
sed "s#/opt/study-app#$INSTALL_DIR#g; s#User=study-app#User=$SERVICE_USER#; s#Group=study-app#Group=$SERVICE_USER#; s#__SESSION_SECRET__#$SESSION_SECRET#" \
|
||||
"$ROOT_DIR/deploy/study-app.service" > "$EXISTING_UNIT"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
@@ -89,4 +94,4 @@ echo "Logs: journalctl -u $SERVICE_NAME -f"
|
||||
echo "App listens on: http://0.0.0.0:\${PORT:-3001}"
|
||||
echo ""
|
||||
echo "If you have a firewall enabled, allow the port, e.g.:"
|
||||
echo " sudo firewall-cmd --add-port=3001/tcp --permanent && sudo firewall-cmd --reload"
|
||||
echo " sudo ufw allow 3001/tcp && sudo ufw reload"
|
||||
@@ -10,6 +10,9 @@ Group=study-app
|
||||
WorkingDirectory=/opt/study-app
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=3001
|
||||
# Replaced with a generated value by install.sh — keep this secret and stable
|
||||
# across deploys, or every existing login session gets invalidated.
|
||||
Environment=SESSION_SECRET=__SESSION_SECRET__
|
||||
ExecStart=/usr/bin/node server/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
+16
-1
@@ -2,8 +2,23 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Bible Study Project</title>
|
||||
|
||||
<!-- PWA manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Theme / status bar -->
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
|
||||
<!-- iOS PWA -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="Bible Study" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Fallback icon for browsers -->
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||
</head>
|
||||
<body class="bg-slate-50 text-slate-900">
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+476
@@ -8,12 +8,18 @@
|
||||
"name": "bible-study-app",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"compression": "^1.8.2",
|
||||
"concurrently": "^8.2.2",
|
||||
"docx": "^9.7.0",
|
||||
"dompurify": "^3.4.9",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.19.0",
|
||||
"mammoth": "^1.12.0",
|
||||
"otplib": "^12.0.1",
|
||||
"perfect-freehand": "^1.2.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
@@ -1107,6 +1113,56 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/core": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@otplib/plugin-crypto": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
|
||||
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-thirty-two": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
|
||||
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"thirty-two": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-default": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
|
||||
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-v11": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
|
||||
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.132.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz",
|
||||
@@ -2325,6 +2381,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.10.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
|
||||
@@ -2550,6 +2615,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@@ -2708,6 +2782,85 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/compressible": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": ">= 1.43.0 < 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
|
||||
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"destroy": "1.2.0",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/compression/node_modules/negotiator": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
|
||||
@@ -2908,6 +3061,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
@@ -2984,6 +3146,12 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dingbat-to-unicode": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||
@@ -3297,6 +3465,64 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
||||
"integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "~0.7.2",
|
||||
"cookie-signature": "~1.0.7",
|
||||
"debug": "~2.6.9",
|
||||
"depd": "~2.0.0",
|
||||
"on-headers": "~1.1.0",
|
||||
"parseurl": "~1.3.3",
|
||||
"safe-buffer": "~5.2.1",
|
||||
"uid-safe": "~2.1.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express-session/node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
@@ -3424,6 +3650,19 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -4256,6 +4495,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
@@ -4663,6 +4914,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
@@ -4678,6 +4938,53 @@
|
||||
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/otplib": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
|
||||
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/preset-default": "^12.0.1",
|
||||
"@otplib/preset-v11": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
@@ -4706,6 +5013,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
@@ -4735,6 +5051,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/perfect-freehand": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.3.tgz",
|
||||
"integrity": "sha512-bHZSfqDHGNlPpgH2yxXgPHlQSPpEbo+qg7li0M78J9vNAi2yjwLeA4x79BEQhX44lEWpCLSFCeRZwpw0niiXPA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -4775,6 +5097,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
@@ -5020,6 +5351,104 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
@@ -5056,6 +5485,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/random-bytes": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
|
||||
"integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -5205,6 +5643,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -5461,6 +5905,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
@@ -5868,6 +6318,14 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/thirty-two": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
|
||||
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
|
||||
"engines": {
|
||||
"node": ">=0.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -6058,6 +6516,18 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/uid-safe": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
|
||||
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"random-bytes": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/underscore": {
|
||||
"version": "1.13.8",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
|
||||
@@ -6460,6 +6930,12 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
||||
+9
-1
@@ -12,15 +12,23 @@
|
||||
"start": "NODE_ENV=production node server/index.js",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"coverage": "vitest run --coverage"
|
||||
"coverage": "vitest run --coverage",
|
||||
"backup": "bash scripts/backup-data.sh",
|
||||
"restore": "bash scripts/restore-data.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"compression": "^1.8.2",
|
||||
"concurrently": "^8.2.2",
|
||||
"docx": "^9.7.0",
|
||||
"dompurify": "^3.4.9",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.19.0",
|
||||
"mammoth": "^1.12.0",
|
||||
"otplib": "^12.0.1",
|
||||
"perfect-freehand": "^1.2.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 694 B |
Binary file not shown.
|
After Width: | Height: | Size: 757 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
const SHELL = 'bible-shell-v1';
|
||||
const API = 'bible-api-v1';
|
||||
const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life'];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(SHELL)
|
||||
.then(c => c.addAll(['/', '/index.html']))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then(keys => Promise.all(
|
||||
keys.filter(k => k !== SHELL && k !== API).map(k => caches.delete(k))
|
||||
))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const { request } = e;
|
||||
const url = request.url;
|
||||
|
||||
// Never intercept API routes — let Express handle them
|
||||
if (new URL(url).pathname.startsWith('/api/')) return;
|
||||
|
||||
// External Bible API: network-first, fall back to cache
|
||||
if (BIBLE_ORIGINS.some(o => url.startsWith(o))) {
|
||||
e.respondWith(
|
||||
fetch(request)
|
||||
.then(res => {
|
||||
if (res.ok) caches.open(API).then(c => c.put(request, res.clone()));
|
||||
return res;
|
||||
})
|
||||
.catch(() =>
|
||||
caches.open(API).then(c => c.match(request)).then(
|
||||
hit => hit ?? new Response('{"error":"offline"}', {
|
||||
status: 503, headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// App-shell navigation: network-first, fall back to cached index.html
|
||||
if (request.mode === 'navigate') {
|
||||
e.respondWith(
|
||||
fetch(request)
|
||||
.then(res => {
|
||||
if (res.ok) caches.open(SHELL).then(c => c.put(request, res.clone()));
|
||||
return res;
|
||||
})
|
||||
.catch(() =>
|
||||
caches.open(SHELL).then(c =>
|
||||
c.match('/index.html').then(hit => hit ?? c.match('/'))
|
||||
)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-origin static assets (hashed JS/CSS/images): cache-first
|
||||
if (url.startsWith(self.location.origin)) {
|
||||
e.respondWith(
|
||||
caches.open(SHELL).then(c =>
|
||||
c.match(request).then(hit => {
|
||||
if (hit) return hit;
|
||||
return fetch(request).then(res => {
|
||||
if (res.ok) c.put(request, res.clone());
|
||||
return res;
|
||||
});
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
+8
-19
@@ -3,23 +3,12 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
stop_pid_file() {
|
||||
local pid_file="$1"
|
||||
local label="$2"
|
||||
if [ -f "$pid_file" ]; then
|
||||
local old_pid
|
||||
old_pid=$(cat "$pid_file")
|
||||
if kill -0 "$old_pid" 2>/dev/null; then
|
||||
echo "Stopping $label (PID $old_pid)..."
|
||||
kill "$old_pid"
|
||||
sleep 1
|
||||
fi
|
||||
rm -f "$pid_file"
|
||||
fi
|
||||
}
|
||||
echo "Pulling latest changes from Gitea..."
|
||||
cd "$ROOT_DIR"
|
||||
git checkout -- package-lock.json 2>/dev/null || true
|
||||
git pull
|
||||
|
||||
stop_pid_file "$ROOT_DIR/.api.pid" "API server"
|
||||
stop_pid_file "$ROOT_DIR/.vite.pid" "Vite server"
|
||||
|
||||
echo "Restarting via setup.sh..."
|
||||
exec "$ROOT_DIR/setup.sh"
|
||||
# setup.sh builds first and only then stops the old server, so the site
|
||||
# stays up during the build (and if the build fails). Pass --dev through.
|
||||
echo "Rebuilding and restarting via setup.sh..."
|
||||
exec "$ROOT_DIR/setup.sh" "$@"
|
||||
Executable
+23
@@ -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>"
|
||||
@@ -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/');
|
||||
Executable
+67
@@ -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
|
||||
@@ -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');
|
||||
}
|
||||
+309
-15
@@ -10,7 +10,7 @@ const DB_PATH = join(DATA_DIR, 'projects.db');
|
||||
let db;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init — create tables if they don't exist
|
||||
// Init — create tables if they don't exist, migrate older schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
export function initDb() {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
@@ -27,6 +27,58 @@ export function initDb() {
|
||||
chapter_summary TEXT,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
sess TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Older databases predate multi-user support — add the ownership column.
|
||||
const projectCols = db.prepare('PRAGMA table_info(projects)').all();
|
||||
if (!projectCols.some((c) => c.name === 'user_id')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN user_id TEXT REFERENCES users(id)');
|
||||
}
|
||||
|
||||
// Older databases predate 2FA support — add the TOTP columns.
|
||||
const userCols = db.prepare('PRAGMA table_info(users)').all();
|
||||
if (!userCols.some((c) => c.name === 'totp_secret')) {
|
||||
db.exec(`
|
||||
ALTER TABLE users ADD COLUMN totp_secret TEXT;
|
||||
ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN backup_codes TEXT;
|
||||
`);
|
||||
}
|
||||
|
||||
// Older databases predate the podcast-name profile setting.
|
||||
if (!userCols.some((c) => c.name === 'podcast_name')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT');
|
||||
}
|
||||
|
||||
// Older databases predate shareable read-only links.
|
||||
if (!projectCols.some((c) => c.name === 'share_token')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
|
||||
}
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
|
||||
|
||||
// Reader ink — one row per user + book + chapter, stored as JSON
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reader_ink (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
book_abbrev TEXT NOT NULL,
|
||||
chapter INTEGER NOT NULL,
|
||||
strokes TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, book_abbrev, chapter)
|
||||
);
|
||||
`);
|
||||
|
||||
console.log(`SQLite database ready at ${DB_PATH}`);
|
||||
@@ -42,27 +94,125 @@ function buildSummary(project) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// Users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function countUsers() {
|
||||
return db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
|
||||
}
|
||||
|
||||
export function createUser({ id, email, passwordHash }) {
|
||||
const createdAt = Date.now();
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, email, password_hash, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(id, email, passwordHash, createdAt);
|
||||
return { id, email, createdAt };
|
||||
}
|
||||
|
||||
function parseUserRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
totpEnabled: !!row.totpEnabled,
|
||||
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
|
||||
};
|
||||
}
|
||||
|
||||
const USER_SELECT = `
|
||||
SELECT id, email, password_hash AS passwordHash, created_at AS createdAt,
|
||||
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
|
||||
podcast_name AS podcastName
|
||||
FROM users
|
||||
`;
|
||||
|
||||
export function getUserByEmail(email) {
|
||||
return parseUserRow(db.prepare(`${USER_SELECT} WHERE email = ?`).get(email));
|
||||
}
|
||||
|
||||
export function getUserById(id) {
|
||||
return parseUserRow(db.prepare(`${USER_SELECT} WHERE id = ?`).get(id));
|
||||
}
|
||||
|
||||
/** Persists a confirmed TOTP secret + one-time backup code hashes, turning 2FA on. */
|
||||
export function enableTotp(userId, secret, backupCodeHashes) {
|
||||
db.prepare(`
|
||||
UPDATE users SET totp_secret = ?, totp_enabled = 1, backup_codes = ? WHERE id = ?
|
||||
`).run(secret, JSON.stringify(backupCodeHashes), userId);
|
||||
}
|
||||
|
||||
/** Turns 2FA off and forgets the secret/backup codes entirely. */
|
||||
export function disableTotp(userId) {
|
||||
db.prepare(`
|
||||
UPDATE users SET totp_secret = NULL, totp_enabled = 0, backup_codes = NULL WHERE id = ?
|
||||
`).run(userId);
|
||||
}
|
||||
|
||||
/** Sets or clears the show name used in the "Prepare for Podcast" prompt. */
|
||||
export function setPodcastName(userId, podcastName) {
|
||||
db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId);
|
||||
}
|
||||
|
||||
/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
|
||||
export function setBackupCodeHashes(userId, backupCodeHashes) {
|
||||
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader ink — cross-device sync for draw-mode annotations in the reader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns all saved reader ink pages for a user as { "BOOK_CH": strokes[] }. */
|
||||
export function getAllReaderInk(userId) {
|
||||
const rows = db.prepare(
|
||||
'SELECT book_abbrev AS book, chapter, strokes FROM reader_ink WHERE user_id = ?'
|
||||
).all(userId);
|
||||
return Object.fromEntries(
|
||||
rows.map((r) => [`${r.book}_${r.chapter}`, JSON.parse(r.strokes)])
|
||||
);
|
||||
}
|
||||
|
||||
/** Upserts the ink strokes for one reader page. */
|
||||
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
|
||||
db.prepare(`
|
||||
INSERT INTO reader_ink (user_id, book_abbrev, chapter, strokes, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, book_abbrev, chapter)
|
||||
DO UPDATE SET strokes = excluded.strokes, updated_at = excluded.updated_at
|
||||
`).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns any pre-existing, unowned projects (from before multi-user support)
|
||||
* to the given user. Intended to run once, right after the first account is created.
|
||||
*/
|
||||
export function claimOrphanProjects(userId) {
|
||||
db.prepare('UPDATE projects SET user_id = ? WHERE user_id IS NULL').run(userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project queries — all scoped to the owning user
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns all project summaries (id, title, lastEdited, chapterSummary).
|
||||
* Returns all project summaries owned by userId (id, title, lastEdited, chapterSummary).
|
||||
* Does NOT return full project data to keep the response small.
|
||||
*/
|
||||
export function getAllProjects() {
|
||||
export function getAllProjects(userId) {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary
|
||||
FROM projects
|
||||
WHERE user_id = ?
|
||||
ORDER BY last_edited DESC
|
||||
`).all();
|
||||
`).all(userId);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single full project by id, or null if not found.
|
||||
* Returns a single full project by id, scoped to userId, or null if not found/not owned.
|
||||
*/
|
||||
export function getProject(id) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id);
|
||||
export function getProject(id, userId) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
@@ -72,29 +222,173 @@ export function getProject(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or replace a project. Returns the summary.
|
||||
* Insert or replace a project owned by userId.
|
||||
* Returns the summary, or null if the id already belongs to a different user.
|
||||
*/
|
||||
export function upsertProject(project) {
|
||||
export function upsertProject(project, userId) {
|
||||
const existing = db.prepare('SELECT user_id AS userId FROM projects WHERE id = ?').get(project.id);
|
||||
if (existing && existing.userId !== userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastEdited = project.lastEdited ?? Date.now();
|
||||
const chapterSummary = buildSummary(project);
|
||||
const updated = { ...project, lastEdited };
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO projects (id, title, last_edited, chapter_summary, data)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO projects (id, title, last_edited, chapter_summary, data, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
last_edited = excluded.last_edited,
|
||||
chapter_summary = excluded.chapter_summary,
|
||||
data = excluded.data
|
||||
`).run(project.id, project.title, lastEdited, chapterSummary, JSON.stringify(updated));
|
||||
`).run(project.id, project.title, lastEdited, chapterSummary, JSON.stringify(updated), userId);
|
||||
|
||||
return { id: project.id, title: project.title, lastEdited, chapterSummary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project by id. No-op if not found.
|
||||
* Delete a project by id, scoped to userId. No-op if not found/not owned.
|
||||
*/
|
||||
export function deleteProject(id) {
|
||||
export function deleteProject(id, userId) {
|
||||
db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only share links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns the current share token for a project owned by userId, or null. */
|
||||
export function getShareToken(id, userId) {
|
||||
const row = db.prepare('SELECT share_token AS shareToken FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
return row?.shareToken ?? null;
|
||||
}
|
||||
|
||||
/** Sets a project's share token (enabling its public read-only link), scoped to userId. */
|
||||
export function setShareToken(id, userId, token) {
|
||||
const result = db.prepare('UPDATE projects SET share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/** Revokes a project's share link, scoped to userId. */
|
||||
export function clearShareToken(id, userId) {
|
||||
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
/** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */
|
||||
export function getProjectByShareToken(token) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin — unscoped views across every user/project. Callers must gate access
|
||||
// themselves (see requireAdmin in server/auth.js); nothing here checks who's asking.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Every account, with a project count, for the admin users list. */
|
||||
export function adminGetAllUsers() {
|
||||
return db.prepare(`
|
||||
SELECT u.id, u.email, u.created_at AS createdAt, u.totp_enabled AS totpEnabled,
|
||||
(SELECT COUNT(*) FROM projects p WHERE p.user_id = u.id) AS projectCount
|
||||
FROM users u
|
||||
ORDER BY u.created_at ASC
|
||||
`).all().map((r) => ({ ...r, totpEnabled: !!r.totpEnabled }));
|
||||
}
|
||||
|
||||
/** Deletes a user account. Their projects are left in place (orphaned, not cascade-deleted) so data isn't lost by accident. */
|
||||
export function adminDeleteUser(userId) {
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
}
|
||||
|
||||
/** Every project across every user, with the owner's email, for the admin projects list. */
|
||||
export function adminGetAllProjects() {
|
||||
return db.prepare(`
|
||||
SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary,
|
||||
p.share_token AS shareToken, u.email AS ownerEmail
|
||||
FROM projects p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
ORDER BY p.last_edited DESC
|
||||
`).all();
|
||||
}
|
||||
|
||||
/** Full project data by id, regardless of owner — for admin inspection. */
|
||||
export function adminGetProject(id) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes any project by id, regardless of owner. */
|
||||
export function adminDeleteProject(id) {
|
||||
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
/** Overwrites a user's password hash directly — used for both self-service and admin-assisted resets. */
|
||||
export function setUserPassword(userId, passwordHash) {
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session store backing (used by server/sessionStore.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getSession(sid) {
|
||||
const row = db.prepare('SELECT sess, expires FROM sessions WHERE sid = ?').get(sid);
|
||||
if (!row || row.expires < Date.now()) return null;
|
||||
try {
|
||||
return JSON.parse(row.sess);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSession(sid, sess, expires) {
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (sid, sess, expires)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expires = excluded.expires
|
||||
`).run(sid, JSON.stringify(sess), expires);
|
||||
}
|
||||
|
||||
export function destroySession(sid) {
|
||||
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
|
||||
}
|
||||
|
||||
export function pruneExpiredSessions() {
|
||||
db.prepare('DELETE FROM sessions WHERE expires < ?').run(Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a user out everywhere by deleting every session that belongs to them.
|
||||
* Sessions don't have an indexed user_id column (they're just an opaque JSON
|
||||
* blob to express-session), so this scans and parses — fine at this app's scale.
|
||||
* Pass exceptSid to keep one session alive (e.g. the one completing a self-service
|
||||
* password change, so the user isn't immediately logged out of their own action).
|
||||
*/
|
||||
export function destroyAllSessionsForUser(userId, exceptSid = null) {
|
||||
const rows = db.prepare('SELECT sid, sess FROM sessions').all();
|
||||
const staleSids = rows
|
||||
.filter((row) => row.sid !== exceptSid)
|
||||
.filter((row) => {
|
||||
try {
|
||||
return JSON.parse(row.sess)?.userId === userId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map((row) => row.sid);
|
||||
if (staleSids.length === 0) return;
|
||||
const placeholders = staleSids.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...staleSids);
|
||||
}
|
||||
+467
-16
@@ -1,13 +1,73 @@
|
||||
import express from 'express';
|
||||
import compression from 'compression';
|
||||
import session from 'express-session';
|
||||
import QRCode from 'qrcode';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { initDb, getAllProjects, getProject, upsertProject, deleteProject } from './db.js';
|
||||
import {
|
||||
initDb, getAllProjects, getProject, upsertProject, deleteProject,
|
||||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||
setUserPassword, destroyAllSessionsForUser,
|
||||
getAllReaderInk, setReaderInkPage,
|
||||
} from './db.js';
|
||||
import { SqliteSessionStore } from './sessionStore.js';
|
||||
import {
|
||||
isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail,
|
||||
generateTotpSecret, totpKeyUri, verifyTotpToken,
|
||||
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
|
||||
} from './auth.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (isProd && !process.env.SESSION_SECRET) {
|
||||
console.warn('WARNING: SESSION_SECRET is not set. Set it to a long random string in production.');
|
||||
}
|
||||
|
||||
// Trust the reverse proxy (needed for secure cookies to work behind nginx/etc).
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Simple in-memory rate limiter for auth endpoints — 10 attempts per 15 min per IP.
|
||||
const _authAttempts = new Map();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, e] of _authAttempts) if (now > e.resetAt) _authAttempts.delete(ip);
|
||||
}, 60 * 60 * 1000);
|
||||
function checkAuthRateLimit(ip) {
|
||||
const now = Date.now();
|
||||
const window = 15 * 60 * 1000;
|
||||
const e = _authAttempts.get(ip) ?? { count: 0, resetAt: now + window };
|
||||
if (now > e.resetAt) { e.count = 0; e.resetAt = now + window; }
|
||||
e.count += 1;
|
||||
_authAttempts.set(ip, e);
|
||||
return e.count > 10;
|
||||
}
|
||||
|
||||
// Gzip responses — the interlinear JSON files shrink ~10x, and JS/CSS/API payloads too.
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(session({
|
||||
store: new SqliteSessionStore(),
|
||||
secret: process.env.SESSION_SECRET || 'dev-only-secret-change-me',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
rolling: true,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
// 'auto' = Secure when the request arrived over HTTPS (via the proxy's
|
||||
// X-Forwarded-Proto). A hard `true` silently breaks login if the proxy
|
||||
// doesn't send that header, or when the app is opened over plain http.
|
||||
secure: isProd ? 'auto' : false,
|
||||
sameSite: 'lax',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
},
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check
|
||||
@@ -17,11 +77,233 @@ app.get('/api/health', (_req, res) => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects — list all project summaries (no full data)
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects', (_req, res) => {
|
||||
|
||||
app.post('/api/auth/register', async (req, res) => {
|
||||
try {
|
||||
const projects = getAllProjects();
|
||||
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(req.body?.password ?? '');
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return res.status(400).json({ error: 'Enter a valid email address.' });
|
||||
}
|
||||
if (!isValidPassword(password)) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
||||
}
|
||||
if (getUserByEmail(email)) {
|
||||
return res.status(409).json({ error: 'An account with that email already exists.' });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = createUser({ id: randomUUID(), email, passwordHash });
|
||||
|
||||
// The very first account inherits any projects created before multi-user support existed.
|
||||
if (countUsers() === 1) {
|
||||
claimOrphanProjects(user.id);
|
||||
}
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email) });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/register error:', err);
|
||||
res.status(500).json({ error: 'Failed to register.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
try {
|
||||
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(req.body?.password ?? '');
|
||||
|
||||
const user = getUserByEmail(email);
|
||||
const valid = user && await verifyPassword(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Incorrect email or password.' });
|
||||
}
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
if (user.totpEnabled) {
|
||||
// Password is correct, but the session stays unauthenticated (no userId)
|
||||
// until a valid TOTP/backup code lands on /api/auth/mfa/verify.
|
||||
req.session.pendingUserId = user.id;
|
||||
return res.json({ mfaRequired: true });
|
||||
}
|
||||
req.session.userId = user.id;
|
||||
res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/login error:', err);
|
||||
res.status(500).json({ error: 'Failed to log in.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/verify', async (req, res) => {
|
||||
try {
|
||||
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
|
||||
const pendingUserId = req.session?.pendingUserId;
|
||||
if (!pendingUserId) {
|
||||
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||||
}
|
||||
const user = getUserById(pendingUserId);
|
||||
if (!user || !user.totpEnabled) {
|
||||
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||||
}
|
||||
|
||||
const token = req.body?.token;
|
||||
const backupCode = req.body?.backupCode;
|
||||
let ok = token ? verifyTotpToken(String(token), user.totpSecret) : false;
|
||||
|
||||
if (!ok && backupCode) {
|
||||
const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes);
|
||||
if (remaining) {
|
||||
setBackupCodeHashes(user.id, remaining);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: 'Invalid code.' });
|
||||
}
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
res.json({ id: user.id, email: user.email, totpEnabled: true, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/verify error:', err);
|
||||
res.status(500).json({ error: 'Failed to verify code.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||||
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
||||
res.json({
|
||||
id: user.id, email: user.email, totpEnabled: user.totpEnabled,
|
||||
podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email),
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/auth/profile — update account-level settings (currently just podcastName)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.patch('/api/auth/profile', requireAuth, (req, res) => {
|
||||
try {
|
||||
const podcastName = String(req.body?.podcastName ?? '').trim().slice(0, 200);
|
||||
setPodcastName(req.session.userId, podcastName || null);
|
||||
res.json({ podcastName: podcastName || null });
|
||||
} catch (err) {
|
||||
console.error('PATCH /api/auth/profile error:', err);
|
||||
res.status(500).json({ error: 'Failed to save profile.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/auth/change-password — self-service password change (Account Settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post('/api/auth/change-password', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const currentPassword = String(req.body?.currentPassword ?? '');
|
||||
const newPassword = String(req.body?.newPassword ?? '');
|
||||
|
||||
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect.' });
|
||||
}
|
||||
if (!isValidPassword(newPassword)) {
|
||||
return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
setUserPassword(user.id, passwordHash);
|
||||
// Sign out every other session (e.g. a stolen one) but keep this one logged in.
|
||||
destroyAllSessionsForUser(user.id, req.sessionID);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/change-password error:', err);
|
||||
res.status(500).json({ error: 'Failed to change password.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Two-factor auth setup (requires an already-authenticated session)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.post('/api/auth/mfa/setup', requireAuth, (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const secret = generateTotpSecret();
|
||||
// Held only in the session until confirmed with a real code — never written
|
||||
// to the DB (and 2FA never turned on) unless /mfa/enable succeeds below.
|
||||
req.session.pendingTotpSecret = secret;
|
||||
QRCode.toDataURL(totpKeyUri(user.email, secret), (err, qrCodeDataUrl) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to generate QR code.' });
|
||||
res.json({ secret, qrCodeDataUrl });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/setup error:', err);
|
||||
res.status(500).json({ error: 'Failed to start 2FA setup.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const secret = req.session.pendingTotpSecret;
|
||||
if (!secret) {
|
||||
return res.status(400).json({ error: 'Start 2FA setup first.' });
|
||||
}
|
||||
if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) {
|
||||
return res.status(401).json({ error: 'That code didn\'t match. Check your authenticator app and try again.' });
|
||||
}
|
||||
|
||||
const backupCodes = generateBackupCodes();
|
||||
const backupCodeHashes = await hashBackupCodes(backupCodes);
|
||||
enableTotp(req.session.userId, secret, backupCodeHashes);
|
||||
delete req.session.pendingTotpSecret;
|
||||
res.json({ backupCodes });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/enable error:', err);
|
||||
res.status(500).json({ error: 'Failed to enable 2FA.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Incorrect password.' });
|
||||
}
|
||||
disableTotp(user.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/disable error:', err);
|
||||
res.status(500).json({ error: 'Failed to disable 2FA.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects — list all project summaries owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects', requireAuth, (req, res) => {
|
||||
try {
|
||||
const projects = getAllProjects(req.session.userId);
|
||||
res.json(projects);
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects error:', err);
|
||||
@@ -30,11 +312,11 @@ app.get('/api/projects', (_req, res) => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects/:id — fetch a single full project
|
||||
// GET /api/projects/:id — fetch a single full project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects/:id', (req, res) => {
|
||||
app.get('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const project = getProject(req.params.id);
|
||||
const project = getProject(req.params.id, req.session.userId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
@@ -44,9 +326,9 @@ app.get('/api/projects/:id', (req, res) => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /api/projects/:id — create or update a project
|
||||
// PUT /api/projects/:id — create or update a project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.put('/api/projects/:id', (req, res) => {
|
||||
app.put('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== 'object') {
|
||||
@@ -58,7 +340,12 @@ app.put('/api/projects/:id', (req, res) => {
|
||||
if (body.id !== req.params.id) {
|
||||
return res.status(400).json({ error: 'URL id does not match body id.' });
|
||||
}
|
||||
const saved = upsertProject(body);
|
||||
if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' });
|
||||
if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' });
|
||||
const saved = upsertProject(body, req.session.userId);
|
||||
if (!saved) {
|
||||
return res.status(403).json({ error: 'That project belongs to a different account.' });
|
||||
}
|
||||
res.json(saved);
|
||||
} catch (err) {
|
||||
console.error('PUT /api/projects/:id error:', err);
|
||||
@@ -67,11 +354,11 @@ app.put('/api/projects/:id', (req, res) => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/projects/:id — remove a project
|
||||
// DELETE /api/projects/:id — remove a project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete('/api/projects/:id', (req, res) => {
|
||||
app.delete('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
deleteProject(req.params.id);
|
||||
deleteProject(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id error:', err);
|
||||
@@ -79,13 +366,177 @@ app.delete('/api/projects/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader ink — cross-device draw annotations on Bible chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/reader/ink — all saved ink pages for the current user
|
||||
app.get('/api/reader/ink', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getAllReaderInk(req.session.userId));
|
||||
} catch (err) {
|
||||
console.error('GET /api/reader/ink error:', err);
|
||||
res.status(500).json({ error: 'Failed to load reader ink.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reader/ink/:book/:chapter — save (upsert) one page's ink
|
||||
app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { book, chapter } = req.params;
|
||||
const { strokes } = req.body;
|
||||
if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' });
|
||||
setReaderInkPage(req.session.userId, book, chapter, strokes);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reader/ink error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reader ink.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only share links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/projects/:id/share — current share status for the project owner
|
||||
app.get('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const project = getProject(req.params.id, req.session.userId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: getShareToken(req.params.id, req.session.userId) });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/projects/:id/share — enable sharing, returns the (new or existing) token
|
||||
app.post('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const existing = getShareToken(req.params.id, req.session.userId);
|
||||
const token = existing || randomUUID().replace(/-/g, '');
|
||||
const ok = setShareToken(req.params.id, req.session.userId, token);
|
||||
if (!ok) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: token });
|
||||
} catch (err) {
|
||||
console.error('POST /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/projects/:id/share — revoke the share link
|
||||
app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
clearShareToken(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only
|
||||
app.get('/api/share/:token', (req, res) => {
|
||||
try {
|
||||
const project = getProjectByShareToken(req.params.token);
|
||||
if (!project) return res.status(404).json({ error: 'This share link is invalid or has been revoked.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/share/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load shared project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin — restricted to the single designated admin account (see ADMIN_EMAIL
|
||||
// in server/auth.js). Full visibility/control over every user and project.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
res.json(adminGetAllUsers());
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/users error:', err);
|
||||
res.status(500).json({ error: 'Failed to list users.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
if (req.params.id === req.session.userId) {
|
||||
return res.status(400).json({ error: "Can't delete your own admin account." });
|
||||
}
|
||||
adminDeleteUser(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/admin/users/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete user.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/admin/users/:id/reset-password — sets a random temporary password and
|
||||
// signs the user out everywhere, since there's no self-service "forgot password" flow yet.
|
||||
app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const temporaryPassword = generateTemporaryPassword();
|
||||
const passwordHash = await hashPassword(temporaryPassword);
|
||||
setUserPassword(req.params.id, passwordHash);
|
||||
destroyAllSessionsForUser(req.params.id);
|
||||
res.json({ temporaryPassword });
|
||||
} catch (err) {
|
||||
console.error('POST /api/admin/users/:id/reset-password error:', err);
|
||||
res.status(500).json({ error: 'Failed to reset password.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
res.json(adminGetAllProjects());
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/projects error:', err);
|
||||
res.status(500).json({ error: 'Failed to list projects.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const project = adminGetProject(req.params.id);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to load project.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
adminDeleteProject(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/admin/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serve Vite production build (when NODE_ENV=production)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (isProd) {
|
||||
const distPath = join(__dirname, '..', 'dist');
|
||||
app.use(express.static(distPath));
|
||||
// Vite's /assets files have content hashes in their names, so they can be
|
||||
// cached forever. Other static files (interlinear JSON, icons) cache for a
|
||||
// day; index.html and the service worker always revalidate so deploys show up.
|
||||
const noCache = (res) => res.setHeader('Cache-Control', 'no-cache');
|
||||
app.use('/assets', express.static(join(distPath, 'assets'), { immutable: true, maxAge: '1y' }));
|
||||
app.use(express.static(distPath, {
|
||||
maxAge: '1d',
|
||||
setHeaders: (res, path) => {
|
||||
if (path.endsWith('index.html') || path.endsWith('sw.js')) noCache(res);
|
||||
},
|
||||
}));
|
||||
app.get('*', (_req, res) => {
|
||||
noCache(res);
|
||||
res.sendFile(join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
@@ -96,7 +547,7 @@ if (process.env.NODE_ENV === 'production') {
|
||||
initDb();
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Bible Study API running on http://localhost:${PORT}`);
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (isProd) {
|
||||
console.log('Serving Vite build from /dist');
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installs dependencies, builds the frontend, and runs the app.
|
||||
#
|
||||
# bash setup.sh Production: one Express server serves the optimized
|
||||
# build + API on $APP_PORT (default 5173).
|
||||
# bash setup.sh --dev Development: Express API on 3001 + Vite dev server
|
||||
# with hot reload (slower; not for real users).
|
||||
set -euo pipefail
|
||||
|
||||
MODE="prod"
|
||||
if [ "${1:-}" = "--dev" ]; then
|
||||
MODE="dev"
|
||||
fi
|
||||
|
||||
# Production serves on the port the Vite dev server used to use, so an
|
||||
# existing reverse proxy pointed at it keeps working. Override with APP_PORT=.
|
||||
APP_PORT="${APP_PORT:-5173}"
|
||||
PREFERRED_VITE_PORT=5173
|
||||
MAX_VITE_PORT=5200
|
||||
API_PORT=3001
|
||||
@@ -41,7 +55,7 @@ stop_pid_file() {
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Bible Study App Setup ==="
|
||||
echo "=== Bible Study App Setup ($MODE) ==="
|
||||
echo ""
|
||||
|
||||
# ── Node check ───────────────────────────────────────────────────────────────
|
||||
@@ -68,7 +82,35 @@ echo ""
|
||||
echo "Installing dependencies..."
|
||||
npm install
|
||||
|
||||
# ── Find a free Vite port ─────────────────────────────────────────────────────
|
||||
# ── Production ────────────────────────────────────────────────────────────────
|
||||
if [ "$MODE" = "prod" ]; then
|
||||
# Build before stopping anything, so a failed build leaves the running site up.
|
||||
echo ""
|
||||
echo "Building frontend..."
|
||||
npm run build
|
||||
|
||||
stop_pid_file "$API_PID_FILE" "server"
|
||||
stop_pid_file "$VITE_PID_FILE" "Vite dev server"
|
||||
|
||||
echo ""
|
||||
echo "Starting production server on http://localhost:$APP_PORT"
|
||||
echo "Logs: $API_LOG_FILE"
|
||||
|
||||
NODE_ENV=production PORT="$APP_PORT" \
|
||||
nohup node "$ROOT_DIR/server/index.js" > "$API_LOG_FILE" 2>&1 &
|
||||
echo $! > "$API_PID_FILE"
|
||||
echo "Server PID: $(cat "$API_PID_FILE")"
|
||||
|
||||
echo ""
|
||||
echo "To stop: kill \$(cat .api.pid)"
|
||||
echo "To view logs: tail -f .api.log"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Development ───────────────────────────────────────────────────────────────
|
||||
stop_pid_file "$API_PID_FILE" "API server"
|
||||
stop_pid_file "$VITE_PID_FILE" "Vite server"
|
||||
|
||||
echo ""
|
||||
VITE_PORT=$(find_port)
|
||||
if [ -z "$VITE_PORT" ]; then
|
||||
@@ -82,11 +124,6 @@ else
|
||||
echo "Port $VITE_PORT is available for Vite."
|
||||
fi
|
||||
|
||||
# ── Stop any previously running instances ─────────────────────────────────────
|
||||
stop_pid_file "$API_PID_FILE" "API server"
|
||||
stop_pid_file "$VITE_PID_FILE" "Vite server"
|
||||
|
||||
# ── Start Express API server ──────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Starting API server on http://localhost:$API_PORT"
|
||||
echo "API logs: $API_LOG_FILE"
|
||||
@@ -98,7 +135,6 @@ echo "API server PID: $(cat "$API_PID_FILE")"
|
||||
# Give the API a moment to initialise before Vite starts
|
||||
sleep 1
|
||||
|
||||
# ── Start Vite dev server ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Starting Vite dev server on http://localhost:$VITE_PORT"
|
||||
echo "Vite logs: $VITE_LOG_FILE"
|
||||
|
||||
+782
-2805
File diff suppressed because it is too large.
Load diff
+153
-12
@@ -63,9 +63,18 @@ afterEach(() => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// App checks /api/auth/me on mount before deciding whether to show the app or
|
||||
// a login gate. In tests the mock fetch reports the server as unreachable, so
|
||||
// it falls back to local-only mode — but that still takes a tick to resolve.
|
||||
async function renderApp() {
|
||||
render(<App />);
|
||||
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
|
||||
}
|
||||
|
||||
async function loadChapter() {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await renderApp();
|
||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
@@ -102,32 +111,32 @@ function findChunkCounter(n, total) {
|
||||
// Initial render
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Initial render', () => {
|
||||
test('shows the home page with "My Studies" heading', () => {
|
||||
render(<App />);
|
||||
test('shows the home page with "My Studies" heading', async () => {
|
||||
await renderApp();
|
||||
expect(screen.getByText('My Studies')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows "No projects yet" when storage is empty', () => {
|
||||
render(<App />);
|
||||
test('shows "No projects yet" when storage is empty', async () => {
|
||||
await renderApp();
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows "New Project" button on home page', () => {
|
||||
render(<App />);
|
||||
test('shows "New Project" button on home page', async () => {
|
||||
await renderApp();
|
||||
expect(screen.getAllByRole('button', { name: /new project/i }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('clicking "New Project" shows the project setup form', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await renderApp();
|
||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
expect(screen.getByText('Project Setup')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Project Setup')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('setup form shows translation, book, and chapter inputs', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await renderApp();
|
||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
expect(screen.getByText('Translation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Book')).toBeInTheDocument();
|
||||
@@ -162,7 +171,7 @@ describe('Loading a chapter', () => {
|
||||
return Promise.resolve({ ok: false });
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await renderApp();
|
||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/unable to load chapter/i);
|
||||
@@ -171,7 +180,7 @@ describe('Loading a chapter', () => {
|
||||
test('shows an error message when chapter data contains no verses', async () => {
|
||||
vi.stubGlobal('fetch', buildFetchMock({ chapterData: { chapter: { content: [] } } }));
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await renderApp();
|
||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/invalid bible data/i);
|
||||
@@ -354,3 +363,135 @@ describe('Greek word lookup', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication (useAuth + AuthPage/SettingsPage local form state)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Authentication', () => {
|
||||
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: 'My Show', isAdmin: false };
|
||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||
|
||||
// A tiny fake API: `session` decides what /api/auth/me returns; login can
|
||||
// optionally require a 2FA code first.
|
||||
function stubApi({ session = null, mfaRequired = false } = {}) {
|
||||
const fallback = buildFetchMock();
|
||||
const fetchMock = vi.fn((url, opts = {}) => {
|
||||
if (!url.startsWith('/api/')) return fallback(url, opts);
|
||||
const route = `${opts.method ?? 'GET'} ${url}`;
|
||||
if (route === 'GET /api/auth/me') return session ? json(200, session) : json(401, { error: 'Not signed in' });
|
||||
if (route === 'POST /api/auth/login') return json(200, mfaRequired ? { mfaRequired: true } : user);
|
||||
if (route === 'POST /api/auth/mfa/verify') return json(200, user);
|
||||
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
|
||||
if (route === 'GET /api/projects') return json(200, []);
|
||||
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||
return json(404, { error: 'not mocked' });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
const calledWith = (fetchMock, method, url) =>
|
||||
fetchMock.mock.calls.some(([u, o = {}]) => u === url && (o.method ?? 'GET') === method);
|
||||
|
||||
async function signInWithForm(u) {
|
||||
await u.type(await screen.findByLabelText(/email/i), user.email);
|
||||
await u.type(screen.getByLabelText(/password/i), 'correct-horse');
|
||||
await u.click(screen.getByRole('button', { name: /^sign in$/i }));
|
||||
}
|
||||
|
||||
test('signing in with the form shows the app and loads reader ink', async () => {
|
||||
const fetchMock = stubApi();
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await signInWithForm(u);
|
||||
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||||
expect(calledWith(fetchMock, 'POST', '/api/auth/login')).toBe(true);
|
||||
await waitFor(() => expect(calledWith(fetchMock, 'GET', '/api/reader/ink')).toBe(true));
|
||||
});
|
||||
|
||||
test('2FA: a correct code after the password signs the user in', async () => {
|
||||
const fetchMock = stubApi({ mfaRequired: true });
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await signInWithForm(u);
|
||||
await u.type(await screen.findByPlaceholderText('123456'), '123456');
|
||||
await u.click(screen.getByRole('button', { name: /verify/i }));
|
||||
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||||
expect(calledWith(fetchMock, 'POST', '/api/auth/mfa/verify')).toBe(true);
|
||||
});
|
||||
|
||||
test('an existing session skips the login screen, and Log out returns to it', async () => {
|
||||
const fetchMock = stubApi({ session: user });
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||||
await u.click(screen.getByRole('button', { name: /log out/i }));
|
||||
expect(await screen.findByRole('button', { name: /^sign in$/i })).toBeInTheDocument();
|
||||
expect(calledWith(fetchMock, 'POST', '/api/auth/logout')).toBe(true);
|
||||
});
|
||||
|
||||
test('Settings pre-fills the podcast name from the signed-in account', async () => {
|
||||
stubApi({ session: user });
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await u.click(await screen.findByRole('button', { name: /settings/i }));
|
||||
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin page (state is local to AdminPage)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Admin page', () => {
|
||||
const admin = { id: 'a1', email: 'owner@example.com', totpEnabled: true, podcastName: null, isAdmin: true };
|
||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||
|
||||
function stubAdminApi(sessionUser) {
|
||||
let users = [
|
||||
{ id: 'a1', email: 'owner@example.com', totpEnabled: true, projectCount: 1 },
|
||||
{ id: 'u2', email: 'student@example.com', totpEnabled: false, projectCount: 0 },
|
||||
];
|
||||
const fallback = buildFetchMock();
|
||||
const fetchMock = vi.fn((url, opts = {}) => {
|
||||
if (!url.startsWith('/api/')) return fallback(url, opts);
|
||||
const route = `${opts.method ?? 'GET'} ${url}`;
|
||||
if (route === 'GET /api/auth/me') return json(200, sessionUser);
|
||||
if (route === 'GET /api/admin/users') return json(200, users);
|
||||
if (route === 'GET /api/admin/projects') return json(200, [{ id: 'p1', title: 'Romans Study', ownerEmail: 'owner@example.com', chapterSummary: 'Romans 1' }]);
|
||||
if (route === 'DELETE /api/admin/users/u2') { users = users.filter((u) => u.id !== 'u2'); return json(200, { ok: true }); }
|
||||
if (route === 'GET /api/projects') return json(200, []);
|
||||
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||
return json(404, { error: 'not mocked' });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
test('an admin can open the page and see users and projects', async () => {
|
||||
stubAdminApi(admin);
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await u.click(await screen.findByRole('button', { name: /admin/i }));
|
||||
expect(await screen.findByText('student@example.com')).toBeInTheDocument();
|
||||
await u.click(screen.getByRole('button', { name: /projects \(1\)/i }));
|
||||
expect(await screen.findByText('Romans Study')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('deleting a user removes them after the list reloads', async () => {
|
||||
const fetchMock = stubAdminApi(admin);
|
||||
window.confirm.mockReturnValue(true);
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await u.click(await screen.findByRole('button', { name: /admin/i }));
|
||||
const row = (await screen.findByText('student@example.com')).closest('tr');
|
||||
await u.click(within(row).getByRole('button', { name: /delete/i }));
|
||||
await waitFor(() => expect(screen.queryByText('student@example.com')).not.toBeInTheDocument());
|
||||
expect(fetchMock.mock.calls.some(([url, o = {}]) => url === '/api/admin/users/u2' && o.method === 'DELETE')).toBe(true);
|
||||
});
|
||||
|
||||
test('non-admins get no Admin button', async () => {
|
||||
stubAdminApi({ ...admin, isAdmin: false });
|
||||
render(<App />);
|
||||
expect(await screen.findByText(admin.email)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /admin/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const AppContext = createContext(null);
|
||||
export const useApp = () => useContext(AppContext);
|
||||
@@ -0,0 +1,138 @@
|
||||
// Chapter audio playback. The Home page's whole-book player and the Reader's
|
||||
// chapter player share one <audio> element; audioModeRef tracks which owns it.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { bookOptions } from '../utils/bibleUtils.jsx';
|
||||
|
||||
export function useAudio(readerBookAbbrev, readerChapter) {
|
||||
const [audioBook, setAudioBook] = useState(bookOptions[0].abbrev);
|
||||
const [audioNarrator, setAudioNarrator] = useState('souer');
|
||||
const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 });
|
||||
const [readerAudioState, setReaderAudioState] = useState({ status: 'idle', chapter: 0 });
|
||||
const audioRef = useRef(null);
|
||||
const audioModeRef = useRef('book'); // 'book' | 'reader' — which player owns audioRef
|
||||
const audioBookRef = useRef(audioBook);
|
||||
const audioNarratorRef = useRef(audioNarrator);
|
||||
const audioStateRef = useRef(audioState);
|
||||
audioBookRef.current = audioBook;
|
||||
audioNarratorRef.current = audioNarrator;
|
||||
audioStateRef.current = audioState;
|
||||
|
||||
const playAudioChapter = async (abbrev, chapterNum, narrator) => {
|
||||
const res = await fetch(`https://bible.helloao.org/api/BSB/${abbrev}/${chapterNum}.json`);
|
||||
if (!res.ok) throw new Error('Failed to load chapter audio');
|
||||
const data = await res.json();
|
||||
const total = data.book?.numberOfChapters ?? chapterNum;
|
||||
const url = data.thisChapterAudioLinks?.[narrator];
|
||||
if (!url) throw new Error('Audio not available for this narrator');
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio();
|
||||
audioRef.current.addEventListener('ended', () => {
|
||||
if (audioModeRef.current === 'reader') {
|
||||
setReaderAudioState({ status: 'idle', chapter: 0 });
|
||||
return;
|
||||
}
|
||||
const nextChapter = audioStateRef.current.chapter + 1;
|
||||
if (nextChapter > audioStateRef.current.total) {
|
||||
setAudioState({ status: 'idle', chapter: 0, total: 0 });
|
||||
return;
|
||||
}
|
||||
playAudioChapter(audioBookRef.current, nextChapter, audioNarratorRef.current).catch(() =>
|
||||
setAudioState({ status: 'error', chapter: 0, total: 0 }),
|
||||
);
|
||||
});
|
||||
}
|
||||
const audioEl = audioRef.current;
|
||||
audioEl.src = url;
|
||||
await audioEl.play();
|
||||
if (audioModeRef.current === 'reader') {
|
||||
setReaderAudioState({ status: 'playing', chapter: chapterNum });
|
||||
} else {
|
||||
setAudioState({ status: 'playing', chapter: chapterNum, total });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayBookAudio = async () => {
|
||||
audioModeRef.current = 'book';
|
||||
try {
|
||||
await playAudioChapter(audioBook, 1, audioNarrator);
|
||||
} catch {
|
||||
setAudioState({ status: 'error', chapter: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopBookAudio = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
}
|
||||
setAudioState({ status: 'idle', chapter: 0, total: 0 });
|
||||
setReaderAudioState({ status: 'idle', chapter: 0 });
|
||||
};
|
||||
|
||||
const handleToggleBookAudioPause = () => {
|
||||
if (!audioRef.current) return;
|
||||
if (audioRef.current.paused) {
|
||||
audioRef.current.play();
|
||||
setAudioState((prev) => ({ ...prev, status: 'playing' }));
|
||||
} else {
|
||||
audioRef.current.pause();
|
||||
setAudioState((prev) => ({ ...prev, status: 'paused' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayReaderAudio = async () => {
|
||||
audioModeRef.current = 'reader';
|
||||
try {
|
||||
await playAudioChapter(readerBookAbbrev, readerChapter, audioNarrator);
|
||||
} catch {
|
||||
setReaderAudioState({ status: 'error', chapter: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleReaderAudioPause = () => {
|
||||
if (!audioRef.current) return;
|
||||
if (audioRef.current.paused) {
|
||||
audioRef.current.play();
|
||||
setReaderAudioState((prev) => ({ ...prev, status: 'playing' }));
|
||||
} else {
|
||||
audioRef.current.pause();
|
||||
setReaderAudioState((prev) => ({ ...prev, status: 'paused' }));
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-switch the reader's audio to match the book/chapter currently being read.
|
||||
useEffect(() => {
|
||||
if (audioModeRef.current !== 'reader') return;
|
||||
if (readerAudioState.status !== 'playing' && readerAudioState.status !== 'paused') return;
|
||||
if (readerAudioState.chapter === readerChapter) return;
|
||||
const wasPaused = readerAudioState.status === 'paused';
|
||||
playAudioChapter(readerBookAbbrev, readerChapter, audioNarrator)
|
||||
.then(() => {
|
||||
if (wasPaused && audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setReaderAudioState((prev) => ({ ...prev, status: 'paused' }));
|
||||
}
|
||||
})
|
||||
.catch(() => setReaderAudioState({ status: 'error', chapter: 0 }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [readerBookAbbrev, readerChapter]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
audioBook, setAudioBook,
|
||||
audioNarrator, setAudioNarrator,
|
||||
audioState,
|
||||
handlePlayBookAudio,
|
||||
handleStopBookAudio,
|
||||
handleToggleBookAudioPause,
|
||||
readerAudioState,
|
||||
handlePlayReaderAudio,
|
||||
handleToggleReaderAudioPause,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Who is signed in. Checks the session on startup and exposes signIn/logout so
|
||||
// every sign-in path (startup, login form, 2FA) runs the same follow-up work.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { getCurrentUser, logoutUser } from '../syncService.js';
|
||||
|
||||
// onSignIn(user) runs right after a user becomes signed in (switch local
|
||||
// storage to their account, load their data). onSignOut() runs after logout.
|
||||
export function useAuth({ onSignIn, onSignOut }) {
|
||||
// authUser: undefined = still checking · null = signed out · object = signed in
|
||||
const [authUser, setAuthUser] = useState(undefined);
|
||||
const [authServerDown, setAuthServerDown] = useState(false);
|
||||
|
||||
// Callbacks come from App and close over its latest state; read them through
|
||||
// a ref so signIn/logout never call a stale copy.
|
||||
const callbacksRef = useRef({ onSignIn, onSignOut });
|
||||
callbacksRef.current = { onSignIn, onSignOut };
|
||||
|
||||
const signIn = (user) => {
|
||||
setAuthUser(user);
|
||||
callbacksRef.current.onSignIn(user);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await logoutUser();
|
||||
setAuthUser(null);
|
||||
callbacksRef.current.onSignOut();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getCurrentUser().then((result) => {
|
||||
if (result.ok) {
|
||||
if (result.user) signIn(result.user);
|
||||
else setAuthUser(null);
|
||||
} else {
|
||||
// Genuine network failure (server unreachable) — fall back to local-only mode.
|
||||
setAuthServerDown(true);
|
||||
setAuthUser(null);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return { authUser, setAuthUser, authServerDown, signIn, logout };
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// State, data loading, and helpers for the read-only Bible reader page.
|
||||
// Audio playback stays in App because the study page's book player shares
|
||||
// the same <audio> element.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { bookOptions, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
|
||||
|
||||
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
|
||||
|
||||
export function useBibleReader(currentPage) {
|
||||
const [readerBookAbbrev, setReaderBookAbbrev] = useState(bookOptions[0].abbrev);
|
||||
const [readerChapter, setReaderChapter] = useState(1);
|
||||
const [readerVerses, setReaderVerses] = useState([]);
|
||||
const [readerTotalChapters, setReaderTotalChapters] = useState(1);
|
||||
const [readerLoading, setReaderLoading] = useState(false);
|
||||
const [readerError, setReaderError] = useState('');
|
||||
const [readerInterlinear, setReaderInterlinear] = useState(null); // full book interlinear
|
||||
const [readerInterlinearLoading, setReaderInterlinearLoading] = useState(false);
|
||||
const [readerSelectedVerse, setReaderSelectedVerse] = useState(null); // verse number with open interlinear panel
|
||||
const _readerInterlinearCacheRef = useRef({});
|
||||
const [readerFontSize, setReaderFontSize] = useState(1); // em multiplier: 0.875 | 1 | 1.125 | 1.25
|
||||
const [readerBookmarks, setReaderBookmarks] = useState(() => {
|
||||
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
|
||||
});
|
||||
const [readerBookmarksPanelOpen, setReaderBookmarksPanelOpen] = useState(false);
|
||||
const [readerJumpVerse, setReaderJumpVerse] = useState(null); // verse number to scroll to + flash after a jump
|
||||
const [readerCrossRefs, setReaderCrossRefs] = useState(null);
|
||||
const [readerCrossRefsLoading, setReaderCrossRefsLoading] = useState(false);
|
||||
const [readerShowCrossRefs, setReaderShowCrossRefs] = useState(false);
|
||||
const _readerCrossRefCacheRef = useRef({});
|
||||
const [readerSearch, setReaderSearch] = useState('');
|
||||
const [readerSearchActive, setReaderSearchActive] = useState(false);
|
||||
const [readerSearchScope, setReaderSearchScope] = useState('chapter'); // 'chapter' | 'bible'
|
||||
const [bibleIndexStatus, setBibleIndexStatus] = useState('idle'); // 'idle' | 'loading' | 'ready' | 'error'
|
||||
const _bibleIndexCacheRef = useRef({}); // translation -> flat verse array
|
||||
const [readerInkByPage, setReaderInkByPage] = useState(() => {
|
||||
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
|
||||
catch { return {}; }
|
||||
});
|
||||
const [readerTextHighlights, setReaderTextHighlights] = useState(() => {
|
||||
try {
|
||||
const val = JSON.parse(localStorage.getItem('readerTextHighlights') || '[]');
|
||||
return Array.isArray(val) ? val : [];
|
||||
} catch { return []; }
|
||||
});
|
||||
// Debounce timer ref so rapid stroke updates don't flood the server
|
||||
const inkSaveTimerRef = useRef({});
|
||||
|
||||
// The HelloAO API has no search endpoint, so whole-Bible search fetches the
|
||||
// entire translation once (~7MB) and searches an in-memory flat verse index
|
||||
// client-side. Cached per translation for the rest of the session (not persisted
|
||||
// across reloads, to avoid eating into localStorage/IndexedDB quota for a cache
|
||||
// that's cheap enough to rebuild once per visit).
|
||||
const loadBibleIndex = async () => {
|
||||
if (_bibleIndexCacheRef.current.BSB) {
|
||||
setBibleIndexStatus('ready');
|
||||
return;
|
||||
}
|
||||
setBibleIndexStatus('loading');
|
||||
try {
|
||||
// Check IndexedDB cache before hitting the network (~7 MB fetch)
|
||||
const cached = await getBibleIndexFromIDB('BSB');
|
||||
if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
|
||||
_bibleIndexCacheRef.current.BSB = cached.flat;
|
||||
setBibleIndexStatus('ready');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch('https://bible.helloao.org/api/BSB/complete.json');
|
||||
if (!res.ok) throw new Error('Unable to load the full Bible for search.');
|
||||
const data = await res.json();
|
||||
const flat = [];
|
||||
for (const book of data.books ?? []) {
|
||||
for (const ch of book.chapters ?? []) {
|
||||
const verses = parseBibleChapter({ chapter: ch.chapter });
|
||||
for (const v of verses) {
|
||||
if (v.text) {
|
||||
flat.push({ bookAbbrev: book.id, bookName: book.commonName || book.name, chapter: ch.chapter.number, verse: v.number, text: v.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_bibleIndexCacheRef.current.BSB = flat;
|
||||
setBibleIndexStatus('ready');
|
||||
// Persist for future visits — non-blocking
|
||||
saveBibleIndexToIDB('BSB', flat);
|
||||
} catch {
|
||||
setBibleIndexStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const loadReaderChapter = async (bookAbbrev, chapterNumber) => {
|
||||
setReaderLoading(true);
|
||||
setReaderError('');
|
||||
try {
|
||||
const res = await fetch(`https://bible.helloao.org/api/BSB/${bookAbbrev}/${chapterNumber}.json`);
|
||||
if (!res.ok) throw new Error('Unable to load chapter.');
|
||||
const data = await res.json();
|
||||
const verses = parseBibleChapter(data);
|
||||
if (!Array.isArray(verses) || verses.length === 0) {
|
||||
throw new Error('Invalid Bible data returned.');
|
||||
}
|
||||
setReaderVerses(verses);
|
||||
setReaderTotalChapters(data.book?.numberOfChapters ?? chapterNumber);
|
||||
} catch (err) {
|
||||
setReaderError(err.message || 'Failed to load chapter.');
|
||||
setReaderVerses([]);
|
||||
} finally {
|
||||
setReaderLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage !== 'reader') return;
|
||||
loadReaderChapter(readerBookAbbrev, readerChapter);
|
||||
}, [currentPage, readerBookAbbrev, readerChapter]);
|
||||
|
||||
// After navigating to a verse (from the bookmarks panel or a whole-Bible search
|
||||
// result), scroll to it and briefly flash its background so it's easy to spot.
|
||||
useEffect(() => {
|
||||
if (readerJumpVerse == null || readerLoading) return;
|
||||
const el = document.getElementById(`reader-verse-${readerJumpVerse}`);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
const prevTransition = el.style.transition;
|
||||
const prevBackground = el.style.backgroundColor;
|
||||
el.style.transition = 'background-color 0.4s';
|
||||
el.style.backgroundColor = '#fef08a';
|
||||
const t = window.setTimeout(() => {
|
||||
el.style.backgroundColor = prevBackground;
|
||||
window.setTimeout(() => { el.style.transition = prevTransition; }, 400);
|
||||
}, 1200);
|
||||
setReaderJumpVerse(null);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [readerVerses, readerLoading, readerJumpVerse]);
|
||||
|
||||
// Navigates the reader to a specific verse (bookmarks panel, search results) and
|
||||
// scrolls/flashes it once that chapter's verses have loaded.
|
||||
const jumpToReaderVerse = (bookAbbrev, chapter, verseNumber) => {
|
||||
setReaderBookAbbrev(bookAbbrev);
|
||||
setReaderChapter(chapter);
|
||||
setReaderSelectedVerse(null);
|
||||
setReaderInterlinear(null);
|
||||
setReaderCrossRefs(null);
|
||||
setReaderSearch('');
|
||||
setReaderSearchActive(false);
|
||||
setReaderBookmarksPanelOpen(false);
|
||||
setReaderJumpVerse(verseNumber);
|
||||
};
|
||||
|
||||
const readerGoToPreviousChapter = () => {
|
||||
if (readerChapter > 1) { setReaderChapter((c) => c - 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
|
||||
};
|
||||
|
||||
const readerGoToNextChapter = () => {
|
||||
if (readerChapter < readerTotalChapters) { setReaderChapter((c) => c + 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
|
||||
};
|
||||
|
||||
const handleReaderBookChange = (abbrev) => {
|
||||
setReaderBookAbbrev(abbrev);
|
||||
setReaderChapter(1);
|
||||
setReaderSelectedVerse(null);
|
||||
setReaderInterlinear(null);
|
||||
setReaderCrossRefs(null);
|
||||
setReaderSearch('');
|
||||
setReaderSearchActive(false);
|
||||
};
|
||||
|
||||
const loadReaderInterlinear = async (bookAbbrev) => {
|
||||
if (_readerInterlinearCacheRef.current[bookAbbrev]) {
|
||||
setReaderInterlinear(_readerInterlinearCacheRef.current[bookAbbrev]);
|
||||
return;
|
||||
}
|
||||
setReaderInterlinearLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/interlinear/${bookAbbrev}.json`);
|
||||
if (!res.ok) throw new Error('no interlinear');
|
||||
const data = await res.json();
|
||||
_readerInterlinearCacheRef.current[bookAbbrev] = data;
|
||||
setReaderInterlinear(data);
|
||||
} catch {
|
||||
setReaderInterlinear(null);
|
||||
} finally {
|
||||
setReaderInterlinearLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage !== 'reader') return;
|
||||
loadReaderInterlinear(readerBookAbbrev);
|
||||
setReaderSelectedVerse(null);
|
||||
}, [currentPage, readerBookAbbrev]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage !== 'reader' || !readerShowCrossRefs) return;
|
||||
loadReaderCrossRefs(readerBookAbbrev, readerChapter);
|
||||
}, [currentPage, readerBookAbbrev, readerChapter, readerShowCrossRefs]);
|
||||
|
||||
// Bookmarks, ink, highlights, cross-refs
|
||||
const toggleReaderBookmark = (verseKey) => {
|
||||
setReaderBookmarks((prev) => {
|
||||
const next = { ...prev };
|
||||
if (next[verseKey]) {
|
||||
delete next[verseKey];
|
||||
} else {
|
||||
const usedColors = Object.values(next);
|
||||
const color = BOOKMARK_COLORS.find((c) => !usedColors.includes(c)) ?? BOOKMARK_COLORS[0];
|
||||
next[verseKey] = color;
|
||||
}
|
||||
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
|
||||
const key = `${bookAbbrev}_${chapter}`;
|
||||
setReaderInkByPage((prev) => {
|
||||
const next = { ...prev, [key]: strokes };
|
||||
try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
// Debounce server sync — wait 1 s after last stroke before sending
|
||||
clearTimeout(inkSaveTimerRef.current[key]);
|
||||
inkSaveTimerRef.current[key] = setTimeout(() => {
|
||||
fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ strokes }),
|
||||
}).catch(() => {}); // silent — localStorage already saved locally
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const cycleBookmarkColor = (verseKey) => {
|
||||
setReaderBookmarks((prev) => {
|
||||
const cur = prev[verseKey];
|
||||
if (!cur) return prev;
|
||||
const idx = BOOKMARK_COLORS.indexOf(cur);
|
||||
const next = { ...prev, [verseKey]: BOOKMARK_COLORS[(idx + 1) % BOOKMARK_COLORS.length] };
|
||||
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addTextHighlight = (highlight) => {
|
||||
const id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
setReaderTextHighlights(prev => {
|
||||
const next = [...prev, { id, ...highlight }];
|
||||
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeTextHighlight = (id) => {
|
||||
setReaderTextHighlights(prev => {
|
||||
const next = prev.filter(h => h.id !== id);
|
||||
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const loadReaderCrossRefs = async (bookAbbrev, chapterNumber) => {
|
||||
const cacheKey = `${bookAbbrev}/${chapterNumber}`;
|
||||
if (_readerCrossRefCacheRef.current[cacheKey]) {
|
||||
setReaderCrossRefs(_readerCrossRefCacheRef.current[cacheKey]);
|
||||
return;
|
||||
}
|
||||
setReaderCrossRefsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`https://bible.helloao.org/api/d/open-cross-ref/${bookAbbrev}/${chapterNumber}.json`);
|
||||
if (!res.ok) throw new Error('no cross-refs');
|
||||
const data = await res.json();
|
||||
const byVerse = {};
|
||||
for (const item of data.chapter?.content ?? []) {
|
||||
if (item.references?.length) byVerse[item.verse] = item.references;
|
||||
}
|
||||
_readerCrossRefCacheRef.current[cacheKey] = byVerse;
|
||||
setReaderCrossRefs(byVerse);
|
||||
} catch {
|
||||
setReaderCrossRefs(null);
|
||||
} finally {
|
||||
setReaderCrossRefsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Merges reader ink loaded from the server at sign-in; server wins over local.
|
||||
const mergeServerInk = useCallback((serverInk) => {
|
||||
setReaderInkByPage((local) => {
|
||||
const merged = { ...local, ...serverInk };
|
||||
try { localStorage.setItem('readerInkByPage', JSON.stringify(merged)); } catch {}
|
||||
return merged;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
readerBookAbbrev, setReaderBookAbbrev,
|
||||
readerChapter, setReaderChapter,
|
||||
readerVerses,
|
||||
readerTotalChapters,
|
||||
readerLoading,
|
||||
readerError,
|
||||
readerInterlinear,
|
||||
readerSelectedVerse, setReaderSelectedVerse,
|
||||
readerFontSize, setReaderFontSize,
|
||||
readerBookmarks, setReaderBookmarks,
|
||||
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
|
||||
readerCrossRefs, setReaderCrossRefs,
|
||||
readerCrossRefsLoading,
|
||||
readerShowCrossRefs, setReaderShowCrossRefs,
|
||||
readerSearch, setReaderSearch,
|
||||
readerSearchActive, setReaderSearchActive,
|
||||
readerSearchScope, setReaderSearchScope,
|
||||
readerInkByPage,
|
||||
updateReaderPageInk,
|
||||
mergeServerInk,
|
||||
readerTextHighlights,
|
||||
addTextHighlight,
|
||||
removeTextHighlight,
|
||||
bibleIndexStatus,
|
||||
loadBibleIndex,
|
||||
_bibleIndexCacheRef,
|
||||
loadReaderChapter,
|
||||
handleReaderBookChange,
|
||||
readerGoToPreviousChapter,
|
||||
readerGoToNextChapter,
|
||||
jumpToReaderVerse,
|
||||
toggleReaderBookmark,
|
||||
cycleBookmarkColor,
|
||||
loadReaderCrossRefs,
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,13 @@ select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* When installed as a PWA, push header content below the status bar */
|
||||
@media all and (display-mode: standalone) {
|
||||
header {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
|
||||
@@ -3,6 +3,19 @@ import ReactDOM from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './index.css';
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
|
||||
// Pages are lazy-loaded chunks. If a deploy happened while this tab was open,
|
||||
// the old chunk files are gone — reload once to pick up the new build.
|
||||
window.addEventListener('vite:preloadError', () => {
|
||||
if (sessionStorage.getItem('chunk-reload')) return;
|
||||
sessionStorage.setItem('chunk-reload', '1');
|
||||
window.location.reload();
|
||||
});
|
||||
window.addEventListener('load', () => sessionStorage.removeItem('chunk-reload'));
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { buildExportHtml } from '../utils/bibleUtils.jsx';
|
||||
import {
|
||||
adminListUsers,
|
||||
adminListProjects,
|
||||
adminDeleteUser,
|
||||
adminDeleteProject,
|
||||
adminGetProject,
|
||||
adminResetPassword,
|
||||
} from '../syncService.js';
|
||||
|
||||
export default function AdminPage() {
|
||||
const { authUser, authStatus, goHome } = useApp();
|
||||
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects'
|
||||
const [adminUsers, setAdminUsers] = useState([]);
|
||||
const [adminProjects, setAdminProjects] = useState([]);
|
||||
const [adminLoading, setAdminLoading] = useState(false);
|
||||
const [adminError, setAdminError] = useState('');
|
||||
const [adminViewProject, setAdminViewProject] = useState(null); // full project data being previewed
|
||||
const [adminResetResult, setAdminResetResult] = useState(null); // { email, temporaryPassword } shown once
|
||||
|
||||
const loadAdminData = () => {
|
||||
setAdminLoading(true);
|
||||
setAdminError('');
|
||||
Promise.all([adminListUsers(), adminListProjects()]).then(([usersResult, projectsResult]) => {
|
||||
setAdminLoading(false);
|
||||
if (!usersResult.ok || !projectsResult.ok) {
|
||||
setAdminError(usersResult.error ?? projectsResult.error ?? 'Failed to load admin data.');
|
||||
return;
|
||||
}
|
||||
setAdminUsers(usersResult.data);
|
||||
setAdminProjects(projectsResult.data);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (authUser?.isAdmin) loadAdminData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [authUser?.isAdmin]);
|
||||
|
||||
const handleDeleteUserAdmin = async (id, email) => {
|
||||
if (!window.confirm(`Delete account "${email}"? Their projects are kept, not deleted, but become inaccessible until reassigned.`)) return;
|
||||
const result = await adminDeleteUser(id);
|
||||
if (result.ok) loadAdminData();
|
||||
else alert(result.error ?? 'Failed to delete user.');
|
||||
};
|
||||
|
||||
const handleDeleteProjectAdmin = async (id, title) => {
|
||||
if (!window.confirm(`Permanently delete project "${title}"? This cannot be undone.`)) return;
|
||||
const result = await adminDeleteProject(id);
|
||||
if (result.ok) loadAdminData();
|
||||
else alert(result.error ?? 'Failed to delete project.');
|
||||
};
|
||||
|
||||
const handleViewProjectAdmin = async (id) => {
|
||||
const result = await adminGetProject(id);
|
||||
if (result.ok) setAdminViewProject(result.data);
|
||||
else alert(result.error ?? 'Failed to load project.');
|
||||
};
|
||||
|
||||
const handleResetPasswordAdmin = async (id, email) => {
|
||||
if (!window.confirm(`Reset the password for "${email}"? They'll be signed out everywhere and need the new temporary password to log back in.`)) return;
|
||||
const result = await adminResetPassword(id);
|
||||
if (result.ok) setAdminResetResult({ email, temporaryPassword: result.data.temporaryPassword });
|
||||
else alert(result.error ?? 'Failed to reset password.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold">🛡 Admin</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goHome}
|
||||
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
{authStatus}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setAdminTab('users')}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'users' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
|
||||
Users ({adminUsers.length})
|
||||
</button>
|
||||
<button type="button" onClick={() => setAdminTab('projects')}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'projects' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
|
||||
Projects ({adminProjects.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{adminLoading && <p className="text-sm text-slate-500">Loading…</p>}
|
||||
{adminError && <p className="text-sm text-rose-600">{adminError}</p>}
|
||||
|
||||
{!adminLoading && !adminError && adminTab === 'users' && (
|
||||
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] text-sm">
|
||||
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Email</th>
|
||||
<th className="px-4 py-3">Joined</th>
|
||||
<th className="px-4 py-3">2FA</th>
|
||||
<th className="px-4 py-3">Projects</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adminUsers.map((u) => (
|
||||
<tr key={u.id} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-medium text-slate-800">
|
||||
{u.email}{u.id === authUser.id && <span className="ml-2 text-xs font-normal text-slate-400">(you)</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">{new Date(u.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{u.totpEnabled ? '✓' : '—'}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{u.projectCount}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{u.id !== authUser.id && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={() => handleResetPasswordAdmin(u.id, u.email)}
|
||||
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
|
||||
Reset Password
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDeleteUserAdmin(u.id, u.email)}
|
||||
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!adminLoading && !adminError && adminTab === 'projects' && (
|
||||
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] text-sm">
|
||||
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Title</th>
|
||||
<th className="px-4 py-3">Owner</th>
|
||||
<th className="px-4 py-3">Passage</th>
|
||||
<th className="px-4 py-3">Last edited</th>
|
||||
<th className="px-4 py-3">Shared</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adminProjects.map((p) => (
|
||||
<tr key={p.id} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-medium text-slate-800">{p.title}</td>
|
||||
<td className="px-4 py-3 text-slate-500">
|
||||
{p.ownerEmail ?? <span className="italic text-slate-400">orphaned</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">{p.chapterSummary}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{new Date(p.lastEdited).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{p.shareToken ? '🔗' : '—'}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={() => handleViewProjectAdmin(p.id)}
|
||||
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
|
||||
View
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDeleteProjectAdmin(p.id, p.title)}
|
||||
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{adminViewProject && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setAdminViewProject(null)}>
|
||||
<div className="h-full w-full max-w-4xl overflow-hidden rounded-2xl bg-white shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-slate-700">{adminViewProject.title}</p>
|
||||
<button type="button" onClick={() => setAdminViewProject(null)}
|
||||
className="rounded-lg border border-slate-300 px-3 py-1 text-xs text-slate-600 hover:bg-slate-50">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<iframe
|
||||
title="Project preview"
|
||||
srcDoc={buildExportHtml(adminViewProject)}
|
||||
sandbox="allow-popups"
|
||||
className="h-[calc(100%-3rem)] w-full border-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adminResetResult && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 className="text-sm font-semibold text-slate-900">Password reset</h3>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Relay this to <span className="font-medium text-slate-700">{adminResetResult.email}</span> yourself
|
||||
(text, call, in person) — it won't be shown again. They're signed out everywhere until they log in with it.
|
||||
</p>
|
||||
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-center font-mono text-lg tracking-wider text-amber-800">
|
||||
{adminResetResult.temporaryPassword}
|
||||
</p>
|
||||
<button type="button" onClick={() => setAdminResetResult(null)}
|
||||
className="mt-4 w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
|
||||
|
||||
export default function AuthPage() {
|
||||
const { signIn } = useApp();
|
||||
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register'
|
||||
const [authForm, setAuthForm] = useState({ email: '', password: '' });
|
||||
const [authError, setAuthError] = useState('');
|
||||
const [authBusy, setAuthBusy] = useState(false);
|
||||
const [authMfaPending, setAuthMfaPending] = useState(false); // password ok, waiting on TOTP/backup code
|
||||
const [authMfaCode, setAuthMfaCode] = useState('');
|
||||
const [authMfaUseBackup, setAuthMfaUseBackup] = useState(false);
|
||||
|
||||
const submitAuth = async (e) => {
|
||||
e.preventDefault();
|
||||
setAuthError('');
|
||||
setAuthBusy(true);
|
||||
const action = authMode === 'login' ? loginUser : registerUser;
|
||||
const result = await action(authForm.email.trim(), authForm.password);
|
||||
setAuthBusy(false);
|
||||
if (!result.ok) {
|
||||
setAuthError(result.error ?? 'Something went wrong.');
|
||||
return;
|
||||
}
|
||||
if (result.data?.mfaRequired) {
|
||||
setAuthMfaPending(true);
|
||||
return;
|
||||
}
|
||||
signIn(result.data);
|
||||
};
|
||||
|
||||
const submitMfa = async (e) => {
|
||||
e.preventDefault();
|
||||
setAuthError('');
|
||||
setAuthBusy(true);
|
||||
const result = await verifyMfaLogin(
|
||||
authMfaUseBackup ? { backupCode: authMfaCode.trim() } : { token: authMfaCode.trim() },
|
||||
);
|
||||
setAuthBusy(false);
|
||||
if (!result.ok) {
|
||||
setAuthError(result.error ?? 'Invalid code.');
|
||||
return;
|
||||
}
|
||||
setAuthMfaPending(false);
|
||||
setAuthMfaCode('');
|
||||
signIn(result.data);
|
||||
};
|
||||
|
||||
if (authMfaPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4">
|
||||
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 shadow-panel">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-xl font-semibold text-slate-900">Two-factor verification</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{authMfaUseBackup
|
||||
? 'Enter one of your saved backup codes.'
|
||||
: 'Enter the 6-digit code from your authenticator app.'}
|
||||
</p>
|
||||
<form onSubmit={submitMfa} className="mt-6 space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
inputMode={authMfaUseBackup ? 'text' : 'numeric'}
|
||||
placeholder={authMfaUseBackup ? 'xxxxxxxxxx' : '123456'}
|
||||
value={authMfaCode}
|
||||
onChange={(e) => setAuthMfaCode(e.target.value)}
|
||||
className="block w-full rounded-xl border border-slate-300 px-3 py-2 text-center text-lg tracking-widest shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
{authError && <p className="text-sm text-rose-600">{authError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authBusy}
|
||||
className="w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{authBusy ? 'Verifying…' : 'Verify'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuthMfaUseBackup((v) => !v);
|
||||
setAuthMfaCode('');
|
||||
setAuthError('');
|
||||
}}
|
||||
className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700"
|
||||
>
|
||||
{authMfaUseBackup ? 'Use your authenticator app instead' : "Lost your device? Use a backup code"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await logoutUser();
|
||||
setAuthMfaPending(false);
|
||||
setAuthMfaCode('');
|
||||
setAuthError('');
|
||||
}}
|
||||
className="mt-2 w-full text-center text-sm text-slate-400 underline hover:text-slate-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4 py-12">
|
||||
<div className="grid w-full max-w-5xl items-center gap-16 lg:grid-cols-2">
|
||||
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */}
|
||||
<div className="hidden lg:block">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
|
||||
<h1 className="mt-4 text-3xl font-semibold leading-tight text-white">
|
||||
Deep Bible study,<br />organized chunk by chunk.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-sm text-slate-300">
|
||||
Split any passage into chunks and work through Observation, Interpretation, and
|
||||
Application notes alongside Greek & 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useEffect, useRef, useCallback, useMemo, 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. Memoized so the canvas
|
||||
// only redraws when the strokes actually change, not on every parent re-render.
|
||||
const realStrokes = useMemo(() => extractMeta(strokes).realStrokes, [strokes]);
|
||||
|
||||
const canvasRef = useRef(null);
|
||||
const hoverRef = useRef(null);
|
||||
const activeStrokeRef = useRef([]);
|
||||
const isDrawingRef = useRef(false);
|
||||
|
||||
// Refs prevent stale closures in stable callbacks
|
||||
const strokesRef = useRef(realStrokes);
|
||||
const onStrokesChangeRef = useRef(onStrokesChange);
|
||||
const drawToolRef = useRef(drawTool);
|
||||
const drawColorRef = useRef(drawColor);
|
||||
const drawSizeRef = useRef(drawSize);
|
||||
strokesRef.current = realStrokes;
|
||||
onStrokesChangeRef.current = onStrokesChange;
|
||||
const pageCountRef = useRef(pageCount);
|
||||
pageCountRef.current = pageCount;
|
||||
drawToolRef.current = drawTool;
|
||||
drawColorRef.current = drawColor;
|
||||
drawSizeRef.current = drawSize;
|
||||
|
||||
const render = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
renderInkToCanvas(
|
||||
canvas,
|
||||
strokesRef.current,
|
||||
activeStrokeRef.current,
|
||||
drawToolRef.current,
|
||||
drawColorRef.current,
|
||||
drawSizeRef.current,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { render(); }, [realStrokes, drawTool, drawColor, drawSize, render]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const obs = new ResizeObserver(render);
|
||||
obs.observe(canvas);
|
||||
return () => obs.disconnect();
|
||||
}, [render]);
|
||||
|
||||
// Hover cursor — reads refs so no stale-closure risk; no React re-renders on each move
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
function onMove(e) {
|
||||
if (e.pointerType !== 'pen') return;
|
||||
const el = hoverRef.current;
|
||||
if (!el) return;
|
||||
if (e.pressure > 0) { el.style.display = 'none'; return; }
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const sizePx = Math.max(6, drawSizeRef.current * rect.height);
|
||||
const tool = drawToolRef.current;
|
||||
const color = tool === 'eraser' ? '#94a3b8' : drawColorRef.current;
|
||||
el.style.display = 'block';
|
||||
el.style.width = `${sizePx}px`;
|
||||
el.style.height = `${sizePx}px`;
|
||||
el.style.left = `${x}px`;
|
||||
el.style.top = `${y}px`;
|
||||
el.style.borderColor = color;
|
||||
el.style.borderRadius = tool === 'eraser' ? '2px' : '50%';
|
||||
}
|
||||
|
||||
function onLeave() {
|
||||
if (hoverRef.current) hoverRef.current.style.display = 'none';
|
||||
}
|
||||
|
||||
canvas.addEventListener('pointermove', onMove);
|
||||
canvas.addEventListener('pointerleave', onLeave);
|
||||
return () => {
|
||||
canvas.removeEventListener('pointermove', onMove);
|
||||
canvas.removeEventListener('pointerleave', onLeave);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getPoint = useCallback((e) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return [0, 0, 0.5, 0];
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
// tiltX/tiltY range ±90°; normalize to 0-1 where 1 = fully flat (60° threshold)
|
||||
const tilt = e.pointerType === 'pen'
|
||||
? Math.min(Math.hypot(e.tiltX || 0, e.tiltY || 0) / 60, 1)
|
||||
: 0;
|
||||
return [
|
||||
(e.clientX - rect.left) / rect.width,
|
||||
(e.clientY - rect.top) / rect.height,
|
||||
e.pressure ?? 0.5,
|
||||
tilt,
|
||||
];
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
function commitStroke() {
|
||||
const pts = activeStrokeRef.current;
|
||||
if (drawToolRef.current !== 'eraser' && pts.length > 1) {
|
||||
onStrokesChangeRef.current(packStrokes(
|
||||
[
|
||||
...strokesRef.current,
|
||||
{
|
||||
id: crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`,
|
||||
tool: drawToolRef.current,
|
||||
color: drawColorRef.current,
|
||||
size: drawSizeRef.current,
|
||||
points: pts,
|
||||
},
|
||||
],
|
||||
pageCountRef.current,
|
||||
));
|
||||
}
|
||||
activeStrokeRef.current = [];
|
||||
isDrawingRef.current = false;
|
||||
render();
|
||||
}
|
||||
|
||||
function eraseAt(pt) {
|
||||
const [ex, ey] = pt;
|
||||
const r = drawSizeRef.current * 3;
|
||||
const remaining = strokesRef.current.filter(
|
||||
(s) => !s.points.some(([sx, sy]) => Math.hypot(sx - ex, sy - ey) < r),
|
||||
);
|
||||
if (remaining.length !== strokesRef.current.length)
|
||||
onStrokesChangeRef.current(packStrokes(remaining, pageCountRef.current));
|
||||
}
|
||||
|
||||
// iOS Safari: Apple Pencil fires Touch Events with touchType === 'stylus'.
|
||||
// Pointer Events on iPadOS emit pointercancel immediately after pointerdown
|
||||
// (palm detection or canvas-width resets from React re-renders), which forces
|
||||
// a double-tap to start every stroke. Touch Events bypass this entirely.
|
||||
if (typeof Touch !== 'undefined' && 'touchType' in Touch.prototype) {
|
||||
function pointFromTouch(t) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
// radiusX grows as the pencil tilts flat; ~10px radius ≈ fully tilted
|
||||
const tilt = Math.min((t.radiusX || 0) / 10, 1);
|
||||
return [
|
||||
(t.clientX - rect.left) / rect.width,
|
||||
(t.clientY - rect.top) / rect.height,
|
||||
t.force ?? 0.5,
|
||||
tilt,
|
||||
];
|
||||
}
|
||||
|
||||
function tStart(e) {
|
||||
// Two-finger tap (both non-stylus) = undo last stroke
|
||||
if (e.touches.length === 2 && Array.from(e.touches).every((t) => t.touchType !== 'stylus')) {
|
||||
e.preventDefault();
|
||||
if (strokesRef.current.length > 0) {
|
||||
onStrokesChangeRef.current(packStrokes(strokesRef.current.slice(0, -1), pageCountRef.current));
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const t of e.changedTouches) {
|
||||
if (t.touchType !== 'stylus') continue;
|
||||
e.preventDefault();
|
||||
if (hoverRef.current) hoverRef.current.style.display = 'none';
|
||||
isDrawingRef.current = true;
|
||||
activeStrokeRef.current = [pointFromTouch(t)];
|
||||
render();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function tMove(e) {
|
||||
if (!isDrawingRef.current) return;
|
||||
for (const t of e.changedTouches) {
|
||||
if (t.touchType !== 'stylus') continue;
|
||||
e.preventDefault();
|
||||
const pt = pointFromTouch(t);
|
||||
activeStrokeRef.current = [...activeStrokeRef.current, pt];
|
||||
if (drawToolRef.current === 'eraser') eraseAt(pt);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function tEnd(e) {
|
||||
if (!isDrawingRef.current) return;
|
||||
for (const t of e.changedTouches) {
|
||||
if (t.touchType !== 'stylus') continue;
|
||||
e.preventDefault();
|
||||
commitStroke();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.addEventListener('touchstart', tStart, { passive: false });
|
||||
canvas.addEventListener('touchmove', tMove, { passive: false });
|
||||
canvas.addEventListener('touchend', tEnd, { passive: false });
|
||||
canvas.addEventListener('touchcancel', tEnd, { passive: false });
|
||||
return () => {
|
||||
canvas.removeEventListener('touchstart', tStart);
|
||||
canvas.removeEventListener('touchmove', tMove);
|
||||
canvas.removeEventListener('touchend', tEnd);
|
||||
canvas.removeEventListener('touchcancel', tEnd);
|
||||
};
|
||||
}
|
||||
|
||||
// Non-iOS: Pointer Events (mouse, Windows pen, etc.)
|
||||
function down(e) {
|
||||
if (e.pointerType === 'touch') return;
|
||||
isDrawingRef.current = true;
|
||||
activeStrokeRef.current = [getPoint(e)];
|
||||
render();
|
||||
}
|
||||
|
||||
function move(e) {
|
||||
if (e.pointerType === 'touch') return;
|
||||
if (!isDrawingRef.current) return;
|
||||
e.preventDefault();
|
||||
const pt = getPoint(e);
|
||||
activeStrokeRef.current = [...activeStrokeRef.current, pt];
|
||||
if (drawToolRef.current === 'eraser') eraseAt(pt);
|
||||
render();
|
||||
}
|
||||
|
||||
function up(e) {
|
||||
if (e.pointerType === 'touch') return;
|
||||
if (!isDrawingRef.current) return;
|
||||
commitStroke();
|
||||
}
|
||||
|
||||
canvas.addEventListener('pointerdown', down);
|
||||
canvas.addEventListener('pointermove', move, { passive: false });
|
||||
canvas.addEventListener('pointerup', up);
|
||||
canvas.addEventListener('pointercancel', up);
|
||||
return () => {
|
||||
canvas.removeEventListener('pointerdown', down);
|
||||
canvas.removeEventListener('pointermove', move);
|
||||
canvas.removeEventListener('pointerup', up);
|
||||
canvas.removeEventListener('pointercancel', up);
|
||||
};
|
||||
}, [getPoint, render]);
|
||||
|
||||
const undo = () =>
|
||||
realStrokes.length > 0 &&
|
||||
onStrokesChange(packStrokes(realStrokes.slice(0, -1), pageCount));
|
||||
|
||||
const clear = () =>
|
||||
realStrokes.length > 0 &&
|
||||
window.confirm('Clear all ink notes?') &&
|
||||
onStrokesChange(packStrokes([], pageCount));
|
||||
|
||||
// Add another notebook page below existing content.
|
||||
// Rescales all stroke y-coords so existing ink stays at the same pixel position.
|
||||
const addSpace = () => {
|
||||
const newCount = pageCount + 1;
|
||||
const ratio = pageCount / newCount;
|
||||
const rescaled = realStrokes.map((s) => ({
|
||||
...s,
|
||||
points: s.points.map(([x, y, p]) => [x, y * ratio, p]),
|
||||
}));
|
||||
onStrokesChange(packStrokes(rescaled, newCount));
|
||||
setPageCount(newCount);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-3xl border border-slate-200 shadow-sm select-none" style={{ WebkitUserSelect: 'none' }}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-slate-200 bg-white px-3 py-2">
|
||||
{[
|
||||
{ id: 'pen', label: '✒', title: 'Pen' },
|
||||
{ id: 'highlighter', label: '▐', title: 'Highlighter' },
|
||||
{ id: 'eraser', label: '⌫', title: 'Eraser' },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
title={t.title}
|
||||
onClick={() => setDrawTool(t.id)}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg text-sm font-bold transition ${
|
||||
drawTool === t.id ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-slate-200" />
|
||||
|
||||
{INK_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => { setDrawColor(c); if (drawTool === 'eraser') setDrawTool('pen'); }}
|
||||
style={{ background: c }}
|
||||
className={`h-5 w-5 rounded-full border-2 transition ${
|
||||
drawColor === c && drawTool !== 'eraser' ? 'scale-125 border-slate-900' : 'border-white shadow-sm'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-slate-200" />
|
||||
|
||||
{INK_SIZES.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
type="button"
|
||||
onClick={() => setDrawSize(s.value)}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg text-xs font-bold transition ${
|
||||
drawSize === s.value ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-slate-200" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={undo}
|
||||
disabled={realStrokes.length === 0}
|
||||
className="rounded-lg px-2.5 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-100 disabled:opacity-40"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
disabled={realStrokes.length === 0}
|
||||
className="rounded-lg px-2.5 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-40"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
|
||||
{onDone && (
|
||||
<>
|
||||
<div className="mx-1 h-5 w-px bg-slate-200" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDone}
|
||||
className="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-slate-800"
|
||||
>
|
||||
Done ✓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Draw surface: optional scripture header + ruled notebook, one canvas over both */}
|
||||
<div className="relative">
|
||||
{/* Scripture content — canvas sits on top so you can draw directly on the text */}
|
||||
{headerContent && (
|
||||
<div
|
||||
className="border-b border-slate-200 bg-slate-50 p-4 font-serif text-sm leading-relaxed text-slate-800"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{headerContent}
|
||||
</div>
|
||||
)}
|
||||
{/* Ruled notebook area — height grows with pageCount */}
|
||||
<div
|
||||
style={{
|
||||
minHeight: PAGE_HEIGHT * pageCount,
|
||||
background: 'white',
|
||||
backgroundImage: 'repeating-linear-gradient(transparent 0px, transparent 31px, #dde3ec 31px, #dde3ec 32px)',
|
||||
}}
|
||||
/>
|
||||
{/* Red margin line — only in pure-notebook mode */}
|
||||
{!headerContent && (
|
||||
<div className="absolute bottom-0 left-10 top-0 w-px bg-rose-200" style={{ zIndex: 1 }} />
|
||||
)}
|
||||
{/* Hover cursor — positioned by the pointermove handler, never by React */}
|
||||
<div
|
||||
ref={hoverRef}
|
||||
style={{
|
||||
display: 'none',
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 3,
|
||||
border: '1.5px solid',
|
||||
boxSizing: 'border-box',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
{/* Single canvas spanning the entire area (scripture + notebook) */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
style={{
|
||||
zIndex: 2,
|
||||
cursor: drawTool === 'eraser' ? 'cell' : 'crosshair',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add more notebook space */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSpace}
|
||||
className="w-full border-t border-slate-200 bg-slate-50 py-3 text-xs font-semibold text-slate-500 transition hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
+ Add More Space
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../utils/bibleUtils.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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { bookOptions } from '../utils/bibleUtils.jsx';
|
||||
|
||||
export default function ImportPage() {
|
||||
const {
|
||||
authStatus,
|
||||
setCurrentPage,
|
||||
availableTranslations,
|
||||
importBookAbbrev, setImportBookAbbrev,
|
||||
importTranslation, setImportTranslation,
|
||||
importTitle, setImportTitle,
|
||||
importFile,
|
||||
importPreview,
|
||||
importBusy,
|
||||
importError,
|
||||
handleImportFileChange,
|
||||
runEpisodeImport,
|
||||
} = 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 items-center justify-between px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold">Import Session List</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage('home')}
|
||||
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
{authStatus}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-5">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600 space-y-2">
|
||||
<p className="font-semibold text-slate-700">How this works</p>
|
||||
<p>
|
||||
This is a shortcut for setting up a multi-part study — a teaching series, sermon series, class
|
||||
curriculum, or podcast — all at once, instead of building each chapter and chunk by hand.
|
||||
</p>
|
||||
<p>
|
||||
Upload a .docx containing a table with three columns: session number, title, and passage
|
||||
(e.g. <span className="font-mono text-xs">1:1–2</span>). Each row becomes one chunk, grouped
|
||||
automatically by chapter. If you don't have a document like this, just use{' '}
|
||||
<span className="font-semibold">+ New Project</span> on the home page instead — this import
|
||||
step is entirely optional.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Book
|
||||
<select
|
||||
value={importBookAbbrev}
|
||||
onChange={(e) => setImportBookAbbrev(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
{bookOptions.map((b) => (
|
||||
<option key={b.abbrev} value={b.abbrev}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Translation
|
||||
<select
|
||||
value={importTranslation}
|
||||
onChange={(e) => setImportTranslation(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
{availableTranslations.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Project title
|
||||
<input
|
||||
type="text"
|
||||
value={importTitle}
|
||||
onChange={(e) => setImportTitle(e.target.value)}
|
||||
placeholder={`${bookOptions.find((b) => b.abbrev === importBookAbbrev)?.name ?? ''} Sessions`}
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Session list (.docx)
|
||||
<input
|
||||
type="file"
|
||||
accept=".docx"
|
||||
onChange={(e) => handleImportFileChange(e.target.files?.[0] ?? null)}
|
||||
className="mt-1 block w-full text-sm text-slate-600 file:mr-4 file:rounded-xl file:border-0 file:bg-slate-700 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-white hover:file:bg-slate-600"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{importBusy && <p className="text-sm text-slate-500">Working…</p>}
|
||||
{importError && <p className="text-sm text-rose-600">{importError}</p>}
|
||||
|
||||
{importPreview && !importBusy && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold text-slate-700">
|
||||
{importPreview.length} session{importPreview.length === 1 ? '' : 's'} found
|
||||
{' · '}
|
||||
{importPreview.filter((s) => s.parsed && s.parsed !== 'invalid').length} with passages
|
||||
</p>
|
||||
<div className="max-h-80 overflow-y-auto rounded-xl border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{importPreview.map((spec) => (
|
||||
<tr key={spec.episodeNumber} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-3 py-1.5 text-slate-500">#{spec.episodeNumber}</td>
|
||||
<td className="px-3 py-1.5 text-slate-900">{spec.title}</td>
|
||||
<td className="px-3 py-1.5 text-right text-slate-500">
|
||||
{spec.parsed === 'invalid'
|
||||
? <span className="text-rose-600">unrecognized — skipped</span>
|
||||
: spec.parsed
|
||||
? spec.passage
|
||||
: <span className="text-slate-400">marker (no passage)</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runEpisodeImport}
|
||||
disabled={importBusy}
|
||||
className="rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
Create Project from Import
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { buildPronunciationGuide } from '../utils/bibleUtils.jsx';
|
||||
|
||||
export default function PronunciationPage() {
|
||||
const { project, speakOriginalWord, setCurrentPage } = useApp();
|
||||
const [search, setSearch] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
|
||||
|
||||
// Collect all words with chapter/chunk context, deduplicated by Strong's number
|
||||
const seen = new Set();
|
||||
const entries = [];
|
||||
chapters.forEach((ch) => {
|
||||
ch.chunks.forEach((chunk) => {
|
||||
(chunk.greekWords ?? []).forEach((word) => {
|
||||
if (!word.lexeme && !word.transliteration) return;
|
||||
const key = word.strongNumber || `${word.lexeme}__${word.transliteration}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
entries.push({ word, chapterRef: `${ch.book} ${ch.chapter}` });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? entries.filter(({ word }) =>
|
||||
(word.englishGloss ?? '').toLowerCase().includes(q) ||
|
||||
(word.shortDefinition ?? '').toLowerCase().includes(q) ||
|
||||
(word.lexeme ?? '').toLowerCase().includes(q) ||
|
||||
(word.transliteration ?? '').toLowerCase().includes(q) ||
|
||||
(word.strongNumber ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
: entries;
|
||||
|
||||
const greek = filtered.filter(({ word }) => !word.strongNumber?.startsWith('H'));
|
||||
const hebrew = filtered.filter(({ word }) => word.strongNumber?.startsWith('H'));
|
||||
|
||||
const copyAll = () => {
|
||||
const guide = buildPronunciationGuide(project);
|
||||
navigator.clipboard.writeText(guide).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||
{/* Page header */}
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Pronunciation Guide</h1>
|
||||
{project?.title && (
|
||||
<p className="mt-1 text-sm text-slate-500">{project.title}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{entries.length} unique word{entries.length !== 1 ? 's' : ''} across all chapters
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyAll}
|
||||
className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-100"
|
||||
>
|
||||
{copied ? '✓ Copied!' : '📋 Copy all'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage('study')}
|
||||
className="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-slate-400"
|
||||
>
|
||||
← Back to Study
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by word, gloss, Strong's number…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-slate-300 bg-white p-12 text-center">
|
||||
<p className="text-2xl">🔤</p>
|
||||
<p className="mt-3 font-semibold text-slate-700">No words yet</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Add Greek or Hebrew words in the Word Studies panel on the Study page.
|
||||
</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-slate-500">No words match "{search}"</p>
|
||||
) : (
|
||||
<div className="space-y-10">
|
||||
{greek.length > 0 && <WordGroup title="Greek (New Testament)" color="sky" words={greek} onSpeak={speakOriginalWord} />}
|
||||
{hebrew.length > 0 && <WordGroup title="Hebrew (Old Testament)" color="emerald" words={hebrew} onSpeak={speakOriginalWord} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WordGroup({ title, color, words, onSpeak }) {
|
||||
const accent = {
|
||||
sky: { badge: 'bg-sky-100 text-sky-700 border-sky-200', speak: 'bg-sky-100 text-sky-700 hover:bg-sky-200', heading: 'text-sky-800 border-sky-200 bg-sky-50' },
|
||||
emerald: { badge: 'bg-emerald-100 text-emerald-700 border-emerald-200', speak: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200', heading: 'text-emerald-800 border-emerald-200 bg-emerald-50' },
|
||||
}[color];
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className={`mb-4 inline-block rounded-full border px-4 py-1.5 text-sm font-semibold ${accent.heading}`}>
|
||||
{title} · {words.length} word{words.length !== 1 ? 's' : ''}
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{words.map(({ word, chapterRef }) => (
|
||||
<WordCard key={word.id ?? word.lexeme} word={word} chapterRef={chapterRef} accent={accent} onSpeak={onSpeak} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function WordCard({ word, chapterRef, accent, onSpeak }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
{/* Top row: lexeme + speak */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
{word.lexeme && (
|
||||
<p className="text-3xl font-light leading-tight tracking-wide text-slate-900 break-words">
|
||||
{word.lexeme}
|
||||
</p>
|
||||
)}
|
||||
{word.transliteration && (
|
||||
<p className="mt-1 text-base font-medium italic text-slate-500">
|
||||
{word.transliteration}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
{word.lexeme && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSpeak(word.lexeme, word.strongNumber)}
|
||||
className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${accent.speak} border-current/20`}
|
||||
title="Hear pronunciation"
|
||||
>
|
||||
🔊 Speak
|
||||
</button>
|
||||
)}
|
||||
{word.strongNumber && (
|
||||
<span className={`rounded-full border px-2.5 py-0.5 text-xs font-semibold ${accent.badge}`}>
|
||||
{word.strongNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gloss + definition */}
|
||||
<div className="space-y-0.5">
|
||||
{word.englishGloss && (
|
||||
<p className="text-sm font-semibold text-slate-800">{word.englishGloss}</p>
|
||||
)}
|
||||
{word.shortDefinition && word.shortDefinition !== word.englishGloss && (
|
||||
<p className="text-sm text-slate-500 leading-snug">{word.shortDefinition}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: part of speech + source passage */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-slate-100 pt-3 text-xs text-slate-400">
|
||||
{word.partOfSpeech && (
|
||||
<span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-slate-600">{word.partOfSpeech}</span>
|
||||
)}
|
||||
<span className="ml-auto">{chapterRef}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,937 @@
|
||||
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
|
||||
import DrawCanvas from './DrawCanvas.jsx';
|
||||
|
||||
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
|
||||
|
||||
export default function ReaderPage() {
|
||||
const {
|
||||
authStatus,
|
||||
goHome,
|
||||
readerBookAbbrev,
|
||||
readerChapter, setReaderChapter,
|
||||
readerVerses,
|
||||
readerTotalChapters,
|
||||
readerLoading,
|
||||
readerError,
|
||||
readerInterlinear,
|
||||
readerSelectedVerse, setReaderSelectedVerse,
|
||||
readerFontSize, setReaderFontSize,
|
||||
readerBookmarks,
|
||||
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
|
||||
readerCrossRefs,
|
||||
readerCrossRefsLoading,
|
||||
readerShowCrossRefs, setReaderShowCrossRefs,
|
||||
readerSearch, setReaderSearch,
|
||||
readerSearchActive, setReaderSearchActive,
|
||||
readerSearchScope, setReaderSearchScope,
|
||||
readerAudioState,
|
||||
bibleIndexStatus,
|
||||
audioNarrator, setAudioNarrator,
|
||||
_bibleIndexCacheRef,
|
||||
handleReaderBookChange,
|
||||
readerGoToPreviousChapter,
|
||||
readerGoToNextChapter,
|
||||
jumpToReaderVerse,
|
||||
toggleReaderBookmark,
|
||||
cycleBookmarkColor,
|
||||
loadReaderCrossRefs,
|
||||
loadBibleIndex,
|
||||
handlePlayReaderAudio,
|
||||
handleToggleReaderAudioPause,
|
||||
handleStopBookAudio,
|
||||
speakOriginalWord,
|
||||
copyVerse,
|
||||
formatCrossRef,
|
||||
setReaderCrossRefs,
|
||||
readerInkByPage,
|
||||
updateReaderPageInk,
|
||||
readerTextHighlights,
|
||||
addTextHighlight,
|
||||
removeTextHighlight,
|
||||
readingPlan,
|
||||
markChapterRead,
|
||||
} = useApp();
|
||||
|
||||
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
|
||||
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
||||
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
|
||||
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
|
||||
const [selectionPicker, setSelectionPicker] = useState(null);
|
||||
|
||||
// Whole-Bible search: lowercase the ~31k-verse index once (not per keystroke),
|
||||
// and let typing stay responsive while results catch up via useDeferredValue.
|
||||
const bibleIndex = bibleIndexStatus === 'ready' ? (_bibleIndexCacheRef.current.BSB ?? null) : null;
|
||||
const bibleIndexLower = useMemo(
|
||||
() => (bibleIndex ? bibleIndex.map((v) => v.text.toLowerCase()) : null),
|
||||
[bibleIndex],
|
||||
);
|
||||
const deferredBibleQuery = useDeferredValue(
|
||||
readerSearchActive && readerSearchScope === 'bible' ? readerSearch.trim().toLowerCase() : '',
|
||||
);
|
||||
const bibleMatches = useMemo(() => {
|
||||
if (!bibleIndex || !deferredBibleQuery) return [];
|
||||
const out = [];
|
||||
for (let i = 0; i < bibleIndex.length; i++) {
|
||||
if (bibleIndexLower[i].includes(deferredBibleQuery)) out.push(bibleIndex[i]);
|
||||
}
|
||||
return out;
|
||||
}, [bibleIndex, bibleIndexLower, deferredBibleQuery]);
|
||||
const swipeTouchRef = useRef(null);
|
||||
|
||||
const [toolbarVisible, setToolbarVisible] = useState(true);
|
||||
const [readerPageMode, setReaderPageMode] = useState(() => localStorage.getItem('reader-page-mode') === '1');
|
||||
const [flipPage, setFlipPage] = useState(0);
|
||||
const [flipTotalPages, setFlipTotalPages] = useState(1);
|
||||
const [flipPageWidth, setFlipPageWidth] = useState(0);
|
||||
const flipContainerRef = useRef(null);
|
||||
const flipContentRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('reader-wide', readerWideLayout ? '1' : '0');
|
||||
// Auto-collapse toolbar when entering wide mode, restore when leaving
|
||||
setToolbarVisible(!readerWideLayout);
|
||||
}, [readerWideLayout]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('reader-page-mode', readerPageMode ? '1' : '0');
|
||||
}, [readerPageMode]);
|
||||
|
||||
// Reset to page 0 when navigating chapters
|
||||
useEffect(() => {
|
||||
setFlipPage(0);
|
||||
}, [readerChapter, readerBookAbbrev]);
|
||||
|
||||
// Track container width for CSS column sizing
|
||||
useEffect(() => {
|
||||
if (!readerPageMode || !flipContainerRef.current) return;
|
||||
const el = flipContainerRef.current;
|
||||
const update = () => setFlipPageWidth(el.clientWidth);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [readerPageMode, readerVerses]);
|
||||
|
||||
// Count pages after content renders
|
||||
useEffect(() => {
|
||||
if (!readerPageMode || !flipContentRef.current || !flipPageWidth) return;
|
||||
const t = setTimeout(() => {
|
||||
const sw = flipContentRef.current?.scrollWidth ?? 0;
|
||||
setFlipTotalPages(Math.max(1, Math.round(sw / flipPageWidth)));
|
||||
}, 120);
|
||||
return () => clearTimeout(t);
|
||||
}, [readerPageMode, readerVerses, readerFontSize, flipPageWidth]);
|
||||
|
||||
// Swipe left/right to navigate chapters
|
||||
const handleTouchStart = (e) => {
|
||||
if (readerDrawMode) return;
|
||||
const t = e.changedTouches[0];
|
||||
swipeTouchRef.current = { x: t.clientX, y: t.clientY };
|
||||
};
|
||||
const handleTouchEnd = (e) => {
|
||||
if (readerDrawMode || !swipeTouchRef.current) return;
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - swipeTouchRef.current.x;
|
||||
const dy = t.clientY - swipeTouchRef.current.y;
|
||||
swipeTouchRef.current = null;
|
||||
if (Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx) * 0.8) return;
|
||||
if (dx < 0) {
|
||||
if (readerPageMode && flipPage < flipTotalPages - 1) setFlipPage((p) => p + 1);
|
||||
else readerGoToNextChapter();
|
||||
} else {
|
||||
if (readerPageMode && flipPage > 0) setFlipPage((p) => p - 1);
|
||||
else readerGoToPreviousChapter();
|
||||
}
|
||||
};
|
||||
|
||||
// Arrow keys for desktop
|
||||
useEffect(() => {
|
||||
const handleKey = (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
|
||||
if (e.key === 'ArrowLeft') {
|
||||
if (readerPageMode && flipPage > 0) setFlipPage((p) => p - 1);
|
||||
else readerGoToPreviousChapter();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
if (readerPageMode && flipPage < flipTotalPages - 1) setFlipPage((p) => p + 1);
|
||||
else readerGoToNextChapter();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, [readerGoToPreviousChapter, readerGoToNextChapter, readerPageMode, flipPage, flipTotalPages]);
|
||||
|
||||
// Dismiss the highlight picker when tapping outside it
|
||||
useEffect(() => {
|
||||
if (!selectionPicker) return;
|
||||
const dismiss = () => setSelectionPicker(null);
|
||||
document.addEventListener('pointerdown', dismiss);
|
||||
return () => document.removeEventListener('pointerdown', dismiss);
|
||||
}, [selectionPicker]);
|
||||
|
||||
// Render verse text with colored highlights and optional search match highlight
|
||||
function renderVerseText(verseNum, text, searchLow) {
|
||||
const spans = [];
|
||||
for (const h of (readerTextHighlights || [])) {
|
||||
if (h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum) {
|
||||
spans.push({ start: h.startOffset, end: h.endOffset, type: 'hl', id: h.id, color: h.color });
|
||||
}
|
||||
}
|
||||
if (searchLow) {
|
||||
const lower = text.toLowerCase();
|
||||
let i = 0;
|
||||
while ((i = lower.indexOf(searchLow, i)) !== -1) {
|
||||
spans.push({ start: i, end: i + searchLow.length, type: 'search' });
|
||||
i += searchLow.length;
|
||||
}
|
||||
}
|
||||
if (!spans.length) return text;
|
||||
spans.sort((a, b) => a.start - b.start);
|
||||
const parts = [];
|
||||
let cursor = 0;
|
||||
for (const s of spans) {
|
||||
if (s.start < cursor) continue;
|
||||
if (s.start > cursor) parts.push(text.slice(cursor, s.start));
|
||||
if (s.type === 'hl') {
|
||||
parts.push(
|
||||
<mark key={s.id} style={{ backgroundColor: s.color, borderRadius: '2px', cursor: 'pointer' }}
|
||||
onClick={() => removeTextHighlight(s.id)} title="Click to remove highlight">
|
||||
{text.slice(s.start, s.end)}
|
||||
</mark>
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
<mark key={`srch${s.start}`} className="bg-yellow-200 rounded px-0.5">
|
||||
{text.slice(s.start, s.end)}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
cursor = s.end;
|
||||
}
|
||||
if (cursor < text.length) parts.push(text.slice(cursor));
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
// Detect text selection and show the highlight color picker
|
||||
function handleSelectionEnd() {
|
||||
setTimeout(() => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (!rect.width) return;
|
||||
let node = range.commonAncestorContainer;
|
||||
if (node.nodeType !== 1) node = node.parentNode;
|
||||
while (node && !(node.dataset && node.dataset.verseNum)) node = node.parentElement;
|
||||
if (!node) return;
|
||||
const verseNum = Number(node.dataset.verseNum);
|
||||
const verse = readerVerses.find(v => v.number === verseNum);
|
||||
if (!verse) return;
|
||||
const selectedText = sel.toString();
|
||||
const startOffset = verse.text.indexOf(selectedText);
|
||||
if (startOffset === -1) return;
|
||||
setSelectionPicker({
|
||||
x: (rect.left + rect.right) / 2,
|
||||
y: rect.top,
|
||||
verse: verseNum,
|
||||
startOffset,
|
||||
endOffset: startOffset + selectedText.length,
|
||||
});
|
||||
}, 20);
|
||||
}
|
||||
|
||||
const bookmarkEntries = Object.entries(readerBookmarks).map(([key, color]) => {
|
||||
const [bAbbrev, chapterStr, verseStr] = key.split('-');
|
||||
const bookIndex = bookOptions.findIndex((b) => b.abbrev === bAbbrev);
|
||||
return {
|
||||
key, color,
|
||||
bookAbbrev: bAbbrev,
|
||||
bookName: bookOptions[bookIndex]?.name ?? bAbbrev,
|
||||
chapter: Number(chapterStr),
|
||||
verse: Number(verseStr),
|
||||
bookIndex,
|
||||
};
|
||||
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold">Read the Bible (BSB)</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goHome}
|
||||
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||
>
|
||||
← Back to Studies
|
||||
</button>
|
||||
{authStatus}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main
|
||||
className={`mx-auto px-4 py-8 sm:px-6 lg:px-8 ${readerDrawMode ? 'max-w-full' : readerWideLayout ? 'max-w-7xl' : 'max-w-3xl'}`}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Collapsible toolbar — hidden in wide focus mode */}
|
||||
<div className={`transition-all duration-200 ${!toolbarVisible ? 'hidden' : ''}`}>
|
||||
|
||||
{/* Navigation + tools bar */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel">
|
||||
<label className="text-sm text-slate-600">
|
||||
Book{' '}
|
||||
<select
|
||||
value={readerBookAbbrev}
|
||||
onChange={(e) => handleReaderBookChange(e.target.value)}
|
||||
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
{bookOptions.map((b) => (
|
||||
<option key={b.abbrev} value={b.abbrev}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-600">
|
||||
Chapter{' '}
|
||||
<select
|
||||
value={readerChapter}
|
||||
onChange={(e) => { setReaderChapter(Number(e.target.value)); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }}
|
||||
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
{Array.from({ length: readerTotalChapters }, (_, i) => i + 1).map((num) => (
|
||||
<option key={num} value={num}>{num}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{readingPlan && readingPlan.bookAbbrev === readerBookAbbrev && (() => {
|
||||
const done = readingPlan.chaptersRead.includes(readerChapter);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markChapterRead(readerChapter)}
|
||||
className={`rounded-xl px-3 py-1.5 text-sm font-semibold transition ${done ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'border border-emerald-300 bg-white text-emerald-700 hover:bg-emerald-50'}`}
|
||||
title={done ? 'Click to unmark' : 'Mark this chapter as read in your reading plan'}
|
||||
>
|
||||
{done ? '✓ Read' : 'Mark as read'}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<button type="button" onClick={readerGoToPreviousChapter} disabled={readerChapter <= 1}
|
||||
className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
‹ Prev
|
||||
</button>
|
||||
<button type="button" onClick={readerGoToNextChapter} disabled={readerChapter >= readerTotalChapters}
|
||||
className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool strip: font size · cross-refs · search */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-3xl border border-slate-200 bg-white px-4 py-3 shadow-panel">
|
||||
<span className="text-xs text-slate-500 mr-1">Text size</span>
|
||||
{[['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25]].map(([label, size]) => (
|
||||
<button key={label} type="button"
|
||||
onClick={() => setReaderFontSize(size)}
|
||||
className={`rounded-lg px-2.5 py-1 text-xs font-semibold transition ${readerFontSize === size ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="mx-2 h-4 w-px bg-slate-200" />
|
||||
<button type="button"
|
||||
onClick={() => { setReaderShowCrossRefs((v) => !v); if (!readerShowCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
|
||||
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerShowCrossRefs ? 'bg-amber-100 text-amber-800' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
|
||||
{readerCrossRefsLoading ? 'Loading refs…' : '🔗 Cross-Refs'}
|
||||
</button>
|
||||
<div className="mx-2 h-4 w-px bg-slate-200" />
|
||||
<div className="relative">
|
||||
<button type="button"
|
||||
onClick={() => setReaderBookmarksPanelOpen((v) => !v)}
|
||||
className={`flex items-center gap-1.5 rounded-lg px-3 py-1 text-xs font-semibold transition ${readerBookmarksPanelOpen ? 'bg-sky-100 text-sky-800' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
|
||||
<BookmarkIcon filled={bookmarkEntries.length > 0} />
|
||||
Bookmarks{bookmarkEntries.length > 0 ? ` (${bookmarkEntries.length})` : ''}
|
||||
</button>
|
||||
{readerBookmarksPanelOpen && (
|
||||
<div className="absolute left-0 top-full z-20 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-3 shadow-lg">
|
||||
{bookmarkEntries.length === 0 ? (
|
||||
<p className="p-2 text-sm text-slate-500">
|
||||
No bookmarks yet. Tap the bookmark icon next to any verse to save it here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto">
|
||||
{bookmarkEntries.map((entry) => (
|
||||
<div key={entry.key} className="flex items-center gap-2 rounded-xl px-2 py-1.5 hover:bg-slate-50">
|
||||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: entry.color }} />
|
||||
<button type="button"
|
||||
onClick={() => jumpToReaderVerse(entry.bookAbbrev, entry.chapter, entry.verse)}
|
||||
className="flex-1 truncate text-left text-sm font-medium text-slate-700 hover:text-sky-700">
|
||||
{entry.bookName} {entry.chapter}:{entry.verse}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => toggleReaderBookmark(entry.key)}
|
||||
title="Remove bookmark"
|
||||
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-rose-600">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-2 h-4 w-px bg-slate-200" />
|
||||
{readerSearchActive ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex overflow-hidden rounded-lg border border-slate-300">
|
||||
<button type="button"
|
||||
onClick={() => setReaderSearchScope('chapter')}
|
||||
className={`px-2 py-1 text-xs font-semibold transition ${readerSearchScope === 'chapter' ? 'bg-slate-900 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}>
|
||||
This chapter
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => { setReaderSearchScope('bible'); if (bibleIndexStatus === 'idle') loadBibleIndex(); }}
|
||||
className={`px-2 py-1 text-xs font-semibold transition ${readerSearchScope === 'bible' ? 'bg-slate-900 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}>
|
||||
Whole Bible
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={readerSearch}
|
||||
onChange={(e) => setReaderSearch(e.target.value)}
|
||||
placeholder={readerSearchScope === 'bible' ? 'Search the whole Bible…' : 'Search this chapter…'}
|
||||
className="rounded-xl border border-slate-300 bg-slate-50 px-3 py-1 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
<button type="button" onClick={() => { setReaderSearch(''); setReaderSearchActive(false); }}
|
||||
className="rounded-lg border border-slate-300 px-2 py-1 text-xs text-slate-500 hover:bg-slate-50">✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => setReaderSearchActive(true)}
|
||||
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
|
||||
🔍 Search
|
||||
</button>
|
||||
)}
|
||||
<div className="mx-2 h-4 w-px bg-slate-200" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReaderDrawMode((v) => !v)}
|
||||
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerDrawMode ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
✏ Draw
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReaderWideLayout((v) => !v)}
|
||||
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerWideLayout ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
⊞ Wide
|
||||
</button>
|
||||
{readerWideLayout && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setToolbarVisible(false)}
|
||||
className="rounded-lg px-3 py-1 text-xs font-semibold border border-slate-300 text-slate-600 hover:bg-slate-50 transition"
|
||||
>
|
||||
✕ Hide
|
||||
</button>
|
||||
)}
|
||||
{!readerDrawMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReaderPageMode((v) => !v)}
|
||||
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerPageMode ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
⎕ Page
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>{/* end collapsible toolbar */}
|
||||
|
||||
{/* Whole-Bible search results */}
|
||||
{readerSearchActive && readerSearchScope === 'bible' && (
|
||||
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
||||
{bibleIndexStatus === 'loading' && (
|
||||
<p className="text-sm text-slate-500">Loading the full Bible for search — this happens once per visit (~7MB)…</p>
|
||||
)}
|
||||
{bibleIndexStatus === 'error' && (
|
||||
<p className="text-sm text-rose-600">Couldn't load the full Bible for search. <button type="button" onClick={loadBibleIndex} className="underline">Try again</button></p>
|
||||
)}
|
||||
{bibleIndexStatus === 'ready' && (() => {
|
||||
const q = deferredBibleQuery;
|
||||
if (!q) return <p className="text-sm text-slate-500">Type at least a word to search all 66 books.</p>;
|
||||
const matches = bibleMatches;
|
||||
if (matches.length === 0) return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
|
||||
const shown = matches.slice(0, 100);
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
{matches.length} match{matches.length === 1 ? '' : 'es'}{matches.length > shown.length ? ` (showing first ${shown.length})` : ''}
|
||||
</p>
|
||||
<div className="max-h-96 space-y-1 overflow-y-auto">
|
||||
{shown.map((v) => {
|
||||
const idx = v.text.toLowerCase().indexOf(q);
|
||||
return (
|
||||
<button key={`${v.bookAbbrev}-${v.chapter}-${v.verse}`} type="button"
|
||||
onClick={() => jumpToReaderVerse(v.bookAbbrev, v.chapter, v.verse)}
|
||||
className="block w-full rounded-xl px-3 py-2 text-left text-sm hover:bg-slate-50">
|
||||
<span className="font-semibold text-slate-700">{v.bookName} {v.chapter}:{v.verse}</span>{' '}
|
||||
<span className="text-slate-600">
|
||||
{v.text.slice(0, idx)}
|
||||
<mark className="rounded bg-yellow-200 px-0.5">{v.text.slice(idx, idx + q.length)}</mark>
|
||||
{v.text.slice(idx + q.length)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio player */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-slate-900">Listen to this chapter</h3>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{readerAudioState.status === 'error'
|
||||
? "Couldn't load audio for this chapter/narrator."
|
||||
: `Audio follows ${readerBook?.name} ${readerChapter} as you browse.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)}
|
||||
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200">
|
||||
<option value="david">David</option>
|
||||
<option value="hays">Hays</option>
|
||||
<option value="souer">Souer</option>
|
||||
</select>
|
||||
{readerAudioState.status === 'playing' || readerAudioState.status === 'paused' ? (
|
||||
<>
|
||||
<button type="button" onClick={handleToggleReaderAudioPause}
|
||||
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500">
|
||||
{readerAudioState.status === 'paused' ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
<button type="button" onClick={handleStopBookAudio}
|
||||
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={handlePlayReaderAudio}
|
||||
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500">
|
||||
Play
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verses — normal scroll mode */}
|
||||
{!readerDrawMode && !readerPageMode && (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
||||
<h2 className="mb-1 text-xl font-semibold text-slate-900">
|
||||
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">(BSB)</span>
|
||||
</h2>
|
||||
{readerInterlinear && (
|
||||
<p className="mb-4 text-xs text-slate-400">Click a verse number to see original words & pronunciation · bookmark icon to save · copy icon to copy</p>
|
||||
)}
|
||||
{!readerInterlinear && (
|
||||
<p className="mb-4 text-xs text-slate-400">Hover a verse for actions</p>
|
||||
)}
|
||||
{readerLoading && <p className="text-sm text-slate-500">Loading…</p>}
|
||||
{readerError && <p className="text-sm text-rose-600">{readerError}</p>}
|
||||
{!readerLoading && !readerError && (() => {
|
||||
const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
|
||||
const filtered = searchLower
|
||||
? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower))
|
||||
: readerVerses;
|
||||
if (searchLower && filtered.length === 0) {
|
||||
return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
|
||||
}
|
||||
return (
|
||||
<div className={`leading-relaxed text-slate-800 ${readerWideLayout ? 'md:columns-2 md:gap-x-10 space-y-0' : 'space-y-3'}`}
|
||||
style={{ fontSize: `${readerFontSize}em` }}
|
||||
onPointerUp={handleSelectionEnd}>
|
||||
{filtered.map((verse) => {
|
||||
const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
|
||||
const verseWords = chapterInterlinear?.[String(verse.number)];
|
||||
const isOpen = readerSelectedVerse === verse.number;
|
||||
const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`;
|
||||
const bmColor = readerBookmarks[verseKey];
|
||||
const crossRefs = readerCrossRefs?.[verse.number];
|
||||
|
||||
return (
|
||||
<div key={verse.number} id={`reader-verse-${verse.number}`} data-verse-num={verse.number}
|
||||
className="group break-inside-avoid rounded-xl transition mb-3"
|
||||
style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: '0.5rem' } : {}}>
|
||||
<div className="flex items-start gap-1">
|
||||
<button type="button"
|
||||
onClick={() => setReaderSelectedVerse(isOpen ? null : verse.number)}
|
||||
className={`mt-0.5 shrink-0 rounded px-1 text-xs font-bold transition ${
|
||||
verseWords
|
||||
? isOpen ? 'bg-sky-600 text-white' : 'text-sky-600 hover:bg-sky-50'
|
||||
: 'cursor-default text-slate-400'
|
||||
}`}
|
||||
title={verseWords ? 'Show original words' : undefined}>
|
||||
{verse.number}
|
||||
</button>
|
||||
<p className="flex-1">{renderVerseText(verse.number, verse.text, searchLower)}</p>
|
||||
<span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1">
|
||||
<button type="button"
|
||||
onClick={() => toggleReaderBookmark(verseKey)}
|
||||
className={`rounded p-1 leading-none hover:bg-slate-100 ${bmColor ? 'text-amber-600' : 'text-slate-400'}`}
|
||||
title={bmColor ? 'Remove bookmark' : 'Bookmark this verse'}>
|
||||
<BookmarkIcon filled={!!bmColor} />
|
||||
</button>
|
||||
{bmColor && (
|
||||
<button type="button"
|
||||
onClick={() => cycleBookmarkColor(verseKey)}
|
||||
className="rounded p-1 text-sm leading-none hover:bg-slate-100"
|
||||
title="Change highlight colour">
|
||||
🎨
|
||||
</button>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => copyVerse(readerBook?.name, readerChapter, verse.number, verse.text)}
|
||||
className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||||
title="Copy verse">
|
||||
<CopyIcon />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{readerShowCrossRefs && crossRefs && (
|
||||
<div className="mt-1 ml-6 flex flex-wrap gap-1">
|
||||
{crossRefs.slice(0, 8).map((ref, i) => {
|
||||
const label = formatCrossRef(ref);
|
||||
return (
|
||||
<span key={i} className="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800 cursor-default" title={`Score: ${ref.score ?? '?'}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && verseWords && (
|
||||
<div className="mt-2 mb-1 ml-6 rounded-2xl border border-sky-100 bg-sky-50 p-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{verseWords.map((w, i) => {
|
||||
const isHebrew = w.s?.startsWith('H');
|
||||
const canSpeak = isHebrew || w.s?.startsWith('G');
|
||||
return (
|
||||
<div key={i} className="rounded-xl border border-sky-200 bg-white p-2 text-center shadow-sm"
|
||||
style={{ minWidth: '4.5rem', maxWidth: '9rem' }}>
|
||||
<div className={`text-lg font-medium leading-tight ${isHebrew ? 'font-serif' : ''}`} dir={isHebrew ? 'rtl' : 'ltr'}>
|
||||
{w.o}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500 italic">{w.t}</div>
|
||||
<div className="mt-1 text-xs font-semibold text-slate-800">{w.g}</div>
|
||||
{w.p && <div className="mt-0.5 text-[10px] text-slate-400 leading-tight">{w.p}</div>}
|
||||
{w.s && <div className="mt-0.5 text-[10px] text-slate-400">{w.s}</div>}
|
||||
{canSpeak && (
|
||||
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
|
||||
className="mt-1.5 rounded-lg bg-sky-100 px-2 py-0.5 text-[11px] font-medium text-sky-700 hover:bg-sky-200 transition"
|
||||
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>
|
||||
🔊 Speak
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verses — page-flip mode */}
|
||||
{!readerDrawMode && readerPageMode && (
|
||||
<div ref={flipContainerRef}
|
||||
className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel"
|
||||
style={{ height: toolbarVisible ? 'calc(100dvh - 380px)' : 'calc(100dvh - 120px)' }}>
|
||||
{readerLoading && <p className="p-6 text-sm text-slate-500">Loading…</p>}
|
||||
{readerError && <p className="p-6 text-sm text-rose-600">{readerError}</p>}
|
||||
{!readerLoading && !readerError && (() => {
|
||||
const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
|
||||
const filtered = searchLower
|
||||
? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower))
|
||||
: readerVerses;
|
||||
if (searchLower && filtered.length === 0) {
|
||||
return <p className="p-6 text-sm text-slate-500">No verses match "{readerSearch}".</p>;
|
||||
}
|
||||
return (
|
||||
<div ref={flipContentRef}
|
||||
className="h-full leading-relaxed text-slate-800"
|
||||
style={{
|
||||
fontSize: `${readerFontSize}em`,
|
||||
columnWidth: flipPageWidth ? `${flipPageWidth}px` : '100%',
|
||||
columnGap: 0,
|
||||
paddingBottom: '3.5rem',
|
||||
transform: flipPageWidth ? `translateX(${-flipPage * flipPageWidth}px)` : undefined,
|
||||
transition: 'transform 0.3s ease',
|
||||
}}
|
||||
onPointerUp={handleSelectionEnd}>
|
||||
<div className="px-6 pt-6 pb-3 break-inside-avoid">
|
||||
<h2 className="mb-1 text-xl font-semibold text-slate-900">
|
||||
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">(BSB)</span>
|
||||
</h2>
|
||||
{readerInterlinear
|
||||
? <p className="text-xs text-slate-400">Click a verse number to see original words & pronunciation</p>
|
||||
: <p className="text-xs text-slate-400">Hover a verse for actions</p>
|
||||
}
|
||||
</div>
|
||||
{filtered.map((verse) => {
|
||||
const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
|
||||
const verseWords = chapterInterlinear?.[String(verse.number)];
|
||||
const isOpen = readerSelectedVerse === verse.number;
|
||||
const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`;
|
||||
const bmColor = readerBookmarks[verseKey];
|
||||
const crossRefs = readerCrossRefs?.[verse.number];
|
||||
|
||||
return (
|
||||
<div key={verse.number} id={`reader-verse-${verse.number}`} data-verse-num={verse.number}
|
||||
className="group break-inside-avoid rounded-xl transition mb-3 px-6"
|
||||
style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: 'calc(1.5rem + 0.5rem)' } : {}}>
|
||||
<div className="flex items-start gap-1">
|
||||
<button type="button"
|
||||
onClick={() => setReaderSelectedVerse(isOpen ? null : verse.number)}
|
||||
className={`mt-0.5 shrink-0 rounded px-1 text-xs font-bold transition ${
|
||||
verseWords
|
||||
? isOpen ? 'bg-sky-600 text-white' : 'text-sky-600 hover:bg-sky-50'
|
||||
: 'cursor-default text-slate-400'
|
||||
}`}
|
||||
title={verseWords ? 'Show original words' : undefined}>
|
||||
{verse.number}
|
||||
</button>
|
||||
<p className="flex-1">{renderVerseText(verse.number, verse.text, searchLower)}</p>
|
||||
<span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1">
|
||||
<button type="button"
|
||||
onClick={() => toggleReaderBookmark(verseKey)}
|
||||
className={`rounded p-1 leading-none hover:bg-slate-100 ${bmColor ? 'text-amber-600' : 'text-slate-400'}`}
|
||||
title={bmColor ? 'Remove bookmark' : 'Bookmark this verse'}>
|
||||
<BookmarkIcon filled={!!bmColor} />
|
||||
</button>
|
||||
{bmColor && (
|
||||
<button type="button"
|
||||
onClick={() => cycleBookmarkColor(verseKey)}
|
||||
className="rounded p-1 text-sm leading-none hover:bg-slate-100"
|
||||
title="Change highlight colour">
|
||||
🎨
|
||||
</button>
|
||||
)}
|
||||
<button type="button"
|
||||
onClick={() => copyVerse(readerBook?.name, readerChapter, verse.number, verse.text)}
|
||||
className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||||
title="Copy verse">
|
||||
<CopyIcon />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{readerShowCrossRefs && crossRefs && (
|
||||
<div className="mt-1 ml-6 flex flex-wrap gap-1">
|
||||
{crossRefs.slice(0, 8).map((ref, i) => {
|
||||
const label = formatCrossRef(ref);
|
||||
return (
|
||||
<span key={i} className="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800 cursor-default" title={`Score: ${ref.score ?? '?'}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && verseWords && (
|
||||
<div className="mt-2 mb-1 ml-6 rounded-2xl border border-sky-100 bg-sky-50 p-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{verseWords.map((w, i) => {
|
||||
const isHebrew = w.s?.startsWith('H');
|
||||
const canSpeak = isHebrew || w.s?.startsWith('G');
|
||||
return (
|
||||
<div key={i} className="rounded-xl border border-sky-200 bg-white p-2 text-center shadow-sm"
|
||||
style={{ minWidth: '4.5rem', maxWidth: '9rem' }}>
|
||||
<div className={`text-lg font-medium leading-tight ${isHebrew ? 'font-serif' : ''}`} dir={isHebrew ? 'rtl' : 'ltr'}>
|
||||
{w.o}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500 italic">{w.t}</div>
|
||||
<div className="mt-1 text-xs font-semibold text-slate-800">{w.g}</div>
|
||||
{w.p && <div className="mt-0.5 text-[10px] text-slate-400 leading-tight">{w.p}</div>}
|
||||
{w.s && <div className="mt-0.5 text-[10px] text-slate-400">{w.s}</div>}
|
||||
{canSpeak && (
|
||||
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
|
||||
className="mt-1.5 rounded-lg bg-sky-100 px-2 py-0.5 text-[11px] font-medium text-sky-700 hover:bg-sky-200 transition"
|
||||
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>
|
||||
🔊 Speak
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Page navigation bar */}
|
||||
<div className="absolute bottom-0 inset-x-0 flex items-center justify-between border-t border-slate-100 bg-white/95 px-6 py-2.5 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={() => flipPage > 0 ? setFlipPage((p) => p - 1) : readerGoToPreviousChapter()}
|
||||
disabled={flipPage === 0 && readerChapter <= 1}
|
||||
className="rounded-xl border border-slate-200 bg-white px-4 py-1.5 text-sm font-semibold text-slate-600 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
{flipPage === 0 ? '← Prev chapter' : '← Back'}
|
||||
</button>
|
||||
<span className="text-xs text-slate-400">
|
||||
{readerBook?.name} {readerChapter} · {flipPage + 1} / {flipTotalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => flipPage < flipTotalPages - 1 ? setFlipPage((p) => p + 1) : readerGoToNextChapter()}
|
||||
disabled={flipPage === flipTotalPages - 1 && readerChapter >= readerTotalChapters}
|
||||
className="rounded-xl border border-slate-200 bg-white px-4 py-1.5 text-sm font-semibold text-slate-600 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
{flipPage === flipTotalPages - 1 ? 'Next chapter →' : 'Next →'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Draw mode — Bible text on left, notebook canvas on right */}
|
||||
{readerDrawMode && (
|
||||
<div className="flex flex-col items-start gap-4 sm:flex-row">
|
||||
{/* Scripture panel — sticky so it stays in view while notebook scrolls */}
|
||||
<div className="w-full rounded-3xl border border-slate-200 bg-slate-50 p-5 font-serif text-sm leading-relaxed text-slate-800 sm:w-72 sm:shrink-0 sm:sticky sm:top-6 sm:max-h-[80vh] sm:overflow-y-auto"
|
||||
onPointerUp={handleSelectionEnd}>
|
||||
<div className="mb-3">
|
||||
<span className="font-semibold text-slate-900">{readerBook?.name} {readerChapter}</span>
|
||||
<span className="ml-2 text-xs text-slate-400">BSB</span>
|
||||
<p className="mt-1 text-xs text-slate-400 font-sans">Select text to highlight</p>
|
||||
</div>
|
||||
{!readerLoading && readerVerses.map((v) => (
|
||||
<p key={v.number} className="mb-2" data-verse-num={v.number}>
|
||||
<span className="font-semibold text-slate-700">{v.number}.</span>{' '}
|
||||
{renderVerseText(v.number, v.text, '')}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{/* Notebook canvas */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<DrawCanvas
|
||||
strokes={readerPageInkStrokes}
|
||||
onStrokesChange={(s) => updateReaderPageInk(readerBookAbbrev, readerChapter, s)}
|
||||
onDone={() => setReaderDrawMode(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Wide focus mode — fixed top bar to restore toolbar */}
|
||||
{readerWideLayout && !toolbarVisible && (
|
||||
<div className="fixed top-0 inset-x-0 z-30 flex items-center justify-between gap-4 border-b border-slate-200/60 bg-white/80 px-4 py-2 backdrop-blur-sm">
|
||||
<span className="text-sm font-semibold text-slate-700">
|
||||
{readerBook?.name} {readerChapter}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={readerGoToPreviousChapter} disabled={readerChapter <= 1}
|
||||
className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40">
|
||||
‹ Prev
|
||||
</button>
|
||||
<button type="button" onClick={readerGoToNextChapter} disabled={readerChapter >= readerTotalChapters}
|
||||
className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40">
|
||||
Next ›
|
||||
</button>
|
||||
<button type="button" onClick={() => setToolbarVisible(true)}
|
||||
className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
|
||||
⚙ Tools
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Side chapter nav — fixed left/right arrows */}
|
||||
{!readerDrawMode && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={readerGoToPreviousChapter}
|
||||
disabled={readerChapter <= 1}
|
||||
aria-label="Previous chapter"
|
||||
className="fixed left-3 top-1/2 -translate-y-1/2 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-500 shadow-md backdrop-blur-sm transition hover:bg-white hover:text-slate-900 disabled:opacity-0 disabled:pointer-events-none"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={readerGoToNextChapter}
|
||||
disabled={readerChapter >= readerTotalChapters}
|
||||
aria-label="Next chapter"
|
||||
className="fixed right-3 top-1/2 -translate-y-1/2 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-500 shadow-md backdrop-blur-sm transition hover:bg-white hover:text-slate-900 disabled:opacity-0 disabled:pointer-events-none"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Highlight color picker — appears above text selection */}
|
||||
{selectionPicker && (
|
||||
<div
|
||||
className="fixed z-50 flex items-center gap-1.5 rounded-2xl bg-white px-2.5 py-2 shadow-2xl border border-slate-200"
|
||||
style={{
|
||||
left: Math.max(8, Math.min(selectionPicker.x - 105, window.innerWidth - 220)),
|
||||
top: selectionPicker.y > 80 ? selectionPicker.y - 56 : selectionPicker.y + 28,
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa'].map(color => (
|
||||
<button
|
||||
key={color}
|
||||
className="h-7 w-7 rounded-full border-2 border-white shadow-sm hover:scale-110 transition-transform active:scale-95"
|
||||
style={{ backgroundColor: color }}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
addTextHighlight({
|
||||
book: readerBookAbbrev,
|
||||
chapter: readerChapter,
|
||||
verse: selectionPicker.verse,
|
||||
startOffset: selectionPicker.startOffset,
|
||||
endOffset: selectionPicker.endOffset,
|
||||
color,
|
||||
});
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setSelectionPicker(null);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
className="ml-1 h-7 w-7 rounded-full bg-slate-100 text-slate-500 text-xs flex items-center justify-center hover:bg-slate-200 transition"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setSelectionPicker(null);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import {
|
||||
startMfaSetup,
|
||||
confirmMfaSetup,
|
||||
disableMfa,
|
||||
changePassword,
|
||||
updateProfile,
|
||||
} from '../syncService.js';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const {
|
||||
authUser, setAuthUser,
|
||||
authStatus,
|
||||
goHome,
|
||||
} = useApp();
|
||||
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState('');
|
||||
const [mfaSetupError, setMfaSetupError] = useState('');
|
||||
const [mfaSetupBusy, setMfaSetupBusy] = useState(false);
|
||||
const [mfaBackupCodes, setMfaBackupCodes] = useState(null); // shown once, right after enabling
|
||||
const [mfaDisablePassword, setMfaDisablePassword] = useState('');
|
||||
const [mfaDisableError, setMfaDisableError] = useState('');
|
||||
const [podcastNameInput, setPodcastNameInput] = useState(authUser?.podcastName ?? '');
|
||||
const [podcastNameSaving, setPodcastNameSaving] = useState(false);
|
||||
const [podcastNameSaved, setPodcastNameSaved] = useState(false);
|
||||
const [changePasswordForm, setChangePasswordForm] = useState({ current: '', next: '', confirm: '' });
|
||||
const [changePasswordBusy, setChangePasswordBusy] = useState(false);
|
||||
const [changePasswordError, setChangePasswordError] = useState('');
|
||||
const [changePasswordSaved, setChangePasswordSaved] = useState(false);
|
||||
|
||||
const startSetup = async () => {
|
||||
setMfaSetupError('');
|
||||
setMfaSetupBusy(true);
|
||||
const result = await startMfaSetup();
|
||||
setMfaSetupBusy(false);
|
||||
if (!result.ok) {
|
||||
setMfaSetupError(result.error ?? 'Could not start 2FA setup.');
|
||||
return;
|
||||
}
|
||||
setMfaSetup(result.data);
|
||||
setMfaSetupCode('');
|
||||
};
|
||||
|
||||
const confirmSetup = async (e) => {
|
||||
e.preventDefault();
|
||||
setMfaSetupError('');
|
||||
setMfaSetupBusy(true);
|
||||
const result = await confirmMfaSetup(mfaSetupCode.trim());
|
||||
setMfaSetupBusy(false);
|
||||
if (!result.ok) {
|
||||
setMfaSetupError(result.error ?? 'Invalid code.');
|
||||
return;
|
||||
}
|
||||
setMfaSetup(null);
|
||||
setMfaSetupCode('');
|
||||
setMfaBackupCodes(result.data.backupCodes);
|
||||
setAuthUser((u) => ({ ...u, totpEnabled: true }));
|
||||
};
|
||||
|
||||
const cancelSetup = () => {
|
||||
setMfaSetup(null);
|
||||
setMfaSetupCode('');
|
||||
setMfaSetupError('');
|
||||
};
|
||||
|
||||
const submitDisable = async (e) => {
|
||||
e.preventDefault();
|
||||
setMfaDisableError('');
|
||||
const result = await disableMfa(mfaDisablePassword);
|
||||
if (!result.ok) {
|
||||
setMfaDisableError(result.error ?? 'Incorrect password.');
|
||||
return;
|
||||
}
|
||||
setMfaDisablePassword('');
|
||||
setAuthUser((u) => ({ ...u, totpEnabled: false }));
|
||||
};
|
||||
|
||||
const submitChangePassword = async (e) => {
|
||||
e.preventDefault();
|
||||
setChangePasswordError('');
|
||||
if (changePasswordForm.next !== changePasswordForm.confirm) {
|
||||
setChangePasswordError("New passwords don't match.");
|
||||
return;
|
||||
}
|
||||
setChangePasswordBusy(true);
|
||||
const result = await changePassword(changePasswordForm.current, changePasswordForm.next);
|
||||
setChangePasswordBusy(false);
|
||||
if (!result.ok) {
|
||||
setChangePasswordError(result.error ?? 'Failed to change password.');
|
||||
return;
|
||||
}
|
||||
setChangePasswordForm({ current: '', next: '', confirm: '' });
|
||||
setChangePasswordSaved(true);
|
||||
window.setTimeout(() => setChangePasswordSaved(false), 3000);
|
||||
};
|
||||
|
||||
const submitPodcastName = async (e) => {
|
||||
e.preventDefault();
|
||||
setPodcastNameSaving(true);
|
||||
setPodcastNameSaved(false);
|
||||
const result = await updateProfile({ podcastName: podcastNameInput.trim() });
|
||||
setPodcastNameSaving(false);
|
||||
if (!result.ok) return;
|
||||
setAuthUser((u) => ({ ...u, podcastName: result.data.podcastName }));
|
||||
setPodcastNameSaved(true);
|
||||
window.setTimeout(() => setPodcastNameSaved(false), 2500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold">Account Settings</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goHome}
|
||||
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
{authStatus}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Account</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Signed in as <span className="font-medium text-slate-700">{authUser.email}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Change password</h2>
|
||||
<p className="text-sm text-slate-500">Changing your password signs you out of every other device — this one stays signed in.</p>
|
||||
</div>
|
||||
<form onSubmit={submitChangePassword} className="space-y-3">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Current password
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={changePasswordForm.current}
|
||||
onChange={(e) => setChangePasswordForm((f) => ({ ...f, current: e.target.value }))}
|
||||
className="mt-1 block w-full max-w-xs rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
New password
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
value={changePasswordForm.next}
|
||||
onChange={(e) => setChangePasswordForm((f) => ({ ...f, next: e.target.value }))}
|
||||
className="mt-1 block w-full max-w-xs rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Confirm new password
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
value={changePasswordForm.confirm}
|
||||
onChange={(e) => setChangePasswordForm((f) => ({ ...f, confirm: e.target.value }))}
|
||||
className="mt-1 block w-full max-w-xs rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
{changePasswordError && <p className="text-sm text-rose-600">{changePasswordError}</p>}
|
||||
{changePasswordSaved && <p className="text-sm text-emerald-600">Password changed.</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={changePasswordBusy}
|
||||
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{changePasswordBusy ? 'Saving…' : 'Change password'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Podcast / show name</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Only needed if you use "🎙 Prepare for Podcast" on the study page — it fills in your show's
|
||||
name when asking Claude to write an episode script. Leave blank if you're just doing personal
|
||||
study; that button still works, it just won't name a specific show.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={submitPodcastName} className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={podcastNameInput}
|
||||
onChange={(e) => setPodcastNameInput(e.target.value)}
|
||||
placeholder="e.g. Verse by Verse with Nate: A Journey Through Scripture"
|
||||
className="w-full flex-1 rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={podcastNameSaving}
|
||||
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{podcastNameSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</form>
|
||||
{podcastNameSaved && <p className="text-sm text-emerald-600">Saved.</p>}
|
||||
</section>
|
||||
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Two-factor authentication</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{authUser.totpEnabled
|
||||
? "Enabled — you'll need a code from your authenticator app to sign in."
|
||||
: 'Add a 6-digit code from an authenticator app (Google Authenticator, Authy, 1Password, etc.) as a second step at sign-in.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mfaBackupCodes ? (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 space-y-3">
|
||||
<p className="text-sm font-semibold text-amber-800">
|
||||
Save these backup codes now — each works once if you ever lose your device. They won't be shown again.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 font-mono text-sm text-slate-800">
|
||||
{mfaBackupCodes.map((code) => (
|
||||
<div key={code} className="rounded-lg border border-amber-200 bg-white px-3 py-1.5">{code}</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMfaBackupCodes(null)}
|
||||
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-amber-400"
|
||||
>
|
||||
I've saved these codes
|
||||
</button>
|
||||
</div>
|
||||
) : authUser.totpEnabled ? (
|
||||
<form onSubmit={submitDisable} className="space-y-3">
|
||||
<label className="block max-w-xs text-sm font-medium text-slate-700">
|
||||
Enter your password to disable 2FA
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={mfaDisablePassword}
|
||||
onChange={(e) => setMfaDisablePassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
{mfaDisableError && <p className="text-sm text-rose-600">{mfaDisableError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-rose-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-rose-400"
|
||||
>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</form>
|
||||
) : mfaSetup ? (
|
||||
<form onSubmit={confirmSetup} className="space-y-4">
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
|
||||
<img src={mfaSetup.qrCodeDataUrl} alt="2FA QR code" className="h-40 w-40 rounded-xl border border-slate-200" />
|
||||
<div className="space-y-1 text-sm text-slate-600">
|
||||
<p>Scan this with your authenticator app, or enter the code manually:</p>
|
||||
<p className="break-all rounded-lg bg-slate-100 px-2 py-1 font-mono text-xs">{mfaSetup.secret}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="block max-w-xs text-sm font-medium text-slate-700">
|
||||
Enter the 6-digit code it shows
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
inputMode="numeric"
|
||||
placeholder="123456"
|
||||
value={mfaSetupCode}
|
||||
onChange={(e) => setMfaSetupCode(e.target.value)}
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-center text-lg tracking-widest shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
{mfaSetupError && <p className="text-sm text-rose-600">{mfaSetupError}</p>}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mfaSetupBusy}
|
||||
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{mfaSetupBusy ? 'Verifying…' : 'Confirm & enable'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelSetup}
|
||||
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={startSetup}
|
||||
disabled={mfaSetupBusy}
|
||||
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{mfaSetupBusy ? 'Starting…' : 'Enable 2FA'}
|
||||
</button>
|
||||
{mfaSetupError && <p className="text-sm text-rose-600">{mfaSetupError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { bookOptions, formatChunkReference } from '../utils/bibleUtils.jsx';
|
||||
|
||||
function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter, errorMessage, hideTitle, onField, onTitleChange, onLoad }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!hideTitle && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Project Setup</h2>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Pick a translation, chapter, and title. Load the chapter to begin structuring your study into chunks.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Translation
|
||||
<select
|
||||
value={setup.translation}
|
||||
onChange={(e) => onField('translation', e.target.value)}
|
||||
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
{availableTranslations.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Book
|
||||
<select
|
||||
value={setup.bookAbbrev}
|
||||
onChange={(e) => onField('book', e.target.value)}
|
||||
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
{bookOptions.map((book) => (
|
||||
<option key={book.abbrev} value={book.abbrev}>{book.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Chapter
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={setup.chapter}
|
||||
onChange={(e) => onField('chapter', e.target.value)}
|
||||
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
{!hideTitle && (
|
||||
<label className="block text-sm font-medium text-slate-700 sm:col-span-2">
|
||||
Project title
|
||||
<input
|
||||
type="text"
|
||||
value={setup.title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
className="mt-2 block w-full rounded-xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-slate-600">
|
||||
{errorMessage
|
||||
? <span className="text-rose-500">{errorMessage}</span>
|
||||
: 'Start by loading the chapter text from HelloAO.'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoad}
|
||||
disabled={loadingChapter}
|
||||
className="inline-flex items-center justify-center rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-500"
|
||||
>
|
||||
{loadingChapter ? 'Loading...' : 'Load Chapter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SetupPage() {
|
||||
const {
|
||||
headerButtons,
|
||||
project,
|
||||
setup,
|
||||
availableTranslations,
|
||||
titleEdited, setTitleEdited,
|
||||
activeChapterIndex, setActiveChapterIndex,
|
||||
activeChapter,
|
||||
showAddChapterForm, setShowAddChapterForm,
|
||||
rangeStart, setRangeStart,
|
||||
rangeEnd, setRangeEnd,
|
||||
verseSearch, setVerseSearch,
|
||||
typedChunkStart, setTypedChunkStart,
|
||||
typedChunkEnd, setTypedChunkEnd,
|
||||
typedChunkNextEnd, setTypedChunkNextEnd,
|
||||
typedChunkBulk, setTypedChunkBulk,
|
||||
clickedSpanNextEnd, setClickedSpanNextEnd,
|
||||
loadingChapter,
|
||||
errorMessage,
|
||||
statusMessage,
|
||||
allChunks,
|
||||
handleSetupField,
|
||||
handleLoadChapter,
|
||||
beginStudying,
|
||||
handleVerseClick,
|
||||
addTypedChunk,
|
||||
addBulkTypedChunks,
|
||||
addClickSpanChunk,
|
||||
moveChunk,
|
||||
deleteChunk,
|
||||
updateProject,
|
||||
} = useApp();
|
||||
|
||||
const chapterTabs = project?.chapters ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold">
|
||||
{project ? project.title : 'New Study'}
|
||||
</h1>
|
||||
</div>
|
||||
{headerButtons}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-7xl px-4 py-4 sm:py-8 sm:px-6 lg:px-8 space-y-8">
|
||||
{/* Project setup form — shown when no project loaded yet */}
|
||||
{!project && (
|
||||
<section className="mx-auto max-w-3xl rounded-3xl border border-slate-200 bg-white p-8 shadow-panel">
|
||||
<SetupForm
|
||||
setup={setup}
|
||||
availableTranslations={availableTranslations}
|
||||
titleEdited={titleEdited}
|
||||
loadingChapter={loadingChapter}
|
||||
errorMessage={errorMessage}
|
||||
onField={handleSetupField}
|
||||
onTitleChange={(val) => { setTitleEdited(true); handleSetupField('title', val); }}
|
||||
onLoad={handleLoadChapter}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Chapter tabs + verse/chunk editors */}
|
||||
{project && (
|
||||
<section className="space-y-6">
|
||||
{/* Chapter tabs */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{chapterTabs.map((ch, idx) => (
|
||||
<button
|
||||
key={`${ch.bookAbbrev}-${ch.chapter}`}
|
||||
type="button"
|
||||
onClick={() => { setActiveChapterIndex(idx); setRangeStart(null); setRangeEnd(null); }}
|
||||
className={`rounded-full px-4 py-1.5 text-sm font-medium transition ${
|
||||
activeChapterIndex === idx
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{ch.book} {ch.chapter}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddChapterForm((v) => !v)}
|
||||
className="rounded-full border border-dashed border-slate-400 px-4 py-1.5 text-sm text-slate-500 transition hover:border-slate-600 hover:text-slate-700"
|
||||
>
|
||||
+ Add Chapter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add-chapter form */}
|
||||
{showAddChapterForm && (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
||||
<h3 className="mb-4 text-sm font-semibold text-slate-900">Add another chapter</h3>
|
||||
<SetupForm
|
||||
setup={setup}
|
||||
availableTranslations={availableTranslations}
|
||||
titleEdited={titleEdited}
|
||||
loadingChapter={loadingChapter}
|
||||
errorMessage={errorMessage}
|
||||
hideTitle
|
||||
onField={handleSetupField}
|
||||
onTitleChange={(val) => { setTitleEdited(true); handleSetupField('title', val); }}
|
||||
onLoad={handleLoadChapter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verse + chunk panel for active chapter */}
|
||||
{activeChapter && (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500">Scripture & Chunks</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-slate-900">
|
||||
{activeChapter.book} {activeChapter.chapter} ({project.translation})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-700">
|
||||
{statusMessage || 'Click/shift-click, or type a verse range, to create a chunk.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid min-w-0 gap-6 lg:grid-cols-[1.25fr_0.75fr]">
|
||||
<div className="min-w-0 rounded-3xl border border-slate-200 bg-slate-50 p-4 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium text-slate-600">Chapter verses</span>
|
||||
<span className="text-xs text-slate-500">Click a verse, then shift-click an end verse.</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={verseSearch}
|
||||
onChange={(e) => setVerseSearch(e.target.value)}
|
||||
placeholder="Search verses in this chapter…"
|
||||
className="mb-3 block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
<div data-testid="verse-list" className="max-h-[520px] overflow-y-auto rounded-3xl border border-slate-200 bg-white p-4 scrollbar-thin">
|
||||
{activeChapter.verses
|
||||
.filter((verse) => verse.text.toLowerCase().includes(verseSearch.trim().toLowerCase()))
|
||||
.map((verse) => {
|
||||
const inRange =
|
||||
rangeStart !== null &&
|
||||
verse.number >= Math.min(rangeStart, rangeEnd) &&
|
||||
verse.number <= Math.max(rangeStart, rangeEnd);
|
||||
const inOwnChapterChunk = activeChapter.chunks.some(
|
||||
(chunk) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse,
|
||||
);
|
||||
const prevChapter = project?.chapters?.[activeChapterIndex - 1] ?? null;
|
||||
const inPrevChapterSpillover = prevChapter
|
||||
? prevChapter.chunks.some((chunk) =>
|
||||
Number.isInteger(chunk.spilloverEndVerse)
|
||||
&& prevChapter.bookAbbrev === activeChapter.bookAbbrev
|
||||
&& verse.number <= chunk.spilloverEndVerse
|
||||
)
|
||||
: false;
|
||||
const inChunk = inOwnChapterChunk || inPrevChapterSpillover;
|
||||
return (
|
||||
<button
|
||||
key={verse.number}
|
||||
type="button"
|
||||
onClick={(event) => handleVerseClick(verse.number, event)}
|
||||
className={`group mb-2 w-full rounded-3xl px-4 py-3 text-left transition ${
|
||||
inRange ? 'bg-sky-100 ring-1 ring-sky-200' : inChunk ? 'bg-slate-100' : 'bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-200 text-sm font-semibold text-slate-700 transition group-hover:bg-slate-300">
|
||||
{verse.number}
|
||||
</span>
|
||||
<span className="ml-3 text-sm leading-relaxed text-slate-700">{verse.text}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-4 rounded-3xl border border-slate-200 bg-slate-50 p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-slate-900">Chunks</h3>
|
||||
<span className="text-xs text-slate-500">{activeChapter.chunks.length} created</span>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Type chunk range</p>
|
||||
<div className="mt-2 flex flex-wrap items-end gap-2">
|
||||
<label className="min-w-0 flex-1 text-xs text-slate-500">
|
||||
Start
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={activeChapter.verses.at(-1)?.number ?? 1}
|
||||
value={typedChunkStart}
|
||||
onChange={(e) => setTypedChunkStart(e.target.value)}
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 flex-1 text-xs text-slate-500">
|
||||
End
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={activeChapter.verses.at(-1)?.number ?? 1}
|
||||
value={typedChunkEnd}
|
||||
onChange={(e) => setTypedChunkEnd(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addTypedChunk();
|
||||
}
|
||||
}}
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 flex-1 text-xs text-slate-500">
|
||||
Next ch end (optional)
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={project?.chapters?.[activeChapterIndex + 1]?.verses?.at(-1)?.number ?? 1}
|
||||
value={typedChunkNextEnd}
|
||||
onChange={(e) => setTypedChunkNextEnd(e.target.value)}
|
||||
placeholder="e.g. 5"
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTypedChunk}
|
||||
className="rounded-xl bg-slate-900 px-3 py-2 text-xs font-semibold text-white transition hover:bg-slate-800"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="text-xs text-slate-500">
|
||||
Bulk ranges (comma/new line; use `start-end:nextEnd` to span)
|
||||
<textarea
|
||||
rows={2}
|
||||
value={typedChunkBulk}
|
||||
onChange={(e) => setTypedChunkBulk(e.target.value)}
|
||||
placeholder="1-6, 7-31:5, 6"
|
||||
className="mt-1 block w-full resize-y rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addBulkTypedChunks}
|
||||
className="mt-2 rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
|
||||
>
|
||||
Add All Ranges
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Click-based chapter span</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Click a start verse in this chapter, then choose where to end in the next chapter.</p>
|
||||
<div className="mt-2 flex items-end gap-2">
|
||||
<div className="flex-1 text-xs text-slate-500">
|
||||
Start
|
||||
<div className="mt-1 rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-2 text-sm text-slate-900">
|
||||
{rangeStart ?? 'Not selected'}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex-1 text-xs text-slate-500">
|
||||
Next ch end
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={project?.chapters?.[activeChapterIndex + 1]?.verses?.at(-1)?.number ?? 1}
|
||||
value={clickedSpanNextEnd}
|
||||
onChange={(e) => setClickedSpanNextEnd(e.target.value)}
|
||||
placeholder="e.g. 5"
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addClickSpanChunk}
|
||||
className="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
|
||||
>
|
||||
Span Into Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 max-h-[520px] overflow-y-auto scrollbar-thin">
|
||||
{activeChapter.chunks.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-slate-300 bg-white p-4 text-sm text-slate-500">
|
||||
No chunks yet. Select verse ranges to add sections.
|
||||
</div>
|
||||
) : (
|
||||
activeChapter.chunks.map((chunk, index) => (
|
||||
<div
|
||||
key={chunk.id}
|
||||
className={`rounded-3xl border p-4 ${project.selectedChunkId === chunk.id ? 'border-sky-300 bg-sky-50' : 'border-slate-200 bg-white'} shadow-sm`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateProject((current) => ({ ...current, selectedChunkId: chunk.id }))}
|
||||
className="mb-3 w-full text-left"
|
||||
>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{formatChunkReference(project, activeChapterIndex, chunk, '–')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-600 truncate">
|
||||
{activeChapter.verses.find((v) => v.number === chunk.startVerse)?.text || ''}
|
||||
</p>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveChunk(chunk.id, -1)}
|
||||
disabled={index === 0}
|
||||
className="rounded-full border border-slate-300 bg-white px-2 py-1 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveChunk(chunk.id, 1)}
|
||||
disabled={index === activeChapter.chunks.length - 1}
|
||||
className="rounded-full border border-slate-300 bg-white px-2 py-1 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteChunk(chunk.id)}
|
||||
className="rounded-full border border-rose-300 bg-rose-50 px-2 py-1 text-rose-600 transition hover:bg-rose-100"
|
||||
>
|
||||
× Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={beginStudying}
|
||||
disabled={allChunks.length === 0}
|
||||
className="inline-flex items-center justify-center rounded-xl bg-slate-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-500"
|
||||
>
|
||||
Begin Studying →
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+118
-2
@@ -15,16 +15,17 @@ async function request(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
};
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(`${BASE}${path}`, opts);
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: data?.error ?? `HTTP ${res.status}` };
|
||||
return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}` };
|
||||
}
|
||||
return { ok: true, data };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message ?? 'Network error' };
|
||||
return { ok: false, status: null, error: err?.message ?? 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +65,30 @@ export async function deleteRemoteProject(id) {
|
||||
return request('DELETE', `/projects/${id}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only share links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns { shareToken } — the project's current share token, or null if sharing is off. */
|
||||
export async function getShareStatus(id) {
|
||||
return request('GET', `/projects/${id}/share`);
|
||||
}
|
||||
|
||||
/** Enables sharing (or returns the existing token if already enabled). Returns { shareToken }. */
|
||||
export async function enableSharing(id) {
|
||||
return request('POST', `/projects/${id}/share`);
|
||||
}
|
||||
|
||||
/** Revokes a project's share link. */
|
||||
export async function disableSharing(id) {
|
||||
return request('DELETE', `/projects/${id}/share`);
|
||||
}
|
||||
|
||||
/** Public lookup — no session required. Returns the full project for a valid share token. */
|
||||
export async function getSharedProject(token) {
|
||||
return request('GET', `/share/${token}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the server is reachable.
|
||||
* Returns true / false.
|
||||
@@ -76,3 +101,94 @@ export async function isServerReachable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the signed-in user, or null if not signed in.
|
||||
* Returns { ok: false } (with status: null) only on a genuine network failure,
|
||||
* so callers can distinguish "not logged in" from "server unreachable".
|
||||
*/
|
||||
export async function getCurrentUser() {
|
||||
const result = await request('GET', '/auth/me');
|
||||
if (result.ok) return { ok: true, user: result.data };
|
||||
if (result.status === 401) return { ok: true, user: null };
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function registerUser(email, password) {
|
||||
return request('POST', '/auth/register', { email, password });
|
||||
}
|
||||
|
||||
export async function loginUser(email, password) {
|
||||
return request('POST', '/auth/login', { email, password });
|
||||
}
|
||||
|
||||
export async function logoutUser() {
|
||||
return request('POST', '/auth/logout');
|
||||
}
|
||||
|
||||
/** Submits the code from the auth gate's post-password MFA step. Pass token or backupCode. */
|
||||
export async function verifyMfaLogin({ token, backupCode }) {
|
||||
return request('POST', '/auth/mfa/verify', { token, backupCode });
|
||||
}
|
||||
|
||||
/** Updates account-level profile settings (currently just the podcast/show name). */
|
||||
export async function updateProfile({ podcastName }) {
|
||||
return request('PATCH', '/auth/profile', { podcastName });
|
||||
}
|
||||
|
||||
/** Self-service password change. Requires the current password; signs out every other session. */
|
||||
export async function changePassword(currentPassword, newPassword) {
|
||||
return request('POST', '/auth/change-password', { currentPassword, newPassword });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Two-factor auth setup (Account Settings page)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Starts 2FA setup: returns { secret, qrCodeDataUrl } for the user to scan. */
|
||||
export async function startMfaSetup() {
|
||||
return request('POST', '/auth/mfa/setup');
|
||||
}
|
||||
|
||||
/** Confirms the scanned code and turns 2FA on. Returns { backupCodes } (shown once). */
|
||||
export async function confirmMfaSetup(token) {
|
||||
return request('POST', '/auth/mfa/enable', { token });
|
||||
}
|
||||
|
||||
/** Turns 2FA off. Requires the current password as a safety check. */
|
||||
export async function disableMfa(password) {
|
||||
return request('POST', '/auth/mfa/disable', { password });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin (restricted server-side to the designated admin account)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function adminListUsers() {
|
||||
return request('GET', '/admin/users');
|
||||
}
|
||||
|
||||
export async function adminDeleteUser(id) {
|
||||
return request('DELETE', `/admin/users/${id}`);
|
||||
}
|
||||
|
||||
/** Sets a random temporary password for a user and signs them out everywhere. Returns { temporaryPassword }. */
|
||||
export async function adminResetPassword(id) {
|
||||
return request('POST', `/admin/users/${id}/reset-password`);
|
||||
}
|
||||
|
||||
export async function adminListProjects() {
|
||||
return request('GET', '/admin/projects');
|
||||
}
|
||||
|
||||
export async function adminGetProject(id) {
|
||||
return request('GET', `/admin/projects/${id}`);
|
||||
}
|
||||
|
||||
export async function adminDeleteProject(id) {
|
||||
return request('DELETE', `/admin/projects/${id}`);
|
||||
}
|
||||
+44
-5
@@ -7,11 +7,10 @@ import {
|
||||
parseBibleChapter,
|
||||
wordTableHtml,
|
||||
buildExportHtml,
|
||||
buildClaudePrompt,
|
||||
createParagraphsFromText,
|
||||
migrateChunk,
|
||||
migrateProject,
|
||||
} from './App.jsx';
|
||||
} from './utils/bibleUtils.jsx';
|
||||
import { buildMarkdownExport, buildClaudePrompt } from './utils/exportBuilders.js';
|
||||
import { migrateChunk, migrateProject } from './utils/projectStorage.js';
|
||||
import { createParagraphsFromText } from './utils/docxExport.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// storageKey
|
||||
@@ -469,6 +468,46 @@ describe('buildExportHtml', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildMarkdownExport
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('buildMarkdownExport', () => {
|
||||
test('includes the project title and translation as Markdown headers', () => {
|
||||
const md = buildMarkdownExport(baseProject);
|
||||
expect(md).toContain('# Titus 1 Study');
|
||||
expect(md).toContain('*BSB*');
|
||||
});
|
||||
|
||||
test('includes a chapter heading and chunk reference', () => {
|
||||
const md = buildMarkdownExport(baseProject);
|
||||
expect(md).toContain('## Titus 1');
|
||||
expect(md).toContain('### Titus 1:1-2');
|
||||
});
|
||||
|
||||
test('includes verse text and OIA notes', () => {
|
||||
const md = buildMarkdownExport(baseProject);
|
||||
expect(md).toContain('Paul, a servant of God.');
|
||||
expect(md).toContain('Key observations.');
|
||||
expect(md).toContain('Theological meaning.');
|
||||
expect(md).toContain('Live it out.');
|
||||
});
|
||||
|
||||
test('includes cross-references and Greek word data', () => {
|
||||
const md = buildMarkdownExport(baseProject);
|
||||
expect(md).toContain('John 1:1');
|
||||
expect(md).toContain('G1401');
|
||||
expect(md).toContain('δοῦλος');
|
||||
});
|
||||
|
||||
test('shows placeholder text when observation is empty', () => {
|
||||
const project = {
|
||||
...baseProject,
|
||||
chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, observation: '', interpretation: '', application: '' }] }],
|
||||
};
|
||||
expect(buildMarkdownExport(project)).toContain('_No observation._');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createParagraphsFromText
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// IndexedDB cache for the whole-Bible search index.
|
||||
const BIBLE_IDB_NAME = 'study-app-bible-index';
|
||||
const BIBLE_IDB_STORE = 'bible-index';
|
||||
export const BIBLE_IDB_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
function openBibleIndexDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(BIBLE_IDB_NAME, 1);
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains(BIBLE_IDB_STORE)) {
|
||||
db.createObjectStore(BIBLE_IDB_STORE, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
req.onsuccess = (e) => resolve(e.target.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBibleIndexFromIDB(translation) {
|
||||
try {
|
||||
const db = await openBibleIndexDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(BIBLE_IDB_STORE, 'readonly');
|
||||
const req = tx.objectStore(BIBLE_IDB_STORE).get(translation);
|
||||
req.onsuccess = () => resolve(req.result ?? null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBibleIndexToIDB(translation, flat) {
|
||||
try {
|
||||
const db = await openBibleIndexDB();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(BIBLE_IDB_STORE, 'readwrite');
|
||||
const req = tx.objectStore(BIBLE_IDB_STORE).put({ id: translation, flat, cachedAt: Date.now() });
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal; in-memory cache still works for this session
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Study constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const COMMENTARY_OPTIONS = [
|
||||
{ id: 'matthew-henry', name: 'Matthew Henry' },
|
||||
{ id: 'john-gill', name: 'John Gill' },
|
||||
{ id: 'jamieson-fausset-brown', name: 'Jamieson-Fausset-Brown' },
|
||||
{ id: 'adam-clarke', name: 'Adam Clarke' },
|
||||
{ id: 'keil-delitzsch', name: 'Keil & Delitzsch (OT)' },
|
||||
{ id: 'tyndale', name: 'Tyndale Open Study Notes' },
|
||||
];
|
||||
|
||||
export const STUDY_TABS = [
|
||||
{ id: 'notes', label: 'Notes' },
|
||||
{ id: 'crossRefs', label: 'Cross-Refs' },
|
||||
{ id: 'wordStudy', label: 'Word Study' },
|
||||
{ id: 'commentary', label: 'Commentary' },
|
||||
{ id: 'script', label: 'Script' },
|
||||
];
|
||||
|
||||
export const CHAPTER_COUNTS = {
|
||||
GEN:50,EXO:40,LEV:27,NUM:36,DEU:34,JOS:24,JDG:21,RUT:4,
|
||||
'1SA':31,'2SA':24,'1KI':22,'2KI':25,'1CH':29,'2CH':36,
|
||||
EZR:10,NEH:13,EST:10,JOB:42,PSA:150,PRO:31,ECC:12,SNG:8,
|
||||
ISA:66,JER:52,LAM:5,EZK:48,DAN:12,HOS:14,JOL:3,AMO:9,
|
||||
OBA:1,JON:4,MIC:7,NAM:3,HAB:3,ZEP:3,HAG:2,ZEC:14,MAL:4,
|
||||
MAT:28,MRK:16,LUK:24,JHN:21,ACT:28,ROM:16,'1CO':16,'2CO':13,
|
||||
GAL:6,EPH:6,PHP:4,COL:4,'1TH':5,'2TH':3,'1TI':6,'2TI':4,
|
||||
TIT:3,PHM:1,HEB:13,JAS:5,'1PE':5,'2PE':3,'1JN':5,'2JN':1,
|
||||
'3JN':1,JUD:1,REV:22,
|
||||
};
|
||||
|
||||
export const bookOptions = [
|
||||
{ name: 'Genesis', abbrev: 'GEN' },
|
||||
{ name: 'Exodus', abbrev: 'EXO' },
|
||||
{ name: 'Leviticus', abbrev: 'LEV' },
|
||||
{ name: 'Numbers', abbrev: 'NUM' },
|
||||
{ name: 'Deuteronomy', abbrev: 'DEU' },
|
||||
{ name: 'Joshua', abbrev: 'JOS' },
|
||||
{ name: 'Judges', abbrev: 'JDG' },
|
||||
{ name: 'Ruth', abbrev: 'RUT' },
|
||||
{ name: '1 Samuel', abbrev: '1SA' },
|
||||
{ name: '2 Samuel', abbrev: '2SA' },
|
||||
{ name: '1 Kings', abbrev: '1KI' },
|
||||
{ name: '2 Kings', abbrev: '2KI' },
|
||||
{ name: '1 Chronicles', abbrev: '1CH' },
|
||||
{ name: '2 Chronicles', abbrev: '2CH' },
|
||||
{ name: 'Ezra', abbrev: 'EZR' },
|
||||
{ name: 'Nehemiah', abbrev: 'NEH' },
|
||||
{ name: 'Esther', abbrev: 'EST' },
|
||||
{ name: 'Job', abbrev: 'JOB' },
|
||||
{ name: 'Psalms', abbrev: 'PSA' },
|
||||
{ name: 'Proverbs', abbrev: 'PRO' },
|
||||
{ name: 'Ecclesiastes', abbrev: 'ECC' },
|
||||
{ name: 'Song', abbrev: 'SNG' },
|
||||
{ name: 'Isaiah', abbrev: 'ISA' },
|
||||
{ name: 'Jeremiah', abbrev: 'JER' },
|
||||
{ name: 'Lamentations', abbrev: 'LAM' },
|
||||
{ name: 'Ezekiel', abbrev: 'EZK' },
|
||||
{ name: 'Daniel', abbrev: 'DAN' },
|
||||
{ name: 'Hosea', abbrev: 'HOS' },
|
||||
{ name: 'Joel', abbrev: 'JOL' },
|
||||
{ name: 'Amos', abbrev: 'AMO' },
|
||||
{ name: 'Obadiah', abbrev: 'OBA' },
|
||||
{ name: 'Jonah', abbrev: 'JON' },
|
||||
{ name: 'Micah', abbrev: 'MIC' },
|
||||
{ name: 'Nahum', abbrev: 'NAM' },
|
||||
{ name: 'Habakkuk', abbrev: 'HAB' },
|
||||
{ name: 'Zephaniah', abbrev: 'ZEP' },
|
||||
{ name: 'Haggai', abbrev: 'HAG' },
|
||||
{ name: 'Zechariah', abbrev: 'ZEC' },
|
||||
{ name: 'Malachi', abbrev: 'MAL' },
|
||||
{ name: 'Matthew', abbrev: 'MAT' },
|
||||
{ name: 'Mark', abbrev: 'MRK' },
|
||||
{ name: 'Luke', abbrev: 'LUK' },
|
||||
{ name: 'John', abbrev: 'JHN' },
|
||||
{ name: 'Acts', abbrev: 'ACT' },
|
||||
{ name: 'Romans', abbrev: 'ROM' },
|
||||
{ name: '1 Corinthians', abbrev: '1CO' },
|
||||
{ name: '2 Corinthians', abbrev: '2CO' },
|
||||
{ name: 'Galatians', abbrev: 'GAL' },
|
||||
{ name: 'Ephesians', abbrev: 'EPH' },
|
||||
{ name: 'Philippians', abbrev: 'PHP' },
|
||||
{ name: 'Colossians', abbrev: 'COL' },
|
||||
{ name: '1 Thessalonians', abbrev: '1TH' },
|
||||
{ name: '2 Thessalonians', abbrev: '2TH' },
|
||||
{ name: '1 Timothy', abbrev: '1TI' },
|
||||
{ name: '2 Timothy', abbrev: '2TI' },
|
||||
{ name: 'Titus', abbrev: 'TIT' },
|
||||
{ name: 'Philemon', abbrev: 'PHM' },
|
||||
{ name: 'Hebrews', abbrev: 'HEB' },
|
||||
{ name: 'James', abbrev: 'JAS' },
|
||||
{ name: '1 Peter', abbrev: '1PE' },
|
||||
{ name: '2 Peter', abbrev: '2PE' },
|
||||
{ name: '1 John', abbrev: '1JN' },
|
||||
{ name: '2 John', abbrev: '2JN' },
|
||||
{ name: '3 John', abbrev: '3JN' },
|
||||
{ name: 'Jude', abbrev: 'JUD' },
|
||||
{ name: 'Revelation', abbrev: 'REV' },
|
||||
];
|
||||
|
||||
export const NT_BOOK_NUMBER = {
|
||||
MAT: 40, MRK: 41, LUK: 42, JHN: 43, ACT: 44,
|
||||
ROM: 45, '1CO': 46, '2CO': 47, GAL: 48, EPH: 49,
|
||||
PHP: 50, COL: 51, '1TH': 52, '2TH': 53, '1TI': 54,
|
||||
'2TI': 55, TIT: 56, PHM: 57, HEB: 58, JAS: 59,
|
||||
'1PE': 60, '2PE': 61, '1JN': 62, '2JN': 63, '3JN': 64,
|
||||
JUD: 65, REV: 66,
|
||||
};
|
||||
|
||||
export const storageKey = (translation, book, chapter) => `bible-study-${translation}-${book}-${chapter}`;
|
||||
export const makeId = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatRelativeDate(ts) {
|
||||
if (!ts) return '';
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function chunkSpansNextChapter(project, startChapterIndex, chunk) {
|
||||
if (!project || !Array.isArray(project.chapters)) return false;
|
||||
if (!Number.isInteger(chunk?.spilloverEndVerse)) return false;
|
||||
if (startChapterIndex < 0 || startChapterIndex >= project.chapters.length - 1) return false;
|
||||
const startChapter = project.chapters[startChapterIndex];
|
||||
const nextChapter = project.chapters[startChapterIndex + 1];
|
||||
return startChapter?.bookAbbrev && startChapter.bookAbbrev === nextChapter?.bookAbbrev;
|
||||
}
|
||||
|
||||
export function formatChunkReference(project, startChapterIndex, chunk, separator = '-') {
|
||||
if (!project || !chunk) return '';
|
||||
const startChapter = project.chapters?.[startChapterIndex];
|
||||
if (!startChapter) return '';
|
||||
|
||||
if (chunkSpansNextChapter(project, startChapterIndex, chunk)) {
|
||||
const nextChapter = project.chapters[startChapterIndex + 1];
|
||||
return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${nextChapter.chapter}:${chunk.spilloverEndVerse}`;
|
||||
}
|
||||
|
||||
if (chunk.startVerse === chunk.endVerse) {
|
||||
return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}`;
|
||||
}
|
||||
return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${chunk.endVerse}`;
|
||||
}
|
||||
|
||||
export function getChunkVerseEntries(project, startChapterIndex, chunk) {
|
||||
if (!project || !chunk) return [];
|
||||
const startChapter = project.chapters?.[startChapterIndex];
|
||||
if (!startChapter) return [];
|
||||
|
||||
const startVerses = (startChapter.verses ?? [])
|
||||
.filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse)
|
||||
.map((verse) => ({
|
||||
book: startChapter.book,
|
||||
chapter: startChapter.chapter,
|
||||
...verse,
|
||||
}));
|
||||
|
||||
if (!chunkSpansNextChapter(project, startChapterIndex, chunk)) {
|
||||
return startVerses;
|
||||
}
|
||||
|
||||
const nextChapter = project.chapters[startChapterIndex + 1];
|
||||
const spilloverVerses = (nextChapter.verses ?? [])
|
||||
.filter((verse) => verse.number >= 1 && verse.number <= chunk.spilloverEndVerse)
|
||||
.map((verse) => ({
|
||||
book: nextChapter.book,
|
||||
chapter: nextChapter.chapter,
|
||||
...verse,
|
||||
}));
|
||||
|
||||
return [...startVerses, ...spilloverVerses];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML export utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const escHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
export function buildExportHtml(project) {
|
||||
const style = `
|
||||
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 32px; }
|
||||
.page { max-width: 900px; margin: auto; }
|
||||
h1, h2 { font-family: Georgia, serif; }
|
||||
h1 { margin-bottom: 0.5rem; }
|
||||
.chapter-heading { margin: 2rem 0 0.5rem; font-size: 1.2rem; font-weight: 700; border-bottom: 1px solid #cbd5e1; padding-bottom: 0.25rem; }
|
||||
.chunk { margin-bottom: 2rem; padding: 1.25rem 1.5rem; border: 1px solid #cbd5e1; border-radius: 0.75rem; background: #ffffff; }
|
||||
.verse { margin: 0 0 0.75rem; line-height: 1.7; }
|
||||
.scripture-ref { font-weight: 700; margin-bottom: 0.75rem; }
|
||||
.oia, .cross-refs, .greek { margin-top: 1rem; }
|
||||
.oia-section { margin-bottom: 0.75rem; }
|
||||
.greek table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
|
||||
.greek th, .greek td { border: 1px solid #d1d5db; padding: 0.65rem; text-align: left; }
|
||||
.greek th { background: #f8fafc; }
|
||||
.definition { margin-top: 0.75rem; font-size: 0.95rem; line-height: 1.6; }
|
||||
.definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; }
|
||||
@media print {
|
||||
body { padding: 0; }
|
||||
.chunk { break-inside: avoid; border-color: #cbd5e1; }
|
||||
.chapter-heading { break-after: avoid; }
|
||||
}
|
||||
`;
|
||||
|
||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||
|
||||
const chunksHtml = chapters.map((ch, chapterIndex) => {
|
||||
const chapterHeader = `<h2 class="chapter-heading">${escHtml(ch.book)} ${escHtml(ch.chapter)}</h2>`;
|
||||
const chunkSections = ch.chunks.map((chunk) => {
|
||||
const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
|
||||
const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
|
||||
.map((verse) => `<p class="verse"><strong>${escHtml(verse.chapter)}:${escHtml(verse.number)}</strong> ${escHtml(verse.text)}</p>`)
|
||||
.join('');
|
||||
|
||||
const observation = escHtml((chunk.observation ?? '').trim()).replace(/\n/g, '<br />') || '<em>No observation.</em>';
|
||||
const interpretation = escHtml((chunk.interpretation ?? '').trim()).replace(/\n/g, '<br />') || '<em>No interpretation.</em>';
|
||||
const application = escHtml((chunk.application ?? '').trim()).replace(/\n/g, '<br />') || '<em>No application.</em>';
|
||||
|
||||
const crossRefsHtml = (chunk.crossReferences ?? []).length > 0
|
||||
? `<div class="cross-refs"><strong>CROSS-REFERENCES:</strong> ${chunk.crossReferences.map(escHtml).join(', ')}</div>`
|
||||
: '';
|
||||
|
||||
const greekRows = chunk.greekWords.map((word) => `
|
||||
<tr>
|
||||
<td><strong>${escHtml(word.strongNumber)}</strong></td>
|
||||
<td>${escHtml(word.lexeme)}</td>
|
||||
<td>${escHtml(word.transliteration)}</td>
|
||||
<td>${escHtml(word.partOfSpeech)}</td>
|
||||
<td>${escHtml(word.shortDefinition)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
const greekTable = wordTableHtml(greekRows);
|
||||
const extendedDefinitions = chunk.greekWords
|
||||
.map((word) => {
|
||||
if (word.definitionHtml) {
|
||||
return `
|
||||
<div class="definition-block">
|
||||
<p><strong>${escHtml(word.strongNumber)} — ${escHtml(word.lexeme)}</strong></p>
|
||||
<div class="definition">${word.definitionHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if ((word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query) {
|
||||
const raw = word.query.trim();
|
||||
const normalized = /^\d+$/.test(raw) ? `G${raw}` : raw.toUpperCase();
|
||||
const isStrongs = /^G\d+$/.test(normalized);
|
||||
const num = isStrongs ? normalized.slice(1) : null;
|
||||
const links = isStrongs && num
|
||||
? `<a href="https://biblehub.com/greek/${num}.htm">BibleHub</a> · <a href="https://www.blueletterbible.org/lexicon/g${num}/esv/0-1/">Blue Letter Bible</a> · <a href="https://www.studylight.org/lexicons/eng/greek/${num}.html">StudyLight</a>`
|
||||
: `<a href="https://biblehub.com/search.php?q=${encodeURIComponent(raw)}">BibleHub</a> · <a href="https://www.blueletterbible.org/search/search.cfm?Criteria=${encodeURIComponent(raw)}&t=KJV#s=s_lexiconc">Blue Letter Bible</a>`;
|
||||
return `
|
||||
<div class="definition-block" style="border-color:#fcd34d;background:#fffbeb;">
|
||||
<p><strong>${escHtml(raw)}</strong> — definition not found in auto-lookup</p>
|
||||
<p style="margin-top:0.5rem;font-size:0.9rem;">Look it up manually: ${links}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<section class="chunk">
|
||||
<div class="scripture-ref">${escHtml(scripture)}</div>
|
||||
${versesText}
|
||||
<div class="oia">
|
||||
<div class="oia-section"><strong>OBSERVATION:</strong><p>${observation}</p></div>
|
||||
<div class="oia-section"><strong>INTERPRETATION:</strong><p>${interpretation}</p></div>
|
||||
<div class="oia-section"><strong>APPLICATION:</strong><p>${application}</p></div>
|
||||
</div>
|
||||
${crossRefsHtml}
|
||||
<div class="greek"><strong>GREEK WORDS:</strong>
|
||||
${greekRows ? greekTable : '<p><em>No Greek word notes.</em></p>'}
|
||||
${extendedDefinitions}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}).join('');
|
||||
return chapterHeader + chunkSections;
|
||||
}).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${escHtml(project.title)}</title>
|
||||
<style>${style}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>${escHtml(project.title)}</h1>
|
||||
<p>${escHtml(project.translation)}</p>
|
||||
${chunksHtml}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function wordTableHtml(rows) {
|
||||
return `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Strong's</th>
|
||||
<th>Greek</th>
|
||||
<th>Transliteration</th>
|
||||
<th>Part of Speech</th>
|
||||
<th>Short Definition</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
export function extractPartOfSpeech(definitionHtml) {
|
||||
const match = /Part\(s\) of speech:\s*([^<]+)/i.exec(definitionHtml || '');
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function htmlToPlainText(html = '') {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const lines = [];
|
||||
doc.body.childNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
lines.push(node.textContent || '');
|
||||
return;
|
||||
}
|
||||
if (node.nodeName === 'BR' || node.nodeName === 'P') {
|
||||
lines.push(node.textContent || '');
|
||||
lines.push('\n');
|
||||
return;
|
||||
}
|
||||
lines.push(node.textContent || '');
|
||||
});
|
||||
return lines.join('').replace(/\n{2,}/g, '\n').trim();
|
||||
}
|
||||
|
||||
export function renderVerseContent(content) {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content.map(renderVerseContent).join('');
|
||||
}
|
||||
if (content && typeof content === 'object') {
|
||||
if (content.lineBreak) {
|
||||
return '\n';
|
||||
}
|
||||
if (content.type === 'line_break') {
|
||||
return '\n';
|
||||
}
|
||||
if (Array.isArray(content.content)) {
|
||||
return renderVerseContent(content.content);
|
||||
}
|
||||
if (typeof content.text === 'string') {
|
||||
return content.text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function parseBibleChapter(data) {
|
||||
if (data?.verses && Array.isArray(data.verses)) {
|
||||
return data.verses.map((verse) => ({
|
||||
number: verse.number,
|
||||
text: verse.text,
|
||||
}));
|
||||
}
|
||||
|
||||
const chapterContents = data?.chapter?.content;
|
||||
if (!Array.isArray(chapterContents)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return chapterContents
|
||||
.filter((item) => item?.type === 'verse')
|
||||
.map((item) => ({
|
||||
number: item.number,
|
||||
text: renderVerseContent(item.content).replace(/\s+/g, ' ').trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Storage helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INDEX_KEY_LEGACY = 'bible-study-index';
|
||||
const projectKeyLegacy = (id) => `bible-study-project-${id}`;
|
||||
|
||||
let activeStorageUserId = null;
|
||||
|
||||
export function indexKey() {
|
||||
return activeStorageUserId ? `bible-study-index:${activeStorageUserId}` : INDEX_KEY_LEGACY;
|
||||
}
|
||||
|
||||
export function projectKey(id) {
|
||||
return activeStorageUserId ? `bible-study-project:${activeStorageUserId}:${id}` : projectKeyLegacy(id);
|
||||
}
|
||||
|
||||
export function loadProjectIndex() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(indexKey());
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function migrateLegacyLocalDataToUser(userId) {
|
||||
if (!userId || window.localStorage.getItem(`bible-study-index:${userId}`)) return;
|
||||
const legacyRaw = window.localStorage.getItem(INDEX_KEY_LEGACY);
|
||||
if (!legacyRaw) return;
|
||||
try {
|
||||
const legacyIndex = JSON.parse(legacyRaw) ?? [];
|
||||
legacyIndex.forEach((entry) => {
|
||||
const raw = window.localStorage.getItem(projectKeyLegacy(entry.id));
|
||||
if (raw != null) {
|
||||
window.localStorage.setItem(`bible-study-project:${userId}:${entry.id}`, raw);
|
||||
window.localStorage.removeItem(projectKeyLegacy(entry.id));
|
||||
}
|
||||
});
|
||||
window.localStorage.setItem(`bible-study-index:${userId}`, legacyRaw);
|
||||
window.localStorage.removeItem(INDEX_KEY_LEGACY);
|
||||
} catch {
|
||||
// Leave legacy data in place if anything goes wrong
|
||||
}
|
||||
}
|
||||
|
||||
export function switchStorageUser(userId) {
|
||||
activeStorageUserId = userId ?? null;
|
||||
if (userId) migrateLegacyLocalDataToUser(userId);
|
||||
return loadProjectIndex();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function BookmarkIcon({ filled }) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" width="16" height="16" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.6">
|
||||
<path d="M5 3.5C5 2.67 5.67 2 6.5 2h7c.83 0 1.5.67 1.5 1.5v14l-5-3.5-5 3.5v-14z" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<rect x="7" y="7" width="10" height="11" rx="1.5" />
|
||||
<path d="M4.5 13.5h-1A1.5 1.5 0 0 1 2 12V3.5A1.5 1.5 0 0 1 3.5 2H12a1.5 1.5 0 0 1 1.5 1.5v1" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CrossRefChip({ label, onRemove, loadVerseText }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [verseText, setVerseText] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!hovered || verseText || error) return;
|
||||
let cancelled = false;
|
||||
loadVerseText(label)
|
||||
.then((text) => { if (!cancelled) setVerseText(text || 'Verse text unavailable.'); })
|
||||
.catch(() => { if (!cancelled) setError('Verse text unavailable.'); });
|
||||
return () => { cancelled = true; };
|
||||
}, [hovered]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="relative inline-flex items-center gap-1 rounded-full bg-slate-200 px-3 py-1 text-sm text-slate-800"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{label}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="ml-1 text-slate-500 hover:text-rose-600"
|
||||
aria-label={`Remove ${label}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{hovered && (
|
||||
<span className="absolute bottom-full left-0 z-10 mb-2 w-64 rounded-xl border border-slate-300 bg-white p-3 text-xs font-normal normal-case text-slate-700 shadow-lg">
|
||||
{error || verseText || 'Loading...'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPronunciationGuide(project) {
|
||||
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
|
||||
const lines = [];
|
||||
chapters.forEach((ch) => {
|
||||
ch.chunks.forEach((chunk) => {
|
||||
(chunk.greekWords ?? []).forEach((word) => {
|
||||
const gloss = word.shortDefinition || word.englishGloss || '';
|
||||
const lexeme = word.lexeme || '';
|
||||
const translit = word.transliteration || '';
|
||||
if (!lexeme && !translit) return;
|
||||
const label = gloss ? `${gloss} — ` : '';
|
||||
lines.push(`${label}${lexeme}${translit ? ` (${translit})` : ''}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
if (lines.length === 0) return 'No Greek/Hebrew words collected yet.';
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// DOCX export — kept in its own module so the (large) `docx` library is only
|
||||
// downloaded when the user actually exports, instead of on every page load.
|
||||
import {
|
||||
BorderStyle,
|
||||
Document,
|
||||
HeadingLevel,
|
||||
Packer,
|
||||
Paragraph,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from 'docx';
|
||||
import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx';
|
||||
|
||||
export function createParagraphsFromText(text) {
|
||||
return text
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => new Paragraph({ children: [new TextRun({ text: line })] }));
|
||||
}
|
||||
|
||||
const DOCX_ACCENT = 'C9A84C'; // antique gold
|
||||
const DOCX_HEADING_FONT = 'Cormorant Garamond';
|
||||
const DOCX_BODY_FONT = 'Lora';
|
||||
|
||||
export function sectionLabel(text) {
|
||||
return new Paragraph({
|
||||
spacing: { before: 240, after: 80 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: text.toUpperCase(),
|
||||
bold: true,
|
||||
color: DOCX_ACCENT,
|
||||
font: DOCX_BODY_FONT,
|
||||
size: 20,
|
||||
characterSpacing: 20,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildStudyDocxBlob(project, chapterSummary) {
|
||||
const children = [
|
||||
new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `${project.translation} — ${chapterSummary}`,
|
||||
italics: true,
|
||||
color: '7A7060',
|
||||
}),
|
||||
],
|
||||
spacing: { after: 300 },
|
||||
}),
|
||||
];
|
||||
|
||||
const noBorder = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' };
|
||||
|
||||
project.chapters.forEach((ch, chapterIndex) => {
|
||||
children.push(new Paragraph({
|
||||
text: `${ch.book} ${ch.chapter}`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: DOCX_ACCENT, space: 4 } },
|
||||
spacing: { before: 360, after: 160 },
|
||||
}));
|
||||
ch.chunks.forEach((chunk) => {
|
||||
const scriptureHeading = formatChunkReference(project, chapterIndex, chunk, '-');
|
||||
|
||||
const headingRuns = [new TextRun({ text: scriptureHeading, bold: true, size: 28, color: DOCX_ACCENT, font: DOCX_HEADING_FONT })];
|
||||
if (chunk.episodeNumber || chunk.episodeTitle) {
|
||||
headingRuns.push(new TextRun({
|
||||
text: ` (Session ${chunk.episodeNumber || '—'}${chunk.episodeTitle ? `: ${chunk.episodeTitle}` : ''})`,
|
||||
italics: true,
|
||||
size: 22,
|
||||
color: '7A7060',
|
||||
}));
|
||||
}
|
||||
children.push(new Paragraph({ children: headingRuns, spacing: { before: 320, after: 120 } }));
|
||||
|
||||
// Scripture text in a left-bordered table cell for a "callout" look (no fill)
|
||||
const scriptureParas = getChunkVerseEntries(project, chapterIndex, chunk).map((verse) => new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: `${verse.chapter}:${verse.number} `, bold: true, color: DOCX_ACCENT }),
|
||||
new TextRun({ text: verse.text, italics: true }),
|
||||
],
|
||||
spacing: { after: 80 },
|
||||
}));
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
margins: { top: 120, bottom: 120, left: 180, right: 180 },
|
||||
borders: {
|
||||
...{ top: noBorder, right: noBorder, bottom: noBorder },
|
||||
left: { style: BorderStyle.SINGLE, size: 24, color: DOCX_ACCENT },
|
||||
},
|
||||
children: scriptureParas,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}));
|
||||
children.push(new Paragraph({ text: '', spacing: { after: 120 } }));
|
||||
|
||||
if (chunk.generalNotes?.trim()) {
|
||||
children.push(sectionLabel('Background / General Notes'));
|
||||
children.push(...createParagraphsFromText(chunk.generalNotes));
|
||||
}
|
||||
|
||||
children.push(sectionLabel('Observation'));
|
||||
children.push(...createParagraphsFromText(chunk.observation || 'No observation.'));
|
||||
children.push(sectionLabel('Interpretation'));
|
||||
children.push(...createParagraphsFromText(chunk.interpretation || 'No interpretation.'));
|
||||
children.push(sectionLabel('Application'));
|
||||
children.push(...createParagraphsFromText(chunk.application || 'No application.'));
|
||||
|
||||
if ((chunk.tags ?? []).length > 0) {
|
||||
children.push(sectionLabel('Tags'));
|
||||
children.push(new Paragraph({
|
||||
children: chunk.tags.map((tag, i) => new TextRun({
|
||||
text: i === 0 ? `#${tag}` : ` #${tag}`,
|
||||
bold: true,
|
||||
color: DOCX_ACCENT,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
if ((chunk.crossReferences ?? []).length > 0) {
|
||||
children.push(sectionLabel('Cross-References'));
|
||||
children.push(new Paragraph({ text: chunk.crossReferences.join(' • ') }));
|
||||
}
|
||||
|
||||
if (chunk.greekWords.length > 0) {
|
||||
children.push(sectionLabel('Word Studies'));
|
||||
const headerCellStyle = (label) => new TableCell({
|
||||
width: { size: 20, type: WidthType.PERCENTAGE },
|
||||
borders: { bottom: { style: BorderStyle.SINGLE, size: 8, color: DOCX_ACCENT } },
|
||||
children: [new Paragraph({ children: [new TextRun({ text: label, bold: true, color: DOCX_ACCENT })] })],
|
||||
});
|
||||
const tableRows = [
|
||||
new TableRow({
|
||||
tableHeader: true,
|
||||
children: ['Strong', 'Greek', 'Transliteration', 'Part of Speech', 'Short Definition'].map(headerCellStyle),
|
||||
}),
|
||||
...chunk.greekWords.map((word) => new TableRow({
|
||||
children: [
|
||||
new TableCell({ children: [new Paragraph(word.strongNumber || '')] }),
|
||||
new TableCell({ children: [new Paragraph(word.lexeme || '')] }),
|
||||
new TableCell({ children: [new Paragraph(word.transliteration || '')] }),
|
||||
new TableCell({ children: [new Paragraph(word.partOfSpeech || '')] }),
|
||||
new TableCell({ children: [new Paragraph(word.shortDefinition || '')] }),
|
||||
],
|
||||
})),
|
||||
];
|
||||
children.push(new Table({ rows: tableRows, width: { size: 100, type: WidthType.PERCENTAGE } }));
|
||||
chunk.greekWords.forEach((word) => {
|
||||
if (word.definitionHtml) {
|
||||
children.push(new Paragraph({
|
||||
spacing: { before: 180, after: 80 },
|
||||
children: [new TextRun({ text: `${word.strongNumber} — ${word.lexeme || ''}`, bold: true, color: DOCX_ACCENT })],
|
||||
}));
|
||||
children.push(...createParagraphsFromText(htmlToPlainText(word.definitionHtml)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (chunk.finalScript?.trim()) {
|
||||
children.push(sectionLabel('Final Script'));
|
||||
children.push(...createParagraphsFromText(chunk.finalScript));
|
||||
}
|
||||
|
||||
children.push(new Paragraph({
|
||||
text: '',
|
||||
spacing: { after: 200 },
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: '2A2518', space: 8 } },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
const doc = new Document({
|
||||
styles: {
|
||||
default: {
|
||||
document: { run: { font: DOCX_BODY_FONT, color: '0A0A08' } },
|
||||
},
|
||||
paragraphStyles: [
|
||||
{
|
||||
id: 'Title',
|
||||
name: 'Title',
|
||||
basedOn: 'Normal',
|
||||
next: 'Normal',
|
||||
run: { font: DOCX_HEADING_FONT, size: 56, bold: true, color: '0A0A08' },
|
||||
},
|
||||
{
|
||||
id: 'Heading1',
|
||||
name: 'Heading 1',
|
||||
basedOn: 'Normal',
|
||||
next: 'Normal',
|
||||
run: { font: DOCX_HEADING_FONT, size: 36, bold: true, color: '0A0A08' },
|
||||
},
|
||||
{
|
||||
id: 'Heading2',
|
||||
name: 'Heading 2',
|
||||
basedOn: 'Normal',
|
||||
next: 'Normal',
|
||||
run: { font: DOCX_HEADING_FONT, size: 30, bold: true, color: '0A0A08' },
|
||||
},
|
||||
],
|
||||
},
|
||||
sections: [{ children }],
|
||||
});
|
||||
return Packer.toBlob(doc);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Markdown export and Claude prompt builders.
|
||||
import {
|
||||
formatChunkReference,
|
||||
getChunkVerseEntries,
|
||||
htmlToPlainText,
|
||||
} from './bibleUtils.jsx';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export / prompt builders (exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildMarkdownExport(project) {
|
||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||
|
||||
const chapterSections = chapters.map((ch, chapterIndex) => {
|
||||
const chunkSections = ch.chunks.map((chunk) => {
|
||||
const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
|
||||
const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
|
||||
.map((verse) => `> **${verse.chapter}:${verse.number}** ${verse.text}`)
|
||||
.join('\n>\n');
|
||||
|
||||
const observation = (chunk.observation ?? '').trim() || '_No observation._';
|
||||
const interpretation = (chunk.interpretation ?? '').trim() || '_No interpretation._';
|
||||
const application = (chunk.application ?? '').trim() || '_No application._';
|
||||
const generalNotes = (chunk.generalNotes ?? '').trim();
|
||||
|
||||
const crossRefsLine = (chunk.crossReferences ?? []).length > 0
|
||||
? `**Cross-References:** ${chunk.crossReferences.join(', ')}\n\n`
|
||||
: '';
|
||||
|
||||
const greekSection = chunk.greekWords.length === 0
|
||||
? ''
|
||||
: `**Greek/Hebrew Words**\n\n${chunk.greekWords.map((word) => {
|
||||
const summary = [
|
||||
word.strongNumber,
|
||||
word.lexeme,
|
||||
word.transliteration && `(${word.transliteration})`,
|
||||
word.partOfSpeech,
|
||||
word.shortDefinition,
|
||||
].filter(Boolean).join(' — ');
|
||||
const definition = word.definitionHtml ? `\n ${htmlToPlainText(word.definitionHtml)}` : '';
|
||||
return `- ${summary}${definition}`;
|
||||
}).join('\n')}\n\n`;
|
||||
|
||||
return `### ${scripture}\n\n${versesText}\n\n${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
|
||||
}).join('---\n\n');
|
||||
|
||||
return `## ${ch.book} ${ch.chapter}\n\n${chunkSections}`;
|
||||
}).join('\n');
|
||||
|
||||
return `# ${project.title}\n\n*${project.translation}*\n\n${chapterSections}`;
|
||||
}
|
||||
|
||||
export function buildChunkBodies(project) {
|
||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||
let chunkIndex = 0;
|
||||
return chapters.map((ch, chapterIndex) => {
|
||||
return ch.chunks.map((chunk) => {
|
||||
chunkIndex += 1;
|
||||
const ref = formatChunkReference(project, chapterIndex, chunk, '–');
|
||||
|
||||
const verses = getChunkVerseEntries(project, chapterIndex, chunk)
|
||||
.map((v) => `${v.chapter}:${v.number} ${v.text}`)
|
||||
.join('\n');
|
||||
|
||||
const observation = (chunk.observation ?? '').trim() || 'No observation.';
|
||||
const interpretation = (chunk.interpretation ?? '').trim() || 'No interpretation.';
|
||||
const application = (chunk.application ?? '').trim() || 'No application.';
|
||||
|
||||
const crossRefs = (chunk.crossReferences ?? []).length > 0
|
||||
? chunk.crossReferences.join(', ')
|
||||
: 'None.';
|
||||
|
||||
const generalNotes = (chunk.generalNotes ?? '').trim();
|
||||
const episodeLabelLine = (chunk.episodeNumber ?? '').trim() || (chunk.episodeTitle ?? '').trim()
|
||||
? `Session: ${[chunk.episodeNumber?.trim(), chunk.episodeTitle?.trim()].filter(Boolean).join(' — ')}\n`
|
||||
: '';
|
||||
|
||||
const greekWords = chunk.greekWords.length === 0
|
||||
? 'None.'
|
||||
: chunk.greekWords.map((word) => {
|
||||
const summary = [
|
||||
word.strongNumber,
|
||||
word.lexeme,
|
||||
word.transliteration && `(${word.transliteration})`,
|
||||
word.partOfSpeech,
|
||||
word.shortDefinition,
|
||||
].filter(Boolean).join(' | ');
|
||||
const definition = word.definitionHtml
|
||||
? `\n ${htmlToPlainText(word.definitionHtml)}`
|
||||
: '';
|
||||
return `• ${summary}${definition}`;
|
||||
}).join('\n');
|
||||
|
||||
return `===
|
||||
|
||||
CHUNK ${chunkIndex} — ${ref}
|
||||
${episodeLabelLine}
|
||||
Scripture:
|
||||
${verses}
|
||||
${generalNotes ? `\nBackground / General Notes:\n${generalNotes}\n` : ''}
|
||||
Observation:
|
||||
${observation}
|
||||
|
||||
Interpretation:
|
||||
${interpretation}
|
||||
|
||||
Application:
|
||||
${application}
|
||||
|
||||
Cross-References: ${crossRefs}
|
||||
|
||||
Greek Words:
|
||||
${greekWords}
|
||||
`;
|
||||
}).join('\n');
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
export function buildClaudePrompt(project) {
|
||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||
const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', ');
|
||||
|
||||
const header = `I've prepared a Bible study on ${chapterLabel} (${project.translation}) and need your help turning my notes into a polished study guide.
|
||||
|
||||
Below is my work organised by passage chunk, including my OIA notes and Greek word research. Please create a clear, structured study guide that:
|
||||
- Synthesises my notes into coherent teaching points
|
||||
- Naturally integrates the Greek word insights
|
||||
- Includes 2–3 reflection questions per chunk
|
||||
- Preserves the passage-by-passage structure
|
||||
|
||||
---
|
||||
|
||||
PROJECT: ${project.title}
|
||||
TRANSLATION: ${project.translation}
|
||||
PASSAGE: ${chapterLabel}
|
||||
`;
|
||||
|
||||
return header + buildChunkBodies(project);
|
||||
}
|
||||
|
||||
export function buildPodcastPrompt(project, podcastName) {
|
||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||
const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', ');
|
||||
const episodeLabel = 'EPISODE';
|
||||
const showName = (podcastName ?? '').trim();
|
||||
const showLine = showName || 'MY BIBLE STUDY PODCAST';
|
||||
const signOffLine = showName
|
||||
? `Introduce yourself, then sign off with: "...and this is ${showName}."`
|
||||
: 'Introduce yourself and the name of the show as a sign-off.';
|
||||
|
||||
const header = `I'm recording an episode of my Bible study podcast${showName ? ` "${showName}"` : ''} and need a full script written from my study notes below.
|
||||
|
||||
Please write the script in this exact structure, using the section markers and tone shown:
|
||||
|
||||
${showLine.toUpperCase()}
|
||||
${episodeLabel}
|
||||
${chapterLabel} (${project.translation}) · [estimate XX–XX minutes based on content]
|
||||
|
||||
✝ OPENING PRAYER
|
||||
[Pause before opening the text — invite God into the study]
|
||||
A short prayer (4-6 sentences) tying into the themes of this passage.
|
||||
|
||||
— ✦ —
|
||||
|
||||
🎙️ COLD OPEN
|
||||
[Co-host delivers the cold open solo, if there is one — hands off to the host at the end]
|
||||
A few short punchy lines previewing the passage and its hook, ending with a hand-off line introducing the host and the show.
|
||||
|
||||
— ✦ —
|
||||
|
||||
📖 SEGMENT [N] — [SEGMENT TITLE]
|
||||
[Brief stage direction in brackets]
|
||||
One segment per passage chunk (use the chunk reference, observation, interpretation, and application notes below as the raw material). Conversational, spoken-word style — not academic prose. Work through the text the way the host would talk it through out loud, weaving in the OIA notes and cross-references naturally.
|
||||
|
||||
— ✦ —
|
||||
|
||||
📖 SEGMENT [N] — GREEK WORD STUDY
|
||||
[Work through the key terms — keep it vivid, help the words come alive]
|
||||
For each Greek/Hebrew word collected below, a block in this format:
|
||||
|
||||
[English Gloss] — [English meaning]
|
||||
[DO NOT READ: original-language word (transliteration) — part of speech]
|
||||
2-4 sentences unpacking the word's meaning and why it matters for this passage, conversational tone.
|
||||
|
||||
— ✦ —
|
||||
|
||||
💬 DISCUSSION QUESTIONS
|
||||
[From the study guide — ${chapterLabel}]
|
||||
3 reflection questions drawn from the application notes, numbered.
|
||||
|
||||
— ✦ —
|
||||
|
||||
✦ CLOSING
|
||||
[Grounded and direct — send them away with something to carry]
|
||||
A closing reflection that ties the segments together, a short pull-quote from the passage with its reference, then close out. ${signOffLine}
|
||||
"Until next time — keep studying verse by verse."
|
||||
|
||||
— End of Episode —
|
||||
|
||||
${showLine} · ${episodeLabel} · ${chapterLabel}
|
||||
|
||||
---
|
||||
|
||||
Here are my study notes, organised by passage chunk, including my OIA notes, cross-references, and Greek/Hebrew word research:
|
||||
|
||||
PROJECT: ${project.title}
|
||||
TRANSLATION: ${project.translation}
|
||||
PASSAGE: ${chapterLabel}
|
||||
`;
|
||||
|
||||
return header + buildChunkBodies(project);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Parsers for typed cross-references and imported DOCX session lists.
|
||||
import {
|
||||
bookOptions,
|
||||
} from './bibleUtils.jsx';
|
||||
|
||||
// Common short-form aliases for typed-in cross-references (e.g. "Rom 3:23")
|
||||
export const BOOK_NAME_ALIASES = {
|
||||
'gen': 'GEN', 'genesis': 'GEN', 'exo': 'EXO', 'exod': 'EXO', 'exodus': 'EXO',
|
||||
'lev': 'LEV', 'leviticus': 'LEV', 'num': 'NUM', 'numbers': 'NUM',
|
||||
'deut': 'DEU', 'deuteronomy': 'DEU', 'josh': 'JOS', 'joshua': 'JOS',
|
||||
'judg': 'JDG', 'judges': 'JDG', 'ruth': 'RUT',
|
||||
'1 sam': '1SA', '1 samuel': '1SA', '2 sam': '2SA', '2 samuel': '2SA',
|
||||
'1 kgs': '1KI', '1 kings': '1KI', '2 kgs': '2KI', '2 kings': '2KI',
|
||||
'1 chr': '1CH', '1 chronicles': '1CH', '2 chr': '2CH', '2 chronicles': '2CH',
|
||||
'ezra': 'EZR', 'neh': 'NEH', 'nehemiah': 'NEH', 'esth': 'EST', 'esther': 'EST',
|
||||
'job': 'JOB', 'ps': 'PSA', 'psa': 'PSA', 'psalm': 'PSA', 'psalms': 'PSA',
|
||||
'prov': 'PRO', 'proverbs': 'PRO', 'eccl': 'ECC', 'ecclesiastes': 'ECC',
|
||||
'song': 'SNG', 'isa': 'ISA', 'isaiah': 'ISA', 'jer': 'JER', 'jeremiah': 'JER',
|
||||
'lam': 'LAM', 'lamentations': 'LAM', 'ezek': 'EZK', 'ezekiel': 'EZK',
|
||||
'dan': 'DAN', 'daniel': 'DAN', 'hos': 'HOS', 'hosea': 'HOS', 'joel': 'JOL',
|
||||
'amos': 'AMO', 'obad': 'OBA', 'obadiah': 'OBA', 'jonah': 'JON', 'mic': 'MIC',
|
||||
'micah': 'MIC', 'nah': 'NAM', 'nahum': 'NAM', 'hab': 'HAB', 'habakkuk': 'HAB',
|
||||
'zeph': 'ZEP', 'zephaniah': 'ZEP', 'hag': 'HAG', 'haggai': 'HAG',
|
||||
'zech': 'ZEC', 'zechariah': 'ZEC', 'mal': 'MAL', 'malachi': 'MAL',
|
||||
'matt': 'MAT', 'mt': 'MAT', 'matthew': 'MAT', 'mark': 'MRK', 'mk': 'MRK',
|
||||
'luke': 'LUK', 'lk': 'LUK', 'john': 'JHN', 'jn': 'JHN', 'acts': 'ACT',
|
||||
'rom': 'ROM', 'romans': 'ROM', '1 cor': '1CO', '1 corinthians': '1CO',
|
||||
'2 cor': '2CO', '2 corinthians': '2CO', 'gal': 'GAL', 'galatians': 'GAL',
|
||||
'eph': 'EPH', 'ephesians': 'EPH', 'phil': 'PHP', 'philippians': 'PHP',
|
||||
'col': 'COL', 'colossians': 'COL', '1 thess': '1TH', '1 thessalonians': '1TH',
|
||||
'2 thess': '2TH', '2 thessalonians': '2TH', '1 tim': '1TI', '1 timothy': '1TI',
|
||||
'2 tim': '2TI', '2 timothy': '2TI', 'titus': 'TIT', 'phlm': 'PHM', 'philemon': 'PHM',
|
||||
'heb': 'HEB', 'hebrews': 'HEB', 'jas': 'JAS', 'james': 'JAS',
|
||||
'1 pet': '1PE', '1 peter': '1PE', '2 pet': '2PE', '2 peter': '2PE',
|
||||
'1 jn': '1JN', '1 john': '1JN', '2 jn': '2JN', '2 john': '2JN',
|
||||
'3 jn': '3JN', '3 john': '3JN', 'jude': 'JUD', 'rev': 'REV', 'revelation': 'REV',
|
||||
};
|
||||
|
||||
bookOptions.forEach((b) => {
|
||||
BOOK_NAME_ALIASES[b.name.toLowerCase()] = b.abbrev;
|
||||
});
|
||||
|
||||
// Parses strings like "Romans 3:23", "1 Timothy 3:2-7" into { bookAbbrev, chapter, verse, endVerse }
|
||||
export function parseCrossRefString(ref) {
|
||||
const match = ref.trim().match(/^(.+?)\s+(\d+):(\d+)(?:-(\d+))?$/);
|
||||
if (!match) return null;
|
||||
const [, bookPart, chapter, verse, endVerse] = match;
|
||||
const abbrev = BOOK_NAME_ALIASES[bookPart.trim().toLowerCase()];
|
||||
if (!abbrev) return null;
|
||||
return {
|
||||
bookAbbrev: abbrev,
|
||||
chapter: Number(chapter),
|
||||
verse: Number(verse),
|
||||
endVerse: endVerse ? Number(endVerse) : Number(verse),
|
||||
};
|
||||
}
|
||||
|
||||
// Parses a "Passage" cell like "1:1–2", "9:32–10:23", "9:1–19a", or "—" (no specific passage)
|
||||
export function parseEpisodePassage(raw) {
|
||||
const str = (raw ?? '').trim().replace(/(\d)[a-z]\b/g, '$1'); // strip "19a"/"19b" verse-letter suffixes
|
||||
if (!str || /^[-–—]+$/.test(str)) return null;
|
||||
const match = str.match(/^(\d+):(\d+)\s*[-–—]\s*(?:(\d+):)?(\d+)$/)
|
||||
?? str.match(/^(\d+):(\d+)$/);
|
||||
if (!match) return 'invalid';
|
||||
const [, ch1, v1, ch2, v2raw] = match;
|
||||
const endChapter = ch2 ? Number(ch2) : Number(ch1);
|
||||
const endVerse = v2raw !== undefined ? Number(v2raw) : Number(v1);
|
||||
return {
|
||||
startChapter: Number(ch1),
|
||||
startVerse: Number(v1),
|
||||
endChapter,
|
||||
endVerse,
|
||||
};
|
||||
}
|
||||
|
||||
// Extracts a "Session # / Title / Passage" list from a docx, including both
|
||||
// table rows and standalone "Ep. N — Title" / "Session N — Title" paragraphs (e.g. part intros).
|
||||
export async function parseEpisodeListDocx(file) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const { default: mammoth } = await import('mammoth');
|
||||
const { value: html } = await mammoth.convertToHtml({ arrayBuffer });
|
||||
const parsedDoc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
const episodes = new Map(); // episodeNumber -> { episodeNumber, title, passage }
|
||||
|
||||
parsedDoc.querySelectorAll('table tr').forEach((row) => {
|
||||
const cells = Array.from(row.querySelectorAll('td,th')).map((c) => c.textContent.trim());
|
||||
if (cells.length < 3) return;
|
||||
const [epRaw, title, passage] = cells;
|
||||
if (!/^\d+$/.test(epRaw)) return; // header row
|
||||
episodes.set(epRaw, { episodeNumber: epRaw, title, passage });
|
||||
});
|
||||
|
||||
parsedDoc.querySelectorAll('p').forEach((p) => {
|
||||
const match = p.textContent.trim().match(/^(?:Ep\.|Episode|Session)\s*(\d+)\s*[-–—]\s*(.+)$/i);
|
||||
if (!match) return;
|
||||
const [, epRaw, title] = match;
|
||||
if (!episodes.has(epRaw)) {
|
||||
episodes.set(epRaw, { episodeNumber: epRaw, title: title.trim(), passage: '—' });
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(episodes.values()).sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { getStroke } from 'perfect-freehand';
|
||||
|
||||
export function pathFromStroke(points) {
|
||||
if (!points.length) return '';
|
||||
const d = [`M ${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}`];
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const mx = ((points[i][0] + points[i + 1][0]) / 2).toFixed(2);
|
||||
const my = ((points[i][1] + points[i + 1][1]) / 2).toFixed(2);
|
||||
d.push(`Q ${points[i][0].toFixed(2)} ${points[i][1].toFixed(2)} ${mx} ${my}`);
|
||||
}
|
||||
d.push('Z');
|
||||
return d.join(' ');
|
||||
}
|
||||
|
||||
// Renders saved strokes + optional active stroke onto a canvas element.
|
||||
// Coordinates are stored as fractions [0,1] of the draw canvas dimensions.
|
||||
// size is stored as a fraction of canvas height (e.g. 0.012 ≈ 8px on a 600px canvas).
|
||||
export function renderInkToCanvas(
|
||||
canvas,
|
||||
strokes,
|
||||
activeStroke = [],
|
||||
activeTool = 'pen',
|
||||
activeColor = '#0f172a',
|
||||
activeSize = 0.012,
|
||||
) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.clientWidth;
|
||||
const h = canvas.clientHeight;
|
||||
if (!w || !h) return;
|
||||
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
function strokeOpts(tool, baseSize, points) {
|
||||
const avgTilt = points.length
|
||||
? points.reduce((s, p) => s + (p[3] ?? 0), 0) / points.length
|
||||
: 0;
|
||||
return {
|
||||
// Tilt widens the stroke and reduces pressure-thinning (pencil-shading feel)
|
||||
size: tool === 'pen' ? baseSize * (1 + avgTilt * 2.5) : baseSize,
|
||||
thinning: tool === 'highlighter' ? 0 : 0.5 * (1 - (tool === 'pen' ? avgTilt * 0.8 : 0)),
|
||||
smoothing: 0.5,
|
||||
streamline: 0.4,
|
||||
};
|
||||
}
|
||||
|
||||
for (const s of strokes) {
|
||||
const pts = s.points.map(([x, y, p]) => [x * w, y * h, p]);
|
||||
const outline = getStroke(pts, strokeOpts(s.tool, s.size * h, s.points));
|
||||
ctx.globalAlpha = s.tool === 'highlighter' ? 0.35 : 1;
|
||||
ctx.fillStyle = s.color;
|
||||
ctx.fill(new Path2D(pathFromStroke(outline)));
|
||||
}
|
||||
|
||||
if (activeStroke.length > 1 && activeTool !== 'eraser') {
|
||||
const pts = activeStroke.map(([x, y, p]) => [x * w, y * h, p]);
|
||||
const outline = getStroke(pts, strokeOpts(activeTool, activeSize * h, activeStroke));
|
||||
ctx.globalAlpha = activeTool === 'highlighter' ? 0.35 : 1;
|
||||
ctx.fillStyle = activeColor;
|
||||
ctx.fill(new Path2D(pathFromStroke(outline)));
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Project persistence in localStorage, plus migrations from older data shapes.
|
||||
import {
|
||||
makeId,
|
||||
indexKey,
|
||||
projectKey,
|
||||
loadProjectIndex,
|
||||
} from './bibleUtils.jsx';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migration helpers (exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function migrateChunk(chunk) {
|
||||
if (chunk.observation !== undefined) {
|
||||
return {
|
||||
...chunk,
|
||||
spilloverEndVerse: Number.isInteger(chunk.spilloverEndVerse) ? chunk.spilloverEndVerse : null,
|
||||
generalNotes: chunk.generalNotes ?? '',
|
||||
episodeNumber: chunk.episodeNumber ?? '',
|
||||
episodeTitle: chunk.episodeTitle ?? '',
|
||||
finalScript: chunk.finalScript ?? '',
|
||||
tags: chunk.tags ?? [],
|
||||
}; // already new format
|
||||
}
|
||||
return {
|
||||
...chunk,
|
||||
observation: chunk.notes ?? '',
|
||||
interpretation: '',
|
||||
application: '',
|
||||
crossReferences: [],
|
||||
tags: [],
|
||||
spilloverEndVerse: null,
|
||||
generalNotes: '',
|
||||
episodeNumber: '',
|
||||
episodeTitle: '',
|
||||
finalScript: '',
|
||||
notes: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateProject(raw) {
|
||||
if (!raw) return null;
|
||||
// Already new format
|
||||
if (Array.isArray(raw.chapters)) {
|
||||
return {
|
||||
...raw,
|
||||
chapters: raw.chapters.map((ch) => ({
|
||||
...ch,
|
||||
chunks: ch.chunks.map(migrateChunk),
|
||||
})),
|
||||
};
|
||||
}
|
||||
// Old flat format → wrap in chapters array
|
||||
const id = raw.id ?? makeId();
|
||||
return {
|
||||
id,
|
||||
title: raw.title ?? '',
|
||||
translation: raw.translation ?? 'BSB',
|
||||
lastEdited: raw.lastEdited ?? Date.now(),
|
||||
selectedChunkId: raw.selectedChunkId ?? null,
|
||||
chapters: [
|
||||
{
|
||||
book: raw.book ?? '',
|
||||
bookAbbrev: raw.bookAbbrev ?? '',
|
||||
chapter: raw.chapter ?? '1',
|
||||
verses: raw.verses ?? [],
|
||||
chunks: (raw.chunks ?? []).map(migrateChunk),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Storage helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function saveProjectToStorage(project) {
|
||||
// Honour an already-stamped lastEdited (e.g. from autosave) so localStorage
|
||||
// and the server always receive the identical timestamp.
|
||||
const updated = { ...project, lastEdited: project.lastEdited ?? Date.now() };
|
||||
window.localStorage.setItem(projectKey(updated.id), JSON.stringify(updated));
|
||||
const index = loadProjectIndex();
|
||||
const existing = index.findIndex((e) => e.id === updated.id);
|
||||
const tags = Array.from(
|
||||
new Set(
|
||||
(updated.chapters ?? [])
|
||||
.flatMap((ch) => ch.chunks ?? [])
|
||||
.flatMap((chunk) => chunk.tags ?? []),
|
||||
),
|
||||
);
|
||||
const summary = {
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
lastEdited: updated.lastEdited,
|
||||
chapterSummary: buildChapterSummary(updated),
|
||||
tags,
|
||||
};
|
||||
if (existing >= 0) {
|
||||
index[existing] = summary;
|
||||
} else {
|
||||
index.push(summary);
|
||||
}
|
||||
window.localStorage.setItem(indexKey(), JSON.stringify(index));
|
||||
return updated; // caller can use this exact object for the server PUT
|
||||
}
|
||||
|
||||
export function deleteProjectFromStorage(id) {
|
||||
window.localStorage.removeItem(projectKey(id));
|
||||
const index = loadProjectIndex().filter((e) => e.id !== id);
|
||||
window.localStorage.setItem(indexKey(), JSON.stringify(index));
|
||||
}
|
||||
|
||||
export function loadProjectById(id) {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(projectKey(id));
|
||||
return raw ? migrateProject(JSON.parse(raw)) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateOldStorageKeys() {
|
||||
const keys = Object.keys(window.localStorage);
|
||||
keys.forEach((key) => {
|
||||
if (!key.startsWith('bible-study-') || key.startsWith('bible-study-index') || key.startsWith('bible-study-project')) return;
|
||||
try {
|
||||
const raw = JSON.parse(window.localStorage.getItem(key));
|
||||
if (!raw || !raw.book) return;
|
||||
const migrated = migrateProject(raw);
|
||||
if (!migrated) return;
|
||||
saveProjectToStorage(migrated);
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// skip corrupt entries
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function buildChapterSummary(project) {
|
||||
if (!Array.isArray(project.chapters)) return '';
|
||||
return project.chapters
|
||||
.map((ch) => `${ch.book} ${ch.chapter}`)
|
||||
.join(', ');
|
||||
}
|
||||
@@ -6,6 +6,9 @@ export default defineConfig({
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: ['study.necloud.us', 'localhost', 'study.versebyversewithnate.us'],
|
||||
watch: {
|
||||
ignored: ['**/.git/**'],
|
||||
},
|
||||
// Proxy /api calls to the Express backend during development
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
Reference in new issue
Block a user