Compare 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,5 @@ coverage/
|
||||
.vite.log
|
||||
.api.pid
|
||||
.api.log
|
||||
.DS_Store
|
||||
.env
|
||||
@@ -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,158 @@
|
||||
# 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.
|
||||
|
||||
---
|
||||
|
||||
## Optional keys (set in `.env` or the environment)
|
||||
|
||||
| Variable | What it turns on |
|
||||
| --- | --- |
|
||||
| `SESSION_SECRET` | **Required in production.** A long random string; the server refuses to start without it. |
|
||||
| `ADMIN_EMAIL` | The one admin account. |
|
||||
| `ESV_API_KEY`, `API_BIBLE_KEY` | ESV, NLT, CSB and NASB in the Reader. |
|
||||
| `IQBIBLE_API_KEY` | IQ Bible: more commentaries, lexicons (BDB, LSJ, Abbott-Smith), places, topics, people, textual notes, Gospel parallels, daily verse and Spurgeon devotional, plan calendar export, more audio narrations, and background for podcast prompts. Hidden when unset. |
|
||||
| `IQBIBLE_CACHE_HOURS` | Leave unset unless IQ Bible has given written permission to cache non-verse data; only verse/chapter text is cached by default (24h), as their terms require. |
|
||||
| `BACKUP_KEEP`, `BACKUP_DISABLED=1` | The server saves a snapshot of the whole database once a day to `DATA_DIR/backups` (default: keep 7). Restore with `scripts/restore-data.sh`. |
|
||||
| `ALLOW_PRIVATE_NTFY=1` | Lets reading reminders use an ntfy server on your own network (blocked by default). |
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run test:run # all tests
|
||||
npm run lint # ESLint (errors fail; warnings are advice)
|
||||
```
|
||||
|
||||
Forgotten passwords: the sign-in page's "Forgot your password?" sends a one-time code to the account's ntfy topic (the one used for reading reminders). Accounts without a topic still ask the administrator for a temporary password.
|
||||
@@ -1,95 +0,0 @@
|
||||
# Study App Improvement Suggestions
|
||||
|
||||
## Features
|
||||
|
||||
### Study Tools
|
||||
- **Bible comparison mode** — show two translations side-by-side (API already supports it)
|
||||
- **Verse-level notes** — annotate individual verses, not just chunks
|
||||
- **Tagging / themes** — tag chunks with themes (e.g. "faith", "grace"), filter/search across projects
|
||||
- **Progress tracking** — mark chunks as "in progress" / "complete"; show progress bar on home card
|
||||
- **Study templates** — pre-fill OIA fields with guiding prompts for new users
|
||||
- **Print view** — clean print-optimized CSS layout
|
||||
- **Old Testament support** — only NT books listed; HelloAO API supports OT. Greek suggest is NT-only but the rest could support OT with Hebrew lookup
|
||||
- **Verse search** — search bar to find a verse by keyword across the loaded chapter
|
||||
|
||||
### Export / Sharing
|
||||
- **PDF export** — "Export PDF" button using `jsPDF` or `window.print()`
|
||||
- **Share link** — read-only shareable URL pointing to a project ID on the server
|
||||
- **Copy individual chunk** — "copy this chunk's notes" button alongside full "Prepare for Claude"
|
||||
- **Markdown export** — useful for Obsidian and similar note-taking apps
|
||||
|
||||
---
|
||||
|
||||
## UX / UI
|
||||
|
||||
### Navigation
|
||||
- **Keyboard shortcuts** — `←`/`→` to navigate chunks; `Ctrl+S` to save; `Escape` to close modals
|
||||
- **"Jump to chunk" dropdown** — for projects with many chunks, a select menu is faster than scrolling
|
||||
- **Breadcrumb in header** — show `Book Chapter:Verse range` so users always know where they are
|
||||
|
||||
### Chunk Builder (Setup Page)
|
||||
- **Drag-to-select verses** — click-and-drag instead of click then shift-click
|
||||
- **Auto-chunk** — button to split chapter into chunks by paragraph/section breaks
|
||||
- **Visual overlap indicator** — already-chunked verses are shaded but there's no tooltip explaining why you can't select them
|
||||
|
||||
### Study Page
|
||||
- **Collapsible sections** — collapse OIA, Cross-References, and Greek Word Studies independently
|
||||
- **Word/character count** on each textarea to encourage note depth
|
||||
- **Inline verse reference popup** — hover popover on cross-references showing verse text (from HelloAO)
|
||||
- **Sticky chunk navigation** — Previous/Next chunk buttons should be sticky, not only at the bottom
|
||||
|
||||
### Home Page
|
||||
- **Search/filter projects** — text filter on the project list
|
||||
- **Sort options** — sort by name, date, or passage
|
||||
- **Project rename** — title is only set at creation; allow renaming from home card
|
||||
- **Last opened chunk** — resume directly to the study page, not the setup page
|
||||
|
||||
---
|
||||
|
||||
## Code Architecture
|
||||
|
||||
### State Management
|
||||
- **`App.jsx` is ~2,250 lines** — biggest maintainability issue. Split into:
|
||||
- `pages/HomePage.jsx`
|
||||
- `pages/SetupPage.jsx`
|
||||
- `pages/StudyPage.jsx`
|
||||
- `components/ChunkEditor.jsx`
|
||||
- `components/GreekWordStudy.jsx`
|
||||
- `components/SuggestModal.jsx`
|
||||
- **Custom hooks** — extract logic into `useProject()`, `useGreekLookup()`, `useAutosave()`
|
||||
|
||||
### Sync / Persistence
|
||||
- **No auth** — the server has zero authentication. Any user who can reach the server can read/overwrite/delete any project. Add at minimum an API key (env var in middleware) or user accounts
|
||||
- **Conflict resolution is basic** — only compares `lastEdited` timestamps. Add a "which version do you want to keep?" UI to prevent silent data loss
|
||||
- **Offline-first** — use a service worker / `workbox` so the app works offline and syncs when back online
|
||||
|
||||
### Security (OWASP)
|
||||
- **XSS via `dangerouslySetInnerHTML`** — `word.definitionHtml` is rendered raw. Add DOMPurify sanitization
|
||||
- **No input validation on server** — add max-length and character validation on `id`/`title` fields (SQL injection is prevented by parameterized queries, but still)
|
||||
- **CORS** — server has no CORS headers; any origin can call the API in production
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
- **Verse data stored in project JSON** — full verse text is saved in localStorage and SQLite for every chapter. For multi-chapter projects this grows large. Consider storing only the chapter reference and re-fetching verses on load
|
||||
- **JSON files cached in refs** — `nt-strongs-gloss.json` and `nt-strongs-concordance.json` should be served with proper `Cache-Control` headers
|
||||
- **Autosave fires on all state changes** — the `[project]` dependency is too broad; it fires even when just selecting a chunk. Debounce only on content field changes
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- Add tests for:
|
||||
- `migrateProject` with the old flat format
|
||||
- `buildClaudePrompt` output structure
|
||||
- `parseBibleChapter` with both API response shapes
|
||||
- Autosave debounce behavior
|
||||
|
||||
---
|
||||
|
||||
## Developer Experience
|
||||
|
||||
- **No ESLint config** — add ESLint with `eslint-plugin-react` and `eslint-plugin-react-hooks` to catch missing `useEffect` deps
|
||||
- **No TypeScript** — JSDoc types or a TS migration would catch shape mismatches between old/new project formats at compile time
|
||||
- **No `docker-compose.yml`** — Dockerfile exists but there's no compose file for one-command local dev with server + SQLite volume
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist/**', 'coverage/**', 'node_modules/**', 'server/data/**', 'public/**'] },
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: { ...globals.browser },
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
plugins: { 'react-hooks': reactHooks },
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'no-unused-vars': ['warn', { varsIgnorePattern: '^_|^[A-Z]', argsIgnorePattern: '^_', caughtErrors: 'none' }],
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['server/**/*.js', 'scripts/**/*.mjs', 'vite.config.js', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } },
|
||||
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] },
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'],
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } },
|
||||
},
|
||||
];
|
||||
@@ -4,8 +4,23 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bible Study Project</title>
|
||||
|
||||
<!-- PWA manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Theme / status bar -->
|
||||
<meta name="theme-color" content="#f1ece0" />
|
||||
|
||||
<!-- iOS PWA -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="Bible Study" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Fallback icon for browsers -->
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||
</head>
|
||||
<body class="bg-slate-50 text-slate-900">
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -12,19 +12,29 @@
|
||||
"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",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -32,6 +42,10 @@
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"csv-parse": "^6.2.1",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.4",
|
||||
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 420 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Bible Study Project">
|
||||
<defs>
|
||||
<radialGradient id="glow" cx="50%" cy="34%" r="45%">
|
||||
<stop offset="0" stop-color="#fbbf24" stop-opacity=".35"/>
|
||||
<stop offset="1" stop-color="#fbbf24" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="#0f172a"/>
|
||||
<rect width="512" height="512" rx="112" fill="url(#glow)"/>
|
||||
<!-- cross -->
|
||||
<rect x="238" y="82" width="36" height="170" rx="6" fill="#fbbf24"/>
|
||||
<rect x="200" y="122" width="112" height="34" rx="6" fill="#fbbf24"/>
|
||||
<!-- open book -->
|
||||
<path d="M256 392 C206 356 146 350 80 366 L80 262 C146 246 206 252 256 286 Z" fill="#f8fafc"/>
|
||||
<path d="M256 392 C306 356 366 350 432 366 L432 262 C366 246 306 252 256 286 Z" fill="#e2e8f0"/>
|
||||
<path d="M60 384 C136 366 206 374 256 414 C306 374 376 366 452 384" fill="none" stroke="#fbbf24" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M256 286 V392" stroke="#0f172a" stroke-width="6" opacity=".35"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "Bible Study Project",
|
||||
"short_name": "Bible Study",
|
||||
"description": "Your personal verse-by-verse Bible study companion",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#faf7f0",
|
||||
"theme_color": "#f1ece0",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
Drop a cover photo here for each built-in reading plan, named exactly:
|
||||
|
||||
bible-year.jpg Whole Bible in a Year
|
||||
bible-90.jpg Whole Bible in 90 Days
|
||||
nt-90.jpg New Testament in 90 Days
|
||||
nt-30.jpg New Testament in 30 Days
|
||||
gospels-30.jpg The Gospels in 30 Days
|
||||
psalms-30.jpg Psalms in 30 Days
|
||||
proverbs-31.jpg Proverbs in 31 Days
|
||||
pentateuch-60.jpg The Pentateuch in 60 Days
|
||||
wisdom-40.jpg Wisdom Literature in 40 Days
|
||||
|
||||
Landscape orientation, ~1200px wide, works best (cards crop to a 16:9 box).
|
||||
Any plan without a matching file just shows its colorful gradient + icon —
|
||||
nothing breaks if some are missing.
|
||||
|
||||
See src/utils/readingPlans.js -> PIXABAY_SEARCH_LINKS for a Pixabay search
|
||||
link per plan to help pick one.
|
||||
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 202 KiB |
@@ -0,0 +1,97 @@
|
||||
const SHELL = 'bible-shell-v2';
|
||||
const API = 'bible-api-v1';
|
||||
// Chapter text, interlinear cross-refs, and (BSB) chapter audio all come from
|
||||
// these origins — cache them so the reader, reading plans, and audio all keep
|
||||
// working offline once you've visited a passage before.
|
||||
const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life', 'https://audio.bible.helloao.org'];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(SHELL)
|
||||
.then(c => c.addAll(['/', '/index.html']))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then(keys => Promise.all(
|
||||
keys.filter(k => k !== SHELL && k !== API).map(k => caches.delete(k))
|
||||
))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const { request } = e;
|
||||
const url = request.url;
|
||||
|
||||
// Never intercept this app's own API routes — let Express handle them. (Only same-origin ones: the
|
||||
// Bible text at bible.helloao.org/api/... must pass through to the offline cache below.)
|
||||
const { origin, pathname } = new URL(url);
|
||||
if (origin === self.location.origin && pathname.startsWith('/api/')) return;
|
||||
|
||||
// External Bible API (text, cross-refs, audio): network-first, fall back to cache.
|
||||
// Audio elements often issue Range requests, which come back as 206 Partial
|
||||
// Content — the Cache API throws on those, so only cache full (200) responses.
|
||||
if (BIBLE_ORIGINS.some(o => url.startsWith(o))) {
|
||||
e.respondWith(
|
||||
fetch(request)
|
||||
.then(res => {
|
||||
// Clone *now*: once the page starts reading the body a later clone() throws and nothing is saved.
|
||||
if (res.status === 200) {
|
||||
const copy = res.clone();
|
||||
e.waitUntil(caches.open(API).then(c => c.put(request, copy)).catch(() => {}));
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() =>
|
||||
caches.open(API).then(c => c.match(request)).then(
|
||||
hit => hit ?? new Response('{"error":"offline"}', {
|
||||
status: 503, headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// App-shell navigation: network-first, fall back to cached index.html
|
||||
if (request.mode === 'navigate') {
|
||||
e.respondWith(
|
||||
fetch(request)
|
||||
.then(res => {
|
||||
if (res.ok) {
|
||||
const copy = res.clone();
|
||||
e.waitUntil(caches.open(SHELL).then(c => c.put(request, copy)).catch(() => {}));
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() =>
|
||||
caches.open(SHELL).then(c =>
|
||||
c.match('/index.html').then(hit => hit ?? c.match('/'))
|
||||
)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-origin static assets (hashed JS/CSS/images): cache-first
|
||||
if (url.startsWith(self.location.origin)) {
|
||||
e.respondWith(
|
||||
caches.open(SHELL).then(c =>
|
||||
c.match(request).then(hit => {
|
||||
if (hit) return hit;
|
||||
return fetch(request).then(res => {
|
||||
if (res.ok) {
|
||||
const copy = res.clone();
|
||||
e.waitUntil(c.put(request, copy).catch(() => {}));
|
||||
}
|
||||
return res;
|
||||
});
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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" "$@"
|
||||
@@ -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/');
|
||||
@@ -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,32 @@
|
||||
// Everything one account holds on the server, in the same file format the app's own "Download backup"
|
||||
// makes, so it can be restored from Settings on any device.
|
||||
export const BACKUP_APP = 'bible-study-project';
|
||||
export const BACKUP_VERSION = 1;
|
||||
|
||||
// `store` supplies the account's data: { projects(), prefs(), marks(), ink(), plans() }.
|
||||
export function buildAccountExport(user, store, now = new Date()) {
|
||||
const prefs = store.prefs() ?? {};
|
||||
const marks = store.marks() ?? {};
|
||||
const plans = store.plans() ?? {};
|
||||
return {
|
||||
app: BACKUP_APP,
|
||||
version: BACKUP_VERSION,
|
||||
exportedAt: now.toISOString(),
|
||||
account: user.email,
|
||||
source: 'server',
|
||||
projects: store.projects(),
|
||||
reader: {
|
||||
bookmarks: marks.bookmarks ?? {},
|
||||
highlights: marks.highlights ?? [],
|
||||
colorLabels: prefs.colorLabels?.v ?? {},
|
||||
ink: store.ink() ?? {},
|
||||
},
|
||||
readingPlans: {
|
||||
progress: plans.progress ?? [],
|
||||
customPlans: plans.customPlans ?? [],
|
||||
activity: plans.activity ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const accountExportName = (now = new Date()) => `bible-study-account-${now.toISOString().slice(0, 10)}.json`;
|
||||
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { buildAccountExport, accountExportName, BACKUP_APP } from './accountExport.js';
|
||||
import { listSnapshots, pruneSnapshots } from './backups.js';
|
||||
import { mkdtempSync, writeFileSync, readdirSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
test('the account export has the same shape as the in-app backup', () => {
|
||||
const out = buildAccountExport({ email: 'a@b.co' }, {
|
||||
projects: () => [{ id: 'p', title: 'T', chapters: [] }],
|
||||
prefs: () => ({ colorLabels: { v: { '#fef08a': 'Promises' }, at: 1 } }),
|
||||
marks: () => ({ bookmarks: { 'JHN-3-16': '#fff' }, highlights: [{ id: 'h' }] }),
|
||||
ink: () => ({ JHN_3: [[1, 2]] }),
|
||||
plans: () => ({ progress: [{ id: 'x' }] }),
|
||||
}, new Date('2026-10-06T10:00:00Z'));
|
||||
expect(out.app).toBe(BACKUP_APP);
|
||||
expect(out.account).toBe('a@b.co');
|
||||
expect(out.reader.colorLabels).toEqual({ '#fef08a': 'Promises' });
|
||||
expect(out.reader.bookmarks['JHN-3-16']).toBe('#fff');
|
||||
expect(out.readingPlans).toEqual({ progress: [{ id: 'x' }], customPlans: [], activity: [] });
|
||||
expect(accountExportName(new Date('2026-10-06T10:00:00Z'))).toBe('bible-study-account-2026-10-06.json');
|
||||
});
|
||||
|
||||
test('only the newest snapshots are kept, and always at least one', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'snap-'));
|
||||
['2026-10-01-03-00', '2026-10-02-03-00', '2026-10-03-03-00', '2026-10-04-03-00'].forEach((d) => writeFileSync(join(dir, `projects-${d}.db`), 'x'));
|
||||
writeFileSync(join(dir, 'notes.txt'), 'not a snapshot');
|
||||
expect(listSnapshots(dir).map((s) => s.name)[0]).toBe('projects-2026-10-04-03-00.db');
|
||||
expect(pruneSnapshots(2, dir)).toEqual(['projects-2026-10-02-03-00.db', 'projects-2026-10-01-03-00.db']);
|
||||
expect(pruneSnapshots(0, dir)).toHaveLength(1); // keep at least the newest
|
||||
expect(readdirSync(dir).sort()).toEqual(['notes.txt', 'projects-2026-10-04-03-00.db']);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
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');
|
||||
}
|
||||
|
||||
// A one-time password-reset code: 8 characters from an alphabet without look-alikes (no 0/O, 1/I/L).
|
||||
const RESET_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
export function generateResetCode() {
|
||||
return Array.from(randomBytes(8), (b) => RESET_ALPHABET[b % RESET_ALPHABET.length]).join('');
|
||||
}
|
||||
export const normalizeResetCode = (code) => String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
@@ -0,0 +1,45 @@
|
||||
// Automatic daily snapshots of the whole database (every account), kept next to it in DATA_DIR/backups.
|
||||
// A snapshot is a complete, consistent copy made while the app keeps running, and is restored with
|
||||
// scripts/restore-data.sh. BACKUP_KEEP sets how many to keep (default 7); BACKUP_DISABLED=1 turns it off.
|
||||
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { backupDatabase, dataDir } from './db.js';
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
export const backupDir = () => join(dataDir(), 'backups');
|
||||
const stamp = (d) => d.toISOString().replace(/[:T]/g, '-').slice(0, 16);
|
||||
const isSnapshot = (name) => /^projects-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}\.db$/.test(name);
|
||||
|
||||
export function listSnapshots(dir = backupDir()) {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir).filter(isSnapshot).sort().reverse()
|
||||
.map((name) => ({ name, bytes: statSync(join(dir, name)).size, at: statSync(join(dir, name)).mtimeMs }));
|
||||
}
|
||||
|
||||
// Deletes all but the newest `keep` snapshots; returns the names removed.
|
||||
export function pruneSnapshots(keep = 7, dir = backupDir()) {
|
||||
const old = listSnapshots(dir).slice(Math.max(1, keep));
|
||||
old.forEach((s) => unlinkSync(join(dir, s.name)));
|
||||
return old.map((s) => s.name);
|
||||
}
|
||||
|
||||
export async function runBackup(now = new Date()) {
|
||||
const dir = backupDir();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = join(dir, `projects-${stamp(now)}.db`);
|
||||
await backupDatabase(file);
|
||||
pruneSnapshots(Number(process.env.BACKUP_KEEP) || 7, dir);
|
||||
return file;
|
||||
}
|
||||
|
||||
// Takes a snapshot when the newest is more than a day old (checked hourly, and once at start-up).
|
||||
export function startBackupScheduler() {
|
||||
if (process.env.BACKUP_DISABLED === '1') return;
|
||||
const tick = () => {
|
||||
const latest = listSnapshots()[0];
|
||||
if (latest && Date.now() - latest.at < DAY) return;
|
||||
runBackup().then((f) => console.log(`Database snapshot saved: ${f}`)).catch((err) => console.error('Database snapshot failed:', err.message));
|
||||
};
|
||||
setTimeout(tick, 30_000).unref();
|
||||
setInterval(tick, 60 * 60 * 1000).unref();
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Proxy for licensed translations (NLT, CSB, NASB) served by api.bible.
|
||||
// The API key stays on the server (API_BIBLE_KEY, loaded from .env if present);
|
||||
// the browser only ever talks to /api/bible/...
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Minimal .env loader so the key doesn't have to be exported in every launcher
|
||||
// (setup.sh, systemd, docker). Real environment variables win.
|
||||
const envPath = join(__dirname, '..', '.env');
|
||||
if (existsSync(envPath)) {
|
||||
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
|
||||
}
|
||||
}
|
||||
|
||||
const API_BASE = 'https://rest.api.bible/v1';
|
||||
|
||||
// Canonical book order (index + 1 = the book number the ESV API uses in ranges).
|
||||
const BOOK_ORDER = ['GEN','EXO','LEV','NUM','DEU','JOS','JDG','RUT','1SA','2SA','1KI','2KI','1CH','2CH','EZR','NEH','EST','JOB','PSA','PRO','ECC','SNG','ISA','JER','LAM','EZK','DAN','HOS','JOL','AMO','OBA','JON','MIC','NAM','HAB','ZEP','HAG','ZEC','MAL','MAT','MRK','LUK','JHN','ACT','ROM','1CO','2CO','GAL','EPH','PHP','COL','1TH','2TH','1TI','2TI','TIT','PHM','HEB','JAS','1PE','2PE','1JN','2JN','3JN','JUD','REV'];
|
||||
|
||||
// ESV search returns "Psalm 23:1"-style references; map the names back to abbreviations.
|
||||
const ESV_NAMES = ['Genesis','Exodus','Leviticus','Numbers','Deuteronomy','Joshua','Judges','Ruth','1 Samuel','2 Samuel','1 Kings','2 Kings','1 Chronicles','2 Chronicles','Ezra','Nehemiah','Esther','Job','Psalm','Proverbs','Ecclesiastes','Song of Solomon','Isaiah','Jeremiah','Lamentations','Ezekiel','Daniel','Hosea','Joel','Amos','Obadiah','Jonah','Micah','Nahum','Habakkuk','Zephaniah','Haggai','Zechariah','Malachi','Matthew','Mark','Luke','John','Acts','Romans','1 Corinthians','2 Corinthians','Galatians','Ephesians','Philippians','Colossians','1 Thessalonians','2 Thessalonians','1 Timothy','2 Timothy','Titus','Philemon','Hebrews','James','1 Peter','2 Peter','1 John','2 John','3 John','Jude','Revelation'];
|
||||
const ABBREV_BY_NAME = new Map(ESV_NAMES.map((n, i) => [n.toLowerCase(), BOOK_ORDER[i]]));
|
||||
ABBREV_BY_NAME.set('psalms', 'PSA');
|
||||
ABBREV_BY_NAME.set('song of songs', 'SNG');
|
||||
|
||||
export function parseEsvReference(ref) {
|
||||
const m = String(ref).match(/^(.+?)\s+(\d+):(\d+)/);
|
||||
const bookAbbrev = m && ABBREV_BY_NAME.get(m[1].toLowerCase());
|
||||
return bookAbbrev ? { bookAbbrev, chapter: Number(m[2]), verse: Number(m[3]) } : null;
|
||||
}
|
||||
|
||||
export const LICENSED_TRANSLATIONS = {
|
||||
ESV: { provider: 'esv', name: 'English Standard Version' },
|
||||
NLT: { provider: 'api.bible', bibleId: 'd6e14a625393b4da-01', name: 'New Living Translation' },
|
||||
CSB: { provider: 'api.bible', bibleId: 'a556c5305ee15c3f-01', name: 'Christian Standard Bible' },
|
||||
NASB: { provider: 'api.bible', bibleId: 'b8ee27bcd1cae43a-01', name: 'New American Standard Bible (1995)' },
|
||||
};
|
||||
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
||||
const CACHE_MAX = 1000;
|
||||
// Crossway limits how much ESV text an app may store, so ESV chapters are only
|
||||
// held briefly and in small numbers (enough to absorb repeat loads).
|
||||
const ESV_CACHE_TTL = 60 * 60 * 1000;
|
||||
const ESV_CACHE_MAX = 20;
|
||||
const cache = new Map(); // "NLT/PSA/23" -> { at, payload }
|
||||
|
||||
// api.bible returns plain text with inline "[12]" verse markers, poetry line
|
||||
// breaks, and stray "#" / nbsp characters. Split it back into { number, text }.
|
||||
export function parseChapterText(content) {
|
||||
const clean = String(content ?? '')
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/#/g, '');
|
||||
const parts = clean.split(/\[(\d+)\]/);
|
||||
const verses = [];
|
||||
for (let i = 1; i < parts.length; i += 2) {
|
||||
const text = parts[i + 1].replace(/\s+/g, ' ').trim().replace(/\s+,$/, '');
|
||||
if (text) verses.push({ number: Number(parts[i]), text });
|
||||
}
|
||||
return verses;
|
||||
}
|
||||
|
||||
export function registerBibleRoutes(app, requireAuth) {
|
||||
app.get('/api/bible/translations', requireAuth, (_req, res) => {
|
||||
res.json(Object.entries(LICENSED_TRANSLATIONS).map(([id, t]) => ({
|
||||
id, name: t.name, available: Boolean(t.provider === 'esv' ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY),
|
||||
})));
|
||||
});
|
||||
|
||||
// Whole-Bible search for the licensed translations, proxied so the keys stay
|
||||
// server-side. Results are { bookAbbrev, chapter, verse, text }; nothing is cached.
|
||||
const SEARCH_PAGE_SIZE = 25;
|
||||
app.get('/api/bible/:translation/search', requireAuth, async (req, res) => {
|
||||
const id = req.params.translation.toUpperCase();
|
||||
const translation = LICENSED_TRANSLATIONS[id];
|
||||
const q = String(req.query.q ?? '').trim();
|
||||
const page = Math.max(1, Math.min(200, parseInt(req.query.page, 10) || 1));
|
||||
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
|
||||
if (q.length < 2 || q.length > 100) return res.status(400).json({ error: 'Search must be 2 to 100 characters.' });
|
||||
const isEsv = translation.provider === 'esv';
|
||||
const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY;
|
||||
if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` });
|
||||
|
||||
try {
|
||||
let results;
|
||||
let total;
|
||||
if (isEsv) {
|
||||
const qs = new URLSearchParams({ q, 'page-size': String(SEARCH_PAGE_SIZE), page: String(page) });
|
||||
const upstream = await fetch(`https://api.esv.org/v3/passage/search/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } });
|
||||
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
|
||||
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||
const body = await upstream.json();
|
||||
total = body.total_results ?? 0;
|
||||
results = (body.results ?? []).map((r) => {
|
||||
const ref = parseEsvReference(r.reference);
|
||||
return ref && { ...ref, text: String(r.content ?? '').replace(/<[^>]+>/g, '').trim() };
|
||||
}).filter(Boolean);
|
||||
} else {
|
||||
const qs = new URLSearchParams({ query: q, limit: String(SEARCH_PAGE_SIZE), offset: String((page - 1) * SEARCH_PAGE_SIZE), sort: 'canonical' });
|
||||
const upstream = await fetch(`${API_BASE}/bibles/${translation.bibleId}/search?${qs}`, { headers: { 'api-key': apiKey } });
|
||||
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||
const body = await upstream.json();
|
||||
total = body.data?.total ?? 0;
|
||||
results = (body.data?.verses ?? []).map((v) => ({
|
||||
bookAbbrev: v.bookId,
|
||||
chapter: Number(String(v.chapterId).split('.')[1]),
|
||||
verse: Number(String(v.id).split('.')[2]),
|
||||
text: String(v.text ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(),
|
||||
})).filter((r) => r.bookAbbrev && r.chapter && r.verse);
|
||||
}
|
||||
res.json({ translation: id, query: q, page, total, hasMore: page * SEARCH_PAGE_SIZE < total, results });
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach the Bible provider.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ESV chapter audio (read by David Cochran Heath). The ESV API answers with a
|
||||
// redirect to a public mp3; hand that URL to the browser's <audio> element.
|
||||
app.get('/api/bible/ESV/audio/:book/:chapter', requireAuth, async (req, res) => {
|
||||
const { book, chapter } = req.params;
|
||||
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
|
||||
if (!bookNum || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' });
|
||||
if (!process.env.ESV_API_KEY) return res.status(503).json({ error: 'ESV_API_KEY is not configured on the server.' });
|
||||
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
|
||||
try {
|
||||
const upstream = await fetch(`https://api.esv.org/v3/passage/audio/?q=${prefix}001-${prefix}999`, {
|
||||
headers: { Authorization: `Token ${process.env.ESV_API_KEY}` }, redirect: 'manual',
|
||||
});
|
||||
const url = upstream.headers.get('location');
|
||||
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
|
||||
if (!url) return res.status(502).json({ error: 'ESV audio is not available for this chapter.' });
|
||||
res.json({ url });
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach the ESV audio service.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/bible/:translation/:book/:chapter', requireAuth, async (req, res) => {
|
||||
const translation = LICENSED_TRANSLATIONS[req.params.translation.toUpperCase()];
|
||||
const { book, chapter } = req.params;
|
||||
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
|
||||
if (!/^[1-3A-Z]{3}$/i.test(book) || !/^\d{1,3}$/.test(chapter)) {
|
||||
return res.status(400).json({ error: 'Invalid book or chapter.' });
|
||||
}
|
||||
const id = req.params.translation.toUpperCase();
|
||||
const isEsv = translation.provider === 'esv';
|
||||
const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY;
|
||||
if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` });
|
||||
|
||||
const key = `${id}/${book.toUpperCase()}/${chapter}`;
|
||||
const ttl = isEsv ? ESV_CACHE_TTL : CACHE_TTL;
|
||||
const hit = cache.get(key);
|
||||
if (hit && Date.now() - hit.at < ttl) return res.json(hit.payload);
|
||||
|
||||
try {
|
||||
let payload;
|
||||
if (isEsv) {
|
||||
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
|
||||
if (!bookNum) return res.status(400).json({ error: 'Invalid book.' });
|
||||
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
|
||||
const qs = new URLSearchParams({
|
||||
q: `${prefix}001-${prefix}999`,
|
||||
'include-passage-references': 'false', 'include-verse-numbers': 'true', 'include-first-verse-numbers': 'true',
|
||||
'include-footnotes': 'false', 'include-headings': 'false', 'include-short-copyright': 'false',
|
||||
'include-passage-horizontal-lines': 'false', 'include-heading-horizontal-lines': 'false',
|
||||
});
|
||||
const upstream = await fetch(`https://api.esv.org/v3/passage/text/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } });
|
||||
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
|
||||
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||
const body = await upstream.json();
|
||||
const verses = parseChapterText(body.passages?.[0]);
|
||||
if (verses.length === 0) return res.status(404).json({ error: 'Chapter not found.' });
|
||||
payload = { translation: id, verses, copyright: '', fumsToken: null };
|
||||
} else {
|
||||
const qs = new URLSearchParams({
|
||||
'content-type': 'text', 'include-notes': 'false', 'include-titles': 'false',
|
||||
'include-chapter-numbers': 'false', 'include-verse-numbers': 'true', 'include-verse-spans': 'false',
|
||||
});
|
||||
const upstream = await fetch(
|
||||
`${API_BASE}/bibles/${translation.bibleId}/chapters/${book.toUpperCase()}.${chapter}?${qs}`,
|
||||
{ headers: { 'api-key': apiKey } },
|
||||
);
|
||||
if (upstream.status === 404) return res.status(404).json({ error: 'Chapter not found.' });
|
||||
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
|
||||
const body = await upstream.json();
|
||||
const verses = parseChapterText(body.data?.content);
|
||||
if (verses.length === 0) return res.status(502).json({ error: 'Bible provider returned no verses.' });
|
||||
payload = { translation: id, verses, copyright: body.data?.copyright ?? '', fumsToken: body.meta?.fumsToken ?? null };
|
||||
}
|
||||
const max = isEsv ? ESV_CACHE_MAX : CACHE_MAX;
|
||||
if (cache.size >= CACHE_MAX + ESV_CACHE_MAX) cache.delete(cache.keys().next().value);
|
||||
if (isEsv) {
|
||||
const esvKeys = [...cache.keys()].filter((k) => k.startsWith('ESV/'));
|
||||
if (esvKeys.length >= max) cache.delete(esvKeys[0]);
|
||||
}
|
||||
cache.set(key, { at: Date.now(), payload });
|
||||
res.json(payload);
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach the Bible provider.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { parseChapterText, parseEsvReference } from './bibleApi.js';
|
||||
|
||||
test('parseChapterText splits inline verse markers and cleans provider artifacts', () => {
|
||||
const text = ' [1] The Lord is my shepherd;\n I have what I need.\n [2] He lets me lie down #— in pastures, ,\n';
|
||||
expect(parseChapterText(text)).toEqual([
|
||||
{ number: 1, text: 'The Lord is my shepherd; I have what I need.' },
|
||||
{ number: 2, text: 'He lets me lie down — in pastures,' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseChapterText ignores headings before the first verse', () => {
|
||||
expect(parseChapterText('Psalm 23\n\nA Psalm of David.\n\n[1] The LORD is my shepherd;')).toEqual([
|
||||
{ number: 1, text: 'The LORD is my shepherd;' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseEsvReference maps ESV book names back to abbreviations', () => {
|
||||
expect(parseEsvReference('Psalm 23:1')).toEqual({ bookAbbrev: 'PSA', chapter: 23, verse: 1 });
|
||||
expect(parseEsvReference('1 John 3:16-18')).toEqual({ bookAbbrev: '1JN', chapter: 3, verse: 16 });
|
||||
expect(parseEsvReference('Song of Solomon 2:4')).toEqual({ bookAbbrev: 'SNG', chapter: 2, verse: 4 });
|
||||
expect(parseEsvReference('Not a book 1:1')).toBeNull();
|
||||
});
|
||||
@@ -9,8 +9,15 @@ const DB_PATH = join(DATA_DIR, 'projects.db');
|
||||
|
||||
let db;
|
||||
|
||||
export const dataDir = () => DATA_DIR;
|
||||
|
||||
/** A consistent copy of the whole database, made while it is in use. */
|
||||
export function backupDatabase(destPath) {
|
||||
return db.backup(destPath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 +34,117 @@ export function initDb() {
|
||||
chapter_summary TEXT,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
sess TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Older databases predate multi-user support — add the ownership column.
|
||||
const projectCols = db.prepare('PRAGMA table_info(projects)').all();
|
||||
if (!projectCols.some((c) => c.name === 'user_id')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN user_id TEXT REFERENCES users(id)');
|
||||
}
|
||||
|
||||
// Older databases predate 2FA support — add the TOTP columns.
|
||||
const userCols = db.prepare('PRAGMA table_info(users)').all();
|
||||
if (!userCols.some((c) => c.name === 'totp_secret')) {
|
||||
db.exec(`
|
||||
ALTER TABLE users ADD COLUMN totp_secret TEXT;
|
||||
ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN backup_codes TEXT;
|
||||
`);
|
||||
}
|
||||
|
||||
// Older databases predate the podcast-name profile setting.
|
||||
if (!userCols.some((c) => c.name === 'podcast_name')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT');
|
||||
}
|
||||
|
||||
// Older databases predate ntfy.sh daily reading reminders.
|
||||
if (!userCols.some((c) => c.name === 'ntfy_url')) {
|
||||
db.exec(`
|
||||
ALTER TABLE users ADD COLUMN ntfy_url TEXT;
|
||||
ALTER TABLE users ADD COLUMN ntfy_topic TEXT;
|
||||
ALTER TABLE users ADD COLUMN reminder_time TEXT;
|
||||
ALTER TABLE users ADD COLUMN reminder_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN last_reminder_date TEXT;
|
||||
`);
|
||||
}
|
||||
|
||||
// Reminders fire at the user's own local time, so remember their timezone.
|
||||
if (!userCols.some((c) => c.name === 'reminder_tz')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN reminder_tz TEXT');
|
||||
}
|
||||
|
||||
// Older databases predate shareable read-only links.
|
||||
if (!projectCols.some((c) => c.name === 'share_token')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
|
||||
}
|
||||
if (!projectCols.some((c) => c.name === 'outline_share_token')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
|
||||
}
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
|
||||
|
||||
// One-time codes for "forgot password": hashed, short-lived, limited tries.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS password_resets (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Reader ink — one row per user + book + chapter, stored as JSON
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reader_ink (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
book_abbrev TEXT NOT NULL,
|
||||
chapter INTEGER NOT NULL,
|
||||
strokes TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, book_abbrev, chapter)
|
||||
);
|
||||
`);
|
||||
|
||||
// Display preferences (theme, default translation, layout, ...) — one row per user.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Reader bookmarks + text highlights — one row per user (small JSON), so they follow the
|
||||
// user across devices. updated_at lets a device tell whether the server copy is newer.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reader_marks (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Reading plan progress — one row per user, holding the whole list of
|
||||
// in-progress/completed plans as JSON (small; no need to normalize).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reading_progress (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
console.log(`SQLite database ready at ${DB_PATH}`);
|
||||
@@ -42,26 +160,377 @@ function buildSummary(project) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// Users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function countUsers() {
|
||||
return db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
|
||||
}
|
||||
|
||||
export function createUser({ id, email, passwordHash }) {
|
||||
const createdAt = Date.now();
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, email, password_hash, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(id, email, passwordHash, createdAt);
|
||||
return { id, email, createdAt };
|
||||
}
|
||||
|
||||
function parseUserRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
totpEnabled: !!row.totpEnabled,
|
||||
reminderEnabled: !!row.reminderEnabled,
|
||||
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
|
||||
};
|
||||
}
|
||||
|
||||
const USER_SELECT = `
|
||||
SELECT id, email, password_hash AS passwordHash, created_at AS createdAt,
|
||||
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
|
||||
podcast_name AS podcastName,
|
||||
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
|
||||
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz
|
||||
FROM users
|
||||
`;
|
||||
|
||||
export function getUserByEmail(email) {
|
||||
return parseUserRow(db.prepare(`${USER_SELECT} WHERE email = ?`).get(email));
|
||||
}
|
||||
|
||||
export function getUserById(id) {
|
||||
return parseUserRow(db.prepare(`${USER_SELECT} WHERE id = ?`).get(id));
|
||||
}
|
||||
|
||||
/** Persists a confirmed TOTP secret + one-time backup code hashes, turning 2FA on. */
|
||||
export function enableTotp(userId, secret, backupCodeHashes) {
|
||||
db.prepare(`
|
||||
UPDATE users SET totp_secret = ?, totp_enabled = 1, backup_codes = ? WHERE id = ?
|
||||
`).run(secret, JSON.stringify(backupCodeHashes), userId);
|
||||
}
|
||||
|
||||
/** Turns 2FA off and forgets the secret/backup codes entirely. */
|
||||
export function disableTotp(userId) {
|
||||
db.prepare(`
|
||||
UPDATE users SET totp_secret = NULL, totp_enabled = 0, backup_codes = NULL WHERE id = ?
|
||||
`).run(userId);
|
||||
}
|
||||
|
||||
/** Sets or clears the show name used in the "Prepare for Podcast" prompt. */
|
||||
export function setPodcastName(userId, podcastName) {
|
||||
db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daily reading reminders (sent via ntfy.sh — see server/reminders.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Saves a user's ntfy reminder settings ({ ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }). */
|
||||
export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime, reminderEnabled, reminderTz }) {
|
||||
db.prepare(`
|
||||
UPDATE users SET ntfy_url = ?, ntfy_topic = ?, reminder_time = ?, reminder_enabled = ?, reminder_tz = ?
|
||||
WHERE id = ?
|
||||
`).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, reminderTz || null, userId);
|
||||
}
|
||||
|
||||
/** Every account with reminders turned on and a topic configured — checked once a minute. */
|
||||
export function getUsersWithRemindersEnabled() {
|
||||
return db.prepare(`
|
||||
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
|
||||
last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz
|
||||
FROM users
|
||||
WHERE reminder_enabled = 1 AND ntfy_topic IS NOT NULL AND reminder_time IS NOT NULL
|
||||
`).all();
|
||||
}
|
||||
|
||||
/** Marks that a user's reminder was sent today, so it isn't sent twice. */
|
||||
export function setLastReminderDate(userId, dateString) {
|
||||
db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Password reset codes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setPasswordReset(userId, codeHash, expires) {
|
||||
db.prepare(`
|
||||
INSERT INTO password_resets (user_id, code_hash, expires, attempts) VALUES (?, ?, ?, 0)
|
||||
ON CONFLICT(user_id) DO UPDATE SET code_hash = excluded.code_hash, expires = excluded.expires, attempts = 0
|
||||
`).run(userId, codeHash, expires);
|
||||
}
|
||||
|
||||
export function getPasswordReset(userId) {
|
||||
const row = db.prepare('SELECT code_hash AS codeHash, expires, attempts FROM password_resets WHERE user_id = ?').get(userId);
|
||||
return row && row.expires > Date.now() ? row : null;
|
||||
}
|
||||
|
||||
export function bumpPasswordResetAttempts(userId) {
|
||||
db.prepare('UPDATE password_resets SET attempts = attempts + 1 WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
export function clearPasswordReset(userId) {
|
||||
db.prepare('DELETE FROM password_resets WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
|
||||
export function setBackupCodeHashes(userId, backupCodeHashes) {
|
||||
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader ink — cross-device sync for draw-mode annotations in the reader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns all saved reader ink pages for a user as { "BOOK_CH": strokes[] }. */
|
||||
export function getAllReaderInk(userId) {
|
||||
const rows = db.prepare(
|
||||
'SELECT book_abbrev AS book, chapter, strokes FROM reader_ink WHERE user_id = ?'
|
||||
).all(userId);
|
||||
return Object.fromEntries(
|
||||
rows.map((r) => [`${r.book}_${r.chapter}`, JSON.parse(r.strokes)])
|
||||
);
|
||||
}
|
||||
|
||||
/** The user's synced preferences: { key: { v, at } } (empty when none saved yet). */
|
||||
export function getUserPrefs(userId) {
|
||||
const row = db.prepare('SELECT data FROM user_prefs WHERE user_id = ?').get(userId);
|
||||
return row ? JSON.parse(row.data) : {};
|
||||
}
|
||||
|
||||
/** Replaces the user's stored preferences with `prefs` ({ key: { v, at } }). */
|
||||
export function setUserPrefs(userId, prefs) {
|
||||
db.prepare(`
|
||||
INSERT INTO user_prefs (user_id, data, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||
`).run(userId, JSON.stringify(prefs), Date.now());
|
||||
}
|
||||
|
||||
/** The user's bookmarks + highlights: { bookmarks, highlights, updatedAt } (updatedAt 0 = never saved). */
|
||||
export function getReaderMarks(userId) {
|
||||
const row = db.prepare('SELECT data, updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId);
|
||||
if (!row) return { bookmarks: {}, highlights: [], updatedAt: 0 };
|
||||
const data = JSON.parse(row.data);
|
||||
return { bookmarks: data.bookmarks ?? {}, highlights: data.highlights ?? [], updatedAt: row.updatedAt };
|
||||
}
|
||||
|
||||
/** Replaces the user's bookmarks + highlights; returns the new updatedAt (always moves forward). */
|
||||
export function setReaderMarks(userId, { bookmarks, highlights }) {
|
||||
const prev = db.prepare('SELECT updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId);
|
||||
const updatedAt = Math.max(Date.now(), (prev?.updatedAt ?? 0) + 1);
|
||||
db.prepare(`
|
||||
INSERT INTO reader_marks (user_id, data, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||
`).run(userId, JSON.stringify({ bookmarks, highlights }), updatedAt);
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
/** Upserts the ink strokes for one reader page. */
|
||||
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
|
||||
db.prepare(`
|
||||
INSERT INTO reader_ink (user_id, book_abbrev, chapter, strokes, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, book_abbrev, chapter)
|
||||
DO UPDATE SET strokes = excluded.strokes, updated_at = excluded.updated_at
|
||||
`).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reading plan progress — cross-device sync for started/completed reading plans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns the user's reading-plan data ({ progress, customPlans }, parsed JSON), or an empty shell. */
|
||||
export function getReadingProgress(userId) {
|
||||
const row = db.prepare('SELECT data FROM reading_progress WHERE user_id = ?').get(userId);
|
||||
if (!row) return { progress: [], customPlans: [] };
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
} catch {
|
||||
return { progress: [], customPlans: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Overwrites the user's whole reading-plan progress list. */
|
||||
export function setReadingProgress(userId, progress) {
|
||||
db.prepare(`
|
||||
INSERT INTO reading_progress (user_id, data, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||
`).run(userId, JSON.stringify(progress), Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns any pre-existing, unowned projects (from before multi-user support)
|
||||
* to the given user. Intended to run once, right after the first account is created.
|
||||
*/
|
||||
export function claimOrphanProjects(userId) {
|
||||
db.prepare('UPDATE projects SET user_id = ? WHERE user_id IS NULL').run(userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project queries — all scoped to the owning user
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns all project summaries (id, title, lastEdited, chapterSummary).
|
||||
* Returns all project summaries owned by userId (id, title, lastEdited, chapterSummary).
|
||||
* Does NOT return full project data to keep the response small.
|
||||
*/
|
||||
export function getAllProjects() {
|
||||
export function getAllProjects(userId) {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary
|
||||
FROM projects
|
||||
WHERE user_id = ?
|
||||
ORDER BY last_edited DESC
|
||||
`).all();
|
||||
`).all(userId);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single full project by id, or null if not found.
|
||||
* Returns a single full project by id, scoped to userId, or null if not found/not owned.
|
||||
*/
|
||||
export function getProject(id) {
|
||||
export function getProject(id, userId) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */
|
||||
export function getProjectEdited(id, userId) {
|
||||
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
return row ? row.lastEdited : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or replace a project owned by userId.
|
||||
* Returns the summary, or null if the id already belongs to a different user.
|
||||
*/
|
||||
export function upsertProject(project, userId) {
|
||||
const existing = db.prepare('SELECT user_id AS userId FROM projects WHERE id = ?').get(project.id);
|
||||
if (existing && existing.userId !== userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The server's clock decides the version, so a device with a wrong clock can't hide or fake changes.
|
||||
const prev = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(project.id);
|
||||
const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1);
|
||||
const chapterSummary = buildSummary(project);
|
||||
const updated = { ...project, lastEdited };
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO projects (id, title, last_edited, chapter_summary, data, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
last_edited = excluded.last_edited,
|
||||
chapter_summary = excluded.chapter_summary,
|
||||
data = excluded.data
|
||||
`).run(project.id, project.title, lastEdited, chapterSummary, JSON.stringify(updated), userId);
|
||||
|
||||
return { id: project.id, title: project.title, lastEdited, chapterSummary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project by id, scoped to userId. No-op if not found/not owned.
|
||||
*/
|
||||
export function deleteProject(id, userId) {
|
||||
db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only share links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns the current share token for a project owned by userId, or null. */
|
||||
export function getShareToken(id, userId) {
|
||||
const row = db.prepare('SELECT share_token AS shareToken FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
return row?.shareToken ?? null;
|
||||
}
|
||||
|
||||
/** Sets a project's share token (enabling its public read-only link), scoped to userId. */
|
||||
export function setShareToken(id, userId, token) {
|
||||
const result = db.prepare('UPDATE projects SET share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/** Revokes a project's share link, scoped to userId. */
|
||||
export function clearShareToken(id, userId) {
|
||||
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
// ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ----
|
||||
|
||||
export function getOutlineShareToken(id, userId) {
|
||||
const row = db.prepare('SELECT outline_share_token AS t FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
return row?.t ?? null;
|
||||
}
|
||||
|
||||
export function setOutlineShareToken(id, userId, token) {
|
||||
return db.prepare('UPDATE projects SET outline_share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId).changes > 0;
|
||||
}
|
||||
|
||||
export function clearOutlineShareToken(id, userId) {
|
||||
db.prepare('UPDATE projects SET outline_share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
|
||||
export function getProjectByOutlineToken(token) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE outline_share_token = ?').get(token);
|
||||
if (!row) return null;
|
||||
try { return JSON.parse(row.data); } catch { return null; }
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
// projects.user_id is a foreign key, so release the projects first (they stay as unowned rows).
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE projects SET user_id = NULL, share_token = NULL, outline_share_token = NULL WHERE user_id = ?').run(userId);
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Every project across every user, with the owner's email, for the admin projects list. */
|
||||
export function adminGetAllProjects() {
|
||||
return db.prepare(`
|
||||
SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary,
|
||||
p.share_token AS shareToken, u.email AS ownerEmail
|
||||
FROM projects p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
ORDER BY p.last_edited DESC
|
||||
`).all();
|
||||
}
|
||||
|
||||
/** Full project data by id, regardless of owner — for admin inspection. */
|
||||
export function adminGetProject(id) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id);
|
||||
if (!row) return null;
|
||||
try {
|
||||
@@ -71,30 +540,66 @@ export function getProject(id) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or replace a project. Returns the summary.
|
||||
*/
|
||||
export function upsertProject(project) {
|
||||
const lastEdited = project.lastEdited ?? Date.now();
|
||||
const chapterSummary = buildSummary(project);
|
||||
const updated = { ...project, lastEdited };
|
||||
/** 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 projects (id, title, last_edited, chapter_summary, data)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
last_edited = excluded.last_edited,
|
||||
chapter_summary = excluded.chapter_summary,
|
||||
data = excluded.data
|
||||
`).run(project.id, project.title, lastEdited, chapterSummary, JSON.stringify(updated));
|
||||
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);
|
||||
}
|
||||
|
||||
return { id: project.id, title: project.title, lastEdited, chapterSummary };
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project by id. No-op if not found.
|
||||
* 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 deleteProject(id) {
|
||||
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -1,13 +1,117 @@
|
||||
import express from 'express';
|
||||
import compression from 'compression';
|
||||
import session from 'express-session';
|
||||
import QRCode from 'qrcode';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { initDb, getAllProjects, getProject, upsertProject, deleteProject } from './db.js';
|
||||
import {
|
||||
initDb, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
|
||||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||
getOutlineShareToken, setOutlineShareToken, clearOutlineShareToken, getProjectByOutlineToken,
|
||||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||
setUserPassword, destroyAllSessionsForUser,
|
||||
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
|
||||
getReadingProgress, setReadingProgress,
|
||||
setReminderSettings,
|
||||
setPasswordReset, getPasswordReset, bumpPasswordResetAttempts, clearPasswordReset,
|
||||
} from './db.js';
|
||||
import { SqliteSessionStore } from './sessionStore.js';
|
||||
import {
|
||||
isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail,
|
||||
generateTotpSecret, totpKeyUri, verifyTotpToken,
|
||||
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
|
||||
generateResetCode, normalizeResetCode,
|
||||
} from './auth.js';
|
||||
import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js';
|
||||
import { registerBibleRoutes } from './bibleApi.js';
|
||||
import { registerIqBibleRoutes } from './iqbible.js';
|
||||
import { buildAccountExport, accountExportName } from './accountExport.js';
|
||||
import { startBackupScheduler } from './backups.js';
|
||||
import { cleanMarks } from './readerMarks.js';
|
||||
import { cleanPrefsPatch, mergePrefs } from './prefs.js';
|
||||
import { isSaveConflict, parseBase } from './projectSync.js';
|
||||
import { buildOutlinePayload } from './outlineShare.js';
|
||||
|
||||
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
|
||||
function userPayload(user) {
|
||||
return {
|
||||
id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null,
|
||||
isAdmin: isAdminEmail(user.email),
|
||||
reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null,
|
||||
reminderTime: user.reminderTime ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (isProd && !process.env.SESSION_SECRET) {
|
||||
console.error('SESSION_SECRET is not set. Set it to a long random string before running in production.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Trust the reverse proxy (needed for secure cookies to work behind nginx/etc).
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// In-memory limiter for sign-in style endpoints: 10 FAILED attempts per 15 min, tracked per IP and per
|
||||
// account (key). Successful sign-ins don't count, so a shared IP doesn't lock everyone out.
|
||||
const _failures = new Map();
|
||||
const FAIL_WINDOW = 15 * 60 * 1000;
|
||||
const FAIL_MAX = 10;
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, e] of _failures) if (now > e.resetAt) _failures.delete(k);
|
||||
}, 60 * 60 * 1000).unref();
|
||||
function isLimited(...keys) {
|
||||
const now = Date.now();
|
||||
return keys.some((k) => { const e = _failures.get(k); return e && now <= e.resetAt && e.count >= FAIL_MAX; });
|
||||
}
|
||||
function recordFailure(...keys) {
|
||||
const now = Date.now();
|
||||
for (const k of keys) {
|
||||
const e = _failures.get(k);
|
||||
if (!e || now > e.resetAt) _failures.set(k, { count: 1, resetAt: now + FAIL_WINDOW });
|
||||
else e.count += 1;
|
||||
}
|
||||
}
|
||||
function clearFailures(...keys) { keys.forEach((k) => _failures.delete(k)); }
|
||||
const TOO_MANY = { error: 'Too many attempts. Please wait 15 minutes.' };
|
||||
|
||||
// Compared against when an email isn't registered, so a login takes the same time either way.
|
||||
const DUMMY_HASH = '$2b$10$UbGSJvxosy5oInS6zfvqM.gWOUv9ypVg81ou.jWOswHyYgUjpkhJK';
|
||||
|
||||
// TOTP codes already used (userId -> code), so a captured code can't be replayed inside its time window.
|
||||
const _usedTotp = new Map();
|
||||
function totpAlreadyUsed(userId, token) { return _usedTotp.get(userId) === token; }
|
||||
function markTotpUsed(userId, token) {
|
||||
_usedTotp.set(userId, token);
|
||||
setTimeout(() => { if (_usedTotp.get(userId) === token) _usedTotp.delete(userId); }, 90 * 1000).unref();
|
||||
}
|
||||
|
||||
// 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 +121,373 @@ 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 (isLimited(`ip:${req.ip}`)) return res.status(429).json(TOO_MANY);
|
||||
recordFailure(`ip:${req.ip}`); // every registration attempt counts
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(req.body?.password ?? '');
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return res.status(400).json({ error: 'Enter a valid email address.' });
|
||||
}
|
||||
if (!isValidPassword(password)) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
||||
}
|
||||
if (getUserByEmail(email)) {
|
||||
return res.status(409).json({ error: 'An account with that email already exists.' });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = createUser({ id: randomUUID(), email, passwordHash });
|
||||
|
||||
// The very first account inherits any projects created before multi-user support existed.
|
||||
if (countUsers() === 1) {
|
||||
claimOrphanProjects(user.id);
|
||||
}
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
res.json({
|
||||
id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email),
|
||||
reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/register error:', err);
|
||||
res.status(500).json({ error: 'Failed to register.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
try {
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(req.body?.password ?? '');
|
||||
const keys = [`ip:${req.ip}`, `acct:${email}`];
|
||||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||
|
||||
const user = getUserByEmail(email);
|
||||
const valid = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH) && !!user;
|
||||
if (!valid) {
|
||||
recordFailure(...keys);
|
||||
return res.status(401).json({ error: 'Incorrect email or password.' });
|
||||
}
|
||||
clearFailures(`acct:${email}`);
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
if (user.totpEnabled) {
|
||||
// Password is correct, but the session stays unauthenticated (no userId)
|
||||
// until a valid TOTP/backup code lands on /api/auth/mfa/verify.
|
||||
req.session.pendingUserId = user.id;
|
||||
return res.json({ mfaRequired: true });
|
||||
}
|
||||
req.session.userId = user.id;
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/login error:', err);
|
||||
res.status(500).json({ error: 'Failed to log in.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/verify', async (req, res) => {
|
||||
try {
|
||||
const pendingUserId = req.session?.pendingUserId;
|
||||
if (!pendingUserId) {
|
||||
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||||
}
|
||||
const keys = [`ip:${req.ip}`, `mfa:${pendingUserId}`];
|
||||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||
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) && !totpAlreadyUsed(user.id, String(token)) : false;
|
||||
if (ok) markTotpUsed(user.id, String(token));
|
||||
|
||||
if (!ok && backupCode) {
|
||||
const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes);
|
||||
if (remaining) {
|
||||
setBackupCodeHashes(user.id, remaining);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
recordFailure(...keys);
|
||||
return res.status(401).json({ error: 'Invalid code.' });
|
||||
}
|
||||
clearFailures(`mfa:${pendingUserId}`);
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/verify error:', err);
|
||||
res.status(500).json({ error: 'Failed to verify code.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Forgot password. There is no email here, so the one-time code goes to the account's ntfy topic (the same
|
||||
// one used for reading reminders). Accounts without a topic still go through the admin. The answer is the
|
||||
// same whether or not the account exists, so this can't be used to find out who has one.
|
||||
// ---------------------------------------------------------------------------
|
||||
const RESET_MINUTES = 15;
|
||||
const RESET_REPLY = { ok: true, message: 'If that account has a notification topic set up, a reset code was just sent to it. Codes last 15 minutes. No topic? Ask the administrator for a temporary password.' };
|
||||
|
||||
app.post('/api/auth/forgot', async (req, res) => {
|
||||
try {
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const keys = [`ip:${req.ip}`, `forgot:${email}`];
|
||||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||
recordFailure(...keys); // every request counts
|
||||
const user = isValidEmail(email) ? getUserByEmail(email) : null;
|
||||
if (user?.ntfyTopic) {
|
||||
const code = generateResetCode();
|
||||
setPasswordReset(user.id, await hashPassword(code), Date.now() + RESET_MINUTES * 60 * 1000);
|
||||
sendNtfy(user, { title: 'Password reset code', body: `Your Bible Study reset code is ${code}. It works for ${RESET_MINUTES} minutes. If you didn't ask for it, ignore this.`, tags: 'key' })
|
||||
.catch((err) => console.error('Password reset notification failed:', err.message));
|
||||
}
|
||||
res.json(RESET_REPLY);
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/forgot error:', err);
|
||||
res.status(500).json({ error: 'Failed to start the reset.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/reset', async (req, res) => {
|
||||
try {
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const keys = [`ip:${req.ip}`, `reset:${email}`];
|
||||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||
const fail = (error) => { recordFailure(...keys); return res.status(400).json({ error }); };
|
||||
const newPassword = String(req.body?.newPassword ?? '');
|
||||
if (!isValidPassword(newPassword)) return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
||||
|
||||
const user = getUserByEmail(email);
|
||||
const reset = user ? getPasswordReset(user.id) : null;
|
||||
const code = normalizeResetCode(req.body?.code);
|
||||
// Always spend one bcrypt compare, so timing doesn't show whether a code was issued
|
||||
const codeOk = await verifyPassword(code, reset?.codeHash ?? DUMMY_HASH) && !!reset && reset.attempts < 5;
|
||||
if (reset && !codeOk) bumpPasswordResetAttempts(user.id);
|
||||
if (!codeOk) return fail('That code is wrong or has expired. Ask for a new one.');
|
||||
if (user.totpEnabled) {
|
||||
const token = String(req.body?.token ?? '');
|
||||
if (!verifyTotpToken(token, user.totpSecret) || totpAlreadyUsed(user.id, token)) return fail('Enter the current 6-digit code from your authenticator app too.');
|
||||
markTotpUsed(user.id, token);
|
||||
}
|
||||
setUserPassword(user.id, await hashPassword(newPassword));
|
||||
clearPasswordReset(user.id);
|
||||
destroyAllSessionsForUser(user.id);
|
||||
clearFailures(...keys);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/reset error:', err);
|
||||
res.status(500).json({ error: 'Failed to reset the password.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||||
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/auth/profile — update account-level settings (currently just podcastName)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.patch('/api/auth/profile', requireAuth, (req, res) => {
|
||||
try {
|
||||
const podcastName = String(req.body?.podcastName ?? '').trim().slice(0, 200);
|
||||
setPodcastName(req.session.userId, podcastName || null);
|
||||
res.json({ podcastName: podcastName || null });
|
||||
} catch (err) {
|
||||
console.error('PATCH /api/auth/profile error:', err);
|
||||
res.status(500).json({ error: 'Failed to save profile.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh
|
||||
// ---------------------------------------------------------------------------
|
||||
app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const reminderEnabled = !!req.body?.reminderEnabled;
|
||||
const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim();
|
||||
const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim();
|
||||
const reminderTime = String(req.body?.reminderTime ?? '').trim();
|
||||
const reminderTz = isValidTimeZone(req.body?.reminderTz) ? String(req.body.reminderTz) : null;
|
||||
|
||||
if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
||||
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
||||
}
|
||||
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
||||
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
||||
}
|
||||
if (ntfyUrl) {
|
||||
try { await assertPublicUrl(ntfyUrl); } catch (err) { return res.status(400).json({ error: err.message }); }
|
||||
}
|
||||
if (reminderEnabled) {
|
||||
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' });
|
||||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) {
|
||||
return res.status(400).json({ error: 'Reminder time must be in HH:MM format.' });
|
||||
}
|
||||
}
|
||||
|
||||
setReminderSettings(req.session.userId, {
|
||||
ntfyUrl: ntfyUrl || null,
|
||||
ntfyTopic: ntfyTopic || null,
|
||||
reminderTime: reminderTime || null,
|
||||
reminderEnabled,
|
||||
reminderTz,
|
||||
});
|
||||
res.json({
|
||||
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('PATCH /api/auth/reminder-settings error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reminder settings.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/auth/change-password — self-service password change (Account Settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post('/api/auth/change-password', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const currentPassword = String(req.body?.currentPassword ?? '');
|
||||
const newPassword = String(req.body?.newPassword ?? '');
|
||||
|
||||
if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY);
|
||||
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
recordFailure(`pw:${user.id}`);
|
||||
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.' });
|
||||
}
|
||||
|
||||
clearFailures(`pw:${user.id}`);
|
||||
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 (isLimited(`mfaset:${req.session.userId}`)) return res.status(429).json(TOO_MANY);
|
||||
if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) {
|
||||
recordFailure(`mfaset:${req.session.userId}`);
|
||||
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);
|
||||
if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY);
|
||||
const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash);
|
||||
if (!valid) {
|
||||
recordFailure(`pw:${user.id}`);
|
||||
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/account/export — everything this account holds, as one restorable file
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/account/export', requireAuth, (req, res) => {
|
||||
try {
|
||||
const userId = req.session.userId;
|
||||
const user = getUserById(userId);
|
||||
const backup = buildAccountExport(user, {
|
||||
projects: () => getAllProjects(userId).map((p) => getProject(p.id, userId)).filter(Boolean),
|
||||
prefs: () => getUserPrefs(userId),
|
||||
marks: () => getReaderMarks(userId),
|
||||
ink: () => getAllReaderInk(userId),
|
||||
plans: () => getReadingProgress(userId),
|
||||
});
|
||||
res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`);
|
||||
res.json(backup);
|
||||
} catch (err) {
|
||||
console.error('GET /api/account/export error:', err);
|
||||
res.status(500).json({ error: 'Failed to export your data.' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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 +496,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 +510,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 +524,17 @@ app.put('/api/projects/:id', (req, res) => {
|
||||
if (body.id !== req.params.id) {
|
||||
return res.status(400).json({ error: 'URL id does not match body id.' });
|
||||
}
|
||||
const saved = upsertProject(body);
|
||||
if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' });
|
||||
if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' });
|
||||
// Refuse to overwrite a copy another device saved after the one this device last saw
|
||||
const stored = getProjectEdited(body.id, req.session.userId);
|
||||
if (isSaveConflict({ stored, base: parseBase(req.get('X-Base-Edited')), force: req.query.force === '1' })) {
|
||||
return res.status(409).json({ error: 'This study was changed on another device.', code: 'conflict', serverEdited: stored });
|
||||
}
|
||||
const saved = upsertProject(body, req.session.userId);
|
||||
if (!saved) {
|
||||
return res.status(403).json({ error: 'That project belongs to a different account.' });
|
||||
}
|
||||
res.json(saved);
|
||||
} catch (err) {
|
||||
console.error('PUT /api/projects/:id error:', err);
|
||||
@@ -67,11 +543,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 +555,332 @@ 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 (!/^[1-3A-Za-z]{3}$/.test(book) || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' });
|
||||
if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' });
|
||||
if (strokes.length > 20000) return res.status(413).json({ error: 'Too much ink on one page.' });
|
||||
setReaderInkPage(req.session.userId, book, chapter, strokes);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reader/ink error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reader ink.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/prefs — the user's synced display preferences ({ prefs: { key: { v, at } } })
|
||||
app.get('/api/prefs', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json({ prefs: getUserPrefs(req.session.userId) });
|
||||
} catch (err) {
|
||||
console.error('GET /api/prefs error:', err);
|
||||
res.status(500).json({ error: 'Failed to load preferences.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/prefs — merge changed preferences in (newest change per key wins); returns the result
|
||||
app.put('/api/prefs', requireAuth, (req, res) => {
|
||||
try {
|
||||
const merged = mergePrefs(getUserPrefs(req.session.userId), cleanPrefsPatch(req.body));
|
||||
setUserPrefs(req.session.userId, merged);
|
||||
res.json({ prefs: merged });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/prefs error:', err);
|
||||
res.status(500).json({ error: 'Failed to save preferences.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/reader/marks — the user's bookmarks + highlights, with an updatedAt to compare against
|
||||
app.get('/api/reader/marks', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getReaderMarks(req.session.userId));
|
||||
} catch (err) {
|
||||
console.error('GET /api/reader/marks error:', err);
|
||||
res.status(500).json({ error: 'Failed to load bookmarks and highlights.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights })
|
||||
app.put('/api/reader/marks', requireAuth, (req, res) => {
|
||||
try {
|
||||
const marks = cleanMarks(req.body);
|
||||
if (!marks) return res.status(400).json({ error: 'Body must be an object.' });
|
||||
res.json({ ok: true, updatedAt: setReaderMarks(req.session.userId, marks) });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reader/marks error:', err);
|
||||
res.status(500).json({ error: 'Failed to save bookmarks and highlights.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reading plan progress — cross-device sync for started/completed reading plans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/reading-plans — the current user's whole progress list
|
||||
registerBibleRoutes(app, requireAuth);
|
||||
registerIqBibleRoutes(app, requireAuth);
|
||||
|
||||
app.get('/api/reading-plans', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getReadingProgress(req.session.userId));
|
||||
} catch (err) {
|
||||
console.error('GET /api/reading-plans error:', err);
|
||||
res.status(500).json({ error: 'Failed to load reading plan progress.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reading-plans — overwrite the current user's whole progress list
|
||||
// (an object: { progress: [...], customPlans: [...] })
|
||||
app.put('/api/reading-plans', requireAuth, (req, res) => {
|
||||
try {
|
||||
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||||
return res.status(400).json({ error: 'Body must be an object.' });
|
||||
}
|
||||
const { progress, customPlans, activity } = req.body;
|
||||
if ([progress, customPlans, activity].some((v) => v !== undefined && !Array.isArray(v))
|
||||
|| (progress?.length ?? 0) > 500 || (customPlans?.length ?? 0) > 200 || (activity?.length ?? 0) > 20000) {
|
||||
return res.status(400).json({ error: 'progress, customPlans and activity must be reasonably sized lists.' });
|
||||
}
|
||||
setReadingProgress(req.session.userId, { progress: progress ?? [], customPlans: customPlans ?? [], activity: activity ?? [] });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reading-plans error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reading plan progress.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/reminder-test — sends a test notification to the topic in the request
|
||||
// body (so it can be tried before saving), or to the saved settings.
|
||||
const _lastReminderTest = new Map(); // userId -> timestamp, to keep test sends from being spammed
|
||||
app.post('/api/auth/reminder-test', requireAuth, async (req, res) => {
|
||||
const userId = req.session.userId;
|
||||
if (Date.now() - (_lastReminderTest.get(userId) ?? 0) < 5000) {
|
||||
return res.status(429).json({ error: 'Wait a few seconds before sending another test.' });
|
||||
}
|
||||
_lastReminderTest.set(userId, Date.now());
|
||||
const saved = getUserById(userId);
|
||||
const ntfyTopic = String(req.body?.ntfyTopic ?? saved?.ntfyTopic ?? '').trim();
|
||||
const ntfyUrl = String(req.body?.ntfyUrl ?? saved?.ntfyUrl ?? '').trim();
|
||||
if (!ntfyTopic) return res.status(400).json({ error: 'Enter a topic first.' });
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
||||
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
||||
}
|
||||
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
||||
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
||||
}
|
||||
try {
|
||||
await sendTestReminder({ ntfyTopic, ntfyUrl });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only share links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/projects/:id/share — current share status for the project owner
|
||||
app.get('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const project = getProject(req.params.id, req.session.userId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: getShareToken(req.params.id, req.session.userId) });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/projects/:id/share — enable sharing, returns the (new or existing) token
|
||||
app.post('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const existing = getShareToken(req.params.id, req.session.userId);
|
||||
const token = existing || randomUUID().replace(/-/g, '');
|
||||
const ok = setShareToken(req.params.id, req.session.userId, token);
|
||||
if (!ok) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: token });
|
||||
} catch (err) {
|
||||
console.error('POST /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/projects/:id/share — revoke the share link
|
||||
app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
clearShareToken(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Read-only outline links. A separate token from the study share link, and the public endpoint returns
|
||||
// only the outlines (see outlineShare.js), never the notes or scripts.
|
||||
app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json({ shareToken: getOutlineShareToken(req.params.id, req.session.userId) });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const token = getOutlineShareToken(req.params.id, req.session.userId) || randomUUID().replace(/-/g, '');
|
||||
if (!setOutlineShareToken(req.params.id, req.session.userId, token)) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: token });
|
||||
} catch (err) {
|
||||
console.error('POST /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
clearOutlineShareToken(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/share-outline/:token — PUBLIC, no login required
|
||||
app.get('/api/share-outline/:token', (req, res) => {
|
||||
try {
|
||||
const project = getProjectByOutlineToken(req.params.token);
|
||||
if (!project) return res.status(404).json({ error: 'This outline link is invalid or has been turned off.' });
|
||||
res.json(buildOutlinePayload(project));
|
||||
} catch (err) {
|
||||
console.error('GET /api/share-outline/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load the shared outline.' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
destroyAllSessionsForUser(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/admin/users/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete user.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/admin/users/:id/reset-password — sets a random temporary password and
|
||||
// signs the user out everywhere, since there's no self-service "forgot password" flow yet.
|
||||
app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const temporaryPassword = generateTemporaryPassword();
|
||||
const passwordHash = await hashPassword(temporaryPassword);
|
||||
setUserPassword(req.params.id, passwordHash);
|
||||
destroyAllSessionsForUser(req.params.id);
|
||||
res.json({ temporaryPassword });
|
||||
} catch (err) {
|
||||
console.error('POST /api/admin/users/:id/reset-password error:', err);
|
||||
res.status(500).json({ error: 'Failed to reset password.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
res.json(adminGetAllProjects());
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/projects error:', err);
|
||||
res.status(500).json({ error: 'Failed to list projects.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const project = adminGetProject(req.params.id);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to load project.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
adminDeleteProject(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/admin/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serve Vite production build (when NODE_ENV=production)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (isProd) {
|
||||
const distPath = join(__dirname, '..', 'dist');
|
||||
app.use(express.static(distPath));
|
||||
// Vite's /assets files have content hashes in their names, so they can be
|
||||
// cached forever. Other static files (interlinear JSON, icons) cache for a
|
||||
// day; index.html and the service worker always revalidate so deploys show up.
|
||||
const noCache = (res) => res.setHeader('Cache-Control', 'no-cache');
|
||||
app.use('/assets', express.static(join(distPath, 'assets'), { immutable: true, maxAge: '1y' }));
|
||||
app.use(express.static(distPath, {
|
||||
maxAge: '1d',
|
||||
setHeaders: (res, path) => {
|
||||
if (path.endsWith('index.html') || path.endsWith('sw.js')) noCache(res);
|
||||
},
|
||||
}));
|
||||
app.get('*', (_req, res) => {
|
||||
noCache(res);
|
||||
res.sendFile(join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
@@ -94,9 +889,11 @@ if (process.env.NODE_ENV === 'production') {
|
||||
// Start
|
||||
// ---------------------------------------------------------------------------
|
||||
initDb();
|
||||
startReminderScheduler();
|
||||
startBackupScheduler();
|
||||
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,143 @@
|
||||
// Proxy for the IQ Bible API (https://api.iqbible.com): commentaries, lexicons, original-language text,
|
||||
// places, topics, devotionals, narrations and more. The API key stays on the server (IQBIBLE_API_KEY,
|
||||
// from the environment or .env); the browser only talks to /api/iq/... and only to the routes listed here.
|
||||
// Only verse/chapter text is cached (up to 24h, as the API's terms require); see isTextPath.
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = join(__dirname, '..', '.env');
|
||||
if (existsSync(envPath)) {
|
||||
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
|
||||
}
|
||||
}
|
||||
|
||||
const BASE = () => (process.env.IQBIBLE_API_BASE || 'https://api.iqbible.com/api/v2').replace(/\/+$/, '');
|
||||
const S = '[\\w.~%-]+'; // one path segment
|
||||
const route = (pattern, opts = {}) => ({ re: new RegExp(`^${pattern.replace(/@/g, S)}/?$`), ...opts });
|
||||
|
||||
// Every upstream route the app may reach. Anything else is a 404 here, whatever the browser asks for.
|
||||
export const ROUTES = [
|
||||
route('/commentaries'), route('/commentaries/@/@/@'), route('/commentaries/@/@/@/@'),
|
||||
route('/books/@/info'), route('/books/@/chapters/@/info'), route('/books/@/commentaries'), route('/books/abbreviations'),
|
||||
route('/lexicon/sources'), route('/lexicon/crosswalk/@'),
|
||||
route('/lexicon/(?:greek|hebrew)/(?:strongs|bdb|lsj|abbott-smith)/@'), route('/lexicon/greek/lsj/word/@'),
|
||||
route('/original-language/@/@'), route('/original-language/@/@/@'), route('/morphology/@'),
|
||||
route('/geo/@/@'), route('/geo/@/@/@'), route('/geo/places/@'),
|
||||
route('/topics'), route('/topics/@'), route('/topics/@/@'), route('/topics/@/@/@'),
|
||||
route('/genealogies/@'), route('/bible-characters/@'), route('/bible-people/@'), route('/bible-people/@/@'),
|
||||
route('/harmony'), route('/harmony/@'), route('/textual-variants/@/@'), route('/textual-variants/@/@/@'),
|
||||
route('/chronology/for/@/@'), route('/reference/weights-measures'),
|
||||
route('/cross-references/@/@'), route('/cross-references/@/@/@'),
|
||||
route('/dictionaries'), route('/dictionaries/@'),
|
||||
route('/devotionals/today'), route('/devotionals/@/@'),
|
||||
route('/bibles'), route('/bibles/languages'), route('/bibles/parallel'), route('/bibles/@'), route('/bibles/@/@'), route('/bibles/@/verse-of-the-day'), route('/bibles/@/reading-plan'),
|
||||
route('/bibles/@/@/@/@'), route('/bibles/@/@/@'),
|
||||
route('/audio'), route('/audio/@/@/@', { cache: false }),
|
||||
route('/ai/context', { heavy: true }), route('/bibles/@/search', { heavy: true }), route('/parse/citations'),
|
||||
];
|
||||
|
||||
// Verse and chapter text is the one thing the API's terms let (and require) a client to cache, for up to 24h.
|
||||
// Everything else caps at 1 hour and needs written permission, so it is only cached when IQBIBLE_CACHE_HOURS
|
||||
// is set (do that only once permission is on file).
|
||||
const TEXT_PATH = /^\/bibles\/[\w]+\/(?!random\/|search|reading-plan|verse-of-the-day|verses|books|word-count)[\w.~%-]+\/\d+(?:\/[\d-]+)?$/;
|
||||
export const isTextPath = (p) => TEXT_PATH.test(p.split('?')[0]);
|
||||
|
||||
const QUERY_KEY = /^[\w-]{1,40}$/;
|
||||
|
||||
// Maps an incoming sub-path + query onto an upstream URL, or null when it isn't an allowed route.
|
||||
// The API key can never be supplied by the browser (apikey is dropped).
|
||||
export function resolveIqPath(path, query = {}) {
|
||||
const clean = `/${String(path ?? '').replace(/^\/+/, '')}`;
|
||||
if (clean.includes('..') || clean.includes('//') || clean.length > 300) return null;
|
||||
const hit = ROUTES.find((r) => r.re.test(clean));
|
||||
if (!hit) return null;
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (!QUERY_KEY.test(k) || k.toLowerCase() === 'apikey' || typeof v !== 'string' || v.length > 200) continue;
|
||||
qs.append(k, v);
|
||||
}
|
||||
const q = qs.toString();
|
||||
const key = `${clean}${q ? `?${q}` : ''}`;
|
||||
return { key, cache: hit.cache !== false, heavy: !!hit.heavy, text: isTextPath(clean) };
|
||||
}
|
||||
|
||||
// ---- caching, bounded by the API's own licensing summary ----
|
||||
let policy = null; // { textMs, otherMs }
|
||||
let policyAt = 0;
|
||||
async function cacheTtl(headers, isText) {
|
||||
if (!policy || Date.now() - policyAt > 6 * 3600_000) {
|
||||
try {
|
||||
const res = await fetch(`${BASE()}/licensing`, { headers, signal: AbortSignal.timeout(8000) });
|
||||
const s = (await res.json())?.summary;
|
||||
const allowed = s?.caching_allowed !== false;
|
||||
const textHours = allowed ? Math.min(Number(s?.caching_verse_chapter_hours) || 24, 24) : 0;
|
||||
const optIn = Number(process.env.IQBIBLE_CACHE_HOURS) || 0; // only with the API owner's written permission
|
||||
const otherHours = allowed ? Math.min(optIn, Number(s?.caching_max_hours) || 1) : 0;
|
||||
policy = { textMs: textHours * 3600_000, otherMs: otherHours * 3600_000 };
|
||||
} catch {
|
||||
policy = { textMs: 0, otherMs: 0 };
|
||||
}
|
||||
policyAt = Date.now();
|
||||
}
|
||||
return isText ? policy.textMs : policy.otherMs;
|
||||
}
|
||||
|
||||
const cache = new Map(); // key -> { at, status, type, body }
|
||||
const CACHE_MAX = 1500;
|
||||
|
||||
const hits = new Map(); // `${userId}:${bucket}` -> { n, resetAt }
|
||||
function overLimit(userId, heavy) {
|
||||
const bucket = heavy ? 'h' : 'n';
|
||||
const max = heavy ? 10 : 120; // per minute, per signed-in user (the API budget is shared by everyone)
|
||||
const k = `${userId}:${bucket}`;
|
||||
const now = Date.now();
|
||||
const e = hits.get(k);
|
||||
if (!e || now > e.resetAt) { hits.set(k, { n: 1, resetAt: now + 60_000 }); return false; }
|
||||
e.n += 1;
|
||||
return e.n > max;
|
||||
}
|
||||
|
||||
export function registerIqBibleRoutes(app, requireAuth) {
|
||||
const apiKey = () => process.env.IQBIBLE_API_KEY;
|
||||
|
||||
app.get('/api/iq/status', requireAuth, (_req, res) => res.json({ available: Boolean(apiKey()) }));
|
||||
|
||||
app.get('/api/iq/*', requireAuth, async (req, res) => {
|
||||
const target = resolveIqPath(req.params[0], req.query);
|
||||
if (!target) return res.status(404).json({ error: 'Unknown IQ Bible route.' });
|
||||
if (!apiKey()) return res.status(503).json({ error: 'IQBIBLE_API_KEY is not configured on the server.' });
|
||||
if (overLimit(req.session.userId, target.heavy)) return res.status(429).json({ error: 'Slow down a little and try again in a minute.' });
|
||||
|
||||
const headers = { 'X-IQBible-API-Key': apiKey(), Accept: '*/*' };
|
||||
const hit = target.cache && (target.text || process.env.IQBIBLE_CACHE_HOURS) ? cache.get(target.key) : null;
|
||||
if (hit && Date.now() - hit.at < hit.ttl) {
|
||||
res.status(hit.status).type(hit.type).send(hit.body);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const upstream = await fetch(`${BASE()}${target.key}`, { headers, signal: AbortSignal.timeout(20_000) });
|
||||
const body = Buffer.from(await upstream.arrayBuffer());
|
||||
const type = upstream.headers.get('content-type') || 'application/json';
|
||||
if (upstream.status === 401) return res.status(502).json({ error: 'The IQ Bible key was rejected. Check IQBIBLE_API_KEY.' });
|
||||
if (upstream.status === 429) {
|
||||
const retry = upstream.headers.get('retry-after');
|
||||
if (retry) res.set('Retry-After', retry);
|
||||
return res.status(429).json({ error: 'The IQ Bible allowance is used up for now. Try again later.' });
|
||||
}
|
||||
if (upstream.ok && target.cache && (target.text || process.env.IQBIBLE_CACHE_HOURS)) {
|
||||
const ttl = await cacheTtl(headers, target.text);
|
||||
if (ttl > 0) {
|
||||
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
|
||||
cache.set(target.key, { at: Date.now(), ttl, status: upstream.status, type, body });
|
||||
}
|
||||
}
|
||||
res.status(upstream.status).type(type).send(body);
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach the IQ Bible service.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { resolveIqPath } from './iqbible.js';
|
||||
|
||||
test('only listed IQ Bible routes are reachable', () => {
|
||||
expect(resolveIqPath('commentaries/gill/JHN/3/16')?.key).toBe('/commentaries/gill/JHN/3/16');
|
||||
expect(resolveIqPath('/geo/JHN/3')?.cache).toBe(true);
|
||||
expect(resolveIqPath('/me')).toBeNull();
|
||||
expect(resolveIqPath('/licensing')).toBeNull();
|
||||
expect(resolveIqPath('/commentaries/gill/../me')).toBeNull();
|
||||
expect(resolveIqPath('/lexicon/greek/strongs/G26')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('lexicon sources and audio routes', () => {
|
||||
expect(resolveIqPath('/lexicon/greek/lsj/G0026')).not.toBeNull();
|
||||
expect(resolveIqPath('/lexicon/hebrew/bdb/BDB1005')).not.toBeNull();
|
||||
expect(resolveIqPath('/audio/eng_kjv/JHN/3')?.cache).toBe(false);
|
||||
expect(resolveIqPath('/ai/context', { ref: 'JHN.3.16' })?.heavy).toBe(true);
|
||||
});
|
||||
|
||||
test('the browser cannot send its own key and the query is normalised', () => {
|
||||
const r = resolveIqPath('/bibles/eng_kjv/reading-plan', { days: '30', apikey: 'x', 'bad key': '1', format: 'ical' });
|
||||
expect(r.key).toBe('/bibles/eng_kjv/reading-plan?days=30&format=ical');
|
||||
});
|
||||
|
||||
test('only verse and chapter text counts as cacheable text', async () => {
|
||||
const { isTextPath } = await import('./iqbible.js');
|
||||
expect(isTextPath('/bibles/eng_kjv/JHN/3')).toBe(true);
|
||||
expect(isTextPath('/bibles/eng_kjv/JHN/3/16')).toBe(true);
|
||||
expect(isTextPath('/bibles/eng_kjv/random/verse')).toBe(false);
|
||||
expect(isTextPath('/bibles/eng_kjv/verse-of-the-day')).toBe(false);
|
||||
expect(isTextPath('/commentaries/gill/JHN/3')).toBe(false);
|
||||
expect(resolveIqPath('/bibles/parallel', { v: 'eng_kjv,spa_rv' })).not.toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// What a read-only outline link shows: the outlines of a study (the whole-study one and each chunk's)
|
||||
// and nothing else — no observations, interpretations, applications, scripts or word notes. The
|
||||
// outline data comes from the browser, so it is rebuilt field by field instead of passed through.
|
||||
const MAX_NODES = 2000;
|
||||
const MAX_DEPTH = 8;
|
||||
const KINDS = new Set(['', 'illustration', 'application', 'quote', 'question', 'transition']);
|
||||
|
||||
const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
|
||||
|
||||
function cleanTree(nodes, depth, counter) {
|
||||
if (!Array.isArray(nodes) || depth > MAX_DEPTH) return [];
|
||||
const out = [];
|
||||
for (const n of nodes) {
|
||||
if (!n || typeof n !== 'object' || counter.n >= MAX_NODES) continue;
|
||||
counter.n += 1;
|
||||
const minutes = Number.isFinite(n.minutes) && n.minutes >= 0 && n.minutes <= 600 ? n.minutes : null;
|
||||
out.push({
|
||||
id: str(n.id, 64) || `n${counter.n}`,
|
||||
text: str(n.text, 2000),
|
||||
ref: str(n.ref, 200),
|
||||
kind: KINDS.has(n.kind) ? n.kind : '',
|
||||
minutes,
|
||||
note: str(n.note, 8000),
|
||||
scripture: n.scripture && typeof n.scripture.text === 'string'
|
||||
? { ref: str(n.scripture.ref, 200), text: str(n.scripture.text, 6000), translation: str(n.scripture.translation, 12) }
|
||||
: null,
|
||||
attachments: (Array.isArray(n.attachments) ? n.attachments : []).slice(0, 50)
|
||||
.filter((a) => a && typeof a === 'object')
|
||||
.map((a) => ({ id: str(a.id, 100), type: a.type === 'word' ? 'word' : 'xref', label: str(a.label, 200), ...(a.detail ? { detail: str(a.detail, 500) } : {}) })),
|
||||
children: cleanTree(n.children, depth + 1, counter),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const chunkLabel = (chapters, chapterIndex, chunk) => {
|
||||
const ch = chapters[chapterIndex];
|
||||
const next = chapters[chapterIndex + 1];
|
||||
if (Number.isInteger(chunk.spilloverEndVerse) && next && next.bookAbbrev === ch.bookAbbrev) {
|
||||
return `${ch.book} ${ch.chapter}:${chunk.startVerse}–${next.chapter}:${chunk.spilloverEndVerse}`;
|
||||
}
|
||||
return chunk.startVerse === chunk.endVerse ? `${ch.book} ${ch.chapter}:${chunk.startVerse}` : `${ch.book} ${ch.chapter}:${chunk.startVerse}–${chunk.endVerse}`;
|
||||
};
|
||||
|
||||
// -> { title, translation, outlines: [{ scope, label, tree }] } with only the outlines that have points
|
||||
export function buildOutlinePayload(project) {
|
||||
const counter = { n: 0 };
|
||||
const outlines = [];
|
||||
const whole = cleanTree(project?.outline, 0, counter);
|
||||
if (whole.length > 0) outlines.push({ scope: 'study', label: 'Whole study', tree: whole });
|
||||
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
|
||||
chapters.forEach((ch, chapterIndex) => {
|
||||
for (const chunk of Array.isArray(ch?.chunks) ? ch.chunks : []) {
|
||||
const tree = cleanTree(chunk?.outline, 0, counter);
|
||||
if (tree.length > 0) outlines.push({ scope: str(chunk.id, 64), label: str(chunkLabel(chapters, chapterIndex, chunk), 200), tree });
|
||||
}
|
||||
});
|
||||
return { title: str(project?.title, 500), translation: str(project?.translation, 12) || 'BSB', outlines };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { buildOutlinePayload } from './outlineShare.js';
|
||||
|
||||
const node = (id, extra = {}) => ({ id, text: id, ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [], ...extra });
|
||||
const project = {
|
||||
title: 'Titus', translation: 'BSB',
|
||||
outline: [node('a', { minutes: 5, kind: 'illustration', children: [node('a1')] })],
|
||||
chapters: [{
|
||||
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'secret verse text' }],
|
||||
chunks: [
|
||||
{ id: 'c1', startVerse: 1, endVerse: 4, observation: 'PRIVATE OBSERVATION', finalScript: 'PRIVATE SCRIPT', greekWords: [{ lexeme: 'x' }], outline: [node('b')] },
|
||||
{ id: 'c2', startVerse: 5, endVerse: 5, observation: 'more private', outline: [] },
|
||||
],
|
||||
}],
|
||||
};
|
||||
|
||||
test('shares the outlines only, with each chunk labelled by its passage', () => {
|
||||
const p = buildOutlinePayload(project);
|
||||
expect(p.title).toBe('Titus');
|
||||
expect(p.outlines.map((o) => [o.scope, o.label])).toEqual([['study', 'Whole study'], ['c1', 'Titus 1:1–4']]);
|
||||
expect(p.outlines[0].tree[0]).toMatchObject({ text: 'a', minutes: 5, kind: 'illustration' });
|
||||
expect(p.outlines[0].tree[0].children[0].text).toBe('a1');
|
||||
});
|
||||
|
||||
test('nothing from the rest of the study leaks into what is shared', () => {
|
||||
const json = JSON.stringify(buildOutlinePayload(project));
|
||||
for (const secret of ['PRIVATE OBSERVATION', 'PRIVATE SCRIPT', 'secret verse text', 'more private', 'greekWords']) expect(json).not.toContain(secret);
|
||||
});
|
||||
|
||||
test('unknown fields on a point are dropped, and bad values are cleaned', () => {
|
||||
const p = buildOutlinePayload({ title: 'T', outline: [{ id: 'x', text: 'hi', evil: '<script>', kind: 'bogus', minutes: -4, scripture: { text: 5 }, attachments: [{ type: 'word', label: 'L' }, null], children: 'nope' }], chapters: [] });
|
||||
expect(p.outlines[0].tree[0]).toEqual({
|
||||
id: 'x', text: 'hi', ref: '', kind: '', minutes: null, note: '', scripture: null,
|
||||
attachments: [{ id: '', type: 'word', label: 'L' }], children: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('a study with no outlines shares none, and a huge outline is capped', () => {
|
||||
expect(buildOutlinePayload({ title: 'T', chapters: [] }).outlines).toEqual([]);
|
||||
const big = { title: 'T', outline: Array.from({ length: 3000 }, (_, i) => node(`n${i}`)), chapters: [] };
|
||||
expect(buildOutlinePayload(big).outlines[0].tree).toHaveLength(2000);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// A user's synced display preferences: theme, default Reader translation, verse layout, the
|
||||
// commentary they last chose, and where they left off in the Reader. Each preference carries
|
||||
// its own timestamp ({ v, at }) so the most recent change to each one wins, device by device.
|
||||
const ENUMS = {
|
||||
theme: ['paper', 'sepia', 'dark'],
|
||||
translation: ['BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'],
|
||||
layout: ['flow', 'verses'],
|
||||
startPage: ['studies', 'reader', 'plans'], // where the app opens and where Home buttons go
|
||||
planTranslation: ['same', 'BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'], // which Bible new reading plans start in
|
||||
commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'],
|
||||
};
|
||||
const DAY_MS = 86_400_000;
|
||||
const COLOR = /^#[0-9a-fA-F]{3,8}$/;
|
||||
|
||||
// A cleaned value, or undefined when it isn't acceptable for that preference.
|
||||
export function cleanPrefValue(key, value) {
|
||||
if (ENUMS[key]) return ENUMS[key].includes(value) ? value : undefined;
|
||||
if (key === 'colorLabels') {
|
||||
// What each highlight colour means to the reader: { "#fef08a": "Promises", ... }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const out = {};
|
||||
for (const [color, label] of Object.entries(value).slice(0, 12)) {
|
||||
if (COLOR.test(color) && typeof label === 'string' && label.trim()) out[color] = label.trim().slice(0, 30);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (key === 'position') {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const book = String(value.book ?? '').toUpperCase();
|
||||
if (!/^[1-3A-Z]{3}$/.test(book) || !Number.isInteger(value.chapter) || value.chapter < 1 || value.chapter > 200) return undefined;
|
||||
return { book, chapter: value.chapter };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Body -> { key: { v, at } } with only known keys, valid values and sane timestamps.
|
||||
export function cleanPrefsPatch(body, now = Date.now()) {
|
||||
const out = {};
|
||||
const prefs = body?.prefs;
|
||||
if (!prefs || typeof prefs !== 'object' || Array.isArray(prefs)) return out;
|
||||
for (const [key, entry] of Object.entries(prefs)) {
|
||||
const v = cleanPrefValue(key, entry?.v);
|
||||
const at = entry?.at;
|
||||
if (v === undefined || !Number.isFinite(at) || at < 0 || at > now + DAY_MS) continue;
|
||||
out[key] = { v, at };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Per key, the entry with the newer timestamp wins (the stored one on a tie).
|
||||
export function mergePrefs(existing = {}, patch = {}) {
|
||||
const merged = { ...existing };
|
||||
for (const [key, entry] of Object.entries(patch)) {
|
||||
if (!merged[key] || entry.at > merged[key].at) merged[key] = entry;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { test, expect } from 'vitest';
|
||||
import { cleanPrefValue, cleanPrefsPatch, mergePrefs } from './prefs.js';
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
test('only known preferences with valid values are accepted', () => {
|
||||
expect(cleanPrefValue('theme', 'dark')).toBe('dark');
|
||||
expect(cleanPrefValue('theme', 'neon')).toBeUndefined();
|
||||
expect(cleanPrefValue('translation', 'ESV')).toBe('ESV');
|
||||
expect(cleanPrefValue('translation', 'KJV')).toBeUndefined();
|
||||
expect(cleanPrefValue('layout', 'flow')).toBe('flow');
|
||||
expect(cleanPrefValue('commentarySource', 'tyndale')).toBe('tyndale');
|
||||
expect(cleanPrefValue('startPage', 'reader')).toBe('reader');
|
||||
expect(cleanPrefValue('startPage', 'admin')).toBeUndefined();
|
||||
expect(cleanPrefValue('planTranslation', 'same')).toBe('same');
|
||||
expect(cleanPrefValue('planTranslation', 'NASB')).toBe('NASB');
|
||||
expect(cleanPrefValue('planTranslation', 'KJV')).toBeUndefined();
|
||||
expect(cleanPrefValue('fontSize', 2)).toBeUndefined(); // not a synced preference
|
||||
});
|
||||
|
||||
test('position needs a real book and chapter, and the book is normalised', () => {
|
||||
expect(cleanPrefValue('position', { book: 'jhn', chapter: 3 })).toEqual({ book: 'JHN', chapter: 3 });
|
||||
expect(cleanPrefValue('position', { book: 'JOHN', chapter: 3 })).toBeUndefined();
|
||||
expect(cleanPrefValue('position', { book: 'JHN', chapter: 0 })).toBeUndefined();
|
||||
expect(cleanPrefValue('position', { book: 'JHN', chapter: '3' })).toBeUndefined();
|
||||
expect(cleanPrefValue('position', null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('cleanPrefsPatch drops unknown keys, bad values and silly timestamps', () => {
|
||||
const patch = cleanPrefsPatch({ prefs: {
|
||||
theme: { v: 'sepia', at: NOW },
|
||||
layout: { v: 'nope', at: NOW },
|
||||
evil: { v: 'x', at: NOW },
|
||||
translation: { v: 'NLT', at: 'yesterday' },
|
||||
commentarySource: { v: 'tyndale', at: NOW + 5 * 86_400_000 }, // far in the future
|
||||
position: { v: { book: 'GEN', chapter: 2 }, at: NOW - 10 },
|
||||
} }, NOW);
|
||||
expect(patch).toEqual({ theme: { v: 'sepia', at: NOW }, position: { v: { book: 'GEN', chapter: 2 }, at: NOW - 10 } });
|
||||
expect(cleanPrefsPatch(null)).toEqual({});
|
||||
expect(cleanPrefsPatch({ prefs: [] })).toEqual({});
|
||||
});
|
||||
|
||||
test('the newest change to each preference wins, independently', () => {
|
||||
const existing = { theme: { v: 'dark', at: 100 }, layout: { v: 'flow', at: 500 } };
|
||||
const patch = { theme: { v: 'paper', at: 200 }, layout: { v: 'verses', at: 300 }, translation: { v: 'ESV', at: 50 } };
|
||||
expect(mergePrefs(existing, patch)).toEqual({
|
||||
theme: { v: 'paper', at: 200 }, // patch is newer
|
||||
layout: { v: 'flow', at: 500 }, // stored is newer
|
||||
translation: { v: 'ESV', at: 50 }, // new key
|
||||
});
|
||||
expect(mergePrefs({ theme: { v: 'dark', at: 100 } }, { theme: { v: 'paper', at: 100 } }).theme.v).toBe('dark'); // tie keeps stored
|
||||
});
|
||||
|
||||
test('the database keeps one row per user', async () => {
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'prefs-'));
|
||||
const db = await import('./db.js');
|
||||
db.initDb();
|
||||
db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' });
|
||||
db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' });
|
||||
expect(db.getUserPrefs('u1')).toEqual({});
|
||||
db.setUserPrefs('u1', { theme: { v: 'dark', at: 1 } });
|
||||
db.setUserPrefs('u1', { theme: { v: 'sepia', at: 2 } });
|
||||
expect(db.getUserPrefs('u1')).toEqual({ theme: { v: 'sepia', at: 2 } });
|
||||
expect(db.getUserPrefs('u2')).toEqual({});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// Stops one device from silently overwriting a study another device has saved since. The browser sends
|
||||
// the `lastEdited` of the server copy it last saw (X-Base-Edited); if the server's copy has moved past
|
||||
// that, the save is refused with 409 and the person chooses whose version to keep.
|
||||
|
||||
// stored: the server copy's lastEdited (null when there is none); base: what the client last saw
|
||||
// (null/undefined when it never synced this study); force: the person chose "keep mine".
|
||||
export function isSaveConflict({ stored, base, force = false }) {
|
||||
if (force || stored == null) return false;
|
||||
if (!Number.isFinite(base)) return false; // a device that never saw the server's copy has nothing to compare
|
||||
return stored > base;
|
||||
}
|
||||
|
||||
// Reads the base header; anything that isn't a plain number is treated as "unknown".
|
||||
export function parseBase(header) {
|
||||
if (header == null || header === '') return null;
|
||||
const n = Number(header);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { isSaveConflict, parseBase } from './projectSync.js';
|
||||
|
||||
test('a save is fine when the server copy is no newer than what the device last saw', () => {
|
||||
expect(isSaveConflict({ stored: 100, base: 100 })).toBe(false);
|
||||
expect(isSaveConflict({ stored: 90, base: 100 })).toBe(false);
|
||||
});
|
||||
|
||||
test('a save conflicts when another device saved after the one this device last saw', () => {
|
||||
expect(isSaveConflict({ stored: 200, base: 100 })).toBe(true);
|
||||
});
|
||||
|
||||
test('first saves, unknown bases and "keep mine" never conflict', () => {
|
||||
expect(isSaveConflict({ stored: null, base: 100 })).toBe(false);
|
||||
expect(isSaveConflict({ stored: 200, base: null })).toBe(false);
|
||||
expect(isSaveConflict({ stored: 200, base: 100, force: true })).toBe(false);
|
||||
});
|
||||
|
||||
test('reads the base header', () => {
|
||||
expect(parseBase('1700000000000')).toBe(1700000000000);
|
||||
expect(parseBase('')).toBeNull();
|
||||
expect(parseBase(undefined)).toBeNull();
|
||||
expect(parseBase('abc')).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// Validation for a user's synced Reader bookmarks and text highlights. The data comes
|
||||
// from the browser, so everything is checked and malformed entries are dropped.
|
||||
const COLOR = /^#[0-9a-fA-F]{3,8}$/;
|
||||
const BOOKMARK_KEY = /^[1-3]?[A-Za-z]{2,3}-\d{1,3}-\d{1,3}$/; // e.g. "JHN-3-16", "1SA-3-5"
|
||||
const BOOK = /^[1-3A-Za-z]{3}$/;
|
||||
const MAX_BOOKMARKS = 5000;
|
||||
const MAX_HIGHLIGHTS = 20000;
|
||||
|
||||
const int = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
|
||||
|
||||
// Returns { bookmarks, highlights } with only well-formed entries, or null when the body is
|
||||
// not an object at all.
|
||||
export function cleanMarks(body) {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
|
||||
|
||||
const bookmarks = {};
|
||||
if (body.bookmarks && typeof body.bookmarks === 'object' && !Array.isArray(body.bookmarks)) {
|
||||
for (const [key, color] of Object.entries(body.bookmarks).slice(0, MAX_BOOKMARKS)) {
|
||||
if (BOOKMARK_KEY.test(key) && typeof color === 'string' && COLOR.test(color)) bookmarks[key] = color;
|
||||
}
|
||||
}
|
||||
|
||||
const highlights = [];
|
||||
if (Array.isArray(body.highlights)) {
|
||||
const seen = new Set();
|
||||
for (const h of body.highlights.slice(0, MAX_HIGHLIGHTS)) {
|
||||
if (!h || typeof h !== 'object') continue;
|
||||
if (typeof h.id !== 'string' || h.id.length === 0 || h.id.length > 64 || seen.has(h.id)) continue;
|
||||
if (!BOOK.test(String(h.book)) || !int(h.chapter, 1, 200) || !int(h.verse, 1, 200)) continue;
|
||||
if (!int(h.startOffset, 0, 5000) || !int(h.endOffset, 1, 5000) || h.endOffset <= h.startOffset) continue;
|
||||
if (typeof h.color !== 'string' || !COLOR.test(h.color)) continue;
|
||||
seen.add(h.id);
|
||||
const entry = {
|
||||
id: h.id, book: String(h.book).toUpperCase(), chapter: h.chapter, verse: h.verse,
|
||||
startOffset: h.startOffset, endOffset: h.endOffset, color: h.color,
|
||||
};
|
||||
// The highlighted words, kept so lists of highlights can show them without loading the chapter
|
||||
if (typeof h.text === 'string' && h.text.length > 0) entry.text = h.text.slice(0, 500);
|
||||
highlights.push(entry);
|
||||
}
|
||||
}
|
||||
return { bookmarks, highlights };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { test, expect } from 'vitest';
|
||||
import { cleanMarks } from './readerMarks.js';
|
||||
|
||||
const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 4, endOffset: 20, color: '#fef08a', ...over });
|
||||
|
||||
test('a non-object body is rejected', () => {
|
||||
expect(cleanMarks(null)).toBeNull();
|
||||
expect(cleanMarks([])).toBeNull();
|
||||
expect(cleanMarks('x')).toBeNull();
|
||||
});
|
||||
|
||||
test('good bookmarks and highlights pass through unchanged', () => {
|
||||
const body = { bookmarks: { 'JHN-3-16': '#fef08a', '1SA-3-5': '#bbf7d0' }, highlights: [hl()] };
|
||||
expect(cleanMarks(body)).toEqual(body);
|
||||
});
|
||||
|
||||
test('malformed bookmarks are dropped, good ones kept', () => {
|
||||
const out = cleanMarks({ bookmarks: { 'JHN-3-16': '#fef08a', 'bad key': '#fef08a', 'GEN-1-1': 'red', 'GEN-1-2': 5, '../x-1-1': '#fff' } });
|
||||
expect(out.bookmarks).toEqual({ 'JHN-3-16': '#fef08a' });
|
||||
});
|
||||
|
||||
test('highlights are validated field by field and de-duplicated by id', () => {
|
||||
const out = cleanMarks({ highlights: [
|
||||
hl(),
|
||||
hl({ id: 'h1', verse: 17 }), // duplicate id
|
||||
hl({ id: '' }), hl({ id: 'x'.repeat(65) }),
|
||||
hl({ id: 'a', book: 'JOHN' }), hl({ id: 'b', chapter: 0 }), hl({ id: 'c', verse: 201 }),
|
||||
hl({ id: 'd', startOffset: 10, endOffset: 10 }), hl({ id: 'e', startOffset: -1 }), hl({ id: 'f', color: 'yellow' }),
|
||||
'nope', null,
|
||||
hl({ id: 'ok', book: 'gen' }),
|
||||
] });
|
||||
expect(out.highlights.map((h) => h.id)).toEqual(['h1', 'ok']);
|
||||
expect(out.highlights[1].book).toBe('GEN'); // normalised
|
||||
});
|
||||
|
||||
test('missing sections become empty ones, and unknown fields are not stored', () => {
|
||||
expect(cleanMarks({})).toEqual({ bookmarks: {}, highlights: [] });
|
||||
expect(cleanMarks({ highlights: [{ ...hl(), evil: '<script>' }] }).highlights[0]).not.toHaveProperty('evil');
|
||||
});
|
||||
|
||||
test('the database keeps one row per user and updatedAt only moves forward', async () => {
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'marks-'));
|
||||
const db = await import('./db.js');
|
||||
db.initDb();
|
||||
db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' });
|
||||
db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' });
|
||||
expect(db.getReaderMarks('u1')).toEqual({ bookmarks: {}, highlights: [], updatedAt: 0 });
|
||||
const t1 = db.setReaderMarks('u1', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] });
|
||||
const t2 = db.setReaderMarks('u1', { bookmarks: {}, highlights: [hl()] });
|
||||
expect(t2).toBeGreaterThan(t1);
|
||||
const saved = db.getReaderMarks('u1');
|
||||
expect(saved.updatedAt).toBe(t2);
|
||||
expect(saved.bookmarks).toEqual({});
|
||||
expect(saved.highlights).toHaveLength(1);
|
||||
expect(db.getReaderMarks('u2').updatedAt).toBe(0); // other users are untouched
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// Daily "don't forget to read" reminders, sent via ntfy.sh (https://ntfy.sh) —
|
||||
// a free pub/sub push service: POST a message to a topic URL, anyone
|
||||
// subscribed to that topic (via the ntfy app or a browser) gets notified.
|
||||
// No API keys or push-subscription plumbing needed.
|
||||
import { lookup } from 'dns/promises';
|
||||
import { isIP } from 'net';
|
||||
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js';
|
||||
|
||||
// A reminder still goes out if the server was briefly down (or restarting) at the
|
||||
// exact minute — as long as it's within this window after the chosen time.
|
||||
const GRACE_MINUTES = 180;
|
||||
const SEND_TIMEOUT_MS = 10_000;
|
||||
// A failed send is retried at most this often, so a dead ntfy server isn't hit every minute for hours.
|
||||
const RETRY_AFTER_MS = 15 * 60 * 1000;
|
||||
const _lastAttempt = new Map(); // userId -> timestamp of the last failed send
|
||||
|
||||
// True for loopback, private, link-local and other non-public addresses (IPv4 and IPv6).
|
||||
export function isPrivateAddress(addr) {
|
||||
const v = isIP(addr);
|
||||
if (v === 4) {
|
||||
const [a, b] = addr.split('.').map(Number);
|
||||
return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|
||||
|| (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
|
||||
}
|
||||
if (v === 6) {
|
||||
const l = addr.toLowerCase();
|
||||
if (l === '::' || l === '::1') return true;
|
||||
if (l.startsWith('::ffff:')) return isPrivateAddress(l.slice(7));
|
||||
return /^f[cd]/.test(l) || /^fe[89ab]/.test(l);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rejects server URLs that would make this server call itself or its private network.
|
||||
// A self-hosted ntfy on the LAN can be allowed by the server's admin with ALLOW_PRIVATE_NTFY=1.
|
||||
export async function assertPublicUrl(raw) {
|
||||
if (process.env.ALLOW_PRIVATE_NTFY === '1') return;
|
||||
let u;
|
||||
try { u = new URL(raw); } catch { throw new Error('Server URL is not valid.'); }
|
||||
if (!/^https?:$/.test(u.protocol)) throw new Error('Server URL must start with http:// or https://.');
|
||||
const host = u.hostname.replace(/^\[|\]$/g, '');
|
||||
if (/^localhost$|\.localhost$|\.local$|\.internal$/i.test(host)) throw new Error('Server URL must be a public address.');
|
||||
const addrs = isIP(host) ? [host] : (await lookup(host, { all: true }).catch(() => [])).map((a) => a.address);
|
||||
if (addrs.length === 0) throw new Error(`Could not find ${host}.`);
|
||||
if (addrs.some(isPrivateAddress)) throw new Error('Server URL must be a public address.');
|
||||
}
|
||||
|
||||
export function isValidTimeZone(tz) {
|
||||
if (!tz) return false;
|
||||
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
// The calendar date ("YYYY-MM-DD") and minutes-since-midnight in a timezone; the
|
||||
// server's own timezone when none (or an invalid one) is saved.
|
||||
export function zonedNow(date = new Date(), tz) {
|
||||
const timeZone = isValidTimeZone(tz) ? tz : undefined;
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||
}).formatToParts(date).map((p) => [p.type, p.value]),
|
||||
);
|
||||
return { date: `${parts.year}-${parts.month}-${parts.day}`, minutes: Number(parts.hour) * 60 + Number(parts.minute) };
|
||||
}
|
||||
|
||||
// Is this user's reminder due right now (and not already sent today)?
|
||||
export function isDue(user, now = new Date()) {
|
||||
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(user.reminderTime ?? '');
|
||||
if (!match) return false;
|
||||
const target = Number(match[1]) * 60 + Number(match[2]);
|
||||
const local = zonedNow(now, user.reminderTz);
|
||||
if (user.lastReminderDate === local.date) return false;
|
||||
return local.minutes >= target && local.minutes < target + GRACE_MINUTES;
|
||||
}
|
||||
|
||||
// POSTs one notification to the user's ntfy topic. Throws with a readable message on failure.
|
||||
export async function sendNtfy(user, { title, body, tags = 'open_book' }) {
|
||||
const base = (user.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||
const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`;
|
||||
await assertPublicUrl(url);
|
||||
const headers = { Title: title, Tags: tags };
|
||||
if (process.env.APP_URL) headers.Click = process.env.APP_URL; // tapping the notification opens the app
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, { method: 'POST', headers, body, redirect: 'error', signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
|
||||
} catch (err) {
|
||||
throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`, { cause: err });
|
||||
}
|
||||
if (!res.ok) throw new Error(`ntfy responded ${res.status}`);
|
||||
}
|
||||
|
||||
// Has the user already finished a reading day on `date` ("YYYY-MM-DD", in their timezone)?
|
||||
export function readToday(progress, date) {
|
||||
return Array.isArray(progress?.activity) && progress.activity.includes(date);
|
||||
}
|
||||
|
||||
export function reminderBody(progress) {
|
||||
const active = (Array.isArray(progress?.progress) ? progress.progress : []).filter((p) => !p.completedAt).length;
|
||||
return active > 1 ? `Don't forget today's reading! You have ${active} plans in progress.` : "Don't forget today's reading!";
|
||||
}
|
||||
|
||||
/** Sends a one-off notification so the user can confirm their topic/server work. */
|
||||
export function sendTestReminder(user) {
|
||||
return sendNtfy(user, { title: 'Reading reminders are working', body: 'This is a test from Bible Study Project. Your daily reminder will arrive like this.', tags: 'white_check_mark' });
|
||||
}
|
||||
|
||||
export async function checkReminders(now = new Date()) {
|
||||
for (const user of getUsersWithRemindersEnabled().filter((u) => isDue(u, now))) {
|
||||
try {
|
||||
const progress = getReadingProgress(user.id);
|
||||
const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt);
|
||||
if (!hasActivePlan) continue; // nothing to nudge them about
|
||||
if (readToday(progress, zonedNow(now, user.reminderTz).date)) continue; // already read today: no nudge
|
||||
if (now.getTime() - (_lastAttempt.get(user.id) ?? 0) < RETRY_AFTER_MS) continue;
|
||||
_lastAttempt.set(user.id, now.getTime());
|
||||
await sendNtfy(user, { title: 'Reading Plan Reminder', body: reminderBody(progress) });
|
||||
_lastAttempt.delete(user.id);
|
||||
setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date);
|
||||
} catch (err) {
|
||||
console.error(`Reading reminder failed for user ${user.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks once a minute whether any user's reminder time has arrived. */
|
||||
export function startReminderScheduler() {
|
||||
setInterval(() => {
|
||||
checkReminders().catch((err) => console.error('Reminder scheduler error:', err));
|
||||
}, 60 * 1000);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment node
|
||||
import http from 'node:http';
|
||||
import { test, expect, afterEach } from 'vitest';
|
||||
process.env.ALLOW_PRIVATE_NTFY = '1'; // the tests talk to a local server
|
||||
import { isDue, isValidTimeZone, reminderBody, sendNtfy, sendTestReminder, zonedNow } from './reminders.js';
|
||||
|
||||
const at = (iso) => new Date(iso);
|
||||
|
||||
test('zonedNow reads the date and time in the user\'s timezone, not the server\'s', () => {
|
||||
// 2026-09-30T03:30Z is 23:30 the day before in New York, and 12:30 the same day in Tokyo
|
||||
const d = at('2026-09-30T03:30:00Z');
|
||||
expect(zonedNow(d, 'America/New_York')).toEqual({ date: '2026-09-29', minutes: 23 * 60 + 30 });
|
||||
expect(zonedNow(d, 'Asia/Tokyo')).toEqual({ date: '2026-09-30', minutes: 12 * 60 + 30 });
|
||||
expect(zonedNow(d, 'Not/AZone')).toEqual(zonedNow(d)); // bad timezone falls back to the server's
|
||||
});
|
||||
|
||||
test('timezone validation', () => {
|
||||
expect(isValidTimeZone('America/Chicago')).toBe(true);
|
||||
expect(isValidTimeZone('Mars/Base')).toBe(false);
|
||||
expect(isValidTimeZone('')).toBe(false);
|
||||
});
|
||||
|
||||
test('a reminder is due at its time in the user\'s timezone', () => {
|
||||
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: null };
|
||||
expect(isDue(user, at('2026-09-29T11:59:00Z'))).toBe(false); // 06:59 in Chicago (CDT)
|
||||
expect(isDue(user, at('2026-09-29T12:00:00Z'))).toBe(true); // 07:00
|
||||
});
|
||||
|
||||
test('a late server still sends today\'s reminder, but only within the grace window', () => {
|
||||
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: null };
|
||||
expect(isDue(user, at('2026-09-29T12:40:00Z'))).toBe(true); // 07:40 — server was down at 07:00
|
||||
expect(isDue(user, at('2026-09-29T15:30:00Z'))).toBe(false); // 10:30 — too late to be useful
|
||||
});
|
||||
|
||||
test('it is not sent twice on the same local day', () => {
|
||||
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: '2026-09-29' };
|
||||
expect(isDue(user, at('2026-09-29T12:05:00Z'))).toBe(false);
|
||||
expect(isDue({ ...user, lastReminderDate: '2026-09-28' }, at('2026-09-29T12:05:00Z'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a missing or malformed time is never due', () => {
|
||||
expect(isDue({ reminderTime: null }, new Date())).toBe(false);
|
||||
expect(isDue({ reminderTime: '7am' }, new Date())).toBe(false);
|
||||
});
|
||||
|
||||
test('the message mentions how many plans are in progress', () => {
|
||||
expect(reminderBody({ progress: [{ completedAt: null }] })).toBe("Don't forget today's reading!");
|
||||
expect(reminderBody({ progress: [{ completedAt: null }, { completedAt: null }, { completedAt: 1 }] })).toContain('2 plans');
|
||||
});
|
||||
|
||||
// --- delivery, against a local stand-in for an ntfy server ------------------------
|
||||
let server;
|
||||
afterEach(() => new Promise((resolve) => (server ? server.close(resolve) : resolve())));
|
||||
|
||||
const startNtfy = (handler) => new Promise((resolve) => {
|
||||
const seen = [];
|
||||
server = http.createServer((req, res) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
req.on('end', () => { seen.push({ method: req.method, url: req.url, headers: req.headers, body }); handler(res); });
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, seen }));
|
||||
});
|
||||
|
||||
test('sendNtfy POSTs the message to <server>/<topic> with a title', async () => {
|
||||
const ntfy = await startNtfy((res) => res.end('{}'));
|
||||
await sendNtfy({ ntfyUrl: `${ntfy.url}/`, ntfyTopic: 'my-topic_1' }, { title: 'Hello', body: 'Body text' });
|
||||
expect(ntfy.seen).toHaveLength(1);
|
||||
expect(ntfy.seen[0]).toMatchObject({ method: 'POST', url: '/my-topic_1', body: 'Body text' });
|
||||
expect(ntfy.seen[0].headers.title).toBe('Hello');
|
||||
});
|
||||
|
||||
test('the test notification reaches the topic', async () => {
|
||||
const ntfy = await startNtfy((res) => res.end('{}'));
|
||||
await sendTestReminder({ ntfyUrl: ntfy.url, ntfyTopic: 'abc' });
|
||||
expect(ntfy.seen[0].url).toBe('/abc');
|
||||
expect(ntfy.seen[0].headers.title).toMatch(/working/i);
|
||||
});
|
||||
|
||||
test('an ntfy error response becomes a readable failure', async () => {
|
||||
const ntfy = await startNtfy((res) => { res.statusCode = 403; res.end('nope'); });
|
||||
await expect(sendNtfy({ ntfyUrl: ntfy.url, ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow('ntfy responded 403');
|
||||
});
|
||||
|
||||
test('an unreachable server says so instead of hanging', async () => {
|
||||
await expect(sendNtfy({ ntfyUrl: 'http://127.0.0.1:9', ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow(/Could not reach/);
|
||||
});
|
||||
|
||||
test('private and loopback addresses are rejected as reminder servers', async () => {
|
||||
const { isPrivateAddress, assertPublicUrl, readToday } = await import('./reminders.js');
|
||||
for (const a of ['127.0.0.1', '10.1.2.3', '192.168.0.5', '172.16.0.1', '169.254.169.254', '::1', 'fd00::1', '::ffff:10.0.0.1']) expect(isPrivateAddress(a)).toBe(true);
|
||||
expect(isPrivateAddress('8.8.8.8')).toBe(false);
|
||||
const prev = process.env.ALLOW_PRIVATE_NTFY;
|
||||
delete process.env.ALLOW_PRIVATE_NTFY;
|
||||
await expect(assertPublicUrl('http://localhost:3001')).rejects.toThrow();
|
||||
await expect(assertPublicUrl('http://169.254.169.254/latest')).rejects.toThrow();
|
||||
await expect(assertPublicUrl('ftp://example.com')).rejects.toThrow();
|
||||
process.env.ALLOW_PRIVATE_NTFY = prev;
|
||||
expect(readToday({ activity: ['2026-09-29'] }, '2026-09-29')).toBe(true);
|
||||
expect(readToday({ activity: [] }, '2026-09-29')).toBe(false);
|
||||
});
|
||||
@@ -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"
|
||||
@@ -112,4 +148,4 @@ echo "Both servers are running."
|
||||
echo ""
|
||||
echo "To stop both: kill \$(cat .api.pid) \$(cat .vite.pid)"
|
||||
echo "To view API logs: tail -f .api.log"
|
||||
echo "To view Vite logs: tail -f .vite.log"
|
||||
echo "To view Vite logs: tail -f .vite.log"
|
||||
@@ -0,0 +1,443 @@
|
||||
// How the app moves and recovers: the Back button, the ⌘K palette, Undo, sync conflicts, backups.
|
||||
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
|
||||
import App from './App';
|
||||
import { resetLexiconCaches } from './utils/wordStudy.js';
|
||||
import { switchStorageUser } from './utils/bibleUtils.jsx';
|
||||
import { resetSyncBases } from './utils/syncBase.js';
|
||||
|
||||
const chapterData = {
|
||||
chapter: { content: [
|
||||
{ type: 'verse', number: 1, content: ['In the beginning God created.'] },
|
||||
{ type: 'verse', number: 2, content: ['The earth was without form.'] },
|
||||
] },
|
||||
};
|
||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||
|
||||
// A fake server: signed in as `user`, a settable answer for saving studies, and a log of every call.
|
||||
function stubServer({ user = null, putStatus = 200 } = {}) {
|
||||
const calls = [];
|
||||
const state = { putStatus, outlineToken: null };
|
||||
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||
const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`;
|
||||
calls.push({ route, url, opts });
|
||||
if (url.includes('bible.helloao.org')) return json(200, chapterData);
|
||||
if (!url.startsWith('/api/')) return Promise.resolve({ ok: false });
|
||||
if (route === 'GET /api/auth/me') return user ? json(200, user) : json(401, { error: 'Not signed in' });
|
||||
if (route === 'GET /api/projects') return json(200, []);
|
||||
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||
if (route.endsWith('/outline-share')) {
|
||||
if (opts.method === 'POST') state.outlineToken = 'abc123';
|
||||
if (opts.method === 'DELETE') state.outlineToken = null;
|
||||
return json(200, opts.method === 'DELETE' ? { ok: true } : { shareToken: state.outlineToken ?? null });
|
||||
}
|
||||
if (route === 'GET /api/share-outline/abc123') {
|
||||
return json(200, { title: 'Titus', translation: 'BSB', outlines: [
|
||||
{ scope: 'study', label: 'Whole study', tree: [{ id: 'a', text: 'Appoint elders', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
|
||||
{ scope: 'c1', label: 'Titus 1:1–4', tree: [{ id: 'b', text: 'Greeting', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
|
||||
] });
|
||||
}
|
||||
if (route === 'GET /api/share-outline/nope') return json(404, { error: 'This outline link is invalid or has been turned off.' });
|
||||
if (route.startsWith('PUT /api/projects/')) {
|
||||
return state.putStatus === 409
|
||||
? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 })
|
||||
: json(200, { id: url.split('/').pop().split('?')[0], title: 'x', lastEdited: 12345, chapterSummary: '' });
|
||||
}
|
||||
return json(404, { error: 'not mocked' });
|
||||
}));
|
||||
return { calls, state };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetLexiconCaches();
|
||||
resetSyncBases();
|
||||
switchStorageUser(null);
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false })));
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
|
||||
|
||||
async function renderApp() {
|
||||
render(<App />);
|
||||
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
|
||||
}
|
||||
|
||||
function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) {
|
||||
const project = {
|
||||
id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null,
|
||||
chapters: [{
|
||||
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }],
|
||||
chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '', crossReferences: [], greekWords: [], tags: [] }],
|
||||
}],
|
||||
};
|
||||
const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]');
|
||||
index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] });
|
||||
localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project));
|
||||
localStorage.setItem('bible-study-index', JSON.stringify(index));
|
||||
}
|
||||
|
||||
describe('Back button', () => {
|
||||
test('moves back through the pages you visited instead of leaving the app', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
|
||||
window.history.back();
|
||||
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
|
||||
window.history.forward();
|
||||
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const paletteBox = async () => within(await screen.findByRole('dialog', { name: /search and go to/i })).getByRole('combobox');
|
||||
|
||||
describe('Search palette (⌘K)', () => {
|
||||
test('a typed passage goes straight to the Bible reader', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.keyboard('{Control>}k{/Control}');
|
||||
await user.type(await paletteBox(), 'john 3:16');
|
||||
expect(await screen.findByRole('option', { name: /John 3:16/ })).toBeInTheDocument();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(await screen.findByRole('heading', { name: /John 3/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('finds a note you wrote and opens that study', async () => {
|
||||
seedProject({ observation: 'Grace abounds to all.' });
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.keyboard('{Control>}k{/Control}');
|
||||
await user.type(await paletteBox(), 'abounds');
|
||||
const hit = await screen.findByRole('option', { name: /Titus 1:1/ });
|
||||
await user.click(hit);
|
||||
expect(await screen.findByText(/Your notes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Escape closes it', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.keyboard('{Control>}k{/Control}');
|
||||
await paletteBox();
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('? lists the keyboard shortcuts', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.keyboard('?');
|
||||
expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Undo', () => {
|
||||
test('deleting a project, then Undo, keeps its notes', async () => {
|
||||
seedProject();
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click(screen.getByRole('button', { name: /^delete$/i }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(/deleted/i);
|
||||
await user.click(screen.getByRole('button', { name: /undo/i }));
|
||||
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem('bible-study-project-p1')).chapters[0].chunks[0].observation).toBe('Grace abounds to all.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sync conflict', () => {
|
||||
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
|
||||
|
||||
async function startStudy(u) {
|
||||
await renderApp();
|
||||
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
}
|
||||
|
||||
test('a save the server refuses shows the choice instead of overwriting', async () => {
|
||||
const server = stubServer({ user, putStatus: 409 });
|
||||
const u = userEvent.setup();
|
||||
await startStudy(u);
|
||||
const banner = await screen.findByRole('alert', {}, { timeout: 4000 });
|
||||
expect(banner).toHaveTextContent(/changed on another device/i);
|
||||
expect(within(banner).getByRole('button', { name: /keep this version/i })).toBeInTheDocument();
|
||||
expect(within(banner).getByRole('button', { name: /use the other version/i })).toBeInTheDocument();
|
||||
|
||||
// "Keep this version" sends the save again, this time as an overwrite
|
||||
server.state.putStatus = 200;
|
||||
await u.click(within(banner).getByRole('button', { name: /keep this version/i }));
|
||||
await waitFor(() => expect(server.calls.some((c) => c.url.includes('?force=1'))).toBe(true));
|
||||
await waitFor(() => expect(screen.queryByText(/changed on another device/i)).not.toBeInTheDocument());
|
||||
}, 15000);
|
||||
|
||||
test('opening a study without editing it does not touch the server', async () => {
|
||||
seedProject();
|
||||
const server = stubServer({ user });
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
// signed in: the namespaced storage starts empty, so seed under the user's keys by resuming nothing
|
||||
expect(server.calls.filter((c) => c.route.startsWith('PUT')).length).toBe(0);
|
||||
await u.click(await screen.findByRole('button', { name: /read bible/i }));
|
||||
expect(server.calls.filter((c) => c.route.startsWith('PUT /api/projects')).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Outline stage', () => {
|
||||
async function openOutline() {
|
||||
// local-only mode (the account server is unreachable), with chapter text available
|
||||
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
// one chunk covering verses 1-2
|
||||
const verses = within(screen.getByTestId('verse-list'));
|
||||
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
|
||||
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||
await screen.findByRole('heading', { name: 'Outline' });
|
||||
return user;
|
||||
}
|
||||
|
||||
test('starts empty, and "Start from my chunks" makes a main point for each chunk', async () => {
|
||||
const user = await openOutline();
|
||||
expect(screen.getByText(/no outline yet/i)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||
const list = await screen.findByRole('list', { name: 'Outline' });
|
||||
expect(within(list).getAllByRole('textbox')).toHaveLength(1);
|
||||
expect(within(list).getByText(/Open chunk in Study/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Enter adds a point, Tab makes it a sub-point, and they are numbered I. and A.', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||
const first = await screen.findByRole('textbox', { name: 'Point I.' });
|
||||
await user.type(first, 'Creation{Enter}');
|
||||
const second = await screen.findByRole('textbox', { name: 'Point II.' });
|
||||
await user.type(second, 'Light{Tab}');
|
||||
expect(await screen.findByRole('textbox', { name: 'Point A.' })).toHaveValue('Light');
|
||||
expect(screen.getByRole('textbox', { name: 'Point I.' })).toHaveValue('Creation');
|
||||
});
|
||||
|
||||
test('deleting a point can be undone', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'Keep me');
|
||||
await user.click(screen.getByRole('button', { name: /delete i\./i }));
|
||||
expect(screen.queryByRole('textbox', { name: 'Point I.' })).not.toBeInTheDocument();
|
||||
await user.click(await screen.findByRole('button', { name: /undo/i }));
|
||||
expect(await screen.findByRole('textbox', { name: 'Point I.' })).toHaveValue('Keep me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Outline: per chunk, details, print and share', () => {
|
||||
async function openOutline() {
|
||||
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
const verses = within(screen.getByTestId('verse-list'));
|
||||
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
|
||||
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
|
||||
await screen.findByText('1 created');
|
||||
await user.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||
await screen.findByRole('heading', { name: 'Outline' });
|
||||
return user;
|
||||
}
|
||||
|
||||
test('each chunk has its own outline, separate from the whole study\'s', async () => {
|
||||
const user = await openOutline();
|
||||
const picker = screen.getByRole('combobox', { name: 'Outline for' });
|
||||
await user.selectOptions(picker, picker.querySelectorAll('option')[1]); // the chunk
|
||||
expect(await screen.findByText(/no outline yet for/i)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /start from the verses/i }));
|
||||
const list = await screen.findByRole('list', { name: 'Outline' });
|
||||
expect(within(list).getAllByRole('textbox')).toHaveLength(2); // one point per verse
|
||||
expect(within(list).getByText(/In the beginning God created/)).toBeInTheDocument(); // with its words
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Outline for' }), 'study');
|
||||
expect(await screen.findByText(/^No outline yet$/)).toBeInTheDocument(); // the whole study is untouched
|
||||
expect(screen.getByRole('option', { name: /· 2/ })).toBeInTheDocument(); // the chunk shows its count
|
||||
});
|
||||
|
||||
test('a point can be given a type and minutes, and the total shows', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'The story');
|
||||
await user.click(screen.getByRole('button', { name: /details for i\./i }));
|
||||
await user.selectOptions(screen.getByLabelText('Type'), 'illustration');
|
||||
await user.type(screen.getByLabelText('Minutes'), '5');
|
||||
expect(await screen.findByText('About 5 minutes')).toBeInTheDocument();
|
||||
await user.type(screen.getByRole('textbox', { name: 'Notes for I.' }), 'Tell it slowly');
|
||||
await user.click(screen.getByRole('button', { name: /details for i\./i })); // close: the note shows as a line
|
||||
expect(await screen.findByText('Tell it slowly')).toBeInTheDocument();
|
||||
expect(screen.getByText('Illustration')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('"Add verse text" puts the passage\'s words into the point', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start blank/i }));
|
||||
await user.click(await screen.findByRole('button', { name: /details for i\./i }));
|
||||
await user.type(screen.getByLabelText('Passage'), 'Titus 1:1');
|
||||
await user.click(screen.getByRole('button', { name: /add verse text/i }));
|
||||
expect(await screen.findByText(/In the beginning God created/)).toBeInTheDocument();
|
||||
await user.clear(screen.getByLabelText('Passage'));
|
||||
await user.type(screen.getByLabelText('Passage'), 'nonsense');
|
||||
await user.click(screen.getByRole('button', { name: /replace verse text/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i);
|
||||
});
|
||||
|
||||
test('Share opens a sheet; with no account it offers the text, which is copied', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||
await screen.findByRole('list', { name: 'Outline' });
|
||||
await user.click(screen.getByRole('button', { name: /^share$/i }));
|
||||
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
|
||||
expect(await within(sheet).findByText(/links need your account/i)).toBeInTheDocument();
|
||||
await user.click(within(sheet).getByRole('button', { name: /share as text/i }));
|
||||
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Print opens a page of just the outline', async () => {
|
||||
const user = await openOutline();
|
||||
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||
await screen.findByRole('list', { name: 'Outline' });
|
||||
const doc = { write: vi.fn(), close: vi.fn() };
|
||||
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
|
||||
await user.click(screen.getByRole('button', { name: /print \/ pdf/i }));
|
||||
expect(doc.write.mock.calls[0][0]).toContain('class="outline"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Read-only outline link', () => {
|
||||
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
|
||||
|
||||
test('create the link, see it, and turn it off', async () => {
|
||||
const server = stubServer({ user });
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
const verses = within(screen.getByTestId('verse-list'));
|
||||
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
|
||||
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
|
||||
await screen.findByText('1 created');
|
||||
await u.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||
await u.click(await screen.findByRole('button', { name: /start from my chunks/i }));
|
||||
await u.click(await screen.findByRole('button', { name: /^share$/i }));
|
||||
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
|
||||
await u.click(await within(sheet).findByRole('button', { name: /create read-only link/i }));
|
||||
const box = await within(sheet).findByLabelText('Outline link');
|
||||
expect(box.value).toMatch(/\?outline=abc123$/);
|
||||
expect(server.calls.some((c) => c.route === 'POST /api/projects/' + c.url.split('/')[3] + '/outline-share')).toBe(true);
|
||||
await u.click(within(sheet).getByRole('button', { name: /turn off link/i }));
|
||||
expect(await within(sheet).findByRole('button', { name: /create read-only link/i })).toBeInTheDocument();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe('Opening a read-only outline link', () => {
|
||||
afterEach(() => window.history.replaceState({}, '', '/'));
|
||||
|
||||
test('shows the outline with no sign-in and a way to switch between outlines', async () => {
|
||||
stubServer({ user: null });
|
||||
window.history.replaceState({}, '', '/?outline=abc123');
|
||||
render(<App />);
|
||||
expect(await screen.findByRole('heading', { name: 'Titus' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Appoint elders')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); // nothing to edit
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Titus 1:1–4' }));
|
||||
expect(await screen.findByRole('heading', { name: 'Titus — Titus 1:1–4' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Greeting')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Appoint elders')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a turned-off or wrong link says so', async () => {
|
||||
stubServer({ user: null });
|
||||
window.history.replaceState({}, '', '/?outline=nope');
|
||||
render(<App />);
|
||||
expect(await screen.findByText(/invalid or has been turned off/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Study cards on Home', () => {
|
||||
test('a long run of chapters and many tags stay short', async () => {
|
||||
const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', ');
|
||||
const tags = ['A', 'B', 'C', 'D', 'E', 'F'];
|
||||
localStorage.setItem('bible-study-index', JSON.stringify([{ id: 'p', title: 'Luke', lastEdited: Date.now(), chapterSummary: chapters, tags }]));
|
||||
await renderApp();
|
||||
expect(await screen.findByText('Luke 1\u201324')).toBeInTheDocument();
|
||||
expect(screen.getByText('+3 more')).toBeInTheDocument();
|
||||
expect(screen.queryByText('F', { selector: 'span' })).not.toBeInTheDocument(); // only in the Tag filter
|
||||
});
|
||||
});
|
||||
|
||||
describe('Window title', () => {
|
||||
test('names the page you are on, including the chapter in the reader', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
expect(document.title).toBe('My Studies · Bible Study');
|
||||
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||
await waitFor(() => expect(document.title).toBe('Reading Plans · Bible Study'));
|
||||
await user.click(screen.getByRole('button', { name: /read bible/i }));
|
||||
await waitFor(() => expect(document.title).toMatch(/^[A-Za-z0-9 ]+ \d+ · Bible Study$/));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login page', () => {
|
||||
async function openLogin() {
|
||||
stubServer({ user: null });
|
||||
render(<App />);
|
||||
return screen.findByRole('button', { name: /^sign in$/i });
|
||||
}
|
||||
|
||||
test('tells you what the app can do, in groups', async () => {
|
||||
await openLogin();
|
||||
expect(screen.getByRole('heading', { level: 1, name: /read it\. study it\./i })).toBeInTheDocument();
|
||||
for (const group of ['Read', 'Study', 'Teach', 'Everywhere']) expect(screen.getByRole('region', { name: group })).toBeInTheDocument();
|
||||
expect(screen.getByText('A real outline')).toBeInTheDocument();
|
||||
expect(screen.getByText('Compare them')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('switches between signing in and creating an account', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogin();
|
||||
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /need an account/i }));
|
||||
expect(screen.getByRole('heading', { name: 'Create an account' })).toBeInTheDocument();
|
||||
expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^create account$/i })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /already have an account/i }));
|
||||
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a theme can be picked before signing in', async () => {
|
||||
const user = userEvent.setup();
|
||||
await openLogin();
|
||||
await user.click(screen.getByRole('button', { name: 'Dark' }));
|
||||
expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(localStorage.getItem('reader-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
test('a wrong password shows the error', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||
if (url === '/api/auth/me') return json(401, {});
|
||||
if (url === '/api/auth/login') return json(401, { error: 'Incorrect email or password.' });
|
||||
return Promise.resolve({ ok: false });
|
||||
}));
|
||||
render(<App />);
|
||||
await user.type(await screen.findByLabelText(/email/i), 'a@b.co');
|
||||
await user.type(screen.getByLabelText(/^password/i), 'wrong-password');
|
||||
await user.click(screen.getByRole('button', { name: /^sign in$/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/incorrect email or password/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
// A compact media-player control: big play/pause button, seekable progress
|
||||
// bar, and transport buttons. Presentation only — playback state and handlers
|
||||
// come from useAudio.js so the Home (whole book) and Reader (chapter) players
|
||||
// can share this UI.
|
||||
function formatTime(seconds) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function PlayIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" width="20" height="20" fill="currentColor">
|
||||
<path d="M5.5 3.6c0-.9 1-1.45 1.76-.97l9.4 5.9c.72.45.72 1.5 0 1.94l-9.4 5.9c-.76.48-1.76-.07-1.76-.97V3.6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PauseIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" width="20" height="20" fill="currentColor">
|
||||
<rect x="4.5" y="3.5" width="4" height="13" rx="1" />
|
||||
<rect x="11.5" y="3.5" width="4" height="13" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StopIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" width="16" height="16" fill="currentColor">
|
||||
<rect x="4.5" y="4.5" width="11" height="11" rx="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Seek10Icon({ direction }) {
|
||||
const flip = direction === 'back' ? 'scale(-1,1) translate(-20,0)' : undefined;
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<g transform={flip}>
|
||||
<path d="M4 8.5a6.5 6.5 0 1 1 1.2 5.3" strokeLinecap="round" />
|
||||
<path d="M3 5.5v3.3h3.3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
<text x="10" y="12.2" fontSize="6.5" fontFamily="sans-serif" stroke="none" fill="currentColor" textAnchor="middle">10</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NextTrackIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" width="16" height="16" fill="currentColor">
|
||||
<path d="M5 4.3c0-.8.9-1.3 1.6-.9l7.4 4.7c.7.4.7 1.4 0 1.8l-7.4 4.7c-.7.4-1.6-.1-1.6-.9V4.3z" />
|
||||
<rect x="14" y="3.7" width="1.6" height="12.6" rx="0.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AudioPlayer({
|
||||
title,
|
||||
subtitle,
|
||||
errorMessage,
|
||||
status, // 'idle' | 'playing' | 'paused' | 'error'
|
||||
currentTime = 0,
|
||||
duration = 0,
|
||||
onPlay,
|
||||
onTogglePause,
|
||||
onSeekBack,
|
||||
onSeekForward,
|
||||
onSeekTo,
|
||||
onNext,
|
||||
nextDisabled,
|
||||
onStop,
|
||||
controls, // optional extra controls (e.g. book/narrator selects), rendered top-right
|
||||
}) {
|
||||
const isActive = status === 'playing' || status === 'paused';
|
||||
const progressPct = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl border border-[var(--r-line,#e2e8f0)] bg-[var(--r-bar,#ffffff)] p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-[10rem] flex-1">
|
||||
<h3 className="truncate text-sm font-semibold text-[var(--r-text,#0f172a)] sm:text-base">{title}</h3>
|
||||
<p className="mt-0.5 truncate text-xs text-[var(--r-sub,#64748b)] sm:text-sm">{subtitle}</p>
|
||||
{status === 'error' && errorMessage && (
|
||||
<p className="mt-1 text-xs text-rose-600">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
{controls && <div className="flex flex-wrap items-center gap-2">{controls}</div>}
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<div className="mt-4">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 0}
|
||||
step={0.1}
|
||||
value={Math.min(currentTime, duration || 0)}
|
||||
onChange={(e) => onSeekTo?.(Number(e.target.value))}
|
||||
disabled={!duration}
|
||||
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-[var(--r-line,#e2e8f0)] accent-[var(--r-accent,#0284c7)] disabled:cursor-default"
|
||||
style={{ background: `linear-gradient(to right, var(--r-accent, rgb(2 132 199)) ${progressPct}%, var(--r-line, rgb(226 232 240)) ${progressPct}%)` }}
|
||||
aria-label="Seek"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs tabular-nums text-[var(--r-sub,#64748b)]">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center justify-center gap-3">
|
||||
{isActive ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSeekBack}
|
||||
title="Rewind 10 seconds"
|
||||
aria-label="Rewind 10 seconds"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
|
||||
>
|
||||
<Seek10Icon direction="back" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePause}
|
||||
aria-label={status === 'paused' ? 'Resume' : 'Pause'}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--r-accent,#0284c7)] text-[var(--r-on,#ffffff)] hover:opacity-90"
|
||||
>
|
||||
{status === 'paused' ? <PlayIcon /> : <PauseIcon />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSeekForward}
|
||||
title="Fast-forward 10 seconds"
|
||||
aria-label="Fast-forward 10 seconds"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
|
||||
>
|
||||
<Seek10Icon direction="forward" />
|
||||
</button>
|
||||
{onNext && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={nextDisabled}
|
||||
title="Next chapter"
|
||||
aria-label="Next chapter"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<NextTrackIcon />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
title="Stop"
|
||||
aria-label="Stop"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
|
||||
>
|
||||
<StopIcon />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPlay}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--r-accent,#0284c7)] text-[var(--r-on,#ffffff)] hover:opacity-90"
|
||||
aria-label="Play"
|
||||
>
|
||||
<PlayIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Press ⌘K (Ctrl+K) anywhere: type a passage ("john 3:16") to go straight to it, or a few letters to
|
||||
// find a study, a note you wrote, a bookmark or highlight, a page, or to search the Bible's text.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { themeVars } from './reader/readerThemes.js';
|
||||
import { SearchIcon } from './reader/icons.jsx';
|
||||
import { formatPassage, parsePassageRef } from '../utils/passageRef.js';
|
||||
import { searchMarks, searchNotes, searchProjects } from '../utils/personalSearch.js';
|
||||
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
|
||||
import { loadProjectById } from '../utils/projectStorage.js';
|
||||
|
||||
export default function CommandPalette({ onClose }) {
|
||||
const {
|
||||
readerTheme, authUser, authServerDown, projectIndex, readerBookmarks, readerTextHighlights, readerColorLabels,
|
||||
jumpToReaderVerse, setCurrentPage, setProject, resumeProject, openNewProject, goStudies, openReadingPlans,
|
||||
openBibleReader, requestReaderSearch, openBackup,
|
||||
} = useApp();
|
||||
const [query, setQuery] = useState('');
|
||||
const [active, setActive] = useState(0);
|
||||
const listRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const opener = useRef(document.activeElement);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
const from = opener.current;
|
||||
return () => { if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
|
||||
}, []);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const out = [];
|
||||
const q = query.trim();
|
||||
const go = (fn) => () => { onClose(); fn(); };
|
||||
|
||||
// 1. A passage typed in
|
||||
for (const r of parsePassageRef(q)) {
|
||||
out.push({ group: 'Go to', label: formatPassage(r), hint: 'Open in the Bible', run: go(() => { jumpToReaderVerse(r.book, r.chapter, r.verse ?? 1); openBibleReader(); }) });
|
||||
}
|
||||
|
||||
// 2. Pages and actions
|
||||
const pages = [
|
||||
['Studies', 'My Studies', goStudies], ['Read Bible', 'The Bible reader', openBibleReader], ['Reading Plans', 'Plans and today’s reading', openReadingPlans],
|
||||
['New project', 'Start a Bible study', openNewProject], ['Settings', 'Account and preferences', () => setCurrentPage('settings')],
|
||||
['Back up my data', 'Download everything as a file', openBackup],
|
||||
];
|
||||
const lower = q.toLowerCase();
|
||||
for (const [label, hint, fn] of pages) {
|
||||
if (!lower || label.toLowerCase().includes(lower)) out.push({ group: lower ? 'Pages' : 'Go to', label, hint, run: go(fn) });
|
||||
}
|
||||
|
||||
if (q.length >= 2) {
|
||||
// 3. Studies
|
||||
for (const e of searchProjects(projectIndex, q)) {
|
||||
out.push({ group: 'Studies', label: e.title, hint: e.chapterSummary, run: go(() => resumeProject(e.id)) });
|
||||
}
|
||||
// 4. Notes you wrote
|
||||
const projects = loadProjectIndex().map((e) => loadProjectById(e.id)).filter(Boolean);
|
||||
for (const h of searchNotes(projects, q)) {
|
||||
out.push({
|
||||
group: 'Your notes', label: `${h.ref} — ${h.projectTitle}`, hint: h.snippet,
|
||||
run: go(() => {
|
||||
const full = loadProjectById(h.projectId);
|
||||
if (!full) return;
|
||||
setProject({ ...full, selectedChunkId: h.chunkId });
|
||||
setCurrentPage('study');
|
||||
}),
|
||||
});
|
||||
}
|
||||
// 5. Bookmarks and highlights
|
||||
for (const m of searchMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights, labels: readerColorLabels ?? {} }, q)) {
|
||||
out.push({
|
||||
group: 'Bookmarks & highlights', label: `${m.bookName} ${m.chapter}:${m.verse}`, color: m.color,
|
||||
hint: m.kind === 'highlight' ? `${m.label ? `${m.label}: ` : ''}${m.text ?? 'Highlight'}` : 'Bookmark',
|
||||
run: go(() => { jumpToReaderVerse(m.book, m.chapter, m.verse); openBibleReader(); }),
|
||||
});
|
||||
}
|
||||
// 6. The Bible's own text
|
||||
out.push({ group: 'Search', label: `Search the Bible for “${q}”`, hint: 'Whole-Bible search in the reader', run: go(() => { requestReaderSearch(q); openBibleReader(); }) });
|
||||
}
|
||||
return out;
|
||||
}, [query]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { setActive(0); }, [query]);
|
||||
useEffect(() => { listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest' }); }, [active]);
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(a + 1, items.length - 1)); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); items[active]?.run(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); }
|
||||
else if (e.key === 'Tab') { e.preventDefault(); } // the box and the list are one control
|
||||
};
|
||||
|
||||
if (authUser === null && !authServerDown) return null; // the sign-in screen has nothing to search
|
||||
let lastGroup = null;
|
||||
return (
|
||||
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-start justify-center bg-black/40 px-3 pt-[12vh] font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div role="dialog" aria-modal="true" aria-label="Search and go to" className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] shadow-2xl">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--r-line)] px-4 py-3">
|
||||
<span className="text-[var(--r-sub)]"><SearchIcon /></span>
|
||||
<input
|
||||
ref={inputRef} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKeyDown}
|
||||
role="combobox" aria-expanded="true" aria-controls="palette-list" aria-autocomplete="list"
|
||||
aria-activedescendant={items[active] ? `palette-item-${active}` : undefined}
|
||||
placeholder="Go to a passage, or search your studies and notes…"
|
||||
className="min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-[var(--r-sub)]"
|
||||
/>
|
||||
<kbd className="hidden rounded border border-[var(--r-line)] px-1.5 py-0.5 text-[10px] text-[var(--r-sub)] sm:block">esc</kbd>
|
||||
</div>
|
||||
<ul id="palette-list" role="listbox" ref={listRef} className="max-h-[50vh] overflow-y-auto p-2">
|
||||
{items.length === 0 && <li className="px-3 py-6 text-center text-sm text-[var(--r-sub)]">Nothing found. Try a book and chapter, like “romans 8”.</li>}
|
||||
{items.map((item, i) => {
|
||||
const header = item.group !== lastGroup ? item.group : null;
|
||||
lastGroup = item.group;
|
||||
return (
|
||||
<li key={i} role="presentation">
|
||||
{header && <p className="px-3 pb-1 pt-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{header}</p>}
|
||||
<div id={`palette-item-${i}`} role="option" aria-selected={i === active} onMouseMove={() => setActive(i)} onClick={item.run}
|
||||
className={`flex cursor-pointer items-baseline gap-2 rounded-lg px-3 py-2 text-sm ${i === active ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : ''}`}>
|
||||
{item.color && <span className="h-2.5 w-2.5 shrink-0 translate-y-px rounded-full border border-black/10" style={{ backgroundColor: item.color }} />}
|
||||
<span className="shrink-0 font-medium">{item.label}</span>
|
||||
{item.hint && <span className={`min-w-0 truncate text-xs ${i === active ? 'opacity-80' : 'text-[var(--r-sub)]'}`}>{item.hint}</span>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="border-t border-[var(--r-line)] px-4 py-2 text-[11px] text-[var(--r-sub)]">↑↓ to move · Enter to open · Esc to close</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// The "Save for offline" control: a button, or progress with a Stop button while a download runs.
|
||||
import { canSaveOffline } from '../utils/offline.js';
|
||||
|
||||
export function DownloadProgress({ job, onCancel }) {
|
||||
const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
|
||||
return (
|
||||
<div role="status" className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span>Saving {job.label}… {job.done} of {job.total}</span>
|
||||
<button type="button" onClick={onCancel} className="text-[var(--r-accent)] hover:underline">Stop</button>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)] transition-[width]" style={{ width: `${pct}%` }} /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One book of a translation.
|
||||
export function SaveBookOffline({ offline, translation, book, name }) {
|
||||
if (!canSaveOffline(translation)) {
|
||||
return <p className="mt-4 text-[11px] text-[var(--r-sub)]">Saving for offline works with the BSB and NET. The {translation}’s licence doesn’t allow it.</p>;
|
||||
}
|
||||
if (offline.job) return <div className="mt-4"><DownloadProgress job={offline.job} onCancel={offline.cancel} /></div>;
|
||||
const saved = offline.isSaved(translation, book);
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-between gap-3 rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm">
|
||||
<span className={saved ? '' : 'text-[var(--r-sub)]'}>{saved ? `✓ ${name} (${translation}) is saved for offline` : `Read ${name} without a connection`}</span>
|
||||
{!saved && (
|
||||
<button type="button" onClick={() => offline.saveBook(translation, book, name)} className="shrink-0 rounded-full border border-[var(--r-line)] px-3 py-1 text-xs font-medium hover:bg-black/5">Save for offline</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// An outline laid out for reading (not editing): numbered points, small type tags, notes, verse text. Used
|
||||
// by the read-only share page. Styled to match the printed / PDF page.
|
||||
import { labelFor } from '../utils/outlineLabels.js';
|
||||
import { kindLabel } from '../utils/outlineLabels.js';
|
||||
|
||||
function Points({ nodes, depth }) {
|
||||
return (
|
||||
<ul className={depth === 0 ? '' : 'ml-6 sm:ml-10'}>
|
||||
{nodes.map((n, i) => {
|
||||
const main = depth === 0;
|
||||
const mins = Number.isFinite(n.minutes) && n.minutes > 0;
|
||||
return (
|
||||
<li key={n.id} className={main ? 'mt-7 first:mt-5' : 'mt-2.5'}>
|
||||
<div className="flex gap-3">
|
||||
<span className={`shrink-0 font-semibold tabular-nums text-[var(--r-accent)] ${main ? 'w-9 text-lg' : 'w-8'}`}>{labelFor(depth, i)}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`leading-snug ${main ? 'text-xl font-semibold' : 'text-[1.05rem]'}`}>
|
||||
{n.kind && <span className="mr-2 inline-block -translate-y-px rounded-full border border-[#e4b9a3] px-2 py-0.5 align-middle font-sans text-[10px] font-semibold uppercase tracking-widest text-[var(--r-accent)]">{kindLabel(n.kind)}</span>}
|
||||
{n.text.trim() || <span className="text-[var(--r-sub)]">(untitled)</span>}
|
||||
{n.ref && <span className="ml-2 whitespace-nowrap font-sans text-[0.8rem] font-normal text-[var(--r-sub)]">{n.ref}</span>}
|
||||
{mins && <span className="ml-2 whitespace-nowrap font-sans text-[0.8rem] font-normal text-[var(--r-sub)]">{n.minutes} min</span>}
|
||||
</p>
|
||||
{n.note.trim() && <p className="mt-1 whitespace-pre-line text-[0.95rem] italic leading-relaxed text-[var(--r-sub)]">{n.note.trim()}</p>}
|
||||
{n.scripture?.text && (
|
||||
<blockquote className="mt-2 border-l-[3px] border-[#e4b9a3] pl-4 italic leading-relaxed">
|
||||
“{n.scripture.text}” <span className="whitespace-nowrap font-sans text-[0.8rem] not-italic text-[var(--r-sub)]">— {n.scripture.ref}</span>
|
||||
</blockquote>
|
||||
)}
|
||||
{n.attachments.length > 0 && (
|
||||
<p className="mt-1.5 font-sans text-[0.8rem] leading-relaxed text-[var(--r-sub)]">
|
||||
{n.attachments.map((a, k) => <span key={a.id || k}>{k > 0 && ' · '}{a.type === 'word' ? 'Word' : 'See'}: {a.label}{a.detail ? ` — ${a.detail}` : ''}</span>)}
|
||||
</p>
|
||||
)}
|
||||
{n.children.length > 0 && <Points nodes={n.children} depth={depth + 1} />}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OutlineDocument({ title, meta, tree }) {
|
||||
return (
|
||||
<article className="mx-auto max-w-[44rem] px-5 pb-20 pt-10 font-serif text-[var(--r-text)] sm:px-8 sm:pt-14">
|
||||
<p className="font-sans text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Outline</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold leading-tight tracking-tight sm:text-[2.1rem]">{title}</h1>
|
||||
{meta && <p className="mt-2 font-sans text-sm text-[var(--r-sub)]">{meta}</p>}
|
||||
<hr className="mb-1 mt-6 border-[var(--r-line)]" />
|
||||
{tree.length > 0 ? <Points nodes={tree} depth={0} /> : <p className="mt-8 text-[var(--r-sub)]">Nothing in this outline yet.</p>}
|
||||
<p className="mt-14 border-t border-[var(--r-line)] pt-4 text-center font-sans text-[11px] tracking-wide text-[var(--r-sub)]">Bible Study Project</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// "Forgot password": ask for a code (it is sent to the account's notification topic), then choose a new
|
||||
// password with it. Shown on the sign-in page in place of the sign-in form.
|
||||
import { useState } from 'react';
|
||||
import { requestPasswordReset, resetPassword } from '../syncService.js';
|
||||
|
||||
const input = 'mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 text-sm text-[var(--r-text)]';
|
||||
const primary = 'w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
export default function ResetPasswordForm({ initialEmail = '', onDone, onBack }) {
|
||||
const [step, setStep] = useState('ask'); // 'ask' | 'code' | 'done'
|
||||
const [email, setEmail] = useState(initialEmail);
|
||||
const [form, setForm] = useState({ code: '', password: '', token: '' });
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const ask = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(''); setBusy(true);
|
||||
const result = await requestPasswordReset(email.trim());
|
||||
setBusy(false);
|
||||
if (!result.ok) { setError(result.error ?? 'Something went wrong.'); return; }
|
||||
setMessage(result.data?.message ?? 'If that account has a notification topic, a code was sent.');
|
||||
setStep('code');
|
||||
};
|
||||
|
||||
const reset = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(''); setBusy(true);
|
||||
const result = await resetPassword({ email: email.trim(), code: form.code, newPassword: form.password, token: form.token.trim() || undefined });
|
||||
setBusy(false);
|
||||
if (!result.ok) { setError(result.error ?? 'Something went wrong.'); return; }
|
||||
setStep('done');
|
||||
};
|
||||
|
||||
if (step === 'done') {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-medium">Password changed</h2>
|
||||
<p className="mt-2 text-sm text-[var(--r-sub)]">You’ve been signed out everywhere. Sign in with your new password.</p>
|
||||
<button type="button" onClick={onDone} className={`mt-6 ${primary}`}>Back to sign in</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-medium">Reset your password</h2>
|
||||
{step === 'ask' ? (
|
||||
<form onSubmit={ask} className="mt-4 space-y-4">
|
||||
<p className="text-sm text-[var(--r-sub)]">We’ll send a one-time code to the notification topic you set up for reading reminders (ntfy).</p>
|
||||
<label className="block text-sm">Email
|
||||
<input type="email" required autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} className={input} />
|
||||
</label>
|
||||
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
<button type="submit" disabled={busy} className={primary}>{busy ? 'Please wait…' : 'Send me a code'}</button>
|
||||
<button type="button" onClick={() => { setStep('code'); setMessage(''); }} className="block w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">I already have a code</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={reset} className="mt-4 space-y-4">
|
||||
{message && <p role="status" className="text-sm text-[var(--r-sub)]">{message}</p>}
|
||||
<label className="block text-sm">Email
|
||||
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} className={input} />
|
||||
</label>
|
||||
<label className="block text-sm">Code
|
||||
<input type="text" required autoFocus autoComplete="one-time-code" value={form.code} onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))} className={`${input} text-center font-mono tracking-widest`} placeholder="ABCD2345" />
|
||||
</label>
|
||||
<label className="block text-sm">New password
|
||||
<input type="password" required minLength={8} autoComplete="new-password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} className={input} />
|
||||
<span className="mt-1 block text-xs text-[var(--r-sub)]">At least 8 characters.</span>
|
||||
</label>
|
||||
<label className="block text-sm">Authenticator code <span className="text-xs text-[var(--r-sub)]">(only if you use two-factor sign-in)</span>
|
||||
<input type="text" inputMode="numeric" value={form.token} onChange={(e) => setForm((f) => ({ ...f, token: e.target.value }))} className={input} placeholder="123456" />
|
||||
</label>
|
||||
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
<button type="submit" disabled={busy} className={primary}>{busy ? 'Please wait…' : 'Change password'}</button>
|
||||
</form>
|
||||
)}
|
||||
<button type="button" onClick={onBack} className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Back to sign in</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../syncService.js', () => ({ requestPasswordReset: vi.fn(), resetPassword: vi.fn() }));
|
||||
import ResetPasswordForm from './ResetPasswordForm.jsx';
|
||||
import { requestPasswordReset, resetPassword } from '../syncService.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requestPasswordReset.mockResolvedValue({ ok: true, data: { message: 'A code was sent if you have a topic.' } });
|
||||
resetPassword.mockResolvedValue({ ok: true, data: { ok: true } });
|
||||
});
|
||||
|
||||
test('asks for a code, then sets the new password with it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDone = vi.fn();
|
||||
render(<ResetPasswordForm initialEmail="me@x.co" onDone={onDone} onBack={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: /send me a code/i }));
|
||||
expect(requestPasswordReset).toHaveBeenCalledWith('me@x.co');
|
||||
expect(await screen.findByText(/A code was sent/)).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByLabelText(/^Code/), 'ABCD2345');
|
||||
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
|
||||
await user.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(resetPassword).toHaveBeenCalledWith({ email: 'me@x.co', code: 'ABCD2345', newPassword: 'brand-new-pass', token: undefined });
|
||||
await user.click(await screen.findByRole('button', { name: /back to sign in/i }));
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a wrong code shows the server\'s message and stays on the form', async () => {
|
||||
resetPassword.mockResolvedValue({ ok: false, error: 'That code is wrong or has expired. Ask for a new one.' });
|
||||
const user = userEvent.setup();
|
||||
render(<ResetPasswordForm initialEmail="me@x.co" onDone={() => {}} onBack={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: /i already have a code/i }));
|
||||
await user.type(screen.getByLabelText(/^Code/), 'WRONG123');
|
||||
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
|
||||
await user.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong or has expired/);
|
||||
});
|
||||
|
||||
test('an authenticator code is passed along when one is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ResetPasswordForm initialEmail="me@x.co" onDone={() => {}} onBack={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: /i already have a code/i }));
|
||||
await user.type(screen.getByLabelText(/^Code/), 'ABCD2345');
|
||||
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
|
||||
await user.type(screen.getByLabelText(/Authenticator code/), '123456');
|
||||
await user.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(resetPassword).toHaveBeenCalledWith(expect.objectContaining({ token: '123456' }));
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Share an outline: a read-only link anyone can open (no account), or the plain text.
|
||||
import { useState } from 'react';
|
||||
import Sheet from './reader/Sheet.jsx';
|
||||
import { useToast } from './Toasts.jsx';
|
||||
import { useOutlineShareLink } from '../hooks/useOutlineShareLink.js';
|
||||
import { shareOutline } from '../utils/outlineExport.js';
|
||||
|
||||
const btn = 'rounded-xl border border-[var(--r-line)] px-3.5 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
|
||||
|
||||
export default function ShareOutlineSheet({ open, onClose, projectId, outline }) {
|
||||
const { toast } = useToast();
|
||||
const link = useOutlineShareLink(projectId, open);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyLink = async () => {
|
||||
try { await navigator.clipboard.writeText(link.url); setCopied(true); window.setTimeout(() => setCopied(false), 2000); } catch { toast('Couldn’t copy. Select the link and copy it.', { tone: 'error' }); }
|
||||
};
|
||||
// The phone/computer share sheet, with the link (falls back to copying it)
|
||||
const shareLink = async () => {
|
||||
if (navigator.share) {
|
||||
try { await navigator.share({ title: `${outline.title} — Outline`, url: link.url }); return; } catch (err) { if (err?.name === 'AbortError') return; }
|
||||
}
|
||||
copyLink();
|
||||
};
|
||||
const shareText = async () => {
|
||||
const result = await shareOutline(outline);
|
||||
if (result === 'copied') toast('Outline copied. Paste it into a message or email.');
|
||||
else if (result === 'failed') toast('Couldn’t share or copy the outline.', { tone: 'error' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="Share this outline">
|
||||
<h3 className="text-sm font-medium">Read-only link</h3>
|
||||
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">
|
||||
Anyone with the link can read this study’s outlines (the whole-study one and each chunk’s), and print them. They can’t edit anything, and they can’t see your notes or scripts. No account needed.
|
||||
</p>
|
||||
{link.state === 'loading' && <p className="text-sm text-[var(--r-sub)]">Checking…</p>}
|
||||
{link.state === 'unavailable' && (
|
||||
<p className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm text-[var(--r-sub)]">Links need your account and a connection to the server. You can still share the text below.</p>
|
||||
)}
|
||||
{link.state === 'ready' && (link.token ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input readOnly aria-label="Outline link" value={link.url} onFocus={(e) => e.target.select()}
|
||||
className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
|
||||
<button type="button" onClick={copyLink} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{copied ? 'Copied!' : 'Copy'}</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button type="button" onClick={shareLink} className={btn}>Share link…</button>
|
||||
<button type="button" onClick={link.turnOff} disabled={link.busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{link.busy ? 'Turning off…' : 'Turn off link'}</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--r-sub)]">People see the version that last synced to your account, and it updates as you edit. Turning the link off stops it working right away.</p>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={link.create} disabled={link.busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{link.busy ? 'Creating…' : 'Create read-only link'}</button>
|
||||
))}
|
||||
{link.error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{link.error}</p>}
|
||||
|
||||
<div className="mt-5 border-t border-[var(--r-line)] pt-4">
|
||||
<h3 className="text-sm font-medium">Or send the text</h3>
|
||||
<p className="mb-2 mt-1 text-xs text-[var(--r-sub)]">The outline you’re looking at, as plain text, to paste into a message or email.</p>
|
||||
<button type="button" onClick={shareText} className={btn}>Share as text</button>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// What someone sees when they open a read-only outline link: a calm page with the outline, a switcher
|
||||
// when there is more than one, and Print / PDF. Nothing is editable.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getSharedOutline } from '../syncService.js';
|
||||
import { themeVars } from './reader/readerThemes.js';
|
||||
import OutlineDocument from './OutlineDocument.jsx';
|
||||
import { printOutline } from '../utils/outlineExport.js';
|
||||
import { flatten } from '../utils/outline.js';
|
||||
|
||||
const pill = (active) => `shrink-0 rounded-full px-3.5 py-1.5 text-sm transition ${active ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`;
|
||||
|
||||
function Frame({ children }) {
|
||||
return <div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars('paper')}>{children}</div>;
|
||||
}
|
||||
|
||||
export default function SharedOutlineView({ token }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [scope, setScope] = useState('study');
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getSharedOutline(token).then((result) => {
|
||||
if (!active) return;
|
||||
if (result.ok) { setData(result.data); setScope(result.data.outlines[0]?.scope ?? 'study'); } else setError(result.error ?? 'This outline link is invalid or has been turned off.');
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { if (data?.title) document.title = `${data.title} — Outline`; }, [data]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Frame>
|
||||
<div className="flex min-h-dvh items-center justify-center px-5">
|
||||
<div className="max-w-sm text-center">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--r-accent)]">Bible Study Project</p>
|
||||
<p className="mt-4 text-base">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
if (!data) return <Frame><p className="flex min-h-dvh items-center justify-center text-sm text-[var(--r-sub)]">Loading outline…</p></Frame>;
|
||||
|
||||
const current = data.outlines.find((o) => o.scope === scope) ?? data.outlines[0];
|
||||
const title = current && current.scope !== 'study' ? `${data.title} — ${current.label}` : data.title;
|
||||
const minutes = current ? flatten(current.tree).reduce((sum, { node }) => sum + (Number.isFinite(node.minutes) ? node.minutes : 0), 0) : 0;
|
||||
const meta = current ? [current.scope === 'study' ? data.translation : `${current.label} · ${data.translation}`, minutes > 0 ? `About ${minutes} minute${minutes === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ') : '';
|
||||
const outline = current ? { title, subtitle: current.scope === 'study' ? data.translation : `${current.label} · ${data.translation}`, tree: current.tree } : null;
|
||||
|
||||
return (
|
||||
<Frame>
|
||||
<header className="sticky top-0 z-10 border-b border-[var(--r-line)] bg-[var(--r-bar)]/95 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-[44rem] flex-wrap items-center gap-x-3 gap-y-2 px-5 py-2.5 sm:px-8">
|
||||
<span className="shrink-0 text-sm font-medium">Bible Study</span>
|
||||
{data.outlines.length > 1 ? (
|
||||
<nav aria-label="Outline" className="order-last flex w-full min-w-0 gap-1 overflow-x-auto sm:order-none sm:w-auto sm:flex-1">
|
||||
{data.outlines.map((o) => (
|
||||
<button key={o.scope} type="button" onClick={() => setScope(o.scope)} aria-current={current?.scope === o.scope ? 'true' : undefined} className={pill(current?.scope === o.scope)}>{o.label}</button>
|
||||
))}
|
||||
</nav>
|
||||
) : <span className="flex-1" />}
|
||||
{outline && <button type="button" onClick={() => printOutline(outline)} className="ml-auto shrink-0 sm:ml-0 rounded-full border border-[var(--r-line)] px-3.5 py-1.5 text-sm hover:bg-black/5">Print / PDF</button>}
|
||||
</div>
|
||||
</header>
|
||||
{current ? <OutlineDocument title={title} meta={meta} tree={current.tree} /> : <p className="p-10 text-center text-sm text-[var(--r-sub)]">This study doesn’t have an outline yet.</p>}
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// The "?" cheat sheet: every keyboard shortcut for the page you are on, plus the ones that work everywhere.
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { themeVars } from './reader/readerThemes.js';
|
||||
|
||||
const GLOBAL = [
|
||||
['⌘K / Ctrl K', 'Search and go to a passage'],
|
||||
['?', 'Show this list'],
|
||||
['Esc', 'Close whatever is open'],
|
||||
];
|
||||
const BY_PAGE = {
|
||||
reader: [
|
||||
['← →', 'Previous / next chapter'],
|
||||
['/', 'Search this chapter or the whole Bible'],
|
||||
['G', 'Pick a book and chapter'],
|
||||
['B', 'Bookmark the selected verse'],
|
||||
['C', 'Compare translations'],
|
||||
['H', 'Your highlights'],
|
||||
[',', 'Reading settings'],
|
||||
['F', 'Hide or show the controls'],
|
||||
],
|
||||
study: [
|
||||
['← →', 'Previous / next chunk'],
|
||||
],
|
||||
};
|
||||
|
||||
export default function ShortcutsHelp({ onClose }) {
|
||||
const { readerTheme, currentPage } = useApp();
|
||||
const closeRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const from = document.activeElement;
|
||||
closeRef.current?.focus();
|
||||
const onKey = (e) => { if (e.key === 'Escape' || e.key === '?') { e.preventDefault(); e.stopPropagation(); onClose(); } };
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => { document.removeEventListener('keydown', onKey, true); if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
|
||||
}, [onClose]);
|
||||
|
||||
const page = BY_PAGE[currentPage] ?? [];
|
||||
const section = (title, rows) => (
|
||||
<div className="mb-4 last:mb-0">
|
||||
<h3 className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{title}</h3>
|
||||
<dl className="space-y-1.5">
|
||||
{rows.map(([keys, what]) => (
|
||||
<div key={keys} className="flex items-center justify-between gap-4 text-sm">
|
||||
<dt><kbd className="rounded-md border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 font-mono text-xs">{keys}</kbd></dt>
|
||||
<dd className="text-right text-[var(--r-sub)]">{what}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-3 font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" className="max-h-[80vh] w-full max-w-sm overflow-y-auto rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5 text-[var(--r-text)] shadow-2xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-base font-medium">Keyboard shortcuts</h2>
|
||||
<button ref={closeRef} type="button" onClick={onClose} className="rounded-lg px-2 py-1 text-sm text-[var(--r-sub)] hover:bg-black/5">Close</button>
|
||||
</div>
|
||||
{page.length > 0 && section(currentPage === 'reader' ? 'In the Bible' : 'In this study', page)}
|
||||
{section('Everywhere', GLOBAL)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Inline study panel shown under a verse in the Reader: commentary from several
|
||||
// public-domain / open sources, plus the people, places and events tied to the verse.
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { useIqAvailable } from '../hooks/useIq.js';
|
||||
import IqCommentary from './iq/IqCommentary.jsx';
|
||||
import {
|
||||
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
|
||||
} from '../hooks/useVerseStudy.js';
|
||||
|
||||
export function CommentaryTab({ book, chapter, verse }) {
|
||||
const iq = useIqAvailable();
|
||||
const [more, setMore] = useState(() => { try { return localStorage.getItem('iq-commentary-mode') === '1'; } catch { return false; } });
|
||||
const setMode = (on) => { setMore(on); try { localStorage.setItem('iq-commentary-mode', on ? '1' : '0'); } catch { /* storage unavailable */ } };
|
||||
const chip = (on) => `rounded-full border px-2.5 py-0.5 text-[11px] ${on ? 'border-[var(--r-text,#334155)] bg-[var(--r-text,#334155)] text-[var(--r-bg,#ffffff)]' : 'border-[var(--r-line,#cbd5e1)] text-[var(--r-sub,#64748b)]'}`;
|
||||
return (
|
||||
<div>
|
||||
{iq && (
|
||||
<div className="mb-2 flex gap-1.5" role="group" aria-label="Commentary library">
|
||||
<button type="button" onClick={() => setMode(false)} aria-pressed={!more} className={chip(!more)}>Classic</button>
|
||||
<button type="button" onClick={() => setMode(true)} aria-pressed={more} className={chip(more)}>More sources</button>
|
||||
</div>
|
||||
)}
|
||||
{iq && more ? <IqCommentary book={book} chapter={chapter} verse={verse} /> : <ClassicCommentary book={book} chapter={chapter} verse={verse} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicCommentary({ book, chapter, verse }) {
|
||||
// The chosen commentary is a synced preference, shared with the Study stage
|
||||
const { readerCommentary: sourceId, setReaderCommentary: changeSource } = useApp();
|
||||
const source = COMMENTARIES.find((c) => c.id === sourceId) ?? COMMENTARIES[0];
|
||||
const { status, data } = useAsync(() => loadCommentary(source.id, book, chapter), [source.id, book, chapter]);
|
||||
const entry = pickCommentaryEntry(data, verse);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-[var(--r-sub,#64748b)]">
|
||||
Commentary{' '}
|
||||
<select value={source.id} onChange={(e) => changeSource(e.target.value)}
|
||||
className="rounded-lg border border-[var(--r-line,#cbd5e1)] bg-[var(--r-bg,#ffffff)] px-2 py-1 text-xs">
|
||||
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
{status === 'loading' && <p className="text-xs text-[var(--r-sub,#64748b)]">Loading…</p>}
|
||||
{status === 'empty' && <p className="text-xs text-[var(--r-sub,#64748b)]">{source.name} has no notes for this chapter.</p>}
|
||||
{status === 'ready' && !entry && <p className="text-xs text-[var(--r-sub,#64748b)]">No notes at or before this verse in {source.name}.</p>}
|
||||
{status === 'ready' && entry && (
|
||||
<>
|
||||
{entry.number !== verse && (
|
||||
<p className="mb-1 text-[11px] text-[var(--r-sub,#64748b)]">Notes on the passage starting at verse {entry.number}.</p>
|
||||
)}
|
||||
<div className="max-h-96 overflow-y-auto whitespace-pre-line rounded-xl bg-[var(--r-bg,#ffffff)] p-3 text-[13px] leading-relaxed text-[var(--r-text,#334155)]">
|
||||
{entry.text}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-[var(--r-sub,#64748b)]">{source.credit}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonDetails({ id }) {
|
||||
const { status, data } = useAsync(() => loadPerson(id), [id]);
|
||||
if (status === 'loading') return <p className="mt-1 text-xs text-[var(--r-sub,#64748b)]">Loading…</p>;
|
||||
if (status === 'empty') return <p className="mt-1 text-xs text-[var(--r-sub,#64748b)]">No details available.</p>;
|
||||
const era = (y) => `${Math.abs(y)}${y < 0 ? ' BC' : ' AD'}`;
|
||||
return (
|
||||
<div className="mt-1 space-y-1 text-xs leading-relaxed text-[var(--r-sub,#64748b)]">
|
||||
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
|
||||
{!data.description?.length && <p>No description available.</p>}
|
||||
{data.minYear != null && (
|
||||
<p className="text-[var(--r-sub,#64748b)]">
|
||||
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonCard({ person }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<li className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2">
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} className="w-full text-left text-sm font-semibold text-[var(--r-text,#334155)]">
|
||||
{person.name} <span className="text-xs font-normal text-[var(--r-sub,#64748b)]">{open ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{open && <PersonDetails id={person.id} />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function PeoplePlacesTab({ book, chapter, verse }) {
|
||||
const { status, data } = useAsync(() => loadTheographic(book, chapter), [book, chapter]);
|
||||
if (status === 'loading') return <p className="text-xs text-[var(--r-sub,#64748b)]">Loading…</p>;
|
||||
if (status === 'empty') return <p className="text-xs text-[var(--r-sub,#64748b)]">No people or places data for this chapter.</p>;
|
||||
const here = (list) => list.filter((x) => x.verses?.includes(verse));
|
||||
// Theographic lists some places twice (variant spellings) — collapse by name.
|
||||
const places = [...new Map(here(data.places).map((p) => [p.name, p])).values()];
|
||||
const people = here(data.people);
|
||||
const events = here(data.events);
|
||||
if (!people.length && !places.length && !events.length) {
|
||||
return <p className="text-xs text-[var(--r-sub,#64748b)]">Nothing tagged on verse {verse}.</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{people.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub,#64748b)]">People</p>
|
||||
<ul className="space-y-1">{people.map((p) => <PersonCard key={p.id} person={p} />)}</ul>
|
||||
</div>
|
||||
)}
|
||||
{places.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub,#64748b)]">Places</p>
|
||||
<ul className="space-y-1">
|
||||
{places.map((p) => (
|
||||
<li key={p.id} className="flex items-center justify-between gap-2 rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2 text-sm text-[var(--r-text,#334155)]">
|
||||
<span>{p.name}{p.featureType && <span className="ml-1 text-xs text-[var(--r-sub,#64748b)]">{p.featureType}</span>}</span>
|
||||
{p.latitude != null && p.longitude != null && (
|
||||
<a href={`https://www.openstreetmap.org/?mlat=${p.latitude}&mlon=${p.longitude}#map=8/${p.latitude}/${p.longitude}`}
|
||||
target="_blank" rel="noopener noreferrer" className="text-xs font-semibold text-sky-600 hover:underline">
|
||||
Map ↗
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{events.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub,#64748b)]">Events</p>
|
||||
<ul className="space-y-1">
|
||||
{events.map((e) => (
|
||||
<li key={e.id} className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2 text-sm text-[var(--r-text,#334155)]">
|
||||
{e.name}{e.startDate && <span className="ml-1 text-xs text-[var(--r-sub,#64748b)]">approx. AD {e.startDate}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-[var(--r-sub,#64748b)]">
|
||||
People, places and events: Theographic Bible Metadata, licensed CC BY-SA 4.0.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StudyPanel({ book, chapter, verse, onClose }) {
|
||||
const [tab, setTab] = useState('commentary');
|
||||
const tabBtn = (id, label) => (
|
||||
<button type="button" onClick={() => setTab(id)}
|
||||
className={`rounded-lg px-3 py-1 text-xs font-semibold ${tab === id ? 'bg-slate-900 text-white' : 'bg-[var(--r-bg,#ffffff)] text-[var(--r-sub,#64748b)] hover:bg-slate-100'}`}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div className="mt-2 mb-1 ml-6 rounded-2xl border border-indigo-100 bg-indigo-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex gap-1">
|
||||
{tabBtn('commentary', 'Commentary')}
|
||||
{tabBtn('people', 'People & Places')}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="rounded p-1 text-xs text-[var(--r-sub,#64748b)] hover:bg-[var(--r-bg,#ffffff)]" title="Close">✕</button>
|
||||
</div>
|
||||
{tab === 'commentary'
|
||||
? <CommentaryTab book={book} chapter={chapter} verse={verse} />
|
||||
: <PeoplePlacesTab book={book} chapter={chapter} verse={verse} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// App-wide toasts: short messages at the bottom of the screen, optionally with one
|
||||
// action button (usually "Undo"). Used instead of alert()/confirm() so a slip is
|
||||
// reversible and nothing blocks the page.
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { themeVars } from './reader/readerThemes.js';
|
||||
|
||||
const ToastContext = createContext(null);
|
||||
|
||||
// toast(message, { action: { label, onClick }, ms, tone: 'info' | 'error' }) -> id
|
||||
// Outside a provider (a page rendered on its own in a test) it quietly does nothing.
|
||||
const NOOP = { toast: () => 0, dismiss: () => {}, setTheme: () => {} };
|
||||
export const useToast = () => useContext(ToastContext) ?? NOOP;
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
export function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const [theme, setTheme] = useState('paper'); // set by the app so toasts match the reading theme
|
||||
const timers = useRef(new Map());
|
||||
|
||||
const dismiss = useCallback((id) => {
|
||||
window.clearTimeout(timers.current.get(id));
|
||||
timers.current.delete(id);
|
||||
setToasts((list) => list.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const toast = useCallback((message, { action, ms, tone = 'info' } = {}) => {
|
||||
const id = nextId++;
|
||||
// Messages with an action stay long enough to read and react to.
|
||||
const duration = ms ?? (action ? 8000 : tone === 'error' ? 6000 : 3500);
|
||||
setToasts((list) => [...list.slice(-2), { id, message, action, tone }]);
|
||||
timers.current.set(id, window.setTimeout(() => dismiss(id), duration));
|
||||
return id;
|
||||
}, [dismiss]);
|
||||
|
||||
useEffect(() => {
|
||||
const live = timers.current;
|
||||
return () => live.forEach((t) => window.clearTimeout(t));
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ toast, dismiss, setTheme }), [toast, dismiss]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div style={themeVars(theme)} className="pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] font-sans">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} role={t.tone === 'error' ? 'alert' : 'status'}
|
||||
className={`pointer-events-auto flex max-w-md items-center gap-3 rounded-full px-4 py-2.5 text-sm shadow-lg ${t.tone === 'error' ? 'bg-[var(--r-danger)] text-[var(--r-bg)]' : 'bg-[var(--r-text)] text-[var(--r-bg)]'}`}>
|
||||
<span>{t.message}</span>
|
||||
{t.action && (
|
||||
<button type="button" onClick={() => { t.action.onClick(); dismiss(t.id); }}
|
||||
className="shrink-0 rounded-full px-2 py-0.5 text-sm font-semibold underline underline-offset-2 hover:opacity-80">
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Hover (and keyboard-focus) tooltips for the whole app, without touching every button:
|
||||
// - anything with data-tip="…" shows that text (used for shortcut hints like "Search (/)")
|
||||
// - anything with title="…" shows it too, in the app's style and without the browser's long delay
|
||||
// - an icon-only button (no visible words) shows its aria-label
|
||||
// Touch screens have no hover, so nothing appears there.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { themeVars } from './reader/readerThemes.js';
|
||||
|
||||
const SHOW_DELAY = 450;
|
||||
const SELECTOR = '[data-tip], [title], button[aria-label], a[aria-label], [role="button"][aria-label], [role="tab"][aria-label]';
|
||||
|
||||
const canHover = () => (typeof window.matchMedia === 'function' ? window.matchMedia('(hover: hover)').matches : true);
|
||||
|
||||
// The words a tooltip should show for an element, or '' for none. A title is moved to data-tip the first
|
||||
// time (so the browser's own tooltip doesn't also appear); a title on a button with no other name becomes
|
||||
// its aria-label so screen readers keep it.
|
||||
export function tipFor(el) {
|
||||
if (!(el instanceof HTMLElement)) return '';
|
||||
if (el.hasAttribute('title')) {
|
||||
const title = el.getAttribute('title').trim();
|
||||
if (title) {
|
||||
if (!el.getAttribute('aria-label') && el.textContent.replace(/\s+/g, '').length <= 2) el.setAttribute('aria-label', title);
|
||||
if (!el.hasAttribute('data-tip')) el.setAttribute('data-tip', title);
|
||||
}
|
||||
el.removeAttribute('title');
|
||||
}
|
||||
const tip = el.getAttribute('data-tip');
|
||||
if (tip) return tip;
|
||||
// Icon-only controls (little or no text of their own) explain themselves with their label
|
||||
const label = el.getAttribute('aria-label');
|
||||
const visible = el.textContent.replace(/\s+/g, '').length;
|
||||
return label && visible <= 2 ? label : '';
|
||||
}
|
||||
|
||||
export default function Tooltips({ theme }) {
|
||||
const [tip, setTip] = useState(null); // { text, x, y, below }
|
||||
const timer = useRef(null);
|
||||
const current = useRef(null);
|
||||
const box = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const hide = () => {
|
||||
window.clearTimeout(timer.current);
|
||||
if (current.current) current.current.removeAttribute('aria-describedby');
|
||||
current.current = null;
|
||||
setTip(null);
|
||||
};
|
||||
const show = (el, text) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
current.current = el;
|
||||
el.setAttribute('aria-describedby', 'app-tooltip');
|
||||
setTip({ text, x: r.left + r.width / 2, y: r.top, bottom: r.bottom });
|
||||
};
|
||||
const find = (node) => (node instanceof Element ? node.closest(SELECTOR) : null);
|
||||
|
||||
const onOver = (e) => {
|
||||
if (!canHover()) return;
|
||||
const el = find(e.target);
|
||||
if (!el || el === current.current) return;
|
||||
hide();
|
||||
const text = tipFor(el);
|
||||
if (!text || el.disabled) return;
|
||||
timer.current = window.setTimeout(() => show(el, text), SHOW_DELAY);
|
||||
};
|
||||
const onOut = (e) => {
|
||||
const el = find(e.target);
|
||||
if (!el || (e.relatedTarget instanceof Node && el.contains(e.relatedTarget))) return;
|
||||
hide();
|
||||
};
|
||||
const onFocus = (e) => {
|
||||
const el = find(e.target);
|
||||
if (!el || !el.matches(':focus-visible')) return; // only keyboard focus, not a mouse click
|
||||
const text = tipFor(el);
|
||||
if (text) { window.clearTimeout(timer.current); show(el, text); }
|
||||
};
|
||||
const onKey = (e) => { if (e.key === 'Escape') hide(); };
|
||||
|
||||
document.addEventListener('pointerover', onOver);
|
||||
document.addEventListener('pointerout', onOut);
|
||||
document.addEventListener('pointerdown', hide, true);
|
||||
document.addEventListener('focusin', onFocus);
|
||||
document.addEventListener('focusout', hide);
|
||||
document.addEventListener('keydown', onKey);
|
||||
window.addEventListener('scroll', hide, true);
|
||||
window.addEventListener('resize', hide);
|
||||
return () => {
|
||||
window.clearTimeout(timer.current);
|
||||
document.removeEventListener('pointerover', onOver);
|
||||
document.removeEventListener('pointerout', onOut);
|
||||
document.removeEventListener('pointerdown', hide, true);
|
||||
document.removeEventListener('focusin', onFocus);
|
||||
document.removeEventListener('focusout', hide);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('scroll', hide, true);
|
||||
window.removeEventListener('resize', hide);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!tip) return null;
|
||||
// Centred over the control, kept inside the window, and flipped below it when there's no room above
|
||||
const width = box.current?.offsetWidth ?? 0;
|
||||
const left = Math.max(8, Math.min(tip.x - width / 2, window.innerWidth - width - 8));
|
||||
const flip = tip.y < 40;
|
||||
return (
|
||||
<div
|
||||
ref={box} id="app-tooltip" role="tooltip" style={{ ...themeVars(theme), left, top: flip ? tip.bottom + 8 : tip.y - 8, transform: flip ? 'none' : 'translateY(-100%)' }}
|
||||
className="pointer-events-none fixed z-[120] max-w-[16rem] rounded-lg bg-[var(--r-text)] px-2.5 py-1.5 font-sans text-xs leading-snug text-[var(--r-bg)] shadow-lg"
|
||||
>
|
||||
{tip.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { vi, beforeEach, afterEach, test, expect } from 'vitest';
|
||||
import Tooltips, { tipFor } from './Tooltips.jsx';
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const over = (el) => act(() => { fireEvent.pointerOver(el); vi.advanceTimersByTime(600); });
|
||||
|
||||
function setup() {
|
||||
render(
|
||||
<>
|
||||
<button aria-label="Search" data-tip="Search (/)">🔍</button>
|
||||
<button aria-label="Close">×</button>
|
||||
<button title="Out a level (Shift+Tab)">←</button>
|
||||
<button aria-label="Save changes">Save changes</button>
|
||||
<button disabled aria-label="Nothing">·</button>
|
||||
<Tooltips theme="paper" />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
test('shows a data-tip after a short pause, and hides when the pointer leaves', () => {
|
||||
setup();
|
||||
const btn = screen.getByLabelText('Search');
|
||||
over(btn);
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Search (/)');
|
||||
expect(btn).toHaveAttribute('aria-describedby', 'app-tooltip');
|
||||
act(() => { fireEvent.pointerOut(btn); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
expect(btn).not.toHaveAttribute('aria-describedby');
|
||||
});
|
||||
|
||||
test('does not appear before the pause', () => {
|
||||
setup();
|
||||
act(() => { fireEvent.pointerOver(screen.getByLabelText('Search')); vi.advanceTimersByTime(100); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('an icon-only button shows its label', () => {
|
||||
setup();
|
||||
over(screen.getByLabelText('Close'));
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Close');
|
||||
});
|
||||
|
||||
test('a title is shown in the app style and the browser\'s own tooltip is turned off', () => {
|
||||
setup();
|
||||
const btn = screen.getByText('←');
|
||||
over(btn);
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Out a level (Shift+Tab)');
|
||||
expect(btn).not.toHaveAttribute('title');
|
||||
expect(btn).toHaveAttribute('aria-label', 'Out a level (Shift+Tab)'); // a screen reader still gets its name
|
||||
});
|
||||
|
||||
test('a button that already says what it is gets no tooltip, and neither does a disabled one', () => {
|
||||
setup();
|
||||
over(screen.getByText('Save changes'));
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
over(screen.getByLabelText('Nothing'));
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking, scrolling or Escape dismisses it', () => {
|
||||
setup();
|
||||
const btn = screen.getByLabelText('Search');
|
||||
over(btn);
|
||||
act(() => { fireEvent.pointerDown(btn); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
over(btn);
|
||||
act(() => { fireEvent.keyDown(document, { key: 'Escape' }); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
over(btn);
|
||||
act(() => { fireEvent.scroll(window); });
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('nothing shows on a device without hover (touch)', () => {
|
||||
vi.stubGlobal('matchMedia', () => ({ matches: false }));
|
||||
setup();
|
||||
over(screen.getByLabelText('Search'));
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('tipFor ignores things that are not elements', () => {
|
||||
expect(tipFor(null)).toBe('');
|
||||
expect(tipFor(document.createTextNode('x'))).toBe('');
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// More commentaries from IQ Bible: Gill and Matthew Henry plus hundreds of church-history sources.
|
||||
// Only sources that actually have notes on the chapter being read are offered.
|
||||
import { useState } from 'react';
|
||||
import { useIq } from '../../hooks/useIq.js';
|
||||
import { listOf, pickIqEntries } from '../../utils/iqbible.js';
|
||||
|
||||
const KEY = 'iq-commentary';
|
||||
const muted = 'text-xs text-[var(--r-sub,#64748b)]';
|
||||
|
||||
export default function IqCommentary({ book, chapter, verse }) {
|
||||
const [chosen, setChosen] = useState(() => { try { return localStorage.getItem(KEY) || 'mhenry'; } catch { return 'mhenry'; } });
|
||||
const sources = useIq(`books/${book}/commentaries`, { chapter });
|
||||
const list = listOf(sources.data);
|
||||
const source = list.find((s) => s.name === chosen) ?? list.find((s) => s.name === 'mhenry') ?? list[0];
|
||||
const notes = useIq(source ? `commentaries/${source.name}/${book}/${chapter}` : null);
|
||||
const entries = pickIqEntries(notes.data?.entries, chapter, verse);
|
||||
|
||||
const pick = (name) => { setChosen(name); try { localStorage.setItem(KEY, name); } catch { /* storage unavailable */ } };
|
||||
const groups = list.reduce((acc, s) => { (acc[s.category || 'Other'] ??= []).push(s); return acc; }, {});
|
||||
|
||||
if (sources.status === 'loading') return <p className={muted}>Loading…</p>;
|
||||
if (sources.status === 'error') return <p className={muted}>{sources.error}</p>;
|
||||
if (!list.length) return <p className={muted}>No IQ Bible commentaries cover this chapter.</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className={muted}>
|
||||
Source ({list.length}){' '}
|
||||
<select value={source?.name ?? ''} onChange={(e) => pick(e.target.value)} className="max-w-[16rem] rounded-lg border border-[var(--r-line,#cbd5e1)] bg-[var(--r-bg,#ffffff)] px-2 py-1 text-xs">
|
||||
{Object.entries(groups).map(([cat, items]) => (
|
||||
<optgroup key={cat} label={cat}>
|
||||
{items.map((s) => <option key={s.name} value={s.name}>{s.author_name}{s.year ? ` (${s.year})` : ''}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="mt-2 space-y-2">
|
||||
{notes.status === 'loading' && <p className={muted}>Loading…</p>}
|
||||
{notes.status === 'error' && <p className={muted}>{notes.error}</p>}
|
||||
{(notes.status === 'empty' || (notes.status === 'ready' && entries.length === 0)) && (
|
||||
<p className={muted}>{source?.author_name} has no notes at or before verse {verse} in this chapter.</p>
|
||||
)}
|
||||
{entries.map((e, i) => (
|
||||
<div key={i}>
|
||||
<p className="mb-1 text-[11px] text-[var(--r-sub,#64748b)]">
|
||||
{e.verse !== verse || e.verse_end ? `Notes on verse${e.verse_end && e.verse_end !== e.verse ? `s ${e.verse}–${e.verse_end}` : ` ${e.verse}`}` : ''}
|
||||
{e.source_title ? ` · ${e.source_title}` : ''}
|
||||
</p>
|
||||
<div className="max-h-96 overflow-y-auto whitespace-pre-line rounded-xl bg-[var(--r-bg,#ffffff)] p-3 text-[13px] leading-relaxed text-[var(--r-text,#334155)]">{e.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{source && <p className="mt-2 text-[10px] text-[var(--r-sub,#64748b)]">{source.author_name}{source.year ? `, ${source.year}` : ''} · via IQ Bible{source.license ? ` · ${source.license}` : ''}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Home card: today's verse and Spurgeon's Morning & Evening reading, both from IQ Bible.
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../../context/AppContext.js';
|
||||
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
|
||||
import { bookOptions } from '../../utils/bibleUtils.jsx';
|
||||
|
||||
export default function IqDailyCard() {
|
||||
const on = useIqAvailable();
|
||||
if (!on) return null;
|
||||
return <Card />;
|
||||
}
|
||||
|
||||
function Card() {
|
||||
const { jumpToReaderVerse, openBibleReader } = useApp();
|
||||
const votd = useIq('bibles/eng_kjv/verse-of-the-day');
|
||||
const dev = useIq('devotionals/today');
|
||||
const [part, setPart] = useState(() => (new Date().getHours() < 15 ? 'morning' : 'evening'));
|
||||
const verse = votd.data?.data;
|
||||
const reading = dev.data?.data?.[part];
|
||||
const name = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
|
||||
if (!verse && !reading) return null;
|
||||
return (
|
||||
<section className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Daily verse and devotional">
|
||||
{verse && (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day · KJV</p>
|
||||
<p className="mt-1 font-serif text-[15px] leading-relaxed">{verse.text}</p>
|
||||
<button type="button" onClick={() => { jumpToReaderVerse(verse.book_usfm, verse.chapter_number, verse.verse_number); openBibleReader(); }}
|
||||
className="mt-1 text-xs font-medium text-[var(--r-accent)] hover:underline">{name(verse.book_usfm)} {verse.chapter_number}:{verse.verse_number} — read in context</button>
|
||||
</div>
|
||||
)}
|
||||
{reading && (
|
||||
<details className="mt-3 border-t border-[var(--r-line)] pt-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">Spurgeon, {part === 'morning' ? 'Morning' : 'Evening'}: “{reading.heading_text}”</summary>
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
{['morning', 'evening'].map((p) => (
|
||||
<button key={p} type="button" onClick={() => setPart(p)} aria-pressed={part === p}
|
||||
className={`rounded-full border px-2.5 py-0.5 text-[11px] ${part === p ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)]'}`}>{p === 'morning' ? 'Morning' : 'Evening'}</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">{reading.heading_citation}</p>
|
||||
<div className="mt-2 max-h-72 overflow-y-auto whitespace-pre-line font-serif text-[15px] leading-relaxed">{reading.body}</div>
|
||||
<p className="mt-2 text-[10px] text-[var(--r-sub)]">Charles Spurgeon, Morning and Evening (1866), via IQ Bible.</p>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// A word's entry in the original-language lexicons: Strong's, then Brown-Driver-Briggs (Hebrew) or
|
||||
// Liddell-Scott-Jones and Abbott-Smith (Greek), found through IQ Bible's Strong's crosswalk.
|
||||
import { useState } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useIq } from '../../hooks/useIq.js';
|
||||
import { listOf, normalizeStrongs } from '../../utils/iqbible.js';
|
||||
|
||||
const muted = 'text-xs text-[var(--r-sub,#64748b)]';
|
||||
const SOURCES = { bdb: 'Brown-Driver-Briggs', lsj: 'Liddell-Scott-Jones', 'abbott-smith': 'Abbott-Smith' };
|
||||
const html = (s) => DOMPurify.sanitize(String(s ?? ''), { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'br', 'p', 'span', 'ul', 'ol', 'li', 'sup', 'sub'], ALLOWED_ATTR: [] });
|
||||
|
||||
function SourceEntry({ lang, source, nativeKey }) {
|
||||
const { status, data, error } = useIq(`lexicon/${lang}/${source}/${encodeURIComponent(nativeKey)}`);
|
||||
const entry = listOf(data)[0];
|
||||
return (
|
||||
<details className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2">
|
||||
<summary className="cursor-pointer text-sm font-medium">{SOURCES[source] ?? source}</summary>
|
||||
<div className="mt-2 text-[13px] leading-relaxed">
|
||||
{status === 'loading' && <p className={muted}>Loading…</p>}
|
||||
{status === 'error' && <p className={muted}>{error}</p>}
|
||||
{status === 'empty' && <p className={muted}>No entry.</p>}
|
||||
{entry && source === 'bdb' && (
|
||||
<>
|
||||
<p><span className="font-serif text-base" dir="rtl">{entry.head_word}</span> <span className={muted}>{entry.pos}</span></p>
|
||||
{entry.primary_gloss && <p className="font-medium">{entry.primary_gloss}</p>}
|
||||
<div className="whitespace-pre-line" dangerouslySetInnerHTML={{ __html: html(entry.description) }} />
|
||||
</>
|
||||
)}
|
||||
{entry && source !== 'bdb' && (
|
||||
<>
|
||||
<p><span className="font-serif text-base">{entry.greek}</span> <span className={muted}>{entry.transliteration}{entry.morph ? ` · ${entry.morph}` : ''}</span></p>
|
||||
{entry.gloss && <p className="font-medium">{entry.gloss}</p>}
|
||||
<div className="max-h-64 overflow-y-auto" dangerouslySetInnerHTML={{ __html: html(entry.meaning) }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function Occurrences({ strongs }) {
|
||||
const { status, data, error } = useIq('original-language/search', { strongs, limit: 20 });
|
||||
if (status === 'loading') return <p className={muted}>Loading…</p>;
|
||||
if (status === 'error') return <p className={muted}>{error}</p>;
|
||||
const list = data?.occurrences ?? [];
|
||||
if (!list.length) return <p className={muted}>No occurrences found.</p>;
|
||||
return (
|
||||
<p className="text-[13px] leading-relaxed">
|
||||
{list.map((o) => `${o.name_en} ${o.chapter}:${o.verse}`).join(' · ')}
|
||||
{data.count >= 20 && <span className={muted}> … first 20 shown</span>}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IqLexicon({ strongs: raw, onClose }) {
|
||||
const strongs = normalizeStrongs(raw);
|
||||
const lang = strongs?.startsWith('H') ? 'hebrew' : 'greek';
|
||||
const basic = useIq(strongs ? `lexicon/${lang}/strongs/${strongs}` : null);
|
||||
const cross = useIq(strongs ? `lexicon/crosswalk/${strongs}` : null);
|
||||
const [showOcc, setShowOcc] = useState(false);
|
||||
if (!strongs) return null;
|
||||
const strong = listOf(basic.data)[0];
|
||||
const sources = listOf(cross.data).filter((c) => SOURCES[c.source] && c.native_key && (lang === 'hebrew' ? c.source === 'bdb' : c.source !== 'bdb'));
|
||||
const seen = new Set();
|
||||
const unique = sources.filter((c) => { const k = `${c.source}/${c.native_key}`; if (seen.has(k)) return false; seen.add(k); return true; }).slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-[var(--r-line,#cbd5e1)] p-3" role="region" aria-label={`Lexicon entry for ${strongs}`}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">{strongs}{strong?.occurences ? <span className="ml-2 text-xs font-normal text-[var(--r-sub,#64748b)]">{strong.occurences}× in the Bible</span> : null}</p>
|
||||
<button type="button" onClick={onClose} aria-label="Close lexicon entry" className="rounded p-1 text-[var(--r-sub,#64748b)] hover:bg-black/5">×</button>
|
||||
</div>
|
||||
{basic.status === 'loading' && <p className={muted}>Loading…</p>}
|
||||
{basic.status === 'error' && <p className={muted}>{basic.error}</p>}
|
||||
{strong && <p className="mb-2 whitespace-pre-line text-[13px] leading-relaxed">{strong.glossary}</p>}
|
||||
<div className="space-y-1.5">
|
||||
{cross.status === 'loading' && <p className={muted}>Finding other lexicons…</p>}
|
||||
{unique.map((c) => <SourceEntry key={`${c.source}/${c.native_key}`} lang={lang} source={c.source} nativeKey={c.native_key} />)}
|
||||
</div>
|
||||
<button type="button" onClick={() => setShowOcc((o) => !o)} className="mt-2 text-xs font-medium text-[var(--r-accent,#0369a1)] hover:underline">
|
||||
{showOcc ? 'Hide' : 'Where this word appears'}
|
||||
</button>
|
||||
{showOcc && <div className="mt-1"><Occurrences strongs={strongs} /></div>}
|
||||
<p className="mt-2 text-[10px] text-[var(--r-sub,#64748b)]">Lexicons via IQ Bible (Strong's, BDB, LSJ, Abbott-Smith; see credits there).</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// <option>s for IQ Bible's narrated Bibles, to add inside an audio narrator <select>. Playing a
|
||||
// chapter this way uses one of the account's monthly audio streams, so the group says so.
|
||||
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
|
||||
import { listOf } from '../../utils/iqbible.js';
|
||||
|
||||
export default function IqNarratorOptions() {
|
||||
const on = useIqAvailable();
|
||||
const { data } = useIq(on ? 'audio' : null);
|
||||
const sets = listOf(data).filter((s) => s.active !== false);
|
||||
if (!on || sets.length === 0) return null;
|
||||
return (
|
||||
<optgroup label="IQ Bible narrations (uses monthly streams)">
|
||||
{sets.map((s) => <option key={s.audio_id} value={`iq:${s.audio_id}`}>{s.audio_id}{s.narrator ? ` · ${s.narrator}` : ''}</option>)}
|
||||
</optgroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Calendar (.ics) and spreadsheet (.csv) downloads of a reading plan, generated by IQ Bible so the
|
||||
// readings show up as daily all-day events in any calendar app.
|
||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||
import { iqUrl } from '../../utils/iqbible.js';
|
||||
|
||||
const link = 'text-[var(--r-sub)] hover:text-[var(--r-text)] underline-offset-2 hover:underline';
|
||||
|
||||
export default function IqPlanExport({ plan, mcheyne = false }) {
|
||||
const on = useIqAvailable();
|
||||
if (!on) return null;
|
||||
const query = mcheyne
|
||||
? { plan: 'mcheyne' }
|
||||
: Array.isArray(plan?.books) && plan.books.length > 0 ? { days: plan.days, books: plan.books.join(',') } : null;
|
||||
if (!query) return null;
|
||||
const href = (format) => iqUrl('bibles/eng_kjv/reading-plan', { ...query, format });
|
||||
return (
|
||||
<p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--r-sub)]">
|
||||
<span>{mcheyne ? 'M’Cheyne’s 365-day calendar' : 'Take this plan with you'}:</span>
|
||||
<a href={href('ical')} download className={link}>Add to calendar (.ics)</a>
|
||||
<a href={href('csv')} download className={link}>Spreadsheet (.csv)</a>
|
||||
{!mcheyne && <span>Starts today, in the order of the Bible.</span>}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// A verse's "study aids" from IQ Bible: chapter overview, topics, places, people, textual notes, Gospel
|
||||
// parallels and timeline. Each section loads only when opened, so nothing is spent on what isn't read.
|
||||
import { useState } from 'react';
|
||||
import { useIq } from '../../hooks/useIq.js';
|
||||
import { listOf } from '../../utils/iqbible.js';
|
||||
|
||||
const muted = 'text-xs text-[var(--r-sub,#64748b)]';
|
||||
const GOSPELS = ['MAT', 'MRK', 'LUK', 'JHN'];
|
||||
|
||||
function Aid({ title, path, query, children, empty = 'Nothing recorded here.' }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { status, data, error } = useIq(open ? path : null, query);
|
||||
return (
|
||||
<details onToggle={(e) => setOpen(e.currentTarget.open)} className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2">
|
||||
<summary className="cursor-pointer text-sm font-medium">{title}</summary>
|
||||
<div className="mt-2 text-[13px] leading-relaxed">
|
||||
{status === 'loading' && <p className={muted}>Loading…</p>}
|
||||
{status === 'error' && <p className={muted}>{error}</p>}
|
||||
{status === 'empty' && <p className={muted}>{empty}</p>}
|
||||
{status === 'ready' && children(data)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonProfile({ ustrong }) {
|
||||
const { status, data } = useIq(`bible-characters/by-ustrong/${ustrong}`);
|
||||
const p = data?.data;
|
||||
if (status === 'loading') return <p className={muted}>Loading…</p>;
|
||||
if (!p) return <p className={muted}>No profile.</p>;
|
||||
return (
|
||||
<div className="mt-1 space-y-1">
|
||||
<p>{p.brief || p.briefest || p.description}</p>
|
||||
{p.tribe_nation && <p className={muted}>{p.tribe_nation}</p>}
|
||||
{p.first_appearance && <p className={muted}>First appears {p.first_appearance.book} {p.first_appearance.chapter}:{p.first_appearance.verse}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function People({ data, verse }) {
|
||||
const [open, setOpen] = useState(null);
|
||||
const people = listOf(data);
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{people.map((p) => (
|
||||
<li key={p.ustrong}>
|
||||
<button type="button" onClick={() => setOpen(open === p.ustrong ? null : p.ustrong)} className="text-left font-medium">
|
||||
{p.name} <span className={muted}>{p.first_verse === verse ? 'this verse' : `v${p.first_verse}`} {open === p.ustrong ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{open === p.ustrong && <PersonProfile ustrong={p.ustrong} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Places({ data }) {
|
||||
const places = listOf(data);
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{places.map((p) => (
|
||||
<li key={p.id} className="flex items-baseline justify-between gap-2">
|
||||
<span>{p.name}<span className={muted}> {p.place_type}{p.modern_name && p.modern_name !== p.name ? ` · today: ${p.modern_name}` : ''}{p.special ? ` · ${p.special.replace(/_/g, ' ')}` : ''}</span></span>
|
||||
{p.lat != null && p.lon != null && (
|
||||
<a href={`https://www.openstreetmap.org/?mlat=${p.lat}&mlon=${p.lon}#map=8/${p.lat}/${p.lon}`} target="_blank" rel="noopener noreferrer" className="shrink-0 text-xs font-semibold text-sky-600 hover:underline">Map ↗</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Variants({ data }) {
|
||||
const items = listOf(data);
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{items.map((v, i) => (
|
||||
<li key={v.slug ?? i}>
|
||||
<p className="font-medium">{v.title}</p>
|
||||
{v.traditional_reading && <p><span className={muted}>Traditional (KJV/TR): </span>{v.traditional_reading}</p>}
|
||||
{v.critical_reading && <p><span className={muted}>Critical text: </span>{v.critical_reading}</p>}
|
||||
{v.explanation && <p className="mt-1">{v.explanation}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Harmony({ data, book, chapter }) {
|
||||
const items = listOf(data);
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{items.map((s) => (
|
||||
<li key={s.number}>
|
||||
<p className="font-medium">§{s.number} {String(s.title ?? '').toLowerCase().replace(/(^|[.\s])([a-z])/g, (m, a, b) => a + b.toUpperCase())}</p>
|
||||
<p className={muted}>{(s.references ?? []).map((r) => r.citation ?? r).join(' · ')}</p>
|
||||
</li>
|
||||
))}
|
||||
<li className={muted} aria-hidden="true">{book} {chapter}</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IqStudyAids({ book, chapter, verse }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Aid title="About this chapter" path={`books/${book}/chapters/${chapter}/info`} empty="No chapter overview is available.">
|
||||
{(d) => (
|
||||
<>
|
||||
<p className="whitespace-pre-line">{d.summary}</p>
|
||||
{d.source_author && <p className={`mt-1 ${muted}`}>{d.source_author}{d.source_year ? `, ${d.source_year}` : ''}</p>}
|
||||
</>
|
||||
)}
|
||||
</Aid>
|
||||
<Aid title="Topics on this verse" path={`topics/${book}/${chapter}/${verse}`} empty="No topics are tagged on this verse.">
|
||||
{(d) => (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{listOf(d).slice(0, 40).map((t, i) => (
|
||||
<span key={i} className="rounded-full border border-[var(--r-line,#cbd5e1)] px-2 py-0.5 text-xs" title={t.label || t.edition}>{t.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Aid>
|
||||
<Aid title="Places on this verse" path={`geo/${book}/${chapter}/${verse}`} empty="No places are mentioned on this verse.">{(d) => <Places data={d} />}</Aid>
|
||||
<Aid title="People in this chapter" path={`bible-people/${book}/${chapter}`} empty="No named people in this chapter.">{(d) => <People data={d} verse={verse} />}</Aid>
|
||||
<Aid title="Textual notes" path={`textual-variants/${book}/${chapter}/${verse}`} empty="No known manuscript differences on this verse.">{(d) => <Variants data={d} />}</Aid>
|
||||
{GOSPELS.includes(book) && (
|
||||
<Aid title="Parallel accounts in the Gospels" path="harmony" query={{ book, chapter }} empty="No Gospel parallels recorded for this chapter.">{(d) => <Harmony data={d} book={book} chapter={chapter} />}</Aid>
|
||||
)}
|
||||
<Aid title="Where this sits in the timeline" path={`chronology/for/${book}/${chapter}`}>
|
||||
{(d) => (
|
||||
<>
|
||||
<p>{d.placement ?? `${d.date ?? ''}`}</p>
|
||||
{d.preceding?.length > 0 && <p className={muted}>Before: {d.preceding.map((e) => `${e.event} (${e.date})`).join('; ')}</p>}
|
||||
{d.following?.length > 0 && <p className={muted}>After: {d.following.map((e) => `${e.event} (${e.date})`).join('; ')}</p>}
|
||||
</>
|
||||
)}
|
||||
</Aid>
|
||||
<p className="pt-1 text-[10px] text-[var(--r-sub,#64748b)]">Study aids via IQ Bible.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, describe, test, expect, beforeEach, afterEach } from 'vitest';
|
||||
import IqCommentary from './IqCommentary.jsx';
|
||||
import IqLexicon from './IqLexicon.jsx';
|
||||
import IqStudyAids from './IqStudyAids.jsx';
|
||||
import IqPlanExport from './IqPlanExport.jsx';
|
||||
import { resetIq } from '../../utils/iqbible.js';
|
||||
|
||||
// Answers /api/iq/... from a table of path -> JSON (or { status }), and records what was asked.
|
||||
let routes;
|
||||
let asked;
|
||||
const reply = (body, status = 200) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(body), text: () => Promise.resolve('') });
|
||||
|
||||
beforeEach(() => {
|
||||
resetIq();
|
||||
asked = [];
|
||||
routes = { '/api/iq/status': { available: true } };
|
||||
vi.stubGlobal('fetch', vi.fn((url) => {
|
||||
const path = String(url);
|
||||
asked.push(path);
|
||||
const hit = routes[path] ?? routes[path.split('?')[0]];
|
||||
return hit === undefined ? reply({ error: 'not found' }, 404) : hit.status ? reply(hit.body ?? { error: 'x' }, hit.status) : reply(hit);
|
||||
}));
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('IqCommentary', () => {
|
||||
const sources = { data: [
|
||||
{ name: 'mhenry', author_name: 'Matthew Henry', category: 'Puritan', year: '1706' },
|
||||
{ name: 'thomas-aquinas', author_name: 'Thomas Aquinas', category: 'Western & Medieval Theology', year: '1274' },
|
||||
] };
|
||||
|
||||
test('offers the commentators on this chapter and shows their notes for the verse', async () => {
|
||||
routes['/api/iq/books/JHN/commentaries?chapter=3'] = sources;
|
||||
routes['/api/iq/commentaries/mhenry/JHN/3'] = { entries: [
|
||||
{ chapter: 3, verse: 0, text: 'intro' },
|
||||
{ chapter: 3, verse: 14, verse_end: 21, text: 'God so loved the world.' },
|
||||
] };
|
||||
render(<IqCommentary book="JHN" chapter={3} verse={16} />);
|
||||
expect(await screen.findByText('God so loved the world.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Notes on verses 14–21/)).toBeInTheDocument();
|
||||
const options = within(screen.getByRole('combobox')).getAllByRole('option').map((o) => o.textContent);
|
||||
expect(options).toEqual(['Matthew Henry (1706)', 'Thomas Aquinas (1274)']);
|
||||
});
|
||||
|
||||
test('choosing another commentator loads theirs and remembers the choice', async () => {
|
||||
routes['/api/iq/books/JHN/commentaries?chapter=3'] = sources;
|
||||
routes['/api/iq/commentaries/mhenry/JHN/3'] = { entries: [{ chapter: 3, verse: 16, text: 'Henry on 16' }] };
|
||||
routes['/api/iq/commentaries/thomas-aquinas/JHN/3'] = { entries: [{ chapter: 3, verse: 16, text: 'Aquinas on 16' }] };
|
||||
render(<IqCommentary book="JHN" chapter={3} verse={16} />);
|
||||
await screen.findByText('Henry on 16');
|
||||
await userEvent.setup().selectOptions(screen.getByRole('combobox'), 'thomas-aquinas');
|
||||
expect(await screen.findByText('Aquinas on 16')).toBeInTheDocument();
|
||||
expect(localStorage.getItem('iq-commentary')).toBe('thomas-aquinas');
|
||||
});
|
||||
|
||||
test('says so when nobody wrote on the chapter', async () => {
|
||||
routes['/api/iq/books/JHN/commentaries?chapter=3'] = { data: [] };
|
||||
render(<IqCommentary book="JHN" chapter={3} verse={16} />);
|
||||
expect(await screen.findByText(/No IQ Bible commentaries cover this chapter/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IqLexicon', () => {
|
||||
test('shows Strong\'s and the Greek lexicons the crosswalk points to', async () => {
|
||||
routes['/api/iq/lexicon/greek/strongs/G26'] = { data: [{ glossary: 'agape: love', occurences: 116 }] };
|
||||
routes['/api/iq/lexicon/crosswalk/G26'] = { data: [
|
||||
{ strongs_id: 'G26', source: 'lsj', native_key: 'G0026' },
|
||||
{ strongs_id: 'G26', source: 'abbott-smith', native_key: 'G0026' },
|
||||
] };
|
||||
routes['/api/iq/lexicon/greek/lsj/G0026'] = { data: [{ greek: 'ἀγάπη', transliteration: 'agapē', gloss: 'love', meaning: '<b>love</b><script>alert(1)</script>' }] };
|
||||
const { container } = render(<IqLexicon strongs="G0026" onClose={() => {}} />);
|
||||
expect(await screen.findByText(/agape: love/)).toBeInTheDocument();
|
||||
expect(screen.getByText('116× in the Bible')).toBeInTheDocument();
|
||||
await userEvent.setup().click(await screen.findByText('Liddell-Scott-Jones'));
|
||||
await screen.findByText('love', { selector: 'p.font-medium' });
|
||||
expect(container.querySelector('script')).toBeNull(); // lexicon HTML is sanitised
|
||||
expect(screen.getByText('Abbott-Smith')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Hebrew words use BDB', async () => {
|
||||
routes['/api/iq/lexicon/hebrew/strongs/H430'] = { data: [{ glossary: 'elohim' }] };
|
||||
routes['/api/iq/lexicon/crosswalk/H430'] = { data: [{ source: 'bdb', native_key: 'BDB65' }, { source: 'lsj', native_key: 'x' }] };
|
||||
render(<IqLexicon strongs="H430" onClose={() => {}} />);
|
||||
expect(await screen.findByText('Brown-Driver-Briggs')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Liddell-Scott-Jones')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IqStudyAids', () => {
|
||||
test('each section loads only when opened', async () => {
|
||||
routes['/api/iq/topics/JHN/3/16'] = { data: [{ name: 'faith', edition: 'iqbible' }, { name: 'eternal life', edition: 'nave-torrey' }] };
|
||||
render(<IqStudyAids book="JHN" chapter={3} verse={16} />);
|
||||
expect(asked.filter((u) => u.startsWith('/api/iq/') && !u.includes('status'))).toEqual([]);
|
||||
await userEvent.setup().click(screen.getByText('Topics on this verse'));
|
||||
expect(await screen.findByText('faith')).toBeInTheDocument();
|
||||
expect(screen.getByText('eternal life')).toBeInTheDocument();
|
||||
expect(asked.some((u) => u.includes('/geo/'))).toBe(false);
|
||||
});
|
||||
|
||||
test('a section with nothing says so, and the Gospel parallels appear only in the Gospels', async () => {
|
||||
const { rerender } = render(<IqStudyAids book="ROM" chapter={8} verse={1} />);
|
||||
expect(screen.queryByText('Parallel accounts in the Gospels')).toBeNull();
|
||||
await userEvent.setup().click(screen.getByText('Textual notes'));
|
||||
expect(await screen.findByText(/No known manuscript differences/)).toBeInTheDocument();
|
||||
rerender(<IqStudyAids book="MAT" chapter={14} verse={1} />);
|
||||
expect(screen.getByText('Parallel accounts in the Gospels')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('places link to a map when they have coordinates', async () => {
|
||||
routes['/api/iq/geo/2KI/5/12'] = { data: [{ id: 1, name: 'Abana', place_type: 'river', modern_name: 'Barada River', lat: 33.5, lon: 36.3 }, { id: 2, name: 'Somewhere', place_type: 'region', special: 'unknown_place' }] };
|
||||
render(<IqStudyAids book="2KI" chapter={5} verse={12} />);
|
||||
await userEvent.setup().click(screen.getByText('Places on this verse'));
|
||||
const link = await screen.findByRole('link', { name: /Map/ });
|
||||
expect(link.href).toContain('mlat=33.5');
|
||||
expect(screen.getAllByRole('link')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IqPlanExport', () => {
|
||||
test('links a plan to calendar and spreadsheet files, only when IQ Bible is on', async () => {
|
||||
const plan = { id: 'p', days: 30, books: ['MAT', 'MRK'] };
|
||||
const { container, rerender } = render(<IqPlanExport plan={plan} />);
|
||||
await waitFor(() => expect(screen.getByRole('link', { name: /calendar/i })).toBeInTheDocument());
|
||||
const href = screen.getByRole('link', { name: /calendar/i }).getAttribute('href');
|
||||
expect(href).toContain('/api/iq/bibles/eng_kjv/reading-plan?');
|
||||
expect(href).toContain('format=ical');
|
||||
expect(href).toContain('days=30');
|
||||
rerender(<IqPlanExport plan={{ id: 'q', days: 5 }} />); // a plan with no book list can't be exported
|
||||
expect(container.querySelector('a')).toBeNull();
|
||||
});
|
||||
|
||||
test('shows nothing without a key on the server', async () => {
|
||||
routes['/api/iq/status'] = { available: false };
|
||||
const { container } = render(<IqPlanExport plan={{ days: 30, books: ['MAT'] }} />);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
// Form for creating or editing a custom reading plan (title, days, cover photo,
|
||||
// books and chapter ranges). Themed with the reader's --r-* colour variables.
|
||||
import { useRef, useState } from 'react';
|
||||
import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx';
|
||||
import {
|
||||
bookChapterCount, clampPlanDays, ALL_BOOKS, OT_BOOKS, NT_BOOKS, GOSPELS, PENTATEUCH, WISDOM_BOOKS,
|
||||
} from '../../utils/readingPlans.js';
|
||||
|
||||
// Downscales a picked image file to a reasonable cover-photo size and returns
|
||||
// a data URL — no server upload endpoint needed, it's stored right on the plan.
|
||||
export function resizeImageToDataUrl(file, maxWidth = 960, quality = 0.82) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('Could not read file.'));
|
||||
reader.onload = () => {
|
||||
const img = new Image();
|
||||
img.onerror = () => reject(new Error('Could not decode image.'));
|
||||
img.onload = () => {
|
||||
const scale = Math.min(1, maxWidth / img.width);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(img.width * scale);
|
||||
canvas.height = Math.round(img.height * scale);
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
resolve(canvas.toDataURL('image/jpeg', quality));
|
||||
};
|
||||
img.src = reader.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
const BOOK_PRESETS = [
|
||||
['All', ALL_BOOKS],
|
||||
['None', []],
|
||||
['Old Testament', OT_BOOKS],
|
||||
['New Testament', NT_BOOKS],
|
||||
['Gospels', GOSPELS],
|
||||
['Pentateuch', PENTATEUCH],
|
||||
['Psalms', ['PSA']],
|
||||
['Proverbs', ['PRO']],
|
||||
['Wisdom', WISDOM_BOOKS],
|
||||
];
|
||||
|
||||
export default function CustomPlanBuilder({ initialValues, onSubmit, onCancel }) {
|
||||
const [title, setTitle] = useState(initialValues?.title ?? '');
|
||||
const [description, setDescription] = useState(initialValues?.description ?? '');
|
||||
const [days, setDays] = useState(initialValues?.days ?? 30);
|
||||
const [books, setBooks] = useState(initialValues?.books ?? []);
|
||||
const [chapterRanges, setChapterRanges] = useState(initialValues?.chapterRanges ?? {});
|
||||
const [image, setImage] = useState(initialValues?.image ?? null);
|
||||
const [imageError, setImageError] = useState('');
|
||||
const fileInputRef = useRef(null);
|
||||
const isEditing = !!initialValues;
|
||||
|
||||
const toggleBook = (abbrev) => {
|
||||
const isOn = books.includes(abbrev);
|
||||
setBooks((prev) => (isOn ? prev.filter((b) => b !== abbrev) : [...prev, abbrev]));
|
||||
if (isOn) {
|
||||
setChapterRanges((prev) => {
|
||||
if (!(abbrev in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[abbrev];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const setChapterRange = (abbrev, key, value) => {
|
||||
const total = CHAPTER_COUNTS[abbrev] ?? 1;
|
||||
const clamped = Math.min(total, Math.max(1, Math.round(value) || 1));
|
||||
setChapterRanges((prev) => ({
|
||||
...prev,
|
||||
[abbrev]: { start: prev[abbrev]?.start ?? 1, end: prev[abbrev]?.end ?? total, [key]: clamped },
|
||||
}));
|
||||
};
|
||||
|
||||
const applyPreset = (list) => {
|
||||
setBooks(list);
|
||||
setChapterRanges({});
|
||||
};
|
||||
|
||||
const pickImage = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
setImageError('');
|
||||
setImage(await resizeImageToDataUrl(file));
|
||||
} catch {
|
||||
setImageError("Couldn't use that image.");
|
||||
}
|
||||
};
|
||||
|
||||
const totalChapters = books.reduce((sum, b) => sum + bookChapterCount(b, chapterRanges), 0);
|
||||
const canCreate = title.trim().length > 0 && totalChapters > 0 && days >= 1;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||
<h3 className="mb-3 text-sm font-semibold text-[var(--r-text)]">{isEditing ? 'Edit reading plan' : 'New reading plan'}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-sm text-[var(--r-text)]">
|
||||
Title
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Advent Readings"
|
||||
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-[var(--r-text)]">
|
||||
Days
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="mt-3 block text-sm text-[var(--r-text)]">
|
||||
Description <span className="text-[var(--r-sub)]">(optional)</span>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mt-3">
|
||||
<span className="text-sm text-[var(--r-text)]">Cover photo <span className="text-[var(--r-sub)]">(optional)</span></span>
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
{image ? (
|
||||
<img src={image} alt="" className="h-16 w-28 rounded-lg object-cover" />
|
||||
) : (
|
||||
<div className="flex h-16 w-28 items-center justify-center rounded-lg bg-[var(--r-bg)] text-2xl">🖼️</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-3 py-1.5 text-sm font-semibold text-[var(--r-text)] hover:bg-black/5"
|
||||
>
|
||||
{image ? 'Change photo' : 'Choose photo'}
|
||||
</button>
|
||||
{image && (
|
||||
<button type="button" onClick={() => setImage(null)} className="text-sm text-[var(--r-sub)] hover:text-rose-500">
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
|
||||
</div>
|
||||
{imageError && <p className="mt-1 text-xs text-rose-500">{imageError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--r-text)]">Books</span>
|
||||
{BOOK_PRESETS.map(([label, list]) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => applyPreset(list)}
|
||||
className="rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-2.5 py-1 text-xs font-semibold text-[var(--r-text)] hover:bg-black/5"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-[var(--r-sub)]">
|
||||
Check a book to include the whole thing, or narrow it to a chapter range with the fields that appear.
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="max-h-56 overflow-y-auto rounded-xl border border-[var(--r-line)] p-2">
|
||||
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub)]">Old Testament</p>
|
||||
{bookOptions.filter((b) => OT_BOOKS.includes(b.abbrev)).map((b) => (
|
||||
<BookRow
|
||||
key={b.abbrev}
|
||||
book={b}
|
||||
checked={books.includes(b.abbrev)}
|
||||
range={chapterRanges[b.abbrev]}
|
||||
onToggle={() => toggleBook(b.abbrev)}
|
||||
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto rounded-xl border border-[var(--r-line)] p-2">
|
||||
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-[var(--r-sub)]">New Testament</p>
|
||||
{bookOptions.filter((b) => NT_BOOKS.includes(b.abbrev)).map((b) => (
|
||||
<BookRow
|
||||
key={b.abbrev}
|
||||
book={b}
|
||||
checked={books.includes(b.abbrev)}
|
||||
range={chapterRanges[b.abbrev]}
|
||||
onToggle={() => toggleBook(b.abbrev)}
|
||||
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[var(--r-sub)]">
|
||||
{books.length} book{books.length !== 1 ? 's' : ''} selected · {totalChapters} chapters
|
||||
{days > 0 && totalChapters > 0 && ` · ~${(totalChapters / Math.min(days, totalChapters)).toFixed(1)} ch/day`}
|
||||
</p>
|
||||
{totalChapters > 0 && Number(days) > totalChapters && (
|
||||
<p className="mt-1 text-xs text-[var(--r-accent)]">
|
||||
That’s more days than chapters, so the plan will be {totalChapters} days, one chapter a day.
|
||||
</p>
|
||||
)}
|
||||
{isEditing && (
|
||||
books.join() !== initialValues.books.join()
|
||||
|| Number(days) !== initialValues.days
|
||||
|| JSON.stringify(chapterRanges) !== JSON.stringify(initialValues.chapterRanges ?? {})
|
||||
) && (
|
||||
<p className="mt-2 text-xs text-amber-600">
|
||||
Changing the books, chapters, or day count restarts progress for any run of this plan already in your list.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canCreate}
|
||||
onClick={() => onSubmit({ title, description, days: clampPlanDays(books, chapterRanges, days), books, chapterRanges, image })}
|
||||
className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-semibold text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isEditing ? 'Save changes' : 'Create plan'}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="text-sm text-[var(--r-sub)] hover:text-[var(--r-text)]">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One book row in the custom-plan builder: a checkbox, and — once checked — a
|
||||
// "ch. X–Y" chapter-range pair that defaults to the whole book.
|
||||
function BookRow({ book, checked, range, onToggle, onRangeChange }) {
|
||||
const total = CHAPTER_COUNTS[book.abbrev] ?? 1;
|
||||
return (
|
||||
<label className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-black/5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onToggle}
|
||||
className="h-4 w-4 shrink-0 rounded border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] accent-[var(--r-accent)]"
|
||||
/>
|
||||
<span className="flex-1">{book.name}</span>
|
||||
{checked && total > 1 && (
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs text-[var(--r-sub)]" onClick={(e) => e.preventDefault()}>
|
||||
ch.
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={total}
|
||||
value={range?.start ?? 1}
|
||||
onChange={(e) => onRangeChange('start', e.target.value)}
|
||||
className="w-11 rounded border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-1 py-0.5 text-center"
|
||||
/>
|
||||
–
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={total}
|
||||
value={range?.end ?? total}
|
||||
onChange={(e) => onRangeChange('end', e.target.value)}
|
||||
className="w-11 rounded border border-[var(--r-line)] bg-[var(--r-bg)] text-[var(--r-text)] px-1 py-0.5 text-center"
|
||||
/>
|
||||
<span className="text-[var(--r-sub)]">/{total}</span>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Detail for one plan. Two modes: an in-progress plan (days to tick or jump into,
|
||||
// its translation, catch-up / restart / remove) and a catalog plan (preview of the
|
||||
// first days, pick a translation, start it).
|
||||
import { useRef, useState } from 'react';
|
||||
import Sheet from '../reader/Sheet.jsx';
|
||||
import TranslationSelect from './TranslationSelect.jsx';
|
||||
import { resizeImageToDataUrl } from './CustomPlanBuilder.jsx';
|
||||
import { formatDayReadings, getPlanSchedule, daysBehindPace, nextUnreadDayIndex } from '../../utils/readingPlans.js';
|
||||
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
|
||||
import { DownloadProgress } from '../OfflineSave.jsx';
|
||||
import { canSaveOffline } from '../../utils/offline.js';
|
||||
import IqPlanExport from '../iq/IqPlanExport.jsx';
|
||||
|
||||
const btn = 'rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm hover:bg-black/5';
|
||||
const primary = 'w-full rounded-xl bg-[var(--r-accent)] py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50';
|
||||
|
||||
function ActiveBody({ plan, progress, onClose, actions }) {
|
||||
const schedule = getPlanSchedule(plan.id);
|
||||
const behind = daysBehindPace(plan, progress);
|
||||
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
|
||||
const offline = useOfflineSave();
|
||||
const translation = progress.translation ?? actions.defaultTranslation;
|
||||
// The next week of readings, so a trip or a bad connection doesn't interrupt the plan
|
||||
const upcoming = (() => {
|
||||
const from = nextUnreadDayIndex(plan, progress.completedDays);
|
||||
return from == null ? [] : schedule.slice(from, from + 7).flat();
|
||||
})();
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between text-sm">
|
||||
<span>{progress.completedDays.length} of {plan.days} days · {pct}%</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--r-sub)]">Read in</span>
|
||||
<TranslationSelect value={progress.translation ?? actions.defaultTranslation} onChange={(t) => actions.setTranslation(progress.id, t)} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-3 h-1.5 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)]" style={{ width: `${pct}%` }} /></div>
|
||||
{behind >= 1 && (
|
||||
<div className="mb-3 rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
|
||||
<p className="text-[var(--r-sub)]">{behind} {behind === 1 ? 'day' : 'days'} behind pace. Catch up without losing the days you've read:</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<button type="button" onClick={() => actions.recalibrate(progress.id)} className="font-medium text-[var(--r-accent)] hover:underline">Catch me up (pace starts today)</button>
|
||||
<button type="button" onClick={() => { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Redo the plan from Day 1</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[38vh] overflow-y-auto rounded-xl border border-[var(--r-line)]">
|
||||
{schedule.map((refs, i) => {
|
||||
const done = progress.completedDays.includes(i);
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3 border-b border-[var(--r-line)] px-3 py-2 last:border-0">
|
||||
<input type="checkbox" checked={done} onChange={() => actions.toggleDay(progress.id, i, plan.days)} className="h-4 w-4 shrink-0 accent-[var(--r-accent)]" aria-label={`Day ${i + 1} read`} />
|
||||
<span className={`flex-1 text-sm ${done ? 'text-[var(--r-sub)] line-through' : ''}`}>Day {i + 1} · {formatDayReadings(refs)}</span>
|
||||
<button type="button" onClick={() => { onClose(); actions.read(progress.id, i); }} className="shrink-0 text-xs font-medium text-[var(--r-accent)] hover:underline">Read</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{upcoming.length > 0 && canSaveOffline(translation) && (
|
||||
<div className="mt-3">
|
||||
{offline.job
|
||||
? <DownloadProgress job={offline.job} onCancel={offline.cancel} />
|
||||
: (
|
||||
<button type="button" onClick={() => offline.saveRefs('the next 7 days', translation, upcoming)}
|
||||
className="w-full rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs text-[var(--r-sub)] hover:bg-black/5">
|
||||
Save the next 7 days for offline ({translation})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3"><IqPlanExport plan={plan} /></div>
|
||||
<div className="mt-4 flex items-center justify-between text-xs">
|
||||
<button type="button" onClick={() => { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
|
||||
<button type="button" onClick={() => { actions.remove(progress.id); onClose(); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogBody({ plan, active, completedCount, onClose, actions }) {
|
||||
const [translation, setTranslation] = useState(actions.defaultTranslation);
|
||||
const [imageError, setImageError] = useState('');
|
||||
const fileRef = useRef(null);
|
||||
const schedule = getPlanSchedule(plan.id);
|
||||
const chapters = schedule.reduce((n, d) => n + d.length, 0);
|
||||
const pickImage = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
try { setImageError(''); actions.updateCover(plan.id, await resizeImageToDataUrl(file)); } catch { setImageError('Couldn’t use that image.'); }
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{plan.image && <img src={plan.image} alt="" onError={(e) => { e.currentTarget.style.display = 'none'; }} className="mb-3 h-32 w-full rounded-xl object-cover" style={{ objectPosition: plan.imageFocus ?? 'center' }} />}
|
||||
<p className="text-sm text-[var(--r-sub)]">{plan.description || 'A custom reading plan.'}</p>
|
||||
<p className="mt-2 text-xs text-[var(--r-sub)]">{plan.days} days · {chapters} chapters · about {(chapters / plan.days).toFixed(1)} a day{completedCount > 0 ? ` · completed ${completedCount}×` : ''}</p>
|
||||
|
||||
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">First days</p>
|
||||
<div className="rounded-xl border border-[var(--r-line)]">
|
||||
{schedule.slice(0, 5).map((refs, i) => (
|
||||
<div key={i} className="border-b border-[var(--r-line)] px-3 py-1.5 text-sm last:border-0">Day {i + 1} · {formatDayReadings(refs)}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!active && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<span>Read in</span>
|
||||
<TranslationSelect value={translation} onChange={setTranslation} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
{active
|
||||
? <button type="button" className={primary} onClick={() => { onClose(); actions.read(active.id, actions.nextDay(plan, active)); }}>Continue reading</button>
|
||||
: <button type="button" className={primary} onClick={() => { actions.start(plan.id, translation); onClose(); }}>{completedCount > 0 ? 'Start again' : 'Start plan'}</button>}
|
||||
</div>
|
||||
|
||||
<div className="mt-3"><IqPlanExport plan={plan} /></div>
|
||||
{plan.custom && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-[var(--r-line)] pt-3 text-xs">
|
||||
<button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button>
|
||||
<button type="button" onClick={() => fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}</button>
|
||||
{plan.image && <button type="button" onClick={() => actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove cover</button>}
|
||||
<button type="button" onClick={() => { actions.delete(plan.id); onClose(); }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
|
||||
{imageError && <p className="w-full text-[var(--r-danger,#b42318)]">{imageError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlanDetailSheet({ plan, progress, active, completedCount, onClose, actions }) {
|
||||
if (!plan) return null;
|
||||
return (
|
||||
<Sheet open onClose={onClose} title={<span>{plan.icon ? `${plan.icon} ` : ''}{plan.title}</span>}>
|
||||
{progress
|
||||
? <ActiveBody plan={plan} progress={progress} onClose={onClose} actions={actions} />
|
||||
: <CatalogBody plan={plan} active={active} completedCount={completedCount} onClose={onClose} actions={actions} />}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
|
||||
|
||||
// Every Bible the app can read in — used for a plan's own translation.
|
||||
export default function TranslationSelect({ value, onChange, className = '' }) {
|
||||
return (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)}
|
||||
className={`rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-sm text-[var(--r-text)] ${className}`}>
|
||||
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Sheet from './Sheet.jsx';
|
||||
import AudioPlayer from '../AudioPlayer.jsx';
|
||||
|
||||
// Wraps the existing player. Audio is BSB-only, so licensed translations get a note.
|
||||
export default function AudioSheet({ open, onClose, translation, bookName, chapter, audio, narrator, onNarrator, onNext, nextDisabled }) {
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="Listen">
|
||||
<AudioPlayer
|
||||
title={`${bookName} ${chapter}`}
|
||||
subtitle={audio.state.status === 'error'
|
||||
? "Couldn't load audio for this chapter/narrator."
|
||||
: translation === 'ESV'
|
||||
? 'ESV audio, read by David Cochran Heath. It follows the chapter as you browse.'
|
||||
: translation !== 'BSB'
|
||||
? `Audio is BSB only — no ${translation} audio is available, so it won't match the ${translation} text on screen.`
|
||||
: 'Audio follows the chapter as you browse.'}
|
||||
status={audio.state.status}
|
||||
currentTime={audio.progress.currentTime}
|
||||
duration={audio.progress.duration}
|
||||
onPlay={audio.play}
|
||||
onTogglePause={audio.togglePause}
|
||||
onSeekBack={() => audio.seek(-10)}
|
||||
onSeekForward={() => audio.seek(10)}
|
||||
onSeekTo={audio.seekTo}
|
||||
onNext={onNext}
|
||||
nextDisabled={nextDisabled}
|
||||
onStop={audio.stop}
|
||||
controls={translation === 'ESV' ? null : (
|
||||
<select value={narrator} onChange={(e) => onNarrator(e.target.value)}
|
||||
className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
|
||||
<option value="david">David</option>
|
||||
<option value="hays">Hays</option>
|
||||
<option value="souer">Souer</option>
|
||||
</select>
|
||||
)}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Compare translations: pick a second Bible to read beside the one you're reading, and whether the
|
||||
// words that differ are highlighted. The chapter shows how different the two are.
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
|
||||
|
||||
export default function CompareSheet({ open, onClose, translation, parallel, onParallel, showDiff, onShowDiff }) {
|
||||
const chip = (active) => `rounded-full border px-3 py-1.5 text-sm font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
|
||||
const options = READER_TRANSLATIONS.filter((t) => t.id !== translation);
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="Compare translations">
|
||||
<p className="mb-3 text-sm text-[var(--r-sub)]">
|
||||
Read another Bible beside the {translation}. You’ll see how different its wording is for the whole chapter and for each verse.
|
||||
</p>
|
||||
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Compare {translation} with</span>
|
||||
<div role="group" aria-label="Translation to compare with" className="mb-4 flex flex-wrap gap-1.5">
|
||||
{options.map((t) => (
|
||||
<button key={t.id} type="button" title={t.name} aria-pressed={parallel === t.id} onClick={() => { onParallel(parallel === t.id ? '' : t.id); if (parallel !== t.id) onClose(); }} className={chip(parallel === t.id)}>{t.id}</button>
|
||||
))}
|
||||
<button type="button" aria-pressed={!parallel} onClick={() => onParallel('')} className={chip(!parallel)}>Off</button>
|
||||
</div>
|
||||
{parallel && (
|
||||
<>
|
||||
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Highlight the words that differ</span>
|
||||
<div role="group" aria-label="Highlight differences" className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
|
||||
{[[true, 'Show'], [false, 'Hide']].map(([id, text]) => (
|
||||
<button key={String(id)} type="button" aria-pressed={showDiff === id} onClick={() => onShowDiff(id)}
|
||||
className={`flex-1 px-3 py-2 text-sm ${showDiff === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{text}</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="mt-4 text-[11px] text-[var(--r-sub)]">The wording is compared word by word, ignoring capitals and punctuation. It shows how the wording differs, not which translation is more accurate. Tap any verse and open Compare to see all six translations of it.</p>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Text with the words that differ from the other translation marked.
|
||||
export function DiffText({ text, ranges = [] }) {
|
||||
if (!ranges.length) return text;
|
||||
const parts = [];
|
||||
let cursor = 0;
|
||||
ranges.forEach((r, i) => {
|
||||
if (r.start > cursor) parts.push(text.slice(cursor, r.start));
|
||||
parts.push(<span key={i} className="diff-hl">{text.slice(r.start, r.end)}</span>);
|
||||
cursor = r.end;
|
||||
});
|
||||
if (cursor < text.length) parts.push(text.slice(cursor));
|
||||
return <>{parts}</>;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Everything you have marked in the Bible: highlights (grouped and filtered by colour, each colour
|
||||
// named by you — "Promises", "Commands") and bookmarks. Opens from the bookmark button in the top bar.
|
||||
import { useEffect, useState } from 'react';
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { CloseIcon } from './icons.jsx';
|
||||
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
|
||||
|
||||
const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
|
||||
|
||||
export default function MarksSheet({
|
||||
open, onClose, initialTab = 'highlights', bookmarks, highlights, colorLabels, onSetLabel, onJump, onRemoveBookmark, onRemoveHighlight,
|
||||
}) {
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
// Opening from the Highlights button shows highlights; from the Bookmarks button, bookmarks
|
||||
useEffect(() => { if (open) setTab(initialTab); }, [open, initialTab]);
|
||||
const [filter, setFilter] = useState(null); // a highlight colour, or null for all
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const counts = Object.fromEntries(HIGHLIGHT_COLORS.map((c) => [c, highlights.filter((h) => h.color === c).length]));
|
||||
const shown = filter ? highlights.filter((h) => h.color === filter) : highlights;
|
||||
// Colours in use that aren't one of the picker's (older data) still get listed
|
||||
const total = highlights.length + bookmarks.length;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} label="Bookmarks and highlights" title={`Bookmarks & highlights${total ? ` (${total})` : ''}`}>
|
||||
<div role="tablist" aria-label="What to show" className="mb-3 flex gap-1.5">
|
||||
<button type="button" role="tab" aria-selected={tab === 'highlights'} onClick={() => setTab('highlights')} className={tabBtn(tab === 'highlights')}>Highlights ({highlights.length})</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'bookmarks'} onClick={() => setTab('bookmarks')} className={tabBtn(tab === 'bookmarks')}>Bookmarks ({bookmarks.length})</button>
|
||||
</div>
|
||||
|
||||
{tab === 'highlights' && (
|
||||
<>
|
||||
{/* Colour key: tap a colour to show only that one; "Name colours" lets you say what each means */}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by colour">
|
||||
<button type="button" onClick={() => setFilter(null)} aria-pressed={filter === null} className={tabBtn(filter === null)}>All</button>
|
||||
{HIGHLIGHT_COLORS.map((c) => (
|
||||
<button key={c} type="button" onClick={() => setFilter(filter === c ? null : c)} aria-pressed={filter === c}
|
||||
title={colorTitle(c, colorLabels)}
|
||||
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${filter === c ? 'border-[var(--r-text)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
|
||||
<span className="h-3 w-3 rounded-full border border-black/10" style={{ backgroundColor: c }} />
|
||||
<span>{colorLabels[c] || COLOR_NAMES[c]}</span>
|
||||
<span className="text-[var(--r-sub)]">{counts[c]}</span>
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => setEditing((e) => !e)} className="ml-auto text-xs text-[var(--r-accent)] hover:underline">{editing ? 'Done' : 'Name colours'}</button>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="mb-3 grid gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 sm:grid-cols-2">
|
||||
{HIGHLIGHT_COLORS.map((c) => (
|
||||
<label key={c} className="flex items-center gap-2 text-xs text-[var(--r-sub)]">
|
||||
<span className="h-4 w-4 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: c }} />
|
||||
<span className="sr-only">{COLOR_NAMES[c]} means</span>
|
||||
<input value={colorLabels[c] ?? ''} maxLength={30} onChange={(e) => onSetLabel(c, e.target.value)} placeholder={COLOR_SUGGESTIONS[c]}
|
||||
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-transparent px-2 py-1 text-sm text-[var(--r-text)] outline-none focus:border-[var(--r-accent)]" />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{highlights.length === 0 ? (
|
||||
<p className="text-sm text-[var(--r-sub)]">No highlights yet. Select some words in a verse and pick a colour to save them here.</p>
|
||||
) : shown.length === 0 ? (
|
||||
<p className="text-sm text-[var(--r-sub)]">Nothing highlighted in that colour yet.</p>
|
||||
) : shown.map((h) => (
|
||||
<div key={h.id} className="flex items-start gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||
<span className="mt-1 h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} title={colorTitle(h.color, colorLabels)} />
|
||||
<button type="button" onClick={() => onJump(h.book, h.chapter, h.verse)} className="min-w-0 flex-1 text-left text-sm">
|
||||
<span className="font-medium">{h.bookName} {h.chapter}:{h.verse}</span>
|
||||
{colorLabels[h.color] && <span className="ml-1.5 text-[11px] text-[var(--r-sub)]">{colorLabels[h.color]}</span>}
|
||||
{h.text && <span className="mt-0.5 block font-serif text-[var(--r-sub)]">“{h.text}”</span>}
|
||||
</button>
|
||||
<button type="button" onClick={() => onRemoveHighlight(h)} aria-label={`Remove highlight on ${h.bookName} ${h.chapter}:${h.verse}`}
|
||||
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'bookmarks' && (
|
||||
bookmarks.length === 0 ? (
|
||||
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
|
||||
) : bookmarks.map((e) => (
|
||||
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
|
||||
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
|
||||
{e.bookName} {e.chapter}:{e.verse}
|
||||
</button>
|
||||
<button type="button" onClick={() => onRemoveBookmark(e)} aria-label={`Remove bookmark on ${e.bookName} ${e.chapter}:${e.verse}`}
|
||||
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// One place to pick translation, book and chapter (replaces three dropdowns).
|
||||
import { useState } from 'react';
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx';
|
||||
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
|
||||
import { SaveBookOffline } from '../OfflineSave.jsx';
|
||||
|
||||
const NT_START = bookOptions.findIndex((b) => b.abbrev === 'MAT');
|
||||
const chip = 'rounded-lg border px-2 py-2 text-center text-sm transition';
|
||||
|
||||
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo, offline }) {
|
||||
const [pickedBook, setPickedBook] = useState(null); // book abbrev whose chapters are showing
|
||||
const close = () => { setPickedBook(null); onClose(); };
|
||||
const book = pickedBook ? bookOptions.find((b) => b.abbrev === pickedBook) : null;
|
||||
|
||||
const bookButton = (b) => (
|
||||
<button key={b.abbrev} type="button" onClick={() => setPickedBook(b.abbrev)}
|
||||
className={`${chip} truncate ${b.abbrev === bookAbbrev ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
|
||||
{b.name}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={close} label="Go to" title={book ? (
|
||||
<button type="button" onClick={() => setPickedBook(null)} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">‹ All books · <span className="text-[var(--r-text)]">{book.name}</span></button>
|
||||
) : 'Go to'}>
|
||||
{!book && (
|
||||
<>
|
||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||
{READER_TRANSLATIONS.map((t) => (
|
||||
<button key={t.id} type="button" onClick={() => onTranslation(t.id)} title={t.name}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-medium ${t.id === translation ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
{t.id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{translationNote && <p className="-mt-1 mb-3 text-[11px] text-[var(--r-sub)]">{translationNote}</p>}
|
||||
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Old Testament</p>
|
||||
<div className="mb-4 grid grid-cols-3 gap-1.5 sm:grid-cols-4">{bookOptions.slice(0, NT_START).map(bookButton)}</div>
|
||||
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">New Testament</p>
|
||||
<div className="grid grid-cols-3 gap-1.5 sm:grid-cols-4">{bookOptions.slice(NT_START).map(bookButton)}</div>
|
||||
</>
|
||||
)}
|
||||
{book && (
|
||||
<>
|
||||
<div className="grid grid-cols-6 gap-1.5 sm:grid-cols-8">
|
||||
{Array.from({ length: CHAPTER_COUNTS[book.abbrev] ?? 1 }, (_, i) => i + 1).map((n) => (
|
||||
<button key={n} type="button" onClick={() => { onGo(book.abbrev, n); close(); }}
|
||||
className={`${chip} ${book.abbrev === bookAbbrev && n === chapter ? 'border-[var(--r-accent)] bg-[var(--r-accent)] text-[var(--r-on)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{offline && <SaveBookOffline offline={offline} translation={translation} book={book.abbrev} name={book.name} />}
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Card at the end of the chapter while reading a plan: step to the next chapter of
|
||||
// today's reading, or finish the day and roll into the next one.
|
||||
export default function PlanFooter({ info, label, onNext, onNextDay, onFinish }) {
|
||||
if (!info || info.position === 0) return null;
|
||||
const last = info.position === info.total;
|
||||
const nextRef = !last ? info.refs[info.position] : null;
|
||||
const btn = 'rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)]';
|
||||
return (
|
||||
<div className="mx-auto mt-10 max-w-md rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4 text-center font-sans">
|
||||
<p className="text-xs text-[var(--r-sub)]">{info.plan.title} · Day {info.dayIndex + 1}</p>
|
||||
{!last && (
|
||||
<>
|
||||
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">{info.total - info.position} more chapter{info.total - info.position === 1 ? '' : 's'} today</p>
|
||||
<button type="button" onClick={onNext} className={btn}>Next: {label(nextRef)}</button>
|
||||
</>
|
||||
)}
|
||||
{last && info.nextDayRefs?.length > 0 && (
|
||||
<>
|
||||
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">That’s Day {info.dayIndex + 1}</p>
|
||||
<button type="button" onClick={onNextDay} className={btn}>Mark read · Day {info.dayIndex + 2}: {label(info.nextDayRefs[0])}</button>
|
||||
<div><button type="button" onClick={onFinish} className="mt-2 text-xs text-[var(--r-sub)] underline">Mark read and stop for today</button></div>
|
||||
</>
|
||||
)}
|
||||
{last && !info.nextDayRefs?.length && (
|
||||
<>
|
||||
<p className="mb-3 mt-1 text-sm font-medium text-[var(--r-text)]">Last day of the plan</p>
|
||||
<button type="button" onClick={onFinish} className={btn}>{info.done ? 'Plan complete' : 'Finish plan'}</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// The plan's control room, one tap from the reader: pick the day, tick chapters,
|
||||
// choose the Bible this plan is read in, and jump to another active plan.
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { ChevronLeft, ChevronRight, CheckIcon } from './icons.jsx';
|
||||
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
|
||||
import { formatDayReadings } from '../../utils/readingPlans.js';
|
||||
|
||||
export default function PlanSheet({
|
||||
open, onClose, info, translation, currentKey, streak, otherPlans, studyBusy,
|
||||
onDay, onToggleChapter, onGoChapter, onTranslation, onToggleDay, onSwitchPlan, onListen, onStudy, onLeave,
|
||||
}) {
|
||||
if (!info) return null;
|
||||
const label = (r) => formatDayReadings([r]);
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title={info.plan.title}>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<button type="button" onClick={() => onDay(-1)} disabled={info.dayIndex <= 0} aria-label="Previous day"
|
||||
className="rounded-lg p-1.5 text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-30"><ChevronLeft /></button>
|
||||
<span className="text-sm font-medium">Day {info.dayIndex + 1} of {info.plan.days}{info.done ? ' · read' : ''}</span>
|
||||
<button type="button" onClick={() => onDay(1)} disabled={info.dayIndex >= info.plan.days - 1} aria-label="Next day"
|
||||
className="rounded-lg p-1.5 text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-30"><ChevronRight /></button>
|
||||
</div>
|
||||
|
||||
<p className="mb-1 mt-3 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Reading</p>
|
||||
{info.refs.map((r) => {
|
||||
const key = `${r.book}.${r.chapter}`;
|
||||
const read = info.readSet.has(key);
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3 border-b border-[var(--r-line)] py-2">
|
||||
<button type="button" onClick={() => onToggleChapter(key)} aria-label={read ? 'Mark unread' : 'Mark read'}
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-[1.5px] ${read ? 'border-[var(--r-accent)] bg-[var(--r-accent)] text-[var(--r-on)]' : 'border-[var(--r-sub)] text-transparent'}`}>
|
||||
<span className="scale-75"><CheckIcon /></span>
|
||||
</button>
|
||||
<button type="button" onClick={() => onGoChapter(r)} className={`flex-1 text-left text-sm ${key === currentKey ? 'font-medium' : ''}`}>{label(r)}</button>
|
||||
{key === currentKey && <span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[10px] text-[var(--r-sub)]">reading</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button type="button" onClick={onToggleDay}
|
||||
className="mt-3 w-full rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5">
|
||||
{info.done ? 'Mark this day unread' : 'Mark the whole day read'}
|
||||
</button>
|
||||
|
||||
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">This plan</p>
|
||||
<div className="flex items-center justify-between py-1.5 text-sm">
|
||||
<span>Read in</span>
|
||||
<select value={translation} onChange={(e) => onTranslation(e.target.value)}
|
||||
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1 text-sm text-[var(--r-text)]">
|
||||
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5 text-sm">
|
||||
<span>Streak</span><span className="text-[var(--r-sub)]">{streak.current} day{streak.current === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button type="button" onClick={onListen} className="flex-1 rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5">Listen</button>
|
||||
<button type="button" onClick={onStudy} disabled={studyBusy} className="flex-1 rounded-xl border border-[var(--r-line)] py-2 text-sm hover:bg-black/5 disabled:opacity-50">{studyBusy ? 'Building…' : 'Study this day'}</button>
|
||||
</div>
|
||||
|
||||
{otherPlans.length > 0 && (
|
||||
<>
|
||||
<p className="mb-1 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Other plans</p>
|
||||
{otherPlans.map((o) => (
|
||||
<button key={o.progress.id} type="button" onClick={() => onSwitchPlan(o)}
|
||||
className="flex w-full items-center justify-between rounded-lg px-1 py-2 text-left text-sm hover:bg-black/5">
|
||||
<span className="truncate">{o.plan.title}</span>
|
||||
<span className="ml-3 shrink-0 text-xs text-[var(--r-sub)]">Day {o.dayIndex + 1}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<button type="button" onClick={onLeave} className="mt-4 w-full text-center text-xs text-[var(--r-sub)] underline">Leave plan (keep reading on my own)</button>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Thin strip under the top bar while reading a plan: which plan and day, one dot
|
||||
// per chapter of today's reading (filled = read, ringed = the chapter on screen),
|
||||
// and the translation. Tapping it opens the plan sheet. When you've wandered off
|
||||
// today's chapters (e.g. following a cross-reference) it offers a way back.
|
||||
export default function PlanStrip({ info, translation, inPlan, notice, onOpen, onJoin, onDot, onBackToDay }) {
|
||||
if (!info && !notice) return null;
|
||||
return (
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-1 px-3 pt-2 font-sans">
|
||||
{notice && <p className="rounded-lg bg-[var(--r-bar)] px-3 py-1.5 text-xs text-[var(--r-accent)]">{notice}</p>}
|
||||
{info && (
|
||||
<div className="rounded-xl bg-[var(--r-bar)] px-3 py-2 text-xs text-[var(--r-sub)]">
|
||||
<button type="button" onClick={inPlan ? onOpen : onJoin} className="flex w-full items-center justify-between gap-3 text-left">
|
||||
<span className="truncate font-medium text-[var(--r-text)]">{info.plan.title} · Day {info.dayIndex + 1}</span>
|
||||
<span className="shrink-0">{inPlan ? translation : 'Open plan'}</span>
|
||||
</button>
|
||||
{inPlan && info.position === 0 && (
|
||||
<button type="button" onClick={onBackToDay} className="mt-1.5 rounded-full border border-[var(--r-line)] px-3 py-1 text-[11px] text-[var(--r-accent)] hover:bg-black/5">
|
||||
Back to today’s reading
|
||||
</button>
|
||||
)}
|
||||
{inPlan && info.position > 0 && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
{info.refs.map((r, i) => {
|
||||
const key = `${r.book}.${r.chapter}`;
|
||||
const read = info.readSet.has(key);
|
||||
const current = i + 1 === info.position;
|
||||
return (
|
||||
<button key={key} type="button" onClick={() => onDot(r)} aria-label={`Go to chapter ${r.chapter}`}
|
||||
className={`h-2.5 w-2.5 rounded-full border-[1.5px] ${read ? 'border-[var(--r-accent)] bg-[var(--r-accent)]' : 'border-[var(--r-sub)]'} ${current ? 'ring-2 ring-[var(--r-line)]' : ''}`} />
|
||||
);
|
||||
})}
|
||||
<span className="ml-auto">{info.done ? 'Day read' : `${info.position} of ${info.total}`}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Top and bottom chrome for the reader. Everything stays one tap away; focus
|
||||
// mode hides both.
|
||||
import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon, HighlighterIcon } from './icons.jsx';
|
||||
import { BookmarkIcon } from '../../utils/bibleUtils.jsx';
|
||||
|
||||
const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent';
|
||||
|
||||
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onHighlights, highlightCount, onCompare, compareActive, onSettings, onDoneDrawing }) {
|
||||
return (
|
||||
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
|
||||
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
|
||||
<button type="button" onClick={onBack} className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] transition hover:bg-black/5" aria-label="Back to home" data-tip="Back to home"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
|
||||
<button type="button" onClick={onOpenNavigator}
|
||||
className="mx-auto flex min-w-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--r-text)] hover:bg-black/5" data-tip="Go to a book and chapter (G)">
|
||||
<span className="truncate">{title}</span><ChevronDown />
|
||||
</button>
|
||||
{onDoneDrawing ? (
|
||||
<button type="button" onClick={onDoneDrawing}
|
||||
className="mr-1 flex items-center gap-1.5 rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-xs font-medium text-[var(--r-on)]">
|
||||
<CheckIcon />Done drawing
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={onSearch} className={iconBtn} aria-label="Search" data-tip="Search (/)"><SearchIcon /></button>
|
||||
<button type="button" onClick={onHighlights} className={`${iconBtn} ${highlightCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Highlights" data-tip="Your highlights, by colour (H)">
|
||||
<HighlighterIcon />
|
||||
</button>
|
||||
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks" data-tip="Your bookmarks">
|
||||
<BookmarkIcon filled={bookmarkCount > 0} />
|
||||
</button>
|
||||
<button type="button" onClick={onCompare} className={`${iconBtn} ${compareActive ? 'text-[var(--r-accent)]' : ''}`} aria-label="Compare translations" aria-pressed={!!compareActive} data-tip="Compare translations (C)"><CompareIcon /></button>
|
||||
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings" data-tip="Text size, typeface and theme (,)">Aa</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BottomBar({
|
||||
onPrev, onNext, prevDisabled, nextDisabled,
|
||||
onAudio, audioActive, onPen, onCrossRefs, crossRefsOn, onFocus, primary,
|
||||
}) {
|
||||
const active = 'text-[var(--r-accent)]';
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)] font-sans">
|
||||
<div className="mx-auto flex h-14 max-w-4xl items-center gap-0.5 px-2">
|
||||
<button type="button" onClick={onPrev} disabled={prevDisabled} className={iconBtn} aria-label="Previous chapter" data-tip="Previous chapter (←)"><ChevronLeft /></button>
|
||||
<button type="button" onClick={onAudio} className={`${iconBtn} ${audioActive ? active : ''}`} aria-label="Listen" data-tip="Listen to this chapter"><HeadphonesIcon /></button>
|
||||
<button type="button" onClick={onPen} className={iconBtn} aria-label="Draw notes" data-tip="Draw notes on this page"><PenIcon /></button>
|
||||
<button type="button" onClick={onCrossRefs} className={`${iconBtn} ${crossRefsOn ? active : ''}`} aria-label="Show cross-references under verses" data-tip="Show cross-references under each verse"><LinkIcon /></button>
|
||||
<button type="button" onClick={onFocus} className={iconBtn} aria-label="Hide controls" data-tip="Hide the controls (F)"><FocusIcon /></button>
|
||||
<div className="mx-auto" />
|
||||
{primary && (
|
||||
<button type="button" onClick={primary.onClick}
|
||||
className="mr-1 flex items-center gap-1.5 rounded-full bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">
|
||||
{primary.done ? null : <CheckIcon />}{primary.label}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onNext} disabled={nextDisabled} className={iconBtn} aria-label="Next chapter" data-tip="Next chapter (→)"><ChevronRight /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// The page itself: chapter heading, translation notice, and verses set in a
|
||||
// serif face. Tap a verse to open the verse sheet; select text to highlight.
|
||||
import { useMemo } from 'react';
|
||||
import { formatCrossRef } from '../../utils/wordStudy.js';
|
||||
import { diffChapter, differenceLabel } from '../../utils/textDiff.js';
|
||||
import { DiffText } from './DiffText.jsx';
|
||||
|
||||
// How each typeface choice is set. "Easy to read" is a sans face with extra letter and word spacing.
|
||||
const FONT_STYLES = {
|
||||
serif: { fontFamily: undefined },
|
||||
sans: { fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif" },
|
||||
easy: { fontFamily: "'Atkinson Hyperlegible', Verdana, 'Trebuchet MS', ui-sans-serif, sans-serif", letterSpacing: '0.02em', wordSpacing: '0.12em' },
|
||||
};
|
||||
const LINE_HEIGHTS = { snug: 1.6, normal: 1.85, airy: 2.15 };
|
||||
|
||||
// `parallel` ({ translation, name, notice, verses, loading, error }) shows a second Bible beside the first.
|
||||
export default function ReaderText({
|
||||
title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide,
|
||||
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null, showDiff = true,
|
||||
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
|
||||
crossRefs, showCrossRefs, onOpenCrossRef, onSelectionEnd, footer,
|
||||
}) {
|
||||
// How much the second Bible's wording differs from the first, over the chapter and verse by verse
|
||||
const diff = useMemo(
|
||||
() => (parallel && showDiff && !parallel.loading && !parallel.error && parallel.verses?.length ? diffChapter(verses, parallel.verses) : null),
|
||||
[parallel, showDiff, verses],
|
||||
);
|
||||
const diffByVerse = useMemo(() => new Map((diff?.verses ?? []).map((d) => [d.number, d])), [diff]);
|
||||
const jumpToVerse = (n) => document.getElementById(`reader-verse-${n}`)?.scrollIntoView?.({ block: 'center', behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
|
||||
|
||||
const handleTap = (n) => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed && sel.toString().trim()) return; // user is selecting text, not tapping
|
||||
onSelectVerse(selectedVerse === n ? null : n);
|
||||
};
|
||||
|
||||
const verseProps = (v) => {
|
||||
const bm = bookmarks[`${bookKey}-${chapter}-${v.number}`];
|
||||
const selected = selectedVerse === v.number;
|
||||
return {
|
||||
id: `reader-verse-${v.number}`,
|
||||
'data-verse-num': v.number,
|
||||
onClick: () => handleTap(v.number),
|
||||
// Verses can be reached and chosen from the keyboard too
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
'aria-pressed': selected,
|
||||
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleTap(v.number); } },
|
||||
className: 'cursor-pointer rounded-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--r-accent)]',
|
||||
style: selected
|
||||
? { backgroundColor: 'var(--r-hl)' }
|
||||
: bm ? { backgroundColor: `${bm}66`, boxShadow: `0 0 0 2px ${bm}66` } : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const chips = (v) => showCrossRefs && crossRefs?.[v.number]?.length > 0 && (
|
||||
<span className="ml-1 inline-flex flex-wrap gap-1 align-baseline font-sans">
|
||||
{crossRefs[v.number].slice(0, 4).map((ref, i) => (
|
||||
// Opens that passage in the side panel; stopPropagation so the verse itself isn't also toggled
|
||||
<button key={i} type="button" onClick={(e) => { e.stopPropagation(); onOpenCrossRef?.(v.number, ref); }}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="rounded-full border border-[var(--r-line)] px-1.5 py-px text-[10px] text-[var(--r-sub)] hover:border-[var(--r-accent)] hover:text-[var(--r-accent)]"
|
||||
data-tip={`Read ${formatCrossRef(ref)} in the side panel`}>{formatCrossRef(ref)}</button>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
|
||||
const num = (n) => (showVerseNumbers ? <sup className="mr-1 select-none font-sans text-[0.6em] text-[var(--r-sub)]">{n}</sup> : null);
|
||||
|
||||
// Both Bibles by verse number, so a verse one translation leaves out still lines up with the other.
|
||||
const parallelRows = parallel && !parallel.error ? (() => {
|
||||
const second = new Map(parallel.verses.map((v) => [v.number, v]));
|
||||
const first = new Map(verses.map((v) => [v.number, v]));
|
||||
return [...new Set([...first.keys(), ...second.keys()])].sort((a, b) => a - b)
|
||||
.map((n) => ({ number: n, first: first.get(n), second: second.get(n) }));
|
||||
})() : null;
|
||||
const sideBySide = !!parallel;
|
||||
const bodyStyle = {
|
||||
...FONT_STYLES[font],
|
||||
lineHeight: LINE_HEIGHTS[spacing] ?? LINE_HEIGHTS.normal,
|
||||
};
|
||||
// The page grows with the screen: type is 18px on a phone, ~20px on an iPad and ~24px on a big
|
||||
// monitor (times your text-size choice), and the column is measured in the type's own width (em), so
|
||||
// the line length stays comfortable (~70 characters) instead of becoming a narrow strip on a wide
|
||||
// screen. Two columns / side-by-side take more room.
|
||||
const pageStyle = {
|
||||
fontSize: `calc(clamp(1.125rem, 0.9rem + 0.7vw, 1.55rem) * ${fontSize})`,
|
||||
maxWidth: wide || sideBySide ? 'min(100%, 72em)' : 'min(100%, 40em)',
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
className="mx-auto px-5 pb-32 pt-6 text-[var(--r-text)] md:px-8"
|
||||
style={pageStyle}
|
||||
onPointerUp={onSelectionEnd}
|
||||
>
|
||||
<header className="mb-5 text-center">
|
||||
<h1 className="font-serif text-3xl font-normal tracking-tight">{title}</h1>
|
||||
<p className="mt-1 font-sans text-xs text-[var(--r-sub)]">{translation}{parallel ? ` · beside ${parallel.name}` : ''}</p>
|
||||
</header>
|
||||
|
||||
{loading && <ChapterSkeleton />}
|
||||
{error && <p role="alert" className="text-center font-sans text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
{!loading && !error && parallel?.error && (
|
||||
<p role="alert" className="mb-4 text-center font-sans text-sm text-[var(--r-danger,#b42318)]">Couldn’t load {parallel.name}: {parallel.error}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && sideBySide && !parallel.error && (
|
||||
<div style={bodyStyle} className="font-serif">
|
||||
{diff && (
|
||||
<div className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 font-sans text-sm" style={{ fontSize: '0.875rem' }}>
|
||||
<p>
|
||||
<strong>{parallel.translation}</strong>{diff.similarity === 1 ? ' uses the same words as ' : <> is <strong>{differenceLabel(diff.similarity)}</strong> from </>}{translationId} in this chapter
|
||||
{diff.total > 0 && <span className="text-[var(--r-sub)]"> · {diff.differing} of {diff.total} verses differ</span>}
|
||||
</p>
|
||||
{diff.mostDifferent.length > 0 && (
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">
|
||||
Most different:{' '}
|
||||
{diff.mostDifferent.map((v, i) => (
|
||||
<span key={v.number}>{i > 0 && ', '}<button type="button" onClick={() => jumpToVerse(v.number)} className="font-medium text-[var(--r-accent)] hover:underline">verse {v.number}</button> ({Math.round((1 - v.similarity) * 100)}%)</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]"><span className="diff-hl">Marked words</span> aren’t in the other wording.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3 hidden grid-cols-2 gap-x-10 border-b border-[var(--r-line)] pb-1 font-sans text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid">
|
||||
<span>{translationId}</span><span>{parallel.translation}</span>
|
||||
</div>
|
||||
{parallelRows.map((row) => (
|
||||
<div key={row.number} className="mb-4 grid gap-x-10 gap-y-1 md:grid-cols-2">
|
||||
<p>
|
||||
{row.first
|
||||
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower, diffByVerse.get(row.number)?.a)}{chips(row.first)}</span>
|
||||
: <span className="text-[var(--r-sub)]">{num(row.number)}—</span>}
|
||||
</p>
|
||||
<p className="border-l-2 border-[var(--r-line)] pl-3 text-[0.95em] text-[var(--r-sub)] md:border-l-0 md:pl-0 md:text-[var(--r-text)]">
|
||||
<span className="mr-1.5 font-sans text-[10px] uppercase tracking-wide md:hidden">{parallel.translation}</span>
|
||||
{row.second ? <>{num(row.second.number)}<DiffText text={row.second.text} ranges={diffByVerse.get(row.number)?.b} />{diffByVerse.get(row.number) && !diffByVerse.get(row.number).same && (
|
||||
<span className="ml-2 whitespace-nowrap font-sans text-[10px] text-[var(--r-sub)]">{differenceLabel(diffByVerse.get(row.number).similarity)}</span>
|
||||
)}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !sideBySide && (
|
||||
<div
|
||||
className={`font-serif ${wide ? 'md:columns-2 md:gap-x-12' : ''}`}
|
||||
style={bodyStyle}
|
||||
>
|
||||
{layout === 'flow' ? (
|
||||
<p className="text-pretty">
|
||||
{verses.map((v) => (
|
||||
<span key={v.number} {...verseProps(v)}>
|
||||
{num(v.number)}{renderVerseText(v.number, v.text, searchLower)}{chips(v)}{' '}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
) : (
|
||||
verses.map((v) => (
|
||||
<p key={v.number} className="mb-3 break-inside-avoid">
|
||||
<span {...verseProps(v)}>{num(v.number)}{renderVerseText(v.number, v.text, searchLower)}{chips(v)}</span>
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && <div style={{ fontSize: '1rem' }}>{footer}</div>}
|
||||
|
||||
{notice && !loading && <p className="mt-10 border-t border-[var(--r-line)] pt-3 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{notice}</p>}
|
||||
{parallel?.notice && !loading && !parallel.loading && <p className="mt-2 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{parallel.notice}</p>}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// Grey bars the shape of a page, so a slow chapter load doesn't just show the word "Loading".
|
||||
function ChapterSkeleton() {
|
||||
return (
|
||||
<div role="status" aria-label="Loading chapter" className="animate-pulse motion-reduce:animate-none space-y-3 pt-2">
|
||||
{[100, 96, 92, 98, 84, 100, 90, 70].map((w, i) => <div key={i} className="h-4 rounded bg-[var(--r-line)]" style={{ width: `${w}%` }} />)}
|
||||
<span className="sr-only">Loading…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
import ReaderText from './ReaderText.jsx';
|
||||
|
||||
const verses = [
|
||||
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
|
||||
{ number: 2, text: 'Now the earth was formless and void.' },
|
||||
];
|
||||
const second = [
|
||||
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
|
||||
{ number: 2, text: 'Now the earth was without shape and empty.' },
|
||||
];
|
||||
const base = {
|
||||
title: 'Genesis 1', translation: 'Berean Standard Bible', translationId: 'BSB', notice: '', verses, loading: false, error: '',
|
||||
layout: 'flow', fontSize: 1, wide: false, bookmarks: {}, bookKey: 'GEN', chapter: 1, selectedVerse: null,
|
||||
onSelectVerse: vi.fn(), renderVerseText: (_n, text) => text, searchLower: '', crossRefs: null, showCrossRefs: false, onSelectionEnd: vi.fn(),
|
||||
};
|
||||
const parallel = (over = {}) => ({ translation: 'NET', name: 'NET Bible', notice: '', verses: second, loading: false, error: '', ...over });
|
||||
|
||||
test('side by side, it says how different the other Bible is and marks the differing words', () => {
|
||||
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff />);
|
||||
expect(screen.getByText(/is/, { selector: 'p' })).toHaveTextContent(/NET is \d+% different from BSB in this chapter/);
|
||||
expect(screen.getByText(/1 of 2 verses differ/)).toBeInTheDocument();
|
||||
const marked = [...container.querySelectorAll('.diff-hl')].map((n) => n.textContent);
|
||||
expect(marked).toContain('without shape'); // the other Bible's side
|
||||
expect(screen.getByRole('button', { name: /verse 2/i })).toBeInTheDocument(); // the most different verse
|
||||
expect(screen.getByText(/% different/, { selector: 'span.ml-2' })).toBeInTheDocument(); // per-verse figure
|
||||
});
|
||||
|
||||
test('identical wording is reported as the same words', () => {
|
||||
render(<ReaderText {...base} parallel={parallel({ verses })} showDiff />);
|
||||
expect(screen.getByText(/uses the same words as/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('with highlighting off there is no summary and no marks', () => {
|
||||
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff={false} />);
|
||||
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.diff-hl')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('while the other Bible loads there is no comparison yet, and the first Bible still shows', () => {
|
||||
render(<ReaderText {...base} parallel={parallel({ verses: [], loading: true })} showDiff />);
|
||||
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText(/In the beginning God created/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('the left Bible gets its own differing words passed to the verse renderer', () => {
|
||||
const renderVerseText = vi.fn((_n, text) => text);
|
||||
render(<ReaderText {...base} renderVerseText={renderVerseText} parallel={parallel()} showDiff />);
|
||||
const call = renderVerseText.mock.calls.find(([n]) => n === 2);
|
||||
expect(call[3].map((r) => verses[1].text.slice(r.start, r.end))).toEqual(['formless', 'void']);
|
||||
fireEvent.click(screen.getByRole('button', { name: /verse 2/i })); // jumping must not throw even if the element is absent
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// Search this chapter, or the whole Bible in the translation being read.
|
||||
// BSB and NET are downloaded once and searched in the browser; ESV, NLT, CSB and
|
||||
// NASB are searched through the server (their licences don't allow a bulk copy).
|
||||
import { useMemo } from 'react';
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { SearchIcon } from './icons.jsx';
|
||||
import { bookOptions } from '../../utils/bibleUtils.jsx';
|
||||
|
||||
const nameFor = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
|
||||
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
// Wraps each searched word (quotes ignored) in <mark>.
|
||||
function highlight(text, query) {
|
||||
const words = [...new Set(query.toLowerCase().replace(/["“”]/g, ' ').split(/\s+/).filter((w) => w.length > 1))];
|
||||
if (!words.length) return text;
|
||||
const re = new RegExp(`(${words.map(escapeRegExp).join('|')})`, 'gi');
|
||||
return text.split(re).map((part, i) => (i % 2 === 1
|
||||
? <mark key={i} className="rounded bg-[var(--r-hl)] px-0.5 text-[var(--r-text)]">{part}</mark>
|
||||
: part));
|
||||
}
|
||||
|
||||
export default function SearchSheet({
|
||||
open, onClose, query, onQuery, scope, onScope, verses, bookName, chapter, currentBook, translation,
|
||||
isLocal, indexStatus, onLoadIndex, bibleMatches, deferredBibleQuery, remote, onJump,
|
||||
}) {
|
||||
const q = query.trim().toLowerCase();
|
||||
const chapterMatches = useMemo(
|
||||
() => (scope === 'chapter' && q ? verses.filter((v) => v.text.toLowerCase().includes(q)) : []),
|
||||
[scope, q, verses],
|
||||
);
|
||||
|
||||
const scopeBtn = (id, label, onClick) => (
|
||||
<button type="button" onClick={onClick}
|
||||
className={`px-3 py-1.5 text-xs ${scope === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{label}</button>
|
||||
);
|
||||
const note = (text) => <p className="text-sm text-[var(--r-sub)]">{text}</p>;
|
||||
const resultRow = (r) => (
|
||||
<button key={`${r.bookAbbrev}-${r.chapter}-${r.verse}`} type="button" onClick={() => onJump(r.bookAbbrev, r.chapter, r.verse)}
|
||||
className="block w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-black/5">
|
||||
<span className="mr-1.5 font-medium">{nameFor(r.bookAbbrev)} {r.chapter}:{r.verse}</span>{highlight(r.text, query)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="Search">
|
||||
<div className="mb-3 flex items-center gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2">
|
||||
<span className="text-[var(--r-sub)]"><SearchIcon /></span>
|
||||
<input autoFocus value={query} onChange={(e) => onQuery(e.target.value)}
|
||||
placeholder={scope === 'bible' ? `Search the whole Bible (${translation})…` : `Search ${bookName} ${chapter}…`}
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--r-text)] outline-none placeholder:text-[var(--r-sub)]" />
|
||||
</div>
|
||||
<div className="mb-3 inline-flex overflow-hidden rounded-lg border border-[var(--r-line)]">
|
||||
{scopeBtn('chapter', 'This chapter', () => onScope('chapter'))}
|
||||
{scopeBtn('bible', `Whole Bible (${translation})`, () => { onScope('bible'); if (isLocal && indexStatus === 'idle') onLoadIndex(); })}
|
||||
</div>
|
||||
|
||||
{scope === 'chapter' && (
|
||||
!q ? note('Type a word to find it in this chapter.')
|
||||
: chapterMatches.length === 0 ? note(`No verses match “${query}”.`)
|
||||
: chapterMatches.map((v) => (
|
||||
<button key={v.number} type="button" onClick={() => onJump(currentBook, chapter, v.number)}
|
||||
className="block w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-black/5">
|
||||
<span className="mr-1.5 font-medium">{v.number}</span>{highlight(v.text, query)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
||||
{scope === 'bible' && isLocal && (
|
||||
<>
|
||||
{indexStatus === 'loading' && note('Loading the full Bible for search. This happens once per visit (about 7 MB).')}
|
||||
{indexStatus === 'error' && <p className="text-sm text-[var(--r-danger,#b42318)]">Couldn’t load the full Bible. <button type="button" onClick={onLoadIndex} className="underline">Try again</button></p>}
|
||||
{indexStatus === 'ready' && (!deferredBibleQuery
|
||||
? note('Type a word to search all 66 books.')
|
||||
: bibleMatches.length === 0
|
||||
? note(`No verses match “${query}”.`)
|
||||
: (
|
||||
<>
|
||||
<p className="mb-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
|
||||
{bibleMatches.length} match{bibleMatches.length === 1 ? '' : 'es'}{bibleMatches.length > 100 ? ' (showing first 100)' : ''}
|
||||
</p>
|
||||
{bibleMatches.slice(0, 100).map(resultRow)}
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{scope === 'bible' && !isLocal && (
|
||||
<>
|
||||
{remote.tooShort && note('Type at least two letters. Use quotes for an exact phrase, like “good shepherd”.')}
|
||||
{!remote.tooShort && remote.error && <p className="text-sm text-[var(--r-danger,#b42318)]">{remote.error}</p>}
|
||||
{!remote.tooShort && !remote.error && remote.loading && remote.results.length === 0 && note('Searching…')}
|
||||
{!remote.tooShort && !remote.error && !remote.loading && remote.total === 0 && remote.page > 0 && note(`No verses match “${query}”.`)}
|
||||
{remote.results.length > 0 && (
|
||||
<>
|
||||
<p className="mb-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
|
||||
{remote.total} match{remote.total === 1 ? '' : 'es'} in {translation}
|
||||
</p>
|
||||
{remote.results.map(resultRow)}
|
||||
{remote.hasMore && (
|
||||
<button type="button" onClick={remote.loadMore} disabled={remote.loading}
|
||||
className="mt-2 w-full rounded-lg border border-[var(--r-line)] py-2 text-sm text-[var(--r-sub)] hover:bg-black/5 disabled:opacity-50">
|
||||
{remote.loading ? 'Loading…' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { test, expect, vi } from 'vitest';
|
||||
import SearchSheet from './SearchSheet.jsx';
|
||||
|
||||
const base = {
|
||||
open: true, onClose: () => {}, query: 'shepherd', onQuery: () => {}, scope: 'bible', onScope: () => {},
|
||||
verses: [], bookName: 'John', chapter: 3, currentBook: 'JHN', translation: 'NLT', isLocal: false,
|
||||
indexStatus: 'idle', onLoadIndex: () => {}, bibleMatches: [], deferredBibleQuery: '',
|
||||
};
|
||||
|
||||
test('server-searched translations list results with highlights and jump on tap', () => {
|
||||
const onJump = vi.fn();
|
||||
const remote = {
|
||||
tooShort: false, loading: false, error: '', page: 1, total: 2, hasMore: true, loadMore: vi.fn(),
|
||||
results: [{ bookAbbrev: 'GEN', chapter: 4, verse: 2, text: 'Abel became a shepherd.' }, { bookAbbrev: 'PSA', chapter: 23, verse: 1, text: 'The Lord is my shepherd.' }],
|
||||
};
|
||||
render(<SearchSheet {...base} remote={remote} onJump={onJump} />);
|
||||
expect(screen.getByText('2 matches in NLT')).toBeTruthy();
|
||||
expect(screen.getAllByText('shepherd')[0].tagName).toBe('MARK');
|
||||
fireEvent.click(screen.getByText(/Genesis 4:2/));
|
||||
expect(onJump).toHaveBeenCalledWith('GEN', 4, 2);
|
||||
fireEvent.click(screen.getByText('Show more'));
|
||||
expect(remote.loadMore).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('shows the server error, e.g. a rate limit', () => {
|
||||
const remote = { tooShort: false, loading: false, error: 'ESV rate limit reached. Try again in a minute.', page: 0, total: 0, hasMore: false, loadMore: () => {}, results: [] };
|
||||
render(<SearchSheet {...base} translation="ESV" remote={remote} onJump={() => {}} />);
|
||||
expect(screen.getByText(/rate limit/)).toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// "Aa" sheet: text size, typeface, spacing, theme, how verses are laid out, and a second Bible
|
||||
// to read beside the first.
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { READER_THEMES } from './readerThemes.js';
|
||||
|
||||
const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]];
|
||||
|
||||
function Segmented({ options, value, onChange, label }) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
|
||||
{options.map(([id, text]) => (
|
||||
<button key={String(id)} type="button" onClick={() => onChange(id)} aria-pressed={value === id}
|
||||
className={`flex-1 px-3 py-2 text-sm ${value === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsSheet({
|
||||
open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide,
|
||||
font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers,
|
||||
}) {
|
||||
const row = 'mb-4';
|
||||
const label = 'mb-1.5 block text-xs text-[var(--r-sub)]';
|
||||
return (
|
||||
<Sheet open={open} onClose={onClose} title="Reading settings">
|
||||
<div className={row}>
|
||||
<span className={label}>Text size</span>
|
||||
<Segmented label="Text size" options={SIZES.map(([l, s]) => [s, l])} value={fontSize} onChange={onFontSize} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Typeface</span>
|
||||
<Segmented label="Typeface" options={[['serif', 'Serif'], ['sans', 'Sans'], ['easy', 'Easy to read']]} value={font} onChange={onFont} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Line spacing</span>
|
||||
<Segmented label="Line spacing" options={[['snug', 'Snug'], ['normal', 'Normal'], ['airy', 'Airy']]} value={spacing} onChange={onSpacing} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Theme</span>
|
||||
<Segmented label="Theme" options={Object.entries(READER_THEMES).map(([id, t]) => [id, t.label])} value={theme} onChange={onTheme} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Verses</span>
|
||||
<Segmented label="Verse layout" options={[['flow', 'Flowing'], ['verses', 'One per line']]} value={layout} onChange={onLayout} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Verse numbers</span>
|
||||
<Segmented label="Verse numbers" options={[[true, 'Show'], [false, 'Hide']]} value={verseNumbers} onChange={onVerseNumbers} />
|
||||
</div>
|
||||
<div className={row}>
|
||||
<span className={label}>Width</span>
|
||||
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Slide-up sheet used for every reader tool. `modal` sheets dim the page and
|
||||
// close on outside tap; non-modal ones (the verse sheet) leave the text
|
||||
// tappable. With `pane`, screens >= lg show it as a right-hand side pane
|
||||
// instead of a bottom sheet (tablet landscape / desktop).
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { CloseIcon } from './icons.jsx';
|
||||
|
||||
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export default function Sheet({ open, onClose, title, label, headerRight, modal = true, pane = false, panelRef, edge, children }) {
|
||||
const dialogRef = useRef(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// Modal sheets behave like dialogs: focus moves in, Tab stays inside, Escape closes, and focus
|
||||
// returns to whatever opened the sheet. (The verse pane is not modal, so it leaves focus alone.)
|
||||
useEffect(() => {
|
||||
if (!open || !modal) return undefined;
|
||||
const opener = document.activeElement;
|
||||
const dialog = dialogRef.current;
|
||||
if (dialog && !dialog.contains(document.activeElement)) dialog.focus({ preventScroll: true });
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') { e.stopPropagation(); onCloseRef.current?.(); return; }
|
||||
if (e.key !== 'Tab' || !dialog) return;
|
||||
const items = [...dialog.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||
if (items.length === 0) { e.preventDefault(); return; }
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (e.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
if (opener instanceof HTMLElement && document.contains(opener)) opener.focus({ preventScroll: true });
|
||||
};
|
||||
}, [open, modal]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<>
|
||||
{modal && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />}
|
||||
<div
|
||||
ref={(el) => { dialogRef.current = el; if (panelRef) panelRef.current = el; }}
|
||||
role="dialog"
|
||||
aria-modal={modal ? 'true' : undefined}
|
||||
aria-label={typeof title === 'string' ? title : label}
|
||||
tabIndex={-1}
|
||||
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl focus:outline-none border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
|
||||
pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[var(--reader-pane,24rem)] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2'
|
||||
}`}
|
||||
>
|
||||
{edge}
|
||||
<div className="mx-auto mt-2 h-1 w-9 shrink-0 rounded-full bg-[var(--r-line)] lg:hidden" />
|
||||
<div className="flex items-center justify-between gap-2 px-4 pb-2 pt-2">
|
||||
<div className="min-w-0 truncate text-sm font-medium">{title}</div>
|
||||
<div className="flex items-center gap-1 text-[var(--r-sub)]">
|
||||
{headerRight}
|
||||
<button type="button" onClick={onClose} aria-label="Close" className="rounded-lg p-1.5 hover:bg-black/5">
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// The single home for per-verse tools: original words, cross-references,
|
||||
// commentary, and people & places, plus bookmark / copy.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
|
||||
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
|
||||
import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js';
|
||||
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
|
||||
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
|
||||
import { DiffText } from './DiffText.jsx';
|
||||
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
|
||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||
import IqLexicon from '../iq/IqLexicon.jsx';
|
||||
import IqStudyAids from '../iq/IqStudyAids.jsx';
|
||||
|
||||
const BASE_TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
|
||||
|
||||
function OriginalTab({ words }) {
|
||||
const iq = useIqAvailable();
|
||||
const [lookup, setLookup] = useState(null); // the Strong's number open in the lexicon panel
|
||||
if (!words) return <p className="text-sm text-[var(--r-sub)]">No original-language data for this verse.</p>;
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{words.map((w, i) => {
|
||||
const isHebrew = w.s?.startsWith('H');
|
||||
const canSpeak = isHebrew || w.s?.startsWith('G');
|
||||
return (
|
||||
<div key={i} className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-2 text-center" style={{ minWidth: '4.5rem', maxWidth: '9rem' }}>
|
||||
<div className={`text-lg leading-tight ${isHebrew ? 'font-serif' : ''}`} dir={isHebrew ? 'rtl' : 'ltr'}>{w.o}</div>
|
||||
<div className="mt-0.5 text-xs italic text-[var(--r-sub)]">{w.t}</div>
|
||||
<div className="mt-1 text-xs font-medium">{w.g}</div>
|
||||
{w.p && <div className="mt-0.5 text-[10px] leading-tight text-[var(--r-sub)]">{w.p}</div>}
|
||||
{w.s && <div className="mt-0.5 text-[10px] text-[var(--r-sub)]">{w.s}</div>}
|
||||
{canSpeak && (
|
||||
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
|
||||
className="mt-1.5 rounded-lg bg-[var(--r-bar)] px-2 py-0.5 text-[11px] hover:bg-black/5"
|
||||
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>Speak</button>
|
||||
)}
|
||||
{iq && w.s && (
|
||||
<button type="button" onClick={() => setLookup((cur) => (cur === w.s ? null : w.s))} aria-pressed={lookup === w.s}
|
||||
className="mt-1.5 ml-1 rounded-lg bg-[var(--r-bar)] px-2 py-0.5 text-[11px] hover:bg-black/5" title="Look this word up in the lexicons">Lexicon</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{lookup && <IqLexicon strongs={lookup} onClose={() => setLookup(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// A cross-referenced passage shown in the panel: its words in the translation being read, without
|
||||
// leaving the chapter. "Open chapter" goes there when you want more.
|
||||
function RefPreview({ refItem, translation, onOpen, onClose }) {
|
||||
const [state, setState] = useState({ key: '', verses: [], error: '' });
|
||||
const key = `${translation}/${refItem.book}/${refItem.chapter}`;
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchChapter(translation, refItem.book, refItem.chapter, { signal: controller.signal })
|
||||
.then(({ verses }) => setState({ key, verses, error: '' }))
|
||||
.catch((err) => { if (!controller.signal.aborted) setState({ key, verses: [], error: err.message || 'Unable to load that passage.' }); });
|
||||
return () => controller.abort();
|
||||
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const end = refItem.endVerse && refItem.endVerse > refItem.verse ? refItem.endVerse : refItem.verse;
|
||||
const picked = state.key === key ? state.verses.filter((v) => v.number >= refItem.verse && v.number <= end) : [];
|
||||
const loading = state.key !== key;
|
||||
return (
|
||||
<div className="mb-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3" role="region" aria-label={`Cross-reference ${formatCrossRef(refItem)}`}>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">{formatCrossRef(refItem)} <span className="text-xs font-normal text-[var(--r-sub)]">{translation}</span></p>
|
||||
<button type="button" onClick={onClose} aria-label="Close cross-reference" className="rounded p-1 text-[var(--r-sub)] hover:bg-black/5">×</button>
|
||||
</div>
|
||||
{loading ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
|
||||
: state.error ? <p className="text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>
|
||||
: picked.length === 0 ? <p className="text-sm text-[var(--r-sub)]">Those verses weren’t found.</p>
|
||||
: (
|
||||
<p className="font-serif text-[15px] leading-relaxed">
|
||||
{picked.map((v) => <span key={v.number}>{picked.length > 1 && <sup className="mr-0.5 font-sans text-[0.65em] text-[var(--r-sub)]">{v.number}</sup>}{v.text}{' '}</span>)}
|
||||
</p>
|
||||
)}
|
||||
<button type="button" onClick={() => onOpen(refItem.book, refItem.chapter, refItem.verse)} className="mt-2 text-xs font-medium text-[var(--r-accent)] hover:underline">Open chapter</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sameRef = (a, b) => a && b && a.book === b.book && a.chapter === b.chapter && a.verse === b.verse && (a.endVerse ?? a.verse) === (b.endVerse ?? b.verse);
|
||||
|
||||
function RefsTab({ refs, loading, active, onPick, preview }) {
|
||||
if (loading && !refs) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
|
||||
if (!refs?.length) return <p className="text-sm text-[var(--r-sub)]">No cross-references for this verse.</p>;
|
||||
return (
|
||||
<>
|
||||
{preview}
|
||||
<p className="mb-1.5 text-[11px] text-[var(--r-sub)]">Tap one to read it here.</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{refs.slice(0, 20).map((ref, i) => (
|
||||
<button key={i} type="button" onClick={() => onPick(ref)} aria-pressed={sameRef(active, ref)}
|
||||
className={`rounded-full border px-2.5 py-1 text-xs ${sameRef(active, ref) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
|
||||
{formatCrossRef(ref)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// This verse in every translation, one under the other. Each Bible loads the chapter it is in
|
||||
// (BSB and NET come from the free source; the rest through the server) and shows this verse.
|
||||
function CompareTab({ book, chapter, verse, current }) {
|
||||
const [rows, setRows] = useState({}); // id -> { text } | { error } | undefined while loading
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setRows({});
|
||||
READER_TRANSLATIONS.forEach((t) => {
|
||||
fetchChapter(t.id, book, chapter, { signal: controller.signal })
|
||||
.then(({ verses }) => {
|
||||
const text = verses.find((v) => v.number === verse)?.text;
|
||||
setRows((r) => ({ ...r, [t.id]: text ? { text } : { error: 'No verse here in this translation.' } }));
|
||||
})
|
||||
.catch((err) => { if (!controller.signal.aborted) setRows((r) => ({ ...r, [t.id]: { error: err.message || 'Unavailable.' } })); });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [book, chapter, verse]);
|
||||
|
||||
const mine = rows[current]?.text;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{READER_TRANSLATIONS.map((t) => {
|
||||
const row = rows[t.id];
|
||||
// How this wording differs from the Bible being read, word by word
|
||||
const d = row?.text && mine && t.id !== current ? diffVerses(mine, row.text) : null;
|
||||
return (
|
||||
<div key={t.id}>
|
||||
<p className="mb-0.5 flex items-center gap-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
|
||||
<span>{t.id}{t.id === current ? ' · reading' : ''}</span>
|
||||
{d && <span className="normal-case tracking-normal">{differenceLabel(d.similarity)}</span>}
|
||||
</p>
|
||||
{!row ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
|
||||
: row.error ? <p className="text-sm text-[var(--r-sub)]">{row.error}</p>
|
||||
: <p className="font-serif text-[15px] leading-relaxed"><DiffText text={row.text} ranges={d?.b} /></p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">
|
||||
{mine ? <><span className="diff-hl">Marked words</span> aren’t in the {current} wording. </> : null}
|
||||
Copyright notices for each translation appear at the foot of the chapter when you read it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerseSheet({
|
||||
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
|
||||
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose, request,
|
||||
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, panelRef, resizeHandle,
|
||||
}) {
|
||||
const iqOn = useIqAvailable();
|
||||
const TABS = iqOn ? [...BASE_TABS, ['aids', 'Study aids']] : BASE_TABS;
|
||||
const [tab, setTab] = useState('commentary');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [preview, setPreview] = useState(null); // the cross-reference being read in the panel
|
||||
const appliedRequest = useRef(0);
|
||||
// Leaving this verse closes the preview; a cross-reference tapped in the text opens it here. (Tracks the
|
||||
// previous verse so React's double-run of effects in development can't clear a preview it just opened.)
|
||||
const previousVerse = useRef(null);
|
||||
useEffect(() => {
|
||||
const verseChanged = previousVerse.current !== verse?.number;
|
||||
previousVerse.current = verse?.number;
|
||||
if (request && request.verse === verse?.number && request.n !== appliedRequest.current) {
|
||||
appliedRequest.current = request.n;
|
||||
setTab('refs');
|
||||
setPreview(request.ref);
|
||||
} else if (verseChanged) {
|
||||
setPreview(null);
|
||||
}
|
||||
}, [verse?.number, request?.n]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { if (verse && tab === 'refs') onLoadCrossRefs(); }, [verse?.number, tab]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { setCopied(false); }, [verse?.number]);
|
||||
if (!verse) return null;
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard?.writeText(`${bookName} ${chapter}:${verse.number} ${translation} — ${verse.text}`);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
const actionBtn = 'rounded-lg p-1.5 hover:bg-black/5';
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open modal={false} pane onClose={onClose} panelRef={panelRef} edge={resizeHandle}
|
||||
title={`${bookName} ${chapter}:${verse.number}`}
|
||||
headerRight={(
|
||||
<>
|
||||
<button type="button" onClick={onToggleBookmark} className={`${actionBtn} ${bookmarkColor ? 'text-[var(--r-accent)]' : ''}`} aria-label={bookmarkColor ? 'Remove bookmark' : 'Bookmark verse'}>
|
||||
<BookmarkIcon filled={!!bookmarkColor} />
|
||||
</button>
|
||||
{bookmarkColor && (
|
||||
<button type="button" onClick={onCycleColor} className={actionBtn} aria-label="Change bookmark colour">
|
||||
<span className="block h-4 w-4 rounded-full border border-black/10" style={{ backgroundColor: bookmarkColor }} />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={copy} className={actionBtn} aria-label="Copy verse">
|
||||
{copied ? <span className="px-1 text-xs">Copied</span> : <CopyIcon />}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{onHighlightVerse && (
|
||||
// One-tap highlighting of the whole verse (to mark just some words, select them in the text)
|
||||
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Highlight this verse">
|
||||
<span className="mr-1 text-xs text-[var(--r-sub)]">Highlight</span>
|
||||
{HIGHLIGHT_COLORS.map((color) => {
|
||||
const on = verseHighlights.some((h) => h.color === color && h.startOffset === 0 && h.endOffset >= verse.text.length);
|
||||
return (
|
||||
<button key={color} type="button" onClick={() => onHighlightVerse(color)} aria-pressed={on}
|
||||
aria-label={`Highlight verse ${colorTitle(color, colorLabels).toLowerCase()}`} data-tip={`${colorTitle(color, colorLabels)}${on ? ' — tap to remove' : ''}`}
|
||||
className={`h-6 w-6 rounded-full border-2 transition hover:scale-110 ${on ? 'border-[var(--r-text)]' : 'border-transparent ring-1 ring-black/10'}`}
|
||||
style={{ backgroundColor: color }}>
|
||||
{on && <span className="text-[10px] leading-none text-[#2b2620]" aria-hidden="true">✓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{verseHighlights.length > 0 && (
|
||||
<button type="button" onClick={onClearVerseHighlights} className="ml-1 text-xs text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Clear</button>
|
||||
)}
|
||||
<span className="basis-full text-[11px] text-[var(--r-sub)]">Or select words in the text to highlight just those.</span>
|
||||
</div>
|
||||
)}
|
||||
<div role="tablist" aria-label="About this verse" className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
|
||||
{TABS.map(([id, label]) => (
|
||||
<button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}
|
||||
className={`shrink-0 rounded-full border px-3 py-1 text-xs ${tab === id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />}
|
||||
{tab === 'compare' && <CompareTab book={book} chapter={chapter} verse={verse.number} current={translation} />}
|
||||
{tab === 'original' && <OriginalTab words={words} />}
|
||||
{tab === 'refs' && (
|
||||
<RefsTab refs={crossRefs} loading={crossRefsLoading} active={preview} onPick={setPreview}
|
||||
preview={preview && <RefPreview refItem={preview} translation={translation} onOpen={onJump} onClose={() => setPreview(null)} />} />
|
||||
)}
|
||||
{tab === 'people' && <PeoplePlacesTab book={book} chapter={chapter} verse={verse.number} />}
|
||||
{tab === 'aids' && iqOn && <IqStudyAids book={book} chapter={chapter} verse={verse.number} />}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Small stroke icons for the reader chrome (currentColor, 20px grid).
|
||||
const base = { viewBox: '0 0 20 20', width: 20, height: 20, fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true };
|
||||
|
||||
export const ChevronLeft = () => <svg {...base}><path d="M12 4l-6 6 6 6" /></svg>;
|
||||
export const ChevronRight = () => <svg {...base}><path d="M8 4l6 6-6 6" /></svg>;
|
||||
export const ChevronDown = ({ size = 14 }) => <svg {...base} width={size} height={size}><path d="M5 8l5 5 5-5" /></svg>;
|
||||
export const SearchIcon = () => <svg {...base}><circle cx="9" cy="9" r="5.5" /><path d="M13.5 13.5L17 17" /></svg>;
|
||||
export const CloseIcon = () => <svg {...base}><path d="M5 5l10 10M15 5L5 15" /></svg>;
|
||||
export const LinkIcon = () => <svg {...base}><path d="M8.5 11.5a3 3 0 004.2 0l2.3-2.3a3 3 0 00-4.2-4.2L9.7 6.1" /><path d="M11.5 8.5a3 3 0 00-4.2 0L5 10.8A3 3 0 009.2 15l1.1-1.1" /></svg>;
|
||||
export const PenIcon = () => <svg {...base}><path d="M13.5 3.5l3 3L7 16l-4 1 1-4 9.5-9.5z" /></svg>;
|
||||
export const HeadphonesIcon = () => <svg {...base}><path d="M3.5 12V10a6.5 6.5 0 0113 0v2" /><rect x="2.5" y="11.5" width="3.5" height="5" rx="1.2" /><rect x="14" y="11.5" width="3.5" height="5" rx="1.2" /></svg>;
|
||||
export const FocusIcon = () => <svg {...base}><path d="M3 7V4h3M17 7V4h-3M3 13v3h3M17 13v3h-3" /></svg>;
|
||||
export const CheckIcon = () => <svg {...base}><path d="M4.5 10.5l3.5 3.5 7.5-8" /></svg>;
|
||||
export const CompareIcon = () => <svg {...base}><rect x="2.5" y="3.5" width="6.5" height="13" rx="1.3" /><rect x="11" y="3.5" width="6.5" height="13" rx="1.3" /><path d="M4.5 7h2.5M4.5 10h2.5M13 7h2.5M13 10h2.5" /></svg>;
|
||||
export const HighlighterIcon = () => <svg {...base}><path d="M12.5 3.5l4 4-6.8 6.8-4-4 6.8-6.8z" /><path d="M5.7 10.3l-1.6 4.4 1.2 1.2 4.4-1.6" /><path d="M3 17.2h6" /></svg>;
|
||||
@@ -0,0 +1,18 @@
|
||||
// Reader colour themes, applied as CSS variables on the reader root so every
|
||||
// reader component can use bg-[var(--r-bg)] etc. and stay in sync.
|
||||
export const READER_THEMES = {
|
||||
paper: {
|
||||
label: 'Paper',
|
||||
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#6b6458', '--r-accent': '#a84e27', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' },
|
||||
},
|
||||
sepia: {
|
||||
label: 'Sepia',
|
||||
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#635539', '--r-accent': '#93471d', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
|
||||
},
|
||||
dark: {
|
||||
label: 'Dark',
|
||||
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#b8b2a4', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
|
||||
},
|
||||
};
|
||||
|
||||
export const themeVars = (id) => (READER_THEMES[id] ?? READER_THEMES.paper).vars;
|
||||