diff --git a/README.md b/README.md
index 420eba0..791d047 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ A self-hosted Bible study tool with notes, projects, and user accounts.
---
-## Quick Start (Development)
+## Quick Start
```bash
git clone https://github.com/nmemmert/study-bible-project.git
@@ -20,24 +20,26 @@ cd study-bible-project
bash setup.sh
```
-`setup.sh` installs dependencies, finds a free port, and starts both servers:
+`setup.sh` installs dependencies, builds the optimized frontend, and runs one
+production server (frontend + API) on **http://localhost:5173**. Set `APP_PORT`
+to use a different port. If the build fails, the previously running server is
+left untouched. `bash restart.sh` pulls the latest code and does the same.
-| Server | Default URL |
-|--------|-------------|
-| API (Express) | http://localhost:3001 |
-| Frontend (Vite) | http://localhost:5173 |
-
-**Stop the servers:**
+**Stop / logs:**
```bash
-kill $(cat .api.pid) $(cat .vite.pid)
+kill $(cat .api.pid)
+tail -f .api.log
```
-**View logs:**
+### Development mode (hot reload)
+
```bash
-tail -f .api.log # API server
-tail -f .vite.log # Vite dev server
+bash setup.sh --dev
```
+Starts the API on http://localhost:3001 and the Vite dev server on
+http://localhost:5173. Dev mode is much slower, so don't use it for real users.
+
---
## Production Install (Ubuntu / systemd)
diff --git a/package-lock.json b/package-lock.json
index 49063a0..71beb73 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
+ "compression": "^1.8.2",
"concurrently": "^8.2.2",
"docx": "^9.7.0",
"dompurify": "^3.4.9",
@@ -2781,6 +2782,85 @@
"node": ">= 6"
}
},
+ "node_modules/compressible": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": ">= 1.43.0 < 2"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/compression": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
+ "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "compressible": "~2.0.18",
+ "debug": "2.6.9",
+ "destroy": "1.2.0",
+ "negotiator": "~0.6.4",
+ "on-headers": "~1.1.0",
+ "safe-buffer": "5.2.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/compression/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/compression/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/compression/node_modules/negotiator": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+ "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/compression/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
diff --git a/package.json b/package.json
index 8c0931a..32f8e13 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
+ "compression": "^1.8.2",
"concurrently": "^8.2.2",
"docx": "^9.7.0",
"dompurify": "^3.4.9",
diff --git a/restart.sh b/restart.sh
index eeb1672..986c7bc 100755
--- a/restart.sh
+++ b/restart.sh
@@ -3,28 +3,12 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-stop_pid_file() {
- local pid_file="$1"
- local label="$2"
- if [ -f "$pid_file" ]; then
- local old_pid
- old_pid=$(cat "$pid_file")
- if kill -0 "$old_pid" 2>/dev/null; then
- echo "Stopping $label (PID $old_pid)..."
- kill "$old_pid"
- sleep 1
- fi
- rm -f "$pid_file"
- fi
-}
-
-stop_pid_file "$ROOT_DIR/.api.pid" "API server"
-stop_pid_file "$ROOT_DIR/.vite.pid" "Vite server"
-
echo "Pulling latest changes from Gitea..."
cd "$ROOT_DIR"
git checkout -- package-lock.json 2>/dev/null || true
git pull
-echo "Restarting via setup.sh..."
-exec "$ROOT_DIR/setup.sh"
\ No newline at end of file
+# setup.sh builds first and only then stops the old server, so the site
+# stays up during the build (and if the build fails). Pass --dev through.
+echo "Rebuilding and restarting via setup.sh..."
+exec "$ROOT_DIR/setup.sh" "$@"
diff --git a/server/index.js b/server/index.js
index ed0e664..d55c285 100644
--- a/server/index.js
+++ b/server/index.js
@@ -1,4 +1,5 @@
import express from 'express';
+import compression from 'compression';
import session from 'express-session';
import QRCode from 'qrcode';
import { randomUUID } from 'crypto';
@@ -48,6 +49,8 @@ function checkAuthRateLimit(ip) {
return e.count > 10;
}
+// Gzip responses — the interlinear JSON files shrink ~10x, and JS/CSS/API payloads too.
+app.use(compression());
app.use(express.json({ limit: '10mb' }));
app.use(session({
store: new SqliteSessionStore(),
@@ -57,7 +60,10 @@ app.use(session({
rolling: true,
cookie: {
httpOnly: true,
- secure: isProd,
+ // 'auto' = Secure when the request arrived over HTTPS (via the proxy's
+ // X-Forwarded-Proto). A hard `true` silently breaks login if the proxy
+ // doesn't send that header, or when the app is opened over plain http.
+ secure: isProd ? 'auto' : false,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
},
@@ -518,8 +524,19 @@ app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
// ---------------------------------------------------------------------------
if (isProd) {
const distPath = join(__dirname, '..', 'dist');
- app.use(express.static(distPath));
+ // Vite's /assets files have content hashes in their names, so they can be
+ // cached forever. Other static files (interlinear JSON, icons) cache for a
+ // day; index.html and the service worker always revalidate so deploys show up.
+ const noCache = (res) => res.setHeader('Cache-Control', 'no-cache');
+ app.use('/assets', express.static(join(distPath, 'assets'), { immutable: true, maxAge: '1y' }));
+ app.use(express.static(distPath, {
+ maxAge: '1d',
+ setHeaders: (res, path) => {
+ if (path.endsWith('index.html') || path.endsWith('sw.js')) noCache(res);
+ },
+ }));
app.get('*', (_req, res) => {
+ noCache(res);
res.sendFile(join(distPath, 'index.html'));
});
}
diff --git a/setup.sh b/setup.sh
index 43bb05a..4dc75ec 100755
--- a/setup.sh
+++ b/setup.sh
@@ -1,6 +1,20 @@
#!/usr/bin/env bash
+# Installs dependencies, builds the frontend, and runs the app.
+#
+# bash setup.sh Production: one Express server serves the optimized
+# build + API on $APP_PORT (default 5173).
+# bash setup.sh --dev Development: Express API on 3001 + Vite dev server
+# with hot reload (slower; not for real users).
set -euo pipefail
+MODE="prod"
+if [ "${1:-}" = "--dev" ]; then
+ MODE="dev"
+fi
+
+# Production serves on the port the Vite dev server used to use, so an
+# existing reverse proxy pointed at it keeps working. Override with APP_PORT=.
+APP_PORT="${APP_PORT:-5173}"
PREFERRED_VITE_PORT=5173
MAX_VITE_PORT=5200
API_PORT=3001
@@ -41,7 +55,7 @@ stop_pid_file() {
fi
}
-echo "=== Bible Study App Setup ==="
+echo "=== Bible Study App Setup ($MODE) ==="
echo ""
# ── Node check ───────────────────────────────────────────────────────────────
@@ -68,7 +82,35 @@ echo ""
echo "Installing dependencies..."
npm install
-# ── Find a free Vite port ─────────────────────────────────────────────────────
+# ── Production ────────────────────────────────────────────────────────────────
+if [ "$MODE" = "prod" ]; then
+ # Build before stopping anything, so a failed build leaves the running site up.
+ echo ""
+ echo "Building frontend..."
+ npm run build
+
+ stop_pid_file "$API_PID_FILE" "server"
+ stop_pid_file "$VITE_PID_FILE" "Vite dev server"
+
+ echo ""
+ echo "Starting production server on http://localhost:$APP_PORT"
+ echo "Logs: $API_LOG_FILE"
+
+ NODE_ENV=production PORT="$APP_PORT" \
+ nohup node "$ROOT_DIR/server/index.js" > "$API_LOG_FILE" 2>&1 &
+ echo $! > "$API_PID_FILE"
+ echo "Server PID: $(cat "$API_PID_FILE")"
+
+ echo ""
+ echo "To stop: kill \$(cat .api.pid)"
+ echo "To view logs: tail -f .api.log"
+ exit 0
+fi
+
+# ── Development ───────────────────────────────────────────────────────────────
+stop_pid_file "$API_PID_FILE" "API server"
+stop_pid_file "$VITE_PID_FILE" "Vite server"
+
echo ""
VITE_PORT=$(find_port)
if [ -z "$VITE_PORT" ]; then
@@ -82,11 +124,6 @@ else
echo "Port $VITE_PORT is available for Vite."
fi
-# ── Stop any previously running instances ─────────────────────────────────────
-stop_pid_file "$API_PID_FILE" "API server"
-stop_pid_file "$VITE_PID_FILE" "Vite server"
-
-# ── Start Express API server ──────────────────────────────────────────────────
echo ""
echo "Starting API server on http://localhost:$API_PORT"
echo "API logs: $API_LOG_FILE"
@@ -98,7 +135,6 @@ echo "API server PID: $(cat "$API_PID_FILE")"
# Give the API a moment to initialise before Vite starts
sleep 1
-# ── Start Vite dev server ─────────────────────────────────────────────────────
echo ""
echo "Starting Vite dev server on http://localhost:$VITE_PORT"
echo "Vite logs: $VITE_LOG_FILE"
@@ -112,4 +148,4 @@ echo "Both servers are running."
echo ""
echo "To stop both: kill \$(cat .api.pid) \$(cat .vite.pid)"
echo "To view API logs: tail -f .api.log"
-echo "To view Vite logs: tail -f .vite.log"
\ No newline at end of file
+echo "To view Vite logs: tail -f .vite.log"
diff --git a/src/App.jsx b/src/App.jsx
index 1cd5d3b..eb969c2 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,56 +1,40 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
-import mammoth from 'mammoth';
+import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import { AppContext, useApp } from './context/AppContext.js';
-import AdminPage from './pages/AdminPage.jsx';
-import AuthPage from './pages/AuthPage.jsx';
+import { useBibleReader } from './hooks/useBibleReader.js';
import HomePage from './pages/HomePage.jsx';
-import ImportPage from './pages/ImportPage.jsx';
-import ReaderPage from './pages/ReaderPage.jsx';
-import SettingsPage from './pages/SettingsPage.jsx';
-import SetupPage from './pages/SetupPage.jsx';
-import StudyPage from './pages/StudyPage.jsx';
-import PronunciationPage from './pages/PronunciationPage.jsx';
+
import {
- AlignmentType,
- BorderStyle,
- Document,
- HeadingLevel,
- Packer,
- Paragraph,
- ShadingType,
- Table,
- TableCell,
- TableRow,
- TextRun,
- WidthType,
-} from 'docx';
-import {
- COMMENTARY_OPTIONS,
- STUDY_TABS,
CHAPTER_COUNTS,
bookOptions,
NT_BOOK_NUMBER,
- storageKey,
makeId,
- formatRelativeDate,
formatChunkReference,
getChunkVerseEntries,
chunkSpansNextChapter,
buildExportHtml,
- wordTableHtml,
extractPartOfSpeech,
- htmlToPlainText,
- renderVerseContent,
parseBibleChapter,
- indexKey,
- projectKey,
loadProjectIndex,
switchStorageUser,
- BookmarkIcon,
- CopyIcon,
- CrossRefChip,
buildPronunciationGuide,
} from './utils/bibleUtils.jsx';
+import {
+ saveProjectToStorage,
+ deleteProjectFromStorage,
+ loadProjectById,
+ migrateOldStorageKeys,
+ buildChapterSummary,
+} from './utils/projectStorage.js';
+import {
+ buildMarkdownExport,
+ buildClaudePrompt,
+ buildPodcastPrompt,
+} from './utils/exportBuilders.js';
+import {
+ parseCrossRefString,
+ parseEpisodePassage,
+ parseEpisodeListDocx,
+} from './utils/importParsers.js';
import {
saveRemoteProject,
@@ -58,589 +42,30 @@ import {
listRemoteProjects,
loadRemoteProject,
getCurrentUser,
- registerUser,
- loginUser,
logoutUser,
- verifyMfaLogin,
- startMfaSetup,
- confirmMfaSetup,
- disableMfa,
- updateProfile,
- changePassword,
getShareStatus,
enableSharing,
disableSharing,
getSharedProject,
adminListUsers,
- adminDeleteUser,
- adminResetPassword,
adminListProjects,
- adminGetProject,
- adminDeleteProject,
} from './syncService.js';
-// COMMENTARY_OPTIONS, STUDY_TABS, CHAPTER_COUNTS imported from ./utils/bibleUtils.jsx
-export { COMMENTARY_OPTIONS, STUDY_TABS, CHAPTER_COUNTS };
-
-// bookOptions, NT_BOOK_NUMBER imported from ./utils/bibleUtils.jsx
-export { bookOptions, NT_BOOK_NUMBER };
+// Less-common pages are split into their own chunks so the first load only
+// downloads what's needed to show the home screen.
+const AdminPage = lazy(() => import('./pages/AdminPage.jsx'));
+const AuthPage = lazy(() => import('./pages/AuthPage.jsx'));
+const ImportPage = lazy(() => import('./pages/ImportPage.jsx'));
+const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx'));
+const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
+const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
+const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
+const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
// ---------------------------------------------------------------------------
// Stable utility exports (used by tests and other modules)
// ---------------------------------------------------------------------------
-// storageKey, makeId imported from ./utils/bibleUtils.jsx
-export { storageKey, makeId };
-
-// ---------------------------------------------------------------------------
-// Migration helpers (exported for testing)
-// ---------------------------------------------------------------------------
-
-export function migrateChunk(chunk) {
- if (chunk.observation !== undefined) {
- return {
- ...chunk,
- spilloverEndVerse: Number.isInteger(chunk.spilloverEndVerse) ? chunk.spilloverEndVerse : null,
- generalNotes: chunk.generalNotes ?? '',
- episodeNumber: chunk.episodeNumber ?? '',
- episodeTitle: chunk.episodeTitle ?? '',
- finalScript: chunk.finalScript ?? '',
- tags: chunk.tags ?? [],
- inkStrokes: chunk.inkStrokes ?? [],
- }; // already new format
- }
- return {
- ...chunk,
- observation: chunk.notes ?? '',
- interpretation: '',
- application: '',
- crossReferences: [],
- tags: [],
- inkStrokes: [],
- spilloverEndVerse: null,
- generalNotes: '',
- episodeNumber: '',
- episodeTitle: '',
- finalScript: '',
- notes: undefined,
- };
-}
-
-// formatChunkReference imported from ./utils/bibleUtils.jsx
-export { formatChunkReference };
-
-
-export function migrateProject(raw) {
- if (!raw) return null;
- // Already new format
- if (Array.isArray(raw.chapters)) {
- return {
- ...raw,
- chapters: raw.chapters.map((ch) => ({
- ...ch,
- chunks: ch.chunks.map(migrateChunk),
- })),
- };
- }
- // Old flat format → wrap in chapters array
- const id = raw.id ?? makeId();
- return {
- id,
- title: raw.title ?? '',
- translation: raw.translation ?? 'BSB',
- lastEdited: raw.lastEdited ?? Date.now(),
- selectedChunkId: raw.selectedChunkId ?? null,
- chapters: [
- {
- book: raw.book ?? '',
- bookAbbrev: raw.bookAbbrev ?? '',
- chapter: raw.chapter ?? '1',
- verses: raw.verses ?? [],
- chunks: (raw.chunks ?? []).map(migrateChunk),
- },
- ],
- };
-}
-
-// ---------------------------------------------------------------------------
-// Storage helpers
-// ---------------------------------------------------------------------------
-
-// indexKey, projectKey, loadProjectIndex, switchStorageUser imported from ./utils/bibleUtils.jsx
-export { switchStorageUser };
-
-function saveProjectToStorage(project) {
- // Honour an already-stamped lastEdited (e.g. from autosave) so localStorage
- // and the server always receive the identical timestamp.
- const updated = { ...project, lastEdited: project.lastEdited ?? Date.now() };
- window.localStorage.setItem(projectKey(updated.id), JSON.stringify(updated));
- const index = loadProjectIndex();
- const existing = index.findIndex((e) => e.id === updated.id);
- const tags = Array.from(
- new Set(
- (updated.chapters ?? [])
- .flatMap((ch) => ch.chunks ?? [])
- .flatMap((chunk) => chunk.tags ?? []),
- ),
- );
- const summary = {
- id: updated.id,
- title: updated.title,
- lastEdited: updated.lastEdited,
- chapterSummary: buildChapterSummary(updated),
- tags,
- };
- if (existing >= 0) {
- index[existing] = summary;
- } else {
- index.push(summary);
- }
- window.localStorage.setItem(indexKey(), JSON.stringify(index));
- return updated; // caller can use this exact object for the server PUT
-}
-
-function deleteProjectFromStorage(id) {
- window.localStorage.removeItem(projectKey(id));
- const index = loadProjectIndex().filter((e) => e.id !== id);
- window.localStorage.setItem(indexKey(), JSON.stringify(index));
-}
-
-function loadProjectById(id) {
- try {
- const raw = window.localStorage.getItem(projectKey(id));
- return raw ? migrateProject(JSON.parse(raw)) : null;
- } catch {
- return null;
- }
-}
-
-function migrateOldStorageKeys() {
- const keys = Object.keys(window.localStorage);
- keys.forEach((key) => {
- if (!key.startsWith('bible-study-') || key.startsWith('bible-study-index') || key.startsWith('bible-study-project')) return;
- try {
- const raw = JSON.parse(window.localStorage.getItem(key));
- if (!raw || !raw.book) return;
- const migrated = migrateProject(raw);
- if (!migrated) return;
- saveProjectToStorage(migrated);
- window.localStorage.removeItem(key);
- } catch {
- // skip corrupt entries
- }
- });
-}
-
-function buildChapterSummary(project) {
- if (!Array.isArray(project.chapters)) return '';
- return project.chapters
- .map((ch) => `${ch.book} ${ch.chapter}`)
- .join(', ');
-}
-
-// formatRelativeDate imported from ./utils/bibleUtils.jsx
-export { formatRelativeDate };
-
-// ---------------------------------------------------------------------------
-// Export / prompt builders (exported for testing)
-// ---------------------------------------------------------------------------
-
-// buildExportHtml imported from ./utils/bibleUtils.jsx
-export { buildExportHtml };
-
-export function buildMarkdownExport(project) {
- const chapters = Array.isArray(project.chapters) ? project.chapters : [];
-
- const chapterSections = chapters.map((ch, chapterIndex) => {
- const chunkSections = ch.chunks.map((chunk) => {
- const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
- const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
- .map((verse) => `> **${verse.chapter}:${verse.number}** ${verse.text}`)
- .join('\n>\n');
-
- const observation = (chunk.observation ?? '').trim() || '_No observation._';
- const interpretation = (chunk.interpretation ?? '').trim() || '_No interpretation._';
- const application = (chunk.application ?? '').trim() || '_No application._';
- const generalNotes = (chunk.generalNotes ?? '').trim();
-
- const crossRefsLine = (chunk.crossReferences ?? []).length > 0
- ? `**Cross-References:** ${chunk.crossReferences.join(', ')}\n\n`
- : '';
-
- const greekSection = chunk.greekWords.length === 0
- ? ''
- : `**Greek/Hebrew Words**\n\n${chunk.greekWords.map((word) => {
- const summary = [
- word.strongNumber,
- word.lexeme,
- word.transliteration && `(${word.transliteration})`,
- word.partOfSpeech,
- word.shortDefinition,
- ].filter(Boolean).join(' — ');
- const definition = word.definitionHtml ? `\n ${htmlToPlainText(word.definitionHtml)}` : '';
- return `- ${summary}${definition}`;
- }).join('\n')}\n\n`;
-
- return `### ${scripture}\n\n${versesText}\n\n${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
- }).join('---\n\n');
-
- return `## ${ch.book} ${ch.chapter}\n\n${chunkSections}`;
- }).join('\n');
-
- return `# ${project.title}\n\n*${project.translation}*\n\n${chapterSections}`;
-}
-
-// wordTableHtml, extractPartOfSpeech, htmlToPlainText imported from ./utils/bibleUtils.jsx
-export { wordTableHtml, extractPartOfSpeech, htmlToPlainText };
-
-export function createParagraphsFromText(text) {
- return text
- .split(/\n+/)
- .map((line) => line.trim())
- .filter(Boolean)
- .map((line) => new Paragraph({ children: [new TextRun({ text: line })] }));
-}
-
-const DOCX_ACCENT = 'C9A84C'; // antique gold
-const DOCX_HEADING_FONT = 'Cormorant Garamond';
-const DOCX_BODY_FONT = 'Lora';
-
-export function sectionLabel(text) {
- return new Paragraph({
- spacing: { before: 240, after: 80 },
- children: [
- new TextRun({
- text: text.toUpperCase(),
- bold: true,
- color: DOCX_ACCENT,
- font: DOCX_BODY_FONT,
- size: 20,
- characterSpacing: 20,
- }),
- ],
- });
-}
-
-// renderVerseContent imported from ./utils/bibleUtils.jsx
-export { renderVerseContent };
-
-function buildChunkBodies(project) {
- const chapters = Array.isArray(project.chapters) ? project.chapters : [];
- let chunkIndex = 0;
- return chapters.map((ch, chapterIndex) => {
- return ch.chunks.map((chunk) => {
- chunkIndex += 1;
- const ref = formatChunkReference(project, chapterIndex, chunk, '–');
-
- const verses = getChunkVerseEntries(project, chapterIndex, chunk)
- .map((v) => `${v.chapter}:${v.number} ${v.text}`)
- .join('\n');
-
- const observation = (chunk.observation ?? '').trim() || 'No observation.';
- const interpretation = (chunk.interpretation ?? '').trim() || 'No interpretation.';
- const application = (chunk.application ?? '').trim() || 'No application.';
-
- const crossRefs = (chunk.crossReferences ?? []).length > 0
- ? chunk.crossReferences.join(', ')
- : 'None.';
-
- const generalNotes = (chunk.generalNotes ?? '').trim();
- const episodeLabelLine = (chunk.episodeNumber ?? '').trim() || (chunk.episodeTitle ?? '').trim()
- ? `Session: ${[chunk.episodeNumber?.trim(), chunk.episodeTitle?.trim()].filter(Boolean).join(' — ')}\n`
- : '';
-
- const greekWords = chunk.greekWords.length === 0
- ? 'None.'
- : chunk.greekWords.map((word) => {
- const summary = [
- word.strongNumber,
- word.lexeme,
- word.transliteration && `(${word.transliteration})`,
- word.partOfSpeech,
- word.shortDefinition,
- ].filter(Boolean).join(' | ');
- const definition = word.definitionHtml
- ? `\n ${htmlToPlainText(word.definitionHtml)}`
- : '';
- return `• ${summary}${definition}`;
- }).join('\n');
-
- return `===
-
-CHUNK ${chunkIndex} — ${ref}
-${episodeLabelLine}
-Scripture:
-${verses}
-${generalNotes ? `\nBackground / General Notes:\n${generalNotes}\n` : ''}
-Observation:
-${observation}
-
-Interpretation:
-${interpretation}
-
-Application:
-${application}
-
-Cross-References: ${crossRefs}
-
-Greek Words:
-${greekWords}
-`;
- }).join('\n');
- }).join('\n');
-}
-
-export function buildClaudePrompt(project) {
- const chapters = Array.isArray(project.chapters) ? project.chapters : [];
- const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', ');
-
- const header = `I've prepared a Bible study on ${chapterLabel} (${project.translation}) and need your help turning my notes into a polished study guide.
-
-Below is my work organised by passage chunk, including my OIA notes and Greek word research. Please create a clear, structured study guide that:
-- Synthesises my notes into coherent teaching points
-- Naturally integrates the Greek word insights
-- Includes 2–3 reflection questions per chunk
-- Preserves the passage-by-passage structure
-
----
-
-PROJECT: ${project.title}
-TRANSLATION: ${project.translation}
-PASSAGE: ${chapterLabel}
-`;
-
- return header + buildChunkBodies(project);
-}
-
-
-export function buildPodcastPrompt(project, podcastName) {
- const chapters = Array.isArray(project.chapters) ? project.chapters : [];
- const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', ');
- const episodeLabel = 'EPISODE';
- const showName = (podcastName ?? '').trim();
- const showLine = showName || 'MY BIBLE STUDY PODCAST';
- const signOffLine = showName
- ? `Introduce yourself, then sign off with: "...and this is ${showName}."`
- : 'Introduce yourself and the name of the show as a sign-off.';
-
- const header = `I'm recording an episode of my Bible study podcast${showName ? ` "${showName}"` : ''} and need a full script written from my study notes below.
-
-Please write the script in this exact structure, using the section markers and tone shown:
-
-${showLine.toUpperCase()}
-${episodeLabel}
-${chapterLabel} (${project.translation}) · [estimate XX–XX minutes based on content]
-
-✝ OPENING PRAYER
-[Pause before opening the text — invite God into the study]
-A short prayer (4-6 sentences) tying into the themes of this passage.
-
-— ✦ —
-
-🎙️ COLD OPEN
-[Co-host delivers the cold open solo, if there is one — hands off to the host at the end]
-A few short punchy lines previewing the passage and its hook, ending with a hand-off line introducing the host and the show.
-
-— ✦ —
-
-📖 SEGMENT [N] — [SEGMENT TITLE]
-[Brief stage direction in brackets]
-One segment per passage chunk (use the chunk reference, observation, interpretation, and application notes below as the raw material). Conversational, spoken-word style — not academic prose. Work through the text the way the host would talk it through out loud, weaving in the OIA notes and cross-references naturally.
-
-— ✦ —
-
-📖 SEGMENT [N] — GREEK WORD STUDY
-[Work through the key terms — keep it vivid, help the words come alive]
-For each Greek/Hebrew word collected below, a block in this format:
-
-[English Gloss] — [English meaning]
-[DO NOT READ: original-language word (transliteration) — part of speech]
-2-4 sentences unpacking the word's meaning and why it matters for this passage, conversational tone.
-
-— ✦ —
-
-💬 DISCUSSION QUESTIONS
-[From the study guide — ${chapterLabel}]
-3 reflection questions drawn from the application notes, numbered.
-
-— ✦ —
-
-✦ CLOSING
-[Grounded and direct — send them away with something to carry]
-A closing reflection that ties the segments together, a short pull-quote from the passage with its reference, then close out. ${signOffLine}
-"Until next time — keep studying verse by verse."
-
-— End of Episode —
-
-${showLine} · ${episodeLabel} · ${chapterLabel}
-
----
-
-Here are my study notes, organised by passage chunk, including my OIA notes, cross-references, and Greek/Hebrew word research:
-
-PROJECT: ${project.title}
-TRANSLATION: ${project.translation}
-PASSAGE: ${chapterLabel}
-`;
-
- return header + buildChunkBodies(project);
-}
-
-// parseBibleChapter imported from ./utils/bibleUtils.jsx
-export { parseBibleChapter };
-
-// Common short-form aliases for typed-in cross-references (e.g. "Rom 3:23")
-const BOOK_NAME_ALIASES = {
- 'gen': 'GEN', 'genesis': 'GEN', 'exo': 'EXO', 'exod': 'EXO', 'exodus': 'EXO',
- 'lev': 'LEV', 'leviticus': 'LEV', 'num': 'NUM', 'numbers': 'NUM',
- 'deut': 'DEU', 'deuteronomy': 'DEU', 'josh': 'JOS', 'joshua': 'JOS',
- 'judg': 'JDG', 'judges': 'JDG', 'ruth': 'RUT',
- '1 sam': '1SA', '1 samuel': '1SA', '2 sam': '2SA', '2 samuel': '2SA',
- '1 kgs': '1KI', '1 kings': '1KI', '2 kgs': '2KI', '2 kings': '2KI',
- '1 chr': '1CH', '1 chronicles': '1CH', '2 chr': '2CH', '2 chronicles': '2CH',
- 'ezra': 'EZR', 'neh': 'NEH', 'nehemiah': 'NEH', 'esth': 'EST', 'esther': 'EST',
- 'job': 'JOB', 'ps': 'PSA', 'psa': 'PSA', 'psalm': 'PSA', 'psalms': 'PSA',
- 'prov': 'PRO', 'proverbs': 'PRO', 'eccl': 'ECC', 'ecclesiastes': 'ECC',
- 'song': 'SNG', 'isa': 'ISA', 'isaiah': 'ISA', 'jer': 'JER', 'jeremiah': 'JER',
- 'lam': 'LAM', 'lamentations': 'LAM', 'ezek': 'EZK', 'ezekiel': 'EZK',
- 'dan': 'DAN', 'daniel': 'DAN', 'hos': 'HOS', 'hosea': 'HOS', 'joel': 'JOL',
- 'amos': 'AMO', 'obad': 'OBA', 'obadiah': 'OBA', 'jonah': 'JON', 'mic': 'MIC',
- 'micah': 'MIC', 'nah': 'NAM', 'nahum': 'NAM', 'hab': 'HAB', 'habakkuk': 'HAB',
- 'zeph': 'ZEP', 'zephaniah': 'ZEP', 'hag': 'HAG', 'haggai': 'HAG',
- 'zech': 'ZEC', 'zechariah': 'ZEC', 'mal': 'MAL', 'malachi': 'MAL',
- 'matt': 'MAT', 'mt': 'MAT', 'matthew': 'MAT', 'mark': 'MRK', 'mk': 'MRK',
- 'luke': 'LUK', 'lk': 'LUK', 'john': 'JHN', 'jn': 'JHN', 'acts': 'ACT',
- 'rom': 'ROM', 'romans': 'ROM', '1 cor': '1CO', '1 corinthians': '1CO',
- '2 cor': '2CO', '2 corinthians': '2CO', 'gal': 'GAL', 'galatians': 'GAL',
- 'eph': 'EPH', 'ephesians': 'EPH', 'phil': 'PHP', 'philippians': 'PHP',
- 'col': 'COL', 'colossians': 'COL', '1 thess': '1TH', '1 thessalonians': '1TH',
- '2 thess': '2TH', '2 thessalonians': '2TH', '1 tim': '1TI', '1 timothy': '1TI',
- '2 tim': '2TI', '2 timothy': '2TI', 'titus': 'TIT', 'phlm': 'PHM', 'philemon': 'PHM',
- 'heb': 'HEB', 'hebrews': 'HEB', 'jas': 'JAS', 'james': 'JAS',
- '1 pet': '1PE', '1 peter': '1PE', '2 pet': '2PE', '2 peter': '2PE',
- '1 jn': '1JN', '1 john': '1JN', '2 jn': '2JN', '2 john': '2JN',
- '3 jn': '3JN', '3 john': '3JN', 'jude': 'JUD', 'rev': 'REV', 'revelation': 'REV',
-};
-
-bookOptions.forEach((b) => {
- BOOK_NAME_ALIASES[b.name.toLowerCase()] = b.abbrev;
-});
-
-// Parses strings like "Romans 3:23", "1 Timothy 3:2-7" into { bookAbbrev, chapter, verse, endVerse }
-function parseCrossRefString(ref) {
- const match = ref.trim().match(/^(.+?)\s+(\d+):(\d+)(?:-(\d+))?$/);
- if (!match) return null;
- const [, bookPart, chapter, verse, endVerse] = match;
- const abbrev = BOOK_NAME_ALIASES[bookPart.trim().toLowerCase()];
- if (!abbrev) return null;
- return {
- bookAbbrev: abbrev,
- chapter: Number(chapter),
- verse: Number(verse),
- endVerse: endVerse ? Number(endVerse) : Number(verse),
- };
-}
-
-// Parses a "Passage" cell like "1:1–2", "9:32–10:23", "9:1–19a", or "—" (no specific passage)
-function parseEpisodePassage(raw) {
- const str = (raw ?? '').trim().replace(/(\d)[a-z]\b/g, '$1'); // strip "19a"/"19b" verse-letter suffixes
- if (!str || /^[-–—]+$/.test(str)) return null;
- const match = str.match(/^(\d+):(\d+)\s*[-–—]\s*(?:(\d+):)?(\d+)$/)
- ?? str.match(/^(\d+):(\d+)$/);
- if (!match) return 'invalid';
- const [, ch1, v1, ch2, v2raw] = match;
- const endChapter = ch2 ? Number(ch2) : Number(ch1);
- const endVerse = v2raw !== undefined ? Number(v2raw) : Number(v1);
- return {
- startChapter: Number(ch1),
- startVerse: Number(v1),
- endChapter,
- endVerse,
- };
-}
-
-// Extracts a "Session # / Title / Passage" list from a docx, including both
-// table rows and standalone "Ep. N — Title" / "Session N — Title" paragraphs (e.g. part intros).
-async function parseEpisodeListDocx(file) {
- const arrayBuffer = await file.arrayBuffer();
- const { value: html } = await mammoth.convertToHtml({ arrayBuffer });
- const parsedDoc = new DOMParser().parseFromString(html, 'text/html');
-
- const episodes = new Map(); // episodeNumber -> { episodeNumber, title, passage }
-
- parsedDoc.querySelectorAll('table tr').forEach((row) => {
- const cells = Array.from(row.querySelectorAll('td,th')).map((c) => c.textContent.trim());
- if (cells.length < 3) return;
- const [epRaw, title, passage] = cells;
- if (!/^\d+$/.test(epRaw)) return; // header row
- episodes.set(epRaw, { episodeNumber: epRaw, title, passage });
- });
-
- parsedDoc.querySelectorAll('p').forEach((p) => {
- const match = p.textContent.trim().match(/^(?:Ep\.|Episode|Session)\s*(\d+)\s*[-–—]\s*(.+)$/i);
- if (!match) return;
- const [, epRaw, title] = match;
- if (!episodes.has(epRaw)) {
- episodes.set(epRaw, { episodeNumber: epRaw, title: title.trim(), passage: '—' });
- }
- });
-
- return Array.from(episodes.values()).sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber));
-}
-
-// BookmarkIcon, CopyIcon, CrossRefChip imported from ./utils/bibleUtils.jsx
-export { BookmarkIcon, CopyIcon, CrossRefChip };
-
-// ---------------------------------------------------------------------------
-// IndexedDB helpers for whole-Bible search cache
-// ---------------------------------------------------------------------------
-
-const BIBLE_IDB_NAME = 'study-app-bible-index';
-const BIBLE_IDB_STORE = 'bible-index';
-const BIBLE_IDB_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
-
-function openBibleIndexDB() {
- return new Promise((resolve, reject) => {
- const req = indexedDB.open(BIBLE_IDB_NAME, 1);
- req.onupgradeneeded = (e) => {
- const db = e.target.result;
- if (!db.objectStoreNames.contains(BIBLE_IDB_STORE)) {
- db.createObjectStore(BIBLE_IDB_STORE, { keyPath: 'id' });
- }
- };
- req.onsuccess = (e) => resolve(e.target.result);
- req.onerror = () => reject(req.error);
- });
-}
-
-async function getBibleIndexFromIDB(translation) {
- try {
- const db = await openBibleIndexDB();
- return new Promise((resolve, reject) => {
- const tx = db.transaction(BIBLE_IDB_STORE, 'readonly');
- const req = tx.objectStore(BIBLE_IDB_STORE).get(translation);
- req.onsuccess = () => resolve(req.result ?? null);
- req.onerror = () => reject(req.error);
- });
- } catch {
- return null;
- }
-}
-
-async function saveBibleIndexToIDB(translation, flat) {
- try {
- const db = await openBibleIndexDB();
- await new Promise((resolve, reject) => {
- const tx = db.transaction(BIBLE_IDB_STORE, 'readwrite');
- const req = tx.objectStore(BIBLE_IDB_STORE).put({ id: translation, flat, cachedAt: Date.now() });
- req.onsuccess = () => resolve();
- req.onerror = () => reject(req.error);
- });
- } catch {
- // Non-fatal; in-memory cache still works for this session
- }
-}
-
// ---------------------------------------------------------------------------
// App component
// ---------------------------------------------------------------------------
@@ -740,47 +165,12 @@ const App = () => {
const [importPreview, setImportPreview] = useState(null); // parsed episode specs, before fetching chapters
const [importBusy, setImportBusy] = useState(false);
const [importError, setImportError] = useState('');
- const [readerBookAbbrev, setReaderBookAbbrev] = useState(bookOptions[0].abbrev);
- const [readerChapter, setReaderChapter] = useState(1);
- const [readerVerses, setReaderVerses] = useState([]);
- const [readerTotalChapters, setReaderTotalChapters] = useState(1);
- const [readerLoading, setReaderLoading] = useState(false);
- const [readerError, setReaderError] = useState('');
- const [readerInterlinear, setReaderInterlinear] = useState(null); // full book interlinear
- const [readerInterlinearLoading, setReaderInterlinearLoading] = useState(false);
- const [readerSelectedVerse, setReaderSelectedVerse] = useState(null); // verse number with open interlinear panel
- const _readerInterlinearCacheRef = useRef({});
- const [readerFontSize, setReaderFontSize] = useState(1); // em multiplier: 0.875 | 1 | 1.125 | 1.25
- const [readerBookmarks, setReaderBookmarks] = useState(() => {
- try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
- });
- const [readerBookmarksPanelOpen, setReaderBookmarksPanelOpen] = useState(false);
- const [readerJumpVerse, setReaderJumpVerse] = useState(null); // verse number to scroll to + flash after a jump
- const [readerCrossRefs, setReaderCrossRefs] = useState(null);
- const [readerCrossRefsLoading, setReaderCrossRefsLoading] = useState(false);
- const [readerShowCrossRefs, setReaderShowCrossRefs] = useState(false);
- const _readerCrossRefCacheRef = useRef({});
- const [readerSearch, setReaderSearch] = useState('');
- const [readerSearchActive, setReaderSearchActive] = useState(false);
- const [readerSearchScope, setReaderSearchScope] = useState('chapter'); // 'chapter' | 'bible'
- const [bibleIndexStatus, setBibleIndexStatus] = useState('idle'); // 'idle' | 'loading' | 'ready' | 'error'
- const _bibleIndexCacheRef = useRef({}); // translation -> flat verse array
+ const reader = useBibleReader(currentPage);
+ const { readerBookAbbrev, readerChapter } = reader; // used by the shared audio player below
const [audioBook, setAudioBook] = useState(bookOptions[0].abbrev);
const [audioNarrator, setAudioNarrator] = useState('souer');
const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 });
const [readerAudioState, setReaderAudioState] = useState({ status: 'idle', chapter: 0 });
- const [readerInkByPage, setReaderInkByPage] = useState(() => {
- try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
- catch { return {}; }
- });
- const [readerTextHighlights, setReaderTextHighlights] = useState(() => {
- try {
- const val = JSON.parse(localStorage.getItem('readerTextHighlights') || '[]');
- return Array.isArray(val) ? val : [];
- } catch { return []; }
- });
- // Debounce timer ref so rapid stroke updates don't flood the server
- const inkSaveTimerRef = useRef({});
const audioRef = useRef(null);
const audioModeRef = useRef('book'); // 'book' | 'reader' — which player owns audioRef
const audioBookRef = useRef(audioBook);
@@ -896,9 +286,10 @@ const App = () => {
audioRef.current.src = '';
}
}, []);
- const [studyLayout, setStudyLayout] = useState(
- () => localStorage.getItem('studyLayout') || 'split',
- ); // 'stacked' | 'split' | 'annotate'
+ const [studyLayout, setStudyLayout] = useState(() => {
+ const saved = localStorage.getItem('studyLayout');
+ return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed
+ }); // 'stacked' | 'split'
const [drawTool, setDrawTool] = useState('pen');
const [drawColor, setDrawColor] = useState('#0f172a');
const [drawSize, setDrawSize] = useState(0.003);
@@ -979,164 +370,10 @@ const App = () => {
return data;
};
- // ---------------------------------------------------------------------------
- // Bible reader (read-only browsing, separate from study projects)
- // ---------------------------------------------------------------------------
-
- // The HelloAO API has no search endpoint, so whole-Bible search fetches the
- // entire translation once (~7MB) and searches an in-memory flat verse index
- // client-side. Cached per translation for the rest of the session (not persisted
- // across reloads, to avoid eating into localStorage/IndexedDB quota for a cache
- // that's cheap enough to rebuild once per visit).
- const loadBibleIndex = async () => {
- if (_bibleIndexCacheRef.current.BSB) {
- setBibleIndexStatus('ready');
- return;
- }
- setBibleIndexStatus('loading');
- try {
- // Check IndexedDB cache before hitting the network (~7 MB fetch)
- const cached = await getBibleIndexFromIDB('BSB');
- if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
- _bibleIndexCacheRef.current.BSB = cached.flat;
- setBibleIndexStatus('ready');
- return;
- }
-
- const res = await fetch('https://bible.helloao.org/api/BSB/complete.json');
- if (!res.ok) throw new Error('Unable to load the full Bible for search.');
- const data = await res.json();
- const flat = [];
- for (const book of data.books ?? []) {
- for (const ch of book.chapters ?? []) {
- const verses = parseBibleChapter({ chapter: ch.chapter });
- for (const v of verses) {
- if (v.text) {
- flat.push({ bookAbbrev: book.id, bookName: book.commonName || book.name, chapter: ch.chapter.number, verse: v.number, text: v.text });
- }
- }
- }
- }
- _bibleIndexCacheRef.current.BSB = flat;
- setBibleIndexStatus('ready');
- // Persist for future visits — non-blocking
- saveBibleIndexToIDB('BSB', flat);
- } catch {
- setBibleIndexStatus('error');
- }
- };
-
- const loadReaderChapter = async (bookAbbrev, chapterNumber) => {
- setReaderLoading(true);
- setReaderError('');
- try {
- const res = await fetch(`https://bible.helloao.org/api/BSB/${bookAbbrev}/${chapterNumber}.json`);
- if (!res.ok) throw new Error('Unable to load chapter.');
- const data = await res.json();
- const verses = parseBibleChapter(data);
- if (!Array.isArray(verses) || verses.length === 0) {
- throw new Error('Invalid Bible data returned.');
- }
- setReaderVerses(verses);
- setReaderTotalChapters(data.book?.numberOfChapters ?? chapterNumber);
- } catch (err) {
- setReaderError(err.message || 'Failed to load chapter.');
- setReaderVerses([]);
- } finally {
- setReaderLoading(false);
- }
- };
-
- useEffect(() => {
- if (currentPage !== 'reader') return;
- loadReaderChapter(readerBookAbbrev, readerChapter);
- }, [currentPage, readerBookAbbrev, readerChapter]);
-
- // After navigating to a verse (from the bookmarks panel or a whole-Bible search
- // result), scroll to it and briefly flash its background so it's easy to spot.
- useEffect(() => {
- if (readerJumpVerse == null || readerLoading) return;
- const el = document.getElementById(`reader-verse-${readerJumpVerse}`);
- if (!el) return;
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
- const prevTransition = el.style.transition;
- const prevBackground = el.style.backgroundColor;
- el.style.transition = 'background-color 0.4s';
- el.style.backgroundColor = '#fef08a';
- const t = window.setTimeout(() => {
- el.style.backgroundColor = prevBackground;
- window.setTimeout(() => { el.style.transition = prevTransition; }, 400);
- }, 1200);
- setReaderJumpVerse(null);
- return () => window.clearTimeout(t);
- }, [readerVerses, readerLoading, readerJumpVerse]);
-
const openBibleReader = () => {
setCurrentPage('reader');
};
- // Navigates the reader to a specific verse (bookmarks panel, search results) and
- // scrolls/flashes it once that chapter's verses have loaded.
- const jumpToReaderVerse = (bookAbbrev, chapter, verseNumber) => {
- setReaderBookAbbrev(bookAbbrev);
- setReaderChapter(chapter);
- setReaderSelectedVerse(null);
- setReaderInterlinear(null);
- setReaderCrossRefs(null);
- setReaderSearch('');
- setReaderSearchActive(false);
- setReaderBookmarksPanelOpen(false);
- setReaderJumpVerse(verseNumber);
- };
-
- const readerGoToPreviousChapter = () => {
- if (readerChapter > 1) { setReaderChapter((c) => c - 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
- };
-
- const readerGoToNextChapter = () => {
- if (readerChapter < readerTotalChapters) { setReaderChapter((c) => c + 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
- };
-
- const handleReaderBookChange = (abbrev) => {
- setReaderBookAbbrev(abbrev);
- setReaderChapter(1);
- setReaderSelectedVerse(null);
- setReaderInterlinear(null);
- setReaderCrossRefs(null);
- setReaderSearch('');
- setReaderSearchActive(false);
- };
-
- const loadReaderInterlinear = async (bookAbbrev) => {
- if (_readerInterlinearCacheRef.current[bookAbbrev]) {
- setReaderInterlinear(_readerInterlinearCacheRef.current[bookAbbrev]);
- return;
- }
- setReaderInterlinearLoading(true);
- try {
- const res = await fetch(`/interlinear/${bookAbbrev}.json`);
- if (!res.ok) throw new Error('no interlinear');
- const data = await res.json();
- _readerInterlinearCacheRef.current[bookAbbrev] = data;
- setReaderInterlinear(data);
- } catch {
- setReaderInterlinear(null);
- } finally {
- setReaderInterlinearLoading(false);
- }
- };
-
- useEffect(() => {
- if (currentPage !== 'reader') return;
- loadReaderInterlinear(readerBookAbbrev);
- setReaderSelectedVerse(null);
- }, [currentPage, readerBookAbbrev]);
-
- useEffect(() => {
- if (currentPage !== 'reader' || !readerShowCrossRefs) return;
- loadReaderCrossRefs(readerBookAbbrev, readerChapter);
- }, [currentPage, readerBookAbbrev, readerChapter, readerShowCrossRefs]);
-
const speakOriginalWord = (word, strongsNum) => {
if (!window.speechSynthesis) return;
if (window.speechSynthesis.speaking) window.speechSynthesis.cancel();
@@ -1158,94 +395,6 @@ const App = () => {
window.speechSynthesis.speak(utt);
};
- // Reader helpers: bookmarks, cross-refs, copy verse
- const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
-
- const toggleReaderBookmark = (verseKey) => {
- setReaderBookmarks((prev) => {
- const next = { ...prev };
- if (next[verseKey]) {
- delete next[verseKey];
- } else {
- const usedColors = Object.values(next);
- const color = BOOKMARK_COLORS.find((c) => !usedColors.includes(c)) ?? BOOKMARK_COLORS[0];
- next[verseKey] = color;
- }
- try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
- return next;
- });
- };
-
- const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
- const key = `${bookAbbrev}_${chapter}`;
- setReaderInkByPage((prev) => {
- const next = { ...prev, [key]: strokes };
- try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {}
- return next;
- });
- // Debounce server sync — wait 1 s after last stroke before sending
- clearTimeout(inkSaveTimerRef.current[key]);
- inkSaveTimerRef.current[key] = setTimeout(() => {
- fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ strokes }),
- }).catch(() => {}); // silent — localStorage already saved locally
- }, 1000);
- }, []);
-
- const cycleBookmarkColor = (verseKey) => {
- setReaderBookmarks((prev) => {
- const cur = prev[verseKey];
- if (!cur) return prev;
- const idx = BOOKMARK_COLORS.indexOf(cur);
- const next = { ...prev, [verseKey]: BOOKMARK_COLORS[(idx + 1) % BOOKMARK_COLORS.length] };
- try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
- return next;
- });
- };
-
- const addTextHighlight = (highlight) => {
- const id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
- setReaderTextHighlights(prev => {
- const next = [...prev, { id, ...highlight }];
- try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
- return next;
- });
- };
-
- const removeTextHighlight = (id) => {
- setReaderTextHighlights(prev => {
- const next = prev.filter(h => h.id !== id);
- try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
- return next;
- });
- };
-
- const loadReaderCrossRefs = async (bookAbbrev, chapterNumber) => {
- const cacheKey = `${bookAbbrev}/${chapterNumber}`;
- if (_readerCrossRefCacheRef.current[cacheKey]) {
- setReaderCrossRefs(_readerCrossRefCacheRef.current[cacheKey]);
- return;
- }
- setReaderCrossRefsLoading(true);
- try {
- const res = await fetch(`https://bible.helloao.org/api/d/open-cross-ref/${bookAbbrev}/${chapterNumber}.json`);
- if (!res.ok) throw new Error('no cross-refs');
- const data = await res.json();
- const byVerse = {};
- for (const item of data.chapter?.content ?? []) {
- if (item.references?.length) byVerse[item.verse] = item.references;
- }
- _readerCrossRefCacheRef.current[cacheKey] = byVerse;
- setReaderCrossRefs(byVerse);
- } catch {
- setReaderCrossRefs(null);
- } finally {
- setReaderCrossRefsLoading(false);
- }
- };
-
const copyVerse = (bookName, chapterNum, verseNum, text) => {
const citation = `${bookName} ${chapterNum}:${verseNum} BSB — ${text}`;
navigator.clipboard?.writeText(citation);
@@ -1268,11 +417,7 @@ const App = () => {
.then((r) => r.ok ? r.json() : null)
.then((serverInk) => {
if (!serverInk) return;
- setReaderInkByPage((local) => {
- const merged = { ...local, ...serverInk };
- try { localStorage.setItem('readerInkByPage', JSON.stringify(merged)); } catch {}
- return merged;
- });
+ reader.mergeServerInk(serverInk);
})
.catch(() => {});
}
@@ -2010,6 +1155,7 @@ const App = () => {
if (!file) return;
try {
const arrayBuffer = await file.arrayBuffer();
+ const { default: mammoth } = await import('mammoth');
const { value } = await mammoth.extractRawText({ arrayBuffer });
updateChunk(chunkId, { finalScript: value.trim() });
setStatusMessage('Imported script from DOCX.');
@@ -2700,177 +1846,8 @@ const App = () => {
const exportChapterDocx = async () => {
if (!project) return;
- const children = [
- new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }),
- new Paragraph({
- children: [
- new TextRun({
- text: `${project.translation} — ${buildChapterSummary(project)}`,
- italics: true,
- color: '7A7060',
- }),
- ],
- spacing: { after: 300 },
- }),
- ];
-
- const noBorder = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' };
-
- project.chapters.forEach((ch, chapterIndex) => {
- children.push(new Paragraph({
- text: `${ch.book} ${ch.chapter}`,
- heading: HeadingLevel.HEADING_1,
- border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: DOCX_ACCENT, space: 4 } },
- spacing: { before: 360, after: 160 },
- }));
- ch.chunks.forEach((chunk) => {
- const scriptureHeading = formatChunkReference(project, chapterIndex, chunk, '-');
-
- const headingRuns = [new TextRun({ text: scriptureHeading, bold: true, size: 28, color: DOCX_ACCENT, font: DOCX_HEADING_FONT })];
- if (chunk.episodeNumber || chunk.episodeTitle) {
- headingRuns.push(new TextRun({
- text: ` (Session ${chunk.episodeNumber || '—'}${chunk.episodeTitle ? `: ${chunk.episodeTitle}` : ''})`,
- italics: true,
- size: 22,
- color: '7A7060',
- }));
- }
- children.push(new Paragraph({ children: headingRuns, spacing: { before: 320, after: 120 } }));
-
- // Scripture text in a left-bordered table cell for a "callout" look (no fill)
- const scriptureParas = getChunkVerseEntries(project, chapterIndex, chunk).map((verse) => new Paragraph({
- children: [
- new TextRun({ text: `${verse.chapter}:${verse.number} `, bold: true, color: DOCX_ACCENT }),
- new TextRun({ text: verse.text, italics: true }),
- ],
- spacing: { after: 80 },
- }));
- children.push(new Table({
- width: { size: 100, type: WidthType.PERCENTAGE },
- rows: [
- new TableRow({
- children: [
- new TableCell({
- margins: { top: 120, bottom: 120, left: 180, right: 180 },
- borders: {
- ...{ top: noBorder, right: noBorder, bottom: noBorder },
- left: { style: BorderStyle.SINGLE, size: 24, color: DOCX_ACCENT },
- },
- children: scriptureParas,
- }),
- ],
- }),
- ],
- }));
- children.push(new Paragraph({ text: '', spacing: { after: 120 } }));
-
- if (chunk.generalNotes?.trim()) {
- children.push(sectionLabel('Background / General Notes'));
- children.push(...createParagraphsFromText(chunk.generalNotes));
- }
-
- children.push(sectionLabel('Observation'));
- children.push(...createParagraphsFromText(chunk.observation || 'No observation.'));
- children.push(sectionLabel('Interpretation'));
- children.push(...createParagraphsFromText(chunk.interpretation || 'No interpretation.'));
- children.push(sectionLabel('Application'));
- children.push(...createParagraphsFromText(chunk.application || 'No application.'));
-
- if ((chunk.tags ?? []).length > 0) {
- children.push(sectionLabel('Tags'));
- children.push(new Paragraph({
- children: chunk.tags.map((tag, i) => new TextRun({
- text: i === 0 ? `#${tag}` : ` #${tag}`,
- bold: true,
- color: DOCX_ACCENT,
- })),
- }));
- }
-
- if ((chunk.crossReferences ?? []).length > 0) {
- children.push(sectionLabel('Cross-References'));
- children.push(new Paragraph({ text: chunk.crossReferences.join(' • ') }));
- }
-
- if (chunk.greekWords.length > 0) {
- children.push(sectionLabel('Word Studies'));
- const headerCellStyle = (label) => new TableCell({
- width: { size: 20, type: WidthType.PERCENTAGE },
- borders: { bottom: { style: BorderStyle.SINGLE, size: 8, color: DOCX_ACCENT } },
- children: [new Paragraph({ children: [new TextRun({ text: label, bold: true, color: DOCX_ACCENT })] })],
- });
- const tableRows = [
- new TableRow({
- tableHeader: true,
- children: ['Strong', 'Greek', 'Transliteration', 'Part of Speech', 'Short Definition'].map(headerCellStyle),
- }),
- ...chunk.greekWords.map((word) => new TableRow({
- children: [
- new TableCell({ children: [new Paragraph(word.strongNumber || '')] }),
- new TableCell({ children: [new Paragraph(word.lexeme || '')] }),
- new TableCell({ children: [new Paragraph(word.transliteration || '')] }),
- new TableCell({ children: [new Paragraph(word.partOfSpeech || '')] }),
- new TableCell({ children: [new Paragraph(word.shortDefinition || '')] }),
- ],
- })),
- ];
- children.push(new Table({ rows: tableRows, width: { size: 100, type: WidthType.PERCENTAGE } }));
- chunk.greekWords.forEach((word) => {
- if (word.definitionHtml) {
- children.push(new Paragraph({
- spacing: { before: 180, after: 80 },
- children: [new TextRun({ text: `${word.strongNumber} — ${word.lexeme || ''}`, bold: true, color: DOCX_ACCENT })],
- }));
- children.push(...createParagraphsFromText(htmlToPlainText(word.definitionHtml)));
- }
- });
- }
-
- if (chunk.finalScript?.trim()) {
- children.push(sectionLabel('Final Script'));
- children.push(...createParagraphsFromText(chunk.finalScript));
- }
-
- children.push(new Paragraph({
- text: '',
- spacing: { after: 200 },
- border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: '2A2518', space: 8 } },
- }));
- });
- });
-
- const doc = new Document({
- styles: {
- default: {
- document: { run: { font: DOCX_BODY_FONT, color: '0A0A08' } },
- },
- paragraphStyles: [
- {
- id: 'Title',
- name: 'Title',
- basedOn: 'Normal',
- next: 'Normal',
- run: { font: DOCX_HEADING_FONT, size: 56, bold: true, color: '0A0A08' },
- },
- {
- id: 'Heading1',
- name: 'Heading 1',
- basedOn: 'Normal',
- next: 'Normal',
- run: { font: DOCX_HEADING_FONT, size: 36, bold: true, color: '0A0A08' },
- },
- {
- id: 'Heading2',
- name: 'Heading 2',
- basedOn: 'Normal',
- next: 'Normal',
- run: { font: DOCX_HEADING_FONT, size: 30, bold: true, color: '0A0A08' },
- },
- ],
- },
- sections: [{ children }],
- });
- const blob = await Packer.toBlob(doc);
+ const { buildStudyDocxBlob } = await import('./utils/docxExport.js');
+ const blob = await buildStudyDocxBlob(project, buildChapterSummary(project));
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -3345,7 +2322,6 @@ const deleteProject = (id) => {
);
-
// ---------------------------------------------------------------------------
// Context value — every state, handler, and computed value pages need.
// ---------------------------------------------------------------------------
@@ -3424,40 +2400,8 @@ const deleteProject = (id) => {
handleImportFileChange,
runEpisodeImport,
// Reader
- readerBookAbbrev, setReaderBookAbbrev,
- readerChapter, setReaderChapter,
- readerVerses,
- readerTotalChapters,
- readerLoading,
- readerError,
- readerInterlinear,
- readerSelectedVerse, setReaderSelectedVerse,
- readerFontSize, setReaderFontSize,
- readerBookmarks, setReaderBookmarks,
- readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
- readerCrossRefs, setReaderCrossRefs,
- readerCrossRefsLoading,
- readerShowCrossRefs, setReaderShowCrossRefs,
- readerSearch, setReaderSearch,
- readerSearchActive, setReaderSearchActive,
- readerSearchScope, setReaderSearchScope,
+ ...reader,
readerAudioState,
- readerInkByPage,
- updateReaderPageInk,
- readerTextHighlights,
- addTextHighlight,
- removeTextHighlight,
- bibleIndexStatus,
- loadReaderChapter,
- _bibleIndexCacheRef,
- handleReaderBookChange,
- readerGoToPreviousChapter,
- readerGoToNextChapter,
- jumpToReaderVerse,
- toggleReaderBookmark,
- cycleBookmarkColor,
- loadReaderCrossRefs,
- loadBibleIndex,
handlePlayReaderAudio,
handleToggleReaderAudioPause,
copyVerse,
@@ -3558,11 +2502,21 @@ const deleteProject = (id) => {
return (
Loading…
+