Files
study-bible-project/src/App.jsx
T
nmemmert 9e57f8088a Add admin-assisted password reset
There's no email infrastructure in this app, so a self-service
"forgot password" flow isn't feasible yet. Adds a "Reset Password"
button per user in the admin panel instead: generates a random
temporary password (shown once, for the admin to relay out-of-band),
overwrites the user's password hash, and signs them out of every
existing session so a stolen session can't outlive the reset.

Verified live: old password rejected after reset, new temporary
password logs in successfully.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 14:31:06 -04:00

6377 lines
287 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
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 = `<h2 class="chapter-heading">${ch.book} ${ch.chapter}</h2>`;
const chunkSections = ch.chunks.map((chunk) => {
const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
.map((verse) => `<p class="verse"><strong>${verse.chapter}:${verse.number}</strong> ${verse.text}</p>`)
.join('');
const observation = (chunk.observation ?? '').trim().replace(/\n/g, '<br />') || '<em>No observation.</em>';
const interpretation = (chunk.interpretation ?? '').trim().replace(/\n/g, '<br />') || '<em>No interpretation.</em>';
const application = (chunk.application ?? '').trim().replace(/\n/g, '<br />') || '<em>No application.</em>';
const crossRefsHtml = (chunk.crossReferences ?? []).length > 0
? `<div class="cross-refs"><strong>CROSS-REFERENCES:</strong> ${chunk.crossReferences.join(', ')}</div>`
: '';
const greekRows = chunk.greekWords.map((word) => `
<tr>
<td><strong>${word.strongNumber}</strong></td>
<td>${word.lexeme || ''}</td>
<td>${word.transliteration || ''}</td>
<td>${word.partOfSpeech || ''}</td>
<td>${word.shortDefinition || ''}</td>
</tr>
`).join('');
const greekTable = wordTableHtml(greekRows);
const extendedDefinitions = chunk.greekWords
.map((word) => {
if (word.definitionHtml) {
return `
<div class="definition-block">
<p><strong>${word.strongNumber}${word.lexeme || ''}</strong></p>
<div class="definition">${word.definitionHtml}</div>
</div>
`;
}
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
? `<a href="https://biblehub.com/greek/${num}.htm">BibleHub</a> · <a href="https://www.blueletterbible.org/lexicon/g${num}/esv/0-1/">Blue Letter Bible</a> · <a href="https://www.studylight.org/lexicons/eng/greek/${num}.html">StudyLight</a>`
: `<a href="https://biblehub.com/search.php?q=${encodeURIComponent(raw)}">BibleHub</a> · <a href="https://www.blueletterbible.org/search/search.cfm?Criteria=${encodeURIComponent(raw)}&t=KJV#s=s_lexiconc">Blue Letter Bible</a>`;
return `
<div class="definition-block" style="border-color:#fcd34d;background:#fffbeb;">
<p><strong>${raw}</strong> — definition not found in auto-lookup</p>
<p style="margin-top:0.5rem;font-size:0.9rem;">Look it up manually: ${links}</p>
</div>
`;
}
return '';
})
.join('');
return `
<section class="chunk">
<div class="scripture-ref">${scripture}</div>
${versesText}
<div class="oia">
<div class="oia-section"><strong>OBSERVATION:</strong><p>${observation}</p></div>
<div class="oia-section"><strong>INTERPRETATION:</strong><p>${interpretation}</p></div>
<div class="oia-section"><strong>APPLICATION:</strong><p>${application}</p></div>
</div>
${crossRefsHtml}
<div class="greek"><strong>GREEK WORDS:</strong>
${greekRows ? greekTable : '<p><em>No Greek word notes.</em></p>'}
${extendedDefinitions}
</div>
</section>
`;
}).join('');
return chapterHeader + chunkSections;
}).join('');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${project.title}</title>
<style>${style}</style>
</head>
<body>
<div class="page">
<h1>${project.title}</h1>
<p>${project.translation}</p>
${chunksHtml}
</div>
</body>
</html>`;
}
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 `
<table>
<thead>
<tr>
<th>Strong's</th>
<th>Greek</th>
<th>Transliteration</th>
<th>Part of Speech</th>
<th>Short Definition</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
}
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 23 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 XXXX 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:12", "9:3210:23", "9:119a", 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 (
<svg viewBox="0 0 20 20" width="16" height="16" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.6">
<path d="M5 3.5C5 2.67 5.67 2 6.5 2h7c.83 0 1.5.67 1.5 1.5v14l-5-3.5-5 3.5v-14z" strokeLinejoin="round" />
</svg>
);
}
function CopyIcon() {
return (
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6">
<rect x="7" y="7" width="10" height="11" rx="1.5" />
<path d="M4.5 13.5h-1A1.5 1.5 0 0 1 2 12V3.5A1.5 1.5 0 0 1 3.5 2H12a1.5 1.5 0 0 1 1.5 1.5v1" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
// 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 (
<span
className="relative inline-flex items-center gap-1 rounded-full bg-slate-200 px-3 py-1 text-sm text-slate-800"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{label}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="ml-1 text-slate-500 hover:text-rose-600"
aria-label={`Remove ${label}`}
>
×
</button>
)}
{hovered && (
<span className="absolute bottom-full left-0 z-10 mb-2 w-64 rounded-xl border border-slate-300 bg-white p-3 text-xs font-normal normal-case text-slate-700 shadow-lg">
{error || verseText || 'Loading...'}
</span>
)}
</span>
);
}
// ---------------------------------------------------------------------------
// 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 [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',
);
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 += `<p>${entry.strongs_def.trim()}</p>`;
if (entry.derivation) html += `<p><em>Derivation:</em> ${entry.derivation}</p>`;
if (entry.kjv_def) html += `<p><em>KJV uses:</em> ${entry.kjv_def}</p>`;
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 && (
<div className="flex items-center gap-2 text-sm text-slate-300">
<span className="hidden sm:inline">{authUser.email}</span>
{authUser.isAdmin && (
<button
type="button"
onClick={() => 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
</button>
)}
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={async () => {
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
</button>
</div>
);
// ---------------------------------------------------------------------------
// Shared header
// ---------------------------------------------------------------------------
const headerButtons = (
<div className="flex flex-wrap items-center gap-3">
{currentPage !== 'home' && (
<button
type="button"
onClick={goHome}
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
>
Home
</button>
)}
{project && currentPage === 'study' && (
<button
type="button"
onClick={() => 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
</button>
)}
{project && (
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={copyForClaude}
disabled={allChunks.length === 0}
className="rounded-md bg-violet-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-violet-400 disabled:cursor-not-allowed disabled:bg-slate-500"
>
Prepare for Claude
</button>
<button
type="button"
onClick={copyForPodcast}
disabled={allChunks.length === 0}
title="Copy a prompt for Claude to write a full spoken-word episode script from your notes, ready to record. Optional — only useful if you're producing a podcast or similar audio series. Set your show name in Account Settings first."
className="rounded-md bg-fuchsia-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-fuchsia-400 disabled:cursor-not-allowed disabled:bg-slate-500"
>
🎙 Prepare for Podcast
</button>
<button
type="button"
onClick={copyPronunciationGuide}
disabled={allChunks.length === 0}
title="Copy a quick word + transliteration list to keep open while recording"
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:bg-slate-500"
>
🗣 Pronunciation Guide
</button>
<div className="relative">
<button
type="button"
onClick={() => 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
</button>
{exportMenuOpen && (
<div className="absolute left-0 top-full z-20 mt-2 w-56 overflow-hidden rounded-2xl border border-slate-200 bg-white py-1.5 text-left shadow-lg">
<button type="button" onClick={() => { exportChapter(); setExportMenuOpen(false); }}
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export HTML
</button>
<button type="button" onClick={() => { exportChapterDocx(); setExportMenuOpen(false); }}
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
Export DOCX
</button>
<button type="button" onClick={() => { 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
</button>
<button type="button" onClick={() => { 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
</button>
</div>
)}
</div>
<div className="relative">
<button
type="button"
onClick={() => 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'}
</button>
{sharePanelOpen && (
<div className="absolute right-0 top-full z-20 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-lg">
<h3 className="text-sm font-semibold text-slate-900">Read-only share link</h3>
<p className="mt-1 text-xs text-slate-500">
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.
</p>
{shareToken ? (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<input
readOnly
value={`${window.location.origin}${window.location.pathname}?share=${shareToken}`}
onFocus={(e) => e.target.select()}
className="w-full flex-1 truncate rounded-lg border border-slate-300 bg-slate-50 px-2 py-1.5 text-xs text-slate-600"
/>
<button type="button" onClick={handleCopyShareLink}
className="shrink-0 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-400">
{shareCopied ? 'Copied!' : 'Copy'}
</button>
</div>
<button type="button" onClick={handleDisableSharing} disabled={shareBusy}
className="w-full rounded-lg border border-rose-200 px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50">
{shareBusy ? 'Revoking…' : 'Revoke link'}
</button>
</div>
) : (
<button type="button" onClick={handleEnableSharing} disabled={shareBusy}
className="mt-3 w-full rounded-lg bg-teal-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-teal-500 disabled:cursor-not-allowed disabled:opacity-50">
{shareBusy ? 'Enabling…' : 'Enable sharing'}
</button>
)}
</div>
)}
</div>
</div>
)}
<div className="text-right text-sm text-slate-300 space-y-0.5">
{loadingChapter ? (
<span>Loading</span>
) : saveStatus ? (
<span className="text-emerald-300">{saveStatus}</span>
) : (
<span>&nbsp;</span>
)}
{syncStatus === 'syncing' && <div className="text-xs text-slate-400">Syncing</div>}
{syncStatus === 'synced' && <div className="text-xs text-emerald-400">Synced </div>}
{syncStatus === 'error' && (
<div className="flex items-center gap-2">
<span className="text-xs text-rose-400"> Sync failed</span>
<button
type="button"
onClick={async () => {
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
</button>
</div>
)}
</div>
{authStatus}
</div>
);
// ---------------------------------------------------------------------------
// 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 (
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4">
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 text-center shadow-panel">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
<p className="mt-4 text-sm text-rose-600">{sharedError}</p>
</div>
</div>
);
}
if (!sharedProject) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-900">
<p className="text-sm text-slate-400">Loading shared study</p>
</div>
);
}
return (
<div className="min-h-screen bg-slate-100">
<div className="border-b border-slate-200 bg-slate-900 px-4 py-3 text-center text-xs text-slate-300">
🔗 Read-only shared view <a href="/" className="underline hover:text-white">go to Bible Study Project</a>
</div>
<iframe
title="Shared study"
srcDoc={buildExportHtml(sharedProject)}
sandbox="allow-popups"
className="h-[calc(100vh-2.5rem)] w-full border-0"
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Auth gate — shown when the server is reachable but no session is present
// ---------------------------------------------------------------------------
if (authUser === undefined && !authServerDown) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-900">
<p className="text-sm text-slate-400">Loading</p>
</div>
);
}
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 (
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4">
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 shadow-panel">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
<h1 className="mt-2 text-xl font-semibold text-slate-900">Two-factor verification</h1>
<p className="mt-2 text-sm text-slate-500">
{authMfaUseBackup
? 'Enter one of your saved backup codes.'
: 'Enter the 6-digit code from your authenticator app.'}
</p>
<form onSubmit={submitMfa} className="mt-6 space-y-4">
<input
type="text"
required
autoFocus
inputMode={authMfaUseBackup ? 'text' : 'numeric'}
placeholder={authMfaUseBackup ? 'xxxxxxxxxx' : '123456'}
value={authMfaCode}
onChange={(e) => setAuthMfaCode(e.target.value)}
className="block w-full rounded-xl border border-slate-300 px-3 py-2 text-center text-lg tracking-widest shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
{authError && <p className="text-sm text-rose-600">{authError}</p>}
<button
type="submit"
disabled={authBusy}
className="w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{authBusy ? 'Verifying…' : 'Verify'}
</button>
</form>
<button
type="button"
onClick={() => {
setAuthMfaUseBackup((v) => !v);
setAuthMfaCode('');
setAuthError('');
}}
className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700"
>
{authMfaUseBackup ? 'Use your authenticator app instead' : "Lost your device? Use a backup code"}
</button>
<button
type="button"
onClick={async () => {
await logoutUser();
setAuthMfaPending(false);
setAuthMfaCode('');
setAuthError('');
}}
className="mt-2 w-full text-center text-sm text-slate-400 underline hover:text-slate-600"
>
Cancel
</button>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4 py-12">
<div className="grid w-full max-w-5xl items-center gap-16 lg:grid-cols-2">
{/* Branding / feature panel — hidden on small screens to keep the form front and center there */}
<div className="hidden lg:block">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
<h1 className="mt-4 text-3xl font-semibold leading-tight text-white">
Deep Bible study,<br />organized chunk by chunk.
</h1>
<p className="mt-4 max-w-sm text-slate-300">
Split any passage into chunks and work through Observation, Interpretation, and
Application notes alongside Greek &amp; Hebrew word studies, cross-references, and commentary.
</p>
<ul className="mt-8 space-y-4 text-sm text-slate-300">
<li className="flex items-center gap-3"><span className="text-lg">📖</span> Chunk-by-chunk OIA notes on any passage</li>
<li className="flex items-center gap-3"><span className="text-lg">🔤</span> Greek &amp; Hebrew word studies with pronunciation</li>
<li className="flex items-center gap-3"><span className="text-lg">🔗</span> Cross-references and commentary, one click away</li>
<li className="flex items-center gap-3"><span className="text-lg">🎙</span> Turn your notes into a podcast-ready script</li>
<li className="flex items-center gap-3"><span className="text-lg">🔒</span> Your studies stay private to your account, with optional 2FA</li>
</ul>
<blockquote className="mt-8 border-l-2 border-slate-700 pl-4 text-sm italic text-slate-400">
"Make every effort to present yourself approved to God, an unashamed workman who accurately
handles the word of truth."
<footer className="mt-1 not-italic text-slate-500"> 2 Timothy 2:15 (BSB)</footer>
</blockquote>
</div>
{/* Sign in / register form */}
<div className="flex justify-center lg:justify-start">
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 shadow-panel">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400 lg:hidden">Bible Study Project</p>
<h1 className="mt-2 text-xl font-semibold text-slate-900">
{authMode === 'login' ? 'Sign in' : 'Create an account'}
</h1>
<form onSubmit={submitAuth} className="mt-6 space-y-4">
<label className="block text-sm font-medium text-slate-700">
Email
<input
type="email"
required
autoComplete="email"
value={authForm.email}
onChange={(e) => setAuthForm((f) => ({ ...f, email: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<label className="block text-sm font-medium text-slate-700">
Password
<input
type="password"
required
minLength={8}
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
value={authForm.password}
onChange={(e) => setAuthForm((f) => ({ ...f, password: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
{authError && <p className="text-sm text-rose-600">{authError}</p>}
<button
type="submit"
disabled={authBusy}
className="w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
</button>
</form>
<button
type="button"
onClick={() => {
setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
setAuthError('');
}}
className="mt-4 w-full text-center text-sm text-slate-500 underline hover:text-slate-700"
>
{authMode === 'login' ? "Need an account? Sign up" : 'Already have an account? Sign in'}
</button>
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">🛡 Admin</h1>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={goHome}
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
Back
</button>
{authStatus}
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
<div className="flex gap-2">
<button type="button" onClick={() => setAdminTab('users')}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'users' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
Users ({adminUsers.length})
</button>
<button type="button" onClick={() => setAdminTab('projects')}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition ${adminTab === 'projects' ? 'bg-slate-900 text-white' : 'border border-slate-300 bg-white text-slate-600 hover:bg-slate-50'}`}>
Projects ({adminProjects.length})
</button>
</div>
{adminLoading && <p className="text-sm text-slate-500">Loading</p>}
{adminError && <p className="text-sm text-rose-600">{adminError}</p>}
{!adminLoading && !adminError && adminTab === 'users' && (
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th className="px-4 py-3">Email</th>
<th className="px-4 py-3">Joined</th>
<th className="px-4 py-3">2FA</th>
<th className="px-4 py-3">Projects</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{adminUsers.map((u) => (
<tr key={u.id} className="border-t border-slate-100">
<td className="px-4 py-3 font-medium text-slate-800">
{u.email}{u.id === authUser.id && <span className="ml-2 text-xs font-normal text-slate-400">(you)</span>}
</td>
<td className="px-4 py-3 text-slate-500">{new Date(u.createdAt).toLocaleDateString()}</td>
<td className="px-4 py-3 text-slate-500">{u.totpEnabled ? '✓' : '—'}</td>
<td className="px-4 py-3 text-slate-500">{u.projectCount}</td>
<td className="px-4 py-3 text-right">
{u.id !== authUser.id && (
<div className="flex justify-end gap-2">
<button type="button" onClick={() => handleResetPasswordAdmin(u.id, u.email)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
Reset Password
</button>
<button type="button" onClick={() => handleDeleteUserAdmin(u.id, u.email)}
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
Delete
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{!adminLoading && !adminError && adminTab === 'projects' && (
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th className="px-4 py-3">Title</th>
<th className="px-4 py-3">Owner</th>
<th className="px-4 py-3">Passage</th>
<th className="px-4 py-3">Last edited</th>
<th className="px-4 py-3">Shared</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{adminProjects.map((p) => (
<tr key={p.id} className="border-t border-slate-100">
<td className="px-4 py-3 font-medium text-slate-800">{p.title}</td>
<td className="px-4 py-3 text-slate-500">
{p.ownerEmail ?? <span className="italic text-slate-400">orphaned</span>}
</td>
<td className="px-4 py-3 text-slate-500">{p.chapterSummary}</td>
<td className="px-4 py-3 text-slate-500">{new Date(p.lastEdited).toLocaleDateString()}</td>
<td className="px-4 py-3 text-slate-500">{p.shareToken ? '🔗' : '—'}</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-2">
<button type="button" onClick={() => handleViewProjectAdmin(p.id)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
View
</button>
<button type="button" onClick={() => handleDeleteProjectAdmin(p.id, p.title)}
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
{adminViewProject && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setAdminViewProject(null)}>
<div className="h-full w-full max-w-4xl overflow-hidden rounded-2xl bg-white shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<p className="text-sm font-semibold text-slate-700">{adminViewProject.title}</p>
<button type="button" onClick={() => setAdminViewProject(null)}
className="rounded-lg border border-slate-300 px-3 py-1 text-xs text-slate-600 hover:bg-slate-50">
Close
</button>
</div>
<iframe
title="Project preview"
srcDoc={buildExportHtml(adminViewProject)}
sandbox="allow-popups"
className="h-[calc(100%-3rem)] w-full border-0"
/>
</div>
</div>
)}
{adminResetResult && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
<h3 className="text-sm font-semibold text-slate-900">Password reset</h3>
<p className="mt-1 text-xs text-slate-500">
Relay this to <span className="font-medium text-slate-700">{adminResetResult.email}</span> yourself
(text, call, in person) it won't be shown again. They're signed out everywhere until they log in with it.
</p>
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-center font-mono text-lg tracking-wider text-amber-800">
{adminResetResult.temporaryPassword}
</p>
<button type="button" onClick={() => setAdminResetResult(null)}
className="mt-4 w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400">
Done
</button>
</div>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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 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 (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">Account Settings</h1>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={goHome}
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
Back
</button>
{authStatus}
</div>
</div>
</header>
<main className="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-2">
<h2 className="text-lg font-semibold text-slate-900">Account</h2>
<p className="text-sm text-slate-500">
Signed in as <span className="font-medium text-slate-700">{authUser.email}</span>
</p>
</section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Podcast / show name</h2>
<p className="text-sm text-slate-500">
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.
</p>
</div>
<form onSubmit={submitPodcastName} className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="text"
value={podcastNameInput}
onChange={(e) => setPodcastNameInput(e.target.value)}
placeholder="e.g. Verse by Verse with Nate: A Journey Through Scripture"
className="w-full flex-1 rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
<button
type="submit"
disabled={podcastNameSaving}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{podcastNameSaving ? 'Saving' : 'Save'}
</button>
</form>
{podcastNameSaved && <p className="text-sm text-emerald-600">Saved.</p>}
</section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Two-factor authentication</h2>
<p className="text-sm text-slate-500">
{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.'}
</p>
</div>
{mfaBackupCodes ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 space-y-3">
<p className="text-sm font-semibold text-amber-800">
Save these backup codes now — each works once if you ever lose your device. They won't be shown again.
</p>
<div className="grid grid-cols-2 gap-2 font-mono text-sm text-slate-800">
{mfaBackupCodes.map((code) => (
<div key={code} className="rounded-lg border border-amber-200 bg-white px-3 py-1.5">{code}</div>
))}
</div>
<button
type="button"
onClick={() => 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
</button>
</div>
) : authUser.totpEnabled ? (
<form onSubmit={submitDisable} className="space-y-3">
<label className="block max-w-xs text-sm font-medium text-slate-700">
Enter your password to disable 2FA
<input
type="password"
required
value={mfaDisablePassword}
onChange={(e) => setMfaDisablePassword(e.target.value)}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
{mfaDisableError && <p className="text-sm text-rose-600">{mfaDisableError}</p>}
<button
type="submit"
className="rounded-md bg-rose-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-rose-400"
>
Disable 2FA
</button>
</form>
) : mfaSetup ? (
<form onSubmit={confirmSetup} className="space-y-4">
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start">
<img src={mfaSetup.qrCodeDataUrl} alt="2FA QR code" className="h-40 w-40 rounded-xl border border-slate-200" />
<div className="space-y-1 text-sm text-slate-600">
<p>Scan this with your authenticator app, or enter the code manually:</p>
<p className="break-all rounded-lg bg-slate-100 px-2 py-1 font-mono text-xs">{mfaSetup.secret}</p>
</div>
</div>
<label className="block max-w-xs text-sm font-medium text-slate-700">
Enter the 6-digit code it shows
<input
type="text"
required
inputMode="numeric"
placeholder="123456"
value={mfaSetupCode}
onChange={(e) => setMfaSetupCode(e.target.value)}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-center text-lg tracking-widest shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
{mfaSetupError && <p className="text-sm text-rose-600">{mfaSetupError}</p>}
<div className="flex gap-3">
<button
type="submit"
disabled={mfaSetupBusy}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{mfaSetupBusy ? 'Verifying…' : 'Confirm & enable'}
</button>
<button
type="button"
onClick={cancelSetup}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50"
>
Cancel
</button>
</div>
</form>
) : (
<div className="space-y-2">
<button
type="button"
onClick={startSetup}
disabled={mfaSetupBusy}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{mfaSetupBusy ? 'Starting…' : 'Enable 2FA'}
</button>
{mfaSetupError && <p className="text-sm text-rose-600">{mfaSetupError}</p>}
</div>
)}
</section>
</main>
</div>
);
}
// ---------------------------------------------------------------------------
// HOME PAGE
// ---------------------------------------------------------------------------
if (currentPage === 'home') {
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">My Studies</h1>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={openBibleReader}
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
📖 Read Bible
</button>
<button
type="button"
onClick={openImportProject}
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
📥 Import Session List
</button>
<button
type="button"
onClick={openNewProject}
className="rounded-xl bg-slate-700 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-600"
>
+ New Project
</button>
{authStatus}
</div>
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{autoRestoredCount !== null && (
<div className="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
<p className="text-sm font-semibold text-emerald-800">
📥 Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.
</p>
</div>
)}
{staleLocalProjects.length > 0 && (
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-4">
<p className="mb-3 text-sm font-semibold text-amber-800">
☁️ {staleLocalProjects.length} project{staleLocalProjects.length > 1 ? 's have' : ' has'} a newer version on the server:
</p>
<div className="flex flex-wrap gap-2">
{staleLocalProjects.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => pullLatestFromServer(entry.id)}
className="rounded-xl bg-amber-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-amber-600"
>
Pull latest "{entry.title}"
</button>
))}
</div>
</div>
)}
{projectIndex.length === 0 ? (
<div className="mx-auto max-w-xl rounded-3xl border border-dashed border-slate-300 bg-white p-10 text-center shadow-panel">
<p className="text-lg font-semibold text-slate-700">No projects yet</p>
<p className="mt-2 text-sm text-slate-500">Start a new Bible study to get going.</p>
<button
type="button"
onClick={openNewProject}
className="mt-6 rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800"
>
+ New Project
</button>
</div>
) : (
<>
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<input
type="text"
value={homeSearch}
onChange={(e) => setHomeSearch(e.target.value)}
placeholder="Search projects by title or passage"
className="w-full max-w-sm rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
<label className="text-sm text-slate-600">
Sort by{' '}
<select
value={homeSort}
onChange={(e) => setHomeSort(e.target.value)}
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
<option value="recent">Last edited</option>
<option value="title">Title</option>
<option value="passage">Passage</option>
</select>
</label>
{(() => {
const allTags = Array.from(
new Set(projectIndex.flatMap((entry) => entry.tags ?? [])),
).sort((a, b) => a.localeCompare(b));
if (allTags.length === 0) return null;
return (
<label className="text-sm text-slate-600">
Tag{' '}
<select
value={homeTagFilter}
onChange={(e) => setHomeTagFilter(e.target.value)}
className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
<option value="">All tags</option>
{allTags.map((tag) => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
</label>
);
})()}
</div>
<div className="mb-4 flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1">
<h3 className="text-base font-semibold text-slate-900">Listen to BSB Audio</h3>
<p className="mt-1 text-sm text-slate-600">
{audioState.status === 'idle' || audioState.status === 'error'
? 'Play a full book of the Berean Standard Bible.'
: `Playing ${bookOptions.find((b) => b.abbrev === audioBook)?.name} — chapter ${audioState.chapter} of ${audioState.total}`}
</p>
{audioState.status === 'error' && (
<p className="mt-1 text-sm text-rose-600">Couldn't load audio for this book/narrator.</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={audioBook}
onChange={(e) => setAudioBook(e.target.value)}
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
>
{bookOptions.map((book) => (
<option key={book.abbrev} value={book.abbrev}>{book.name}</option>
))}
</select>
<select
value={audioNarrator}
onChange={(e) => setAudioNarrator(e.target.value)}
disabled={audioState.status === 'playing' || audioState.status === 'paused'}
className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-60"
>
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
</select>
{audioState.status === 'playing' || audioState.status === 'paused' ? (
<>
<button
type="button"
onClick={handleToggleBookAudioPause}
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500"
>
{audioState.status === 'paused' ? 'Resume' : 'Pause'}
</button>
<button
type="button"
onClick={handleStopBookAudio}
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"
>
Stop
</button>
</>
) : (
<button
type="button"
onClick={handlePlayBookAudio}
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500"
>
Play book
</button>
)}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projectIndex
.slice()
.filter((entry) => {
if (homeTagFilter && !(entry.tags ?? []).includes(homeTagFilter)) return false;
const q = homeSearch.trim().toLowerCase();
if (!q) return true;
return entry.title?.toLowerCase().includes(q)
|| entry.chapterSummary?.toLowerCase().includes(q);
})
.sort((a, b) => {
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
})
.map((entry) => (
<div
key={entry.id}
className="flex flex-col gap-4 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel"
>
<div className="flex-1">
{renamingId === entry.id ? (
<div className="flex items-center gap-2">
<input
type="text"
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const trimmed = renameValue.trim();
if (trimmed) renameProjectInStorage(entry.id, trimmed);
setRenamingId(null);
} else if (e.key === 'Escape') {
setRenamingId(null);
}
}}
className="flex-1 rounded-lg border border-slate-300 px-2 py-1 text-base font-semibold text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
<button
type="button"
onClick={() => {
const trimmed = renameValue.trim();
if (trimmed) renameProjectInStorage(entry.id, trimmed);
setRenamingId(null);
}}
className="text-sm font-semibold text-emerald-600 hover:text-emerald-700"
>
Save
</button>
<button
type="button"
onClick={() => setRenamingId(null)}
className="text-sm text-slate-400 hover:text-slate-600"
>
×
</button>
</div>
) : (
<div className="flex items-start justify-between gap-2">
<h2 className="text-base font-semibold text-slate-900">{entry.title}</h2>
<button
type="button"
onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }}
className="shrink-0 text-xs text-slate-400 hover:text-slate-600"
title="Rename project"
>
✎ Rename
</button>
</div>
)}
{entry.chapterSummary && (
<p className="mt-1 text-sm text-slate-500">{entry.chapterSummary}</p>
)}
{entry.tags?.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{entry.tags.map((tag) => (
<span key={tag} className="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-semibold text-indigo-700">
{tag}
</span>
))}
</div>
)}
<p className="mt-1 text-xs text-slate-400">{formatRelativeDate(entry.lastEdited)}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => resumeProject(entry.id)}
className="flex-1 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-800"
>
Resume
</button>
<button
type="button"
onClick={() => deleteProject(entry.id)}
className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 transition hover:bg-rose-100"
>
Delete
</button>
</div>
</div>
))}
</div>
</>
)}
</main>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">Read the Bible (BSB)</h1>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={goHome}
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
← Back to Studies
</button>
{authStatus}
</div>
</div>
</header>
<main className="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
{/* Navigation + tools bar */}
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel">
<label className="text-sm text-slate-600">
Book{' '}
<select
value={readerBookAbbrev}
onChange={(e) => 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) => (
<option key={b.abbrev} value={b.abbrev}>{b.name}</option>
))}
</select>
</label>
<label className="text-sm text-slate-600">
Chapter{' '}
<select
value={readerChapter}
onChange={(e) => { 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) => (
<option key={num} value={num}>{num}</option>
))}
</select>
</label>
<div className="ml-auto flex items-center gap-2">
<button type="button" onClick={readerGoToPreviousChapter} disabled={readerChapter <= 1}
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">
Prev
</button>
<button type="button" onClick={readerGoToNextChapter} disabled={readerChapter >= 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
</button>
</div>
</div>
{/* Tool strip: font size · cross-refs · search */}
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-3xl border border-slate-200 bg-white px-4 py-3 shadow-panel">
{/* Font size */}
<span className="text-xs text-slate-500 mr-1">Text size</span>
{[['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25]].map(([label, size]) => (
<button key={label} type="button"
onClick={() => 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}
</button>
))}
<div className="mx-2 h-4 w-px bg-slate-200" />
{/* Cross-refs toggle */}
<button type="button"
onClick={() => { 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'}
</button>
<div className="mx-2 h-4 w-px bg-slate-200" />
{/* Bookmarks panel */}
<div className="relative">
<button type="button"
onClick={() => 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'}`}>
<BookmarkIcon filled={bookmarkEntries.length > 0} />
Bookmarks{bookmarkEntries.length > 0 ? ` (${bookmarkEntries.length})` : ''}
</button>
{readerBookmarksPanelOpen && (
<div className="absolute left-0 top-full z-20 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-3 shadow-lg">
{bookmarkEntries.length === 0 ? (
<p className="p-2 text-sm text-slate-500">
No bookmarks yet. Tap the bookmark icon next to any verse to save it here.
</p>
) : (
<div className="max-h-80 space-y-1 overflow-y-auto">
{bookmarkEntries.map((entry) => (
<div key={entry.key} className="flex items-center gap-2 rounded-xl px-2 py-1.5 hover:bg-slate-50">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: entry.color }} />
<button type="button"
onClick={() => 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}
</button>
<button type="button"
onClick={() => toggleReaderBookmark(entry.key)}
title="Remove bookmark"
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-rose-600">
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
<div className="mx-2 h-4 w-px bg-slate-200" />
{/* Search — this chapter or the whole Bible */}
{readerSearchActive ? (
<div className="flex items-center gap-1">
<div className="flex overflow-hidden rounded-lg border border-slate-300">
<button type="button"
onClick={() => setReaderSearchScope('chapter')}
className={`px-2 py-1 text-xs font-semibold transition ${readerSearchScope === 'chapter' ? 'bg-slate-900 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}>
This chapter
</button>
<button type="button"
onClick={() => { setReaderSearchScope('bible'); if (bibleIndexStatus === 'idle') loadBibleIndex(); }}
className={`px-2 py-1 text-xs font-semibold transition ${readerSearchScope === 'bible' ? 'bg-slate-900 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}>
Whole Bible
</button>
</div>
<input
autoFocus
type="text"
value={readerSearch}
onChange={(e) => setReaderSearch(e.target.value)}
placeholder={readerSearchScope === 'bible' ? 'Search the whole Bible…' : 'Search this chapter…'}
className="rounded-xl border border-slate-300 bg-slate-50 px-3 py-1 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
<button type="button" onClick={() => { setReaderSearch(''); setReaderSearchActive(false); }}
className="rounded-lg border border-slate-300 px-2 py-1 text-xs text-slate-500 hover:bg-slate-50">✕</button>
</div>
) : (
<button type="button" onClick={() => 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
</button>
)}
</div>
{/* Whole-Bible search results */}
{readerSearchActive && readerSearchScope === 'bible' && (
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
{bibleIndexStatus === 'loading' && (
<p className="text-sm text-slate-500">Loading the full Bible for search — this happens once per visit (~7MB)…</p>
)}
{bibleIndexStatus === 'error' && (
<p className="text-sm text-rose-600">Couldn't load the full Bible for search. <button type="button" onClick={loadBibleIndex} className="underline">Try again</button></p>
)}
{bibleIndexStatus === 'ready' && (() => {
const q = readerSearch.trim().toLowerCase();
if (!q) return <p className="text-sm text-slate-500">Type at least a word to search all 66 books.</p>;
const index = _bibleIndexCacheRef.current.BSB ?? [];
const matches = index.filter((v) => v.text.toLowerCase().includes(q));
if (matches.length === 0) return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
const shown = matches.slice(0, 100);
return (
<div className="space-y-1">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
{matches.length} match{matches.length === 1 ? '' : 'es'}{matches.length > shown.length ? ` (showing first ${shown.length})` : ''}
</p>
<div className="max-h-96 space-y-1 overflow-y-auto">
{shown.map((v) => {
const idx = v.text.toLowerCase().indexOf(q);
return (
<button key={`${v.bookAbbrev}-${v.chapter}-${v.verse}`} type="button"
onClick={() => 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">
<span className="font-semibold text-slate-700">{v.bookName} {v.chapter}:{v.verse}</span>{' '}
<span className="text-slate-600">
{v.text.slice(0, idx)}
<mark className="rounded bg-yellow-200 px-0.5">{v.text.slice(idx, idx + q.length)}</mark>
{v.text.slice(idx + q.length)}
</span>
</button>
);
})}
</div>
</div>
);
})()}
</div>
)}
{/* Audio player */}
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel">
<div className="flex-1">
<h3 className="text-sm font-semibold text-slate-900">Listen to this chapter</h3>
<p className="mt-1 text-xs text-slate-500">
{readerAudioState.status === 'error'
? "Couldn't load audio for this chapter/narrator."
: `Audio follows ${readerBook?.name} ${readerChapter} as you browse.`}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<select value={audioNarrator} onChange={(e) => 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">
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
</select>
{readerAudioState.status === 'playing' || readerAudioState.status === 'paused' ? (
<>
<button type="button" onClick={handleToggleReaderAudioPause}
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500">
{readerAudioState.status === 'paused' ? 'Resume' : 'Pause'}
</button>
<button type="button" onClick={handleStopBookAudio}
className="rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">
Stop
</button>
</>
) : (
<button type="button" onClick={handlePlayReaderAudio}
className="rounded-xl bg-sky-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-sky-500">
Play
</button>
)}
</div>
</div>
{/* Verses */}
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
<h2 className="mb-1 text-xl font-semibold text-slate-900">
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">(BSB)</span>
</h2>
{readerInterlinear && (
<p className="mb-4 text-xs text-slate-400">Click a verse number to see original words &amp; pronunciation · bookmark icon to save · copy icon to copy</p>
)}
{!readerInterlinear && (
<p className="mb-4 text-xs text-slate-400">Hover a verse for actions</p>
)}
{readerLoading && <p className="text-sm text-slate-500">Loading…</p>}
{readerError && <p className="text-sm text-rose-600">{readerError}</p>}
{!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 <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
}
return (
<div className="space-y-3 leading-relaxed text-slate-800" style={{ fontSize: `${readerFontSize}em` }}>
{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)}
<mark className="bg-yellow-200 rounded px-0.5">{text.slice(idx, idx + searchLower.length)}</mark>
{text.slice(idx + searchLower.length)}
</>
);
};
return (
<div key={verse.number} id={`reader-verse-${verse.number}`} className="group rounded-xl transition"
style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: '0.5rem' } : {}}>
<div className="flex items-start gap-1">
{/* Verse number / interlinear toggle */}
<button type="button"
onClick={() => 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}
</button>
{/* Verse text */}
<p className="flex-1">{highlightText(verse.text)}</p>
{/* Action icons — always visible so bookmarking works on touch devices too */}
<span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1">
<button type="button"
onClick={() => 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'}>
<BookmarkIcon filled={!!bmColor} />
</button>
{bmColor && (
<button type="button"
onClick={() => cycleBookmarkColor(verseKey)}
className="rounded p-1 text-sm leading-none hover:bg-slate-100"
title="Change highlight colour">
🎨
</button>
)}
<button type="button"
onClick={() => 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">
<CopyIcon />
</button>
</span>
</div>
{/* Cross-references */}
{readerShowCrossRefs && crossRefs && (
<div className="mt-1 ml-6 flex flex-wrap gap-1">
{crossRefs.slice(0, 8).map((ref, i) => {
const label = formatCrossRef(ref);
return (
<span key={i} className="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800 cursor-default" title={`Score: ${ref.score ?? '?'}`}>
{label}
</span>
);
})}
</div>
)}
{/* Interlinear panel */}
{isOpen && verseWords && (
<div className="mt-2 mb-1 ml-6 rounded-2xl border border-sky-100 bg-sky-50 p-3">
<div className="flex flex-wrap gap-2">
{verseWords.map((w, i) => {
const isHebrew = w.s?.startsWith('H');
const canSpeak = isHebrew || w.s?.startsWith('G');
return (
<div key={i} className="rounded-xl border border-sky-200 bg-white p-2 text-center shadow-sm"
style={{ minWidth: '4.5rem', maxWidth: '9rem' }}>
<div className={`text-lg font-medium leading-tight ${isHebrew ? 'font-serif' : ''}`} dir={isHebrew ? 'rtl' : 'ltr'}>
{w.o}
</div>
<div className="mt-0.5 text-xs text-slate-500 italic">{w.t}</div>
<div className="mt-1 text-xs font-semibold text-slate-800">{w.g}</div>
{w.p && <div className="mt-0.5 text-[10px] text-slate-400 leading-tight">{w.p}</div>}
{w.s && <div className="mt-0.5 text-[10px] text-slate-400">{w.s}</div>}
{canSpeak && (
<button type="button" onClick={() => 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
</button>
)}
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
);
})()}
</div>
</main>
</div>
);
}
// ---------------------------------------------------------------------------
// SETUP PAGE (chunk builder)
// ---------------------------------------------------------------------------
if (currentPage === 'import') {
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">Import Session List</h1>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setCurrentPage('home')}
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
← Back
</button>
{authStatus}
</div>
</div>
</header>
<main className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8 space-y-6">
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-5">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600 space-y-2">
<p className="font-semibold text-slate-700">How this works</p>
<p>
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.
</p>
<p>
Upload a .docx containing a table with three columns: session number, title, and passage
(e.g. <span className="font-mono text-xs">1:12</span>). Each row becomes one chunk, grouped
automatically by chapter. If you don't have a document like this, just use{' '}
<span className="font-semibold">+ New Project</span> on the home page instead this import
step is entirely optional.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block text-sm font-medium text-slate-700">
Book
<select
value={importBookAbbrev}
onChange={(e) => 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) => (
<option key={b.abbrev} value={b.abbrev}>{b.name}</option>
))}
</select>
</label>
<label className="block text-sm font-medium text-slate-700">
Translation
<select
value={importTranslation}
onChange={(e) => 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) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</label>
</div>
<label className="block text-sm font-medium text-slate-700">
Project title
<input
type="text"
value={importTitle}
onChange={(e) => 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"
/>
</label>
<label className="block text-sm font-medium text-slate-700">
Session list (.docx)
<input
type="file"
accept=".docx"
onChange={(e) => 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"
/>
</label>
{importBusy && <p className="text-sm text-slate-500">Working</p>}
{importError && <p className="text-sm text-rose-600">{importError}</p>}
{importPreview && !importBusy && (
<div className="space-y-3">
<p className="text-sm font-semibold text-slate-700">
{importPreview.length} session{importPreview.length === 1 ? '' : 's'} found
{' · '}
{importPreview.filter((s) => s.parsed && s.parsed !== 'invalid').length} with passages
</p>
<div className="max-h-80 overflow-y-auto rounded-xl border border-slate-200">
<table className="w-full text-sm">
<tbody>
{importPreview.map((spec) => (
<tr key={spec.episodeNumber} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-1.5 text-slate-500">#{spec.episodeNumber}</td>
<td className="px-3 py-1.5 text-slate-900">{spec.title}</td>
<td className="px-3 py-1.5 text-right text-slate-500">
{spec.parsed === 'invalid'
? <span className="text-rose-600">unrecognized skipped</span>
: spec.parsed
? spec.passage
: <span className="text-slate-400">marker (no passage)</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
onClick={runEpisodeImport}
disabled={importBusy}
className="rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:opacity-50"
>
Create Project from Import
</button>
</div>
)}
</section>
</main>
</div>
);
}
if (currentPage === 'setup') {
const chapterTabs = project?.chapters ?? [];
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">
{project ? project.title : 'New Study'}
</h1>
</div>
{headerButtons}
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8 space-y-8">
{/* Project setup form — shown when no project loaded yet */}
{!project && (
<section className="mx-auto max-w-3xl rounded-3xl border border-slate-200 bg-white p-8 shadow-panel">
<SetupForm
setup={setup}
availableTranslations={availableTranslations}
titleEdited={titleEdited}
loadingChapter={loadingChapter}
errorMessage={errorMessage}
onField={handleSetupField}
onTitleChange={(val) => { setTitleEdited(true); handleSetupField('title', val); }}
onLoad={handleLoadChapter}
/>
</section>
)}
{/* Chapter tabs + verse/chunk editors */}
{project && (
<section className="space-y-6">
{/* Chapter tabs */}
<div className="flex flex-wrap items-center gap-2">
{chapterTabs.map((ch, idx) => (
<button
key={`${ch.bookAbbrev}-${ch.chapter}`}
type="button"
onClick={() => { 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}
</button>
))}
<button
type="button"
onClick={() => 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
</button>
</div>
{/* Add-chapter form */}
{showAddChapterForm && (
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
<h3 className="mb-4 text-sm font-semibold text-slate-900">Add another chapter</h3>
<SetupForm
setup={setup}
availableTranslations={availableTranslations}
titleEdited={titleEdited}
loadingChapter={loadingChapter}
errorMessage={errorMessage}
hideTitle
onField={handleSetupField}
onTitleChange={(val) => { setTitleEdited(true); handleSetupField('title', val); }}
onLoad={handleLoadChapter}
/>
</div>
)}
{/* Verse + chunk panel for active chapter */}
{activeChapter && (
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-slate-500">Scripture & Chunks</p>
<h2 className="mt-2 text-xl font-semibold text-slate-900">
{activeChapter.book} {activeChapter.chapter} ({project.translation})
</h2>
</div>
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-700">
{statusMessage || 'Click/shift-click, or type a verse range, to create a chunk.'}
</div>
</div>
<div className="mt-6 grid min-w-0 gap-6 lg:grid-cols-[1.25fr_0.75fr]">
<div className="min-w-0 rounded-3xl border border-slate-200 bg-slate-50 p-4 shadow-sm">
<div className="mb-4 flex items-center justify-between gap-3">
<span className="text-sm font-medium text-slate-600">Chapter verses</span>
<span className="text-xs text-slate-500">Click a verse, then shift-click an end verse.</span>
</div>
<input
type="text"
value={verseSearch}
onChange={(e) => 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"
/>
<div data-testid="verse-list" className="max-h-[520px] overflow-y-auto rounded-3xl border border-slate-200 bg-white p-4 scrollbar-thin">
{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 (
<button
key={verse.number}
type="button"
onClick={(event) => 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'
}`}
>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-200 text-sm font-semibold text-slate-700 transition group-hover:bg-slate-300">
{verse.number}
</span>
<span className="ml-3 text-sm leading-relaxed text-slate-700">{verse.text}</span>
</button>
);
})}
</div>
</div>
<div className="min-w-0 space-y-4 rounded-3xl border border-slate-200 bg-slate-50 p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-slate-900">Chunks</h3>
<span className="text-xs text-slate-500">{activeChapter.chunks.length} created</span>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Type chunk range</p>
<div className="mt-2 flex flex-wrap items-end gap-2">
<label className="min-w-0 flex-1 text-xs text-slate-500">
Start
<input
type="number"
min="1"
max={activeChapter.verses.at(-1)?.number ?? 1}
value={typedChunkStart}
onChange={(e) => setTypedChunkStart(e.target.value)}
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<label className="min-w-0 flex-1 text-xs text-slate-500">
End
<input
type="number"
min="1"
max={activeChapter.verses.at(-1)?.number ?? 1}
value={typedChunkEnd}
onChange={(e) => setTypedChunkEnd(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTypedChunk();
}
}}
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<label className="min-w-0 flex-1 text-xs text-slate-500">
Next ch end (optional)
<input
type="number"
min="1"
max={project?.chapters?.[activeChapterIndex + 1]?.verses?.at(-1)?.number ?? 1}
value={typedChunkNextEnd}
onChange={(e) => setTypedChunkNextEnd(e.target.value)}
placeholder="e.g. 5"
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<button
type="button"
onClick={addTypedChunk}
className="rounded-xl bg-slate-900 px-3 py-2 text-xs font-semibold text-white transition hover:bg-slate-800"
>
Add
</button>
</div>
<div className="mt-3">
<label className="text-xs text-slate-500">
Bulk ranges (comma/new line; use `start-end:nextEnd` to span)
<textarea
rows={2}
value={typedChunkBulk}
onChange={(e) => setTypedChunkBulk(e.target.value)}
placeholder="1-6, 7-31:5, 6"
className="mt-1 block w-full resize-y rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<button
type="button"
onClick={addBulkTypedChunks}
className="mt-2 rounded-xl border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
>
Add All Ranges
</button>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Click-based chapter span</p>
<p className="mt-1 text-xs text-slate-500">Click a start verse in this chapter, then choose where to end in the next chapter.</p>
<div className="mt-2 flex items-end gap-2">
<div className="flex-1 text-xs text-slate-500">
Start
<div className="mt-1 rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-2 text-sm text-slate-900">
{rangeStart ?? 'Not selected'}
</div>
</div>
<label className="flex-1 text-xs text-slate-500">
Next ch end
<input
type="number"
min="1"
max={project?.chapters?.[activeChapterIndex + 1]?.verses?.at(-1)?.number ?? 1}
value={clickedSpanNextEnd}
onChange={(e) => setClickedSpanNextEnd(e.target.value)}
placeholder="e.g. 5"
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
<button
type="button"
onClick={addClickSpanChunk}
className="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700 transition hover:border-slate-400 hover:bg-slate-50"
>
Span Into Next
</button>
</div>
</div>
<div className="space-y-3 max-h-[520px] overflow-y-auto scrollbar-thin">
{activeChapter.chunks.length === 0 ? (
<div className="rounded-3xl border border-dashed border-slate-300 bg-white p-4 text-sm text-slate-500">
No chunks yet. Select verse ranges to add sections.
</div>
) : (
activeChapter.chunks.map((chunk, index) => (
<div
key={chunk.id}
className={`rounded-3xl border p-4 ${project.selectedChunkId === chunk.id ? 'border-sky-300 bg-sky-50' : 'border-slate-200 bg-white'} shadow-sm`}
>
<button
type="button"
onClick={() => updateProject((current) => ({ ...current, selectedChunkId: chunk.id }))}
className="mb-3 w-full text-left"
>
<p className="text-sm font-semibold text-slate-900">
{formatChunkReference(project, activeChapterIndex, chunk, '')}
</p>
<p className="mt-1 text-sm text-slate-600 truncate">
{activeChapter.verses.find((v) => v.number === chunk.startVerse)?.text || ''}
</p>
</button>
<div className="flex items-center gap-2 text-sm text-slate-500">
<button
type="button"
onClick={() => 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"
>
</button>
<button
type="button"
onClick={() => 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"
>
</button>
<button
type="button"
onClick={() => 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
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
)}
<div className="flex justify-end">
<button
type="button"
onClick={beginStudying}
disabled={allChunks.length === 0}
className="inline-flex items-center justify-center rounded-xl bg-slate-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-500"
>
Begin Studying
</button>
</div>
</section>
)}
</main>
</div>
);
}
// ---------------------------------------------------------------------------
// STUDY PAGE
// ---------------------------------------------------------------------------
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">{project?.title ?? ''}</h1>
{selectedChunk && selectedChunkChapterIndex >= 0 && (
<p className="mt-1 text-sm text-slate-300">
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '')}
</p>
)}
</div>
{headerButtons}
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<section className={`grid min-w-0 gap-8 ${studyLayout === 'split' ? '' : 'xl:grid-cols-[280px_1fr]'}`}>
{/* Sidebar */}
<aside className={`min-w-0 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel ${studyLayout === 'split' ? 'hidden' : ''}`}>
<div className="mb-4">
<p className="text-sm font-medium text-slate-500">Chunk Navigation</p>
<h2 className="mt-2 text-xl font-semibold text-slate-900">{allChunks.length} chunks</h2>
</div>
{allChunks.length > 6 && (
<label className="mb-4 block text-xs font-semibold uppercase tracking-wide text-slate-500">
Jump to chunk
<select
value={project?.selectedChunkId ?? ''}
onChange={(e) => updateProject((current) => ({ ...current, selectedChunkId: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 bg-slate-50 px-2.5 py-1.5 text-sm font-normal normal-case text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
>
{project?.chapters.map((ch, chapterIndex) =>
ch.chunks.map((chunk) => (
<option key={chunk.id} value={chunk.id}>
{formatChunkReference(project, chapterIndex, chunk, '')}
</option>
)),
)}
</select>
</label>
)}
<div className="space-y-6 max-h-[calc(100vh-260px)] overflow-y-auto scrollbar-thin">
{project?.chapters.map((ch, chapterIndex) => (
<div key={`${ch.bookAbbrev}-${ch.chapter}`}>
{project.chapters.length > 1 && (
<p className="mb-2 text-xs font-semibold uppercase tracking-widest text-slate-400">
{ch.book} {ch.chapter}
</p>
)}
<div className="space-y-2">
{ch.chunks.length === 0 ? (
<div className="rounded-3xl border border-dashed border-slate-200 p-3 text-xs text-slate-400">
No chunks in this chapter.
</div>
) : (
ch.chunks.map((chunk) => {
const isSelected = project.selectedChunkId === chunk.id;
const hasContent =
(chunk.observation ?? '').trim() ||
(chunk.interpretation ?? '').trim() ||
(chunk.application ?? '').trim() ||
chunk.greekWords.length > 0;
return (
<button
type="button"
key={chunk.id}
onClick={() => updateProject((current) => ({ ...current, selectedChunkId: chunk.id }))}
className={`group flex w-full flex-col gap-1 rounded-3xl border-l-4 p-3 text-left transition ${
isSelected
? 'border-amber-400 bg-amber-50'
: 'border-transparent border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-slate-900">
{formatChunkReference(project, chapterIndex, chunk, '')}
</span>
{hasContent ? <span className="text-emerald-600"></span> : null}
</div>
<p className="text-xs leading-relaxed text-slate-600 truncate">
{ch.verses.find((v) => v.number === chunk.startVerse)?.text || ''}
</p>
</button>
);
})
)}
</div>
</div>
))}
</div>
</aside>
{/* Chunk editor */}
<div className="min-w-0 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-slate-500">Chunk editor</p>
<h2 className="mt-2 text-xl font-semibold text-slate-900">
{selectedChunk
? `Section ${formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '')}`
: 'Select a chunk'}
</h2>
</div>
<div className="flex items-center gap-2">
{selectedChunk && (
<>
<button
type="button"
onClick={goToPreviousChunk}
disabled={selectedChunkGlobalIndex <= 0}
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"
>
Prev
</button>
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-700">
{`Chunk ${selectedChunkGlobalIndex + 1} of ${allChunks.length}`}
</div>
<button
type="button"
onClick={goToNextChunk}
disabled={selectedChunkGlobalIndex >= 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
</button>
</>
)}
{!selectedChunk && (
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-700">
Choose a chunk to study.
</div>
)}
<button
type="button"
onClick={() => setStudyLayout((m) => (m === 'split' ? 'stacked' : 'split'))}
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"
title="Toggle two-pane study layout"
>
{studyLayout === 'split' ? '☰ Stacked View' : '◫ Split View'}
</button>
</div>
</div>
{selectedChunk ? (
<div className="mt-6 space-y-6">
<div className={`space-y-6 ${studyLayout === 'split' ? 'lg:flex lg:items-start lg:gap-6 lg:space-y-0' : ''}`}>
{/* Scripture */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${studyLayout === 'split' ? 'lg:sticky lg:top-6 lg:flex-1 lg:basis-0 lg:min-w-0 lg:self-start' : ''}`}>
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-700">Scripture</p>
<p className="text-xs text-slate-500">Read-only passage for the selected chunk.</p>
</div>
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs uppercase tracking-[0.18em] text-slate-500">
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '')}
</span>
</div>
<div className="space-y-3 font-serif text-slate-800">
{selectedChunkVerses
.map((verse) => (
<p key={`${verse.chapter}-${verse.number}`} className="leading-relaxed">
<span className="font-semibold text-slate-700">{verse.chapter}:{verse.number}.</span> {verse.text}
</p>
))}
</div>
{/* Interlinear */}
<div className="mt-4 border-t border-slate-200 pt-4">
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, interlinear: !c.interlinear }))}
className="mb-2 flex w-full items-center justify-between gap-2 text-left"
>
<h3 className="text-sm font-semibold text-slate-900">Interlinear</h3>
<span className="text-slate-400">{collapsedSections.interlinear ? '▸' : '▾'}</span>
</button>
{!collapsedSections.interlinear && (
<div
className="space-y-2 font-serif text-slate-800"
dir={selectedChunkChapter && !NT_BOOK_NUMBER[selectedChunkChapter.bookAbbrev] ? 'rtl' : 'ltr'}
>
{interlinearLoading && <p className="text-sm font-sans text-slate-500">Loading interlinear text...</p>}
{interlinearError && <p className="text-sm font-sans text-rose-500">{interlinearError}</p>}
{!interlinearLoading && !interlinearError && interlinearData
&& Array.from(
{ length: selectedChunk.endVerse - selectedChunk.startVerse + 1 },
(_, i) => selectedChunk.startVerse + i,
)
.filter((num) => interlinearData[String(num)])
.map((num) => (
<div key={num} className="leading-relaxed">
<span className="font-sans font-semibold text-slate-700">{selectedChunkChapter.chapter}:{num}.</span>{' '}
<span className="mt-1 inline-flex flex-wrap gap-x-3 gap-y-2 align-top">
{interlinearData[String(num)].map((w, i) => (
<span key={i} className="inline-flex flex-col items-center text-center" title={[w.t, w.s, w.p].filter(Boolean).join(' · ')}>
<span className="text-base">{w.o}</span>
<span className="font-sans text-[11px] leading-tight text-slate-500">{w.g || '—'}</span>
</span>
))}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Right column (study panels) */}
<div className={`space-y-6 ${studyLayout === 'split' ? 'lg:flex-1 lg:basis-0 lg:min-w-0' : ''}`}>
{studyLayout === 'split' && (
<div className="sticky top-0 z-10 flex flex-wrap gap-2 rounded-3xl border border-slate-200 bg-white p-2 shadow-sm">
{STUDY_TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => 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}
</button>
))}
</div>
)}
{/* Session metadata — optional, used to label this chunk within a series */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
<h3 className="text-sm font-semibold text-slate-900">Session Info</h3>
<p className="text-xs text-slate-500">
Optional only fill this in if this chunk is part of a numbered series (a podcast episode,
sermon, or class session). Used to label it in exports and in "Prepare for Podcast."
</p>
<div className="mt-3 flex flex-col gap-3 sm:flex-row">
<input
type="text"
value={selectedChunk?.episodeNumber ?? ''}
onChange={(e) => updateChunk(selectedChunk.id, { episodeNumber: e.target.value })}
placeholder="Session #"
className="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 sm:w-32"
/>
<input
type="text"
value={selectedChunk?.episodeTitle ?? ''}
onChange={(e) => updateChunk(selectedChunk.id, { episodeTitle: e.target.value })}
placeholder="Session title"
className="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"
/>
</div>
</div>
{/* Background / general notes — unique per chunk */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, generalNotes: !c.generalNotes }))}
className="flex w-full items-center justify-between gap-2 text-left"
>
<div>
<h3 className="text-sm font-semibold text-slate-900">Background / General Notes</h3>
<p className="text-xs text-slate-500">Context, history, authorship, themes for this chunk.</p>
</div>
<span className="text-slate-400">{collapsedSections.generalNotes ? '▸' : '▾'}</span>
</button>
{!collapsedSections.generalNotes && (
<textarea
value={selectedChunk?.generalNotes ?? ''}
onChange={(e) => updateChunk(selectedChunk.id, { generalNotes: e.target.value })}
rows={4}
placeholder="e.g. author, date, audience, historical context, key themes…"
className="mt-4 w-full resize-y rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm leading-6 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
)}
</div>
{/* OIA Notes */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 space-y-4 ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, oia: !c.oia }))}
className="flex w-full items-center justify-between gap-2 text-left"
>
<div>
<h3 className="text-sm font-semibold text-slate-900">Study Notes (OIA)</h3>
<p className="text-xs text-slate-500">Observation · Interpretation · Application</p>
</div>
<span className="text-slate-400">{collapsedSections.oia ? '▸' : '▾'}</span>
</button>
{!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 }) => (
<div key={field}>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wider text-slate-500">
{label}
</label>
<textarea
value={selectedChunk[field] ?? ''}
onChange={(e) => updateChunk(selectedChunk.id, { [field]: e.target.value })}
rows={4}
placeholder={placeholder}
className="w-full resize-y rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm leading-6 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</div>
))}
</div>
{/* Tags */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
<h3 className="text-sm font-semibold text-slate-900">Tags</h3>
<p className="mb-3 text-xs text-slate-500">Label this chunk by topic so you can search across studies later.</p>
<div className="flex flex-wrap items-center gap-2">
{(selectedChunk.tags ?? []).map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700"
>
{tag}
<button
type="button"
onClick={() => removeTag(selectedChunk.id, tag)}
className="text-indigo-400 transition hover:text-indigo-700"
aria-label={`Remove tag ${tag}`}
>
×
</button>
</span>
))}
<input
type="text"
value={tagInput}
onChange={(e) => 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"
/>
</div>
{(() => {
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 (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs text-slate-500">Suggestions:</span>
{allTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => 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}
</button>
))}
</div>
);
})()}
<datalist id="tag-suggestions">
{Array.from(
new Set([
...projectIndex.flatMap((entry) => entry.tags ?? []),
...(project?.chapters ?? []).flatMap((ch) => ch.chunks ?? []).flatMap((c) => c.tags ?? []),
]),
).map((tag) => (
<option key={tag} value={tag} />
))}
</datalist>
</div>
{/* Cross-references */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${studyLayout === 'split' && activeStudyTab !== 'crossRefs' ? 'hidden' : ''}`}>
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, crossRefs: !c.crossRefs }))}
className="mb-3 flex w-full items-center justify-between gap-2 text-left"
>
<h3 className="text-sm font-semibold text-slate-900">Cross-References</h3>
<span className="text-slate-400">{collapsedSections.crossRefs ? '▸' : '▾'}</span>
</button>
{!collapsedSections.crossRefs && <>
<div className="flex gap-2">
<input
type="text"
value={crossRefInput}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => 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
</button>
</div>
<button
type="button"
onClick={() => 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'}
</button>
{crossRefSuggestions.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{crossRefSuggestions.map((ref) => (
<button
key={ref}
type="button"
onClick={() => 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}
</button>
))}
</div>
)}
{(selectedChunk.crossReferences ?? []).length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{selectedChunk.crossReferences.map((ref) => (
<CrossRefChip
key={ref}
label={ref}
onRemove={() => removeCrossRef(selectedChunk.id, ref)}
loadVerseText={loadVerseText}
/>
))}
</div>
)}
</>}
</div>
{/* Greek words */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${studyLayout === 'split' && activeStudyTab !== 'wordStudy' ? 'hidden' : ''}`}>
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, greek: !c.greek }))}
className="mb-5 flex w-full items-center justify-between gap-4 text-left"
>
<div>
<h3 className="text-sm font-semibold text-slate-900">Word Studies (Greek/Hebrew)</h3>
<p className="text-sm text-slate-500">Add lexical notes, look up Strong's entries, and suggest words from the passage.</p>
</div>
<span className="text-slate-400 shrink-0">{collapsedSections.greek ? '' : ''}</span>
</button>
{!collapsedSections.greek && <>
<div className="mb-5 flex items-center justify-end gap-4">
<div className="flex gap-2">
<button
type="button"
onClick={() => 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'}
</button>
<button
type="button"
onClick={() => 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'}
</button>
<button
type="button"
onClick={() => 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
</button>
</div>
</div>
<div className="space-y-4">
{selectedChunk.greekWords.length === 0 ? (
<div className="rounded-3xl border border-dashed border-slate-300 bg-white p-5 text-sm text-slate-500">
No words yet. Add one to begin a lookup.
</div>
) : (
selectedChunk.greekWords.map((word) => (
<div key={word.id} className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-4 sm:grid-cols-[1.4fr_1fr]">
<label className="text-sm text-slate-600">
Strong's number or English gloss
<input
type="text"
value={word.query}
onChange={(e) => 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"
/>
</label>
<div className="flex items-end justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => 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'}
</button>
<button
type="button"
onClick={() => 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'}
</button>
</div>
<button
type="button"
onClick={() => 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
</button>
</div>
</div>
{word.strongNumber ? (
<div className="mt-3 flex items-center gap-2">
<span
className="group relative inline-flex cursor-default items-center rounded-full bg-sky-100 px-2.5 py-0.5 text-xs font-semibold text-sky-700"
title={word.shortDefinition || word.englishGloss || ''}
>
{word.strongNumber}
{(word.shortDefinition || word.englishGloss) && (
<span className="pointer-events-none absolute bottom-full left-0 z-20 mb-1 hidden w-max max-w-xs rounded-xl bg-slate-900 px-3 py-2 text-xs font-normal leading-5 text-white shadow-lg group-hover:block">
{word.shortDefinition || word.englishGloss}
</span>
)}
</span>
</div>
) : null}
<div className="mt-4 grid gap-4 sm:grid-cols-3">
<label className="text-sm text-slate-600">
<span className="flex items-center gap-2">
{word.strongNumber?.startsWith('H') ? 'Hebrew word' : 'Greek word'}
{word.lexeme && (
<button
type="button"
onClick={() => 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
</button>
)}
</span>
<input
type="text"
value={word.lexeme}
onChange={(e) => 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"
/>
</label>
<label className="text-sm text-slate-600">
Transliteration
<input
type="text"
value={word.transliteration}
onChange={(e) => 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"
/>
</label>
<label className="text-sm text-slate-600">
Part of speech
<input
type="text"
value={word.partOfSpeech}
onChange={(e) => 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"
/>
</label>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="text-sm text-slate-600">
English word
<input
type="text"
value={word.englishGloss ?? ''}
onChange={(e) => 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"
/>
</label>
<label className="text-sm text-slate-600">
Short definition
<input
type="text"
value={word.shortDefinition}
onChange={(e) => 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"
/>
</label>
</div>
{word.definitionHtml ? (
<details className="group mt-4 rounded-3xl border border-slate-200 bg-slate-50 p-4">
<summary className="cursor-pointer text-sm font-semibold text-slate-900 transition hover:text-slate-700">
Extended definition
</summary>
<div
className="mt-3 text-sm leading-6 text-slate-700"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(word.definitionHtml) }}
/>
</details>
) : null}
{(word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query.trim() ? (
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4">
<p className="mb-2 text-xs font-semibold text-amber-800">
Not found in BDBT look it up manually:
</p>
<div className="flex flex-wrap gap-2">
{externalLookupLinks(word.query).map(({ label, url }) => (
<a
key={label}
href={url}
target="_blank"
rel="noreferrer"
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"
>
{label}
</a>
))}
<button
type="button"
onClick={() => 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()}"
</button>
</div>
</div>
) : null}
</div>
))
)}
</div>
</>}
</div>
{/* Commentary */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${studyLayout === 'split' && activeStudyTab !== 'commentary' ? 'hidden' : ''}`}>
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, commentary: !c.commentary }))}
className="mb-3 flex w-full items-center justify-between gap-2 text-left"
>
<h3 className="text-sm font-semibold text-slate-900">Commentary</h3>
<span className="text-slate-400">{collapsedSections.commentary ? '▸' : '▾'}</span>
</button>
{!collapsedSections.commentary && <>
<select
value={commentarySource}
onChange={(e) => 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) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
{commentaryLoading && <p className="text-sm text-slate-500">Loading commentary...</p>}
{commentaryError && <p className="text-sm text-rose-500">{commentaryError}</p>}
{!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 <p className="text-sm text-slate-500">No commentary found for this verse range.</p>;
}
return (
<div className="space-y-4">
{verses.map((v) => (
<div key={v.number}>
<p className="text-sm font-semibold text-slate-700">Verse {v.number}</p>
{(v.content ?? []).map((p, i) => (
<p
key={i}
className="mt-1 text-sm text-slate-600 cursor-context-menu"
title="Right-click to add to Background / General Notes"
onContextMenu={(e) => {
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}
</p>
))}
</div>
))}
</div>
);
})()}
</>}
</div>
{/* Final episode script archive */}
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${studyLayout === 'split' && activeStudyTab !== 'script' ? 'hidden' : ''}`}>
<button
type="button"
onClick={() => setCollapsedSections((c) => ({ ...c, finalScript: !c.finalScript }))}
className="flex w-full items-center justify-between gap-2 text-left"
>
<div>
<h3 className="text-sm font-semibold text-slate-900">Final Script <span className="font-normal text-slate-400">(optional)</span></h3>
<p className="text-xs text-slate-500">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.</p>
</div>
<span className="text-slate-400">{collapsedSections.finalScript ? '▸' : '▾'}</span>
</button>
{!collapsedSections.finalScript && (
<>
<div className="mt-4 flex items-center gap-3">
<label className="inline-flex cursor-pointer 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">
Upload .docx
<input
type="file"
accept=".docx"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
importFinalScriptDocx(selectedChunk.id, file);
e.target.value = '';
}}
/>
</label>
<span className="text-xs text-slate-500">Replaces the text below with the document's text.</span>
</div>
<textarea
value={selectedChunk?.finalScript ?? ''}
onChange={(e) => updateChunk(selectedChunk.id, { finalScript: e.target.value })}
rows={8}
placeholder="Paste the final recorded/recordable script here…"
className="mt-4 w-full resize-y rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm leading-6 text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</>
)}
</div>
</div>
</div>
{/* Prev / Next */}
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={goToPreviousChunk}
disabled={selectedChunkGlobalIndex <= 0}
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"
>
← Previous Chunk
</button>
<button
type="button"
onClick={goToNextChunk}
disabled={selectedChunkGlobalIndex >= 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 →
</button>
</div>
</div>
) : (
<div className="mt-6 rounded-3xl border border-slate-200 bg-slate-50 p-8 text-center text-slate-500">
Select a chunk from the left panel to edit its notes and Greek studies.
</div>
)}
</div>
</section>
</main>
{/* Greek word picker modal — portalled to document.body so it's never clipped by parent CSS */}
{suggestModal && createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={(e) => { if (e.target === e.currentTarget) { setSuggestModal(null); setSuggestSelection(new Set()); } }}
>
<div className="flex max-h-[80vh] w-full max-w-lg flex-col rounded-3xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
<div>
<h2 className="text-base font-semibold text-slate-900">Select {suggestModal.language === 'hebrew' ? 'Hebrew' : 'Greek'} words to add</h2>
<p className="text-xs text-slate-500 mt-0.5">{suggestModal.helperText || "Content words are pre-checked. Uncheck any you don't need."}</p>
</div>
<button
onClick={() => { setSuggestModal(null); setSuggestSelection(new Set()); }}
className="text-slate-400 hover:text-slate-600 text-xl leading-none"
>×</button>
</div>
<div className="flex items-center gap-3 border-b border-slate-100 px-6 py-2">
<button
onClick={() => setSuggestSelection(new Set(suggestModal.words.map((w) => w.strongKey)))}
className="text-xs text-sky-600 hover:underline"
>Select all</button>
<span className="text-slate-300">·</span>
<button
onClick={() => setSuggestSelection(new Set())}
className="text-xs text-sky-600 hover:underline"
>Select none</button>
<span className="ml-auto text-xs text-slate-400">{suggestSelection.size} selected</span>
</div>
<ul className="flex-1 overflow-y-auto divide-y divide-slate-100 px-4 py-2">
{suggestModal.words.map((w) => (
<li key={w.strongKey}>
<label className="flex cursor-pointer items-center gap-3 py-2.5">
<input
type="checkbox"
checked={suggestSelection.has(w.strongKey)}
onChange={(e) => {
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"
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="font-semibold text-slate-900">{w.def || w.strongKey}</span>
<span className="text-sm text-slate-500">{w.lexeme}</span>
{w.translit && <span className="text-xs text-slate-400 italic">{w.translit}</span>}
<span className="text-xs font-mono text-slate-300">{w.strongKey}</span>
</div>
{w.entry && (w.entry.strongs_def || w.entry.kjv_def || w.entry.derivation) && (
<details className="mt-1">
<summary
className="cursor-pointer text-xs font-medium text-sky-600 hover:text-sky-700"
onClick={(e) => e.stopPropagation()}
>
Definition
</summary>
<div
className="mt-1 text-xs leading-5 text-slate-600"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(buildGreekDefinitionHtml(w.strongKey, w.entry)) }}
/>
</details>
)}
</div>
</label>
</li>
))}
</ul>
<div className="flex gap-3 border-t border-slate-200 px-6 py-4">
<button
onClick={() => { 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</button>
<button
onClick={confirmSuggestWords}
disabled={suggestSelection.size === 0}
className="flex-1 rounded-xl bg-slate-900 py-2 text-sm font-semibold text-white hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"
>Add {suggestSelection.size > 0 ? `${suggestSelection.size} word${suggestSelection.size > 1 ? 's' : ''}` : 'words'}</button>
</div>
</div>
</div>,
document.body
)}
</div>
);
};
// ---------------------------------------------------------------------------
// SetupForm — extracted to avoid duplication between new-project and add-chapter flows
// ---------------------------------------------------------------------------
function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter, errorMessage, hideTitle, onField, onTitleChange, onLoad }) {
return (
<div className="space-y-6">
{!hideTitle && (
<div>
<h2 className="text-xl font-semibold text-slate-900">Project Setup</h2>
<p className="mt-2 text-sm text-slate-600">
Pick a translation, chapter, and title. Load the chapter to begin structuring your study into chunks.
</p>
</div>
)}
<div className="grid gap-5 sm:grid-cols-2">
<label className="block text-sm font-medium text-slate-700">
Translation
<select
value={setup.translation}
onChange={(e) => onField('translation', e.target.value)}
className="mt-2 block w-full rounded-xl 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"
>
{availableTranslations.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</label>
<label className="block text-sm font-medium text-slate-700">
Book
<select
value={setup.bookAbbrev}
onChange={(e) => onField('book', e.target.value)}
className="mt-2 block w-full rounded-xl 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"
>
{bookOptions.map((book) => (
<option key={book.abbrev} value={book.abbrev}>{book.name}</option>
))}
</select>
</label>
<label className="block text-sm font-medium text-slate-700">
Chapter
<input
type="number"
min="1"
value={setup.chapter}
onChange={(e) => onField('chapter', e.target.value)}
className="mt-2 block w-full rounded-xl 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"
/>
</label>
{!hideTitle && (
<label className="block text-sm font-medium text-slate-700 sm:col-span-2">
Project title
<input
type="text"
value={setup.title}
onChange={(e) => onTitleChange(e.target.value)}
className="mt-2 block w-full rounded-xl 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"
/>
</label>
)}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-slate-600">
{errorMessage
? <span className="text-rose-500">{errorMessage}</span>
: 'Start by loading the chapter text from HelloAO.'}
</div>
<button
type="button"
onClick={onLoad}
disabled={loadingChapter}
className="inline-flex items-center justify-center rounded-xl bg-slate-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:bg-slate-500"
>
{loadingChapter ? 'Loading...' : 'Load Chapter'}
</button>
</div>
</div>
);
}
export default App;