3210132a21
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
1228 lines
54 KiB
React
1228 lines
54 KiB
React
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> </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;
|