Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c804aa2ccc | |||
| cc234e8192 | |||
| 2d659c4fe9 | |||
| 2106595976 | |||
| 902fb5da02 | |||
| 837b81b038 | |||
| 708f293939 | |||
| f2de985190 | |||
| 90fd5c4e9e | |||
| 22ad12e677 | |||
| b4ccc15f98 | |||
| 1a586db785 | |||
| 5668686f59 | |||
| 692595c629 | |||
| a947b128b9 | |||
| 9a99247f8d | |||
| 717a9b3da0 | |||
| cf040ff8dd | |||
| 0a2def9b60 | |||
| 250d59290d | |||
| edc5f3dae1 | |||
| 9013dc4fc4 | |||
| 42203c038e | |||
| 31fa4b3f77 | |||
| ea44ff142d | |||
| 791e809222 | |||
| 96b6e840a3 | |||
| 8e8e814775 | |||
| 42247b9eae | |||
| 085043c991 | |||
| 092a81718d | |||
| da7cc10851 | |||
| 52d157be69 | |||
| f9283de190 | |||
| 6bbe045ea6 | |||
| eb066bc8cc | |||
| 19d3a6b822 | |||
| 9e57f8088a | |||
| f853d5e301 | |||
| 60f3e4d489 | |||
| 5006d0bfbe | |||
| 7006fbf544 | |||
| 37cfcd55a0 | |||
| 2735ef216c | |||
| 89e701bbda | |||
| 5140958305 | |||
| bf83dc7cc4 | |||
| 103e20c6b4 | |||
| 7b350d3683 | |||
| 01de7281a8 | |||
| 145b840bf1 | |||
| c30405b42d | |||
| 405cf7218b | |||
| 1733df7879 | |||
| ce93b42b83 | |||
| b3c266fa66 | |||
| d1861ec5d9 | |||
| c0ceb68faa | |||
| 9a3ed73d20 | |||
| 0f08721b48 | |||
| 603c3bcbf2 | |||
| bb92095ba4 | |||
| 6c4658e244 | |||
| c41916b3ce | |||
| 6fb3bfbd82 | |||
| 3e84ea37be | |||
| e59bc25ed0 | |||
| 65039d44a5 | |||
| 5d0cc2934f | |||
| 6f1a311ba9 | |||
| f95d5670ae | |||
| faf9ba9e2e | |||
| 91b01c3117 | |||
| b40f03846d | |||
| 33107cf7af | |||
| 7bfbe826a0 | |||
| caa781d039 | |||
| 587eedd192 | |||
| e3b14fb25b | |||
| 7eadb8500b | |||
| be62be0aa6 | |||
| 0b4bb46866 | |||
| 7117f70e4b | |||
| b6a277e16c | |||
| d5c9761584 | |||
| 1ae39118c9 | |||
| 337006c72d | |||
| 945ff15a4f |
@@ -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,57 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: git.necloud.us
|
||||
IMAGE_NAME: nate/study-bible-project
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea container registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: nate@versebyversewithnate.us
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
@@ -1,6 +1,12 @@
|
||||
node_modules/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
dist/
|
||||
coverage/
|
||||
*.local
|
||||
.vite.pid
|
||||
.vite.log
|
||||
.api.pid
|
||||
.api.log
|
||||
.DS_Store
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++ && npm install -g npm@11
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
@@ -12,6 +13,7 @@ RUN npm run build
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++ && npm install -g npm@11
|
||||
|
||||
# Copy package files and install production deps only
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# Bible Study App
|
||||
|
||||
A self-hosted Bible study tool with notes, projects, and user accounts.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js v18 or higher** — [nodejs.org](https://nodejs.org/)
|
||||
- **npm** (bundled with Node.js)
|
||||
- **GCC / make** (only needed for production/server installs — required to build the `better-sqlite3` native module)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (Development)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nmemmert/study-bible-project.git
|
||||
cd study-bible-project
|
||||
bash setup.sh
|
||||
```
|
||||
|
||||
`setup.sh` installs dependencies, finds a free port, and starts both servers:
|
||||
|
||||
| Server | Default URL |
|
||||
|--------|-------------|
|
||||
| API (Express) | http://localhost:3001 |
|
||||
| Frontend (Vite) | http://localhost:5173 |
|
||||
|
||||
**Stop the servers:**
|
||||
```bash
|
||||
kill $(cat .api.pid) $(cat .vite.pid)
|
||||
```
|
||||
|
||||
**View logs:**
|
||||
```bash
|
||||
tail -f .api.log # API server
|
||||
tail -f .vite.log # Vite dev server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Install (Ubuntu / systemd)
|
||||
|
||||
Installs the app as a persistent systemd service under `/opt/study-app`.
|
||||
|
||||
```bash
|
||||
# Install Node.js 20 (if not already installed)
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# Install build tools (required for better-sqlite3)
|
||||
sudo apt-get install -y build-essential python3
|
||||
|
||||
# Clone and install
|
||||
git clone https://github.com/nmemmert/study-bible-project.git
|
||||
cd study-bible-project
|
||||
sudo bash deploy/install.sh
|
||||
```
|
||||
|
||||
The installer will:
|
||||
1. Install Node.js dependencies and build the frontend
|
||||
2. Create a `study-app` system user
|
||||
3. Register and start a systemd service
|
||||
|
||||
**Useful commands after install:**
|
||||
```bash
|
||||
systemctl status study-app # Check service status
|
||||
journalctl -u study-app -f # Stream logs
|
||||
systemctl restart study-app # Restart after an update
|
||||
```
|
||||
|
||||
**Open the firewall port (if needed):**
|
||||
```bash
|
||||
sudo ufw allow 3001/tcp
|
||||
sudo ufw reload
|
||||
```
|
||||
|
||||
The app listens on **port 3001** by default. Set the `PORT` environment variable in the systemd unit to change it.
|
||||
|
||||
---
|
||||
|
||||
## Updating (Production)
|
||||
|
||||
```bash
|
||||
cd /path/to/study-bible-project
|
||||
git pull
|
||||
sudo bash deploy/install.sh
|
||||
```
|
||||
|
||||
The installer preserves the session secret across updates so logged-in users are not signed out.
|
||||
|
||||
---
|
||||
|
||||
## Migrating to a New Machine
|
||||
|
||||
**On the old machine — create a backup:**
|
||||
```bash
|
||||
npm run backup
|
||||
```
|
||||
This creates `study-app-backup_<timestamp>.zip` in the project root.
|
||||
|
||||
**On the new machine — restore after cloning and installing:**
|
||||
|
||||
Development:
|
||||
```bash
|
||||
npm run restore study-app-backup_<timestamp>.zip
|
||||
```
|
||||
|
||||
Production (after `sudo bash deploy/install.sh` completes):
|
||||
```bash
|
||||
sudo bash scripts/restore-data.sh study-app-backup_<timestamp>.zip --production
|
||||
```
|
||||
|
||||
See [scripts/backup-data.sh](scripts/backup-data.sh) and [scripts/restore-data.sh](scripts/restore-data.sh) for details.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── server/ # Express API + SQLite database
|
||||
│ └── data/ # projects.db lives here (gitignored)
|
||||
├── src/ # React frontend (Vite)
|
||||
├── deploy/ # systemd service unit + install script
|
||||
├── scripts/ # backup / restore helpers
|
||||
└── setup.sh # Development quick-start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Private / personal use.
|
||||
+28
-55
@@ -1,95 +1,68 @@
|
||||
# Study App Improvement Suggestions
|
||||
|
||||
_Refreshed 2026-07-06 (multiple passes) — items already shipped have been removed; this reflects what's actually still open. Recent additions: multi-user auth with per-account data scoping, TOTP 2FA with backup codes, podcast terminology generalized to "Session" for study-only users (with a configurable podcast/show name), auto-restore on new devices, study templates (richer OIA guiding prompts), PDF/print export, Markdown export, a passage breadcrumb, whole-Bible search, better bookmark UX (always-visible SVG icons + a jump-to panel), read-only share links, and an admin panel (`server/auth.js` `ADMIN_EMAIL`, hardcoded to the site owner's account) showing every user/project with view/delete controls._
|
||||
|
||||
## Features
|
||||
|
||||
### Study Tools
|
||||
- **Bible comparison mode** — show two translations side-by-side (API already supports it)
|
||||
- **Verse-level notes** — annotate individual verses, not just chunks
|
||||
- **Tagging / themes** — tag chunks with themes (e.g. "faith", "grace"), filter/search across projects
|
||||
- **Progress tracking** — mark chunks as "in progress" / "complete"; show progress bar on home card
|
||||
- **Study templates** — pre-fill OIA fields with guiding prompts for new users
|
||||
- **Print view** — clean print-optimized CSS layout
|
||||
- **Old Testament support** — only NT books listed; HelloAO API supports OT. Greek suggest is NT-only but the rest could support OT with Hebrew lookup
|
||||
- **Verse search** — search bar to find a verse by keyword across the loaded chapter
|
||||
- **Bible comparison mode** — show two translations side-by-side. `availableTranslations` is already fetched and a translation is already selectable per project, but only one renders at a time — no split/parallel view
|
||||
- **Verse-level notes** — annotations are still chunk-level only (OIA fields); no way to attach a note to a single verse within a chunk
|
||||
- **Progress tracking** — no "in progress"/"complete" marker per chunk and no progress bar on the home project card
|
||||
|
||||
### Export / Sharing
|
||||
- **PDF export** — "Export PDF" button using `jsPDF` or `window.print()`
|
||||
- **Share link** — read-only shareable URL pointing to a project ID on the server
|
||||
- **Copy individual chunk** — "copy this chunk's notes" button alongside full "Prepare for Claude"
|
||||
- **Markdown export** — useful for Obsidian and similar note-taking apps
|
||||
- **Episode length estimate** — Final Script field exists per chunk; a word-count-based "~X minutes read aloud" estimate would help podcast planning
|
||||
- **Share link is single-use-case** — one share token per project, all-or-nothing (whole project, all chunks). A per-chunk or per-chapter share might be worth it for someone who only wants to share one episode's notes rather than the whole series
|
||||
|
||||
### Chunk Builder (Setup Page)
|
||||
- **Drag-to-select verses** — still click-then-shift-click; no click-and-drag range selection
|
||||
- **Auto-chunk** — no "split by paragraph/section" button; every chunk boundary is manual or typed
|
||||
|
||||
---
|
||||
|
||||
## UX / UI
|
||||
|
||||
### Navigation
|
||||
- **Keyboard shortcuts** — `←`/`→` to navigate chunks; `Ctrl+S` to save; `Escape` to close modals
|
||||
- **"Jump to chunk" dropdown** — for projects with many chunks, a select menu is faster than scrolling
|
||||
- **Breadcrumb in header** — show `Book Chapter:Verse range` so users always know where they are
|
||||
|
||||
### Chunk Builder (Setup Page)
|
||||
- **Drag-to-select verses** — click-and-drag instead of click then shift-click
|
||||
- **Auto-chunk** — button to split chapter into chunks by paragraph/section breaks
|
||||
- **Visual overlap indicator** — already-chunked verses are shaded but there's no tooltip explaining why you can't select them
|
||||
|
||||
### Study Page
|
||||
- **Collapsible sections** — collapse OIA, Cross-References, and Greek Word Studies independently
|
||||
- **Word/character count** on each textarea to encourage note depth
|
||||
- **Inline verse reference popup** — hover popover on cross-references showing verse text (from HelloAO)
|
||||
- **Sticky chunk navigation** — Previous/Next chunk buttons should be sticky, not only at the bottom
|
||||
|
||||
### Reader
|
||||
- **Bookmark color picker is still an emoji button** (🎨) — the bookmark/copy icons became proper SVGs, but color-cycling didn't get the same treatment
|
||||
|
||||
### Home Page
|
||||
- **Search/filter projects** — text filter on the project list
|
||||
- **Sort options** — sort by name, date, or passage
|
||||
- **Project rename** — title is only set at creation; allow renaming from home card
|
||||
- **Last opened chunk** — resume directly to the study page, not the setup page
|
||||
- Search/filter/sort/rename are all implemented — nothing open here currently
|
||||
|
||||
---
|
||||
|
||||
## Code Architecture
|
||||
|
||||
### State Management
|
||||
- **`App.jsx` is ~2,250 lines** — biggest maintainability issue. Split into:
|
||||
- `pages/HomePage.jsx`
|
||||
- `pages/SetupPage.jsx`
|
||||
- `pages/StudyPage.jsx`
|
||||
- `components/ChunkEditor.jsx`
|
||||
- `components/GreekWordStudy.jsx`
|
||||
- `components/SuggestModal.jsx`
|
||||
- **Custom hooks** — extract logic into `useProject()`, `useGreekLookup()`, `useAutosave()`
|
||||
- **`App.jsx` is now ~5,700+ lines** — still one component. Splitting into `pages/HomePage.jsx`, `pages/SetupPage.jsx`, `pages/StudyPage.jsx`, `pages/BibleReaderPage.jsx`, plus extracted hooks (`useProject`, `useGreekLookup`, `useAutosave`) is more valuable now than ever given the continued size growth
|
||||
|
||||
### Sync / Persistence
|
||||
- **No auth** — the server has zero authentication. Any user who can reach the server can read/overwrite/delete any project. Add at minimum an API key (env var in middleware) or user accounts
|
||||
- **Conflict resolution is basic** — only compares `lastEdited` timestamps. Add a "which version do you want to keep?" UI to prevent silent data loss
|
||||
- **Offline-first** — use a service worker / `workbox` so the app works offline and syncs when back online
|
||||
- No rate-limiting on `/api/auth/*` — a determined attacker could brute-force a weak password or 2FA code; worth adding if this is ever reachable beyond a small trusted group
|
||||
- **Conflict resolution is still last-write-wins** — only `lastEdited` timestamps are compared; no "which version do you want to keep?" UI
|
||||
- **Offline-first** — still no service worker; app requires a live connection to `bible.helloao.org` for chapter/audio/commentary loads with no cached fallback if that API is down
|
||||
|
||||
### Security (OWASP)
|
||||
- **XSS via `dangerouslySetInnerHTML`** — `word.definitionHtml` is rendered raw. Add DOMPurify sanitization
|
||||
- **No input validation on server** — add max-length and character validation on `id`/`title` fields (SQL injection is prevented by parameterized queries, but still)
|
||||
- **CORS** — server has no CORS headers; any origin can call the API in production
|
||||
- **No input validation on server** — still no max-length/character validation on `id`/`title` in `server/index.js`
|
||||
- **CORS** — still no CORS headers configured
|
||||
- **Shared HTML view is sandboxed but not escaped** — `buildExportHtml` interpolates OIA notes into HTML without escaping `<`/`>`/`&`; the public share view mitigates this by rendering in a `sandbox="allow-popups"` iframe (no `allow-scripts`, so injected `<script>`/event handlers can't execute), but the underlying string-building still isn't defense-in-depth. Worth properly HTML-escaping user text in `buildExportHtml` itself
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
- **Verse data stored in project JSON** — full verse text is saved in localStorage and SQLite for every chapter. For multi-chapter projects this grows large. Consider storing only the chapter reference and re-fetching verses on load
|
||||
- **JSON files cached in refs** — `nt-strongs-gloss.json` and `nt-strongs-concordance.json` should be served with proper `Cache-Control` headers
|
||||
- **Autosave fires on all state changes** — the `[project]` dependency is too broad; it fires even when just selecting a chunk. Debounce only on content field changes
|
||||
- **Verse data stored in project JSON** — still true; full verse text is saved per chapter in both localStorage and SQLite
|
||||
- **Hardcoded external API, no fallback** — audio and commentary both call `bible.helloao.org` directly with no retry UI if the free API is briefly down; whole-Bible search now adds a third hard dependency on this API (`/complete.json`)
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- Add tests for:
|
||||
- `migrateProject` with the old flat format
|
||||
- `buildClaudePrompt` output structure
|
||||
- `parseBibleChapter` with both API response shapes
|
||||
- Autosave debounce behavior
|
||||
- Missing: autosave debounce behavior, DOCX session-list import, cross-ref auto-suggest, commentary loading, and no coverage yet for the newer share-link/2FA/whole-Bible-search flows (all verified manually in-browser instead)
|
||||
|
||||
---
|
||||
|
||||
## Developer Experience
|
||||
|
||||
- **No ESLint config** — add ESLint with `eslint-plugin-react` and `eslint-plugin-react-hooks` to catch missing `useEffect` deps
|
||||
- **No TypeScript** — JSDoc types or a TS migration would catch shape mismatches between old/new project formats at compile time
|
||||
- **No `docker-compose.yml`** — Dockerfile exists but there's no compose file for one-command local dev with server + SQLite volume
|
||||
- **No ESLint config** — still true; no `.eslintrc*` or `eslint.config.*` in the repo
|
||||
- **No TypeScript** — still true
|
||||
- **No `docker-compose.yml`** — still true; Dockerfile exists but no one-command local dev with server + SQLite volume
|
||||
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install Bible Study App as a systemd service on Rocky Linux (or any systemd distro).
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./deploy/install.sh [INSTALL_DIR]
|
||||
#
|
||||
# Defaults to /opt/study-app. Run from the project repo root.
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root (e.g. with sudo)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
INSTALL_DIR="${1:-/opt/study-app}"
|
||||
SERVICE_USER="study-app"
|
||||
SERVICE_NAME="study-app"
|
||||
|
||||
echo "=== Installing Bible Study App to $INSTALL_DIR ==="
|
||||
|
||||
# ── Node check / auto-upgrade ─────────────────────────────────────────────────
|
||||
NODE_VERSION=0
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
|
||||
fi
|
||||
if [ "$NODE_VERSION" -lt 18 ]; then
|
||||
echo "Node.js v18+ required (found v${NODE_VERSION}). Installing Node.js 20 via NodeSource..."
|
||||
apt-get install -y curl ca-certificates
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y nodejs
|
||||
fi
|
||||
echo "Node.js $(node -v) found."
|
||||
|
||||
# build tools needed for better-sqlite3 native module
|
||||
if ! command -v gcc >/dev/null 2>&1 || ! command -v make >/dev/null 2>&1; then
|
||||
echo "Installing build tools (build-essential + python3)..."
|
||||
apt-get install -y build-essential python3
|
||||
fi
|
||||
|
||||
# ── Create service user ─────────────────────────────────────────────────────
|
||||
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
echo "Creating service user '$SERVICE_USER'..."
|
||||
useradd --system --home-dir "$INSTALL_DIR" --shell /sbin/nologin "$SERVICE_USER"
|
||||
fi
|
||||
|
||||
# ── Copy app files ────────────────────────────────────────────────────────────
|
||||
echo "Copying application files to $INSTALL_DIR..."
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
rsync -a --delete \
|
||||
--exclude '.git' \
|
||||
--exclude 'node_modules' \
|
||||
--exclude 'dist' \
|
||||
--exclude '*.pid' \
|
||||
--exclude '*.log' \
|
||||
"$ROOT_DIR"/ "$INSTALL_DIR"/
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
# ── Install dependencies & build ─────────────────────────────────────────────
|
||||
echo "Installing dependencies (this can take a while for better-sqlite3)..."
|
||||
npm install
|
||||
|
||||
echo "Building production frontend..."
|
||||
npx vite build
|
||||
|
||||
echo "Removing dev dependencies..."
|
||||
npm prune --omit=dev
|
||||
|
||||
# ── Permissions ──────────────────────────────────────────────────────────────
|
||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
|
||||
|
||||
# ── systemd unit ──────────────────────────────────────────────────────────────
|
||||
echo "Installing systemd unit..."
|
||||
EXISTING_UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
# Reuse the existing session secret across re-installs (upgrades) so signed-in
|
||||
# users aren't logged out; only generate a new one on first install.
|
||||
if [ -f "$EXISTING_UNIT" ] && grep -q '^Environment=SESSION_SECRET=' "$EXISTING_UNIT"; then
|
||||
SESSION_SECRET="$(grep '^Environment=SESSION_SECRET=' "$EXISTING_UNIT" | head -1 | cut -d= -f3-)"
|
||||
else
|
||||
SESSION_SECRET="$(openssl rand -hex 32)"
|
||||
fi
|
||||
sed "s#/opt/study-app#$INSTALL_DIR#g; s#User=study-app#User=$SERVICE_USER#; s#Group=study-app#Group=$SERVICE_USER#; s#__SESSION_SECRET__#$SESSION_SECRET#" \
|
||||
"$ROOT_DIR/deploy/study-app.service" > "$EXISTING_UNIT"
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo "Service status: systemctl status $SERVICE_NAME"
|
||||
echo "Logs: journalctl -u $SERVICE_NAME -f"
|
||||
echo "App listens on: http://0.0.0.0:\${PORT:-3001}"
|
||||
echo ""
|
||||
echo "If you have a firewall enabled, allow the port, e.g.:"
|
||||
echo " sudo ufw allow 3001/tcp && sudo ufw reload"
|
||||
@@ -0,0 +1,27 @@
|
||||
[Unit]
|
||||
Description=Bible Study App
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Edit these to match your deployment
|
||||
User=study-app
|
||||
Group=study-app
|
||||
WorkingDirectory=/opt/study-app
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=3001
|
||||
# Replaced with a generated value by install.sh — keep this secret and stable
|
||||
# across deploys, or every existing login session gets invalidated.
|
||||
Environment=SESSION_SECRET=__SESSION_SECRET__
|
||||
ExecStart=/usr/bin/node server/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Hardening (relax if it causes issues)
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/opt/study-app
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+16
-1
@@ -2,8 +2,23 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Bible Study Project</title>
|
||||
|
||||
<!-- PWA manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Theme / status bar -->
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
|
||||
<!-- iOS PWA -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="Bible Study" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Fallback icon for browsers -->
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
|
||||
</head>
|
||||
<body class="bg-slate-50 text-slate-900">
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+2149
-18
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -12,13 +12,22 @@
|
||||
"start": "NODE_ENV=production node server/index.js",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"coverage": "vitest run --coverage"
|
||||
"coverage": "vitest run --coverage",
|
||||
"backup": "bash scripts/backup-data.sh",
|
||||
"restore": "bash scripts/restore-data.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"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"
|
||||
},
|
||||
@@ -29,6 +38,7 @@
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"csv-parse": "^6.2.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.4",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 694 B |
Binary file not shown.
|
After Width: | Height: | Size: 757 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "Bible Study Project",
|
||||
"short_name": "Bible Study",
|
||||
"description": "Your personal verse-by-verse Bible study companion",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#f8fafc",
|
||||
"theme_color": "#0f172a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
const SHELL = 'bible-shell-v1';
|
||||
const API = 'bible-api-v1';
|
||||
const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life'];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(SHELL)
|
||||
.then(c => c.addAll(['/', '/index.html']))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys()
|
||||
.then(keys => Promise.all(
|
||||
keys.filter(k => k !== SHELL && k !== API).map(k => caches.delete(k))
|
||||
))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const { request } = e;
|
||||
const url = request.url;
|
||||
|
||||
// Never intercept API routes — let Express handle them
|
||||
if (new URL(url).pathname.startsWith('/api/')) return;
|
||||
|
||||
// External Bible API: network-first, fall back to cache
|
||||
if (BIBLE_ORIGINS.some(o => url.startsWith(o))) {
|
||||
e.respondWith(
|
||||
fetch(request)
|
||||
.then(res => {
|
||||
if (res.ok) caches.open(API).then(c => c.put(request, res.clone()));
|
||||
return res;
|
||||
})
|
||||
.catch(() =>
|
||||
caches.open(API).then(c => c.match(request)).then(
|
||||
hit => hit ?? new Response('{"error":"offline"}', {
|
||||
status: 503, headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// App-shell navigation: network-first, fall back to cached index.html
|
||||
if (request.mode === 'navigate') {
|
||||
e.respondWith(
|
||||
fetch(request)
|
||||
.then(res => {
|
||||
if (res.ok) caches.open(SHELL).then(c => c.put(request, res.clone()));
|
||||
return res;
|
||||
})
|
||||
.catch(() =>
|
||||
caches.open(SHELL).then(c =>
|
||||
c.match('/index.html').then(hit => hit ?? c.match('/'))
|
||||
)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-origin static assets (hashed JS/CSS/images): cache-first
|
||||
if (url.startsWith(self.location.origin)) {
|
||||
e.respondWith(
|
||||
caches.open(SHELL).then(c =>
|
||||
c.match(request).then(hit => {
|
||||
if (hit) return hit;
|
||||
return fetch(request).then(res => {
|
||||
if (res.ok) c.put(request, res.clone());
|
||||
return res;
|
||||
});
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -21,5 +21,10 @@ stop_pid_file() {
|
||||
stop_pid_file "$ROOT_DIR/.api.pid" "API server"
|
||||
stop_pid_file "$ROOT_DIR/.vite.pid" "Vite server"
|
||||
|
||||
echo "Pulling latest changes from GitHub..."
|
||||
cd "$ROOT_DIR"
|
||||
git checkout -- package-lock.json 2>/dev/null || true
|
||||
git pull
|
||||
|
||||
echo "Restarting via setup.sh..."
|
||||
exec "$ROOT_DIR/setup.sh"
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
# Backs up user data to a timestamped zip file you can copy to your new PC.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
DB_FILE="$APP_DIR/server/data/projects.db"
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
BACKUP_FILE="$APP_DIR/study-app-backup_$TIMESTAMP.zip"
|
||||
|
||||
if [ ! -f "$DB_FILE" ]; then
|
||||
echo "ERROR: Database not found at $DB_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
zip -j "$BACKUP_FILE" "$DB_FILE"
|
||||
|
||||
echo ""
|
||||
echo "Backup created: $BACKUP_FILE"
|
||||
echo ""
|
||||
echo "Copy this file to your new PC, then run:"
|
||||
echo " scripts/restore-data.sh <path-to-backup-file>"
|
||||
@@ -0,0 +1,96 @@
|
||||
// One-time/offline data prep: convert the BSB Translation Tables (bereanbible.com/bsb_tables.tsv)
|
||||
// into per-book JSON files of word-by-word interlinear data for /public/interlinear/.
|
||||
//
|
||||
// Usage: node scripts/build-interlinear.mjs /path/to/bsb_tables.tsv
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { parse } from 'csv-parse/sync';
|
||||
|
||||
const BOOK_ABBREV = {
|
||||
Genesis: 'GEN', Exodus: 'EXO', Leviticus: 'LEV', Numbers: 'NUM', Deuteronomy: 'DEU',
|
||||
Joshua: 'JOS', Judges: 'JDG', Ruth: 'RUT', '1 Samuel': '1SA', '2 Samuel': '2SA',
|
||||
'1 Kings': '1KI', '2 Kings': '2KI', '1 Chronicles': '1CH', '2 Chronicles': '2CH',
|
||||
Ezra: 'EZR', Nehemiah: 'NEH', Esther: 'EST', Job: 'JOB', Psalm: 'PSA', Proverbs: 'PRO',
|
||||
Ecclesiastes: 'ECC', 'Song of Solomon': 'SNG', Isaiah: 'ISA', Jeremiah: 'JER',
|
||||
Lamentations: 'LAM', Ezekiel: 'EZK', Daniel: 'DAN', Hosea: 'HOS', Joel: 'JOL',
|
||||
Amos: 'AMO', Obadiah: 'OBA', Jonah: 'JON', Micah: 'MIC', Nahum: 'NAM', Habakkuk: 'HAB',
|
||||
Zephaniah: 'ZEP', Haggai: 'HAG', Zechariah: 'ZEC', Malachi: 'MAL',
|
||||
Matthew: 'MAT', Mark: 'MRK', Luke: 'LUK', John: 'JHN', Acts: 'ACT', Romans: 'ROM',
|
||||
'1 Corinthians': '1CO', '2 Corinthians': '2CO', Galatians: 'GAL', Ephesians: 'EPH',
|
||||
Philippians: 'PHP', Colossians: 'COL', '1 Thessalonians': '1TH', '2 Thessalonians': '2TH',
|
||||
'1 Timothy': '1TI', '2 Timothy': '2TI', Titus: 'TIT', Philemon: 'PHM', Hebrews: 'HEB',
|
||||
James: 'JAS', '1 Peter': '1PE', '2 Peter': '2PE', '1 John': '1JN', '2 John': '2JN',
|
||||
'3 John': '3JN', Jude: 'JUD', Revelation: 'REV',
|
||||
};
|
||||
|
||||
const inputPath = process.argv[2];
|
||||
if (!inputPath) {
|
||||
console.error('Usage: node scripts/build-interlinear.mjs /path/to/bsb_tables.tsv');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const outDir = path.resolve('public/interlinear');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const raw = fs.readFileSync(inputPath, 'utf-8');
|
||||
const records = parse(raw, { delimiter: '\t', columns: false, relax_column_count: true });
|
||||
const header = records[0];
|
||||
const idx = Object.fromEntries(header.map((h, i) => [h.trim(), i]));
|
||||
|
||||
// books[ABBREV][chapter][verse] = [{ o, t, p, s, g }]
|
||||
const books = {};
|
||||
let curVerseId = '';
|
||||
|
||||
for (let i = 1; i < records.length; i++) {
|
||||
const row = records[i];
|
||||
const verseId = row[idx.VerseId];
|
||||
if (verseId) curVerseId = verseId;
|
||||
if (!curVerseId) continue;
|
||||
|
||||
const lang = row[idx.Language];
|
||||
const original = (lang === 'Hebrew' || lang === 'Aramaic')
|
||||
? row[idx['WLC / Nestle Base TR RP WH NE NA SBL']]
|
||||
: row[idx['WLC / Nestle Base TR RP WH NE NA SBL']];
|
||||
if (!original || !original.trim()) continue;
|
||||
|
||||
const strongs = row[idx['Str Heb']] || row[idx['Str Grk']] || '';
|
||||
const gloss = (row[idx['BSB version']] || '').replace(/\s+/g, ' ').trim();
|
||||
const translit = (row[idx.Translit] || '').trim();
|
||||
const parsing = (row[idx.Parsing] || '').trim();
|
||||
const sortKey = (lang === 'Hebrew' || lang === 'Aramaic')
|
||||
? row[idx['Heb Sort']]
|
||||
: row[idx['Greek Sort']];
|
||||
|
||||
const m = curVerseId.match(/^(.*) (\d+):(\d+)$/);
|
||||
if (!m) continue;
|
||||
const [, bookName, chapter, verse] = m;
|
||||
const abbrev = BOOK_ABBREV[bookName];
|
||||
if (!abbrev) continue;
|
||||
|
||||
books[abbrev] ??= {};
|
||||
books[abbrev][chapter] ??= {};
|
||||
books[abbrev][chapter][verse] ??= [];
|
||||
books[abbrev][chapter][verse].push({
|
||||
sort: Number(sortKey) || 0,
|
||||
o: original.trim(),
|
||||
t: translit,
|
||||
p: parsing,
|
||||
s: strongs ? `${(lang === 'Hebrew' || lang === 'Aramaic') ? 'H' : 'G'}${strongs}` : '',
|
||||
g: gloss,
|
||||
});
|
||||
}
|
||||
|
||||
let fileCount = 0;
|
||||
for (const [abbrev, chapters] of Object.entries(books)) {
|
||||
for (const chapter of Object.values(chapters)) {
|
||||
for (const verse of Object.values(chapter)) {
|
||||
verse.sort((a, b) => a.sort - b.sort);
|
||||
for (const w of verse) delete w.sort;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(path.join(outDir, `${abbrev}.json`), JSON.stringify(chapters));
|
||||
fileCount++;
|
||||
}
|
||||
|
||||
console.log(`Wrote ${fileCount} book files to ${outDir}`);
|
||||
@@ -0,0 +1,115 @@
|
||||
// Generates PWA icon PNGs using only Node.js built-ins (no dependencies).
|
||||
import { deflateSync } from 'zlib';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
|
||||
function crc32(buf) {
|
||||
const table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
table[i] = c;
|
||||
}
|
||||
let crc = 0xffffffff;
|
||||
for (const b of buf) crc = table[(crc ^ b) & 0xff] ^ (crc >>> 8);
|
||||
return ((crc ^ 0xffffffff) >>> 0);
|
||||
}
|
||||
|
||||
function u32(n) {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const t = Buffer.from(type, 'ascii');
|
||||
return Buffer.concat([u32(data.length), t, data, u32(crc32(Buffer.concat([t, data])))]);
|
||||
}
|
||||
|
||||
function makePNG(size, draw) {
|
||||
const px = new Uint8ClampedArray(size * size * 4); // RGBA
|
||||
draw(px, size);
|
||||
|
||||
const rows = [];
|
||||
for (let y = 0; y < size; y++) {
|
||||
rows.push(0); // PNG filter byte: None
|
||||
for (let x = 0; x < size; x++) {
|
||||
const i = (y * size + x) * 4;
|
||||
rows.push(px[i], px[i + 1], px[i + 2], px[i + 3]);
|
||||
}
|
||||
}
|
||||
|
||||
const raw = Buffer.from(rows);
|
||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const ihdr = chunk('IHDR', Buffer.concat([u32(size), u32(size), Buffer.from([8, 6, 0, 0, 0])]));
|
||||
const idat = chunk('IDAT', deflateSync(raw, { level: 9 }));
|
||||
const iend = chunk('IEND', Buffer.alloc(0));
|
||||
return Buffer.concat([sig, ihdr, idat, iend]);
|
||||
}
|
||||
|
||||
function setPixel(px, size, x, y, r, g, b, a = 255) {
|
||||
if (x < 0 || x >= size || y < 0 || y >= size) return;
|
||||
const i = (y * size + x) * 4;
|
||||
px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = a;
|
||||
}
|
||||
|
||||
function fillRect(px, size, x1, y1, x2, y2, r, g, b, a = 255) {
|
||||
for (let y = Math.max(0, y1); y < Math.min(size, y2); y++)
|
||||
for (let x = Math.max(0, x1); x < Math.min(size, x2); x++)
|
||||
setPixel(px, size, x, y, r, g, b, a);
|
||||
}
|
||||
|
||||
function drawIcon(px, S) {
|
||||
const rad = Math.round(S * 0.22); // corner radius
|
||||
|
||||
// Rounded background: #0f172a (15, 23, 42)
|
||||
for (let y = 0; y < S; y++) {
|
||||
for (let x = 0; x < S; x++) {
|
||||
const cx = Math.min(x, S - 1 - x);
|
||||
const cy = Math.min(y, S - 1 - y);
|
||||
if (cx < rad && cy < rad) {
|
||||
const dx = rad - cx - 1;
|
||||
const dy = rad - cy - 1;
|
||||
if (dx * dx + dy * dy > rad * rad) { setPixel(px, S, x, y, 0, 0, 0, 0); continue; }
|
||||
}
|
||||
setPixel(px, S, x, y, 15, 23, 42);
|
||||
}
|
||||
}
|
||||
|
||||
// Cross: vertical bar (center-ish, top-of-cross higher than center)
|
||||
const cw = Math.round(S * 0.12); // cross bar thickness
|
||||
const cx = Math.round(S / 2 - cw / 2);
|
||||
const vTop = Math.round(S * 0.18);
|
||||
const vBot = Math.round(S * 0.82);
|
||||
fillRect(px, S, cx, vTop, cx + cw, vBot, 255, 255, 255);
|
||||
|
||||
// Horizontal bar (slightly above center)
|
||||
const hh = Math.round(S * 0.12);
|
||||
const hy = Math.round(S * 0.36 - hh / 2);
|
||||
const hLeft = Math.round(S * 0.22);
|
||||
const hRight = Math.round(S * 0.78);
|
||||
fillRect(px, S, hLeft, hy, hRight, hy + hh, 255, 255, 255);
|
||||
|
||||
// Subtle glow/shine at cross intersection (slightly lighter center)
|
||||
const glowR = Math.round(S * 0.07);
|
||||
const gcx = Math.round(S / 2);
|
||||
const gcy = Math.round(S * 0.36);
|
||||
for (let y = gcy - glowR; y <= gcy + glowR; y++) {
|
||||
for (let x = gcx - glowR; x <= gcx + glowR; x++) {
|
||||
const d2 = (x - gcx) ** 2 + (y - gcy) ** 2;
|
||||
if (d2 <= glowR * glowR) {
|
||||
const i = (Math.max(0, Math.min(S - 1, y)) * S + Math.max(0, Math.min(S - 1, x))) * 4;
|
||||
if (px[i + 3] === 255) {
|
||||
px[i] = Math.min(255, px[i] + 20);
|
||||
px[i + 1] = Math.min(255, px[i + 1] + 20);
|
||||
px[i + 2] = Math.min(255, px[i + 2] + 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync('public', { recursive: true });
|
||||
writeFileSync('public/icon-192.png', makePNG(192, drawIcon));
|
||||
writeFileSync('public/icon-512.png', makePNG(512, drawIcon));
|
||||
writeFileSync('public/apple-touch-icon.png', makePNG(180, drawIcon));
|
||||
console.log('Icons written to public/');
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
# Restores user data from a backup zip created by backup-data.sh.
|
||||
#
|
||||
# Development usage:
|
||||
# npm run restore <path-to-backup-file>
|
||||
#
|
||||
# Production usage (restores into the running service directory):
|
||||
# sudo bash scripts/restore-data.sh <path-to-backup-file> --production
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
BACKUP_FILE="$1"
|
||||
MODE="${2:-}"
|
||||
|
||||
if [ -z "$BACKUP_FILE" ]; then
|
||||
echo "Usage: scripts/restore-data.sh <path-to-backup-file> [--production]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
echo "ERROR: Backup file not found: $BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "--production" ]; then
|
||||
DATA_DIR="/opt/study-app/server/data"
|
||||
SERVICE_USER="study-app"
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "ERROR: Production restore must be run as root (sudo)."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
DATA_DIR="$APP_DIR/server/data"
|
||||
SERVICE_USER=""
|
||||
fi
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# Warn if a database already exists
|
||||
if [ -f "$DATA_DIR/projects.db" ]; then
|
||||
echo "WARNING: An existing database was found at $DATA_DIR/projects.db"
|
||||
read -p "Overwrite it? (y/N): " CONFIRM
|
||||
if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then
|
||||
echo "Restore cancelled."
|
||||
exit 0
|
||||
fi
|
||||
cp "$DATA_DIR/projects.db" "$DATA_DIR/projects.db.bak"
|
||||
echo "Old database backed up to projects.db.bak"
|
||||
fi
|
||||
|
||||
unzip -o "$BACKUP_FILE" -d "$DATA_DIR"
|
||||
|
||||
if [ -n "$SERVICE_USER" ]; then
|
||||
chown "$SERVICE_USER":"$SERVICE_USER" "$DATA_DIR/projects.db"
|
||||
echo "Restarting study-app service..."
|
||||
systemctl restart study-app
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Restore complete. Database is at: $DATA_DIR/projects.db"
|
||||
if [ "$MODE" = "--production" ]; then
|
||||
echo "Check service status: systemctl status study-app"
|
||||
else
|
||||
echo "You can now start the app normally."
|
||||
fi
|
||||
@@ -0,0 +1,94 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { authenticator } from 'otplib';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getUserById } from './db.js';
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
// The single admin account for this deployment. Override via env var if you
|
||||
// redeploy this app for someone else — don't hardcode your own email into a
|
||||
// fork without changing this.
|
||||
const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || 'nmemmert@duck.com').toLowerCase();
|
||||
|
||||
export function isValidEmail(email) {
|
||||
return typeof email === 'string' && email.length <= 254 && EMAIL_RE.test(email);
|
||||
}
|
||||
|
||||
export function isValidPassword(password) {
|
||||
return typeof password === 'string' && password.length >= 8 && password.length <= 200;
|
||||
}
|
||||
|
||||
export function hashPassword(password) {
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
export function verifyPassword(password, hash) {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
/** Blocks the request unless a logged-in session is present. */
|
||||
export function requireAuth(req, res, next) {
|
||||
if (!req.session?.userId) {
|
||||
return res.status(401).json({ error: 'Not signed in.' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function isAdminEmail(email) {
|
||||
return typeof email === 'string' && email.toLowerCase() === ADMIN_EMAIL;
|
||||
}
|
||||
|
||||
/** Blocks the request unless the signed-in account is the designated admin. */
|
||||
export function requireAdmin(req, res, next) {
|
||||
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||||
if (!user || !isAdminEmail(user.email)) {
|
||||
return res.status(403).json({ error: 'Admin access only.' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Two-factor auth (TOTP, RFC 6238 — compatible with any authenticator app)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function generateTotpSecret() {
|
||||
return authenticator.generateSecret();
|
||||
}
|
||||
|
||||
export function totpKeyUri(email, secret) {
|
||||
return authenticator.keyuri(email, 'Bible Study Project', secret);
|
||||
}
|
||||
|
||||
export function verifyTotpToken(token, secret) {
|
||||
if (typeof token !== 'string' || !/^\d{6}$/.test(token)) return false;
|
||||
try {
|
||||
return authenticator.verify({ token, secret });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns { codes: string[] } plaintext codes to show the user once, for hashBackupCodes(). */
|
||||
export function generateBackupCodes(count = 8) {
|
||||
return Array.from({ length: count }, () => randomBytes(5).toString('hex'));
|
||||
}
|
||||
|
||||
export async function hashBackupCodes(codes) {
|
||||
return Promise.all(codes.map((code) => bcrypt.hash(code, 10)));
|
||||
}
|
||||
|
||||
/** Checks a submitted backup code against stored hashes; returns the remaining hashes if it matched, else null. */
|
||||
export async function consumeBackupCode(submitted, hashes) {
|
||||
if (typeof submitted !== 'string' || !Array.isArray(hashes)) return null;
|
||||
const normalized = submitted.trim().toLowerCase();
|
||||
for (let i = 0; i < hashes.length; i++) {
|
||||
if (await bcrypt.compare(normalized, hashes[i])) {
|
||||
return [...hashes.slice(0, i), ...hashes.slice(i + 1)];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A random temporary password for admin-assisted resets — shown once, relayed to the user out-of-band. */
|
||||
export function generateTemporaryPassword() {
|
||||
return randomBytes(6).toString('hex');
|
||||
}
|
||||
+309
-15
@@ -10,7 +10,7 @@ const DB_PATH = join(DATA_DIR, 'projects.db');
|
||||
let db;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init — create tables if they don't exist
|
||||
// Init — create tables if they don't exist, migrate older schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
export function initDb() {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
@@ -27,6 +27,58 @@ export function initDb() {
|
||||
chapter_summary TEXT,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
sess TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Older databases predate multi-user support — add the ownership column.
|
||||
const projectCols = db.prepare('PRAGMA table_info(projects)').all();
|
||||
if (!projectCols.some((c) => c.name === 'user_id')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN user_id TEXT REFERENCES users(id)');
|
||||
}
|
||||
|
||||
// Older databases predate 2FA support — add the TOTP columns.
|
||||
const userCols = db.prepare('PRAGMA table_info(users)').all();
|
||||
if (!userCols.some((c) => c.name === 'totp_secret')) {
|
||||
db.exec(`
|
||||
ALTER TABLE users ADD COLUMN totp_secret TEXT;
|
||||
ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN backup_codes TEXT;
|
||||
`);
|
||||
}
|
||||
|
||||
// Older databases predate the podcast-name profile setting.
|
||||
if (!userCols.some((c) => c.name === 'podcast_name')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT');
|
||||
}
|
||||
|
||||
// Older databases predate shareable read-only links.
|
||||
if (!projectCols.some((c) => c.name === 'share_token')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
|
||||
}
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
|
||||
|
||||
// Reader ink — one row per user + book + chapter, stored as JSON
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reader_ink (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
book_abbrev TEXT NOT NULL,
|
||||
chapter INTEGER NOT NULL,
|
||||
strokes TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, book_abbrev, chapter)
|
||||
);
|
||||
`);
|
||||
|
||||
console.log(`SQLite database ready at ${DB_PATH}`);
|
||||
@@ -42,27 +94,125 @@ function buildSummary(project) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queries
|
||||
// Users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function countUsers() {
|
||||
return db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
|
||||
}
|
||||
|
||||
export function createUser({ id, email, passwordHash }) {
|
||||
const createdAt = Date.now();
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, email, password_hash, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(id, email, passwordHash, createdAt);
|
||||
return { id, email, createdAt };
|
||||
}
|
||||
|
||||
function parseUserRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
totpEnabled: !!row.totpEnabled,
|
||||
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
|
||||
};
|
||||
}
|
||||
|
||||
const USER_SELECT = `
|
||||
SELECT id, email, password_hash AS passwordHash, created_at AS createdAt,
|
||||
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
|
||||
podcast_name AS podcastName
|
||||
FROM users
|
||||
`;
|
||||
|
||||
export function getUserByEmail(email) {
|
||||
return parseUserRow(db.prepare(`${USER_SELECT} WHERE email = ?`).get(email));
|
||||
}
|
||||
|
||||
export function getUserById(id) {
|
||||
return parseUserRow(db.prepare(`${USER_SELECT} WHERE id = ?`).get(id));
|
||||
}
|
||||
|
||||
/** Persists a confirmed TOTP secret + one-time backup code hashes, turning 2FA on. */
|
||||
export function enableTotp(userId, secret, backupCodeHashes) {
|
||||
db.prepare(`
|
||||
UPDATE users SET totp_secret = ?, totp_enabled = 1, backup_codes = ? WHERE id = ?
|
||||
`).run(secret, JSON.stringify(backupCodeHashes), userId);
|
||||
}
|
||||
|
||||
/** Turns 2FA off and forgets the secret/backup codes entirely. */
|
||||
export function disableTotp(userId) {
|
||||
db.prepare(`
|
||||
UPDATE users SET totp_secret = NULL, totp_enabled = 0, backup_codes = NULL WHERE id = ?
|
||||
`).run(userId);
|
||||
}
|
||||
|
||||
/** Sets or clears the show name used in the "Prepare for Podcast" prompt. */
|
||||
export function setPodcastName(userId, podcastName) {
|
||||
db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId);
|
||||
}
|
||||
|
||||
/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
|
||||
export function setBackupCodeHashes(userId, backupCodeHashes) {
|
||||
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader ink — cross-device sync for draw-mode annotations in the reader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns all saved reader ink pages for a user as { "BOOK_CH": strokes[] }. */
|
||||
export function getAllReaderInk(userId) {
|
||||
const rows = db.prepare(
|
||||
'SELECT book_abbrev AS book, chapter, strokes FROM reader_ink WHERE user_id = ?'
|
||||
).all(userId);
|
||||
return Object.fromEntries(
|
||||
rows.map((r) => [`${r.book}_${r.chapter}`, JSON.parse(r.strokes)])
|
||||
);
|
||||
}
|
||||
|
||||
/** Upserts the ink strokes for one reader page. */
|
||||
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
|
||||
db.prepare(`
|
||||
INSERT INTO reader_ink (user_id, book_abbrev, chapter, strokes, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, book_abbrev, chapter)
|
||||
DO UPDATE SET strokes = excluded.strokes, updated_at = excluded.updated_at
|
||||
`).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns any pre-existing, unowned projects (from before multi-user support)
|
||||
* to the given user. Intended to run once, right after the first account is created.
|
||||
*/
|
||||
export function claimOrphanProjects(userId) {
|
||||
db.prepare('UPDATE projects SET user_id = ? WHERE user_id IS NULL').run(userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project queries — all scoped to the owning user
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns all project summaries (id, title, lastEdited, chapterSummary).
|
||||
* Returns all project summaries owned by userId (id, title, lastEdited, chapterSummary).
|
||||
* Does NOT return full project data to keep the response small.
|
||||
*/
|
||||
export function getAllProjects() {
|
||||
export function getAllProjects(userId) {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary
|
||||
FROM projects
|
||||
WHERE user_id = ?
|
||||
ORDER BY last_edited DESC
|
||||
`).all();
|
||||
`).all(userId);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single full project by id, or null if not found.
|
||||
* Returns a single full project by id, scoped to userId, or null if not found/not owned.
|
||||
*/
|
||||
export function getProject(id) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id);
|
||||
export function getProject(id, userId) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
@@ -72,29 +222,173 @@ export function getProject(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or replace a project. Returns the summary.
|
||||
* Insert or replace a project owned by userId.
|
||||
* Returns the summary, or null if the id already belongs to a different user.
|
||||
*/
|
||||
export function upsertProject(project) {
|
||||
export function upsertProject(project, userId) {
|
||||
const existing = db.prepare('SELECT user_id AS userId FROM projects WHERE id = ?').get(project.id);
|
||||
if (existing && existing.userId !== userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastEdited = project.lastEdited ?? Date.now();
|
||||
const chapterSummary = buildSummary(project);
|
||||
const updated = { ...project, lastEdited };
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO projects (id, title, last_edited, chapter_summary, data)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO projects (id, title, last_edited, chapter_summary, data, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
last_edited = excluded.last_edited,
|
||||
chapter_summary = excluded.chapter_summary,
|
||||
data = excluded.data
|
||||
`).run(project.id, project.title, lastEdited, chapterSummary, JSON.stringify(updated));
|
||||
`).run(project.id, project.title, lastEdited, chapterSummary, JSON.stringify(updated), userId);
|
||||
|
||||
return { id: project.id, title: project.title, lastEdited, chapterSummary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project by id. No-op if not found.
|
||||
* Delete a project by id, scoped to userId. No-op if not found/not owned.
|
||||
*/
|
||||
export function deleteProject(id) {
|
||||
export function deleteProject(id, userId) {
|
||||
db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only share links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns the current share token for a project owned by userId, or null. */
|
||||
export function getShareToken(id, userId) {
|
||||
const row = db.prepare('SELECT share_token AS shareToken FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
return row?.shareToken ?? null;
|
||||
}
|
||||
|
||||
/** Sets a project's share token (enabling its public read-only link), scoped to userId. */
|
||||
export function setShareToken(id, userId, token) {
|
||||
const result = db.prepare('UPDATE projects SET share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/** Revokes a project's share link, scoped to userId. */
|
||||
export function clearShareToken(id, userId) {
|
||||
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
/** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */
|
||||
export function getProjectByShareToken(token) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin — unscoped views across every user/project. Callers must gate access
|
||||
// themselves (see requireAdmin in server/auth.js); nothing here checks who's asking.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Every account, with a project count, for the admin users list. */
|
||||
export function adminGetAllUsers() {
|
||||
return db.prepare(`
|
||||
SELECT u.id, u.email, u.created_at AS createdAt, u.totp_enabled AS totpEnabled,
|
||||
(SELECT COUNT(*) FROM projects p WHERE p.user_id = u.id) AS projectCount
|
||||
FROM users u
|
||||
ORDER BY u.created_at ASC
|
||||
`).all().map((r) => ({ ...r, totpEnabled: !!r.totpEnabled }));
|
||||
}
|
||||
|
||||
/** Deletes a user account. Their projects are left in place (orphaned, not cascade-deleted) so data isn't lost by accident. */
|
||||
export function adminDeleteUser(userId) {
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
}
|
||||
|
||||
/** Every project across every user, with the owner's email, for the admin projects list. */
|
||||
export function adminGetAllProjects() {
|
||||
return db.prepare(`
|
||||
SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary,
|
||||
p.share_token AS shareToken, u.email AS ownerEmail
|
||||
FROM projects p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
ORDER BY p.last_edited DESC
|
||||
`).all();
|
||||
}
|
||||
|
||||
/** Full project data by id, regardless of owner — for admin inspection. */
|
||||
export function adminGetProject(id) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes any project by id, regardless of owner. */
|
||||
export function adminDeleteProject(id) {
|
||||
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
/** Overwrites a user's password hash directly — used for both self-service and admin-assisted resets. */
|
||||
export function setUserPassword(userId, passwordHash) {
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session store backing (used by server/sessionStore.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getSession(sid) {
|
||||
const row = db.prepare('SELECT sess, expires FROM sessions WHERE sid = ?').get(sid);
|
||||
if (!row || row.expires < Date.now()) return null;
|
||||
try {
|
||||
return JSON.parse(row.sess);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSession(sid, sess, expires) {
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (sid, sess, expires)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expires = excluded.expires
|
||||
`).run(sid, JSON.stringify(sess), expires);
|
||||
}
|
||||
|
||||
export function destroySession(sid) {
|
||||
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
|
||||
}
|
||||
|
||||
export function pruneExpiredSessions() {
|
||||
db.prepare('DELETE FROM sessions WHERE expires < ?').run(Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a user out everywhere by deleting every session that belongs to them.
|
||||
* Sessions don't have an indexed user_id column (they're just an opaque JSON
|
||||
* blob to express-session), so this scans and parses — fine at this app's scale.
|
||||
* Pass exceptSid to keep one session alive (e.g. the one completing a self-service
|
||||
* password change, so the user isn't immediately logged out of their own action).
|
||||
*/
|
||||
export function destroyAllSessionsForUser(userId, exceptSid = null) {
|
||||
const rows = db.prepare('SELECT sid, sess FROM sessions').all();
|
||||
const staleSids = rows
|
||||
.filter((row) => row.sid !== exceptSid)
|
||||
.filter((row) => {
|
||||
try {
|
||||
return JSON.parse(row.sess)?.userId === userId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map((row) => row.sid);
|
||||
if (staleSids.length === 0) return;
|
||||
const placeholders = staleSids.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...staleSids);
|
||||
}
|
||||
|
||||
+449
-15
@@ -1,13 +1,67 @@
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import QRCode from 'qrcode';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { initDb, getAllProjects, getProject, upsertProject, deleteProject } from './db.js';
|
||||
import {
|
||||
initDb, getAllProjects, getProject, upsertProject, deleteProject,
|
||||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||
setUserPassword, destroyAllSessionsForUser,
|
||||
getAllReaderInk, setReaderInkPage,
|
||||
} from './db.js';
|
||||
import { SqliteSessionStore } from './sessionStore.js';
|
||||
import {
|
||||
isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail,
|
||||
generateTotpSecret, totpKeyUri, verifyTotpToken,
|
||||
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
|
||||
} from './auth.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (isProd && !process.env.SESSION_SECRET) {
|
||||
console.warn('WARNING: SESSION_SECRET is not set. Set it to a long random string in production.');
|
||||
}
|
||||
|
||||
// Trust the reverse proxy (needed for secure cookies to work behind nginx/etc).
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Simple in-memory rate limiter for auth endpoints — 10 attempts per 15 min per IP.
|
||||
const _authAttempts = new Map();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, e] of _authAttempts) if (now > e.resetAt) _authAttempts.delete(ip);
|
||||
}, 60 * 60 * 1000);
|
||||
function checkAuthRateLimit(ip) {
|
||||
const now = Date.now();
|
||||
const window = 15 * 60 * 1000;
|
||||
const e = _authAttempts.get(ip) ?? { count: 0, resetAt: now + window };
|
||||
if (now > e.resetAt) { e.count = 0; e.resetAt = now + window; }
|
||||
e.count += 1;
|
||||
_authAttempts.set(ip, e);
|
||||
return e.count > 10;
|
||||
}
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(session({
|
||||
store: new SqliteSessionStore(),
|
||||
secret: process.env.SESSION_SECRET || 'dev-only-secret-change-me',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
rolling: true,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: isProd,
|
||||
sameSite: 'lax',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
},
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check
|
||||
@@ -17,11 +71,233 @@ app.get('/api/health', (_req, res) => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects — list all project summaries (no full data)
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects', (_req, res) => {
|
||||
|
||||
app.post('/api/auth/register', async (req, res) => {
|
||||
try {
|
||||
const projects = getAllProjects();
|
||||
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(req.body?.password ?? '');
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return res.status(400).json({ error: 'Enter a valid email address.' });
|
||||
}
|
||||
if (!isValidPassword(password)) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
||||
}
|
||||
if (getUserByEmail(email)) {
|
||||
return res.status(409).json({ error: 'An account with that email already exists.' });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = createUser({ id: randomUUID(), email, passwordHash });
|
||||
|
||||
// The very first account inherits any projects created before multi-user support existed.
|
||||
if (countUsers() === 1) {
|
||||
claimOrphanProjects(user.id);
|
||||
}
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email) });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/register error:', err);
|
||||
res.status(500).json({ error: 'Failed to register.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
try {
|
||||
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(req.body?.password ?? '');
|
||||
|
||||
const user = getUserByEmail(email);
|
||||
const valid = user && await verifyPassword(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Incorrect email or password.' });
|
||||
}
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
if (user.totpEnabled) {
|
||||
// Password is correct, but the session stays unauthenticated (no userId)
|
||||
// until a valid TOTP/backup code lands on /api/auth/mfa/verify.
|
||||
req.session.pendingUserId = user.id;
|
||||
return res.json({ mfaRequired: true });
|
||||
}
|
||||
req.session.userId = user.id;
|
||||
res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/login error:', err);
|
||||
res.status(500).json({ error: 'Failed to log in.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/verify', async (req, res) => {
|
||||
try {
|
||||
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
|
||||
const pendingUserId = req.session?.pendingUserId;
|
||||
if (!pendingUserId) {
|
||||
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||||
}
|
||||
const user = getUserById(pendingUserId);
|
||||
if (!user || !user.totpEnabled) {
|
||||
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||||
}
|
||||
|
||||
const token = req.body?.token;
|
||||
const backupCode = req.body?.backupCode;
|
||||
let ok = token ? verifyTotpToken(String(token), user.totpSecret) : false;
|
||||
|
||||
if (!ok && backupCode) {
|
||||
const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes);
|
||||
if (remaining) {
|
||||
setBackupCodeHashes(user.id, remaining);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: 'Invalid code.' });
|
||||
}
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
res.json({ id: user.id, email: user.email, totpEnabled: true, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/verify error:', err);
|
||||
res.status(500).json({ error: 'Failed to verify code.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||||
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
||||
res.json({
|
||||
id: user.id, email: user.email, totpEnabled: user.totpEnabled,
|
||||
podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email),
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/auth/profile — update account-level settings (currently just podcastName)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.patch('/api/auth/profile', requireAuth, (req, res) => {
|
||||
try {
|
||||
const podcastName = String(req.body?.podcastName ?? '').trim().slice(0, 200);
|
||||
setPodcastName(req.session.userId, podcastName || null);
|
||||
res.json({ podcastName: podcastName || null });
|
||||
} catch (err) {
|
||||
console.error('PATCH /api/auth/profile error:', err);
|
||||
res.status(500).json({ error: 'Failed to save profile.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/auth/change-password — self-service password change (Account Settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post('/api/auth/change-password', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const currentPassword = String(req.body?.currentPassword ?? '');
|
||||
const newPassword = String(req.body?.newPassword ?? '');
|
||||
|
||||
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect.' });
|
||||
}
|
||||
if (!isValidPassword(newPassword)) {
|
||||
return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
setUserPassword(user.id, passwordHash);
|
||||
// Sign out every other session (e.g. a stolen one) but keep this one logged in.
|
||||
destroyAllSessionsForUser(user.id, req.sessionID);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/change-password error:', err);
|
||||
res.status(500).json({ error: 'Failed to change password.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Two-factor auth setup (requires an already-authenticated session)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.post('/api/auth/mfa/setup', requireAuth, (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const secret = generateTotpSecret();
|
||||
// Held only in the session until confirmed with a real code — never written
|
||||
// to the DB (and 2FA never turned on) unless /mfa/enable succeeds below.
|
||||
req.session.pendingTotpSecret = secret;
|
||||
QRCode.toDataURL(totpKeyUri(user.email, secret), (err, qrCodeDataUrl) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to generate QR code.' });
|
||||
res.json({ secret, qrCodeDataUrl });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/setup error:', err);
|
||||
res.status(500).json({ error: 'Failed to start 2FA setup.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const secret = req.session.pendingTotpSecret;
|
||||
if (!secret) {
|
||||
return res.status(400).json({ error: 'Start 2FA setup first.' });
|
||||
}
|
||||
if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) {
|
||||
return res.status(401).json({ error: 'That code didn\'t match. Check your authenticator app and try again.' });
|
||||
}
|
||||
|
||||
const backupCodes = generateBackupCodes();
|
||||
const backupCodeHashes = await hashBackupCodes(backupCodes);
|
||||
enableTotp(req.session.userId, secret, backupCodeHashes);
|
||||
delete req.session.pendingTotpSecret;
|
||||
res.json({ backupCodes });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/enable error:', err);
|
||||
res.status(500).json({ error: 'Failed to enable 2FA.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Incorrect password.' });
|
||||
}
|
||||
disableTotp(user.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/disable error:', err);
|
||||
res.status(500).json({ error: 'Failed to disable 2FA.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects — list all project summaries owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects', requireAuth, (req, res) => {
|
||||
try {
|
||||
const projects = getAllProjects(req.session.userId);
|
||||
res.json(projects);
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects error:', err);
|
||||
@@ -30,11 +306,11 @@ app.get('/api/projects', (_req, res) => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects/:id — fetch a single full project
|
||||
// GET /api/projects/:id — fetch a single full project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects/:id', (req, res) => {
|
||||
app.get('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
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 +320,9 @@ app.get('/api/projects/:id', (req, res) => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /api/projects/:id — create or update a project
|
||||
// PUT /api/projects/:id — create or update a project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.put('/api/projects/:id', (req, res) => {
|
||||
app.put('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== 'object') {
|
||||
@@ -58,7 +334,12 @@ app.put('/api/projects/:id', (req, res) => {
|
||||
if (body.id !== req.params.id) {
|
||||
return res.status(400).json({ error: 'URL id does not match body id.' });
|
||||
}
|
||||
const saved = upsertProject(body);
|
||||
if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' });
|
||||
if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' });
|
||||
const saved = upsertProject(body, req.session.userId);
|
||||
if (!saved) {
|
||||
return res.status(403).json({ error: 'That project belongs to a different account.' });
|
||||
}
|
||||
res.json(saved);
|
||||
} catch (err) {
|
||||
console.error('PUT /api/projects/:id error:', err);
|
||||
@@ -67,11 +348,11 @@ app.put('/api/projects/:id', (req, res) => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/projects/:id — remove a project
|
||||
// DELETE /api/projects/:id — remove a project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete('/api/projects/:id', (req, res) => {
|
||||
app.delete('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
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,10 +360,163 @@ app.delete('/api/projects/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader ink — cross-device draw annotations on Bible chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/reader/ink — all saved ink pages for the current user
|
||||
app.get('/api/reader/ink', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getAllReaderInk(req.session.userId));
|
||||
} catch (err) {
|
||||
console.error('GET /api/reader/ink error:', err);
|
||||
res.status(500).json({ error: 'Failed to load reader ink.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reader/ink/:book/:chapter — save (upsert) one page's ink
|
||||
app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { book, chapter } = req.params;
|
||||
const { strokes } = req.body;
|
||||
if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' });
|
||||
setReaderInkPage(req.session.userId, book, chapter, strokes);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reader/ink error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reader ink.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only share links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/projects/:id/share — current share status for the project owner
|
||||
app.get('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const project = getProject(req.params.id, req.session.userId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: getShareToken(req.params.id, req.session.userId) });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/projects/:id/share — enable sharing, returns the (new or existing) token
|
||||
app.post('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const existing = getShareToken(req.params.id, req.session.userId);
|
||||
const token = existing || randomUUID().replace(/-/g, '');
|
||||
const ok = setShareToken(req.params.id, req.session.userId, token);
|
||||
if (!ok) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: token });
|
||||
} catch (err) {
|
||||
console.error('POST /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/projects/:id/share — revoke the share link
|
||||
app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
clearShareToken(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only
|
||||
app.get('/api/share/:token', (req, res) => {
|
||||
try {
|
||||
const project = getProjectByShareToken(req.params.token);
|
||||
if (!project) return res.status(404).json({ error: 'This share link is invalid or has been revoked.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/share/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load shared project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin — restricted to the single designated admin account (see ADMIN_EMAIL
|
||||
// in server/auth.js). Full visibility/control over every user and project.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
res.json(adminGetAllUsers());
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/users error:', err);
|
||||
res.status(500).json({ error: 'Failed to list users.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
if (req.params.id === req.session.userId) {
|
||||
return res.status(400).json({ error: "Can't delete your own admin account." });
|
||||
}
|
||||
adminDeleteUser(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/admin/users/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete user.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/admin/users/:id/reset-password — sets a random temporary password and
|
||||
// signs the user out everywhere, since there's no self-service "forgot password" flow yet.
|
||||
app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const temporaryPassword = generateTemporaryPassword();
|
||||
const passwordHash = await hashPassword(temporaryPassword);
|
||||
setUserPassword(req.params.id, passwordHash);
|
||||
destroyAllSessionsForUser(req.params.id);
|
||||
res.json({ temporaryPassword });
|
||||
} catch (err) {
|
||||
console.error('POST /api/admin/users/:id/reset-password error:', err);
|
||||
res.status(500).json({ error: 'Failed to reset password.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
res.json(adminGetAllProjects());
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/projects error:', err);
|
||||
res.status(500).json({ error: 'Failed to list projects.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const project = adminGetProject(req.params.id);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to load project.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
adminDeleteProject(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/admin/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serve Vite production build (when NODE_ENV=production)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (isProd) {
|
||||
const distPath = join(__dirname, '..', 'dist');
|
||||
app.use(express.static(distPath));
|
||||
app.get('*', (_req, res) => {
|
||||
@@ -96,7 +530,7 @@ if (process.env.NODE_ENV === 'production') {
|
||||
initDb();
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Bible Study API running on http://localhost:${PORT}`);
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (isProd) {
|
||||
console.log('Serving Vite build from /dist');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import session from 'express-session';
|
||||
import { getSession, setSession, destroySession, pruneExpiredSessions } from './db.js';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* express-session store backed by the same SQLite database as everything else,
|
||||
* so logins survive a server restart without adding another dependency.
|
||||
*/
|
||||
export class SqliteSessionStore extends session.Store {
|
||||
constructor() {
|
||||
super();
|
||||
// Sweep expired sessions periodically instead of on every request.
|
||||
this._interval = setInterval(() => pruneExpiredSessions(), DAY_MS);
|
||||
this._interval.unref?.();
|
||||
}
|
||||
|
||||
get(sid, cb) {
|
||||
try {
|
||||
cb(null, getSession(sid));
|
||||
} catch (err) {
|
||||
cb(err);
|
||||
}
|
||||
}
|
||||
|
||||
set(sid, sessionData, cb) {
|
||||
try {
|
||||
const maxAge = sessionData.cookie?.maxAge ?? DAY_MS * 30;
|
||||
setSession(sid, sessionData, Date.now() + maxAge);
|
||||
cb?.(null);
|
||||
} catch (err) {
|
||||
cb?.(err);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(sid, cb) {
|
||||
try {
|
||||
destroySession(sid);
|
||||
cb?.(null);
|
||||
} catch (err) {
|
||||
cb?.(err);
|
||||
}
|
||||
}
|
||||
|
||||
touch(sid, sessionData, cb) {
|
||||
this.set(sid, sessionData, cb);
|
||||
}
|
||||
}
|
||||
+2781
-990
File diff suppressed because it is too large
Load Diff
+51
-38
@@ -16,15 +16,15 @@ const mockChapterData = {
|
||||
},
|
||||
};
|
||||
|
||||
const mockGreekDefinition = [
|
||||
{
|
||||
topic: 'G4102',
|
||||
lexeme: 'πίστις',
|
||||
transliteration: 'pistis',
|
||||
short_definition: 'faith, belief',
|
||||
definition: '<p>Part(s) of speech: Noun</p><p>Faith or belief.</p>',
|
||||
// Mirrors the OpenScriptures Strong's Greek dictionary format loaded from jsdelivr.
|
||||
const mockGreekDict = {
|
||||
G4102: {
|
||||
lemma: 'πίστις',
|
||||
translit: 'pistis',
|
||||
kjv_def: 'faith, belief',
|
||||
strongs_def: 'persuasion, i.e. credence; moral conviction',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}) {
|
||||
return vi.fn((url) => {
|
||||
@@ -34,8 +34,11 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
|
||||
if (url.includes('bible.helloao.org')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(chapterData) });
|
||||
}
|
||||
if (url.includes('bolls.life') && greekData !== null) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(greekData) });
|
||||
if (url.includes('strongs-greek-dictionary') && greekData !== null) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(`var strongsGreekDictionary = ${JSON.stringify(greekData)};`),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: false });
|
||||
});
|
||||
@@ -45,10 +48,11 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
|
||||
// Test lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', buildFetchMock());
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:mock'), revokeObjectURL: vi.fn() });
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
localStorage.clear();
|
||||
vi.stubGlobal('fetch', buildFetchMock());
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -59,9 +63,18 @@ afterEach(() => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// App checks /api/auth/me on mount before deciding whether to show the app or
|
||||
// a login gate. In tests the mock fetch reports the server as unreachable, so
|
||||
// it falls back to local-only mode — but that still takes a tick to resolve.
|
||||
async function renderApp() {
|
||||
render(<App />);
|
||||
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
|
||||
}
|
||||
|
||||
async function loadChapter() {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await renderApp();
|
||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
@@ -98,24 +111,24 @@ function findChunkCounter(n, total) {
|
||||
// Initial render
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Initial render', () => {
|
||||
test('shows the home page with "My Studies" heading', () => {
|
||||
render(<App />);
|
||||
test('shows the home page with "My Studies" heading', async () => {
|
||||
await renderApp();
|
||||
expect(screen.getByText('My Studies')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows "No projects yet" when storage is empty', () => {
|
||||
render(<App />);
|
||||
test('shows "No projects yet" when storage is empty', async () => {
|
||||
await renderApp();
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows "New Project" button on home page', () => {
|
||||
render(<App />);
|
||||
test('shows "New Project" button on home page', async () => {
|
||||
await renderApp();
|
||||
expect(screen.getAllByRole('button', { name: /new project/i }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('clicking "New Project" shows the project setup form', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await renderApp();
|
||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
expect(screen.getByText('Project Setup')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
|
||||
@@ -123,7 +136,7 @@ describe('Initial render', () => {
|
||||
|
||||
test('setup form shows translation, book, and chapter inputs', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await renderApp();
|
||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
expect(screen.getByText('Translation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Book')).toBeInTheDocument();
|
||||
@@ -158,7 +171,7 @@ describe('Loading a chapter', () => {
|
||||
return Promise.resolve({ ok: false });
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await renderApp();
|
||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/unable to load chapter/i);
|
||||
@@ -167,7 +180,7 @@ describe('Loading a chapter', () => {
|
||||
test('shows an error message when chapter data contains no verses', async () => {
|
||||
vi.stubGlobal('fetch', buildFetchMock({ chapterData: { chapter: { content: [] } } }));
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await renderApp();
|
||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/invalid bible data/i);
|
||||
@@ -182,7 +195,7 @@ describe('Chunk management', () => {
|
||||
test('creates a chunk by clicking a verse range', async () => {
|
||||
await loadChapter();
|
||||
addChunk('Paul, a servant', 'Grace and peace');
|
||||
await screen.findByText(/1-3/);
|
||||
await screen.findByText(/1[-–]3/);
|
||||
});
|
||||
|
||||
test('chunk count increments after each addition', async () => {
|
||||
@@ -289,28 +302,28 @@ async function goToStudyAndAddGreekWord(fetchMock) {
|
||||
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||
await screen.findByText(/chunk editor/i);
|
||||
fireEvent.click(screen.getByRole('button', { name: /add greek word/i }));
|
||||
await screen.findByPlaceholderText(/G4102, 4102/i);
|
||||
await screen.findByPlaceholderText(/G4102, H7225, 4102/i);
|
||||
}
|
||||
|
||||
describe('Greek word lookup', () => {
|
||||
test('adds a Greek word entry form', async () => {
|
||||
await goToStudyAndAddGreekWord();
|
||||
expect(screen.getByPlaceholderText(/G4102, 4102/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/G4102, H7225, 4102/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('populates fields after a successful lookup', async () => {
|
||||
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: mockGreekDefinition }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/G4102, 4102/i), { target: { value: 'G4102' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /look up/i }));
|
||||
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: mockGreekDict }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
|
||||
await screen.findByDisplayValue('πίστις');
|
||||
expect(screen.getByDisplayValue('pistis')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('faith, belief')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows "No definition found." when the API returns an empty array', async () => {
|
||||
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: [] }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/G4102, 4102/i), { target: { value: 'G4102' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /look up/i }));
|
||||
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: {} }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
|
||||
await screen.findByDisplayValue('No definition found.');
|
||||
});
|
||||
|
||||
@@ -326,8 +339,8 @@ describe('Greek word lookup', () => {
|
||||
return Promise.reject(new Error('Network error'));
|
||||
}),
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText(/G4102, 4102/i), { target: { value: 'G4102' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /look up/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
|
||||
await screen.findByDisplayValue('Lookup failed.');
|
||||
});
|
||||
|
||||
@@ -335,7 +348,7 @@ describe('Greek word lookup', () => {
|
||||
const fetchSpy = buildFetchMock();
|
||||
await goToStudyAndAddGreekWord(fetchSpy);
|
||||
const callCountBefore = fetchSpy.mock.calls.length;
|
||||
fireEvent.click(screen.getByRole('button', { name: /look up/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy.mock.calls.length).toBe(callCountBefore);
|
||||
});
|
||||
@@ -343,10 +356,10 @@ describe('Greek word lookup', () => {
|
||||
|
||||
test('removes a Greek word entry', async () => {
|
||||
await goToStudyAndAddGreekWord();
|
||||
expect(screen.getByPlaceholderText(/G4102, 4102/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/G4102, H7225, 4102/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^delete$/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText(/G4102, 4102/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText(/G4102, H7225, 4102/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const AppContext = createContext(null);
|
||||
export const useApp = () => useContext(AppContext);
|
||||
@@ -30,6 +30,13 @@ select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* When installed as a PWA, push header content below the status bar */
|
||||
@media all and (display-mode: standalone) {
|
||||
header {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
|
||||
@@ -3,6 +3,10 @@ import ReactDOM from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './index.css';
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { buildExportHtml } from '../App.jsx';
|
||||
import {
|
||||
adminDeleteUser,
|
||||
adminDeleteProject,
|
||||
adminGetProject,
|
||||
adminResetPassword,
|
||||
} from '../syncService.js';
|
||||
|
||||
export default function AdminPage() {
|
||||
const {
|
||||
authUser,
|
||||
authStatus,
|
||||
goHome,
|
||||
adminTab, setAdminTab,
|
||||
adminUsers,
|
||||
adminProjects,
|
||||
adminLoading,
|
||||
adminError,
|
||||
adminViewProject, setAdminViewProject,
|
||||
adminResetResult, setAdminResetResult,
|
||||
loadAdminData,
|
||||
} = useApp();
|
||||
|
||||
const handleDeleteUserAdmin = async (id, email) => {
|
||||
if (!window.confirm(`Delete account "${email}"? Their projects are kept, not deleted, but become inaccessible until reassigned.`)) return;
|
||||
const result = await adminDeleteUser(id);
|
||||
if (result.ok) loadAdminData();
|
||||
else alert(result.error ?? 'Failed to delete user.');
|
||||
};
|
||||
|
||||
const handleDeleteProjectAdmin = async (id, title) => {
|
||||
if (!window.confirm(`Permanently delete project "${title}"? This cannot be undone.`)) return;
|
||||
const result = await adminDeleteProject(id);
|
||||
if (result.ok) loadAdminData();
|
||||
else alert(result.error ?? 'Failed to delete project.');
|
||||
};
|
||||
|
||||
const handleViewProjectAdmin = async (id) => {
|
||||
const result = await adminGetProject(id);
|
||||
if (result.ok) setAdminViewProject(result.data);
|
||||
else alert(result.error ?? 'Failed to load project.');
|
||||
};
|
||||
|
||||
const handleResetPasswordAdmin = async (id, email) => {
|
||||
if (!window.confirm(`Reset the password for "${email}"? They'll be signed out everywhere and need the new temporary password to log back in.`)) return;
|
||||
const result = await adminResetPassword(id);
|
||||
if (result.ok) setAdminResetResult({ email, temporaryPassword: result.data.temporaryPassword });
|
||||
else alert(result.error ?? 'Failed to reset password.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold">🛡 Admin</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goHome}
|
||||
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
{authStatus}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => setAdminTab('users')}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'users' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
|
||||
Users ({adminUsers.length})
|
||||
</button>
|
||||
<button type="button" onClick={() => setAdminTab('projects')}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'projects' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
|
||||
Projects ({adminProjects.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{adminLoading && <p className="text-sm text-slate-500">Loading…</p>}
|
||||
{adminError && <p className="text-sm text-rose-600">{adminError}</p>}
|
||||
|
||||
{!adminLoading && !adminError && adminTab === 'users' && (
|
||||
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] text-sm">
|
||||
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Email</th>
|
||||
<th className="px-4 py-3">Joined</th>
|
||||
<th className="px-4 py-3">2FA</th>
|
||||
<th className="px-4 py-3">Projects</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adminUsers.map((u) => (
|
||||
<tr key={u.id} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-medium text-slate-800">
|
||||
{u.email}{u.id === authUser.id && <span className="ml-2 text-xs font-normal text-slate-400">(you)</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">{new Date(u.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{u.totpEnabled ? '✓' : '—'}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{u.projectCount}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{u.id !== authUser.id && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={() => handleResetPasswordAdmin(u.id, u.email)}
|
||||
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
|
||||
Reset Password
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDeleteUserAdmin(u.id, u.email)}
|
||||
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!adminLoading && !adminError && adminTab === 'projects' && (
|
||||
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] text-sm">
|
||||
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Title</th>
|
||||
<th className="px-4 py-3">Owner</th>
|
||||
<th className="px-4 py-3">Passage</th>
|
||||
<th className="px-4 py-3">Last edited</th>
|
||||
<th className="px-4 py-3">Shared</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adminProjects.map((p) => (
|
||||
<tr key={p.id} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-medium text-slate-800">{p.title}</td>
|
||||
<td className="px-4 py-3 text-slate-500">
|
||||
{p.ownerEmail ?? <span className="italic text-slate-400">orphaned</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">{p.chapterSummary}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{new Date(p.lastEdited).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{p.shareToken ? '🔗' : '—'}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={() => handleViewProjectAdmin(p.id)}
|
||||
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
|
||||
View
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDeleteProjectAdmin(p.id, p.title)}
|
||||
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{adminViewProject && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setAdminViewProject(null)}>
|
||||
<div className="h-full w-full max-w-4xl overflow-hidden rounded-2xl bg-white shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-slate-700">{adminViewProject.title}</p>
|
||||
<button type="button" onClick={() => setAdminViewProject(null)}
|
||||
className="rounded-lg border border-slate-300 px-3 py-1 text-xs text-slate-600 hover:bg-slate-50">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<iframe
|
||||
title="Project preview"
|
||||
srcDoc={buildExportHtml(adminViewProject)}
|
||||
sandbox="allow-popups"
|
||||
className="h-[calc(100%-3rem)] w-full border-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adminResetResult && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 className="text-sm font-semibold text-slate-900">Password reset</h3>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Relay this to <span className="font-medium text-slate-700">{adminResetResult.email}</span> yourself
|
||||
(text, call, in person) — it won't be shown again. They're signed out everywhere until they log in with it.
|
||||
</p>
|
||||
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-center font-mono text-lg tracking-wider text-amber-800">
|
||||
{adminResetResult.temporaryPassword}
|
||||
</p>
|
||||
<button type="button" onClick={() => setAdminResetResult(null)}
|
||||
className="mt-4 w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
|
||||
import { switchStorageUser } from '../App.jsx';
|
||||
|
||||
export default function AuthPage() {
|
||||
const {
|
||||
authMode, setAuthMode,
|
||||
authForm, setAuthForm,
|
||||
authError, setAuthError,
|
||||
authBusy, setAuthBusy,
|
||||
authMfaPending, setAuthMfaPending,
|
||||
authMfaCode, setAuthMfaCode,
|
||||
authMfaUseBackup, setAuthMfaUseBackup,
|
||||
setAuthUser,
|
||||
setProjectIndex,
|
||||
} = useApp();
|
||||
|
||||
const submitAuth = async (e) => {
|
||||
e.preventDefault();
|
||||
setAuthError('');
|
||||
setAuthBusy(true);
|
||||
const action = authMode === 'login' ? loginUser : registerUser;
|
||||
const result = await action(authForm.email.trim(), authForm.password);
|
||||
setAuthBusy(false);
|
||||
if (!result.ok) {
|
||||
setAuthError(result.error ?? 'Something went wrong.');
|
||||
return;
|
||||
}
|
||||
if (result.data?.mfaRequired) {
|
||||
setAuthMfaPending(true);
|
||||
return;
|
||||
}
|
||||
setAuthUser(result.data);
|
||||
setProjectIndex(switchStorageUser(result.data.id));
|
||||
};
|
||||
|
||||
const submitMfa = async (e) => {
|
||||
e.preventDefault();
|
||||
setAuthError('');
|
||||
setAuthBusy(true);
|
||||
const result = await verifyMfaLogin(
|
||||
authMfaUseBackup ? { backupCode: authMfaCode.trim() } : { token: authMfaCode.trim() },
|
||||
);
|
||||
setAuthBusy(false);
|
||||
if (!result.ok) {
|
||||
setAuthError(result.error ?? 'Invalid code.');
|
||||
return;
|
||||
}
|
||||
setAuthMfaPending(false);
|
||||
setAuthMfaCode('');
|
||||
setAuthUser(result.data);
|
||||
setProjectIndex(switchStorageUser(result.data.id));
|
||||
};
|
||||
|
||||
if (authMfaPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4">
|
||||
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 shadow-panel">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-xl font-semibold text-slate-900">Two-factor verification</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{authMfaUseBackup
|
||||
? 'Enter one of your saved backup codes.'
|
||||
: 'Enter the 6-digit code from your authenticator app.'}
|
||||
</p>
|
||||
<form onSubmit={submitMfa} className="mt-6 space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
inputMode={authMfaUseBackup ? 'text' : 'numeric'}
|
||||
placeholder={authMfaUseBackup ? 'xxxxxxxxxx' : '123456'}
|
||||
value={authMfaCode}
|
||||
onChange={(e) => setAuthMfaCode(e.target.value)}
|
||||
className="block w-full rounded-xl border border-slate-300 px-3 py-2 text-center text-lg tracking-widest shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
{authError && <p className="text-sm text-rose-600">{authError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authBusy}
|
||||
className="w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{authBusy ? 'Verifying…' : 'Verify'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuthMfaUseBackup((v) => !v);
|
||||
setAuthMfaCode('');
|
||||
setAuthError('');
|
||||
}}
|
||||
className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700"
|
||||
>
|
||||
{authMfaUseBackup ? 'Use your authenticator app instead' : "Lost your device? Use a backup code"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await logoutUser();
|
||||
setAuthMfaPending(false);
|
||||
setAuthMfaCode('');
|
||||
setAuthError('');
|
||||
}}
|
||||
className="mt-2 w-full text-center text-sm text-slate-400 underline hover:text-slate-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4 py-12">
|
||||
<div className="grid w-full max-w-5xl items-center gap-16 lg:grid-cols-2">
|
||||
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */}
|
||||
<div className="hidden lg:block">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
|
||||
<h1 className="mt-4 text-3xl font-semibold leading-tight text-white">
|
||||
Deep Bible study,<br />organized chunk by chunk.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-sm text-slate-300">
|
||||
Split any passage into chunks and work through Observation, Interpretation, and
|
||||
Application notes alongside Greek & Hebrew word studies, cross-references, and commentary.
|
||||
</p>
|
||||
<ul className="mt-8 space-y-4 text-sm text-slate-300">
|
||||
<li className="flex items-center gap-3"><span className="text-lg">📖</span> Chunk-by-chunk OIA notes on any passage</li>
|
||||
<li className="flex items-center gap-3"><span className="text-lg">🔤</span> Greek & Hebrew word studies with pronunciation</li>
|
||||
<li className="flex items-center gap-3"><span className="text-lg">🔗</span> Cross-references and commentary, one click away</li>
|
||||
<li className="flex items-center gap-3"><span className="text-lg">🎙</span> Turn your notes into a podcast-ready script</li>
|
||||
<li className="flex items-center gap-3"><span className="text-lg">🔒</span> Your studies stay private to your account, with optional 2FA</li>
|
||||
</ul>
|
||||
<blockquote className="mt-8 border-l-2 border-slate-700 pl-4 text-sm italic text-slate-400">
|
||||
"Make every effort to present yourself approved to God, an unashamed workman who accurately
|
||||
handles the word of truth."
|
||||
<footer className="mt-1 not-italic text-slate-500">— 2 Timothy 2:15 (BSB)</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
{/* Sign in / register form */}
|
||||
<div className="flex justify-center lg:justify-start">
|
||||
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 shadow-panel">
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-400 lg:hidden">Bible Study Project</p>
|
||||
<h1 className="mt-2 text-xl font-semibold text-slate-900">
|
||||
{authMode === 'login' ? 'Sign in' : 'Create an account'}
|
||||
</h1>
|
||||
<form onSubmit={submitAuth} className="mt-6 space-y-4">
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={authForm.email}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, email: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
|
||||
value={authForm.password}
|
||||
onChange={(e) => setAuthForm((f) => ({ ...f, password: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
</label>
|
||||
{authError && <p className="text-sm text-rose-600">{authError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authBusy}
|
||||
className="w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
|
||||
setAuthError('');
|
||||
}}
|
||||
className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700"
|
||||
>
|
||||
{authMode === 'login' ? "Need an account? Sign up" : 'Already have an account? Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { renderInkToCanvas } from '../utils/inkRender.js';
|
||||
|
||||
const INK_COLORS = ['#0f172a', '#ef4444', '#3b82f6', '#16a34a', '#f59e0b', '#a855f7'];
|
||||
const INK_SIZES = [
|
||||
{ label: 'XS', value: 0.003 },
|
||||
{ label: 'S', value: 0.006 },
|
||||
{ label: 'M', value: 0.012 },
|
||||
{ label: 'L', value: 0.025 },
|
||||
];
|
||||
const PAGE_HEIGHT = 640; // px per notebook page
|
||||
|
||||
// Page count is persisted as a hidden metadata stroke at strokes[0]
|
||||
// so the canvas size survives navigation without a separate state key.
|
||||
function extractMeta(strokes) {
|
||||
if (strokes.length > 0 && strokes[0]?._meta) {
|
||||
return { pageCount: strokes[0].pageCount ?? 1, realStrokes: strokes.slice(1) };
|
||||
}
|
||||
return { pageCount: 1, realStrokes: strokes };
|
||||
}
|
||||
function packStrokes(realStrokes, pageCount) {
|
||||
return [{ _meta: true, pageCount }, ...realStrokes];
|
||||
}
|
||||
|
||||
// Standalone notebook-style draw canvas.
|
||||
// strokes: array of saved stroke objects (may include a leading metadata object)
|
||||
// onStrokesChange: (newStrokes) => void
|
||||
// onDone: optional () => void — shows "Done" button when provided
|
||||
// headerContent: optional JSX rendered above the notebook area.
|
||||
// The canvas extends over it so you can draw directly on the content.
|
||||
export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerContent }) {
|
||||
const { drawTool, setDrawTool, drawColor, setDrawColor, drawSize, setDrawSize } = useApp();
|
||||
|
||||
const { pageCount: initPages } = extractMeta(strokes);
|
||||
const [pageCount, setPageCount] = useState(initPages);
|
||||
|
||||
// realStrokes = strokes without the metadata header
|
||||
const { realStrokes } = extractMeta(strokes);
|
||||
|
||||
const canvasRef = useRef(null);
|
||||
const hoverRef = useRef(null);
|
||||
const activeStrokeRef = useRef([]);
|
||||
const isDrawingRef = useRef(false);
|
||||
|
||||
// Refs prevent stale closures in stable callbacks
|
||||
const strokesRef = useRef(realStrokes);
|
||||
const onStrokesChangeRef = useRef(onStrokesChange);
|
||||
const drawToolRef = useRef(drawTool);
|
||||
const drawColorRef = useRef(drawColor);
|
||||
const drawSizeRef = useRef(drawSize);
|
||||
strokesRef.current = realStrokes;
|
||||
onStrokesChangeRef.current = onStrokesChange;
|
||||
const pageCountRef = useRef(pageCount);
|
||||
pageCountRef.current = pageCount;
|
||||
drawToolRef.current = drawTool;
|
||||
drawColorRef.current = drawColor;
|
||||
drawSizeRef.current = drawSize;
|
||||
|
||||
const render = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
renderInkToCanvas(
|
||||
canvas,
|
||||
strokesRef.current,
|
||||
activeStrokeRef.current,
|
||||
drawToolRef.current,
|
||||
drawColorRef.current,
|
||||
drawSizeRef.current,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { render(); }, [realStrokes, drawTool, drawColor, drawSize, render]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const obs = new ResizeObserver(render);
|
||||
obs.observe(canvas);
|
||||
return () => obs.disconnect();
|
||||
}, [render]);
|
||||
|
||||
// Hover cursor — reads refs so no stale-closure risk; no React re-renders on each move
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
function onMove(e) {
|
||||
if (e.pointerType !== 'pen') return;
|
||||
const el = hoverRef.current;
|
||||
if (!el) return;
|
||||
if (e.pressure > 0) { el.style.display = 'none'; return; }
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const sizePx = Math.max(6, drawSizeRef.current * rect.height);
|
||||
const tool = drawToolRef.current;
|
||||
const color = tool === 'eraser' ? '#94a3b8' : drawColorRef.current;
|
||||
el.style.display = 'block';
|
||||
el.style.width = `${sizePx}px`;
|
||||
el.style.height = `${sizePx}px`;
|
||||
el.style.left = `${x}px`;
|
||||
el.style.top = `${y}px`;
|
||||
el.style.borderColor = color;
|
||||
el.style.borderRadius = tool === 'eraser' ? '2px' : '50%';
|
||||
}
|
||||
|
||||
function onLeave() {
|
||||
if (hoverRef.current) hoverRef.current.style.display = 'none';
|
||||
}
|
||||
|
||||
canvas.addEventListener('pointermove', onMove);
|
||||
canvas.addEventListener('pointerleave', onLeave);
|
||||
return () => {
|
||||
canvas.removeEventListener('pointermove', onMove);
|
||||
canvas.removeEventListener('pointerleave', onLeave);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getPoint = useCallback((e) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return [0, 0, 0.5, 0];
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
// tiltX/tiltY range ±90°; normalize to 0-1 where 1 = fully flat (60° threshold)
|
||||
const tilt = e.pointerType === 'pen'
|
||||
? Math.min(Math.hypot(e.tiltX || 0, e.tiltY || 0) / 60, 1)
|
||||
: 0;
|
||||
return [
|
||||
(e.clientX - rect.left) / rect.width,
|
||||
(e.clientY - rect.top) / rect.height,
|
||||
e.pressure ?? 0.5,
|
||||
tilt,
|
||||
];
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
function commitStroke() {
|
||||
const pts = activeStrokeRef.current;
|
||||
if (drawToolRef.current !== 'eraser' && pts.length > 1) {
|
||||
onStrokesChangeRef.current(packStrokes(
|
||||
[
|
||||
...strokesRef.current,
|
||||
{
|
||||
id: crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`,
|
||||
tool: drawToolRef.current,
|
||||
color: drawColorRef.current,
|
||||
size: drawSizeRef.current,
|
||||
points: pts,
|
||||
},
|
||||
],
|
||||
pageCountRef.current,
|
||||
));
|
||||
}
|
||||
activeStrokeRef.current = [];
|
||||
isDrawingRef.current = false;
|
||||
render();
|
||||
}
|
||||
|
||||
function eraseAt(pt) {
|
||||
const [ex, ey] = pt;
|
||||
const r = drawSizeRef.current * 3;
|
||||
const remaining = strokesRef.current.filter(
|
||||
(s) => !s.points.some(([sx, sy]) => Math.hypot(sx - ex, sy - ey) < r),
|
||||
);
|
||||
if (remaining.length !== strokesRef.current.length)
|
||||
onStrokesChangeRef.current(packStrokes(remaining, pageCountRef.current));
|
||||
}
|
||||
|
||||
// iOS Safari: Apple Pencil fires Touch Events with touchType === 'stylus'.
|
||||
// Pointer Events on iPadOS emit pointercancel immediately after pointerdown
|
||||
// (palm detection or canvas-width resets from React re-renders), which forces
|
||||
// a double-tap to start every stroke. Touch Events bypass this entirely.
|
||||
if (typeof Touch !== 'undefined' && 'touchType' in Touch.prototype) {
|
||||
function pointFromTouch(t) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
// radiusX grows as the pencil tilts flat; ~10px radius ≈ fully tilted
|
||||
const tilt = Math.min((t.radiusX || 0) / 10, 1);
|
||||
return [
|
||||
(t.clientX - rect.left) / rect.width,
|
||||
(t.clientY - rect.top) / rect.height,
|
||||
t.force ?? 0.5,
|
||||
tilt,
|
||||
];
|
||||
}
|
||||
|
||||
function tStart(e) {
|
||||
// Two-finger tap (both non-stylus) = undo last stroke
|
||||
if (e.touches.length === 2 && Array.from(e.touches).every((t) => t.touchType !== 'stylus')) {
|
||||
e.preventDefault();
|
||||
if (strokesRef.current.length > 0) {
|
||||
onStrokesChangeRef.current(packStrokes(strokesRef.current.slice(0, -1), pageCountRef.current));
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const t of e.changedTouches) {
|
||||
if (t.touchType !== 'stylus') continue;
|
||||
e.preventDefault();
|
||||
if (hoverRef.current) hoverRef.current.style.display = 'none';
|
||||
isDrawingRef.current = true;
|
||||
activeStrokeRef.current = [pointFromTouch(t)];
|
||||
render();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function tMove(e) {
|
||||
if (!isDrawingRef.current) return;
|
||||
for (const t of e.changedTouches) {
|
||||
if (t.touchType !== 'stylus') continue;
|
||||
e.preventDefault();
|
||||
const pt = pointFromTouch(t);
|
||||
activeStrokeRef.current = [...activeStrokeRef.current, pt];
|
||||
if (drawToolRef.current === 'eraser') eraseAt(pt);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function tEnd(e) {
|
||||
if (!isDrawingRef.current) return;
|
||||
for (const t of e.changedTouches) {
|
||||
if (t.touchType !== 'stylus') continue;
|
||||
e.preventDefault();
|
||||
commitStroke();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
canvas.addEventListener('touchstart', tStart, { passive: false });
|
||||
canvas.addEventListener('touchmove', tMove, { passive: false });
|
||||
canvas.addEventListener('touchend', tEnd, { passive: false });
|
||||
canvas.addEventListener('touchcancel', tEnd, { passive: false });
|
||||
return () => {
|
||||
canvas.removeEventListener('touchstart', tStart);
|
||||
canvas.removeEventListener('touchmove', tMove);
|
||||
canvas.removeEventListener('touchend', tEnd);
|
||||
canvas.removeEventListener('touchcancel', tEnd);
|
||||
};
|
||||
}
|
||||
|
||||
// Non-iOS: Pointer Events (mouse, Windows pen, etc.)
|
||||
function down(e) {
|
||||
if (e.pointerType === 'touch') return;
|
||||
isDrawingRef.current = true;
|
||||
activeStrokeRef.current = [getPoint(e)];
|
||||
render();
|
||||
}
|
||||
|
||||
function move(e) {
|
||||
if (e.pointerType === 'touch') return;
|
||||
if (!isDrawingRef.current) return;
|
||||
e.preventDefault();
|
||||
const pt = getPoint(e);
|
||||
activeStrokeRef.current = [...activeStrokeRef.current, pt];
|
||||
if (drawToolRef.current === 'eraser') eraseAt(pt);
|
||||
render();
|
||||
}
|
||||
|
||||
function up(e) {
|
||||
if (e.pointerType === 'touch') return;
|
||||
if (!isDrawingRef.current) return;
|
||||
commitStroke();
|
||||
}
|
||||
|
||||
canvas.addEventListener('pointerdown', down);
|
||||
canvas.addEventListener('pointermove', move, { passive: false });
|
||||
canvas.addEventListener('pointerup', up);
|
||||
canvas.addEventListener('pointercancel', up);
|
||||
return () => {
|
||||
canvas.removeEventListener('pointerdown', down);
|
||||
canvas.removeEventListener('pointermove', move);
|
||||
canvas.removeEventListener('pointerup', up);
|
||||
canvas.removeEventListener('pointercancel', up);
|
||||
};
|
||||
}, [getPoint, render]);
|
||||
|
||||
const undo = () =>
|
||||
realStrokes.length > 0 &&
|
||||
onStrokesChange(packStrokes(realStrokes.slice(0, -1), pageCount));
|
||||
|
||||
const clear = () =>
|
||||
realStrokes.length > 0 &&
|
||||
window.confirm('Clear all ink notes?') &&
|
||||
onStrokesChange(packStrokes([], pageCount));
|
||||
|
||||
// Add another notebook page below existing content.
|
||||
// Rescales all stroke y-coords so existing ink stays at the same pixel position.
|
||||
const addSpace = () => {
|
||||
const newCount = pageCount + 1;
|
||||
const ratio = pageCount / newCount;
|
||||
const rescaled = realStrokes.map((s) => ({
|
||||
...s,
|
||||
points: s.points.map(([x, y, p]) => [x, y * ratio, p]),
|
||||
}));
|
||||
onStrokesChange(packStrokes(rescaled, newCount));
|
||||
setPageCount(newCount);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-3xl border border-slate-200 shadow-sm select-none" style={{ WebkitUserSelect: 'none' }}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-slate-200 bg-white px-3 py-2">
|
||||
{[
|
||||
{ id: 'pen', label: '✒', title: 'Pen' },
|
||||
{ id: 'highlighter', label: '▐', title: 'Highlighter' },
|
||||
{ id: 'eraser', label: '⌫', title: 'Eraser' },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
title={t.title}
|
||||
onClick={() => setDrawTool(t.id)}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg text-sm font-bold transition ${
|
||||
drawTool === t.id ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-slate-200" />
|
||||
|
||||
{INK_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => { setDrawColor(c); if (drawTool === 'eraser') setDrawTool('pen'); }}
|
||||
style={{ background: c }}
|
||||
className={`h-5 w-5 rounded-full border-2 transition ${
|
||||
drawColor === c && drawTool !== 'eraser' ? 'scale-125 border-slate-900' : 'border-white shadow-sm'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-slate-200" />
|
||||
|
||||
{INK_SIZES.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
type="button"
|
||||
onClick={() => setDrawSize(s.value)}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg text-xs font-bold transition ${
|
||||
drawSize === s.value ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-slate-200" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={undo}
|
||||
disabled={realStrokes.length === 0}
|
||||
className="rounded-lg px-2.5 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-100 disabled:opacity-40"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
disabled={realStrokes.length === 0}
|
||||
className="rounded-lg px-2.5 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-40"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
|
||||
{onDone && (
|
||||
<>
|
||||
<div className="mx-1 h-5 w-px bg-slate-200" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDone}
|
||||
className="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-slate-800"
|
||||
>
|
||||
Done ✓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Draw surface: optional scripture header + ruled notebook, one canvas over both */}
|
||||
<div className="relative">
|
||||
{/* Scripture content — canvas sits on top so you can draw directly on the text */}
|
||||
{headerContent && (
|
||||
<div
|
||||
className="border-b border-slate-200 bg-slate-50 p-4 font-serif text-sm leading-relaxed text-slate-800"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{headerContent}
|
||||
</div>
|
||||
)}
|
||||
{/* Ruled notebook area — height grows with pageCount */}
|
||||
<div
|
||||
style={{
|
||||
minHeight: PAGE_HEIGHT * pageCount,
|
||||
background: 'white',
|
||||
backgroundImage: 'repeating-linear-gradient(transparent 0px, transparent 31px, #dde3ec 31px, #dde3ec 32px)',
|
||||
}}
|
||||
/>
|
||||
{/* Red margin line — only in pure-notebook mode */}
|
||||
{!headerContent && (
|
||||
<div className="absolute bottom-0 left-10 top-0 w-px bg-rose-200" style={{ zIndex: 1 }} />
|
||||
)}
|
||||
{/* Hover cursor — positioned by the pointermove handler, never by React */}
|
||||
<div
|
||||
ref={hoverRef}
|
||||
style={{
|
||||
display: 'none',
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 3,
|
||||
border: '1.5px solid',
|
||||
boxSizing: 'border-box',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
{/* Single canvas spanning the entire area (scripture + notebook) */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 h-full w-full"
|
||||
style={{
|
||||
zIndex: 2,
|
||||
cursor: drawTool === 'eraser' ? 'cell' : 'crosshair',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add more notebook space */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSpace}
|
||||
className="w-full border-t border-slate-200 bg-slate-50 py-3 text-xs font-semibold text-slate-500 transition hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
+ Add More Space
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../App.jsx';
|
||||
|
||||
export default function HomePage() {
|
||||
const {
|
||||
authStatus,
|
||||
autoRestoredCount,
|
||||
staleLocalProjects,
|
||||
projectIndex,
|
||||
homeSearch, setHomeSearch,
|
||||
homeSort, setHomeSort,
|
||||
homeTagFilter, setHomeTagFilter,
|
||||
homeFullTextResults,
|
||||
readingPlan,
|
||||
createReadingPlan,
|
||||
clearReadingPlan,
|
||||
audioBook, setAudioBook,
|
||||
audioNarrator, setAudioNarrator,
|
||||
audioState,
|
||||
renamingId, setRenamingId,
|
||||
renameValue, setRenameValue,
|
||||
openBibleReader,
|
||||
openImportProject,
|
||||
openNewProject,
|
||||
pullLatestFromServer,
|
||||
resumeProject,
|
||||
renameProjectInStorage,
|
||||
deleteProject,
|
||||
handlePlayBookAudio,
|
||||
handleStopBookAudio,
|
||||
handleToggleBookAudioPause,
|
||||
} = useApp();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold">My Studies</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openBibleReader}
|
||||
className="rounded-xl border border-slate-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||
>
|
||||
📖 Read Bible
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openImportProject}
|
||||
className="rounded-xl border border-slate-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
|
||||
>
|
||||
<span className="hidden sm:inline">📥 Import Session List</span>
|
||||
<span className="sm:hidden">📥 Import</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewProject}
|
||||
className="rounded-xl bg-slate-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-600"
|
||||
>
|
||||
+ New Project
|
||||
</button>
|
||||
{authStatus}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-4 py-4 sm:py-8 sm:px-6 lg:px-8">
|
||||
{autoRestoredCount !== null && (
|
||||
<div className="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<p className="text-sm font-semibold text-emerald-800">
|
||||
📥 Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{staleLocalProjects.length > 0 && (
|
||||
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-4">
|
||||
<p className="mb-3 text-sm font-semibold text-amber-800">
|
||||
☁️ {staleLocalProjects.length} project{staleLocalProjects.length > 1 ? 's have' : ' has'} a newer version on the server:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{staleLocalProjects.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => pullLatestFromServer(entry.id)}
|
||||
className="rounded-xl bg-amber-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-amber-600"
|
||||
>
|
||||
Pull latest "{entry.title}"
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{projectIndex.length === 0 ? (
|
||||
<div className="mx-auto max-w-xl rounded-3xl border border-dashed border-slate-300 bg-white p-10 text-center shadow-panel">
|
||||
<p className="text-lg font-semibold text-slate-700">No projects yet</p>
|
||||
<p className="mt-2 text-sm text-slate-500">Start a new Bible study to get going.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewProject}
|
||||
className="mt-6 rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800"
|
||||
>
|
||||
+ New Project
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<input
|
||||
type="text"
|
||||
value={homeSearch}
|
||||
onChange={(e) => setHomeSearch(e.target.value)}
|
||||
placeholder="Search projects by title or passage…"
|
||||
className="w-full max-w-sm rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
<label className="text-sm text-slate-600">
|
||||
Sort by{' '}
|
||||
<select
|
||||
value={homeSort}
|
||||
onChange={(e) => setHomeSort(e.target.value)}
|
||||
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
<option value="recent">Last edited</option>
|
||||
<option value="title">Title</option>
|
||||
<option value="passage">Passage</option>
|
||||
</select>
|
||||
</label>
|
||||
{(() => {
|
||||
const allTags = Array.from(
|
||||
new Set(projectIndex.flatMap((entry) => entry.tags ?? [])),
|
||||
).sort((a, b) => a.localeCompare(b));
|
||||
if (allTags.length === 0) return null;
|
||||
return (
|
||||
<label className="text-sm text-slate-600">
|
||||
Tag{' '}
|
||||
<select
|
||||
value={homeTagFilter}
|
||||
onChange={(e) => setHomeTagFilter(e.target.value)}
|
||||
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
<option value="">All tags</option>
|
||||
{allTags.map((tag) => (
|
||||
<option key={tag} value={tag}>{tag}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{/* Full-text note search results */}
|
||||
{homeFullTextResults !== null && (
|
||||
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
|
||||
<p className="mb-3 text-sm font-semibold text-slate-700">
|
||||
{homeFullTextResults.length === 0
|
||||
? 'No notes match your search.'
|
||||
: `Notes matching "${homeSearch.trim()}" — ${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0)} result${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0) !== 1 ? 's' : ''} across ${homeFullTextResults.length} project${homeFullTextResults.length !== 1 ? 's' : ''}`}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{homeFullTextResults.map(({ projectId, projectTitle, matches }) => (
|
||||
<div key={projectId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resumeProject(projectId)}
|
||||
className="mb-1.5 text-sm font-semibold text-violet-700 hover:underline"
|
||||
>
|
||||
{projectTitle} →
|
||||
</button>
|
||||
<div className="space-y-1.5">
|
||||
{matches.map(({ chunkId, ref, field, snippet }) => (
|
||||
<div key={chunkId} className="rounded-2xl bg-slate-50 px-3 py-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">{ref} · {field}</span>
|
||||
<p className="mt-0.5 text-sm text-slate-700 leading-snug">{snippet}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reading plan card */}
|
||||
<ReadingPlanCard readingPlan={readingPlan} createReadingPlan={createReadingPlan} clearReadingPlan={clearReadingPlan} openBibleReader={openBibleReader} />
|
||||
|
||||
<div className="mb-4 flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-semibold text-slate-900">Listen to BSB Audio</h3>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{audioState.status === 'idle' || audioState.status === 'error'
|
||||
? 'Play a full book of the Berean Standard Bible.'
|
||||
: `Playing ${bookOptions.find((b) => b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}`}
|
||||
</p>
|
||||
{audioState.status === 'error' && (
|
||||
<p className="mt-1 text-sm text-rose-600">Couldn't load audio for this book/narrator.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={audioBook}
|
||||
onChange={(e) => setAudioBook(e.target.value)}
|
||||
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
|
||||
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
|
||||
>
|
||||
{bookOptions.map((book) => (
|
||||
<option key={book.abbrev} value={book.abbrev}>{book.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={audioNarrator}
|
||||
onChange={(e) => setAudioNarrator(e.target.value)}
|
||||
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
|
||||
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
|
||||
>
|
||||
<option value="david">David</option>
|
||||
<option value="hays">Hays</option>
|
||||
<option value="souer">Souer</option>
|
||||
</select>
|
||||
{audioState.status === 'playing' || audioState.status === 'paused' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleBookAudioPause}
|
||||
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500"
|
||||
>
|
||||
{audioState.status === 'paused' ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStopBookAudio}
|
||||
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlayBookAudio}
|
||||
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500"
|
||||
>
|
||||
Play book
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projectIndex
|
||||
.slice()
|
||||
.filter((entry) => {
|
||||
if (homeTagFilter && !(entry.tags ?? []).includes(homeTagFilter)) return false;
|
||||
const q = homeSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return entry.title?.toLowerCase().includes(q)
|
||||
|| entry.chapterSummary?.toLowerCase().includes(q);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
|
||||
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
|
||||
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
|
||||
})
|
||||
.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex flex-col gap-4 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel"
|
||||
>
|
||||
<div className="flex-1">
|
||||
{renamingId === entry.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed) renameProjectInStorage(entry.id, trimmed);
|
||||
setRenamingId(null);
|
||||
} else if (e.key === 'Escape') {
|
||||
setRenamingId(null);
|
||||
}
|
||||
}}
|
||||
className="flex-1 rounded-lg border border-slate-300 px-2 py-1 text-base font-semibold text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed) renameProjectInStorage(entry.id, trimmed);
|
||||
setRenamingId(null);
|
||||
}}
|
||||
className="text-sm font-semibold text-emerald-600 hover:text-emerald-700"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenamingId(null)}
|
||||
className="text-sm text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 className="text-base font-semibold text-slate-900">{entry.title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }}
|
||||
className="shrink-0 text-xs text-slate-400 hover:text-slate-600"
|
||||
title="Rename project"
|
||||
>
|
||||
✎ Rename
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{entry.chapterSummary && (
|
||||
<p className="mt-1 text-sm text-slate-500">{entry.chapterSummary}</p>
|
||||
)}
|
||||
{entry.tags?.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{entry.tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-semibold text-indigo-700">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-slate-400">{formatRelativeDate(entry.lastEdited)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resumeProject(entry.id)}
|
||||
className="flex-1 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteProject(entry.id)}
|
||||
className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 transition hover:bg-rose-100"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadingPlanCard({ readingPlan, createReadingPlan, clearReadingPlan, openBibleReader }) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [planBook, setPlanBook] = useState(bookOptions[0].abbrev);
|
||||
const [planWeeks, setPlanWeeks] = useState(4);
|
||||
|
||||
if (readingPlan) {
|
||||
const pct = readingPlan.totalChapters > 0
|
||||
? Math.round((readingPlan.chaptersRead.length / readingPlan.totalChapters) * 100)
|
||||
: 0;
|
||||
const daysLeft = Math.max(0, Math.ceil((readingPlan.targetDate - Date.now()) / 86400000));
|
||||
const chapLeft = readingPlan.totalChapters - readingPlan.chaptersRead.length;
|
||||
const paceNeeded = daysLeft > 0 ? (chapLeft / daysLeft).toFixed(1) : '—';
|
||||
return (
|
||||
<div className="mb-4 rounded-3xl border border-emerald-200 bg-emerald-50 p-5 shadow-panel">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-600">Reading Plan</p>
|
||||
<h3 className="mt-1 text-base font-semibold text-slate-900">{readingPlan.bookName}</h3>
|
||||
<p className="mt-0.5 text-sm text-slate-600">
|
||||
{readingPlan.chaptersRead.length} / {readingPlan.totalChapters} chapters · {daysLeft} day{daysLeft !== 1 ? 's' : ''} left · {paceNeeded} ch/day needed
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openBibleReader}
|
||||
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm('Clear reading plan?')) clearReadingPlan(); }}
|
||||
className="rounded-xl border border-emerald-300 px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-emerald-200">
|
||||
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
|
||||
{readingPlan.chaptersRead.length > 0 && (
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Read: ch. {readingPlan.chaptersRead.slice(0, 12).join(', ')}{readingPlan.chaptersRead.length > 12 ? '…' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
|
||||
{showForm ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm font-semibold text-slate-700">Read</span>
|
||||
<select
|
||||
value={planBook}
|
||||
onChange={(e) => setPlanBook(e.target.value)}
|
||||
className="rounded-xl border border-slate-300 bg-slate-50 px-2 py-1.5 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
{bookOptions.map((b) => (
|
||||
<option key={b.abbrev} value={b.abbrev}>{b.name} ({CHAPTER_COUNTS[b.abbrev] ?? '?'} ch)</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm text-slate-600">in</span>
|
||||
<select
|
||||
value={planWeeks}
|
||||
onChange={(e) => setPlanWeeks(Number(e.target.value))}
|
||||
className="rounded-xl border border-slate-300 bg-slate-50 px-2 py-1.5 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||
>
|
||||
{[1,2,3,4,6,8,12,16,26,52].map((w) => <option key={w} value={w}>{w} week{w !== 1 ? 's' : ''}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const book = bookOptions.find((b) => b.abbrev === planBook);
|
||||
createReadingPlan(planBook, book?.name ?? planBook, planWeeks);
|
||||
setShowForm(false);
|
||||
}}
|
||||
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
||||
>
|
||||
Start plan
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} className="text-sm text-slate-400 hover:text-slate-600">Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700">Reading Plan</p>
|
||||
<p className="text-xs text-slate-500">Set a goal to read through a book, track chapters as you go.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm(true)}
|
||||
className="shrink-0 rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Set goal
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user