Files
study-bible-project/src/App.jsx
T
Claude 3210132a21 Add test infrastructure and initial test suite
Sets up Vitest + React Testing Library with jsdom, exports utility functions
for unit testing, and adds 78 tests covering pure functions and component
integration flows (chapter loading, chunk management, navigation, Greek word
lookup).

https://claude.ai/code/session_01C4T3bYHR2svLq7JVcxKJ6Y
2026-05-27 11:04:20 +00:00

1228 lines
54 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 {
Document,
HeadingLevel,
Packer,
Paragraph,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
} from 'docx';
const bookOptions = [
{ 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' },
];
export const storageKey = (translation, book, chapter) => `bible-study-${translation}-${book}-${chapter}`;
export const makeId = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
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; }
.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; }
.notes, .greek { margin-top: 1rem; }
.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 chunksHtml = project.chunks
.map((chunk) => {
const scripture = chunk.startVerse === chunk.endVerse
? `${project.book} ${project.chapter}:${chunk.startVerse}`
: `${project.book} ${project.chapter}:${chunk.startVerse}-${chunk.endVerse}`;
const versesText = project.verses
.filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse)
.map((verse) => `<p class="verse"><strong>${verse.number}</strong> ${verse.text}</p>`)
.join('');
const notes = chunk.notes.trim().replace(/\n/g, '<br />') || '<em>No notes.</em>';
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
.filter((word) => word.definitionHtml)
.map((word) => `
<div class="definition-block">
<p><strong>${word.strongNumber}${word.lexeme || ''}</strong></p>
<div class="definition">${word.definitionHtml}</div>
</div>
`)
.join('');
return `
<section class="chunk">
<div class="scripture-ref">${scripture}</div>
${versesText}
<div class="notes"><strong>STUDY NOTES:</strong><p>${notes}</p></div>
<div class="greek"><strong>GREEK WORDS:</strong>
${greekRows ? greekTable : '<p><em>No Greek word notes.</em></p>'}
${extendedDefinitions}
</div>
</section>
`;
})
.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>
<h2>${project.translation}${project.book} ${project.chapter}</h2>
${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 '';
}
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(),
}));
}
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);
const [currentPage, setCurrentPage] = useState('setup');
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 saveTimerRef = useRef(null);
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(() => {
// Keep default translation if API fails.
});
}, []);
useEffect(() => {
if (!titleEdited) {
setSetup((current) => ({
...current,
title: `${current.book} ${current.chapter} Study`,
}));
}
}, [setup.book, setup.chapter, titleEdited]);
useEffect(() => {
const key = storageKey(setup.translation, setup.bookAbbrev, setup.chapter);
const saved = window.localStorage.getItem(key);
if (!saved) {
return;
}
try {
const parsed = JSON.parse(saved);
if (parsed && parsed.bookAbbrev === setup.bookAbbrev && parsed.chapter === setup.chapter) {
if (parsed.chunks?.length > 0 && !parsed.selectedChunkId) {
parsed.selectedChunkId = parsed.chunks[0].id;
}
const resume = window.confirm('A saved study exists for this chapter. Resume studying? Press OK to continue studying, or Cancel to edit chunks.');
setProject(parsed);
setSetup({
translation: parsed.translation,
book: parsed.book,
bookAbbrev: parsed.bookAbbrev,
chapter: parsed.chapter,
title: parsed.title,
});
setCurrentPage(resume ? 'study' : 'setup');
}
} catch {
// Ignore invalid saved data.
}
}, []);
useEffect(() => {
if (!project) {
return;
}
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = window.setTimeout(() => {
const key = storageKey(project.translation, project.bookAbbrev, project.chapter);
window.localStorage.setItem(key, JSON.stringify(project));
setSaveStatus('Saved');
window.setTimeout(() => setSaveStatus(''), 1400);
}, 1000);
return () => {
if (saveTimerRef.current) {
window.clearTimeout(saveTimerRef.current);
}
};
}, [project]);
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 createProjectFromChapter = (verses) => ({
translation: setup.translation,
book: setup.book,
bookAbbrev: setup.bookAbbrev,
chapter: setup.chapter,
title: setup.title,
verses,
chunks: [],
selectedChunkId: null,
});
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.');
}
const key = storageKey(setup.translation, setup.bookAbbrev, setup.chapter);
const saved = window.localStorage.getItem(key);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed && parsed.bookAbbrev === setup.bookAbbrev && parsed.chapter === setup.chapter) {
if (parsed.chunks?.length > 0 && !parsed.selectedChunkId) {
parsed.selectedChunkId = parsed.chunks[0].id;
}
const resume = window.confirm('A saved study exists for this chapter. Resume saved project?');
if (resume) {
setProject(parsed);
setSetup({
translation: parsed.translation,
book: parsed.book,
bookAbbrev: parsed.bookAbbrev,
chapter: parsed.chapter,
title: parsed.title,
});
setCurrentPage('study');
setLoadingChapter(false);
return;
}
}
}
setProject(createProjectFromChapter(verses));
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 || project.chunks.length === 0) return;
updateProject((current) => ({
...current,
selectedChunkId: current.selectedChunkId || current.chunks[0]?.id || null,
}));
setCurrentPage('study');
};
const goToPreviousChunk = () => {
if (!project?.chunks.length || !project.selectedChunkId) return;
const index = project.chunks.findIndex((chunk) => chunk.id === project.selectedChunkId);
if (index > 0) {
updateProject((current) => ({ ...current, selectedChunkId: current.chunks[index - 1].id }));
}
};
const goToNextChunk = () => {
if (!project?.chunks.length || !project.selectedChunkId) return;
const index = project.chunks.findIndex((chunk) => chunk.id === project.selectedChunkId);
if (index >= 0 && index < project.chunks.length - 1) {
updateProject((current) => ({ ...current, selectedChunkId: current.chunks[index + 1].id }));
}
};
const addChunk = (start, end) => {
if (!project) {
return;
}
const overlap = project.chunks.find((chunk) => chunk.startVerse === start && chunk.endVerse === end);
if (overlap) {
setStatusMessage('That chunk already exists.');
window.setTimeout(() => setStatusMessage(''), 1800);
return;
}
const newChunk = {
id: makeId(),
startVerse: start,
endVerse: end,
notes: '',
greekWords: [],
};
updateProject((current) => ({
...current,
chunks: [...current.chunks, newChunk],
selectedChunkId: newChunk.id,
}));
};
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 selectedChunk = project?.chunks.find((chunk) => chunk.id === project.selectedChunkId) ?? project?.chunks[0] ?? null;
const updateChunk = (chunkId, patch) => {
updateProject((current) => ({
...current,
chunks: current.chunks.map((chunk) => (chunk.id === chunkId ? { ...chunk, ...patch } : chunk)),
}));
};
const updateChunkWord = (chunkId, wordId, patch) => {
updateProject((current) => ({
...current,
chunks: current.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: '',
lexeme: '',
transliteration: '',
partOfSpeech: '',
shortDefinition: '',
definitionHtml: '',
loading: false,
};
updateProject((current) => ({
...current,
chunks: current.chunks.map((chunk) =>
chunk.id === chunkId
? { ...chunk, greekWords: [...chunk.greekWords, newWord] }
: chunk
),
}));
};
const removeGreekWord = (chunkId, wordId) => {
updateProject((current) => ({
...current,
chunks: current.chunks.map((chunk) =>
chunk.id === chunkId
? { ...chunk, greekWords: chunk.greekWords.filter((word) => word.id !== wordId) }
: chunk
),
}));
};
const lookupGreekWord = async (chunkId, wordId) => {
const chunk = project?.chunks.find((item) => item.id === chunkId);
const word = chunk?.greekWords.find((item) => item.id === wordId);
if (!word || !word.query.trim()) {
return;
}
updateChunkWord(chunkId, wordId, { loading: true });
try {
const query = encodeURIComponent(word.query.trim());
const response = await fetch(`https://bolls.life/dictionary-definition/BDBT/${query}/`);
if (!response.ok) {
throw new Error('Lookup failed.');
}
const definitions = await response.json();
if (!Array.isArray(definitions) || definitions.length === 0) {
updateChunkWord(chunkId, wordId, {
strongNumber: '',
shortDefinition: 'No definition found.',
definitionHtml: '',
});
return;
}
const first = definitions[0];
const extractedPartOfSpeech = extractPartOfSpeech(first.definition || '');
updateChunkWord(chunkId, wordId, {
strongNumber: first.topic || word.query,
lexeme: first.lexeme || '',
transliteration: first.transliteration || '',
partOfSpeech: extractedPartOfSpeech || word.partOfSpeech || '',
shortDefinition: first.short_definition || '',
definitionHtml: first.definition || '',
});
} catch (error) {
updateChunkWord(chunkId, wordId, {
shortDefinition: 'Lookup failed.',
});
} finally {
updateChunkWord(chunkId, wordId, { loading: false });
}
};
const moveChunk = (chunkId, direction) => {
updateProject((current) => {
const index = current.chunks.findIndex((chunk) => chunk.id === chunkId);
if (index < 0) return current;
const nextIndex = index + direction;
if (nextIndex < 0 || nextIndex >= current.chunks.length) return current;
const updated = [...current.chunks];
const [removed] = updated.splice(index, 1);
updated.splice(nextIndex, 0, removed);
return {
...current,
chunks: updated,
};
});
};
const deleteChunk = (chunkId) => {
updateProject((current) => {
const remaining = current.chunks.filter((chunk) => chunk.id !== chunkId);
const nextSelected = current.selectedChunkId === chunkId ? remaining[0]?.id ?? null : current.selectedChunkId;
return {
...current,
chunks: remaining,
selectedChunkId: nextSelected,
};
});
};
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.book}-${project.chapter}-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}${project.book} ${project.chapter}`,
spacing: { after: 300 },
}),
];
project.chunks.forEach((chunk) => {
const scriptureHeading = chunk.startVerse === chunk.endVerse
? `${project.book} ${project.chapter}:${chunk.startVerse}`
: `${project.book} ${project.chapter}:${chunk.startVerse}-${chunk.endVerse}`;
children.push(new Paragraph({ text: scriptureHeading, heading: HeadingLevel.HEADING_2 }));
project.verses
.filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse)
.forEach((verse) => {
children.push(new Paragraph({
children: [
new TextRun({ text: `${verse.number}. `, bold: true }),
new TextRun({ text: verse.text }),
],
}));
});
children.push(new Paragraph({ text: 'STUDY NOTES:', spacing: { before: 240, after: 120 }, bold: true }));
children.push(...createParagraphsFromText(chunk.notes || 'No notes.'));
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.book}-${project.chapter}-study.docx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const verseLabel = (start, end) => (start === end ? `${start}` : `${start}-${end}`);
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 className="flex items-center gap-4">
{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>
) : null}
<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 : 'Create a new chapter study'}</h1>
</div>
</div>
<div className="flex items-center gap-3">
{project && (
<button
type="button"
onClick={() => {
setProject(null);
setRangeStart(null);
setRangeEnd(null);
setErrorMessage('');
setStatusMessage('');
}}
className="rounded-md border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
>
New Project
</button>
)}
{project && (
<div className="flex flex-wrap items-center gap-3">
<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">
{loadingChapter ? <span>Loading</span> : saveStatus ? <span className="text-emerald-300">{saveStatus}</span> : <span>&nbsp;</span>}
</div>
</div>
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
{!project ? (
<section className="mx-auto max-w-3xl rounded-3xl border border-slate-200 bg-white p-8 shadow-panel">
<div className="space-y-6">
<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={(event) => handleSetupField('translation', event.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((translation) => (
<option key={translation} value={translation}>
{translation}
</option>
))}
</select>
</label>
<label className="block text-sm font-medium text-slate-700">
Book
<select
value={setup.bookAbbrev}
onChange={(event) => handleSetupField('book', event.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={(event) => handleSetupField('chapter', event.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>
<label className="block text-sm font-medium text-slate-700 sm:col-span-2">
Project title
<input
type="text"
value={setup.title}
onChange={(event) => {
setTitleEdited(true);
handleSetupField('title', event.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={handleLoadChapter}
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>
</section>
) : currentPage === 'setup' ? (
<section className="space-y-8">
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
<div className="space-y-6">
<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={(event) => handleSetupField('translation', event.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((translation) => (
<option key={translation} value={translation}>
{translation}
</option>
))}
</select>
</label>
<label className="block text-sm font-medium text-slate-700">
Book
<select
value={setup.bookAbbrev}
onChange={(event) => handleSetupField('book', event.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={(event) => handleSetupField('chapter', event.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>
<label className="block text-sm font-medium text-slate-700 sm:col-span-2">
Project title
<input
type="text"
value={setup.title}
onChange={(event) => {
setTitleEdited(true);
handleSetupField('title', event.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={handleLoadChapter}
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>
</div>
<div className="grid gap-8 xl:grid-cols-[1.8fr_1fr]">
<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">{project.book} {project.chapter} ({project.translation})</h2>
</div>
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-700">
{statusMessage || 'Shift-click a second verse to create a chunk.'}
</div>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-[1.25fr_0.75fr]">
<div className="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>
<div data-testid="verse-list" className="max-h-[520px] overflow-y-auto rounded-3xl border border-slate-200 bg-white p-4 scrollbar-thin">
{project.verses.map((verse) => {
const inRange = rangeStart !== null && (verse.number >= Math.min(rangeStart, rangeEnd) && verse.number <= Math.max(rangeStart, rangeEnd));
const inChunk = project.chunks.some((chunk) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse);
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="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">{project.chunks.length} created</span>
</div>
<div className="space-y-3 max-h-[520px] overflow-y-auto scrollbar-thin">
{project.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>
) : (
project.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">{project.book} {project.chapter}:{verseLabel(chunk.startVerse, chunk.endVerse)}</p>
<p className="mt-1 text-sm text-slate-600 truncate">{project.verses.find((verse) => verse.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 === project.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={project.chunks.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>
</div>
</section>
) : (
<section className="grid gap-8 xl:grid-cols-[260px_1fr]">
<aside className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
<div className="mb-6">
<p className="text-sm font-medium text-slate-500">Chunk Navigation</p>
<h2 className="mt-2 text-xl font-semibold text-slate-900">{project.chunks.length} chunks</h2>
</div>
<div className="space-y-3 max-h-[calc(100vh-260px)] overflow-y-auto scrollbar-thin">
{project.chunks.length === 0 ? (
<div className="rounded-3xl border border-dashed border-slate-300 bg-slate-50 p-4 text-sm text-slate-500">
No chunks available. Return to chunks to create a passage.
</div>
) : (
project.chunks.map((chunk) => {
const isSelected = project.selectedChunkId === chunk.id;
const hasContent = chunk.notes.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-3 rounded-3xl border-l-4 p-4 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">{project.book} {project.chapter}:{verseLabel(chunk.startVerse, chunk.endVerse)}</span>
{hasContent ? <span className="text-emerald-600"></span> : null}
</div>
<p className="text-sm leading-relaxed text-slate-600 truncate">
{project.verses.find((verse) => verse.number === chunk.startVerse)?.text || ''}
</p>
</button>
);
})
)}
</div>
</aside>
<div className="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 ${verseLabel(selectedChunk.startVerse, selectedChunk.endVerse)}` : 'Select a chunk'}</h2>
</div>
<div className="rounded-2xl bg-slate-100 px-3 py-2 text-sm text-slate-700">
{selectedChunk ? `Chunk ${project.chunks.findIndex((chunk) => chunk.id === selectedChunk.id) + 1} of ${project.chunks.length}` : 'Choose a chunk to study.'}
</div>
</div>
{selectedChunk ? (
<div className="mt-6 space-y-6">
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5">
<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">
{project.book} {project.chapter}:{verseLabel(selectedChunk.startVerse, selectedChunk.endVerse)}
</span>
</div>
<div className="space-y-3 font-serif text-slate-800">
{project.verses
.filter((verse) => verse.number >= selectedChunk.startVerse && verse.number <= selectedChunk.endVerse)
.map((verse) => (
<p key={verse.number} className="leading-relaxed">
<span className="font-semibold text-slate-700">{verse.number}.</span> {verse.text}
</p>
))}
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5">
<div className="mb-3 flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-slate-900">Study Notes</h3>
<p className="text-sm text-slate-500">Write your observations, cross-references, and teaching notes here.</p>
</div>
</div>
<textarea
value={selectedChunk.notes}
onChange={(event) => updateChunk(selectedChunk.id, { notes: event.target.value })}
rows={10}
placeholder="Write your observations, cross-references, and teaching notes here..."
className="w-full resize-y rounded-3xl border border-slate-300 bg-white px-4 py-4 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 className="rounded-3xl border border-slate-200 bg-slate-50 p-5">
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-slate-900">Greek Word Studies</h3>
<p className="text-sm text-slate-500">Add lexical notes, look up Strong's entries, and keep definitions with each chunk.</p>
</div>
<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 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 Greek 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={(event) => updateChunkWord(selectedChunk.id, word.id, { query: event.target.value })}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
lookupGreekWord(selectedChunk.id, word.id);
}
}}
placeholder="G4102 or faith"
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">
<button
type="button"
onClick={() => lookupGreekWord(selectedChunk.id, word.id)}
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'}
</button>
<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={(event) => updateChunkWord(selectedChunk.id, word.id, { lexeme: event.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={(event) => updateChunkWord(selectedChunk.id, word.id, { transliteration: event.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={(event) => updateChunkWord(selectedChunk.id, word.id, { partOfSpeech: event.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">
Short definition
<input
type="text"
value={word.shortDefinition}
onChange={(event) => updateChunkWord(selectedChunk.id, word.id, { shortDefinition: event.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: word.definitionHtml }} />
</details>
) : null}
</div>
))
)}
</div>
</div>
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={goToPreviousChunk}
disabled={!project.selectedChunkId || project.chunks.findIndex((chunk) => chunk.id === project.selectedChunkId) === 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={!project.selectedChunkId || project.chunks.findIndex((chunk) => chunk.id === project.selectedChunkId) === project.chunks.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>
</div>
);
};
export default App;