Strong's dictionary loaders, lexicon lookup, gloss helpers, cross-ref formatting, and speech move out of App. Dictionaries are now cached at module level (still one download per visit), and a failed download is no longer cached forever, so the next lookup retries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2141 lines
77 KiB
React
2141 lines
77 KiB
React
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
||
import { AppContext, useApp } from './context/AppContext.js';
|
||
import { useAudio } from './hooks/useAudio.js';
|
||
import { useAuth } from './hooks/useAuth.js';
|
||
import { useBibleReader } from './hooks/useBibleReader.js';
|
||
import {
|
||
loadNtGloss,
|
||
loadNtConcordance,
|
||
loadHebrewDict,
|
||
loadGreekDict,
|
||
GREEK_FUNCTION_WORDS,
|
||
shortHebrewGloss,
|
||
ENGLISH_STOP_WORDS,
|
||
externalLookupLinks,
|
||
buildGreekDefinitionHtml,
|
||
fetchBollsDefinition,
|
||
formatCrossRef,
|
||
speakOriginalWord,
|
||
} from './utils/wordStudy.js';
|
||
import HomePage from './pages/HomePage.jsx';
|
||
|
||
import {
|
||
CHAPTER_COUNTS,
|
||
bookOptions,
|
||
NT_BOOK_NUMBER,
|
||
makeId,
|
||
formatChunkReference,
|
||
getChunkVerseEntries,
|
||
chunkSpansNextChapter,
|
||
buildExportHtml,
|
||
extractPartOfSpeech,
|
||
parseBibleChapter,
|
||
loadProjectIndex,
|
||
switchStorageUser,
|
||
buildPronunciationGuide,
|
||
} from './utils/bibleUtils.jsx';
|
||
import {
|
||
saveProjectToStorage,
|
||
deleteProjectFromStorage,
|
||
loadProjectById,
|
||
migrateOldStorageKeys,
|
||
buildChapterSummary,
|
||
} from './utils/projectStorage.js';
|
||
import {
|
||
buildMarkdownExport,
|
||
buildClaudePrompt,
|
||
buildPodcastPrompt,
|
||
} from './utils/exportBuilders.js';
|
||
import {
|
||
parseCrossRefString,
|
||
parseEpisodePassage,
|
||
parseEpisodeListDocx,
|
||
} from './utils/importParsers.js';
|
||
|
||
import {
|
||
saveRemoteProject,
|
||
deleteRemoteProject,
|
||
listRemoteProjects,
|
||
loadRemoteProject,
|
||
getShareStatus,
|
||
enableSharing,
|
||
disableSharing,
|
||
getSharedProject,
|
||
} from './syncService.js';
|
||
|
||
// Less-common pages are split into their own chunks so the first load only
|
||
// downloads what's needed to show the home screen.
|
||
const AdminPage = lazy(() => import('./pages/AdminPage.jsx'));
|
||
const AuthPage = lazy(() => import('./pages/AuthPage.jsx'));
|
||
const ImportPage = lazy(() => import('./pages/ImportPage.jsx'));
|
||
const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx'));
|
||
const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
|
||
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
|
||
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
|
||
const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stable utility exports (used by tests and other modules)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||
// Read-only share links
|
||
const [shareToken, setShareToken] = useState(null);
|
||
const [sharePanelOpen, setSharePanelOpen] = useState(false);
|
||
const [shareBusy, setShareBusy] = useState(false);
|
||
const [shareCopied, setShareCopied] = useState(false);
|
||
const [sharedViewToken] = useState(() => new URLSearchParams(window.location.search).get('share'));
|
||
const [sharedProject, setSharedProject] = useState(null);
|
||
const [sharedError, setSharedError] = useState('');
|
||
const [project, setProject] = useState(null);
|
||
// 'home' | 'setup' | 'study' | 'settings'
|
||
const [currentPage, setCurrentPage] = useState('home');
|
||
const [projectIndex, setProjectIndex] = useState([]);
|
||
const [activeChapterIndex, setActiveChapterIndex] = useState(0);
|
||
const [showAddChapterForm, setShowAddChapterForm] = useState(false);
|
||
const [crossRefInput, setCrossRefInput] = useState('');
|
||
const [saveStatus, setSaveStatus] = useState('');
|
||
const [rangeStart, setRangeStart] = useState(null);
|
||
const [rangeEnd, setRangeEnd] = useState(null);
|
||
const [loadingChapter, setLoadingChapter] = useState(false);
|
||
const [errorMessage, setErrorMessage] = useState('');
|
||
const [statusMessage, setStatusMessage] = useState('');
|
||
const [typedChunkStart, setTypedChunkStart] = useState('');
|
||
const [typedChunkEnd, setTypedChunkEnd] = useState('');
|
||
const [typedChunkNextEnd, setTypedChunkNextEnd] = useState('');
|
||
const [clickedSpanNextEnd, setClickedSpanNextEnd] = useState('');
|
||
const [typedChunkBulk, setTypedChunkBulk] = useState('');
|
||
const saveTimerRef = useRef(null);
|
||
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
||
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
|
||
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
|
||
const [suggestingGreekForChunkId, setSuggestingGreekForChunkId] = useState(null);
|
||
const [suggestingHebrewForChunkId, setSuggestingHebrewForChunkId] = useState(null);
|
||
// suggestModal: null | { chunkId, words: [{ strongKey, lexeme, translit, def }] }
|
||
const [suggestModal, setSuggestModal] = useState(null);
|
||
const [suggestSelection, setSuggestSelection] = useState(new Set());
|
||
const [homeSearch, setHomeSearch] = useState('');
|
||
const [homeSort, setHomeSort] = useState('recent'); // 'recent' | 'title' | 'passage'
|
||
const [homeTagFilter, setHomeTagFilter] = useState('');
|
||
const [homeFullTextResults, setHomeFullTextResults] = useState(null); // null=idle, []=no match, [...]= results
|
||
const fullTextTimerRef = useRef(null);
|
||
const [readingPlan, setReadingPlan] = useState(() => {
|
||
try { return JSON.parse(localStorage.getItem('readingPlan') || 'null'); } catch { return null; }
|
||
});
|
||
const [renamingId, setRenamingId] = useState(null);
|
||
const [renameValue, setRenameValue] = useState('');
|
||
const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev);
|
||
const [importTranslation, setImportTranslation] = useState('BSB');
|
||
const [importTitle, setImportTitle] = useState('');
|
||
const [importFile, setImportFile] = useState(null);
|
||
const [importPreview, setImportPreview] = useState(null); // parsed episode specs, before fetching chapters
|
||
const [importBusy, setImportBusy] = useState(false);
|
||
const [importError, setImportError] = useState('');
|
||
const reader = useBibleReader(currentPage);
|
||
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
|
||
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
|
||
onSignIn: (user) => {
|
||
setProjectIndex(switchStorageUser(user.id));
|
||
// Load reader ink from the server; server wins over any stale localStorage copy
|
||
fetch('/api/reader/ink')
|
||
.then((r) => r.ok ? r.json() : null)
|
||
.then((serverInk) => { if (serverInk) reader.mergeServerInk(serverInk); })
|
||
.catch(() => {});
|
||
},
|
||
onSignOut: () => {
|
||
setProjectIndex(switchStorageUser(null));
|
||
goHome();
|
||
},
|
||
});
|
||
const [studyLayout, setStudyLayout] = useState(() => {
|
||
const saved = localStorage.getItem('studyLayout');
|
||
return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed
|
||
}); // 'stacked' | 'split'
|
||
const [drawTool, setDrawTool] = useState('pen');
|
||
const [drawColor, setDrawColor] = useState('#0f172a');
|
||
const [drawSize, setDrawSize] = useState(0.003);
|
||
const [activeStudyTab, setActiveStudyTab] = useState(
|
||
() => localStorage.getItem('activeStudyTab') || 'notes',
|
||
);
|
||
const [mobileStudyTab, setMobileStudyTab] = useState('scripture');
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem('studyLayout', studyLayout);
|
||
}, [studyLayout]);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem('activeStudyTab', activeStudyTab);
|
||
}, [activeStudyTab]);
|
||
|
||
// Full-text search across all project notes (debounced, synchronous localStorage read)
|
||
useEffect(() => {
|
||
clearTimeout(fullTextTimerRef.current);
|
||
const q = homeSearch.trim().toLowerCase();
|
||
if (q.length < 3) { setHomeFullTextResults(null); return; }
|
||
fullTextTimerRef.current = window.setTimeout(() => {
|
||
const index = loadProjectIndex();
|
||
const results = [];
|
||
for (const entry of index) {
|
||
const proj = loadProjectById(entry.id);
|
||
if (!proj) continue;
|
||
const matches = [];
|
||
for (const [chIdx, ch] of (proj.chapters ?? []).entries()) {
|
||
for (const chunk of (ch.chunks ?? [])) {
|
||
for (const field of ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript']) {
|
||
const text = (chunk[field] ?? '').toLowerCase();
|
||
if (!text.includes(q)) continue;
|
||
const idx = text.indexOf(q);
|
||
const raw = chunk[field] ?? '';
|
||
const start = Math.max(0, idx - 40);
|
||
const snippet = (start > 0 ? '…' : '') + raw.slice(start, idx + q.length + 60) + (idx + q.length + 60 < raw.length ? '…' : '');
|
||
matches.push({ chunkId: chunk.id, ref: formatChunkReference(proj, chIdx, chunk, '–'), field, snippet });
|
||
break; // one match per chunk
|
||
}
|
||
}
|
||
}
|
||
if (matches.length > 0) results.push({ projectId: entry.id, projectTitle: entry.title, matches });
|
||
}
|
||
setHomeFullTextResults(results);
|
||
}, 300);
|
||
}, [homeSearch]);
|
||
|
||
const [verseSearch, setVerseSearch] = useState('');
|
||
const [collapsedSections, setCollapsedSections] = useState({});
|
||
const [commentarySource, setCommentarySource] = useState('matthew-henry');
|
||
const [commentaryData, setCommentaryData] = useState(null); // { content: [...] } for current chapter
|
||
const [commentaryLoading, setCommentaryLoading] = useState(false);
|
||
const [commentaryError, setCommentaryError] = useState('');
|
||
const _commentaryCacheRef = useRef({});
|
||
const [interlinearData, setInterlinearData] = useState(null);
|
||
const [interlinearLoading, setInterlinearLoading] = useState(false);
|
||
const [interlinearError, setInterlinearError] = useState('');
|
||
const _interlinearCacheRef = useRef({});
|
||
|
||
const loadInterlinearChapter = async (bookAbbrev, chapterNumber) => {
|
||
if (_interlinearCacheRef.current[bookAbbrev]) return _interlinearCacheRef.current[bookAbbrev];
|
||
const res = await fetch(`/interlinear/${bookAbbrev}.json`);
|
||
if (!res.ok) throw new Error('No interlinear data for this book.');
|
||
const data = await res.json();
|
||
_interlinearCacheRef.current[bookAbbrev] = data;
|
||
return data;
|
||
};
|
||
|
||
const loadCommentaryChapter = async (commentaryId, bookAbbrev, chapterNumber) => {
|
||
const cacheKey = `${commentaryId}/${bookAbbrev}/${chapterNumber}`;
|
||
if (_commentaryCacheRef.current[cacheKey]) return _commentaryCacheRef.current[cacheKey];
|
||
const res = await fetch(`https://bible.helloao.org/api/c/${commentaryId}/${bookAbbrev}/${chapterNumber}.json`);
|
||
const contentType = res.headers.get('content-type') || '';
|
||
if (!res.ok || !contentType.includes('json')) throw new Error(`No ${commentaryId} commentary for this chapter.`);
|
||
const data = await res.json();
|
||
_commentaryCacheRef.current[cacheKey] = data;
|
||
return data;
|
||
};
|
||
|
||
const openBibleReader = () => {
|
||
setCurrentPage('reader');
|
||
};
|
||
|
||
const copyVerse = (bookName, chapterNum, verseNum, text) => {
|
||
const citation = `${bookName} ${chapterNum}:${verseNum} BSB — ${text}`;
|
||
navigator.clipboard?.writeText(citation);
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Startup: migrate old keys and load index
|
||
// ---------------------------------------------------------------------------
|
||
useEffect(() => {
|
||
migrateOldStorageKeys();
|
||
setProjectIndex(loadProjectIndex());
|
||
}, []);
|
||
|
||
// A ?share=TOKEN URL loads a read-only view of someone else's project — no
|
||
// login required, so this runs independent of auth state entirely.
|
||
useEffect(() => {
|
||
if (!sharedViewToken) return;
|
||
getSharedProject(sharedViewToken).then((result) => {
|
||
if (result.ok) {
|
||
setSharedProject(result.data);
|
||
} else {
|
||
setSharedError(result.error ?? 'This share link is invalid or has been revoked.');
|
||
}
|
||
});
|
||
}, [sharedViewToken]);
|
||
|
||
// Loads current share status whenever the study page's project changes.
|
||
useEffect(() => {
|
||
if (!project?.id || currentPage !== 'study') return;
|
||
setShareToken(null);
|
||
getShareStatus(project.id).then((result) => {
|
||
if (result.ok) setShareToken(result.data.shareToken);
|
||
});
|
||
}, [project?.id, currentPage]);
|
||
|
||
// Once we know who's signed in, reconcile the local project index against the server.
|
||
// Runs on every authUser change (including logout -> different login) so a previous
|
||
// account's stale suggestions never linger after switching users. Projects that exist
|
||
// on the server but not on this device (e.g. a brand new browser) are pulled down
|
||
// automatically instead of requiring a manual "Restore" click per project.
|
||
useEffect(() => {
|
||
if (!authUser) {
|
||
setStaleLocalProjects([]);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
const localIndex = loadProjectIndex();
|
||
listRemoteProjects().then(async (result) => {
|
||
if (!result.ok || cancelled) return;
|
||
const localMap = new Map(localIndex.map((e) => [e.id, e]));
|
||
|
||
const missing = result.data.filter((e) => !localMap.has(e.id));
|
||
if (missing.length > 0) {
|
||
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
|
||
fetched.filter((r) => r.ok).forEach((r) => saveProjectToStorage(r.data));
|
||
if (cancelled) return;
|
||
const restoredCount = fetched.filter((r) => r.ok).length;
|
||
if (restoredCount > 0) {
|
||
setProjectIndex(loadProjectIndex());
|
||
setAutoRestoredCount(restoredCount);
|
||
window.setTimeout(() => setAutoRestoredCount(null), 4000);
|
||
}
|
||
}
|
||
|
||
const stale = result.data.filter((e) => {
|
||
const local = localMap.get(e.id);
|
||
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0);
|
||
});
|
||
if (!cancelled) setStaleLocalProjects(stale);
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, [authUser]);
|
||
|
||
useEffect(() => {
|
||
fetch('https://bible.helloao.org/api/available_translations.json')
|
||
.then((res) => res.json())
|
||
.then((data) => {
|
||
if (Array.isArray(data) && data.length > 0) {
|
||
setAvailableTranslations(data);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!titleEdited) {
|
||
setSetup((current) => ({
|
||
...current,
|
||
title: `${current.book} ${current.chapter} Study`,
|
||
}));
|
||
}
|
||
}, [setup.book, setup.chapter, titleEdited]);
|
||
|
||
// Reset cross-ref input when selected chunk changes
|
||
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
|
||
const activeChapter = project?.chapters[activeChapterIndex] ?? null;
|
||
const selectedChunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
|
||
const selectedChunkChapterIndex = selectedChunk
|
||
? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === selectedChunk.id))
|
||
: -1;
|
||
|
||
useEffect(() => {
|
||
if (collapsedSections.commentary) return;
|
||
if (selectedChunkChapterIndex < 0) return;
|
||
const chapter = project.chapters[selectedChunkChapterIndex];
|
||
setCommentaryLoading(true);
|
||
setCommentaryError('');
|
||
loadCommentaryChapter(commentarySource, chapter.bookAbbrev, chapter.chapter)
|
||
.then((data) => setCommentaryData(data))
|
||
.catch((err) => {
|
||
setCommentaryData(null);
|
||
setCommentaryError(err.message);
|
||
})
|
||
.finally(() => setCommentaryLoading(false));
|
||
}, [commentarySource, selectedChunk?.id, selectedChunkChapterIndex, collapsedSections.commentary]);
|
||
|
||
useEffect(() => {
|
||
if (collapsedSections.interlinear) return;
|
||
if (selectedChunkChapterIndex < 0) return;
|
||
const chapter = project.chapters[selectedChunkChapterIndex];
|
||
setInterlinearLoading(true);
|
||
setInterlinearError('');
|
||
loadInterlinearChapter(chapter.bookAbbrev, chapter.chapter)
|
||
.then((data) => setInterlinearData(data?.[String(chapter.chapter)] ?? null))
|
||
.catch((err) => {
|
||
setInterlinearData(null);
|
||
setInterlinearError(err.message);
|
||
})
|
||
.finally(() => setInterlinearLoading(false));
|
||
}, [selectedChunk?.id, selectedChunkChapterIndex, collapsedSections.interlinear]);
|
||
|
||
useEffect(() => {
|
||
setCrossRefSuggestions([]);
|
||
setTagInput('');
|
||
}, [selectedChunk?.id]);
|
||
const selectedChunkChapter = selectedChunkChapterIndex >= 0
|
||
? project.chapters[selectedChunkChapterIndex]
|
||
: null;
|
||
const selectedChunkVerses = selectedChunk
|
||
? getChunkVerseEntries(project, selectedChunkChapterIndex, selectedChunk)
|
||
: [];
|
||
const selectedChunkGlobalIndex = allChunks.findIndex((c) => c.id === project?.selectedChunkId);
|
||
|
||
useEffect(() => {
|
||
setCrossRefInput('');
|
||
}, [project?.selectedChunkId]);
|
||
|
||
// Keyboard shortcuts: arrow keys navigate chunks on the study page, Esc closes modals
|
||
useEffect(() => {
|
||
const handleKeyDown = (event) => {
|
||
const tag = event.target?.tagName;
|
||
const isEditable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable;
|
||
|
||
if (event.key === 'Escape') {
|
||
if (suggestModal) {
|
||
setSuggestModal(null);
|
||
setSuggestSelection(new Set());
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (currentPage !== 'study' || isEditable || suggestModal) return;
|
||
|
||
if (event.key === 'ArrowLeft') {
|
||
event.preventDefault();
|
||
goToPreviousChunk();
|
||
} else if (event.key === 'ArrowRight') {
|
||
event.preventDefault();
|
||
goToNextChunk();
|
||
}
|
||
};
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||
}, [currentPage, suggestModal, selectedChunkGlobalIndex, allChunks]);
|
||
|
||
// Autosave
|
||
useEffect(() => {
|
||
if (!project) return;
|
||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||
saveTimerRef.current = window.setTimeout(async () => {
|
||
// Stamp lastEdited once so localStorage and server receive the same value
|
||
const toSave = { ...project, lastEdited: Date.now() };
|
||
|
||
// 1. Save locally
|
||
saveProjectToStorage(toSave);
|
||
setProjectIndex(loadProjectIndex());
|
||
setSaveStatus('Saved');
|
||
window.setTimeout(() => setSaveStatus(''), 1400);
|
||
|
||
// 2. Sync to server with the same timestamped object
|
||
setSyncStatus('syncing');
|
||
const result = await saveRemoteProject(toSave);
|
||
setSyncStatus(result.ok ? 'synced' : 'error');
|
||
// On success clear after 2.5s; on error keep visible until next successful sync
|
||
if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500);
|
||
}, 1000);
|
||
return () => {
|
||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||
};
|
||
}, [project]);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Setup helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const handleSetupField = (field, value) => {
|
||
if (field === 'book') {
|
||
const selected = bookOptions.find((book) => book.abbrev === value) ?? bookOptions[0];
|
||
setSetup((current) => ({
|
||
...current,
|
||
book: selected.name,
|
||
bookAbbrev: selected.abbrev,
|
||
}));
|
||
} else {
|
||
setSetup((current) => ({ ...current, [field]: value }));
|
||
}
|
||
};
|
||
|
||
const handleLoadChapter = async () => {
|
||
if (!setup.bookAbbrev || !setup.chapter) {
|
||
setErrorMessage('Please choose a book and chapter.');
|
||
return;
|
||
}
|
||
setErrorMessage('');
|
||
setLoadingChapter(true);
|
||
try {
|
||
const response = await fetch(
|
||
`https://bible.helloao.org/api/${setup.translation}/${setup.bookAbbrev}/${setup.chapter}.json`,
|
||
);
|
||
if (!response.ok) throw new Error('Unable to load chapter.');
|
||
const data = await response.json();
|
||
const verses = parseBibleChapter(data);
|
||
if (!data || !Array.isArray(verses) || verses.length === 0) {
|
||
throw new Error('Invalid Bible data returned.');
|
||
}
|
||
|
||
if (project) {
|
||
// Adding a chapter to an existing project
|
||
const alreadyExists = project.chapters.some(
|
||
(ch) => ch.bookAbbrev === setup.bookAbbrev && ch.chapter === setup.chapter,
|
||
);
|
||
if (alreadyExists) {
|
||
setErrorMessage('That chapter is already in this project.');
|
||
setLoadingChapter(false);
|
||
return;
|
||
}
|
||
const newChapter = {
|
||
book: setup.book,
|
||
bookAbbrev: setup.bookAbbrev,
|
||
chapter: setup.chapter,
|
||
verses,
|
||
chunks: [],
|
||
};
|
||
updateProject((current) => {
|
||
const updatedChapters = [...current.chapters, newChapter];
|
||
return { ...current, chapters: updatedChapters };
|
||
});
|
||
setActiveChapterIndex(project.chapters.length);
|
||
setShowAddChapterForm(false);
|
||
setErrorMessage('');
|
||
} else {
|
||
// Creating a brand-new project
|
||
const newProject = {
|
||
id: makeId(),
|
||
title: setup.title,
|
||
translation: setup.translation,
|
||
lastEdited: Date.now(),
|
||
selectedChunkId: null,
|
||
chapters: [
|
||
{
|
||
book: setup.book,
|
||
bookAbbrev: setup.bookAbbrev,
|
||
chapter: setup.chapter,
|
||
verses,
|
||
chunks: [],
|
||
},
|
||
],
|
||
};
|
||
setProject(newProject);
|
||
setActiveChapterIndex(0);
|
||
setCurrentPage('setup');
|
||
}
|
||
} catch (error) {
|
||
setErrorMessage(error.message || 'Failed to load chapter.');
|
||
} finally {
|
||
setLoadingChapter(false);
|
||
}
|
||
};
|
||
|
||
const updateProject = (updater) => {
|
||
setProject((current) => (current ? updater(current) : current));
|
||
};
|
||
|
||
const beginStudying = () => {
|
||
if (!project || allChunks.length === 0) return;
|
||
updateProject((current) => ({
|
||
...current,
|
||
selectedChunkId: current.selectedChunkId || allChunks[0]?.id || null,
|
||
}));
|
||
setCurrentPage('study');
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Chunk navigation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const goToPreviousChunk = () => {
|
||
if (selectedChunkGlobalIndex <= 0) return;
|
||
updateProject((current) => ({
|
||
...current,
|
||
selectedChunkId: allChunks[selectedChunkGlobalIndex - 1].id,
|
||
}));
|
||
};
|
||
|
||
const goToNextChunk = () => {
|
||
if (selectedChunkGlobalIndex < 0 || selectedChunkGlobalIndex >= allChunks.length - 1) return;
|
||
updateProject((current) => ({
|
||
...current,
|
||
selectedChunkId: allChunks[selectedChunkGlobalIndex + 1].id,
|
||
}));
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Chunk CRUD (operates on activeChapter in setup view, on selectedChunkChapter in study)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const addChunkToChapter = (chapterIndex, start, end, spilloverEndVerse = null) => {
|
||
updateProject((current) => {
|
||
const chapters = current.chapters.map((ch, idx) => {
|
||
if (idx !== chapterIndex) return ch;
|
||
const overlap = ch.chunks.find(
|
||
(c) => c.startVerse === start
|
||
&& c.endVerse === end
|
||
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
|
||
);
|
||
if (overlap) return ch;
|
||
const newChunk = {
|
||
id: makeId(),
|
||
startVerse: start,
|
||
endVerse: end,
|
||
spilloverEndVerse,
|
||
observation: '',
|
||
interpretation: '',
|
||
application: '',
|
||
crossReferences: [],
|
||
greekWords: [],
|
||
generalNotes: '',
|
||
episodeNumber: '',
|
||
episodeTitle: '',
|
||
finalScript: '',
|
||
tags: [],
|
||
};
|
||
return { ...ch, chunks: [...ch.chunks, newChunk] };
|
||
});
|
||
// Find the newly added chunk id
|
||
const newChunk = chapters[chapterIndex].chunks.at(-1);
|
||
return { ...current, chapters, selectedChunkId: newChunk?.id ?? current.selectedChunkId };
|
||
});
|
||
};
|
||
|
||
const addChunk = (start, end, spilloverEndVerse = null) => {
|
||
if (!project) return;
|
||
const chapter = activeChapter;
|
||
if (!chapter) return;
|
||
const overlap = chapter.chunks.find(
|
||
(c) => c.startVerse === start
|
||
&& c.endVerse === end
|
||
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
|
||
);
|
||
if (overlap) {
|
||
setStatusMessage('That chunk already exists.');
|
||
window.setTimeout(() => setStatusMessage(''), 1800);
|
||
return;
|
||
}
|
||
addChunkToChapter(activeChapterIndex, start, end, spilloverEndVerse);
|
||
};
|
||
|
||
const addChunkRanges = (ranges) => {
|
||
if (!project || !activeChapter || ranges.length === 0) {
|
||
return { addedCount: 0, skippedCount: 0 };
|
||
}
|
||
|
||
let addedCount = 0;
|
||
let skippedCount = 0;
|
||
let lastAddedChunkId = null;
|
||
|
||
updateProject((current) => {
|
||
const chapters = current.chapters.map((ch, idx) => {
|
||
if (idx !== activeChapterIndex) return ch;
|
||
const existing = new Set(
|
||
ch.chunks.map((c) => `${c.startVerse}-${c.endVerse}-${c.spilloverEndVerse ?? ''}`),
|
||
);
|
||
const newChunks = [];
|
||
|
||
ranges.forEach(({ start, end, spilloverEndVerse = null }) => {
|
||
const key = `${start}-${end}-${spilloverEndVerse ?? ''}`;
|
||
if (existing.has(key)) {
|
||
skippedCount += 1;
|
||
return;
|
||
}
|
||
const newChunk = {
|
||
id: makeId(),
|
||
startVerse: start,
|
||
endVerse: end,
|
||
spilloverEndVerse,
|
||
observation: '',
|
||
interpretation: '',
|
||
application: '',
|
||
crossReferences: [],
|
||
greekWords: [],
|
||
tags: [],
|
||
};
|
||
existing.add(key);
|
||
newChunks.push(newChunk);
|
||
addedCount += 1;
|
||
lastAddedChunkId = newChunk.id;
|
||
});
|
||
|
||
return newChunks.length > 0
|
||
? { ...ch, chunks: [...ch.chunks, ...newChunks] }
|
||
: ch;
|
||
});
|
||
|
||
return {
|
||
...current,
|
||
chapters,
|
||
selectedChunkId: lastAddedChunkId ?? current.selectedChunkId,
|
||
};
|
||
});
|
||
|
||
return { addedCount, skippedCount };
|
||
};
|
||
|
||
const addTypedChunk = () => {
|
||
if (!activeChapter) return;
|
||
const start = Number.parseInt(typedChunkStart, 10);
|
||
const end = Number.parseInt(typedChunkEnd, 10);
|
||
const nextEndRaw = typedChunkNextEnd.trim();
|
||
const nextEnd = nextEndRaw ? Number.parseInt(nextEndRaw, 10) : null;
|
||
const maxVerse = activeChapter.verses.at(-1)?.number ?? 0;
|
||
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
|
||
const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0;
|
||
|
||
if (!Number.isInteger(start) || !Number.isInteger(end)) {
|
||
setStatusMessage('Type a valid start and end verse.');
|
||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||
return;
|
||
}
|
||
if (start < 1 || end < 1 || start > maxVerse || end > maxVerse) {
|
||
setStatusMessage(`Verse range must be between 1 and ${maxVerse}.`);
|
||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||
return;
|
||
}
|
||
if (start > end) {
|
||
setStatusMessage('Start verse must be less than or equal to end verse.');
|
||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||
return;
|
||
}
|
||
|
||
if (nextEndRaw) {
|
||
if (!nextChapter) {
|
||
setStatusMessage('Add the next chapter first to span chunks across chapters.');
|
||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||
return;
|
||
}
|
||
if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) {
|
||
setStatusMessage('Chapter spanning only works into the next chapter of the same book.');
|
||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||
return;
|
||
}
|
||
if (!Number.isInteger(nextEnd) || nextEnd < 1 || nextEnd > nextMaxVerse) {
|
||
setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`);
|
||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||
return;
|
||
}
|
||
if (end !== maxVerse) {
|
||
setStatusMessage('To span chapters, set End to the last verse of this chapter.');
|
||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse: nextEnd }]);
|
||
if (addedCount === 0) {
|
||
setStatusMessage('That chunk already exists.');
|
||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||
return;
|
||
}
|
||
|
||
setTypedChunkStart('');
|
||
setTypedChunkEnd('');
|
||
setTypedChunkNextEnd('');
|
||
};
|
||
|
||
const addBulkTypedChunks = () => {
|
||
if (!activeChapter) return;
|
||
const raw = typedChunkBulk.trim();
|
||
if (!raw) {
|
||
setStatusMessage('Type chunk ranges first. Example: 1-6, 7-12');
|
||
window.setTimeout(() => setStatusMessage(''), 2200);
|
||
return;
|
||
}
|
||
|
||
const maxVerse = activeChapter.verses.at(-1)?.number ?? 0;
|
||
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
|
||
const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0;
|
||
const parts = raw
|
||
.split(/[\n,;]+/)
|
||
.map((part) => part.trim())
|
||
.filter(Boolean);
|
||
|
||
const validRanges = [];
|
||
let invalidCount = 0;
|
||
|
||
parts.forEach((part) => {
|
||
const single = /^(\d+)$/.exec(part);
|
||
if (single) {
|
||
const v = Number.parseInt(single[1], 10);
|
||
if (v >= 1 && v <= maxVerse) {
|
||
validRanges.push({ start: v, end: v });
|
||
} else {
|
||
invalidCount += 1;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const range = /^(\d+)\s*-\s*(\d+)(?:\s*:\s*(\d+))?$/.exec(part);
|
||
if (!range) {
|
||
invalidCount += 1;
|
||
return;
|
||
}
|
||
|
||
const start = Number.parseInt(range[1], 10);
|
||
const end = Number.parseInt(range[2], 10);
|
||
const spilloverEndVerse = range[3] ? Number.parseInt(range[3], 10) : null;
|
||
if (start < 1 || end < 1 || start > end || start > maxVerse || end > maxVerse) {
|
||
invalidCount += 1;
|
||
return;
|
||
}
|
||
if (spilloverEndVerse !== null) {
|
||
if (!nextChapter
|
||
|| nextChapter.bookAbbrev !== activeChapter.bookAbbrev
|
||
|| spilloverEndVerse < 1
|
||
|| spilloverEndVerse > nextMaxVerse
|
||
|| end !== maxVerse) {
|
||
invalidCount += 1;
|
||
return;
|
||
}
|
||
}
|
||
validRanges.push({ start, end, spilloverEndVerse });
|
||
});
|
||
|
||
if (validRanges.length === 0) {
|
||
setStatusMessage(`No valid ranges found. Use 1-${maxVerse} and format like 1-6, 7-12.`);
|
||
window.setTimeout(() => setStatusMessage(''), 2600);
|
||
return;
|
||
}
|
||
|
||
const { addedCount, skippedCount } = addChunkRanges(validRanges);
|
||
const notes = [];
|
||
if (addedCount > 0) notes.push(`Added ${addedCount} chunk${addedCount > 1 ? 's' : ''}`);
|
||
if (skippedCount > 0) notes.push(`skipped ${skippedCount} duplicate${skippedCount > 1 ? 's' : ''}`);
|
||
if (invalidCount > 0) notes.push(`ignored ${invalidCount} invalid`);
|
||
setStatusMessage(notes.join(' • '));
|
||
window.setTimeout(() => setStatusMessage(''), 2800);
|
||
|
||
if (addedCount > 0) {
|
||
setTypedChunkBulk('');
|
||
}
|
||
};
|
||
|
||
const addClickSpanChunk = () => {
|
||
if (!activeChapter || rangeStart === null) {
|
||
setStatusMessage('Click a start verse first, then set next chapter end.');
|
||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||
return;
|
||
}
|
||
|
||
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
|
||
if (!nextChapter) {
|
||
setStatusMessage('Add the next chapter first to span into it.');
|
||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||
return;
|
||
}
|
||
if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) {
|
||
setStatusMessage('Click-based spanning only works into the next chapter of the same book.');
|
||
window.setTimeout(() => setStatusMessage(''), 2600);
|
||
return;
|
||
}
|
||
|
||
const nextMaxVerse = nextChapter.verses.at(-1)?.number ?? 0;
|
||
const spilloverEndVerse = Number.parseInt(clickedSpanNextEnd, 10);
|
||
if (!Number.isInteger(spilloverEndVerse) || spilloverEndVerse < 1 || spilloverEndVerse > nextMaxVerse) {
|
||
setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`);
|
||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||
return;
|
||
}
|
||
|
||
const start = rangeStart;
|
||
const end = activeChapter.verses.at(-1)?.number ?? start;
|
||
const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse }]);
|
||
if (addedCount === 0) {
|
||
setStatusMessage('That cross-chapter chunk already exists.');
|
||
window.setTimeout(() => setStatusMessage(''), 2400);
|
||
return;
|
||
}
|
||
|
||
setClickedSpanNextEnd('');
|
||
setRangeStart(null);
|
||
setRangeEnd(null);
|
||
setStatusMessage('Cross-chapter chunk added.');
|
||
window.setTimeout(() => setStatusMessage(''), 1800);
|
||
};
|
||
|
||
const handleVerseClick = (verseNumber, event) => {
|
||
if (event.shiftKey && rangeStart !== null) {
|
||
const start = Math.min(rangeStart, verseNumber);
|
||
const end = Math.max(rangeStart, verseNumber);
|
||
addChunk(start, end);
|
||
setRangeStart(null);
|
||
setRangeEnd(null);
|
||
return;
|
||
}
|
||
setRangeStart(verseNumber);
|
||
setRangeEnd(verseNumber);
|
||
};
|
||
|
||
const updateChunk = (chunkId, patch) => {
|
||
updateProject((current) => ({
|
||
...current,
|
||
chapters: current.chapters.map((ch) => ({
|
||
...ch,
|
||
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch } : c)),
|
||
})),
|
||
}));
|
||
};
|
||
|
||
const moveChunk = (chunkId, direction) => {
|
||
updateProject((current) => ({
|
||
...current,
|
||
chapters: current.chapters.map((ch) => {
|
||
const index = ch.chunks.findIndex((c) => c.id === chunkId);
|
||
if (index < 0) return ch;
|
||
const nextIndex = index + direction;
|
||
if (nextIndex < 0 || nextIndex >= ch.chunks.length) return ch;
|
||
const updated = [...ch.chunks];
|
||
const [removed] = updated.splice(index, 1);
|
||
updated.splice(nextIndex, 0, removed);
|
||
return { ...ch, chunks: updated };
|
||
}),
|
||
}));
|
||
};
|
||
|
||
const deleteChunk = (chunkId) => {
|
||
updateProject((current) => {
|
||
const newAllChunks = current.chapters
|
||
.flatMap((ch) => ch.chunks)
|
||
.filter((c) => c.id !== chunkId);
|
||
const nextSelected =
|
||
current.selectedChunkId === chunkId
|
||
? newAllChunks[0]?.id ?? null
|
||
: current.selectedChunkId;
|
||
return {
|
||
...current,
|
||
selectedChunkId: nextSelected,
|
||
chapters: current.chapters.map((ch) => ({
|
||
...ch,
|
||
chunks: ch.chunks.filter((c) => c.id !== chunkId),
|
||
})),
|
||
};
|
||
});
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Cross-references
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const addCrossRef = (chunkId) => {
|
||
const ref = crossRefInput.trim();
|
||
if (!ref) return;
|
||
updateChunk(chunkId, {
|
||
crossReferences: [...(selectedChunk?.crossReferences ?? []), ref],
|
||
});
|
||
setCrossRefInput('');
|
||
};
|
||
|
||
const removeCrossRef = (chunkId, ref) => {
|
||
updateChunk(chunkId, {
|
||
crossReferences: (selectedChunk?.crossReferences ?? []).filter((r) => r !== ref),
|
||
});
|
||
};
|
||
|
||
const [tagInput, setTagInput] = useState('');
|
||
|
||
const addTag = (chunkId) => {
|
||
const tag = tagInput.trim();
|
||
if (!tag) return;
|
||
const existing = selectedChunk?.tags ?? [];
|
||
if (existing.some((t) => t.toLowerCase() === tag.toLowerCase())) {
|
||
setTagInput('');
|
||
return;
|
||
}
|
||
updateChunk(chunkId, { tags: [...existing, tag] });
|
||
setTagInput('');
|
||
};
|
||
|
||
const removeTag = (chunkId, tag) => {
|
||
updateChunk(chunkId, {
|
||
tags: (selectedChunk?.tags ?? []).filter((t) => t !== tag),
|
||
});
|
||
};
|
||
|
||
const importFinalScriptDocx = async (chunkId, file) => {
|
||
if (!file) return;
|
||
try {
|
||
const arrayBuffer = await file.arrayBuffer();
|
||
const { default: mammoth } = await import('mammoth');
|
||
const { value } = await mammoth.extractRawText({ arrayBuffer });
|
||
updateChunk(chunkId, { finalScript: value.trim() });
|
||
setStatusMessage('Imported script from DOCX.');
|
||
window.setTimeout(() => setStatusMessage(''), 1800);
|
||
} catch (err) {
|
||
setStatusMessage('Could not read that DOCX file.');
|
||
window.setTimeout(() => setStatusMessage(''), 1800);
|
||
}
|
||
};
|
||
|
||
const [suggestingCrossRefs, setSuggestingCrossRefs] = useState(false);
|
||
const [crossRefSuggestions, setCrossRefSuggestions] = useState([]);
|
||
const _crossRefDatasetCacheRef = useRef({});
|
||
|
||
const 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,
|
||
),
|
||
})),
|
||
}));
|
||
};
|
||
|
||
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 suggestHebrewWordsForChunk = async (chunkId) => {
|
||
const chunk = allChunks.find((c) => c.id === chunkId);
|
||
const chapterIndex = project?.chapters.findIndex((ch) =>
|
||
ch.chunks.some((c) => c.id === chunkId)
|
||
) ?? -1;
|
||
const chapter = chapterIndex >= 0 ? project.chapters[chapterIndex] : null;
|
||
if (!chunk || !chapter) return;
|
||
if (NT_BOOK_NUMBER[chapter.bookAbbrev]) {
|
||
setStatusMessage('Hebrew suggestions are for Old Testament passages.');
|
||
window.setTimeout(() => setStatusMessage(''), 2500);
|
||
return;
|
||
}
|
||
|
||
setSuggestingHebrewForChunkId(chunkId);
|
||
try {
|
||
const dict = await loadHebrewDict();
|
||
const versesInChunk = getChunkVerseEntries(project, chapterIndex, chunk)
|
||
.map((v) => v.text)
|
||
.join(' ')
|
||
.toLowerCase();
|
||
|
||
const candidateWords = Array.from(new Set(
|
||
versesInChunk
|
||
.replace(/[^a-z\s]/g, ' ')
|
||
.split(/\s+/)
|
||
.map((w) => w.trim())
|
||
.filter((w) => w.length >= 4 && !ENGLISH_STOP_WORDS.has(w)),
|
||
));
|
||
|
||
if (candidateWords.length === 0) {
|
||
setStatusMessage('No Hebrew suggestion candidates found in this passage.');
|
||
window.setTimeout(() => setStatusMessage(''), 2500);
|
||
return;
|
||
}
|
||
|
||
const existingNumbers = new Set(
|
||
chunk.greekWords.map((w) => (w.strongNumber || '').toUpperCase()).filter(Boolean)
|
||
);
|
||
|
||
const seen = new Set();
|
||
const modalWords = [];
|
||
const entries = Object.entries(dict);
|
||
|
||
for (const token of candidateWords) {
|
||
const match = entries.find(([, entry]) => {
|
||
const defs = `${entry.kjv_def || ''} ${entry.strongs_def || ''}`.toLowerCase();
|
||
return defs.split(/[,;()\s]+/).includes(token);
|
||
});
|
||
if (!match) continue;
|
||
const [strongKey, entry] = match;
|
||
if (!/^H\d+$/i.test(strongKey)) continue;
|
||
const normalized = strongKey.toUpperCase();
|
||
if (existingNumbers.has(normalized) || seen.has(normalized)) continue;
|
||
seen.add(normalized);
|
||
modalWords.push({
|
||
strongKey: normalized,
|
||
lexeme: entry?.lemma ?? '',
|
||
translit: entry?.xlit ?? '',
|
||
def: shortHebrewGloss(entry?.kjv_def) || 'No definition found.',
|
||
fullDef: entry?.kjv_def ?? '',
|
||
entry,
|
||
});
|
||
if (modalWords.length >= 20) break;
|
||
}
|
||
|
||
if (modalWords.length === 0) {
|
||
setStatusMessage('No Hebrew suggestions found from this passage text.');
|
||
window.setTimeout(() => setStatusMessage(''), 2500);
|
||
return;
|
||
}
|
||
|
||
setSuggestModal({
|
||
chunkId,
|
||
language: 'hebrew',
|
||
helperText: 'Heuristic matches from passage text; uncheck anything not useful.',
|
||
words: modalWords,
|
||
});
|
||
setSuggestSelection(new Set(modalWords.map((w) => w.strongKey)));
|
||
} catch (err) {
|
||
setStatusMessage(`Hebrew suggest failed: ${err.message}`);
|
||
window.setTimeout(() => setStatusMessage(''), 3000);
|
||
} finally {
|
||
setSuggestingHebrewForChunkId(null);
|
||
}
|
||
};
|
||
|
||
const confirmSuggestWords = () => {
|
||
if (!suggestModal) return;
|
||
const { chunkId, words } = suggestModal;
|
||
const newWords = words
|
||
.filter((w) => suggestSelection.has(w.strongKey))
|
||
.map((w) => ({
|
||
id: makeId(),
|
||
query: w.strongKey,
|
||
strongNumber: w.strongKey,
|
||
englishGloss: w.def,
|
||
lexeme: w.lexeme,
|
||
transliteration: w.translit,
|
||
partOfSpeech: '',
|
||
shortDefinition: w.fullDef || w.def,
|
||
definitionHtml: w.entry ? buildGreekDefinitionHtml(w.strongKey, w.entry) : '',
|
||
loading: false,
|
||
}));
|
||
if (newWords.length > 0) {
|
||
updateProject((current) => ({
|
||
...current,
|
||
chapters: current.chapters.map((ch) => ({
|
||
...ch,
|
||
chunks: ch.chunks.map((c) =>
|
||
c.id === chunkId ? { ...c, greekWords: [...c.greekWords, ...newWords] } : c
|
||
),
|
||
})),
|
||
}));
|
||
const label = suggestModal?.language === 'hebrew' ? 'Hebrew' : 'Greek';
|
||
setStatusMessage(`Added ${newWords.length} ${label} word${newWords.length > 1 ? 's' : ''}.`);
|
||
window.setTimeout(() => setStatusMessage(''), 2000);
|
||
}
|
||
setSuggestModal(null);
|
||
setSuggestSelection(new Set());
|
||
};
|
||
|
||
const lookupWord = async (chunkId, wordId, language = 'greek') => {
|
||
const chunk = allChunks.find((c) => c.id === chunkId);
|
||
const word = chunk?.greekWords.find((w) => w.id === wordId);
|
||
if (!word || !word.query.trim()) return;
|
||
updateChunkWord(chunkId, wordId, { loading: true });
|
||
try {
|
||
// Normalize bare numbers based on selected lookup language.
|
||
const raw = word.query.trim();
|
||
const inferredPrefix = language === 'hebrew' ? 'H' : 'G';
|
||
const normalized = /^\d+$/.test(raw)
|
||
? `${inferredPrefix}${raw}`
|
||
: /^[gGhH]\d+$/.test(raw)
|
||
? raw.toUpperCase()
|
||
: raw;
|
||
|
||
const [definitions, gloss] = await Promise.all([
|
||
fetchBollsDefinition(normalized),
|
||
loadNtGloss().catch(() => ({})),
|
||
]);
|
||
if (!definitions || definitions.length === 0) {
|
||
updateChunkWord(chunkId, wordId, {
|
||
strongNumber: '',
|
||
shortDefinition: 'No definition found.',
|
||
definitionHtml: '',
|
||
});
|
||
return;
|
||
}
|
||
const first = definitions[0];
|
||
const strongKey = (first.topic || normalized).toUpperCase();
|
||
const extractedPartOfSpeech = extractPartOfSpeech(first.definition || '');
|
||
updateChunkWord(chunkId, wordId, {
|
||
strongNumber: first.topic || normalized,
|
||
englishGloss: gloss[strongKey] || first.short_definition || '',
|
||
lexeme: first.lexeme || '',
|
||
transliteration: first.transliteration || '',
|
||
partOfSpeech: extractedPartOfSpeech || word.partOfSpeech || '',
|
||
shortDefinition: first.short_definition || '',
|
||
definitionHtml: first.definition || '',
|
||
});
|
||
} catch {
|
||
updateChunkWord(chunkId, wordId, { shortDefinition: 'Lookup failed.' });
|
||
} finally {
|
||
updateChunkWord(chunkId, wordId, { loading: false });
|
||
}
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Export
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const exportChapter = () => {
|
||
if (!project) return;
|
||
const html = buildExportHtml(project);
|
||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `${project.title.replace(/\s+/g, '-')}-study.html`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
const printChapterPdf = () => {
|
||
if (!project) return;
|
||
const html = buildExportHtml(project);
|
||
const printWindow = window.open('', '_blank');
|
||
if (!printWindow) {
|
||
alert('Please allow pop-ups for this site to print or save as PDF.');
|
||
return;
|
||
}
|
||
printWindow.document.write(html);
|
||
printWindow.document.close();
|
||
printWindow.onload = () => {
|
||
printWindow.focus();
|
||
printWindow.print();
|
||
};
|
||
};
|
||
|
||
const handleEnableSharing = async () => {
|
||
if (!project) return;
|
||
setShareBusy(true);
|
||
const result = await enableSharing(project.id);
|
||
setShareBusy(false);
|
||
if (result.ok) setShareToken(result.data.shareToken);
|
||
};
|
||
|
||
const handleDisableSharing = async () => {
|
||
if (!project) return;
|
||
if (!window.confirm('Revoke this share link? Anyone using it will lose access immediately.')) return;
|
||
setShareBusy(true);
|
||
const result = await disableSharing(project.id);
|
||
setShareBusy(false);
|
||
if (result.ok) setShareToken(null);
|
||
};
|
||
|
||
const handleCopyShareLink = () => {
|
||
if (!shareToken) return;
|
||
const url = `${window.location.origin}${window.location.pathname}?share=${shareToken}`;
|
||
navigator.clipboard.writeText(url).then(() => {
|
||
setShareCopied(true);
|
||
window.setTimeout(() => setShareCopied(false), 2000);
|
||
});
|
||
};
|
||
|
||
const exportChapterMarkdown = () => {
|
||
if (!project) return;
|
||
const markdown = buildMarkdownExport(project);
|
||
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `${project.title.replace(/\s+/g, '-')}-study.md`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
const exportChapterDocx = async () => {
|
||
if (!project) return;
|
||
const { buildStudyDocxBlob } = await import('./utils/docxExport.js');
|
||
const blob = await buildStudyDocxBlob(project, buildChapterSummary(project));
|
||
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! Opening Claude…');
|
||
window.setTimeout(() => setSaveStatus(''), 3000);
|
||
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
|
||
});
|
||
};
|
||
|
||
const copyForPodcast = () => {
|
||
if (!project) return;
|
||
const prompt = buildPodcastPrompt(project, authUser?.podcastName);
|
||
navigator.clipboard.writeText(prompt).then(() => {
|
||
setSaveStatus('Copied! Opening Claude…');
|
||
window.setTimeout(() => setSaveStatus(''), 3000);
|
||
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
|
||
});
|
||
};
|
||
|
||
const createReadingPlan = (bookAbbrev, bookName, weeks) => {
|
||
const totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? 1;
|
||
const plan = { bookAbbrev, bookName, totalChapters, startDate: Date.now(), targetDate: Date.now() + weeks * 7 * 24 * 60 * 60 * 1000, chaptersRead: [] };
|
||
setReadingPlan(plan);
|
||
try { localStorage.setItem('readingPlan', JSON.stringify(plan)); } catch {}
|
||
};
|
||
|
||
const markChapterRead = (chapter) => {
|
||
setReadingPlan((prev) => {
|
||
if (!prev) return prev;
|
||
const chaptersRead = prev.chaptersRead.includes(chapter)
|
||
? prev.chaptersRead.filter((c) => c !== chapter)
|
||
: [...prev.chaptersRead, chapter].sort((a, b) => a - b);
|
||
const next = { ...prev, chaptersRead };
|
||
try { localStorage.setItem('readingPlan', JSON.stringify(next)); } catch {}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const clearReadingPlan = () => {
|
||
setReadingPlan(null);
|
||
try { localStorage.removeItem('readingPlan'); } catch {}
|
||
};
|
||
|
||
const copyPronunciationGuide = () => {
|
||
if (!project) return;
|
||
const guide = buildPronunciationGuide(project);
|
||
navigator.clipboard.writeText(guide).then(() => {
|
||
setSaveStatus('Copied pronunciation guide!');
|
||
window.setTimeout(() => setSaveStatus(''), 2000);
|
||
});
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Home page helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const openNewProject = () => {
|
||
setProject(null);
|
||
setActiveChapterIndex(0);
|
||
setShowAddChapterForm(false);
|
||
setRangeStart(null);
|
||
setRangeEnd(null);
|
||
setErrorMessage('');
|
||
setStatusMessage('');
|
||
setTitleEdited(false);
|
||
setCurrentPage('setup');
|
||
};
|
||
|
||
const openImportProject = () => {
|
||
setProject(null);
|
||
setImportBookAbbrev(bookOptions[0].abbrev);
|
||
setImportTranslation('BSB');
|
||
setImportTitle('');
|
||
setImportFile(null);
|
||
setImportPreview(null);
|
||
setImportError('');
|
||
setCurrentPage('import');
|
||
};
|
||
|
||
const handleImportFileChange = async (file) => {
|
||
setImportFile(file);
|
||
setImportPreview(null);
|
||
setImportError('');
|
||
if (!file) return;
|
||
setImportBusy(true);
|
||
try {
|
||
const episodes = await parseEpisodeListDocx(file);
|
||
if (episodes.length === 0) {
|
||
throw new Error('No "Session # / Title / Passage" table found in that document.');
|
||
}
|
||
const specs = episodes.map((ep) => ({ ...ep, parsed: parseEpisodePassage(ep.passage) }));
|
||
setImportPreview(specs);
|
||
} catch (error) {
|
||
setImportError(error.message || 'Failed to read that document.');
|
||
} finally {
|
||
setImportBusy(false);
|
||
}
|
||
};
|
||
|
||
const runEpisodeImport = async () => {
|
||
if (!importPreview || importPreview.length === 0) {
|
||
setImportError('Choose a .docx file first.');
|
||
return;
|
||
}
|
||
setImportBusy(true);
|
||
setImportError('');
|
||
try {
|
||
const selectedBook = bookOptions.find((b) => b.abbrev === importBookAbbrev) ?? bookOptions[0];
|
||
|
||
const chapterNumbersNeeded = new Set();
|
||
importPreview.forEach((spec) => {
|
||
if (spec.parsed && spec.parsed !== 'invalid') {
|
||
chapterNumbersNeeded.add(spec.parsed.startChapter);
|
||
chapterNumbersNeeded.add(spec.parsed.endChapter);
|
||
}
|
||
});
|
||
const sortedChapters = Array.from(chapterNumbersNeeded).sort((a, b) => a - b);
|
||
if (sortedChapters.length === 0) {
|
||
throw new Error('No verse passages found to import.');
|
||
}
|
||
|
||
const versesByChapter = {};
|
||
for (const chNum of sortedChapters) {
|
||
const response = await fetch(
|
||
`https://bible.helloao.org/api/${importTranslation}/${selectedBook.abbrev}/${chNum}.json`,
|
||
);
|
||
if (!response.ok) throw new Error(`Unable to load ${selectedBook.name} ${chNum}.`);
|
||
const data = await response.json();
|
||
const verses = parseBibleChapter(data);
|
||
if (!verses.length) throw new Error(`No verses returned for ${selectedBook.name} ${chNum}.`);
|
||
versesByChapter[chNum] = verses;
|
||
}
|
||
|
||
const chapterIndexByNum = new Map(sortedChapters.map((n, idx) => [n, idx]));
|
||
const chapters = sortedChapters.map((chNum) => ({
|
||
book: selectedBook.name,
|
||
bookAbbrev: selectedBook.abbrev,
|
||
chapter: chNum,
|
||
verses: versesByChapter[chNum],
|
||
chunks: [],
|
||
}));
|
||
|
||
// Markers (rows with no passage) attach to the nearest upcoming chapter with a
|
||
// real passage, or fall back to the previous one for trailing markers.
|
||
const anchorChapterFor = importPreview.map((spec, i) => {
|
||
if (spec.parsed && spec.parsed !== 'invalid') return spec.parsed.startChapter;
|
||
for (let j = i + 1; j < importPreview.length; j += 1) {
|
||
const next = importPreview[j].parsed;
|
||
if (next && next !== 'invalid') return next.startChapter;
|
||
}
|
||
for (let j = i - 1; j >= 0; j -= 1) {
|
||
const prev = importPreview[j].parsed;
|
||
if (prev && prev !== 'invalid') return prev.startChapter;
|
||
}
|
||
return sortedChapters[0];
|
||
});
|
||
|
||
importPreview.forEach((spec, i) => {
|
||
if (spec.parsed === 'invalid') return; // skip unparseable rows entirely
|
||
const chIdx = chapterIndexByNum.get(anchorChapterFor[i]);
|
||
if (chIdx == null) return;
|
||
const target = chapters[chIdx];
|
||
|
||
const baseChunk = {
|
||
id: makeId(),
|
||
observation: '',
|
||
interpretation: '',
|
||
application: '',
|
||
crossReferences: [],
|
||
greekWords: [],
|
||
generalNotes: '',
|
||
episodeNumber: spec.episodeNumber,
|
||
episodeTitle: spec.title,
|
||
finalScript: '',
|
||
tags: [],
|
||
};
|
||
|
||
if (!spec.parsed) {
|
||
// No passage (e.g. a part intro/review) -> zero-length marker chunk.
|
||
target.chunks.push({ ...baseChunk, startVerse: 0, endVerse: 0, spilloverEndVerse: null });
|
||
return;
|
||
}
|
||
|
||
const { startChapter, startVerse, endChapter, endVerse } = spec.parsed;
|
||
if (endChapter === startChapter) {
|
||
target.chunks.push({ ...baseChunk, startVerse, endVerse, spilloverEndVerse: null });
|
||
} else {
|
||
const lastVerseOfStartChapter = versesByChapter[startChapter]?.at(-1)?.number ?? endVerse;
|
||
target.chunks.push({
|
||
...baseChunk,
|
||
startVerse,
|
||
endVerse: lastVerseOfStartChapter,
|
||
spilloverEndVerse: endVerse,
|
||
});
|
||
}
|
||
});
|
||
|
||
const newProject = {
|
||
id: makeId(),
|
||
title: importTitle.trim() || `${selectedBook.name} Sessions`,
|
||
translation: importTranslation,
|
||
lastEdited: Date.now(),
|
||
selectedChunkId: null,
|
||
chapters,
|
||
};
|
||
setProject(newProject);
|
||
setActiveChapterIndex(0);
|
||
setCurrentPage('setup');
|
||
} catch (error) {
|
||
setImportError(error.message || 'Import failed.');
|
||
} finally {
|
||
setImportBusy(false);
|
||
}
|
||
};
|
||
|
||
const resumeProject = (id) => {
|
||
const loaded = loadProjectById(id);
|
||
if (!loaded) return;
|
||
setProject(loaded);
|
||
setActiveChapterIndex(0);
|
||
const loadedChunks = loaded.chapters.flatMap((ch) => ch.chunks);
|
||
const hasSelectedChunk = loaded.selectedChunkId
|
||
&& loadedChunks.some((c) => c.id === loaded.selectedChunkId);
|
||
setCurrentPage(hasSelectedChunk ? 'study' : 'setup');
|
||
};
|
||
|
||
const renameProjectInStorage = (id, title) => {
|
||
const loaded = loadProjectById(id);
|
||
if (!loaded) return;
|
||
const updated = { ...loaded, title, lastEdited: Date.now() };
|
||
saveProjectToStorage(updated);
|
||
setProjectIndex(loadProjectIndex());
|
||
saveRemoteProject(updated);
|
||
if (project?.id === id) {
|
||
setProject((current) => (current ? { ...current, title } : current));
|
||
}
|
||
};
|
||
|
||
const deleteProject = (id) => {
|
||
if (!window.confirm('Delete this project? This cannot be undone.')) return;
|
||
deleteProjectFromStorage(id);
|
||
setProjectIndex(loadProjectIndex());
|
||
deleteRemoteProject(id); // fire-and-forget
|
||
};
|
||
|
||
const pullLatestFromServer = async (id) => {
|
||
const result = await loadRemoteProject(id);
|
||
if (!result.ok) {
|
||
alert('Could not pull latest version from server.');
|
||
return;
|
||
}
|
||
saveProjectToStorage(result.data);
|
||
setProjectIndex(loadProjectIndex());
|
||
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
|
||
};
|
||
|
||
const goHome = () => {
|
||
setProjectIndex(loadProjectIndex());
|
||
setCurrentPage('home');
|
||
setProject(null);
|
||
setRangeStart(null);
|
||
setRangeEnd(null);
|
||
setErrorMessage('');
|
||
setStatusMessage('');
|
||
};
|
||
|
||
const authStatus = authUser && (
|
||
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||
<span className="hidden sm:inline">{authUser.email}</span>
|
||
{authUser.isAdmin && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setCurrentPage('admin')}
|
||
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
||
>
|
||
🛡 Admin
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => setCurrentPage('settings')}
|
||
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
||
>
|
||
⚙ Settings
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={logout}
|
||
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
||
>
|
||
Log out
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Shared header
|
||
// ---------------------------------------------------------------------------
|
||
const headerButtons = (
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
{currentPage !== 'home' && (
|
||
<button
|
||
type="button"
|
||
onClick={goHome}
|
||
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
|
||
>
|
||
← Home
|
||
</button>
|
||
)}
|
||
{project && currentPage === 'study' && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setCurrentPage('setup')}
|
||
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
|
||
>
|
||
← Back to Chunks
|
||
</button>
|
||
)}
|
||
{project && (
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={copyForClaude}
|
||
disabled={allChunks.length === 0}
|
||
className="rounded-md bg-violet-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-violet-400 disabled:cursor-not-allowed disabled:bg-slate-500"
|
||
title="Copies your study notes as a Claude prompt, then opens claude.ai so you can paste and go"
|
||
>
|
||
<span className="hidden sm:inline">Study Guide → </span>Claude ↗
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={copyForPodcast}
|
||
disabled={allChunks.length === 0}
|
||
title="Copies a podcast script prompt for Claude, then opens claude.ai — set your show name in Account Settings first"
|
||
className="rounded-md bg-fuchsia-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-fuchsia-400 disabled:cursor-not-allowed disabled:bg-slate-500"
|
||
>
|
||
🎙<span className="hidden sm:inline"> Podcast → Claude ↗</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setCurrentPage('pronunciation')}
|
||
disabled={allChunks.length === 0}
|
||
title="Open the pronunciation guide — all Greek & Hebrew words with transliterations"
|
||
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"
|
||
>
|
||
🗣<span className="hidden sm:inline"> Pronunciation Guide</span>
|
||
</button>
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => setExportMenuOpen((v) => !v)}
|
||
className="flex items-center gap-1.5 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400"
|
||
>
|
||
⬇ Export
|
||
</button>
|
||
{exportMenuOpen && (
|
||
<div className="absolute left-0 top-full z-20 mt-2 w-56 overflow-hidden rounded-2xl border border-slate-200 bg-white py-1.5 text-left shadow-lg">
|
||
<button type="button" onClick={() => { exportChapter(); setExportMenuOpen(false); }}
|
||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||
Export HTML
|
||
</button>
|
||
<button type="button" onClick={() => { exportChapterDocx(); setExportMenuOpen(false); }}
|
||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||
Export DOCX
|
||
</button>
|
||
<button type="button" onClick={() => { exportChapterMarkdown(); setExportMenuOpen(false); }}
|
||
title="Handy for Obsidian, Notion, or any Markdown-based notes app"
|
||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||
Export Markdown
|
||
</button>
|
||
<button type="button" onClick={() => { printChapterPdf(); setExportMenuOpen(false); }}
|
||
title="Opens a print-friendly version in a new tab — choose 'Save as PDF' in the print dialog"
|
||
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||
🖨 Print / Save PDF
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSharePanelOpen((v) => !v)}
|
||
className={`rounded-md px-4 py-2 text-sm font-medium shadow-sm transition ${shareToken ? 'bg-teal-600 text-white hover:bg-teal-500' : 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'}`}
|
||
>
|
||
🔗 {shareToken ? 'Shared' : 'Share'}
|
||
</button>
|
||
{sharePanelOpen && (
|
||
<div className="absolute right-0 top-full z-20 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-lg">
|
||
<h3 className="text-sm font-semibold text-slate-900">Read-only share link</h3>
|
||
<p className="mt-1 text-xs text-slate-500">
|
||
Anyone with this link can view (not edit) this study — no account needed. Good for a co-teacher
|
||
or a listener who wants to follow along.
|
||
</p>
|
||
{shareToken ? (
|
||
<div className="mt-3 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
readOnly
|
||
value={`${window.location.origin}${window.location.pathname}?share=${shareToken}`}
|
||
onFocus={(e) => e.target.select()}
|
||
className="w-full flex-1 truncate rounded-lg border border-slate-300 bg-slate-50 px-2 py-1.5 text-xs text-slate-600"
|
||
/>
|
||
<button type="button" onClick={handleCopyShareLink}
|
||
className="shrink-0 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-400">
|
||
{shareCopied ? 'Copied!' : 'Copy'}
|
||
</button>
|
||
</div>
|
||
<button type="button" onClick={handleDisableSharing} disabled={shareBusy}
|
||
className="w-full rounded-lg border border-rose-200 px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50">
|
||
{shareBusy ? 'Revoking…' : 'Revoke link'}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button type="button" onClick={handleEnableSharing} disabled={shareBusy}
|
||
className="mt-3 w-full rounded-lg bg-teal-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-teal-500 disabled:cursor-not-allowed disabled:opacity-50">
|
||
{shareBusy ? 'Enabling…' : 'Enable sharing'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="text-right text-sm text-slate-300 space-y-0.5">
|
||
{loadingChapter ? (
|
||
<span>Loading…</span>
|
||
) : saveStatus ? (
|
||
<span className="text-emerald-300">{saveStatus}</span>
|
||
) : (
|
||
<span> </span>
|
||
)}
|
||
{syncStatus === 'syncing' && <div className="text-xs text-slate-400">Syncing…</div>}
|
||
{syncStatus === 'synced' && <div className="text-xs text-emerald-400">Synced ✓</div>}
|
||
{syncStatus === 'error' && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-rose-400">⚠ Sync failed</span>
|
||
<button
|
||
type="button"
|
||
onClick={async () => {
|
||
if (!project) return;
|
||
setSyncStatus('syncing');
|
||
const toSave = { ...project, lastEdited: Date.now() };
|
||
saveProjectToStorage(toSave);
|
||
const result = await saveRemoteProject(toSave);
|
||
setSyncStatus(result.ok ? 'synced' : 'error');
|
||
if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500);
|
||
}}
|
||
className="text-xs text-slate-300 underline hover:text-white"
|
||
>
|
||
Retry
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{authStatus}
|
||
</div>
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Context value — every state, handler, and computed value pages need.
|
||
// ---------------------------------------------------------------------------
|
||
const contextValue = {
|
||
// Navigation
|
||
currentPage, setCurrentPage,
|
||
goHome,
|
||
authServerDown,
|
||
// Shared view
|
||
sharedViewToken,
|
||
sharedProject,
|
||
sharedError,
|
||
// Auth (login/2FA/settings form state lives in AuthPage and SettingsPage)
|
||
authUser, setAuthUser,
|
||
authStatus,
|
||
signIn,
|
||
// Home
|
||
projectIndex,
|
||
autoRestoredCount,
|
||
staleLocalProjects,
|
||
homeSearch, setHomeSearch,
|
||
homeSort, setHomeSort,
|
||
homeTagFilter, setHomeTagFilter,
|
||
homeFullTextResults,
|
||
readingPlan,
|
||
createReadingPlan,
|
||
markChapterRead,
|
||
clearReadingPlan,
|
||
renamingId, setRenamingId,
|
||
renameValue, setRenameValue,
|
||
openBibleReader,
|
||
openImportProject,
|
||
openNewProject,
|
||
pullLatestFromServer,
|
||
resumeProject,
|
||
renameProjectInStorage,
|
||
deleteProject,
|
||
// Audio (book player on Home + chapter player in the Reader)
|
||
...audio,
|
||
// Import
|
||
importBookAbbrev, setImportBookAbbrev,
|
||
importTranslation, setImportTranslation,
|
||
importTitle, setImportTitle,
|
||
importFile,
|
||
importPreview,
|
||
importBusy,
|
||
importError,
|
||
handleImportFileChange,
|
||
runEpisodeImport,
|
||
// Reader
|
||
...reader,
|
||
copyVerse,
|
||
// Setup
|
||
availableTranslations,
|
||
project, setProject,
|
||
setup,
|
||
titleEdited, setTitleEdited,
|
||
activeChapterIndex, setActiveChapterIndex,
|
||
activeChapter,
|
||
showAddChapterForm, setShowAddChapterForm,
|
||
rangeStart, setRangeStart,
|
||
rangeEnd, setRangeEnd,
|
||
verseSearch, setVerseSearch,
|
||
typedChunkStart, setTypedChunkStart,
|
||
typedChunkEnd, setTypedChunkEnd,
|
||
typedChunkNextEnd, setTypedChunkNextEnd,
|
||
typedChunkBulk, setTypedChunkBulk,
|
||
clickedSpanNextEnd, setClickedSpanNextEnd,
|
||
loadingChapter,
|
||
errorMessage,
|
||
statusMessage,
|
||
allChunks,
|
||
handleSetupField,
|
||
handleLoadChapter,
|
||
beginStudying,
|
||
handleVerseClick,
|
||
addTypedChunk,
|
||
addBulkTypedChunks,
|
||
addClickSpanChunk,
|
||
moveChunk,
|
||
deleteChunk,
|
||
updateProject,
|
||
// Study
|
||
selectedChunk,
|
||
selectedChunkChapterIndex,
|
||
selectedChunkChapter,
|
||
selectedChunkVerses,
|
||
selectedChunkGlobalIndex,
|
||
studyLayout, setStudyLayout,
|
||
drawTool, setDrawTool,
|
||
drawColor, setDrawColor,
|
||
drawSize, setDrawSize,
|
||
activeStudyTab, setActiveStudyTab,
|
||
mobileStudyTab, setMobileStudyTab,
|
||
collapsedSections, setCollapsedSections,
|
||
interlinearData,
|
||
interlinearLoading,
|
||
interlinearError,
|
||
commentarySource, setCommentarySource,
|
||
commentaryData,
|
||
commentaryLoading,
|
||
commentaryError,
|
||
crossRefInput, setCrossRefInput,
|
||
suggestingCrossRefs,
|
||
crossRefSuggestions,
|
||
tagInput, setTagInput,
|
||
suggestModal, setSuggestModal,
|
||
suggestSelection, setSuggestSelection,
|
||
suggestingGreekForChunkId,
|
||
suggestingHebrewForChunkId,
|
||
goToPreviousChunk,
|
||
goToNextChunk,
|
||
updateChunk,
|
||
addCrossRef,
|
||
removeCrossRef,
|
||
suggestCrossRefsForChunk,
|
||
addSuggestedCrossRef,
|
||
addTag,
|
||
removeTag,
|
||
addGreekWord,
|
||
removeGreekWord,
|
||
updateChunkWord,
|
||
lookupWord,
|
||
suggestGreekWordsForChunk,
|
||
suggestHebrewWordsForChunk,
|
||
confirmSuggestWords,
|
||
speakOriginalWord,
|
||
loadVerseText,
|
||
importFinalScriptDocx,
|
||
// Functions defined inside App (cannot be module-level exports)
|
||
formatCrossRef,
|
||
buildGreekDefinitionHtml,
|
||
externalLookupLinks,
|
||
// Computed JSX
|
||
authStatus,
|
||
headerButtons,
|
||
};
|
||
|
||
return (
|
||
<AppContext.Provider value={contextValue}>
|
||
<Suspense fallback={<PageLoading />}>
|
||
<AppRouter />
|
||
</Suspense>
|
||
</AppContext.Provider>
|
||
);
|
||
};
|
||
|
||
function PageLoading() {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center bg-slate-900">
|
||
<p className="text-sm text-slate-400">Loading…</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AppRouter() {
|
||
const {
|
||
currentPage,
|
||
authUser,
|
||
authServerDown,
|
||
sharedViewToken,
|
||
sharedProject,
|
||
sharedError,
|
||
} = useApp();
|
||
|
||
if (sharedViewToken) {
|
||
if (sharedError) {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4">
|
||
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 text-center shadow-panel">
|
||
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
|
||
<p className="mt-4 text-sm text-rose-600">{sharedError}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (!sharedProject) {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center bg-slate-900">
|
||
<p className="text-sm text-slate-400">Loading shared study…</p>
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="min-h-screen bg-slate-100">
|
||
<div className="border-b border-slate-200 bg-slate-900 px-4 py-3 text-center text-xs text-slate-300">
|
||
🔗 Read-only shared view —{' '}
|
||
<a href="/" className="underline hover:text-white">go to Bible Study Project</a>
|
||
</div>
|
||
<iframe
|
||
title="Shared study"
|
||
srcDoc={buildExportHtml(sharedProject)}
|
||
sandbox="allow-popups"
|
||
className="h-[calc(100vh-2.5rem)] w-full border-0"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (authUser === undefined && !authServerDown) {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center bg-slate-900">
|
||
<p className="text-sm text-slate-400">Loading…</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (authUser === null && !authServerDown) return <AuthPage />;
|
||
|
||
if (currentPage === 'admin') return <AdminPage />;
|
||
if (currentPage === 'settings') return <SettingsPage />;
|
||
if (currentPage === 'reader') return <ReaderPage />;
|
||
if (currentPage === 'import') return <ImportPage />;
|
||
if (currentPage === 'study') return <StudyPage />;
|
||
if (currentPage === 'setup') return <SetupPage />;
|
||
if (currentPage === 'pronunciation') return <PronunciationPage />;
|
||
return <HomePage />;
|
||
}
|
||
|
||
export default App;
|