Files
study-bible-project/src/App.jsx
T
nmemmert 65039d44a5 Show expandable definitions in word study suggestion list
Lets users preview a word's full Strong's definition before
deciding whether to add it.
2026-06-11 08:37:45 -04:00

3695 lines
160 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
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 {
Document,
HeadingLevel,
Packer,
Paragraph,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
} from 'docx';
import {
saveRemoteProject,
deleteRemoteProject,
listRemoteProjects,
loadRemoteProject,
} 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 ?? '',
}; // already new format
}
return {
...chunk,
observation: chunk.notes ?? '',
interpretation: '',
application: '',
crossReferences: [],
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
// ---------------------------------------------------------------------------
const INDEX_KEY = 'bible-study-index';
const projectKey = (id) => `bible-study-project-${id}`;
function loadProjectIndex() {
try {
const raw = window.localStorage.getItem(INDEX_KEY);
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 summary = {
id: updated.id,
title: updated.title,
lastEdited: updated.lastEdited,
chapterSummary: buildChapterSummary(updated),
};
if (existing >= 0) {
index[existing] = summary;
} else {
index.push(summary);
}
window.localStorage.setItem(INDEX_KEY, 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(INDEX_KEY, 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 === INDEX_KEY || 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; }
`;
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 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 })] }));
}
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);
}
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()
? `Episode: ${[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) {
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', ');
const episodeLabel = 'EPISODE';
const header = `I'm recording an episode of my Bible study podcast "Verse by Verse with Nate: A Journey Through Scripture" 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:
VERSE BY VERSE WITH NATE
A Journey Through Scripture
${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 — hands off to Nate at the end]
A few short punchy lines previewing the passage and its hook, ending with a hand-off line introducing Nate 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 Nate 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 sign off with:
"I'm Nate, and this is Verse by Verse with Nate: A Journey Through Scripture."
"Until next time — keep studying verse by verse, and nugget by nugget."
— End of Episode —
Verse by Verse with Nate · ${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),
};
}
// 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);
const [project, setProject] = useState(null);
// 'home' | 'setup' | 'study'
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 [remoteOnlyProjects, setRemoteOnlyProjects] = useState([]); // projects on server not in localStorage
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
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 [renamingId, setRenamingId] = useState(null);
const [renameValue, setRenameValue] = useState('');
const [studyLayout, setStudyLayout] = useState('stacked'); // 'stacked' | 'split'
const [activeStudyTab, setActiveStudyTab] = useState('notes');
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 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`);
if (!res.ok) throw new Error(`No ${commentaryId} commentary for this chapter.`);
const data = await res.json();
_commentaryCacheRef.current[cacheKey] = data;
return data;
};
// ---------------------------------------------------------------------------
// Startup: migrate old keys and load index
// ---------------------------------------------------------------------------
useEffect(() => {
migrateOldStorageKeys();
const localIndex = loadProjectIndex();
setProjectIndex(localIndex);
listRemoteProjects().then((result) => {
if (!result.ok) 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) setRemoteOnlyProjects(missing);
const stale = result.data.filter((e) => {
const local = localMap.get(e.id);
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0);
});
if (stale.length > 0) setStaleLocalProjects(stale);
});
}, []);
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(() => {
setCrossRefSuggestions([]);
}, [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: '',
};
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: [],
};
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 [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);
}
};
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: entry?.kjv_def ?? 'No definition found.',
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.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] || '',
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 exportChapterDocx = async () => {
if (!project) return;
const children = [
new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }),
new Paragraph({
text: `${project.translation}${buildChapterSummary(project)}`,
spacing: { after: 300 },
}),
];
project.chapters.forEach((ch, chapterIndex) => {
children.push(new Paragraph({ text: `${ch.book} ${ch.chapter}`, heading: HeadingLevel.HEADING_1 }));
ch.chunks.forEach((chunk) => {
const scriptureHeading = formatChunkReference(project, chapterIndex, chunk, '-');
children.push(new Paragraph({ text: scriptureHeading, heading: HeadingLevel.HEADING_2 }));
getChunkVerseEntries(project, chapterIndex, chunk).forEach((verse) => {
children.push(new Paragraph({
children: [
new TextRun({ text: `${verse.chapter}:${verse.number}. `, bold: true }),
new TextRun({ text: verse.text }),
],
}));
});
children.push(new Paragraph({ text: 'OBSERVATION:', spacing: { before: 240, after: 120 }, bold: true }));
children.push(...createParagraphsFromText(chunk.observation || 'No observation.'));
children.push(new Paragraph({ text: 'INTERPRETATION:', spacing: { before: 240, after: 120 }, bold: true }));
children.push(...createParagraphsFromText(chunk.interpretation || 'No interpretation.'));
children.push(new Paragraph({ text: 'APPLICATION:', spacing: { before: 240, after: 120 }, bold: true }));
children.push(...createParagraphsFromText(chunk.application || 'No application.'));
if ((chunk.crossReferences ?? []).length > 0) {
children.push(new Paragraph({ text: 'CROSS-REFERENCES:', spacing: { before: 240, after: 120 }, bold: true }));
children.push(new Paragraph({ text: chunk.crossReferences.join(', ') }));
}
children.push(new Paragraph({ text: 'GREEK WORDS:', spacing: { before: 240, after: 120 }, bold: true }));
if (chunk.greekWords.length > 0) {
const tableRows = [
new TableRow({
tableHeader: true,
children: ['Strong', 'Greek', 'Transliteration', 'Part of Speech', 'Short Definition'].map((label) =>
new TableCell({
width: { size: 20, type: WidthType.PERCENTAGE },
children: [new Paragraph({ text: label, bold: true })],
}),
),
}),
...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({ text: `${word.strongNumber}${word.lexeme || ''}`, spacing: { before: 180, after: 120 }, bold: true }));
children.push(...createParagraphsFromText(htmlToPlainText(word.definitionHtml)));
}
});
} else {
children.push(new Paragraph('No Greek word notes.'));
}
children.push(new Paragraph({ text: '', spacing: { after: 300 } }));
});
});
const doc = new Document({ 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);
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 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 restoreRemoteProject = async (id) => {
const result = await loadRemoteProject(id);
if (!result.ok) {
alert('Could not restore project from server.');
return;
}
saveProjectToStorage(result.data);
setProjectIndex(loadProjectIndex());
setRemoteOnlyProjects((prev) => prev.filter((e) => e.id !== id));
};
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('');
};
// ---------------------------------------------------------------------------
// 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 episode script in the Verse by Verse with Nate format, ready to record"
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>
<button
type="button"
onClick={exportChapter}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400"
>
Export HTML
</button>
<button
type="button"
onClick={exportChapterDocx}
className="rounded-md bg-emerald-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-emerald-400"
>
Export DOCX
</button>
</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>
</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>
<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>
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{remoteOnlyProjects.length > 0 && (
<div className="mb-6 rounded-2xl border border-sky-200 bg-sky-50 p-4">
<p className="mb-3 text-sm font-semibold text-sky-800">
📥 {remoteOnlyProjects.length} project{remoteOnlyProjects.length > 1 ? 's' : ''} found on the server that aren't saved locally:
</p>
<div className="flex flex-wrap gap-2">
{remoteOnlyProjects.map((entry) => (
<button
key={entry.id}
type="button"
onClick={() => restoreRemoteProject(entry.id)}
className="rounded-xl bg-sky-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sky-600"
>
Restore "{entry.title}"
</button>
))}
</div>
</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>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projectIndex
.slice()
.filter((entry) => {
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>
)}
<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>
);
}
// ---------------------------------------------------------------------------
// SETUP PAGE (chunk builder)
// ---------------------------------------------------------------------------
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>
</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">
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-700">
{selectedChunk
? `Chunk ${selectedChunkGlobalIndex + 1} of ${allChunks.length}`
: '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>
</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="flex flex-wrap gap-2 rounded-3xl border border-slate-200 bg-white p-2">
{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>
)}
{/* Episode metadata for podcast prep — unique per chunk */}
<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">Episode Info</h3>
<p className="text-xs text-slate-500">Used to label the script when preparing podcast content for this chunk.</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="Episode #"
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="Episode 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 say? List facts, details, key words' },
{ field: 'interpretation', label: 'Interpretation', placeholder: 'What does it mean? Context, cross-references, theology' },
{ field: 'application', label: 'Application', placeholder: 'How does it apply? Personal response, life change' },
].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>
{/* 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>
<div className="mt-4 grid gap-4 sm:grid-cols-3">
<label className="text-sm text-slate-600">
Greek word
<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">{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</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.</p>
</div>
<span className="text-slate-400">{collapsedSections.finalScript ? '' : ''}</span>
</button>
{!collapsedSections.finalScript && (
<textarea
value={selectedChunk?.finalScript ?? ''}
onChange={(e) => updateChunk(selectedChunk.id, { finalScript: e.target.value })}
rows={8}
placeholder="Paste the final recorded/recordable episode 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;