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 ( - + }> + + ); }; +function PageLoading() { + return ( +
+

Loading…

+
+ ); +} + function AppRouter() { const { currentPage, diff --git a/src/App.test.jsx b/src/App.test.jsx index 7937f4d..a7c83a2 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -130,7 +130,7 @@ describe('Initial render', () => { const user = userEvent.setup(); await renderApp(); await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); - expect(screen.getByText('Project Setup')).toBeInTheDocument(); + expect(await screen.findByText('Project Setup')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument(); }); diff --git a/src/hooks/useBibleReader.js b/src/hooks/useBibleReader.js new file mode 100644 index 0000000..4d40964 --- /dev/null +++ b/src/hooks/useBibleReader.js @@ -0,0 +1,330 @@ +// State, data loading, and helpers for the read-only Bible reader page. +// Audio playback stays in App because the study page's book player shares +// the same