import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import DOMPurify from 'dompurify';
import mammoth from 'mammoth';
import {
AlignmentType,
BorderStyle,
Document,
HeadingLevel,
Packer,
Paragraph,
ShadingType,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
} from 'docx';
import {
saveRemoteProject,
deleteRemoteProject,
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';
const COMMENTARY_OPTIONS = [
{ id: 'matthew-henry', name: 'Matthew Henry' },
{ id: 'john-gill', name: 'John Gill' },
{ id: 'jamieson-fausset-brown', name: 'Jamieson-Fausset-Brown' },
{ id: 'adam-clarke', name: 'Adam Clarke' },
{ id: 'keil-delitzsch', name: 'Keil & Delitzsch (OT)' },
{ id: 'tyndale', name: 'Tyndale Open Study Notes' },
];
const STUDY_TABS = [
{ id: 'notes', label: 'Notes' },
{ id: 'crossRefs', label: 'Cross-Refs' },
{ id: 'wordStudy', label: 'Word Study' },
{ id: 'commentary', label: 'Commentary' },
{ id: 'script', label: 'Script' },
];
const bookOptions = [
{ name: 'Genesis', abbrev: 'GEN' },
{ name: 'Exodus', abbrev: 'EXO' },
{ name: 'Leviticus', abbrev: 'LEV' },
{ name: 'Numbers', abbrev: 'NUM' },
{ name: 'Deuteronomy', abbrev: 'DEU' },
{ name: 'Joshua', abbrev: 'JOS' },
{ name: 'Judges', abbrev: 'JDG' },
{ name: 'Ruth', abbrev: 'RUT' },
{ name: '1 Samuel', abbrev: '1SA' },
{ name: '2 Samuel', abbrev: '2SA' },
{ name: '1 Kings', abbrev: '1KI' },
{ name: '2 Kings', abbrev: '2KI' },
{ name: '1 Chronicles', abbrev: '1CH' },
{ name: '2 Chronicles', abbrev: '2CH' },
{ name: 'Ezra', abbrev: 'EZR' },
{ name: 'Nehemiah', abbrev: 'NEH' },
{ name: 'Esther', abbrev: 'EST' },
{ name: 'Job', abbrev: 'JOB' },
{ name: 'Psalms', abbrev: 'PSA' },
{ name: 'Proverbs', abbrev: 'PRO' },
{ name: 'Ecclesiastes', abbrev: 'ECC' },
{ name: 'Song', abbrev: 'SNG' },
{ name: 'Isaiah', abbrev: 'ISA' },
{ name: 'Jeremiah', abbrev: 'JER' },
{ name: 'Lamentations', abbrev: 'LAM' },
{ name: 'Ezekiel', abbrev: 'EZK' },
{ name: 'Daniel', abbrev: 'DAN' },
{ name: 'Hosea', abbrev: 'HOS' },
{ name: 'Joel', abbrev: 'JOL' },
{ name: 'Amos', abbrev: 'AMO' },
{ name: 'Obadiah', abbrev: 'OBA' },
{ name: 'Jonah', abbrev: 'JON' },
{ name: 'Micah', abbrev: 'MIC' },
{ name: 'Nahum', abbrev: 'NAM' },
{ name: 'Habakkuk', abbrev: 'HAB' },
{ name: 'Zephaniah', abbrev: 'ZEP' },
{ name: 'Haggai', abbrev: 'HAG' },
{ name: 'Zechariah', abbrev: 'ZEC' },
{ name: 'Malachi', abbrev: 'MAL' },
{ name: 'Matthew', abbrev: 'MAT' },
{ name: 'Mark', abbrev: 'MRK' },
{ name: 'Luke', abbrev: 'LUK' },
{ name: 'John', abbrev: 'JHN' },
{ name: 'Acts', abbrev: 'ACT' },
{ name: 'Romans', abbrev: 'ROM' },
{ name: '1 Corinthians', abbrev: '1CO' },
{ name: '2 Corinthians', abbrev: '2CO' },
{ name: 'Galatians', abbrev: 'GAL' },
{ name: 'Ephesians', abbrev: 'EPH' },
{ name: 'Philippians', abbrev: 'PHP' },
{ name: 'Colossians', abbrev: 'COL' },
{ name: '1 Thessalonians', abbrev: '1TH' },
{ name: '2 Thessalonians', abbrev: '2TH' },
{ name: '1 Timothy', abbrev: '1TI' },
{ name: '2 Timothy', abbrev: '2TI' },
{ name: 'Titus', abbrev: 'TIT' },
{ name: 'Philemon', abbrev: 'PHM' },
{ name: 'Hebrews', abbrev: 'HEB' },
{ name: 'James', abbrev: 'JAS' },
{ name: '1 Peter', abbrev: '1PE' },
{ name: '2 Peter', abbrev: '2PE' },
{ name: '1 John', abbrev: '1JN' },
{ name: '2 John', abbrev: '2JN' },
{ name: '3 John', abbrev: '3JN' },
{ name: 'Jude', abbrev: 'JUD' },
{ name: 'Revelation', abbrev: 'REV' },
];
const NT_BOOK_NUMBER = {
MAT: 40, MRK: 41, LUK: 42, JHN: 43, ACT: 44,
ROM: 45, '1CO': 46, '2CO': 47, GAL: 48, EPH: 49,
PHP: 50, COL: 51, '1TH': 52, '2TH': 53, '1TI': 54,
'2TI': 55, TIT: 56, PHM: 57, HEB: 58, JAS: 59,
'1PE': 60, '2PE': 61, '1JN': 62, '2JN': 63, '3JN': 64,
JUD: 65, REV: 66,
};
// ---------------------------------------------------------------------------
// Stable utility exports (used by tests and other modules)
// ---------------------------------------------------------------------------
export const storageKey = (translation, book, chapter) => `bible-study-${translation}-${book}-${chapter}`;
export const makeId = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
// ---------------------------------------------------------------------------
// Migration helpers (exported for testing)
// ---------------------------------------------------------------------------
export function migrateChunk(chunk) {
if (chunk.observation !== undefined) {
return {
...chunk,
spilloverEndVerse: Number.isInteger(chunk.spilloverEndVerse) ? chunk.spilloverEndVerse : null,
generalNotes: chunk.generalNotes ?? '',
episodeNumber: chunk.episodeNumber ?? '',
episodeTitle: chunk.episodeTitle ?? '',
finalScript: chunk.finalScript ?? '',
tags: chunk.tags ?? [],
}; // already new format
}
return {
...chunk,
observation: chunk.notes ?? '',
interpretation: '',
application: '',
crossReferences: [],
tags: [],
spilloverEndVerse: null,
generalNotes: '',
episodeNumber: '',
episodeTitle: '',
finalScript: '',
notes: undefined,
};
}
function chunkSpansNextChapter(project, startChapterIndex, chunk) {
if (!project || !Array.isArray(project.chapters)) return false;
if (!Number.isInteger(chunk?.spilloverEndVerse)) return false;
if (startChapterIndex < 0 || startChapterIndex >= project.chapters.length - 1) return false;
const startChapter = project.chapters[startChapterIndex];
const nextChapter = project.chapters[startChapterIndex + 1];
return startChapter?.bookAbbrev && startChapter.bookAbbrev === nextChapter?.bookAbbrev;
}
function formatChunkReference(project, startChapterIndex, chunk, separator = '-') {
if (!project || !chunk) return '';
const startChapter = project.chapters?.[startChapterIndex];
if (!startChapter) return '';
if (chunkSpansNextChapter(project, startChapterIndex, chunk)) {
const nextChapter = project.chapters[startChapterIndex + 1];
return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${nextChapter.chapter}:${chunk.spilloverEndVerse}`;
}
if (chunk.startVerse === chunk.endVerse) {
return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}`;
}
return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${chunk.endVerse}`;
}
function getChunkVerseEntries(project, startChapterIndex, chunk) {
if (!project || !chunk) return [];
const startChapter = project.chapters?.[startChapterIndex];
if (!startChapter) return [];
const startVerses = (startChapter.verses ?? [])
.filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse)
.map((verse) => ({
book: startChapter.book,
chapter: startChapter.chapter,
...verse,
}));
if (!chunkSpansNextChapter(project, startChapterIndex, chunk)) {
return startVerses;
}
const nextChapter = project.chapters[startChapterIndex + 1];
const spilloverVerses = (nextChapter.verses ?? [])
.filter((verse) => verse.number >= 1 && verse.number <= chunk.spilloverEndVerse)
.map((verse) => ({
book: nextChapter.book,
chapter: nextChapter.chapter,
...verse,
}));
return [...startVerses, ...spilloverVerses];
}
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
// ---------------------------------------------------------------------------
// Pre-multi-user, un-namespaced keys. Kept around only so existing local data
// (from before accounts existed) can be claimed by the first user who signs in
// on a given browser — see switchStorageUser() below.
const INDEX_KEY_LEGACY = 'bible-study-index';
const projectKeyLegacy = (id) => `bible-study-project-${id}`;
// The signed-in user's id, or null when signed out / not yet resolved.
// Sets which localStorage namespace loadProjectIndex/saveProjectToStorage/etc. read
// and write, so two accounts sharing one browser never see each other's cached projects.
let activeStorageUserId = null;
function indexKey() {
return activeStorageUserId ? `bible-study-index:${activeStorageUserId}` : INDEX_KEY_LEGACY;
}
function projectKey(id) {
return activeStorageUserId ? `bible-study-project:${activeStorageUserId}:${id}` : projectKeyLegacy(id);
}
/**
* Moves any pre-multi-user local project data into this user's own namespace,
* then deletes the shared legacy keys so no other account can claim them afterward.
* No-op if this user already has their own namespaced index, or if there's nothing legacy to claim.
*/
function migrateLegacyLocalDataToUser(userId) {
if (!userId || window.localStorage.getItem(`bible-study-index:${userId}`)) return;
const legacyRaw = window.localStorage.getItem(INDEX_KEY_LEGACY);
if (!legacyRaw) return;
try {
const legacyIndex = JSON.parse(legacyRaw) ?? [];
legacyIndex.forEach((entry) => {
const raw = window.localStorage.getItem(projectKeyLegacy(entry.id));
if (raw != null) {
window.localStorage.setItem(`bible-study-project:${userId}:${entry.id}`, raw);
window.localStorage.removeItem(projectKeyLegacy(entry.id));
}
});
window.localStorage.setItem(`bible-study-index:${userId}`, legacyRaw);
window.localStorage.removeItem(INDEX_KEY_LEGACY);
} catch {
// Leave legacy data in place if anything goes wrong — better to re-try next login than lose it.
}
}
/** Switches the active local-storage namespace and returns the freshly loaded index for it. */
function switchStorageUser(userId) {
activeStorageUserId = userId ?? null;
if (userId) migrateLegacyLocalDataToUser(userId);
return loadProjectIndex();
}
function loadProjectIndex() {
try {
const raw = window.localStorage.getItem(indexKey());
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
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(', ');
}
function formatRelativeDate(ts) {
if (!ts) return '';
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
// ---------------------------------------------------------------------------
// Export / prompt builders (exported for testing)
// ---------------------------------------------------------------------------
export function buildExportHtml(project) {
const style = `
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 32px; }
.page { max-width: 900px; margin: auto; }
h1, h2 { font-family: Georgia, serif; }
h1 { margin-bottom: 0.5rem; }
.chapter-heading { margin: 2rem 0 0.5rem; font-size: 1.2rem; font-weight: 700; border-bottom: 1px solid #cbd5e1; padding-bottom: 0.25rem; }
.chunk { margin-bottom: 2rem; padding: 1.25rem 1.5rem; border: 1px solid #cbd5e1; border-radius: 0.75rem; background: #ffffff; }
.verse { margin: 0 0 0.75rem; line-height: 1.7; }
.scripture-ref { font-weight: 700; margin-bottom: 0.75rem; }
.oia, .cross-refs, .greek { margin-top: 1rem; }
.oia-section { margin-bottom: 0.75rem; }
.greek table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
.greek th, .greek td { border: 1px solid #d1d5db; padding: 0.65rem; text-align: left; }
.greek th { background: #f8fafc; }
.definition { margin-top: 0.75rem; font-size: 0.95rem; line-height: 1.6; }
.definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; }
@media print {
body { padding: 0; }
.chunk { break-inside: avoid; border-color: #cbd5e1; }
.chapter-heading { break-after: avoid; }
}
`;
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
const chunksHtml = chapters.map((ch, chapterIndex) => {
const chapterHeader = `
${ch.book} ${ch.chapter} `;
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('');
const observation = (chunk.observation ?? '').trim().replace(/\n/g, ' ') || 'No observation. ';
const interpretation = (chunk.interpretation ?? '').trim().replace(/\n/g, ' ') || 'No interpretation. ';
const application = (chunk.application ?? '').trim().replace(/\n/g, ' ') || 'No application. ';
const crossRefsHtml = (chunk.crossReferences ?? []).length > 0
? `CROSS-REFERENCES: ${chunk.crossReferences.join(', ')}
`
: '';
const greekRows = chunk.greekWords.map((word) => `
${word.strongNumber}
${word.lexeme || ''}
${word.transliteration || ''}
${word.partOfSpeech || ''}
${word.shortDefinition || ''}
`).join('');
const greekTable = wordTableHtml(greekRows);
const extendedDefinitions = chunk.greekWords
.map((word) => {
if (word.definitionHtml) {
return `
${word.strongNumber} — ${word.lexeme || ''}
${word.definitionHtml}
`;
}
if ((word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query) {
const raw = word.query.trim();
const normalized = /^\d+$/.test(raw) ? `G${raw}` : raw.toUpperCase();
const isStrongs = /^G\d+$/.test(normalized);
const num = isStrongs ? normalized.slice(1) : null;
const links = isStrongs && num
? `BibleHub · Blue Letter Bible · StudyLight `
: `BibleHub · Blue Letter Bible `;
return `
${raw} — definition not found in auto-lookup
Look it up manually: ${links}
`;
}
return '';
})
.join('');
return `
${scripture}
${versesText}
OBSERVATION: ${observation}
INTERPRETATION: ${interpretation}
APPLICATION: ${application}
${crossRefsHtml}
GREEK WORDS:
${greekRows ? greekTable : '
No Greek word notes.
'}
${extendedDefinitions}
`;
}).join('');
return chapterHeader + chunkSections;
}).join('');
return `
${project.title}
${project.title}
${project.translation}
${chunksHtml}
`;
}
export function buildMarkdownExport(project) {
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
const chapterSections = chapters.map((ch, chapterIndex) => {
const chunkSections = ch.chunks.map((chunk) => {
const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
.map((verse) => `> **${verse.chapter}:${verse.number}** ${verse.text}`)
.join('\n>\n');
const observation = (chunk.observation ?? '').trim() || '_No observation._';
const interpretation = (chunk.interpretation ?? '').trim() || '_No interpretation._';
const application = (chunk.application ?? '').trim() || '_No application._';
const generalNotes = (chunk.generalNotes ?? '').trim();
const crossRefsLine = (chunk.crossReferences ?? []).length > 0
? `**Cross-References:** ${chunk.crossReferences.join(', ')}\n\n`
: '';
const greekSection = chunk.greekWords.length === 0
? ''
: `**Greek/Hebrew Words**\n\n${chunk.greekWords.map((word) => {
const summary = [
word.strongNumber,
word.lexeme,
word.transliteration && `(${word.transliteration})`,
word.partOfSpeech,
word.shortDefinition,
].filter(Boolean).join(' — ');
const definition = word.definitionHtml ? `\n ${htmlToPlainText(word.definitionHtml)}` : '';
return `- ${summary}${definition}`;
}).join('\n')}\n\n`;
return `### ${scripture}\n\n${versesText}\n\n${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
}).join('---\n\n');
return `## ${ch.book} ${ch.chapter}\n\n${chunkSections}`;
}).join('\n');
return `# ${project.title}\n\n*${project.translation}*\n\n${chapterSections}`;
}
export function wordTableHtml(rows) {
return `
Strong's
Greek
Transliteration
Part of Speech
Short Definition
${rows}
`;
}
export function extractPartOfSpeech(definitionHtml) {
const match = /Part\(s\) of speech:\s*([^<]+)/i.exec(definitionHtml || '');
if (match && match[1]) {
return match[1].trim();
}
return '';
}
export function htmlToPlainText(html = '') {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const lines = [];
doc.body.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
lines.push(node.textContent || '');
return;
}
if (node.nodeName === 'BR' || node.nodeName === 'P') {
lines.push(node.textContent || '');
lines.push('\n');
return;
}
lines.push(node.textContent || '');
});
return lines.join('').replace(/\n{2,}/g, '\n').trim();
}
export function createParagraphsFromText(text) {
return text
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => new Paragraph({ children: [new TextRun({ text: line })] }));
}
const DOCX_ACCENT = 'C9A84C'; // antique gold
const DOCX_HEADING_FONT = 'Cormorant Garamond';
const DOCX_BODY_FONT = 'Lora';
export function sectionLabel(text) {
return new Paragraph({
spacing: { before: 240, after: 80 },
children: [
new TextRun({
text: text.toUpperCase(),
bold: true,
color: DOCX_ACCENT,
font: DOCX_BODY_FONT,
size: 20,
characterSpacing: 20,
}),
],
});
}
export function renderVerseContent(content) {
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content.map(renderVerseContent).join('');
}
if (content && typeof content === 'object') {
if (content.lineBreak) {
return '\n';
}
if (content.type === 'line_break') {
return '\n';
}
if (Array.isArray(content.content)) {
return renderVerseContent(content.content);
}
if (typeof content.text === 'string') {
return content.text;
}
return '';
}
return '';
}
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 buildPronunciationGuide(project) {
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
const lines = [];
chapters.forEach((ch) => {
ch.chunks.forEach((chunk) => {
(chunk.greekWords ?? []).forEach((word) => {
const gloss = word.shortDefinition || word.englishGloss || '';
const lexeme = word.lexeme || '';
const translit = word.transliteration || '';
if (!lexeme && !translit) return;
const label = gloss ? `${gloss} — ` : '';
lines.push(`${label}${lexeme}${translit ? ` (${translit})` : ''}`);
});
});
});
if (lines.length === 0) return 'No Greek/Hebrew words collected yet.';
return lines.join('\n');
}
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);
}
export function parseBibleChapter(data) {
if (data?.verses && Array.isArray(data.verses)) {
return data.verses.map((verse) => ({
number: verse.number,
text: verse.text,
}));
}
const chapterContents = data?.chapter?.content;
if (!Array.isArray(chapterContents)) {
return [];
}
return chapterContents
.filter((item) => item?.type === 'verse')
.map((item) => ({
number: item.number,
text: renderVerseContent(item.content).replace(/\s+/g, ' ').trim(),
}));
}
// 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));
}
// Small, unambiguous icons for the reader's verse actions (replaces emoji, which
// don't reliably convey "bookmark this" vs. "already bookmarked" at a glance).
function BookmarkIcon({ filled }) {
return (
);
}
function CopyIcon() {
return (
);
}
// A cross-reference chip that fetches and shows the referenced verse text on hover
function CrossRefChip({ label, onRemove, loadVerseText }) {
const [hovered, setHovered] = useState(false);
const [verseText, setVerseText] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
if (!hovered || verseText || error) return;
let cancelled = false;
loadVerseText(label)
.then((text) => { if (!cancelled) setVerseText(text || 'Verse text unavailable.'); })
.catch(() => { if (!cancelled) setError('Verse text unavailable.'); });
return () => { cancelled = true; };
}, [hovered]);
return (
setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{label}
{onRemove && (
×
)}
{hovered && (
{error || verseText || 'Loading...'}
)}
);
}
// ---------------------------------------------------------------------------
// App component
// ---------------------------------------------------------------------------
const App = () => {
const [availableTranslations, setAvailableTranslations] = useState(['BSB']);
const [setup, setSetup] = useState({
translation: 'BSB',
book: 'Titus',
bookAbbrev: 'TIT',
chapter: '1',
title: 'Titus 1 Study',
});
const [titleEdited, setTitleEdited] = useState(false);
// authUser: undefined = still checking · null = signed out · object = signed in
const [authUser, setAuthUser] = useState(undefined);
const [authServerDown, setAuthServerDown] = useState(false);
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register'
const [authForm, setAuthForm] = useState({ email: '', password: '' });
const [authError, setAuthError] = useState('');
const [authBusy, setAuthBusy] = useState(false);
const [authMfaPending, setAuthMfaPending] = useState(false); // password ok, waiting on TOTP/backup code
const [authMfaCode, setAuthMfaCode] = useState('');
const [authMfaUseBackup, setAuthMfaUseBackup] = useState(false);
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
const [mfaSetupCode, setMfaSetupCode] = useState('');
const [mfaSetupError, setMfaSetupError] = useState('');
const [mfaSetupBusy, setMfaSetupBusy] = useState(false);
const [mfaBackupCodes, setMfaBackupCodes] = useState(null); // shown once, right after enabling
const [mfaDisablePassword, setMfaDisablePassword] = useState('');
const [mfaDisableError, setMfaDisableError] = useState('');
const [podcastNameInput, setPodcastNameInput] = useState('');
const [podcastNameSaving, setPodcastNameSaving] = useState(false);
const [podcastNameSaved, setPodcastNameSaved] = useState(false);
const [changePasswordForm, setChangePasswordForm] = useState({ current: '', next: '', confirm: '' });
const [changePasswordBusy, setChangePasswordBusy] = useState(false);
const [changePasswordError, setChangePasswordError] = useState('');
const [changePasswordSaved, setChangePasswordSaved] = useState(false);
const [exportMenuOpen, setExportMenuOpen] = useState(false);
// Read-only share links
const [shareToken, setShareToken] = useState(null);
const [sharePanelOpen, setSharePanelOpen] = useState(false);
const [shareBusy, setShareBusy] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
const [sharedViewToken] = useState(() => new URLSearchParams(window.location.search).get('share'));
const [sharedProject, setSharedProject] = useState(null);
const [sharedError, setSharedError] = useState('');
// Admin (only reachable/rendered when authUser.isAdmin)
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects'
const [adminUsers, setAdminUsers] = useState([]);
const [adminProjects, setAdminProjects] = useState([]);
const [adminLoading, setAdminLoading] = useState(false);
const [adminError, setAdminError] = useState('');
const [adminViewProject, setAdminViewProject] = useState(null); // full project data being previewed
const [adminResetResult, setAdminResetResult] = useState(null); // { email, temporaryPassword } shown once
const [project, setProject] = useState(null);
// 'home' | 'setup' | 'study' | 'settings'
const [currentPage, setCurrentPage] = useState('home');
const [projectIndex, setProjectIndex] = useState([]);
const [activeChapterIndex, setActiveChapterIndex] = useState(0);
const [showAddChapterForm, setShowAddChapterForm] = useState(false);
const [crossRefInput, setCrossRefInput] = useState('');
const [saveStatus, setSaveStatus] = useState('');
const [rangeStart, setRangeStart] = useState(null);
const [rangeEnd, setRangeEnd] = useState(null);
const [loadingChapter, setLoadingChapter] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [typedChunkStart, setTypedChunkStart] = useState('');
const [typedChunkEnd, setTypedChunkEnd] = useState('');
const [typedChunkNextEnd, setTypedChunkNextEnd] = useState('');
const [clickedSpanNextEnd, setClickedSpanNextEnd] = useState('');
const [typedChunkBulk, setTypedChunkBulk] = useState('');
const saveTimerRef = useRef(null);
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
const [suggestingGreekForChunkId, setSuggestingGreekForChunkId] = useState(null);
const [suggestingHebrewForChunkId, setSuggestingHebrewForChunkId] = useState(null);
// suggestModal: null | { chunkId, words: [{ strongKey, lexeme, translit, def }] }
const [suggestModal, setSuggestModal] = useState(null);
const [suggestSelection, setSuggestSelection] = useState(new Set());
const [homeSearch, setHomeSearch] = useState('');
const [homeSort, setHomeSort] = useState('recent'); // 'recent' | 'title' | 'passage'
const [homeTagFilter, setHomeTagFilter] = useState('');
const [renamingId, setRenamingId] = useState(null);
const [renameValue, setRenameValue] = useState('');
const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev);
const [importTranslation, setImportTranslation] = useState('BSB');
const [importTitle, setImportTitle] = useState('');
const [importFile, setImportFile] = useState(null);
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 [audioBook, setAudioBook] = useState(bookOptions[0].abbrev);
const [audioNarrator, setAudioNarrator] = useState('souer');
const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 });
const [readerAudioState, setReaderAudioState] = useState({ status: 'idle', chapter: 0 });
const audioRef = useRef(null);
const audioModeRef = useRef('book'); // 'book' | 'reader' — which player owns audioRef
const audioBookRef = useRef(audioBook);
const audioNarratorRef = useRef(audioNarrator);
const audioStateRef = useRef(audioState);
audioBookRef.current = audioBook;
audioNarratorRef.current = audioNarrator;
audioStateRef.current = audioState;
const playAudioChapter = async (abbrev, chapterNum, narrator) => {
const res = await fetch(`https://bible.helloao.org/api/BSB/${abbrev}/${chapterNum}.json`);
if (!res.ok) throw new Error('Failed to load chapter audio');
const data = await res.json();
const total = data.book?.numberOfChapters ?? chapterNum;
const url = data.thisChapterAudioLinks?.[narrator];
if (!url) throw new Error('Audio not available for this narrator');
if (!audioRef.current) {
audioRef.current = new Audio();
audioRef.current.addEventListener('ended', () => {
if (audioModeRef.current === 'reader') {
setReaderAudioState({ status: 'idle', chapter: 0 });
return;
}
const nextChapter = audioStateRef.current.chapter + 1;
if (nextChapter > audioStateRef.current.total) {
setAudioState({ status: 'idle', chapter: 0, total: 0 });
return;
}
playAudioChapter(audioBookRef.current, nextChapter, audioNarratorRef.current).catch(() =>
setAudioState({ status: 'error', chapter: 0, total: 0 }),
);
});
}
const audioEl = audioRef.current;
audioEl.src = url;
await audioEl.play();
if (audioModeRef.current === 'reader') {
setReaderAudioState({ status: 'playing', chapter: chapterNum });
} else {
setAudioState({ status: 'playing', chapter: chapterNum, total });
}
};
const handlePlayBookAudio = async () => {
audioModeRef.current = 'book';
try {
await playAudioChapter(audioBook, 1, audioNarrator);
} catch {
setAudioState({ status: 'error', chapter: 0, total: 0 });
}
};
const handleStopBookAudio = () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
setAudioState({ status: 'idle', chapter: 0, total: 0 });
setReaderAudioState({ status: 'idle', chapter: 0 });
};
const handleToggleBookAudioPause = () => {
if (!audioRef.current) return;
if (audioRef.current.paused) {
audioRef.current.play();
setAudioState((prev) => ({ ...prev, status: 'playing' }));
} else {
audioRef.current.pause();
setAudioState((prev) => ({ ...prev, status: 'paused' }));
}
};
const handlePlayReaderAudio = async () => {
audioModeRef.current = 'reader';
try {
await playAudioChapter(readerBookAbbrev, readerChapter, audioNarrator);
} catch {
setReaderAudioState({ status: 'error', chapter: 0 });
}
};
const handleToggleReaderAudioPause = () => {
if (!audioRef.current) return;
if (audioRef.current.paused) {
audioRef.current.play();
setReaderAudioState((prev) => ({ ...prev, status: 'playing' }));
} else {
audioRef.current.pause();
setReaderAudioState((prev) => ({ ...prev, status: 'paused' }));
}
};
// Auto-switch the reader's audio to match the book/chapter currently being read.
useEffect(() => {
if (audioModeRef.current !== 'reader') return;
if (readerAudioState.status !== 'playing' && readerAudioState.status !== 'paused') return;
if (readerAudioState.chapter === readerChapter) return;
const wasPaused = readerAudioState.status === 'paused';
playAudioChapter(readerBookAbbrev, readerChapter, audioNarrator)
.then(() => {
if (wasPaused && audioRef.current) {
audioRef.current.pause();
setReaderAudioState((prev) => ({ ...prev, status: 'paused' }));
}
})
.catch(() => setReaderAudioState({ status: 'error', chapter: 0 }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readerBookAbbrev, readerChapter]);
useEffect(() => () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
}, []);
const [studyLayout, setStudyLayout] = useState(
() => localStorage.getItem('studyLayout') || 'stacked',
); // 'stacked' | 'split'
const [activeStudyTab, setActiveStudyTab] = useState(
() => localStorage.getItem('activeStudyTab') || 'notes',
);
const [mobileStudyTab, setMobileStudyTab] = useState('scripture');
useEffect(() => {
localStorage.setItem('studyLayout', studyLayout);
}, [studyLayout]);
useEffect(() => {
localStorage.setItem('activeStudyTab', activeStudyTab);
}, [activeStudyTab]);
const [verseSearch, setVerseSearch] = useState('');
const [collapsedSections, setCollapsedSections] = useState({});
const [commentarySource, setCommentarySource] = useState('matthew-henry');
const [commentaryData, setCommentaryData] = useState(null); // { content: [...] } for current chapter
const [commentaryLoading, setCommentaryLoading] = useState(false);
const [commentaryError, setCommentaryError] = useState('');
const _commentaryCacheRef = useRef({});
const [interlinearData, setInterlinearData] = useState(null);
const [interlinearLoading, setInterlinearLoading] = useState(false);
const [interlinearError, setInterlinearError] = useState('');
const _interlinearCacheRef = useRef({});
const loadInterlinearChapter = async (bookAbbrev, chapterNumber) => {
if (_interlinearCacheRef.current[bookAbbrev]) return _interlinearCacheRef.current[bookAbbrev];
const res = await fetch(`/interlinear/${bookAbbrev}.json`);
if (!res.ok) throw new Error('No interlinear data for this book.');
const data = await res.json();
_interlinearCacheRef.current[bookAbbrev] = data;
return data;
};
const loadCommentaryChapter = async (commentaryId, bookAbbrev, chapterNumber) => {
const cacheKey = `${commentaryId}/${bookAbbrev}/${chapterNumber}`;
if (_commentaryCacheRef.current[cacheKey]) return _commentaryCacheRef.current[cacheKey];
const res = await fetch(`https://bible.helloao.org/api/c/${commentaryId}/${bookAbbrev}/${chapterNumber}.json`);
const contentType = res.headers.get('content-type') || '';
if (!res.ok || !contentType.includes('json')) throw new Error(`No ${commentaryId} commentary for this chapter.`);
const data = await res.json();
_commentaryCacheRef.current[cacheKey] = data;
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 {
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');
} 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();
const lang = strongsNum?.startsWith('H') ? 'he-IL' : 'el-GR';
const voices = window.speechSynthesis.getVoices();
const match = voices.find((v) => v.lang.startsWith(lang.split('-')[0]));
const utt = new SpeechSynthesisUtterance(word + '.');
utt.lang = lang;
utt.rate = 0.85;
if (match) utt.voice = match;
// Chain a silent tail so the audio session fades rather than clicks shut
utt.onend = () => {
const tail = new SpeechSynthesisUtterance(' ');
tail.volume = 0;
tail.lang = lang;
if (match) tail.voice = match;
window.speechSynthesis.speak(tail);
};
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 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 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);
};
// ---------------------------------------------------------------------------
// Startup: migrate old keys and load index
// ---------------------------------------------------------------------------
useEffect(() => {
migrateOldStorageKeys();
setProjectIndex(loadProjectIndex());
getCurrentUser().then((result) => {
if (result.ok) {
setAuthUser(result.user);
if (result.user) setProjectIndex(switchStorageUser(result.user.id));
} else {
// Genuine network failure (server unreachable) — fall back to local-only mode.
setAuthServerDown(true);
setAuthUser(null);
}
});
}, []);
// Keep the Settings page's podcast-name field in sync with whichever account is signed in.
useEffect(() => {
setPodcastNameInput(authUser?.podcastName ?? '');
}, [authUser?.id]);
// A ?share=TOKEN URL loads a read-only view of someone else's project — no
// login required, so this runs independent of auth state entirely.
useEffect(() => {
if (!sharedViewToken) return;
getSharedProject(sharedViewToken).then((result) => {
if (result.ok) {
setSharedProject(result.data);
} else {
setSharedError(result.error ?? 'This share link is invalid or has been revoked.');
}
});
}, [sharedViewToken]);
// Loads current share status whenever the study page's project changes.
useEffect(() => {
if (!project?.id || currentPage !== 'study') return;
setShareToken(null);
getShareStatus(project.id).then((result) => {
if (result.ok) setShareToken(result.data.shareToken);
});
}, [project?.id, currentPage]);
const loadAdminData = () => {
setAdminLoading(true);
setAdminError('');
Promise.all([adminListUsers(), adminListProjects()]).then(([usersResult, projectsResult]) => {
setAdminLoading(false);
if (!usersResult.ok || !projectsResult.ok) {
setAdminError(usersResult.error ?? projectsResult.error ?? 'Failed to load admin data.');
return;
}
setAdminUsers(usersResult.data);
setAdminProjects(projectsResult.data);
});
};
useEffect(() => {
if (currentPage === 'admin' && authUser?.isAdmin) loadAdminData();
}, [currentPage, authUser?.isAdmin]);
// Once we know who's signed in, reconcile the local project index against the server.
// Runs on every authUser change (including logout -> different login) so a previous
// account's stale suggestions never linger after switching users. Projects that exist
// on the server but not on this device (e.g. a brand new browser) are pulled down
// automatically instead of requiring a manual "Restore" click per project.
useEffect(() => {
if (!authUser) {
setStaleLocalProjects([]);
return;
}
let cancelled = false;
const localIndex = loadProjectIndex();
listRemoteProjects().then(async (result) => {
if (!result.ok || cancelled) return;
const localMap = new Map(localIndex.map((e) => [e.id, e]));
const missing = result.data.filter((e) => !localMap.has(e.id));
if (missing.length > 0) {
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
fetched.filter((r) => r.ok).forEach((r) => saveProjectToStorage(r.data));
if (cancelled) return;
const restoredCount = fetched.filter((r) => r.ok).length;
if (restoredCount > 0) {
setProjectIndex(loadProjectIndex());
setAutoRestoredCount(restoredCount);
window.setTimeout(() => setAutoRestoredCount(null), 4000);
}
}
const stale = result.data.filter((e) => {
const local = localMap.get(e.id);
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0);
});
if (!cancelled) setStaleLocalProjects(stale);
});
return () => { cancelled = true; };
}, [authUser]);
useEffect(() => {
fetch('https://bible.helloao.org/api/available_translations.json')
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data) && data.length > 0) {
setAvailableTranslations(data);
}
})
.catch(() => {});
}, []);
useEffect(() => {
if (!titleEdited) {
setSetup((current) => ({
...current,
title: `${current.book} ${current.chapter} Study`,
}));
}
}, [setup.book, setup.chapter, titleEdited]);
// Reset cross-ref input when selected chunk changes
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
const activeChapter = project?.chapters[activeChapterIndex] ?? null;
const selectedChunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
const selectedChunkChapterIndex = selectedChunk
? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === selectedChunk.id))
: -1;
useEffect(() => {
if (collapsedSections.commentary) return;
if (selectedChunkChapterIndex < 0) return;
const chapter = project.chapters[selectedChunkChapterIndex];
setCommentaryLoading(true);
setCommentaryError('');
loadCommentaryChapter(commentarySource, chapter.bookAbbrev, chapter.chapter)
.then((data) => setCommentaryData(data))
.catch((err) => {
setCommentaryData(null);
setCommentaryError(err.message);
})
.finally(() => setCommentaryLoading(false));
}, [commentarySource, selectedChunk?.id, selectedChunkChapterIndex, collapsedSections.commentary]);
useEffect(() => {
if (collapsedSections.interlinear) return;
if (selectedChunkChapterIndex < 0) return;
const chapter = project.chapters[selectedChunkChapterIndex];
setInterlinearLoading(true);
setInterlinearError('');
loadInterlinearChapter(chapter.bookAbbrev, chapter.chapter)
.then((data) => setInterlinearData(data?.[String(chapter.chapter)] ?? null))
.catch((err) => {
setInterlinearData(null);
setInterlinearError(err.message);
})
.finally(() => setInterlinearLoading(false));
}, [selectedChunk?.id, selectedChunkChapterIndex, collapsedSections.interlinear]);
useEffect(() => {
setCrossRefSuggestions([]);
setTagInput('');
}, [selectedChunk?.id]);
const selectedChunkChapter = selectedChunkChapterIndex >= 0
? project.chapters[selectedChunkChapterIndex]
: null;
const selectedChunkVerses = selectedChunk
? getChunkVerseEntries(project, selectedChunkChapterIndex, selectedChunk)
: [];
const selectedChunkGlobalIndex = allChunks.findIndex((c) => c.id === project?.selectedChunkId);
useEffect(() => {
setCrossRefInput('');
}, [project?.selectedChunkId]);
// Keyboard shortcuts: arrow keys navigate chunks on the study page, Esc closes modals
useEffect(() => {
const handleKeyDown = (event) => {
const tag = event.target?.tagName;
const isEditable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable;
if (event.key === 'Escape') {
if (suggestModal) {
setSuggestModal(null);
setSuggestSelection(new Set());
}
return;
}
if (currentPage !== 'study' || isEditable || suggestModal) return;
if (event.key === 'ArrowLeft') {
event.preventDefault();
goToPreviousChunk();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
goToNextChunk();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentPage, suggestModal, selectedChunkGlobalIndex, allChunks]);
// Autosave
useEffect(() => {
if (!project) return;
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(async () => {
// Stamp lastEdited once so localStorage and server receive the same value
const toSave = { ...project, lastEdited: Date.now() };
// 1. Save locally
saveProjectToStorage(toSave);
setProjectIndex(loadProjectIndex());
setSaveStatus('Saved');
window.setTimeout(() => setSaveStatus(''), 1400);
// 2. Sync to server with the same timestamped object
setSyncStatus('syncing');
const result = await saveRemoteProject(toSave);
setSyncStatus(result.ok ? 'synced' : 'error');
// On success clear after 2.5s; on error keep visible until next successful sync
if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500);
}, 1000);
return () => {
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
};
}, [project]);
// ---------------------------------------------------------------------------
// Setup helpers
// ---------------------------------------------------------------------------
const handleSetupField = (field, value) => {
if (field === 'book') {
const selected = bookOptions.find((book) => book.abbrev === value) ?? bookOptions[0];
setSetup((current) => ({
...current,
book: selected.name,
bookAbbrev: selected.abbrev,
}));
} else {
setSetup((current) => ({ ...current, [field]: value }));
}
};
const handleLoadChapter = async () => {
if (!setup.bookAbbrev || !setup.chapter) {
setErrorMessage('Please choose a book and chapter.');
return;
}
setErrorMessage('');
setLoadingChapter(true);
try {
const response = await fetch(
`https://bible.helloao.org/api/${setup.translation}/${setup.bookAbbrev}/${setup.chapter}.json`,
);
if (!response.ok) throw new Error('Unable to load chapter.');
const data = await response.json();
const verses = parseBibleChapter(data);
if (!data || !Array.isArray(verses) || verses.length === 0) {
throw new Error('Invalid Bible data returned.');
}
if (project) {
// Adding a chapter to an existing project
const alreadyExists = project.chapters.some(
(ch) => ch.bookAbbrev === setup.bookAbbrev && ch.chapter === setup.chapter,
);
if (alreadyExists) {
setErrorMessage('That chapter is already in this project.');
setLoadingChapter(false);
return;
}
const newChapter = {
book: setup.book,
bookAbbrev: setup.bookAbbrev,
chapter: setup.chapter,
verses,
chunks: [],
};
updateProject((current) => {
const updatedChapters = [...current.chapters, newChapter];
return { ...current, chapters: updatedChapters };
});
setActiveChapterIndex(project.chapters.length);
setShowAddChapterForm(false);
setErrorMessage('');
} else {
// Creating a brand-new project
const newProject = {
id: makeId(),
title: setup.title,
translation: setup.translation,
lastEdited: Date.now(),
selectedChunkId: null,
chapters: [
{
book: setup.book,
bookAbbrev: setup.bookAbbrev,
chapter: setup.chapter,
verses,
chunks: [],
},
],
};
setProject(newProject);
setActiveChapterIndex(0);
setCurrentPage('setup');
}
} catch (error) {
setErrorMessage(error.message || 'Failed to load chapter.');
} finally {
setLoadingChapter(false);
}
};
const updateProject = (updater) => {
setProject((current) => (current ? updater(current) : current));
};
const beginStudying = () => {
if (!project || allChunks.length === 0) return;
updateProject((current) => ({
...current,
selectedChunkId: current.selectedChunkId || allChunks[0]?.id || null,
}));
setCurrentPage('study');
};
// ---------------------------------------------------------------------------
// Chunk navigation
// ---------------------------------------------------------------------------
const goToPreviousChunk = () => {
if (selectedChunkGlobalIndex <= 0) return;
updateProject((current) => ({
...current,
selectedChunkId: allChunks[selectedChunkGlobalIndex - 1].id,
}));
};
const goToNextChunk = () => {
if (selectedChunkGlobalIndex < 0 || selectedChunkGlobalIndex >= allChunks.length - 1) return;
updateProject((current) => ({
...current,
selectedChunkId: allChunks[selectedChunkGlobalIndex + 1].id,
}));
};
// ---------------------------------------------------------------------------
// Chunk CRUD (operates on activeChapter in setup view, on selectedChunkChapter in study)
// ---------------------------------------------------------------------------
const addChunkToChapter = (chapterIndex, start, end, spilloverEndVerse = null) => {
updateProject((current) => {
const chapters = current.chapters.map((ch, idx) => {
if (idx !== chapterIndex) return ch;
const overlap = ch.chunks.find(
(c) => c.startVerse === start
&& c.endVerse === end
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
);
if (overlap) return ch;
const newChunk = {
id: makeId(),
startVerse: start,
endVerse: end,
spilloverEndVerse,
observation: '',
interpretation: '',
application: '',
crossReferences: [],
greekWords: [],
generalNotes: '',
episodeNumber: '',
episodeTitle: '',
finalScript: '',
tags: [],
};
return { ...ch, chunks: [...ch.chunks, newChunk] };
});
// Find the newly added chunk id
const newChunk = chapters[chapterIndex].chunks.at(-1);
return { ...current, chapters, selectedChunkId: newChunk?.id ?? current.selectedChunkId };
});
};
const addChunk = (start, end, spilloverEndVerse = null) => {
if (!project) return;
const chapter = activeChapter;
if (!chapter) return;
const overlap = chapter.chunks.find(
(c) => c.startVerse === start
&& c.endVerse === end
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
);
if (overlap) {
setStatusMessage('That chunk already exists.');
window.setTimeout(() => setStatusMessage(''), 1800);
return;
}
addChunkToChapter(activeChapterIndex, start, end, spilloverEndVerse);
};
const addChunkRanges = (ranges) => {
if (!project || !activeChapter || ranges.length === 0) {
return { addedCount: 0, skippedCount: 0 };
}
let addedCount = 0;
let skippedCount = 0;
let lastAddedChunkId = null;
updateProject((current) => {
const chapters = current.chapters.map((ch, idx) => {
if (idx !== activeChapterIndex) return ch;
const existing = new Set(
ch.chunks.map((c) => `${c.startVerse}-${c.endVerse}-${c.spilloverEndVerse ?? ''}`),
);
const newChunks = [];
ranges.forEach(({ start, end, spilloverEndVerse = null }) => {
const key = `${start}-${end}-${spilloverEndVerse ?? ''}`;
if (existing.has(key)) {
skippedCount += 1;
return;
}
const newChunk = {
id: makeId(),
startVerse: start,
endVerse: end,
spilloverEndVerse,
observation: '',
interpretation: '',
application: '',
crossReferences: [],
greekWords: [],
tags: [],
};
existing.add(key);
newChunks.push(newChunk);
addedCount += 1;
lastAddedChunkId = newChunk.id;
});
return newChunks.length > 0
? { ...ch, chunks: [...ch.chunks, ...newChunks] }
: ch;
});
return {
...current,
chapters,
selectedChunkId: lastAddedChunkId ?? current.selectedChunkId,
};
});
return { addedCount, skippedCount };
};
const addTypedChunk = () => {
if (!activeChapter) return;
const start = Number.parseInt(typedChunkStart, 10);
const end = Number.parseInt(typedChunkEnd, 10);
const nextEndRaw = typedChunkNextEnd.trim();
const nextEnd = nextEndRaw ? Number.parseInt(nextEndRaw, 10) : null;
const maxVerse = activeChapter.verses.at(-1)?.number ?? 0;
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0;
if (!Number.isInteger(start) || !Number.isInteger(end)) {
setStatusMessage('Type a valid start and end verse.');
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
if (start < 1 || end < 1 || start > maxVerse || end > maxVerse) {
setStatusMessage(`Verse range must be between 1 and ${maxVerse}.`);
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
if (start > end) {
setStatusMessage('Start verse must be less than or equal to end verse.');
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
if (nextEndRaw) {
if (!nextChapter) {
setStatusMessage('Add the next chapter first to span chunks across chapters.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) {
setStatusMessage('Chapter spanning only works into the next chapter of the same book.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
if (!Number.isInteger(nextEnd) || nextEnd < 1 || nextEnd > nextMaxVerse) {
setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`);
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
if (end !== maxVerse) {
setStatusMessage('To span chapters, set End to the last verse of this chapter.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
}
const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse: nextEnd }]);
if (addedCount === 0) {
setStatusMessage('That chunk already exists.');
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
setTypedChunkStart('');
setTypedChunkEnd('');
setTypedChunkNextEnd('');
};
const addBulkTypedChunks = () => {
if (!activeChapter) return;
const raw = typedChunkBulk.trim();
if (!raw) {
setStatusMessage('Type chunk ranges first. Example: 1-6, 7-12');
window.setTimeout(() => setStatusMessage(''), 2200);
return;
}
const maxVerse = activeChapter.verses.at(-1)?.number ?? 0;
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0;
const parts = raw
.split(/[\n,;]+/)
.map((part) => part.trim())
.filter(Boolean);
const validRanges = [];
let invalidCount = 0;
parts.forEach((part) => {
const single = /^(\d+)$/.exec(part);
if (single) {
const v = Number.parseInt(single[1], 10);
if (v >= 1 && v <= maxVerse) {
validRanges.push({ start: v, end: v });
} else {
invalidCount += 1;
}
return;
}
const range = /^(\d+)\s*-\s*(\d+)(?:\s*:\s*(\d+))?$/.exec(part);
if (!range) {
invalidCount += 1;
return;
}
const start = Number.parseInt(range[1], 10);
const end = Number.parseInt(range[2], 10);
const spilloverEndVerse = range[3] ? Number.parseInt(range[3], 10) : null;
if (start < 1 || end < 1 || start > end || start > maxVerse || end > maxVerse) {
invalidCount += 1;
return;
}
if (spilloverEndVerse !== null) {
if (!nextChapter
|| nextChapter.bookAbbrev !== activeChapter.bookAbbrev
|| spilloverEndVerse < 1
|| spilloverEndVerse > nextMaxVerse
|| end !== maxVerse) {
invalidCount += 1;
return;
}
}
validRanges.push({ start, end, spilloverEndVerse });
});
if (validRanges.length === 0) {
setStatusMessage(`No valid ranges found. Use 1-${maxVerse} and format like 1-6, 7-12.`);
window.setTimeout(() => setStatusMessage(''), 2600);
return;
}
const { addedCount, skippedCount } = addChunkRanges(validRanges);
const notes = [];
if (addedCount > 0) notes.push(`Added ${addedCount} chunk${addedCount > 1 ? 's' : ''}`);
if (skippedCount > 0) notes.push(`skipped ${skippedCount} duplicate${skippedCount > 1 ? 's' : ''}`);
if (invalidCount > 0) notes.push(`ignored ${invalidCount} invalid`);
setStatusMessage(notes.join(' • '));
window.setTimeout(() => setStatusMessage(''), 2800);
if (addedCount > 0) {
setTypedChunkBulk('');
}
};
const addClickSpanChunk = () => {
if (!activeChapter || rangeStart === null) {
setStatusMessage('Click a start verse first, then set next chapter end.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
if (!nextChapter) {
setStatusMessage('Add the next chapter first to span into it.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) {
setStatusMessage('Click-based spanning only works into the next chapter of the same book.');
window.setTimeout(() => setStatusMessage(''), 2600);
return;
}
const nextMaxVerse = nextChapter.verses.at(-1)?.number ?? 0;
const spilloverEndVerse = Number.parseInt(clickedSpanNextEnd, 10);
if (!Number.isInteger(spilloverEndVerse) || spilloverEndVerse < 1 || spilloverEndVerse > nextMaxVerse) {
setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`);
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
const start = rangeStart;
const end = activeChapter.verses.at(-1)?.number ?? start;
const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse }]);
if (addedCount === 0) {
setStatusMessage('That cross-chapter chunk already exists.');
window.setTimeout(() => setStatusMessage(''), 2400);
return;
}
setClickedSpanNextEnd('');
setRangeStart(null);
setRangeEnd(null);
setStatusMessage('Cross-chapter chunk added.');
window.setTimeout(() => setStatusMessage(''), 1800);
};
const handleVerseClick = (verseNumber, event) => {
if (event.shiftKey && rangeStart !== null) {
const start = Math.min(rangeStart, verseNumber);
const end = Math.max(rangeStart, verseNumber);
addChunk(start, end);
setRangeStart(null);
setRangeEnd(null);
return;
}
setRangeStart(verseNumber);
setRangeEnd(verseNumber);
};
const updateChunk = (chunkId, patch) => {
updateProject((current) => ({
...current,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch } : c)),
})),
}));
};
const moveChunk = (chunkId, direction) => {
updateProject((current) => ({
...current,
chapters: current.chapters.map((ch) => {
const index = ch.chunks.findIndex((c) => c.id === chunkId);
if (index < 0) return ch;
const nextIndex = index + direction;
if (nextIndex < 0 || nextIndex >= ch.chunks.length) return ch;
const updated = [...ch.chunks];
const [removed] = updated.splice(index, 1);
updated.splice(nextIndex, 0, removed);
return { ...ch, chunks: updated };
}),
}));
};
const deleteChunk = (chunkId) => {
updateProject((current) => {
const newAllChunks = current.chapters
.flatMap((ch) => ch.chunks)
.filter((c) => c.id !== chunkId);
const nextSelected =
current.selectedChunkId === chunkId
? newAllChunks[0]?.id ?? null
: current.selectedChunkId;
return {
...current,
selectedChunkId: nextSelected,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.filter((c) => c.id !== chunkId),
})),
};
});
};
// ---------------------------------------------------------------------------
// Cross-references
// ---------------------------------------------------------------------------
const addCrossRef = (chunkId) => {
const ref = crossRefInput.trim();
if (!ref) return;
updateChunk(chunkId, {
crossReferences: [...(selectedChunk?.crossReferences ?? []), ref],
});
setCrossRefInput('');
};
const removeCrossRef = (chunkId, ref) => {
updateChunk(chunkId, {
crossReferences: (selectedChunk?.crossReferences ?? []).filter((r) => r !== ref),
});
};
const [tagInput, setTagInput] = useState('');
const addTag = (chunkId) => {
const tag = tagInput.trim();
if (!tag) return;
const existing = selectedChunk?.tags ?? [];
if (existing.some((t) => t.toLowerCase() === tag.toLowerCase())) {
setTagInput('');
return;
}
updateChunk(chunkId, { tags: [...existing, tag] });
setTagInput('');
};
const removeTag = (chunkId, tag) => {
updateChunk(chunkId, {
tags: (selectedChunk?.tags ?? []).filter((t) => t !== tag),
});
};
const importFinalScriptDocx = async (chunkId, file) => {
if (!file) return;
try {
const arrayBuffer = await file.arrayBuffer();
const { value } = await mammoth.extractRawText({ arrayBuffer });
updateChunk(chunkId, { finalScript: value.trim() });
setStatusMessage('Imported script from DOCX.');
window.setTimeout(() => setStatusMessage(''), 1800);
} catch (err) {
setStatusMessage('Could not read that DOCX file.');
window.setTimeout(() => setStatusMessage(''), 1800);
}
};
const [suggestingCrossRefs, setSuggestingCrossRefs] = useState(false);
const [crossRefSuggestions, setCrossRefSuggestions] = useState([]);
const _crossRefDatasetCacheRef = useRef({});
const formatCrossRef = (ref) => {
const book = bookOptions.find((b) => b.abbrev === ref.book);
const bookName = book?.name ?? ref.book;
const verseRange = ref.endVerse && ref.endVerse !== ref.verse
? `${ref.verse}-${ref.endVerse}`
: `${ref.verse}`;
return `${bookName} ${ref.chapter}:${verseRange}`;
};
const suggestCrossRefsForChunk = async (chunkId) => {
const chunk = allChunks.find((c) => c.id === chunkId);
const chapterIndex = project?.chapters.findIndex((ch) =>
ch.chunks.some((c) => c.id === chunkId)
) ?? -1;
const chapter = chapterIndex >= 0 ? project.chapters[chapterIndex] : null;
if (!chunk || !chapter) return;
setSuggestingCrossRefs(true);
setCrossRefSuggestions([]);
try {
const cacheKey = `${chapter.bookAbbrev}/${chapter.chapter}`;
let chapterData = _crossRefDatasetCacheRef.current[cacheKey];
if (!chapterData) {
const res = await fetch(`https://bible.helloao.org/api/d/open-cross-ref/${chapter.bookAbbrev}/${chapter.chapter}.json`);
if (!res.ok) throw new Error('No cross-reference data found for this chapter.');
chapterData = await res.json();
_crossRefDatasetCacheRef.current[cacheKey] = chapterData;
}
const verseEntries = (chapterData.chapter?.content ?? []).filter(
(item) => item.verse >= chunk.startVerse
&& item.verse <= chunk.endVerse,
);
const existing = new Set(chunk.crossReferences ?? []);
const seen = new Set();
const refs = [];
for (const v of verseEntries) {
for (const ref of v.references ?? []) {
const formatted = formatCrossRef(ref);
if (existing.has(formatted) || seen.has(formatted)) continue;
seen.add(formatted);
refs.push({ formatted, score: ref.score ?? 0 });
}
}
refs.sort((a, b) => b.score - a.score);
if (refs.length === 0) {
setStatusMessage('No new cross-reference suggestions found for this passage.');
window.setTimeout(() => setStatusMessage(''), 2500);
return;
}
setCrossRefSuggestions(refs.slice(0, 12).map((r) => r.formatted));
} catch (err) {
setStatusMessage(`Cross-reference suggest failed: ${err.message}`);
window.setTimeout(() => setStatusMessage(''), 3000);
} finally {
setSuggestingCrossRefs(false);
}
};
const _verseChapterCacheRef = useRef({});
const loadVerseText = async (refString) => {
const parsed = parseCrossRefString(refString);
if (!parsed) throw new Error('Could not parse reference.');
const translation = project?.translation ?? 'BSB';
const cacheKey = `${translation}/${parsed.bookAbbrev}/${parsed.chapter}`;
let verses = _verseChapterCacheRef.current[cacheKey];
if (!verses) {
const res = await fetch(`https://bible.helloao.org/api/${translation}/${parsed.bookAbbrev}/${parsed.chapter}.json`);
if (!res.ok) throw new Error('Could not load verse text.');
const data = await res.json();
verses = parseBibleChapter(data);
_verseChapterCacheRef.current[cacheKey] = verses;
}
const text = verses
.filter((v) => v.number >= parsed.verse && v.number <= parsed.endVerse)
.map((v) => v.text)
.join(' ');
return text;
};
const addSuggestedCrossRef = (chunkId, ref) => {
updateChunk(chunkId, {
crossReferences: [...(selectedChunk?.crossReferences ?? []), ref],
});
setCrossRefSuggestions((cur) => cur.filter((r) => r !== ref));
};
// ---------------------------------------------------------------------------
// Greek word helpers
// ---------------------------------------------------------------------------
const updateChunkWord = (chunkId, wordId, patch) => {
updateProject((current) => ({
...current,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((chunk) => {
if (chunk.id !== chunkId) return chunk;
return {
...chunk,
greekWords: chunk.greekWords.map((word) =>
word.id === wordId ? { ...word, ...patch } : word,
),
};
}),
})),
}));
};
const addGreekWord = (chunkId) => {
const newWord = {
id: makeId(),
query: '',
strongNumber: '',
englishGloss: '',
lexeme: '',
transliteration: '',
partOfSpeech: '',
shortDefinition: '',
definitionHtml: '',
loading: false,
};
updateProject((current) => ({
...current,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((chunk) =>
chunk.id === chunkId
? { ...chunk, greekWords: [...chunk.greekWords, newWord] }
: chunk,
),
})),
}));
};
const removeGreekWord = (chunkId, wordId) => {
updateProject((current) => ({
...current,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((chunk) =>
chunk.id === chunkId
? { ...chunk, greekWords: chunk.greekWords.filter((w) => w.id !== wordId) }
: chunk,
),
})),
}));
};
// Cache for the NT Strong's concordance (verse → Strong's numbers), loaded once
const _concordanceRef = useRef(null);
const _concordanceLoadingRef = useRef(null);
// Cache for the NT Strong's→English gloss map (from macula-greek dataset), loaded once
const _glossRef = useRef(null);
const _glossLoadingRef = useRef(null);
// Cache for the Hebrew Strong's dictionary (loaded once)
const _hebrewDictRef = useRef(null);
const _hebrewDictLoadingRef = useRef(null);
const loadNtGloss = async () => {
if (_glossRef.current) return _glossRef.current;
if (_glossLoadingRef.current) return _glossLoadingRef.current;
_glossLoadingRef.current = fetch('/nt-strongs-gloss.json')
.then((res) => res.json())
.then((data) => {
_glossRef.current = data;
_glossLoadingRef.current = null;
return data;
});
return _glossLoadingRef.current;
};
const loadNtConcordance = async () => {
if (_concordanceRef.current) return _concordanceRef.current;
if (_concordanceLoadingRef.current) return _concordanceLoadingRef.current;
_concordanceLoadingRef.current = fetch('/nt-strongs-concordance.json')
.then((res) => res.json())
.then((data) => {
_concordanceRef.current = data;
_concordanceLoadingRef.current = null;
return data;
});
return _concordanceLoadingRef.current;
};
const loadHebrewDict = async () => {
if (_hebrewDictRef.current) return _hebrewDictRef.current;
if (_hebrewDictLoadingRef.current) return _hebrewDictLoadingRef.current;
_hebrewDictLoadingRef.current = fetch(
'https://cdn.jsdelivr.net/gh/openscriptures/strongs@master/hebrew/strongs-hebrew-dictionary.js',
)
.then((res) => res.text())
.then((text) => {
const start = text.indexOf('{');
const end = text.lastIndexOf('}') + 1;
const dict = JSON.parse(text.slice(start, end));
_hebrewDictRef.current = dict;
_hebrewDictLoadingRef.current = null;
return dict;
});
return _hebrewDictLoadingRef.current;
};
// Common Greek NT function words to pre-uncheck in the picker
// (articles, prepositions, conjunctions, particles, common pronouns)
const GREEK_FUNCTION_WORDS = new Set([
'G3588','G3739','G3748', // ὁ (the), ὅς (who/which), ὅστις
'G2532','G1161','G1063','G235','G3767','G2443','G5037','G3303', // καί, δέ, γάρ, ἀλλά, οὖν, ἵνα, τέ, μέν
'G1487','G1437','G3754','G5613','G2531','G5618', // εἰ, ἐάν, ὅτι, ὡς, καθώς, ὥσπερ
'G1722','G1519','G1537','G575','G4314','G3326','G2596','G1223', // ἐν, εἰς, ἐκ, ἀπό, πρός, μετά, κατά, διά
'G1909','G5228','G5259','G4862','G3844','G4253','G4012','G1722', // ἐπί, ὑπέρ, ὑπό, σύν, παρά, πρό, περί
'G3756','G3361','G3780','G3762','G3763', // οὐ, μή, οὐχί, οὐδείς, οὐδέποτε
'G846','G1473','G4771','G2249','G5210', // αὐτός, ἐγώ, σύ, ἡμεῖς, ὑμεῖς
'G3778','G1565','G3588', // οὗτος, ἐκεῖνος
'G5100','G5101','G3956','G3745','G3748', // τις, τίς, πᾶς, ὅσος
'G3779','G3568','G5119','G1161','G3767', // οὕτως, νῦν, τότε
]);
const suggestGreekWordsForChunk = async (chunkId) => {
const chunk = allChunks.find((c) => c.id === chunkId);
const chapterIndex = project?.chapters.findIndex((ch) =>
ch.chunks.some((c) => c.id === chunkId)
) ?? -1;
const chapter = chapterIndex >= 0 ? project.chapters[chapterIndex] : null;
if (!chunk || !chapter) return;
if (!NT_BOOK_NUMBER[chapter.bookAbbrev]) {
setStatusMessage('Auto-suggest only works for New Testament books.');
window.setTimeout(() => setStatusMessage(''), 2500);
return;
}
setSuggestingGreekForChunkId(chunkId);
try {
const [concordance, dict, gloss] = await Promise.all([loadNtConcordance(), loadGreekDict(), loadNtGloss()]);
const bookData = concordance[chapter.bookAbbrev] ?? {};
const chapterData = bookData[chapter.chapter] ?? {};
const nextChapterData = chunkSpansNextChapter(project, chapterIndex, chunk)
? bookData[project.chapters[chapterIndex + 1].chapter] ?? {}
: {};
const strongsInRange = new Set();
for (let v = chunk.startVerse; v <= chunk.endVerse; v++) {
for (const s of chapterData[String(v)] ?? []) strongsInRange.add(s);
}
if (chunkSpansNextChapter(project, chapterIndex, chunk)) {
for (let v = 1; v <= chunk.spilloverEndVerse; v++) {
for (const s of nextChapterData[String(v)] ?? []) strongsInRange.add(s);
}
}
if (strongsInRange.size === 0) {
setStatusMessage("No Strong's data found for this passage.");
window.setTimeout(() => setStatusMessage(''), 3000);
return;
}
const existingNumbers = new Set(
chunk.greekWords.map((w) => w.strongNumber.toUpperCase()).filter(Boolean)
);
const modalWords = [];
for (const strongKey of strongsInRange) {
if (existingNumbers.has(strongKey)) continue;
const entry = dict[strongKey];
modalWords.push({
strongKey,
lexeme: entry?.lemma ?? '',
translit: entry?.translit ?? '',
def: gloss[strongKey] ?? entry?.kjv_def ?? 'No definition found.',
entry,
});
}
if (modalWords.length === 0) {
setStatusMessage('All words from this passage are already added.');
window.setTimeout(() => setStatusMessage(''), 2000);
return;
}
// Pre-select content words; pre-uncheck known function words
const preSelected = new Set(
modalWords
.filter((w) => !GREEK_FUNCTION_WORDS.has(w.strongKey))
.map((w) => w.strongKey)
);
setSuggestModal({
chunkId,
language: 'greek',
helperText: "Content words are pre-checked. Uncheck any you don't need.",
words: modalWords,
});
setSuggestSelection(preSelected);
} catch (err) {
setStatusMessage(`Suggest failed: ${err.message}`);
window.setTimeout(() => setStatusMessage(''), 3000);
} finally {
setSuggestingGreekForChunkId(null);
}
};
// Extract a single short English gloss from a verbose kjv_def string.
// e.g. "[idiom] burn (incense), be mindful..." → "burn"
// "another, [idiom] (blood-) thirsty..." → "another"
const shortHebrewGloss = (kjvDef) => {
if (!kjvDef) return '';
let s = kjvDef.trim();
// strip leading [tag] tokens like [idiom], [phrase]
s = s.replace(/^(\[[^\]]+\]\s*)+/, '').trim();
// take first chunk before comma, semicolon, or opening paren
s = s.split(/[,;(]/)[0].trim();
// strip trailing punctuation
s = s.replace(/[.\-]+$/, '').trim();
return s;
};
const ENGLISH_STOP_WORDS = new Set([
'the', 'and', 'for', 'that', 'with', 'this', 'from', 'were', 'was', 'have', 'has',
'had', 'are', 'but', 'not', 'you', 'your', 'his', 'her', 'their', 'they', 'them',
'our', 'out', 'into', 'over', 'under', 'upon', 'then', 'than', 'who', 'what', 'when',
'where', 'why', 'how', 'also', 'there', 'here', 'all', 'any', 'one', 'two', 'three',
'he', 'she', 'it', 'we', 'i', 'me', 'my', 'mine', 'ours', 'theirs', 'its',
]);
const suggestHebrewWordsForChunk = async (chunkId) => {
const chunk = allChunks.find((c) => c.id === chunkId);
const chapterIndex = project?.chapters.findIndex((ch) =>
ch.chunks.some((c) => c.id === chunkId)
) ?? -1;
const chapter = chapterIndex >= 0 ? project.chapters[chapterIndex] : null;
if (!chunk || !chapter) return;
if (NT_BOOK_NUMBER[chapter.bookAbbrev]) {
setStatusMessage('Hebrew suggestions are for Old Testament passages.');
window.setTimeout(() => setStatusMessage(''), 2500);
return;
}
setSuggestingHebrewForChunkId(chunkId);
try {
const dict = await loadHebrewDict();
const versesInChunk = getChunkVerseEntries(project, chapterIndex, chunk)
.map((v) => v.text)
.join(' ')
.toLowerCase();
const candidateWords = Array.from(new Set(
versesInChunk
.replace(/[^a-z\s]/g, ' ')
.split(/\s+/)
.map((w) => w.trim())
.filter((w) => w.length >= 4 && !ENGLISH_STOP_WORDS.has(w)),
));
if (candidateWords.length === 0) {
setStatusMessage('No Hebrew suggestion candidates found in this passage.');
window.setTimeout(() => setStatusMessage(''), 2500);
return;
}
const existingNumbers = new Set(
chunk.greekWords.map((w) => (w.strongNumber || '').toUpperCase()).filter(Boolean)
);
const seen = new Set();
const modalWords = [];
const entries = Object.entries(dict);
for (const token of candidateWords) {
const match = entries.find(([, entry]) => {
const defs = `${entry.kjv_def || ''} ${entry.strongs_def || ''}`.toLowerCase();
return defs.split(/[,;()\s]+/).includes(token);
});
if (!match) continue;
const [strongKey, entry] = match;
if (!/^H\d+$/i.test(strongKey)) continue;
const normalized = strongKey.toUpperCase();
if (existingNumbers.has(normalized) || seen.has(normalized)) continue;
seen.add(normalized);
modalWords.push({
strongKey: normalized,
lexeme: entry?.lemma ?? '',
translit: entry?.xlit ?? '',
def: shortHebrewGloss(entry?.kjv_def) || 'No definition found.',
fullDef: entry?.kjv_def ?? '',
entry,
});
if (modalWords.length >= 20) break;
}
if (modalWords.length === 0) {
setStatusMessage('No Hebrew suggestions found from this passage text.');
window.setTimeout(() => setStatusMessage(''), 2500);
return;
}
setSuggestModal({
chunkId,
language: 'hebrew',
helperText: 'Heuristic matches from passage text; uncheck anything not useful.',
words: modalWords,
});
setSuggestSelection(new Set(modalWords.map((w) => w.strongKey)));
} catch (err) {
setStatusMessage(`Hebrew suggest failed: ${err.message}`);
window.setTimeout(() => setStatusMessage(''), 3000);
} finally {
setSuggestingHebrewForChunkId(null);
}
};
const confirmSuggestWords = () => {
if (!suggestModal) return;
const { chunkId, words } = suggestModal;
const newWords = words
.filter((w) => suggestSelection.has(w.strongKey))
.map((w) => ({
id: makeId(),
query: w.strongKey,
strongNumber: w.strongKey,
englishGloss: w.def,
lexeme: w.lexeme,
transliteration: w.translit,
partOfSpeech: '',
shortDefinition: w.fullDef || w.def,
definitionHtml: w.entry ? buildGreekDefinitionHtml(w.strongKey, w.entry) : '',
loading: false,
}));
if (newWords.length > 0) {
updateProject((current) => ({
...current,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((c) =>
c.id === chunkId ? { ...c, greekWords: [...c.greekWords, ...newWords] } : c
),
})),
}));
const label = suggestModal?.language === 'hebrew' ? 'Hebrew' : 'Greek';
setStatusMessage(`Added ${newWords.length} ${label} word${newWords.length > 1 ? 's' : ''}.`);
window.setTimeout(() => setStatusMessage(''), 2000);
}
setSuggestModal(null);
setSuggestSelection(new Set());
};
const isGreekStrongNumber = (query) => /^G\d+$/i.test(query.trim());
const isHebrewStrongNumber = (query) => /^H\d+$/i.test(query.trim());
const externalLookupLinks = (query) => {
const raw = query.trim();
const normalized = raw.toUpperCase();
const isGreek = /^G\d+$/.test(normalized);
const isHebrew = /^H\d+$/.test(normalized);
const num = (isGreek || isHebrew) ? normalized.slice(1) : null;
if (isGreek && num) {
return [
{ label: 'BibleHub', url: `https://biblehub.com/greek/${num}.htm` },
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/lexicon/g${num}/esv/0-1/` },
{ label: 'StudyLight', url: `https://www.studylight.org/lexicons/eng/greek/${num}.html` },
];
}
if (isHebrew && num) {
return [
{ label: 'BibleHub', url: `https://biblehub.com/hebrew/${num}.htm` },
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/lexicon/h${num}/kjv/wlc/0-1/` },
{ label: 'StudyLight', url: `https://www.studylight.org/lexicons/eng/hebrew/${num}.html` },
];
}
const enc = encodeURIComponent(raw);
return [
{ label: 'BibleHub', url: `https://biblehub.com/search.php?q=${enc}` },
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/search/search.cfm?Criteria=${enc}&t=KJV#s=s_lexiconc` },
];
};
// Module-level cache for the Greek Strong's dictionary (loaded once, ~1.2 MB)
const _greekDictRef = useRef(null);
const _greekDictLoadingRef = useRef(null);
const loadGreekDict = async () => {
if (_greekDictRef.current) return _greekDictRef.current;
if (_greekDictLoadingRef.current) return _greekDictLoadingRef.current;
_greekDictLoadingRef.current = fetch(
'https://cdn.jsdelivr.net/gh/openscriptures/strongs@master/greek/strongs-greek-dictionary.js',
)
.then((res) => res.text())
.then((text) => {
const start = text.indexOf('{');
const end = text.lastIndexOf('}') + 1;
const dict = JSON.parse(text.slice(start, end));
_greekDictRef.current = dict;
_greekDictLoadingRef.current = null;
return dict;
});
return _greekDictLoadingRef.current;
};
const buildGreekDefinitionHtml = (key, entry) => {
let html = '';
if (entry.strongs_def) html += `${entry.strongs_def.trim()}
`;
if (entry.derivation) html += `Derivation: ${entry.derivation}
`;
if (entry.kjv_def) html += `KJV uses: ${entry.kjv_def}
`;
return html;
};
const fetchBollsDefinition = async (query) => {
const encoded = encodeURIComponent(query.trim());
if (isHebrewStrongNumber(query)) {
const response = await fetch(`https://bolls.life/dictionary-definition/BDBT/${encoded}/`);
if (!response.ok) return null;
let defs;
try { defs = await response.json(); } catch { return null; }
return Array.isArray(defs) && defs.length > 0 ? defs : null;
}
// Greek — look up in the cached OpenScriptures Strong's dictionary.
const dict = await loadGreekDict();
const key = isGreekStrongNumber(query) ? query.trim().toUpperCase() : null;
if (key) {
const entry = dict[key];
if (!entry) return null;
return [{
topic: key,
lexeme: entry.lemma || '',
transliteration: entry.translit || '',
short_definition: entry.kjv_def || '',
definition: buildGreekDefinitionHtml(key, entry),
}];
}
// English word search — scan kjv_def and strongs_def for the query term.
const lowerQ = query.trim().toLowerCase();
const match = Object.entries(dict).find(([, entry]) =>
(entry.kjv_def || '').toLowerCase().split(/[,\s]+/).some((w) => w === lowerQ) ||
(entry.strongs_def || '').toLowerCase().includes(lowerQ),
);
if (match) {
const [matchKey, entry] = match;
return [{
topic: matchKey,
lexeme: entry.lemma || '',
transliteration: entry.translit || '',
short_definition: entry.kjv_def || '',
definition: buildGreekDefinitionHtml(matchKey, entry),
}];
}
return null;
};
const lookupWord = async (chunkId, wordId, language = 'greek') => {
const chunk = allChunks.find((c) => c.id === chunkId);
const word = chunk?.greekWords.find((w) => w.id === wordId);
if (!word || !word.query.trim()) return;
updateChunkWord(chunkId, wordId, { loading: true });
try {
// Normalize bare numbers based on selected lookup language.
const raw = word.query.trim();
const inferredPrefix = language === 'hebrew' ? 'H' : 'G';
const normalized = /^\d+$/.test(raw)
? `${inferredPrefix}${raw}`
: /^[gGhH]\d+$/.test(raw)
? raw.toUpperCase()
: raw;
const [definitions, gloss] = await Promise.all([
fetchBollsDefinition(normalized),
loadNtGloss().catch(() => ({})),
]);
if (!definitions || definitions.length === 0) {
updateChunkWord(chunkId, wordId, {
strongNumber: '',
shortDefinition: 'No definition found.',
definitionHtml: '',
});
return;
}
const first = definitions[0];
const strongKey = (first.topic || normalized).toUpperCase();
const extractedPartOfSpeech = extractPartOfSpeech(first.definition || '');
updateChunkWord(chunkId, wordId, {
strongNumber: first.topic || normalized,
englishGloss: gloss[strongKey] || first.short_definition || '',
lexeme: first.lexeme || '',
transliteration: first.transliteration || '',
partOfSpeech: extractedPartOfSpeech || word.partOfSpeech || '',
shortDefinition: first.short_definition || '',
definitionHtml: first.definition || '',
});
} catch {
updateChunkWord(chunkId, wordId, { shortDefinition: 'Lookup failed.' });
} finally {
updateChunkWord(chunkId, wordId, { loading: false });
}
};
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
const exportChapter = () => {
if (!project) return;
const html = buildExportHtml(project);
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${project.title.replace(/\s+/g, '-')}-study.html`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const printChapterPdf = () => {
if (!project) return;
const html = buildExportHtml(project);
const printWindow = window.open('', '_blank');
if (!printWindow) {
alert('Please allow pop-ups for this site to print or save as PDF.');
return;
}
printWindow.document.write(html);
printWindow.document.close();
printWindow.onload = () => {
printWindow.focus();
printWindow.print();
};
};
const handleEnableSharing = async () => {
if (!project) return;
setShareBusy(true);
const result = await enableSharing(project.id);
setShareBusy(false);
if (result.ok) setShareToken(result.data.shareToken);
};
const handleDisableSharing = async () => {
if (!project) return;
if (!window.confirm('Revoke this share link? Anyone using it will lose access immediately.')) return;
setShareBusy(true);
const result = await disableSharing(project.id);
setShareBusy(false);
if (result.ok) setShareToken(null);
};
const handleCopyShareLink = () => {
if (!shareToken) return;
const url = `${window.location.origin}${window.location.pathname}?share=${shareToken}`;
navigator.clipboard.writeText(url).then(() => {
setShareCopied(true);
window.setTimeout(() => setShareCopied(false), 2000);
});
};
const exportChapterMarkdown = () => {
if (!project) return;
const markdown = buildMarkdownExport(project);
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${project.title.replace(/\s+/g, '-')}-study.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
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 url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${project.title.replace(/\s+/g, '-')}-study.docx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const copyForClaude = () => {
if (!project) return;
const prompt = buildClaudePrompt(project);
navigator.clipboard.writeText(prompt).then(() => {
setSaveStatus('Copied for Claude!');
window.setTimeout(() => setSaveStatus(''), 2000);
});
};
const copyForPodcast = () => {
if (!project) return;
const prompt = buildPodcastPrompt(project, authUser?.podcastName);
navigator.clipboard.writeText(prompt).then(() => {
setSaveStatus('Copied podcast prep!');
window.setTimeout(() => setSaveStatus(''), 2000);
});
};
const copyPronunciationGuide = () => {
if (!project) return;
const guide = buildPronunciationGuide(project);
navigator.clipboard.writeText(guide).then(() => {
setSaveStatus('Copied pronunciation guide!');
window.setTimeout(() => setSaveStatus(''), 2000);
});
};
// ---------------------------------------------------------------------------
// Home page helpers
// ---------------------------------------------------------------------------
const openNewProject = () => {
setProject(null);
setActiveChapterIndex(0);
setShowAddChapterForm(false);
setRangeStart(null);
setRangeEnd(null);
setErrorMessage('');
setStatusMessage('');
setTitleEdited(false);
setCurrentPage('setup');
};
const openImportProject = () => {
setProject(null);
setImportBookAbbrev(bookOptions[0].abbrev);
setImportTranslation('BSB');
setImportTitle('');
setImportFile(null);
setImportPreview(null);
setImportError('');
setCurrentPage('import');
};
const handleImportFileChange = async (file) => {
setImportFile(file);
setImportPreview(null);
setImportError('');
if (!file) return;
setImportBusy(true);
try {
const episodes = await parseEpisodeListDocx(file);
if (episodes.length === 0) {
throw new Error('No "Session # / Title / Passage" table found in that document.');
}
const specs = episodes.map((ep) => ({ ...ep, parsed: parseEpisodePassage(ep.passage) }));
setImportPreview(specs);
} catch (error) {
setImportError(error.message || 'Failed to read that document.');
} finally {
setImportBusy(false);
}
};
const runEpisodeImport = async () => {
if (!importPreview || importPreview.length === 0) {
setImportError('Choose a .docx file first.');
return;
}
setImportBusy(true);
setImportError('');
try {
const selectedBook = bookOptions.find((b) => b.abbrev === importBookAbbrev) ?? bookOptions[0];
const chapterNumbersNeeded = new Set();
importPreview.forEach((spec) => {
if (spec.parsed && spec.parsed !== 'invalid') {
chapterNumbersNeeded.add(spec.parsed.startChapter);
chapterNumbersNeeded.add(spec.parsed.endChapter);
}
});
const sortedChapters = Array.from(chapterNumbersNeeded).sort((a, b) => a - b);
if (sortedChapters.length === 0) {
throw new Error('No verse passages found to import.');
}
const versesByChapter = {};
for (const chNum of sortedChapters) {
const response = await fetch(
`https://bible.helloao.org/api/${importTranslation}/${selectedBook.abbrev}/${chNum}.json`,
);
if (!response.ok) throw new Error(`Unable to load ${selectedBook.name} ${chNum}.`);
const data = await response.json();
const verses = parseBibleChapter(data);
if (!verses.length) throw new Error(`No verses returned for ${selectedBook.name} ${chNum}.`);
versesByChapter[chNum] = verses;
}
const chapterIndexByNum = new Map(sortedChapters.map((n, idx) => [n, idx]));
const chapters = sortedChapters.map((chNum) => ({
book: selectedBook.name,
bookAbbrev: selectedBook.abbrev,
chapter: chNum,
verses: versesByChapter[chNum],
chunks: [],
}));
// Markers (rows with no passage) attach to the nearest upcoming chapter with a
// real passage, or fall back to the previous one for trailing markers.
const anchorChapterFor = importPreview.map((spec, i) => {
if (spec.parsed && spec.parsed !== 'invalid') return spec.parsed.startChapter;
for (let j = i + 1; j < importPreview.length; j += 1) {
const next = importPreview[j].parsed;
if (next && next !== 'invalid') return next.startChapter;
}
for (let j = i - 1; j >= 0; j -= 1) {
const prev = importPreview[j].parsed;
if (prev && prev !== 'invalid') return prev.startChapter;
}
return sortedChapters[0];
});
importPreview.forEach((spec, i) => {
if (spec.parsed === 'invalid') return; // skip unparseable rows entirely
const chIdx = chapterIndexByNum.get(anchorChapterFor[i]);
if (chIdx == null) return;
const target = chapters[chIdx];
const baseChunk = {
id: makeId(),
observation: '',
interpretation: '',
application: '',
crossReferences: [],
greekWords: [],
generalNotes: '',
episodeNumber: spec.episodeNumber,
episodeTitle: spec.title,
finalScript: '',
tags: [],
};
if (!spec.parsed) {
// No passage (e.g. a part intro/review) -> zero-length marker chunk.
target.chunks.push({ ...baseChunk, startVerse: 0, endVerse: 0, spilloverEndVerse: null });
return;
}
const { startChapter, startVerse, endChapter, endVerse } = spec.parsed;
if (endChapter === startChapter) {
target.chunks.push({ ...baseChunk, startVerse, endVerse, spilloverEndVerse: null });
} else {
const lastVerseOfStartChapter = versesByChapter[startChapter]?.at(-1)?.number ?? endVerse;
target.chunks.push({
...baseChunk,
startVerse,
endVerse: lastVerseOfStartChapter,
spilloverEndVerse: endVerse,
});
}
});
const newProject = {
id: makeId(),
title: importTitle.trim() || `${selectedBook.name} Sessions`,
translation: importTranslation,
lastEdited: Date.now(),
selectedChunkId: null,
chapters,
};
setProject(newProject);
setActiveChapterIndex(0);
setCurrentPage('setup');
} catch (error) {
setImportError(error.message || 'Import failed.');
} finally {
setImportBusy(false);
}
};
const resumeProject = (id) => {
const loaded = loadProjectById(id);
if (!loaded) return;
setProject(loaded);
setActiveChapterIndex(0);
const loadedChunks = loaded.chapters.flatMap((ch) => ch.chunks);
const hasSelectedChunk = loaded.selectedChunkId
&& loadedChunks.some((c) => c.id === loaded.selectedChunkId);
setCurrentPage(hasSelectedChunk ? 'study' : 'setup');
};
const renameProjectInStorage = (id, title) => {
const loaded = loadProjectById(id);
if (!loaded) return;
const updated = { ...loaded, title, lastEdited: Date.now() };
saveProjectToStorage(updated);
setProjectIndex(loadProjectIndex());
saveRemoteProject(updated);
if (project?.id === id) {
setProject((current) => (current ? { ...current, title } : current));
}
};
const deleteProject = (id) => {
if (!window.confirm('Delete this project? This cannot be undone.')) return;
deleteProjectFromStorage(id);
setProjectIndex(loadProjectIndex());
deleteRemoteProject(id); // fire-and-forget
};
const pullLatestFromServer = async (id) => {
const result = await loadRemoteProject(id);
if (!result.ok) {
alert('Could not pull latest version from server.');
return;
}
saveProjectToStorage(result.data);
setProjectIndex(loadProjectIndex());
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
};
const goHome = () => {
setProjectIndex(loadProjectIndex());
setCurrentPage('home');
setProject(null);
setRangeStart(null);
setRangeEnd(null);
setErrorMessage('');
setStatusMessage('');
};
const authStatus = authUser && (
{authUser.email}
{authUser.isAdmin && (
setCurrentPage('admin')}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
>
🛡 Admin
)}
setCurrentPage('settings')}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
>
⚙ Settings
{
await logoutUser();
setAuthUser(null);
setProjectIndex(switchStorageUser(null));
goHome();
}}
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
>
Log out
);
// ---------------------------------------------------------------------------
// Shared header
// ---------------------------------------------------------------------------
const headerButtons = (
{currentPage !== 'home' && (
← Home
)}
{project && currentPage === 'study' && (
setCurrentPage('setup')}
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
>
← Back to Chunks
)}
{project && (
Prepare for Claude
🎙 Prepare for Podcast
🗣 Pronunciation Guide
setExportMenuOpen((v) => !v)}
className="flex items-center gap-1.5 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400"
>
⬇ Export
{exportMenuOpen && (
{ exportChapter(); setExportMenuOpen(false); }}
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export HTML
{ exportChapterDocx(); setExportMenuOpen(false); }}
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export DOCX
{ exportChapterMarkdown(); setExportMenuOpen(false); }}
title="Handy for Obsidian, Notion, or any Markdown-based notes app"
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export Markdown
{ printChapterPdf(); setExportMenuOpen(false); }}
title="Opens a print-friendly version in a new tab — choose 'Save as PDF' in the print dialog"
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
🖨 Print / Save PDF
)}
setSharePanelOpen((v) => !v)}
className={`rounded-md px-4 py-2 text-sm font-medium shadow-sm transition ${shareToken ? 'bg-teal-600 text-white hover:bg-teal-500' : 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'}`}
>
🔗 {shareToken ? 'Shared' : 'Share'}
{sharePanelOpen && (
Read-only share link
Anyone with this link can view (not edit) this study — no account needed. Good for a co-teacher
or a listener who wants to follow along.
{shareToken ? (
) : (
{shareBusy ? 'Enabling…' : 'Enable sharing'}
)}
)}
)}
{loadingChapter ? (
Loading…
) : saveStatus ? (
{saveStatus}
) : (
)}
{syncStatus === 'syncing' &&
Syncing…
}
{syncStatus === 'synced' &&
Synced ✓
}
{syncStatus === 'error' && (
⚠ Sync failed
{
if (!project) return;
setSyncStatus('syncing');
const toSave = { ...project, lastEdited: Date.now() };
saveProjectToStorage(toSave);
const result = await saveRemoteProject(toSave);
setSyncStatus(result.ok ? 'synced' : 'error');
if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500);
}}
className="text-xs text-slate-300 underline hover:text-white"
>
Retry
)}
{authStatus}
);
// ---------------------------------------------------------------------------
// Shared read-only view — a ?share=TOKEN link, no login required at all.
// Renders in a sandboxed iframe (scripts disabled) since the HTML embeds
// user-authored notes that aren't HTML-escaped.
// ---------------------------------------------------------------------------
if (sharedViewToken) {
if (sharedError) {
return (
Bible Study Project
{sharedError}
);
}
if (!sharedProject) {
return (
);
}
return (
);
}
// ---------------------------------------------------------------------------
// Auth gate — shown when the server is reachable but no session is present
// ---------------------------------------------------------------------------
if (authUser === undefined && !authServerDown) {
return (
);
}
if (authUser === null && !authServerDown) {
const submitAuth = async (e) => {
e.preventDefault();
setAuthError('');
setAuthBusy(true);
const action = authMode === 'login' ? loginUser : registerUser;
const result = await action(authForm.email.trim(), authForm.password);
setAuthBusy(false);
if (!result.ok) {
setAuthError(result.error ?? 'Something went wrong.');
return;
}
if (result.data?.mfaRequired) {
setAuthMfaPending(true);
return;
}
setAuthUser(result.data);
setProjectIndex(switchStorageUser(result.data.id));
};
const submitMfa = async (e) => {
e.preventDefault();
setAuthError('');
setAuthBusy(true);
const result = await verifyMfaLogin(
authMfaUseBackup ? { backupCode: authMfaCode.trim() } : { token: authMfaCode.trim() },
);
setAuthBusy(false);
if (!result.ok) {
setAuthError(result.error ?? 'Invalid code.');
return;
}
setAuthMfaPending(false);
setAuthMfaCode('');
setAuthUser(result.data);
setProjectIndex(switchStorageUser(result.data.id));
};
if (authMfaPending) {
return (
Bible Study Project
Two-factor verification
{authMfaUseBackup
? 'Enter one of your saved backup codes.'
: 'Enter the 6-digit code from your authenticator app.'}
{
setAuthMfaUseBackup((v) => !v);
setAuthMfaCode('');
setAuthError('');
}}
className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700"
>
{authMfaUseBackup ? 'Use your authenticator app instead' : "Lost your device? Use a backup code"}
{
await logoutUser();
setAuthMfaPending(false);
setAuthMfaCode('');
setAuthError('');
}}
className="mt-2 w-full text-center text-sm text-slate-400 underline hover:text-slate-600"
>
Cancel
);
}
return (
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */}
Bible Study Project
Deep Bible study, organized chunk by chunk.
Split any passage into chunks and work through Observation, Interpretation, and
Application notes alongside Greek & Hebrew word studies, cross-references, and commentary.
📖 Chunk-by-chunk OIA notes on any passage
🔤 Greek & Hebrew word studies with pronunciation
🔗 Cross-references and commentary, one click away
🎙 Turn your notes into a podcast-ready script
🔒 Your studies stay private to your account, with optional 2FA
"Make every effort to present yourself approved to God, an unashamed workman who accurately
handles the word of truth."
{/* Sign in / register form */}
Bible Study Project
{authMode === 'login' ? 'Sign in' : 'Create an account'}
{
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
setAuthError('');
}}
className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700"
>
{authMode === 'login' ? "Need an account? Sign up" : 'Already have an account? Sign in'}
);
}
// ---------------------------------------------------------------------------
// ADMIN PAGE — restricted to the designated admin account (server-enforced)
// ---------------------------------------------------------------------------
if (currentPage === 'admin' && authUser?.isAdmin) {
const handleDeleteUserAdmin = async (id, email) => {
if (!window.confirm(`Delete account "${email}"? Their projects are kept, not deleted, but become inaccessible until reassigned.`)) return;
const result = await adminDeleteUser(id);
if (result.ok) loadAdminData();
else alert(result.error ?? 'Failed to delete user.');
};
const handleDeleteProjectAdmin = async (id, title) => {
if (!window.confirm(`Permanently delete project "${title}"? This cannot be undone.`)) return;
const result = await adminDeleteProject(id);
if (result.ok) loadAdminData();
else alert(result.error ?? 'Failed to delete project.');
};
const handleViewProjectAdmin = async (id) => {
const result = await adminGetProject(id);
if (result.ok) setAdminViewProject(result.data);
else alert(result.error ?? 'Failed to load project.');
};
const handleResetPasswordAdmin = async (id, email) => {
if (!window.confirm(`Reset the password for "${email}"? They'll be signed out everywhere and need the new temporary password to log back in.`)) return;
const result = await adminResetPassword(id);
if (result.ok) setAdminResetResult({ email, temporaryPassword: result.data.temporaryPassword });
else alert(result.error ?? 'Failed to reset password.');
};
return (
Bible Study Project
🛡 Admin
← Back
{authStatus}
setAdminTab('users')}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'users' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
Users ({adminUsers.length})
setAdminTab('projects')}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'projects' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
Projects ({adminProjects.length})
{adminLoading && Loading…
}
{adminError && {adminError}
}
{!adminLoading && !adminError && adminTab === 'users' && (
Email
Joined
2FA
Projects
{adminUsers.map((u) => (
{u.email}{u.id === authUser.id && (you) }
{new Date(u.createdAt).toLocaleDateString()}
{u.totpEnabled ? '✓' : '—'}
{u.projectCount}
{u.id !== authUser.id && (
handleResetPasswordAdmin(u.id, u.email)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
Reset Password
handleDeleteUserAdmin(u.id, u.email)}
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
Delete
)}
))}
)}
{!adminLoading && !adminError && adminTab === 'projects' && (
Title
Owner
Passage
Last edited
Shared
{adminProjects.map((p) => (
{p.title}
{p.ownerEmail ?? orphaned }
{p.chapterSummary}
{new Date(p.lastEdited).toLocaleDateString()}
{p.shareToken ? '🔗' : '—'}
handleViewProjectAdmin(p.id)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
View
handleDeleteProjectAdmin(p.id, p.title)}
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
Delete
))}
)}
{adminViewProject && (
setAdminViewProject(null)}>
e.stopPropagation()}>
{adminViewProject.title}
setAdminViewProject(null)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs text-slate-600 hover:bg-slate-50">
Close
)}
{adminResetResult && (
Password reset
Relay this to {adminResetResult.email} yourself
(text, call, in person) — it won't be shown again. They're signed out everywhere until they log in with it.
{adminResetResult.temporaryPassword}
setAdminResetResult(null)}
className="mt-4 w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400">
Done
)}
);
}
// ---------------------------------------------------------------------------
// SETTINGS PAGE
// ---------------------------------------------------------------------------
if (currentPage === 'settings' && authUser) {
const startSetup = async () => {
setMfaSetupError('');
setMfaSetupBusy(true);
const result = await startMfaSetup();
setMfaSetupBusy(false);
if (!result.ok) {
setMfaSetupError(result.error ?? 'Could not start 2FA setup.');
return;
}
setMfaSetup(result.data);
setMfaSetupCode('');
};
const confirmSetup = async (e) => {
e.preventDefault();
setMfaSetupError('');
setMfaSetupBusy(true);
const result = await confirmMfaSetup(mfaSetupCode.trim());
setMfaSetupBusy(false);
if (!result.ok) {
setMfaSetupError(result.error ?? 'Invalid code.');
return;
}
setMfaSetup(null);
setMfaSetupCode('');
setMfaBackupCodes(result.data.backupCodes);
setAuthUser((u) => ({ ...u, totpEnabled: true }));
};
const cancelSetup = () => {
setMfaSetup(null);
setMfaSetupCode('');
setMfaSetupError('');
};
const submitDisable = async (e) => {
e.preventDefault();
setMfaDisableError('');
const result = await disableMfa(mfaDisablePassword);
if (!result.ok) {
setMfaDisableError(result.error ?? 'Incorrect password.');
return;
}
setMfaDisablePassword('');
setAuthUser((u) => ({ ...u, totpEnabled: false }));
};
const submitChangePassword = async (e) => {
e.preventDefault();
setChangePasswordError('');
if (changePasswordForm.next !== changePasswordForm.confirm) {
setChangePasswordError('New passwords don\'t match.');
return;
}
setChangePasswordBusy(true);
const result = await changePassword(changePasswordForm.current, changePasswordForm.next);
setChangePasswordBusy(false);
if (!result.ok) {
setChangePasswordError(result.error ?? 'Failed to change password.');
return;
}
setChangePasswordForm({ current: '', next: '', confirm: '' });
setChangePasswordSaved(true);
window.setTimeout(() => setChangePasswordSaved(false), 3000);
};
const submitPodcastName = async (e) => {
e.preventDefault();
setPodcastNameSaving(true);
setPodcastNameSaved(false);
const result = await updateProfile({ podcastName: podcastNameInput.trim() });
setPodcastNameSaving(false);
if (!result.ok) return;
setAuthUser((u) => ({ ...u, podcastName: result.data.podcastName }));
setPodcastNameSaved(true);
window.setTimeout(() => setPodcastNameSaved(false), 2500);
};
return (
Bible Study Project
Account Settings
← Back
{authStatus}
Account
Signed in as {authUser.email}
Change password
Changing your password signs you out of every other device — this one stays signed in.
Podcast / show name
Only needed if you use "🎙 Prepare for Podcast" on the study page — it fills in your show's
name when asking Claude to write an episode script. Leave blank if you're just doing personal
study; that button still works, it just won't name a specific show.
{podcastNameSaved && Saved.
}
Two-factor authentication
{authUser.totpEnabled
? "Enabled — you'll need a code from your authenticator app to sign in."
: 'Add a 6-digit code from an authenticator app (Google Authenticator, Authy, 1Password, etc.) as a second step at sign-in.'}
{mfaBackupCodes ? (
Save these backup codes now — each works once if you ever lose your device. They won't be shown again.
{mfaBackupCodes.map((code) => (
{code}
))}
setMfaBackupCodes(null)}
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-amber-400"
>
I've saved these codes
) : authUser.totpEnabled ? (
) : mfaSetup ? (
) : (
{mfaSetupBusy ? 'Starting…' : 'Enable 2FA'}
{mfaSetupError &&
{mfaSetupError}
}
)}
);
}
// ---------------------------------------------------------------------------
// HOME PAGE
// ---------------------------------------------------------------------------
if (currentPage === 'home') {
return (
{autoRestoredCount !== null && (
📥 Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.
)}
{staleLocalProjects.length > 0 && (
☁️ {staleLocalProjects.length} project{staleLocalProjects.length > 1 ? 's have' : ' has'} a newer version on the server:
{staleLocalProjects.map((entry) => (
pullLatestFromServer(entry.id)}
className="rounded-xl bg-amber-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-amber-600"
>
Pull latest "{entry.title}"
))}
)}
{projectIndex.length === 0 ? (
No projects yet
Start a new Bible study to get going.
+ New Project
) : (
<>
setHomeSearch(e.target.value)}
placeholder="Search projects by title or passage…"
className="w-full max-w-sm rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
Sort by{' '}
setHomeSort(e.target.value)}
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
Last edited
Title
Passage
{(() => {
const allTags = Array.from(
new Set(projectIndex.flatMap((entry) => entry.tags ?? [])),
).sort((a, b) => a.localeCompare(b));
if (allTags.length === 0) return null;
return (
Tag{' '}
setHomeTagFilter(e.target.value)}
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
All tags
{allTags.map((tag) => (
{tag}
))}
);
})()}
Listen to BSB Audio
{audioState.status === 'idle' || audioState.status === 'error'
? 'Play a full book of the Berean Standard Bible.'
: `Playing ${bookOptions.find((b) => b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}`}
{audioState.status === 'error' && (
Couldn't load audio for this book/narrator.
)}
setAudioBook(e.target.value)}
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
>
{bookOptions.map((book) => (
{book.name}
))}
setAudioNarrator(e.target.value)}
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
>
David
Hays
Souer
{audioState.status === 'playing' || audioState.status === 'paused' ? (
<>
{audioState.status === 'paused' ? 'Resume' : 'Pause'}
Stop
>
) : (
Play book
)}
{projectIndex
.slice()
.filter((entry) => {
if (homeTagFilter && !(entry.tags ?? []).includes(homeTagFilter)) return false;
const q = homeSearch.trim().toLowerCase();
if (!q) return true;
return entry.title?.toLowerCase().includes(q)
|| entry.chapterSummary?.toLowerCase().includes(q);
})
.sort((a, b) => {
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
})
.map((entry) => (
{renamingId === entry.id ? (
setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const trimmed = renameValue.trim();
if (trimmed) renameProjectInStorage(entry.id, trimmed);
setRenamingId(null);
} else if (e.key === 'Escape') {
setRenamingId(null);
}
}}
className="flex-1 rounded-lg border border-slate-300 px-2 py-1 text-base font-semibold text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
{
const trimmed = renameValue.trim();
if (trimmed) renameProjectInStorage(entry.id, trimmed);
setRenamingId(null);
}}
className="text-sm font-semibold text-emerald-600 hover:text-emerald-700"
>
Save
setRenamingId(null)}
className="text-sm text-slate-400 hover:text-slate-600"
>
×
) : (
{entry.title}
{ setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }}
className="shrink-0 text-xs text-slate-400 hover:text-slate-600"
title="Rename project"
>
✎ Rename
)}
{entry.chapterSummary && (
{entry.chapterSummary}
)}
{entry.tags?.length > 0 && (
{entry.tags.map((tag) => (
{tag}
))}
)}
{formatRelativeDate(entry.lastEdited)}
resumeProject(entry.id)}
className="flex-1 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
>
Resume
deleteProject(entry.id)}
className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 transition hover:bg-rose-100"
>
Delete
))}
>
)}
);
}
// ---------------------------------------------------------------------------
// BIBLE READER PAGE (plain read-only browsing)
// ---------------------------------------------------------------------------
if (currentPage === 'reader') {
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
const bookmarkEntries = Object.entries(readerBookmarks).map(([key, color]) => {
const [bAbbrev, chapterStr, verseStr] = key.split('-');
const bookIndex = bookOptions.findIndex((b) => b.abbrev === bAbbrev);
return {
key, color,
bookAbbrev: bAbbrev,
bookName: bookOptions[bookIndex]?.name ?? bAbbrev,
chapter: Number(chapterStr),
verse: Number(verseStr),
bookIndex,
};
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse);
return (
{/* Navigation + tools bar */}
Book{' '}
handleReaderBookChange(e.target.value)}
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{bookOptions.map((b) => (
{b.name}
))}
Chapter{' '}
{ setReaderChapter(Number(e.target.value)); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }}
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{Array.from({ length: readerTotalChapters }, (_, i) => i + 1).map((num) => (
{num}
))}
‹ Prev
= readerTotalChapters}
className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
Next ›
{/* Tool strip: font size · cross-refs · search */}
{/* Font size */}
Text size
{[['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25]].map(([label, size]) => (
setReaderFontSize(size)}
className={`rounded-lg px-2.5 py-1 text-xs font-semibold transition ${readerFontSize === size ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
{label}
))}
{/* Cross-refs toggle */}
{ setReaderShowCrossRefs((v) => !v); if (!readerShowCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerShowCrossRefs ? 'bg-amber-100 text-amber-800' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
{readerCrossRefsLoading ? 'Loading refs…' : '🔗 Cross-Refs'}
{/* Bookmarks panel */}
setReaderBookmarksPanelOpen((v) => !v)}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1 text-xs font-semibold transition ${readerBookmarksPanelOpen ? 'bg-sky-100 text-sky-800' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
0} />
Bookmarks{bookmarkEntries.length > 0 ? ` (${bookmarkEntries.length})` : ''}
{readerBookmarksPanelOpen && (
{bookmarkEntries.length === 0 ? (
No bookmarks yet. Tap the bookmark icon next to any verse to save it here.
) : (
{bookmarkEntries.map((entry) => (
jumpToReaderVerse(entry.bookAbbrev, entry.chapter, entry.verse)}
className="flex-1 truncate text-left text-sm font-medium text-slate-700 hover:text-sky-700">
{entry.bookName} {entry.chapter}:{entry.verse}
toggleReaderBookmark(entry.key)}
title="Remove bookmark"
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-rose-600">
✕
))}
)}
)}
{/* Search — this chapter or the whole Bible */}
{readerSearchActive ? (
) : (
setReaderSearchActive(true)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
🔍 Search
)}
{/* Whole-Bible search results */}
{readerSearchActive && readerSearchScope === 'bible' && (
{bibleIndexStatus === 'loading' && (
Loading the full Bible for search — this happens once per visit (~7MB)…
)}
{bibleIndexStatus === 'error' && (
Couldn't load the full Bible for search. Try again
)}
{bibleIndexStatus === 'ready' && (() => {
const q = readerSearch.trim().toLowerCase();
if (!q) return
Type at least a word to search all 66 books.
;
const index = _bibleIndexCacheRef.current.BSB ?? [];
const matches = index.filter((v) => v.text.toLowerCase().includes(q));
if (matches.length === 0) return
No verses match "{readerSearch}".
;
const shown = matches.slice(0, 100);
return (
{matches.length} match{matches.length === 1 ? '' : 'es'}{matches.length > shown.length ? ` (showing first ${shown.length})` : ''}
{shown.map((v) => {
const idx = v.text.toLowerCase().indexOf(q);
return (
jumpToReaderVerse(v.bookAbbrev, v.chapter, v.verse)}
className="block w-full rounded-xl px-3 py-2 text-left text-sm hover:bg-slate-50">
{v.bookName} {v.chapter}:{v.verse} {' '}
{v.text.slice(0, idx)}
{v.text.slice(idx, idx + q.length)}
{v.text.slice(idx + q.length)}
);
})}
);
})()}
)}
{/* Audio player */}
Listen to this chapter
{readerAudioState.status === 'error'
? "Couldn't load audio for this chapter/narrator."
: `Audio follows ${readerBook?.name} ${readerChapter} as you browse.`}
setAudioNarrator(e.target.value)}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200">
David
Hays
Souer
{readerAudioState.status === 'playing' || readerAudioState.status === 'paused' ? (
<>
{readerAudioState.status === 'paused' ? 'Resume' : 'Pause'}
Stop
>
) : (
Play
)}
{/* Verses */}
{readerBook?.name} {readerChapter} (BSB)
{readerInterlinear && (
Click a verse number to see original words & pronunciation · bookmark icon to save · copy icon to copy
)}
{!readerInterlinear && (
Hover a verse for actions
)}
{readerLoading &&
Loading…
}
{readerError &&
{readerError}
}
{!readerLoading && !readerError && (() => {
// Whole-Bible matches render in their own panel above; this list stays
// un-filtered in that mode so jumping to a result shows full context.
const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
const filtered = searchLower
? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower))
: readerVerses;
if (searchLower && filtered.length === 0) {
return
No verses match "{readerSearch}".
;
}
return (
{filtered.map((verse) => {
const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
const verseWords = chapterInterlinear?.[String(verse.number)];
const isOpen = readerSelectedVerse === verse.number;
const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`;
const bmColor = readerBookmarks[verseKey];
const crossRefs = readerCrossRefs?.[verse.number];
const highlightText = (text) => {
if (!searchLower) return text;
const idx = text.toLowerCase().indexOf(searchLower);
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
{text.slice(idx, idx + searchLower.length)}
{text.slice(idx + searchLower.length)}
>
);
};
return (
{/* Verse number / interlinear toggle */}
setReaderSelectedVerse(isOpen ? null : verse.number)}
className={`mt-0.5 shrink-0 rounded px-1 text-xs font-bold transition ${
verseWords
? isOpen ? 'bg-sky-600 text-white' : 'text-sky-600 hover:bg-sky-50'
: 'cursor-default text-slate-400'
}`}
title={verseWords ? 'Show original words' : undefined}>
{verse.number}
{/* Verse text */}
{highlightText(verse.text)}
{/* Action icons — always visible so bookmarking works on touch devices too */}
toggleReaderBookmark(verseKey)}
className={`rounded p-1 leading-none hover:bg-slate-100 ${bmColor ? 'text-amber-600' : 'text-slate-400'}`}
title={bmColor ? 'Remove bookmark' : 'Bookmark this verse'}>
{bmColor && (
cycleBookmarkColor(verseKey)}
className="rounded p-1 text-sm leading-none hover:bg-slate-100"
title="Change highlight colour">
🎨
)}
copyVerse(readerBook?.name, readerChapter, verse.number, verse.text)}
className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
title="Copy verse">
{/* Cross-references */}
{readerShowCrossRefs && crossRefs && (
{crossRefs.slice(0, 8).map((ref, i) => {
const label = formatCrossRef(ref);
return (
{label}
);
})}
)}
{/* Interlinear panel */}
{isOpen && verseWords && (
{verseWords.map((w, i) => {
const isHebrew = w.s?.startsWith('H');
const canSpeak = isHebrew || w.s?.startsWith('G');
return (
{w.o}
{w.t}
{w.g}
{w.p &&
{w.p}
}
{w.s &&
{w.s}
}
{canSpeak && (
speakOriginalWord(w.o, w.s)}
className="mt-1.5 rounded-lg bg-sky-100 px-2 py-0.5 text-[11px] font-medium text-sky-700 hover:bg-sky-200 transition"
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>
🔊 Speak
)}
);
})}
)}
);
})}
);
})()}
);
}
// ---------------------------------------------------------------------------
// SETUP PAGE (chunk builder)
// ---------------------------------------------------------------------------
if (currentPage === 'import') {
return (
How this works
This is a shortcut for setting up a multi-part study — a teaching series, sermon series, class
curriculum, or podcast — all at once, instead of building each chapter and chunk by hand.
Upload a .docx containing a table with three columns: session number, title, and passage
(e.g. 1:1–2 ). Each row becomes one chunk, grouped
automatically by chapter. If you don't have a document like this, just use{' '}
+ New Project on the home page instead — this import
step is entirely optional.
Book
setImportBookAbbrev(e.target.value)}
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{bookOptions.map((b) => (
{b.name}
))}
Translation
setImportTranslation(e.target.value)}
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{availableTranslations.map((t) => (
{t}
))}
Project title
setImportTitle(e.target.value)}
placeholder={`${bookOptions.find((b) => b.abbrev === importBookAbbrev)?.name ?? ''} Sessions`}
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
Session list (.docx)
handleImportFileChange(e.target.files?.[0] ?? null)}
className="mt-1 block w-full text-sm text-slate-600 file:mr-4 file:rounded-xl file:border-0 file:bg-slate-700 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-white hover:file:bg-slate-600"
/>
{importBusy && Working…
}
{importError && {importError}
}
{importPreview && !importBusy && (
{importPreview.length} session{importPreview.length === 1 ? '' : 's'} found
{' · '}
{importPreview.filter((s) => s.parsed && s.parsed !== 'invalid').length} with passages
{importPreview.map((spec) => (
#{spec.episodeNumber}
{spec.title}
{spec.parsed === 'invalid'
? unrecognized — skipped
: spec.parsed
? spec.passage
: marker (no passage) }
))}
Create Project from Import
)}
);
}
if (currentPage === 'setup') {
const chapterTabs = project?.chapters ?? [];
return (
Bible Study Project
{project ? project.title : 'New Study'}
{headerButtons}
{/* Project setup form — shown when no project loaded yet */}
{!project && (
{ setTitleEdited(true); handleSetupField('title', val); }}
onLoad={handleLoadChapter}
/>
)}
{/* Chapter tabs + verse/chunk editors */}
{project && (
{/* Chapter tabs */}
{chapterTabs.map((ch, idx) => (
{ setActiveChapterIndex(idx); setRangeStart(null); setRangeEnd(null); }}
className={`rounded-full px-4 py-1.5 text-sm font-medium transition ${
activeChapterIndex === idx
? 'bg-slate-900 text-white'
: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'
}`}
>
{ch.book} {ch.chapter}
))}
setShowAddChapterForm((v) => !v)}
className="rounded-full border border-dashed border-slate-400 px-4 py-1.5 text-sm text-slate-500 transition hover:border-slate-600 hover:text-slate-700"
>
+ Add Chapter
{/* Add-chapter form */}
{showAddChapterForm && (
Add another chapter
{ setTitleEdited(true); handleSetupField('title', val); }}
onLoad={handleLoadChapter}
/>
)}
{/* Verse + chunk panel for active chapter */}
{activeChapter && (
Scripture & Chunks
{activeChapter.book} {activeChapter.chapter} ({project.translation})
{statusMessage || 'Click/shift-click, or type a verse range, to create a chunk.'}
Chapter verses
Click a verse, then shift-click an end verse.
setVerseSearch(e.target.value)}
placeholder="Search verses in this chapter…"
className="mb-3 block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
{activeChapter.verses
.filter((verse) => verse.text.toLowerCase().includes(verseSearch.trim().toLowerCase()))
.map((verse) => {
const inRange =
rangeStart !== null &&
verse.number >= Math.min(rangeStart, rangeEnd) &&
verse.number <= Math.max(rangeStart, rangeEnd);
const inOwnChapterChunk = activeChapter.chunks.some(
(chunk) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse,
);
const prevChapter = project?.chapters?.[activeChapterIndex - 1] ?? null;
const inPrevChapterSpillover = prevChapter
? prevChapter.chunks.some((chunk) =>
Number.isInteger(chunk.spilloverEndVerse)
&& prevChapter.bookAbbrev === activeChapter.bookAbbrev
&& verse.number <= chunk.spilloverEndVerse
)
: false;
const inChunk = inOwnChapterChunk || inPrevChapterSpillover;
return (
handleVerseClick(verse.number, event)}
className={`group mb-2 w-full rounded-3xl px-4 py-3 text-left transition ${
inRange ? 'bg-sky-100 ring-1 ring-sky-200' : inChunk ? 'bg-slate-100' : 'bg-white hover:bg-slate-50'
}`}
>
{verse.number}
{verse.text}
);
})}
Chunks
{activeChapter.chunks.length} created
Click-based chapter span
Click a start verse in this chapter, then choose where to end in the next chapter.
{activeChapter.chunks.length === 0 ? (
No chunks yet. Select verse ranges to add sections.
) : (
activeChapter.chunks.map((chunk, index) => (
updateProject((current) => ({ ...current, selectedChunkId: chunk.id }))}
className="mb-3 w-full text-left"
>
{formatChunkReference(project, activeChapterIndex, chunk, '–')}
{activeChapter.verses.find((v) => v.number === chunk.startVerse)?.text || ''}
moveChunk(chunk.id, -1)}
disabled={index === 0}
className="rounded-full border border-slate-300 bg-white px-2 py-1 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
↑
moveChunk(chunk.id, 1)}
disabled={index === activeChapter.chunks.length - 1}
className="rounded-full border border-slate-300 bg-white px-2 py-1 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
↓
deleteChunk(chunk.id)}
className="rounded-full border border-rose-300 bg-rose-50 px-2 py-1 text-rose-600 transition hover:bg-rose-100"
>
× Delete
))
)}
)}
Begin Studying →
)}
);
}
// ---------------------------------------------------------------------------
// STUDY PAGE
// ---------------------------------------------------------------------------
return (
Bible Study Project
{project?.title ?? ''}
{selectedChunk && selectedChunkChapterIndex >= 0 && (
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '–')}
)}
{headerButtons}
{/* Sidebar */}
{/* Chunk editor */}
Chunk editor
{selectedChunk
? `Section ${formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '–')}`
: 'Select a chunk'}
{selectedChunk && (
<>
‹ Prev
{`Chunk ${selectedChunkGlobalIndex + 1} of ${allChunks.length}`}
= allChunks.length - 1}
className="rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
Next ›
>
)}
{!selectedChunk && (
Choose a chunk to study.
)}
setStudyLayout((m) => (m === 'split' ? 'stacked' : 'split'))}
className="max-sm:hidden rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400"
title="Toggle two-pane study layout"
>
{studyLayout === 'split' ? '☰ Stacked View' : '◫ Split View'}
{/* Mobile tab bar — shown only below sm, replaces the stacked-scroll layout */}
{[
{ id: 'scripture', label: 'Scripture' },
{ id: 'notes', label: 'Notes' },
{ id: 'crossRefs', label: 'Refs' },
{ id: 'wordStudy', label: 'Words' },
{ id: 'commentary', label: 'Commentary' },
{ id: 'script', label: 'Script' },
].map((tab) => (
setMobileStudyTab(tab.id)}
className={`shrink-0 border-b-2 px-4 py-2.5 text-sm font-semibold transition ${
mobileStudyTab === tab.id
? 'border-slate-900 text-slate-900'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
))}
{selectedChunk ? (
{/* Scripture */}
Scripture
Read-only passage for the selected chunk.
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '–')}
{selectedChunkVerses
.map((verse) => (
{verse.chapter}:{verse.number}. {verse.text}
))}
{/* Interlinear */}
setCollapsedSections((c) => ({ ...c, interlinear: !c.interlinear }))}
className="mb-2 flex w-full items-center justify-between gap-2 text-left"
>
Interlinear
{collapsedSections.interlinear ? '▸' : '▾'}
{!collapsedSections.interlinear && (
{interlinearLoading &&
Loading interlinear text...
}
{interlinearError &&
{interlinearError}
}
{!interlinearLoading && !interlinearError && interlinearData
&& Array.from(
{ length: selectedChunk.endVerse - selectedChunk.startVerse + 1 },
(_, i) => selectedChunk.startVerse + i,
)
.filter((num) => interlinearData[String(num)])
.map((num) => (
{selectedChunkChapter.chapter}:{num}. {' '}
{interlinearData[String(num)].map((w, i) => (
{w.o}
{w.g || '—'}
))}
))}
)}
{/* Right column (study panels) */}
{studyLayout === 'split' && (
{STUDY_TABS.map((tab) => (
setActiveStudyTab(tab.id)}
className={`rounded-2xl px-4 py-2 text-sm font-semibold transition ${
activeStudyTab === tab.id
? 'bg-slate-900 text-white'
: 'text-slate-600 hover:bg-slate-100'
}`}
>
{tab.label}
))}
)}
{/* Session metadata — optional, used to label this chunk within a series */}
{/* Background / general notes — unique per chunk */}
setCollapsedSections((c) => ({ ...c, generalNotes: !c.generalNotes }))}
className="flex w-full items-center justify-between gap-2 text-left"
>
Background / General Notes
Context, history, authorship, themes for this chunk.
{collapsedSections.generalNotes ? '▸' : '▾'}
{!collapsedSections.generalNotes && (
{/* OIA Notes */}
setCollapsedSections((c) => ({ ...c, oia: !c.oia }))}
className="flex w-full items-center justify-between gap-2 text-left"
>
Study Notes (OIA)
Observation · Interpretation · Application
{collapsedSections.oia ? '▸' : '▾'}
{!collapsedSections.oia && [
{
field: 'observation',
label: 'Observation',
placeholder: 'What does the text actually say?\n• Who is speaking, and to whom?\n• What key words or phrases repeat?\n• What\'s the tone, structure, or literary style?',
},
{
field: 'interpretation',
label: 'Interpretation',
placeholder: 'What did this mean to its original audience?\n• What\'s the historical/cultural context?\n• How does it fit the surrounding argument?\n• What does it reveal about God\'s character?',
},
{
field: 'application',
label: 'Application',
placeholder: 'How should this shape your life today?\n• What attitude or action does this call for?\n• Is there a promise to trust or a warning to heed?\n• Who could you share this with?',
},
].map(({ field, label, placeholder }) => (
{label}
))}
{/* Tags */}
Tags
Label this chunk by topic so you can search across studies later.
{(selectedChunk.tags ?? []).map((tag) => (
{tag}
removeTag(selectedChunk.id, tag)}
className="text-indigo-400 transition hover:text-indigo-700"
aria-label={`Remove tag ${tag}`}
>
×
))}
setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addTag(selectedChunk.id); }
}}
placeholder="Add a tag and press Enter"
list="tag-suggestions"
className="min-w-[10rem] flex-1 rounded-2xl border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
{(() => {
const used = new Set((selectedChunk.tags ?? []).map((t) => t.toLowerCase()));
const allTags = Array.from(
new Set([
...projectIndex.flatMap((entry) => entry.tags ?? []),
...(project?.chapters ?? []).flatMap((ch) => ch.chunks ?? []).flatMap((c) => c.tags ?? []),
]),
)
.filter((t) => !used.has(t.toLowerCase()))
.sort((a, b) => a.localeCompare(b));
if (allTags.length === 0) return null;
return (
Suggestions:
{allTags.map((tag) => (
updateChunk(selectedChunk.id, { tags: [...(selectedChunk.tags ?? []), tag] })}
className="rounded-full border border-indigo-200 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:bg-indigo-50"
>
+ {tag}
))}
);
})()}
{Array.from(
new Set([
...projectIndex.flatMap((entry) => entry.tags ?? []),
...(project?.chapters ?? []).flatMap((ch) => ch.chunks ?? []).flatMap((c) => c.tags ?? []),
]),
).map((tag) => (
))}
{/* Cross-references */}
setCollapsedSections((c) => ({ ...c, crossRefs: !c.crossRefs }))}
className="mb-3 flex w-full items-center justify-between gap-2 text-left"
>
Cross-References
{collapsedSections.crossRefs ? '▸' : '▾'}
{!collapsedSections.crossRefs && <>
setCrossRefInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCrossRef(selectedChunk.id); } }}
placeholder="e.g. John 1:1 or Rom 3:23"
className="flex-1 rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
addCrossRef(selectedChunk.id)}
className="rounded-2xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
>
Add
suggestCrossRefsForChunk(selectedChunk.id)}
disabled={suggestingCrossRefs}
className="mt-2 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-700 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-60"
>
{suggestingCrossRefs ? 'Suggesting...' : '✨ Suggest from passage'}
{crossRefSuggestions.length > 0 && (
{crossRefSuggestions.map((ref) => (
addSuggestedCrossRef(selectedChunk.id, ref)}
className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-white px-3 py-1 text-sm text-amber-700 transition hover:bg-amber-100"
>
+ {ref}
))}
)}
{(selectedChunk.crossReferences ?? []).length > 0 && (
{selectedChunk.crossReferences.map((ref) => (
removeCrossRef(selectedChunk.id, ref)}
loadVerseText={loadVerseText}
/>
))}
)}
>}
{/* Greek words */}
setCollapsedSections((c) => ({ ...c, greek: !c.greek }))}
className="mb-5 flex w-full items-center justify-between gap-4 text-left"
>
Word Studies (Greek/Hebrew)
Add lexical notes, look up Strong's entries, and suggest words from the passage.
{collapsedSections.greek ? '▸' : '▾'}
{!collapsedSections.greek && <>
suggestGreekWordsForChunk(selectedChunk.id)}
disabled={suggestingGreekForChunkId === selectedChunk.id}
className="rounded-full bg-amber-100 px-4 py-2 text-sm font-semibold text-amber-800 border border-amber-300 transition hover:bg-amber-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Auto-populate Greek words that actually appear in this passage"
>
{suggestingGreekForChunkId === selectedChunk.id ? 'Suggesting…' : '✦ Suggest from passage'}
suggestHebrewWordsForChunk(selectedChunk.id)}
disabled={suggestingHebrewForChunkId === selectedChunk.id}
className="rounded-full bg-emerald-100 px-4 py-2 text-sm font-semibold text-emerald-800 border border-emerald-300 transition hover:bg-emerald-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Suggest Hebrew words for Old Testament passages"
>
{suggestingHebrewForChunkId === selectedChunk.id ? 'Suggesting…' : '✦ Suggest Hebrew'}
addGreekWord(selectedChunk.id)}
className="rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
>
Add Greek Word
{selectedChunk.greekWords.length === 0 ? (
No words yet. Add one to begin a lookup.
) : (
selectedChunk.greekWords.map((word) => (
Strong's number or English gloss
updateChunkWord(selectedChunk.id, word.id, { query: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); lookupWord(selectedChunk.id, word.id, 'greek'); }
}}
placeholder="G4102, H7225, 4102, or a word"
className="mt-2 block w-full rounded-2xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
lookupWord(selectedChunk.id, word.id, 'greek')}
className="inline-flex items-center justify-center rounded-2xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
>
{word.loading ? 'Looking up...' : 'Look Up Greek'}
lookupWord(selectedChunk.id, word.id, 'hebrew')}
className="inline-flex items-center justify-center rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400"
>
{word.loading ? 'Looking up...' : 'Look Up Hebrew'}
removeGreekWord(selectedChunk.id, word.id)}
className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 transition hover:bg-rose-100"
>
Delete
{word.strongNumber ? (
{word.strongNumber}
{(word.shortDefinition || word.englishGloss) && (
{word.shortDefinition || word.englishGloss}
)}
) : null}
{word.strongNumber?.startsWith('H') ? 'Hebrew word' : 'Greek word'}
{word.lexeme && (
speakOriginalWord(word.lexeme, word.strongNumber)}
className="rounded-lg bg-sky-100 px-2 py-0.5 text-[11px] font-medium text-sky-700 hover:bg-sky-200 transition"
title="Hear pronunciation"
>
🔊 Speak
)}
updateChunkWord(selectedChunk.id, word.id, { lexeme: e.target.value })}
className="mt-2 block w-full rounded-2xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
Transliteration
updateChunkWord(selectedChunk.id, word.id, { transliteration: e.target.value })}
className="mt-2 block w-full rounded-2xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
Part of speech
updateChunkWord(selectedChunk.id, word.id, { partOfSpeech: e.target.value })}
placeholder="e.g. Noun"
className="mt-2 block w-full rounded-2xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
English word
updateChunkWord(selectedChunk.id, word.id, { englishGloss: e.target.value })}
placeholder="e.g. grace"
className="mt-2 block w-full rounded-2xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
Short definition
updateChunkWord(selectedChunk.id, word.id, { shortDefinition: e.target.value })}
className="mt-2 block w-full rounded-2xl border border-slate-300 bg-slate-50 px-3 py-2 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
{word.definitionHtml ? (
Extended definition
) : null}
{(word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query.trim() ? (
Not found in BDBT — look it up manually:
{externalLookupLinks(word.query).map(({ label, url }) => (
{label} ↗
))}
navigator.clipboard.writeText(word.query.trim())}
className="rounded-lg border border-amber-300 bg-white px-3 py-1 text-xs font-medium text-amber-900 transition hover:bg-amber-100"
>
Copy "{word.query.trim()}"
) : null}
))
)}
>}
{/* Commentary */}
setCollapsedSections((c) => ({ ...c, commentary: !c.commentary }))}
className="mb-3 flex w-full items-center justify-between gap-2 text-left"
>
Commentary
{collapsedSections.commentary ? '▸' : '▾'}
{!collapsedSections.commentary && <>
setCommentarySource(e.target.value)}
className="mb-3 block w-full rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{COMMENTARY_OPTIONS.map((c) => (
{c.name}
))}
{commentaryLoading &&
Loading commentary...
}
{commentaryError &&
{commentaryError}
}
{!commentaryLoading && !commentaryError && commentaryData && (() => {
const verses = (commentaryData.chapter?.content ?? []).filter(
(item) => item.type === 'verse'
&& item.number >= selectedChunk.startVerse
&& item.number <= selectedChunk.endVerse,
);
if (verses.length === 0) {
return
No commentary found for this verse range.
;
}
return (
{verses.map((v) => (
Verse {v.number}
{(v.content ?? []).map((p, i) => (
{
e.preventDefault();
const sourceName = COMMENTARY_OPTIONS.find((c) => c.id === commentarySource)?.name ?? commentarySource;
const ref = formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '–');
const citation = `${p} (${sourceName}, ${ref}:${v.number})`;
const existing = selectedChunk.generalNotes ?? '';
const next = existing ? `${existing}\n\n${citation}` : citation;
updateChunk(selectedChunk.id, { generalNotes: next });
}}
>
{p}
))}
))}
);
})()}
>}
{/* Final episode script archive */}
setCollapsedSections((c) => ({ ...c, finalScript: !c.finalScript }))}
className="flex w-full items-center justify-between gap-2 text-left"
>
Final Script (optional)
Paste the finished script for this chunk once Claude has helped you write it — keeps the project as a complete archive. Useful for a podcast, sermon, or any spoken teaching, not required for personal study.
{collapsedSections.finalScript ? '▸' : '▾'}
{!collapsedSections.finalScript && (
<>
⬆ Upload .docx
{
const file = e.target.files?.[0];
importFinalScriptDocx(selectedChunk.id, file);
e.target.value = '';
}}
/>
Replaces the text below with the document's text.
{/* Prev / Next */}
← Previous Chunk
= allChunks.length - 1}
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
>
Next Chunk →
) : (
Use ‹ Prev / Next › above to pick a chunk.
Select a chunk from the left panel to edit its notes and Greek studies.
)}
{/* Greek word picker modal — portalled to document.body so it's never clipped by parent CSS */}
{suggestModal && createPortal(
{ if (e.target === e.currentTarget) { setSuggestModal(null); setSuggestSelection(new Set()); } }}
>
Select {suggestModal.language === 'hebrew' ? 'Hebrew' : 'Greek'} words to add
{suggestModal.helperText || "Content words are pre-checked. Uncheck any you don't need."}
{ setSuggestModal(null); setSuggestSelection(new Set()); }}
className="text-slate-400 hover:text-slate-600 text-xl leading-none"
>×
setSuggestSelection(new Set(suggestModal.words.map((w) => w.strongKey)))}
className="text-xs text-sky-600 hover:underline"
>Select all
·
setSuggestSelection(new Set())}
className="text-xs text-sky-600 hover:underline"
>Select none
{suggestSelection.size} selected
{suggestModal.words.map((w) => (
{
setSuggestSelection((prev) => {
const next = new Set(prev);
e.target.checked ? next.add(w.strongKey) : next.delete(w.strongKey);
return next;
});
}}
className="h-4 w-4 shrink-0 rounded border-slate-300 accent-slate-900"
/>
{w.def || w.strongKey}
{w.lexeme}
{w.translit && {w.translit} }
{w.strongKey}
{w.entry && (w.entry.strongs_def || w.entry.kjv_def || w.entry.derivation) && (
e.stopPropagation()}
>
Definition
)}
))}
{ setSuggestModal(null); setSuggestSelection(new Set()); }}
className="flex-1 rounded-xl border border-slate-200 py-2 text-sm font-semibold text-slate-600 hover:bg-slate-50"
>Cancel
Add {suggestSelection.size > 0 ? `${suggestSelection.size} word${suggestSelection.size > 1 ? 's' : ''}` : 'words'}
,
document.body
)}
);
};
// ---------------------------------------------------------------------------
// SetupForm — extracted to avoid duplication between new-project and add-chapter flows
// ---------------------------------------------------------------------------
function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter, errorMessage, hideTitle, onField, onTitleChange, onLoad }) {
return (
);
}
export default App;