diff --git a/src/App.jsx b/src/App.jsx
index 76b79bb..74ed2af 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,8 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import mammoth from 'mammoth';
-import { getStroke } from 'perfect-freehand';
-import { pathFromStroke } from './utils/inkRender.js';
-
import { AppContext, useApp } from './context/AppContext.js';
import AdminPage from './pages/AdminPage.jsx';
import AuthPage from './pages/AuthPage.jsx';
@@ -26,6 +23,32 @@ import {
TextRun,
WidthType,
} from 'docx';
+import {
+ COMMENTARY_OPTIONS,
+ STUDY_TABS,
+ CHAPTER_COUNTS,
+ bookOptions,
+ NT_BOOK_NUMBER,
+ storageKey,
+ makeId,
+ formatRelativeDate,
+ formatChunkReference,
+ getChunkVerseEntries,
+ chunkSpansNextChapter,
+ buildExportHtml,
+ wordTableHtml,
+ extractPartOfSpeech,
+ htmlToPlainText,
+ renderVerseContent,
+ parseBibleChapter,
+ indexKey,
+ projectKey,
+ loadProjectIndex,
+ switchStorageUser,
+ BookmarkIcon,
+ CopyIcon,
+ CrossRefChip,
+} from './utils/bibleUtils.jsx';
import {
saveRemoteProject,
@@ -54,119 +77,18 @@ import {
adminDeleteProject,
} from './syncService.js';
-export const COMMENTARY_OPTIONS = [
- { id: 'matthew-henry', name: 'Matthew Henry' },
- { id: 'john-gill', name: 'John Gill' },
- { id: 'jamieson-fausset-brown', name: 'Jamieson-Fausset-Brown' },
- { id: 'adam-clarke', name: 'Adam Clarke' },
- { id: 'keil-delitzsch', name: 'Keil & Delitzsch (OT)' },
- { id: 'tyndale', name: 'Tyndale Open Study Notes' },
-];
+// COMMENTARY_OPTIONS, STUDY_TABS, CHAPTER_COUNTS imported from ./utils/bibleUtils.jsx
+export { COMMENTARY_OPTIONS, STUDY_TABS, CHAPTER_COUNTS };
-export const STUDY_TABS = [
- { id: 'notes', label: 'Notes' },
- { id: 'crossRefs', label: 'Cross-Refs' },
- { id: 'wordStudy', label: 'Word Study' },
- { id: 'commentary', label: 'Commentary' },
- { id: 'script', label: 'Script' },
-];
-
-export const CHAPTER_COUNTS = {
- GEN:50,EXO:40,LEV:27,NUM:36,DEU:34,JOS:24,JDG:21,RUT:4,
- '1SA':31,'2SA':24,'1KI':22,'2KI':25,'1CH':29,'2CH':36,
- EZR:10,NEH:13,EST:10,JOB:42,PSA:150,PRO:31,ECC:12,SNG:8,
- ISA:66,JER:52,LAM:5,EZK:48,DAN:12,HOS:14,JOL:3,AMO:9,
- OBA:1,JON:4,MIC:7,NAM:3,HAB:3,ZEP:3,HAG:2,ZEC:14,MAL:4,
- MAT:28,MRK:16,LUK:24,JHN:21,ACT:28,ROM:16,'1CO':16,'2CO':13,
- GAL:6,EPH:6,PHP:4,COL:4,'1TH':5,'2TH':3,'1TI':6,'2TI':4,
- TIT:3,PHM:1,HEB:13,JAS:5,'1PE':5,'2PE':3,'1JN':5,'2JN':1,
- '3JN':1,JUD:1,REV:22,
-};
-
-export const bookOptions = [
- { name: 'Genesis', abbrev: 'GEN' },
- { name: 'Exodus', abbrev: 'EXO' },
- { name: 'Leviticus', abbrev: 'LEV' },
- { name: 'Numbers', abbrev: 'NUM' },
- { name: 'Deuteronomy', abbrev: 'DEU' },
- { name: 'Joshua', abbrev: 'JOS' },
- { name: 'Judges', abbrev: 'JDG' },
- { name: 'Ruth', abbrev: 'RUT' },
- { name: '1 Samuel', abbrev: '1SA' },
- { name: '2 Samuel', abbrev: '2SA' },
- { name: '1 Kings', abbrev: '1KI' },
- { name: '2 Kings', abbrev: '2KI' },
- { name: '1 Chronicles', abbrev: '1CH' },
- { name: '2 Chronicles', abbrev: '2CH' },
- { name: 'Ezra', abbrev: 'EZR' },
- { name: 'Nehemiah', abbrev: 'NEH' },
- { name: 'Esther', abbrev: 'EST' },
- { name: 'Job', abbrev: 'JOB' },
- { name: 'Psalms', abbrev: 'PSA' },
- { name: 'Proverbs', abbrev: 'PRO' },
- { name: 'Ecclesiastes', abbrev: 'ECC' },
- { name: 'Song', abbrev: 'SNG' },
- { name: 'Isaiah', abbrev: 'ISA' },
- { name: 'Jeremiah', abbrev: 'JER' },
- { name: 'Lamentations', abbrev: 'LAM' },
- { name: 'Ezekiel', abbrev: 'EZK' },
- { name: 'Daniel', abbrev: 'DAN' },
- { name: 'Hosea', abbrev: 'HOS' },
- { name: 'Joel', abbrev: 'JOL' },
- { name: 'Amos', abbrev: 'AMO' },
- { name: 'Obadiah', abbrev: 'OBA' },
- { name: 'Jonah', abbrev: 'JON' },
- { name: 'Micah', abbrev: 'MIC' },
- { name: 'Nahum', abbrev: 'NAM' },
- { name: 'Habakkuk', abbrev: 'HAB' },
- { name: 'Zephaniah', abbrev: 'ZEP' },
- { name: 'Haggai', abbrev: 'HAG' },
- { name: 'Zechariah', abbrev: 'ZEC' },
- { name: 'Malachi', abbrev: 'MAL' },
- { name: 'Matthew', abbrev: 'MAT' },
- { name: 'Mark', abbrev: 'MRK' },
- { name: 'Luke', abbrev: 'LUK' },
- { name: 'John', abbrev: 'JHN' },
- { name: 'Acts', abbrev: 'ACT' },
- { name: 'Romans', abbrev: 'ROM' },
- { name: '1 Corinthians', abbrev: '1CO' },
- { name: '2 Corinthians', abbrev: '2CO' },
- { name: 'Galatians', abbrev: 'GAL' },
- { name: 'Ephesians', abbrev: 'EPH' },
- { name: 'Philippians', abbrev: 'PHP' },
- { name: 'Colossians', abbrev: 'COL' },
- { name: '1 Thessalonians', abbrev: '1TH' },
- { name: '2 Thessalonians', abbrev: '2TH' },
- { name: '1 Timothy', abbrev: '1TI' },
- { name: '2 Timothy', abbrev: '2TI' },
- { name: 'Titus', abbrev: 'TIT' },
- { name: 'Philemon', abbrev: 'PHM' },
- { name: 'Hebrews', abbrev: 'HEB' },
- { name: 'James', abbrev: 'JAS' },
- { name: '1 Peter', abbrev: '1PE' },
- { name: '2 Peter', abbrev: '2PE' },
- { name: '1 John', abbrev: '1JN' },
- { name: '2 John', abbrev: '2JN' },
- { name: '3 John', abbrev: '3JN' },
- { name: 'Jude', abbrev: 'JUD' },
- { name: 'Revelation', abbrev: 'REV' },
-];
-
-export const NT_BOOK_NUMBER = {
- MAT: 40, MRK: 41, LUK: 42, JHN: 43, ACT: 44,
- ROM: 45, '1CO': 46, '2CO': 47, GAL: 48, EPH: 49,
- PHP: 50, COL: 51, '1TH': 52, '2TH': 53, '1TI': 54,
- '2TI': 55, TIT: 56, PHM: 57, HEB: 58, JAS: 59,
- '1PE': 60, '2PE': 61, '1JN': 62, '2JN': 63, '3JN': 64,
- JUD: 65, REV: 66,
-};
+// bookOptions, NT_BOOK_NUMBER imported from ./utils/bibleUtils.jsx
+export { bookOptions, NT_BOOK_NUMBER };
// ---------------------------------------------------------------------------
// Stable utility exports (used by tests and other modules)
// ---------------------------------------------------------------------------
-export const storageKey = (translation, book, chapter) => `bible-study-${translation}-${book}-${chapter}`;
-export const makeId = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
+// storageKey, makeId imported from ./utils/bibleUtils.jsx
+export { storageKey, makeId };
// ---------------------------------------------------------------------------
// Migration helpers (exported for testing)
@@ -202,59 +124,9 @@ export function migrateChunk(chunk) {
};
}
-function chunkSpansNextChapter(project, startChapterIndex, chunk) {
- if (!project || !Array.isArray(project.chapters)) return false;
- if (!Number.isInteger(chunk?.spilloverEndVerse)) return false;
- if (startChapterIndex < 0 || startChapterIndex >= project.chapters.length - 1) return false;
- const startChapter = project.chapters[startChapterIndex];
- const nextChapter = project.chapters[startChapterIndex + 1];
- return startChapter?.bookAbbrev && startChapter.bookAbbrev === nextChapter?.bookAbbrev;
-}
+// formatChunkReference imported from ./utils/bibleUtils.jsx
+export { formatChunkReference };
-export function formatChunkReference(project, startChapterIndex, chunk, separator = '-') {
- if (!project || !chunk) return '';
- const startChapter = project.chapters?.[startChapterIndex];
- if (!startChapter) return '';
-
- if (chunkSpansNextChapter(project, startChapterIndex, chunk)) {
- const nextChapter = project.chapters[startChapterIndex + 1];
- return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${nextChapter.chapter}:${chunk.spilloverEndVerse}`;
- }
-
- if (chunk.startVerse === chunk.endVerse) {
- return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}`;
- }
- return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${chunk.endVerse}`;
-}
-
-function getChunkVerseEntries(project, startChapterIndex, chunk) {
- if (!project || !chunk) return [];
- const startChapter = project.chapters?.[startChapterIndex];
- if (!startChapter) return [];
-
- const startVerses = (startChapter.verses ?? [])
- .filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse)
- .map((verse) => ({
- book: startChapter.book,
- chapter: startChapter.chapter,
- ...verse,
- }));
-
- if (!chunkSpansNextChapter(project, startChapterIndex, chunk)) {
- return startVerses;
- }
-
- const nextChapter = project.chapters[startChapterIndex + 1];
- const spilloverVerses = (nextChapter.verses ?? [])
- .filter((verse) => verse.number >= 1 && verse.number <= chunk.spilloverEndVerse)
- .map((verse) => ({
- book: nextChapter.book,
- chapter: nextChapter.chapter,
- ...verse,
- }));
-
- return [...startVerses, ...spilloverVerses];
-}
export function migrateProject(raw) {
if (!raw) return null;
@@ -292,65 +164,8 @@ export function migrateProject(raw) {
// Storage helpers
// ---------------------------------------------------------------------------
-// Pre-multi-user, un-namespaced keys. Kept around only so existing local data
-// (from before accounts existed) can be claimed by the first user who signs in
-// on a given browser — see switchStorageUser() below.
-const INDEX_KEY_LEGACY = 'bible-study-index';
-const projectKeyLegacy = (id) => `bible-study-project-${id}`;
-
-// The signed-in user's id, or null when signed out / not yet resolved.
-// Sets which localStorage namespace loadProjectIndex/saveProjectToStorage/etc. read
-// and write, so two accounts sharing one browser never see each other's cached projects.
-let activeStorageUserId = null;
-
-function indexKey() {
- return activeStorageUserId ? `bible-study-index:${activeStorageUserId}` : INDEX_KEY_LEGACY;
-}
-
-function projectKey(id) {
- return activeStorageUserId ? `bible-study-project:${activeStorageUserId}:${id}` : projectKeyLegacy(id);
-}
-
-/**
- * Moves any pre-multi-user local project data into this user's own namespace,
- * then deletes the shared legacy keys so no other account can claim them afterward.
- * No-op if this user already has their own namespaced index, or if there's nothing legacy to claim.
- */
-function migrateLegacyLocalDataToUser(userId) {
- if (!userId || window.localStorage.getItem(`bible-study-index:${userId}`)) return;
- const legacyRaw = window.localStorage.getItem(INDEX_KEY_LEGACY);
- if (!legacyRaw) return;
- try {
- const legacyIndex = JSON.parse(legacyRaw) ?? [];
- legacyIndex.forEach((entry) => {
- const raw = window.localStorage.getItem(projectKeyLegacy(entry.id));
- if (raw != null) {
- window.localStorage.setItem(`bible-study-project:${userId}:${entry.id}`, raw);
- window.localStorage.removeItem(projectKeyLegacy(entry.id));
- }
- });
- window.localStorage.setItem(`bible-study-index:${userId}`, legacyRaw);
- window.localStorage.removeItem(INDEX_KEY_LEGACY);
- } catch {
- // Leave legacy data in place if anything goes wrong — better to re-try next login than lose it.
- }
-}
-
-/** Switches the active local-storage namespace and returns the freshly loaded index for it. */
-export function switchStorageUser(userId) {
- activeStorageUserId = userId ?? null;
- if (userId) migrateLegacyLocalDataToUser(userId);
- return loadProjectIndex();
-}
-
-function loadProjectIndex() {
- try {
- const raw = window.localStorage.getItem(indexKey());
- return raw ? JSON.parse(raw) : [];
- } catch {
- return [];
- }
-}
+// indexKey, projectKey, loadProjectIndex, switchStorageUser imported from ./utils/bibleUtils.jsx
+export { switchStorageUser };
function saveProjectToStorage(project) {
// Honour an already-stamped lastEdited (e.g. from autosave) so localStorage
@@ -421,167 +236,15 @@ function buildChapterSummary(project) {
.join(', ');
}
-export function formatRelativeDate(ts) {
- if (!ts) return '';
- const diff = Date.now() - ts;
- const mins = Math.floor(diff / 60000);
- if (mins < 1) return 'just now';
- if (mins < 60) return `${mins}m ago`;
- const hours = Math.floor(mins / 60);
- if (hours < 24) return `${hours}h ago`;
- const days = Math.floor(hours / 24);
- return `${days}d ago`;
-}
+// formatRelativeDate imported from ./utils/bibleUtils.jsx
+export { formatRelativeDate };
// ---------------------------------------------------------------------------
// Export / prompt builders (exported for testing)
// ---------------------------------------------------------------------------
-const escHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
-
-function inkStrokesToSvgHtml(strokes) {
- if (!strokes?.length) return '';
- const W = 700, H = 500;
- const lines = [];
- for (let y = 32; y < H; y += 32)
- lines.push(``);
- lines.push(``);
- const paths = strokes.map((s) => {
- const pts = s.points.map(([x, y, p]) => [x * W, y * H, p]);
- const outline = getStroke(pts, {
- size: s.size * H,
- thinning: s.tool === 'highlighter' ? 0 : 0.5,
- smoothing: 0.5,
- streamline: 0.4,
- });
- const d = pathFromStroke(outline);
- if (!d) return '';
- return ``;
- }).filter(Boolean).join('');
- return `
INK NOTES: `;
-}
-
-export function buildExportHtml(project) {
- const style = `
- body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 32px; }
- .page { max-width: 900px; margin: auto; }
- h1, h2 { font-family: Georgia, serif; }
- h1 { margin-bottom: 0.5rem; }
- .chapter-heading { margin: 2rem 0 0.5rem; font-size: 1.2rem; font-weight: 700; border-bottom: 1px solid #cbd5e1; padding-bottom: 0.25rem; }
- .chunk { margin-bottom: 2rem; padding: 1.25rem 1.5rem; border: 1px solid #cbd5e1; border-radius: 0.75rem; background: #ffffff; }
- .verse { margin: 0 0 0.75rem; line-height: 1.7; }
- .scripture-ref { font-weight: 700; margin-bottom: 0.75rem; }
- .oia, .cross-refs, .greek, .ink-notes { margin-top: 1rem; }
- .oia-section { margin-bottom: 0.75rem; }
- .greek table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
- .greek th, .greek td { border: 1px solid #d1d5db; padding: 0.65rem; text-align: left; }
- .greek th { background: #f8fafc; }
- .definition { margin-top: 0.75rem; font-size: 0.95rem; line-height: 1.6; }
- .definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; }
- @media print {
- body { padding: 0; }
- .chunk { break-inside: avoid; border-color: #cbd5e1; }
- .chapter-heading { break-after: avoid; }
- }
- `;
-
- const chapters = Array.isArray(project.chapters) ? project.chapters : [];
-
- const chunksHtml = chapters.map((ch, chapterIndex) => {
- const chapterHeader = `${escHtml(ch.book)} ${escHtml(ch.chapter)}
`;
- const chunkSections = ch.chunks.map((chunk) => {
- const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
- const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
- .map((verse) => `${escHtml(verse.chapter)}:${escHtml(verse.number)} ${escHtml(verse.text)}
`)
- .join('');
-
- const observation = escHtml((chunk.observation ?? '').trim()).replace(/\n/g, '
') || 'No observation.';
- const interpretation = escHtml((chunk.interpretation ?? '').trim()).replace(/\n/g, '
') || 'No interpretation.';
- const application = escHtml((chunk.application ?? '').trim()).replace(/\n/g, '
') || 'No application.';
-
- const crossRefsHtml = (chunk.crossReferences ?? []).length > 0
- ? `CROSS-REFERENCES: ${chunk.crossReferences.map(escHtml).join(', ')}
`
- : '';
-
- const greekRows = chunk.greekWords.map((word) => `
-
- | ${escHtml(word.strongNumber)} |
- ${escHtml(word.lexeme)} |
- ${escHtml(word.transliteration)} |
- ${escHtml(word.partOfSpeech)} |
- ${escHtml(word.shortDefinition)} |
-
- `).join('');
- const greekTable = wordTableHtml(greekRows);
- const extendedDefinitions = chunk.greekWords
- .map((word) => {
- if (word.definitionHtml) {
- return `
-
-
${escHtml(word.strongNumber)} — ${escHtml(word.lexeme)}
-
${word.definitionHtml}
-
- `;
- }
- if ((word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query) {
- const raw = word.query.trim();
- const normalized = /^\d+$/.test(raw) ? `G${raw}` : raw.toUpperCase();
- const isStrongs = /^G\d+$/.test(normalized);
- const num = isStrongs ? normalized.slice(1) : null;
- const links = isStrongs && num
- ? `BibleHub · Blue Letter Bible · StudyLight`
- : `BibleHub · Blue Letter Bible`;
- return `
-
-
${escHtml(raw)} — definition not found in auto-lookup
-
Look it up manually: ${links}
-
- `;
- }
- return '';
- })
- .join('');
-
- const inkHtml = inkStrokesToSvgHtml(chunk.inkStrokes);
-
- return `
-
- ${escHtml(scripture)}
- ${versesText}
-
-
OBSERVATION:${observation}
-
INTERPRETATION:${interpretation}
-
APPLICATION:${application}
-
- ${crossRefsHtml}
- GREEK WORDS:
- ${greekRows ? greekTable : '
No Greek word notes.
'}
- ${extendedDefinitions}
-
- ${inkHtml}
-
- `;
- }).join('');
- return chapterHeader + chunkSections;
- }).join('');
-
- return `
-
-
-
-
- ${escHtml(project.title)}
-
-
-
-
-
${escHtml(project.title)}
-
${escHtml(project.translation)}
- ${chunksHtml}
-
-
-`;
-}
+// buildExportHtml imported from ./utils/bibleUtils.jsx
+export { buildExportHtml };
export function buildMarkdownExport(project) {
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
@@ -625,51 +288,8 @@ export function buildMarkdownExport(project) {
return `# ${project.title}\n\n*${project.translation}*\n\n${chapterSections}`;
}
-export function wordTableHtml(rows) {
- return `
-
-
-
- | Strong's |
- Greek |
- Transliteration |
- Part of Speech |
- Short Definition |
-
-
-
- ${rows}
-
-
- `;
-}
-
-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();
-}
+// wordTableHtml, extractPartOfSpeech, htmlToPlainText imported from ./utils/bibleUtils.jsx
+export { wordTableHtml, extractPartOfSpeech, htmlToPlainText };
export function createParagraphsFromText(text) {
return text
@@ -699,30 +319,8 @@ export function sectionLabel(text) {
});
}
-export function renderVerseContent(content) {
- if (typeof content === 'string') {
- return content;
- }
- if (Array.isArray(content)) {
- return content.map(renderVerseContent).join('');
- }
- if (content && typeof content === 'object') {
- if (content.lineBreak) {
- return '\n';
- }
- if (content.type === 'line_break') {
- return '\n';
- }
- if (Array.isArray(content.content)) {
- return renderVerseContent(content.content);
- }
- if (typeof content.text === 'string') {
- return content.text;
- }
- return '';
- }
- return '';
-}
+// renderVerseContent imported from ./utils/bibleUtils.jsx
+export { renderVerseContent };
function buildChunkBodies(project) {
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
@@ -904,26 +502,8 @@ PASSAGE: ${chapterLabel}
return header + buildChunkBodies(project);
}
-export function parseBibleChapter(data) {
- if (data?.verses && Array.isArray(data.verses)) {
- return data.verses.map((verse) => ({
- number: verse.number,
- text: verse.text,
- }));
- }
-
- const chapterContents = data?.chapter?.content;
- if (!Array.isArray(chapterContents)) {
- return [];
- }
-
- return chapterContents
- .filter((item) => item?.type === 'verse')
- .map((item) => ({
- number: item.number,
- text: renderVerseContent(item.content).replace(/\s+/g, ' ').trim(),
- }));
-}
+// parseBibleChapter imported from ./utils/bibleUtils.jsx
+export { parseBibleChapter };
// Common short-form aliases for typed-in cross-references (e.g. "Rom 3:23")
const BOOK_NAME_ALIASES = {
@@ -1024,65 +604,8 @@ async function parseEpisodeListDocx(file) {
return Array.from(episodes.values()).sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber));
}
-// Small, unambiguous icons for the reader's verse actions (replaces emoji, which
-// don't reliably convey "bookmark this" vs. "already bookmarked" at a glance).
-export function BookmarkIcon({ filled }) {
- return (
-
- );
-}
-
-export function CopyIcon() {
- return (
-
- );
-}
-
-// A cross-reference chip that fetches and shows the referenced verse text on hover
-export function CrossRefChip({ label, onRemove, loadVerseText }) {
- const [hovered, setHovered] = useState(false);
- const [verseText, setVerseText] = useState(null);
- const [error, setError] = useState('');
-
- useEffect(() => {
- if (!hovered || verseText || error) return;
- let cancelled = false;
- loadVerseText(label)
- .then((text) => { if (!cancelled) setVerseText(text || 'Verse text unavailable.'); })
- .catch(() => { if (!cancelled) setError('Verse text unavailable.'); });
- return () => { cancelled = true; };
- }, [hovered]);
-
- return (
- setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- >
- {label}
- {onRemove && (
-
- )}
- {hovered && (
-
- {error || verseText || 'Loading...'}
-
- )}
-
- );
-}
+// BookmarkIcon, CopyIcon, CrossRefChip imported from ./utils/bibleUtils.jsx
+export { BookmarkIcon, CopyIcon, CrossRefChip };
// ---------------------------------------------------------------------------
// IndexedDB helpers for whole-Bible search cache
diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx
index 90b290b..b86ff14 100644
--- a/src/pages/AdminPage.jsx
+++ b/src/pages/AdminPage.jsx
@@ -1,5 +1,5 @@
import { useApp } from '../context/AppContext.js';
-import { buildExportHtml } from '../App.jsx';
+import { buildExportHtml } from '../utils/bibleUtils.jsx';
import {
adminDeleteUser,
adminDeleteProject,
diff --git a/src/pages/AuthPage.jsx b/src/pages/AuthPage.jsx
index 8676132..22f7a4e 100644
--- a/src/pages/AuthPage.jsx
+++ b/src/pages/AuthPage.jsx
@@ -1,6 +1,6 @@
import { useApp } from '../context/AppContext.js';
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
-import { switchStorageUser } from '../App.jsx';
+import { switchStorageUser } from '../utils/bibleUtils.jsx';
export default function AuthPage() {
const {
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx
index eeaec28..1fe283e 100644
--- a/src/pages/HomePage.jsx
+++ b/src/pages/HomePage.jsx
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
-import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../App.jsx';
+import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../utils/bibleUtils.jsx';
export default function HomePage() {
const {
diff --git a/src/pages/ImportPage.jsx b/src/pages/ImportPage.jsx
index 01b950a..f415de4 100644
--- a/src/pages/ImportPage.jsx
+++ b/src/pages/ImportPage.jsx
@@ -1,5 +1,5 @@
import { useApp } from '../context/AppContext.js';
-import { bookOptions } from '../App.jsx';
+import { bookOptions } from '../utils/bibleUtils.jsx';
export default function ImportPage() {
const {
diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx
index 1c2a719..cf096b5 100644
--- a/src/pages/ReaderPage.jsx
+++ b/src/pages/ReaderPage.jsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useApp } from '../context/AppContext.js';
-import { bookOptions, BookmarkIcon, CopyIcon } from '../App.jsx';
+import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
import DrawCanvas from './DrawCanvas.jsx';
export default function ReaderPage() {
diff --git a/src/pages/SetupPage.jsx b/src/pages/SetupPage.jsx
index cc26ebc..97c383b 100644
--- a/src/pages/SetupPage.jsx
+++ b/src/pages/SetupPage.jsx
@@ -1,5 +1,5 @@
import { useApp } from '../context/AppContext.js';
-import { bookOptions, formatChunkReference } from '../App.jsx';
+import { bookOptions, formatChunkReference } from '../utils/bibleUtils.jsx';
function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter, errorMessage, hideTitle, onField, onTitleChange, onLoad }) {
return (
diff --git a/src/pages/StudyPage.jsx b/src/pages/StudyPage.jsx
index 9868f13..f69a645 100644
--- a/src/pages/StudyPage.jsx
+++ b/src/pages/StudyPage.jsx
@@ -1,7 +1,7 @@
import { createPortal } from 'react-dom';
import DOMPurify from 'dompurify';
import { useApp } from '../context/AppContext.js';
-import { STUDY_TABS, COMMENTARY_OPTIONS, NT_BOOK_NUMBER, formatChunkReference, CrossRefChip } from '../App.jsx';
+import { STUDY_TABS, COMMENTARY_OPTIONS, NT_BOOK_NUMBER, formatChunkReference, CrossRefChip } from '../utils/bibleUtils.jsx';
import DrawCanvas from './DrawCanvas.jsx';
export default function StudyPage() {
diff --git a/src/utils/bibleUtils.jsx b/src/utils/bibleUtils.jsx
new file mode 100644
index 0000000..6069879
--- /dev/null
+++ b/src/utils/bibleUtils.jsx
@@ -0,0 +1,542 @@
+import { useState, useEffect } from 'react';
+import { getStroke } from 'perfect-freehand';
+import { pathFromStroke } from './inkRender.js';
+
+// ---------------------------------------------------------------------------
+// Study constants
+// ---------------------------------------------------------------------------
+
+export const COMMENTARY_OPTIONS = [
+ { id: 'matthew-henry', name: 'Matthew Henry' },
+ { id: 'john-gill', name: 'John Gill' },
+ { id: 'jamieson-fausset-brown', name: 'Jamieson-Fausset-Brown' },
+ { id: 'adam-clarke', name: 'Adam Clarke' },
+ { id: 'keil-delitzsch', name: 'Keil & Delitzsch (OT)' },
+ { id: 'tyndale', name: 'Tyndale Open Study Notes' },
+];
+
+export const STUDY_TABS = [
+ { id: 'notes', label: 'Notes' },
+ { id: 'crossRefs', label: 'Cross-Refs' },
+ { id: 'wordStudy', label: 'Word Study' },
+ { id: 'commentary', label: 'Commentary' },
+ { id: 'script', label: 'Script' },
+];
+
+export const CHAPTER_COUNTS = {
+ GEN:50,EXO:40,LEV:27,NUM:36,DEU:34,JOS:24,JDG:21,RUT:4,
+ '1SA':31,'2SA':24,'1KI':22,'2KI':25,'1CH':29,'2CH':36,
+ EZR:10,NEH:13,EST:10,JOB:42,PSA:150,PRO:31,ECC:12,SNG:8,
+ ISA:66,JER:52,LAM:5,EZK:48,DAN:12,HOS:14,JOL:3,AMO:9,
+ OBA:1,JON:4,MIC:7,NAM:3,HAB:3,ZEP:3,HAG:2,ZEC:14,MAL:4,
+ MAT:28,MRK:16,LUK:24,JHN:21,ACT:28,ROM:16,'1CO':16,'2CO':13,
+ GAL:6,EPH:6,PHP:4,COL:4,'1TH':5,'2TH':3,'1TI':6,'2TI':4,
+ TIT:3,PHM:1,HEB:13,JAS:5,'1PE':5,'2PE':3,'1JN':5,'2JN':1,
+ '3JN':1,JUD:1,REV:22,
+};
+
+export const bookOptions = [
+ { name: 'Genesis', abbrev: 'GEN' },
+ { name: 'Exodus', abbrev: 'EXO' },
+ { name: 'Leviticus', abbrev: 'LEV' },
+ { name: 'Numbers', abbrev: 'NUM' },
+ { name: 'Deuteronomy', abbrev: 'DEU' },
+ { name: 'Joshua', abbrev: 'JOS' },
+ { name: 'Judges', abbrev: 'JDG' },
+ { name: 'Ruth', abbrev: 'RUT' },
+ { name: '1 Samuel', abbrev: '1SA' },
+ { name: '2 Samuel', abbrev: '2SA' },
+ { name: '1 Kings', abbrev: '1KI' },
+ { name: '2 Kings', abbrev: '2KI' },
+ { name: '1 Chronicles', abbrev: '1CH' },
+ { name: '2 Chronicles', abbrev: '2CH' },
+ { name: 'Ezra', abbrev: 'EZR' },
+ { name: 'Nehemiah', abbrev: 'NEH' },
+ { name: 'Esther', abbrev: 'EST' },
+ { name: 'Job', abbrev: 'JOB' },
+ { name: 'Psalms', abbrev: 'PSA' },
+ { name: 'Proverbs', abbrev: 'PRO' },
+ { name: 'Ecclesiastes', abbrev: 'ECC' },
+ { name: 'Song', abbrev: 'SNG' },
+ { name: 'Isaiah', abbrev: 'ISA' },
+ { name: 'Jeremiah', abbrev: 'JER' },
+ { name: 'Lamentations', abbrev: 'LAM' },
+ { name: 'Ezekiel', abbrev: 'EZK' },
+ { name: 'Daniel', abbrev: 'DAN' },
+ { name: 'Hosea', abbrev: 'HOS' },
+ { name: 'Joel', abbrev: 'JOL' },
+ { name: 'Amos', abbrev: 'AMO' },
+ { name: 'Obadiah', abbrev: 'OBA' },
+ { name: 'Jonah', abbrev: 'JON' },
+ { name: 'Micah', abbrev: 'MIC' },
+ { name: 'Nahum', abbrev: 'NAM' },
+ { name: 'Habakkuk', abbrev: 'HAB' },
+ { name: 'Zephaniah', abbrev: 'ZEP' },
+ { name: 'Haggai', abbrev: 'HAG' },
+ { name: 'Zechariah', abbrev: 'ZEC' },
+ { name: 'Malachi', abbrev: 'MAL' },
+ { name: 'Matthew', abbrev: 'MAT' },
+ { name: 'Mark', abbrev: 'MRK' },
+ { name: 'Luke', abbrev: 'LUK' },
+ { name: 'John', abbrev: 'JHN' },
+ { name: 'Acts', abbrev: 'ACT' },
+ { name: 'Romans', abbrev: 'ROM' },
+ { name: '1 Corinthians', abbrev: '1CO' },
+ { name: '2 Corinthians', abbrev: '2CO' },
+ { name: 'Galatians', abbrev: 'GAL' },
+ { name: 'Ephesians', abbrev: 'EPH' },
+ { name: 'Philippians', abbrev: 'PHP' },
+ { name: 'Colossians', abbrev: 'COL' },
+ { name: '1 Thessalonians', abbrev: '1TH' },
+ { name: '2 Thessalonians', abbrev: '2TH' },
+ { name: '1 Timothy', abbrev: '1TI' },
+ { name: '2 Timothy', abbrev: '2TI' },
+ { name: 'Titus', abbrev: 'TIT' },
+ { name: 'Philemon', abbrev: 'PHM' },
+ { name: 'Hebrews', abbrev: 'HEB' },
+ { name: 'James', abbrev: 'JAS' },
+ { name: '1 Peter', abbrev: '1PE' },
+ { name: '2 Peter', abbrev: '2PE' },
+ { name: '1 John', abbrev: '1JN' },
+ { name: '2 John', abbrev: '2JN' },
+ { name: '3 John', abbrev: '3JN' },
+ { name: 'Jude', abbrev: 'JUD' },
+ { name: 'Revelation', abbrev: 'REV' },
+];
+
+export const NT_BOOK_NUMBER = {
+ MAT: 40, MRK: 41, LUK: 42, JHN: 43, ACT: 44,
+ ROM: 45, '1CO': 46, '2CO': 47, GAL: 48, EPH: 49,
+ PHP: 50, COL: 51, '1TH': 52, '2TH': 53, '1TI': 54,
+ '2TI': 55, TIT: 56, PHM: 57, HEB: 58, JAS: 59,
+ '1PE': 60, '2PE': 61, '1JN': 62, '2JN': 63, '3JN': 64,
+ JUD: 65, REV: 66,
+};
+
+export const storageKey = (translation, book, chapter) => `bible-study-${translation}-${book}-${chapter}`;
+export const makeId = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
+
+// ---------------------------------------------------------------------------
+// Format helpers
+// ---------------------------------------------------------------------------
+
+export function formatRelativeDate(ts) {
+ if (!ts) return '';
+ const diff = Date.now() - ts;
+ const mins = Math.floor(diff / 60000);
+ if (mins < 1) return 'just now';
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ const days = Math.floor(hours / 24);
+ return `${days}d ago`;
+}
+
+export function chunkSpansNextChapter(project, startChapterIndex, chunk) {
+ if (!project || !Array.isArray(project.chapters)) return false;
+ if (!Number.isInteger(chunk?.spilloverEndVerse)) return false;
+ if (startChapterIndex < 0 || startChapterIndex >= project.chapters.length - 1) return false;
+ const startChapter = project.chapters[startChapterIndex];
+ const nextChapter = project.chapters[startChapterIndex + 1];
+ return startChapter?.bookAbbrev && startChapter.bookAbbrev === nextChapter?.bookAbbrev;
+}
+
+export function formatChunkReference(project, startChapterIndex, chunk, separator = '-') {
+ if (!project || !chunk) return '';
+ const startChapter = project.chapters?.[startChapterIndex];
+ if (!startChapter) return '';
+
+ if (chunkSpansNextChapter(project, startChapterIndex, chunk)) {
+ const nextChapter = project.chapters[startChapterIndex + 1];
+ return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${nextChapter.chapter}:${chunk.spilloverEndVerse}`;
+ }
+
+ if (chunk.startVerse === chunk.endVerse) {
+ return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}`;
+ }
+ return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${chunk.endVerse}`;
+}
+
+export function getChunkVerseEntries(project, startChapterIndex, chunk) {
+ if (!project || !chunk) return [];
+ const startChapter = project.chapters?.[startChapterIndex];
+ if (!startChapter) return [];
+
+ const startVerses = (startChapter.verses ?? [])
+ .filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse)
+ .map((verse) => ({
+ book: startChapter.book,
+ chapter: startChapter.chapter,
+ ...verse,
+ }));
+
+ if (!chunkSpansNextChapter(project, startChapterIndex, chunk)) {
+ return startVerses;
+ }
+
+ const nextChapter = project.chapters[startChapterIndex + 1];
+ const spilloverVerses = (nextChapter.verses ?? [])
+ .filter((verse) => verse.number >= 1 && verse.number <= chunk.spilloverEndVerse)
+ .map((verse) => ({
+ book: nextChapter.book,
+ chapter: nextChapter.chapter,
+ ...verse,
+ }));
+
+ return [...startVerses, ...spilloverVerses];
+}
+
+// ---------------------------------------------------------------------------
+// HTML export utilities
+// ---------------------------------------------------------------------------
+
+const escHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+
+function inkStrokesToSvgHtml(strokes) {
+ if (!strokes?.length) return '';
+ const W = 700, H = 500;
+ const lines = [];
+ for (let y = 32; y < H; y += 32)
+ lines.push(``);
+ lines.push(``);
+ const paths = strokes.map((s) => {
+ const pts = s.points.map(([x, y, p]) => [x * W, y * H, p]);
+ const outline = getStroke(pts, {
+ size: s.size * H,
+ thinning: s.tool === 'highlighter' ? 0 : 0.5,
+ smoothing: 0.5,
+ streamline: 0.4,
+ });
+ const d = pathFromStroke(outline);
+ if (!d) return '';
+ return ``;
+ }).filter(Boolean).join('');
+ return `INK NOTES: `;
+}
+
+export function buildExportHtml(project) {
+ const style = `
+ body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 32px; }
+ .page { max-width: 900px; margin: auto; }
+ h1, h2 { font-family: Georgia, serif; }
+ h1 { margin-bottom: 0.5rem; }
+ .chapter-heading { margin: 2rem 0 0.5rem; font-size: 1.2rem; font-weight: 700; border-bottom: 1px solid #cbd5e1; padding-bottom: 0.25rem; }
+ .chunk { margin-bottom: 2rem; padding: 1.25rem 1.5rem; border: 1px solid #cbd5e1; border-radius: 0.75rem; background: #ffffff; }
+ .verse { margin: 0 0 0.75rem; line-height: 1.7; }
+ .scripture-ref { font-weight: 700; margin-bottom: 0.75rem; }
+ .oia, .cross-refs, .greek, .ink-notes { margin-top: 1rem; }
+ .oia-section { margin-bottom: 0.75rem; }
+ .greek table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
+ .greek th, .greek td { border: 1px solid #d1d5db; padding: 0.65rem; text-align: left; }
+ .greek th { background: #f8fafc; }
+ .definition { margin-top: 0.75rem; font-size: 0.95rem; line-height: 1.6; }
+ .definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; }
+ @media print {
+ body { padding: 0; }
+ .chunk { break-inside: avoid; border-color: #cbd5e1; }
+ .chapter-heading { break-after: avoid; }
+ }
+ `;
+
+ const chapters = Array.isArray(project.chapters) ? project.chapters : [];
+
+ const chunksHtml = chapters.map((ch, chapterIndex) => {
+ const chapterHeader = `${escHtml(ch.book)} ${escHtml(ch.chapter)}
`;
+ const chunkSections = ch.chunks.map((chunk) => {
+ const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
+ const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
+ .map((verse) => `${escHtml(verse.chapter)}:${escHtml(verse.number)} ${escHtml(verse.text)}
`)
+ .join('');
+
+ const observation = escHtml((chunk.observation ?? '').trim()).replace(/\n/g, '
') || 'No observation.';
+ const interpretation = escHtml((chunk.interpretation ?? '').trim()).replace(/\n/g, '
') || 'No interpretation.';
+ const application = escHtml((chunk.application ?? '').trim()).replace(/\n/g, '
') || 'No application.';
+
+ const crossRefsHtml = (chunk.crossReferences ?? []).length > 0
+ ? `CROSS-REFERENCES: ${chunk.crossReferences.map(escHtml).join(', ')}
`
+ : '';
+
+ const greekRows = chunk.greekWords.map((word) => `
+
+ | ${escHtml(word.strongNumber)} |
+ ${escHtml(word.lexeme)} |
+ ${escHtml(word.transliteration)} |
+ ${escHtml(word.partOfSpeech)} |
+ ${escHtml(word.shortDefinition)} |
+
+ `).join('');
+ const greekTable = wordTableHtml(greekRows);
+ const extendedDefinitions = chunk.greekWords
+ .map((word) => {
+ if (word.definitionHtml) {
+ return `
+
+
${escHtml(word.strongNumber)} — ${escHtml(word.lexeme)}
+
${word.definitionHtml}
+
+ `;
+ }
+ if ((word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query) {
+ const raw = word.query.trim();
+ const normalized = /^\d+$/.test(raw) ? `G${raw}` : raw.toUpperCase();
+ const isStrongs = /^G\d+$/.test(normalized);
+ const num = isStrongs ? normalized.slice(1) : null;
+ const links = isStrongs && num
+ ? `BibleHub · Blue Letter Bible · StudyLight`
+ : `BibleHub · Blue Letter Bible`;
+ return `
+
+
${escHtml(raw)} — definition not found in auto-lookup
+
Look it up manually: ${links}
+
+ `;
+ }
+ return '';
+ })
+ .join('');
+
+ const inkHtml = inkStrokesToSvgHtml(chunk.inkStrokes);
+
+ return `
+
+ ${escHtml(scripture)}
+ ${versesText}
+
+
OBSERVATION:${observation}
+
INTERPRETATION:${interpretation}
+
APPLICATION:${application}
+
+ ${crossRefsHtml}
+ GREEK WORDS:
+ ${greekRows ? greekTable : '
No Greek word notes.
'}
+ ${extendedDefinitions}
+
+ ${inkHtml}
+
+ `;
+ }).join('');
+ return chapterHeader + chunkSections;
+ }).join('');
+
+ return `
+
+
+
+
+ ${escHtml(project.title)}
+
+
+
+
+
${escHtml(project.title)}
+
${escHtml(project.translation)}
+ ${chunksHtml}
+
+
+`;
+}
+
+export function wordTableHtml(rows) {
+ return `
+
+
+
+ | Strong's |
+ Greek |
+ Transliteration |
+ Part of Speech |
+ Short Definition |
+
+
+
+ ${rows}
+
+
+ `;
+}
+
+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 renderVerseContent(content) {
+ if (typeof content === 'string') {
+ return content;
+ }
+ if (Array.isArray(content)) {
+ return content.map(renderVerseContent).join('');
+ }
+ if (content && typeof content === 'object') {
+ if (content.lineBreak) {
+ return '\n';
+ }
+ if (content.type === 'line_break') {
+ return '\n';
+ }
+ if (Array.isArray(content.content)) {
+ return renderVerseContent(content.content);
+ }
+ if (typeof content.text === 'string') {
+ return content.text;
+ }
+ return '';
+ }
+ return '';
+}
+
+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(),
+ }));
+}
+
+// ---------------------------------------------------------------------------
+// Storage helpers
+// ---------------------------------------------------------------------------
+
+const INDEX_KEY_LEGACY = 'bible-study-index';
+const projectKeyLegacy = (id) => `bible-study-project-${id}`;
+
+let activeStorageUserId = null;
+
+export function indexKey() {
+ return activeStorageUserId ? `bible-study-index:${activeStorageUserId}` : INDEX_KEY_LEGACY;
+}
+
+export function projectKey(id) {
+ return activeStorageUserId ? `bible-study-project:${activeStorageUserId}:${id}` : projectKeyLegacy(id);
+}
+
+export function loadProjectIndex() {
+ try {
+ const raw = window.localStorage.getItem(indexKey());
+ return raw ? JSON.parse(raw) : [];
+ } catch {
+ return [];
+ }
+}
+
+function migrateLegacyLocalDataToUser(userId) {
+ if (!userId || window.localStorage.getItem(`bible-study-index:${userId}`)) return;
+ const legacyRaw = window.localStorage.getItem(INDEX_KEY_LEGACY);
+ if (!legacyRaw) return;
+ try {
+ const legacyIndex = JSON.parse(legacyRaw) ?? [];
+ legacyIndex.forEach((entry) => {
+ const raw = window.localStorage.getItem(projectKeyLegacy(entry.id));
+ if (raw != null) {
+ window.localStorage.setItem(`bible-study-project:${userId}:${entry.id}`, raw);
+ window.localStorage.removeItem(projectKeyLegacy(entry.id));
+ }
+ });
+ window.localStorage.setItem(`bible-study-index:${userId}`, legacyRaw);
+ window.localStorage.removeItem(INDEX_KEY_LEGACY);
+ } catch {
+ // Leave legacy data in place if anything goes wrong
+ }
+}
+
+export function switchStorageUser(userId) {
+ activeStorageUserId = userId ?? null;
+ if (userId) migrateLegacyLocalDataToUser(userId);
+ return loadProjectIndex();
+}
+
+// ---------------------------------------------------------------------------
+// Components
+// ---------------------------------------------------------------------------
+
+export function BookmarkIcon({ filled }) {
+ return (
+
+ );
+}
+
+export function CopyIcon() {
+ return (
+
+ );
+}
+
+export function CrossRefChip({ label, onRemove, loadVerseText }) {
+ const [hovered, setHovered] = useState(false);
+ const [verseText, setVerseText] = useState(null);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ if (!hovered || verseText || error) return;
+ let cancelled = false;
+ loadVerseText(label)
+ .then((text) => { if (!cancelled) setVerseText(text || 'Verse text unavailable.'); })
+ .catch(() => { if (!cancelled) setError('Verse text unavailable.'); });
+ return () => { cancelled = true; };
+ }, [hovered]);
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ >
+ {label}
+ {onRemove && (
+
+ )}
+ {hovered && (
+
+ {error || verseText || 'Loading...'}
+
+ )}
+
+ );
+}