Fix circular imports causing React dispatcher null error
Build and Push Docker Image / build-and-push (push) Successful in 4m0s
Build and Push Docker Image / build-and-push (push) Successful in 4m0s
Extract shared utilities (constants, helpers, components) from App.jsx into src/utils/bibleUtils.jsx so page components can import from there instead of from App.jsx. This breaks the App.jsx <-> pages circular dependency that was triggering vite:import-analysis warnings and causing the React hook dispatcher to be null at runtime. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+48
-525
@@ -1,8 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import mammoth from 'mammoth';
|
import mammoth from 'mammoth';
|
||||||
import { getStroke } from 'perfect-freehand';
|
|
||||||
import { pathFromStroke } from './utils/inkRender.js';
|
|
||||||
|
|
||||||
import { AppContext, useApp } from './context/AppContext.js';
|
import { AppContext, useApp } from './context/AppContext.js';
|
||||||
import AdminPage from './pages/AdminPage.jsx';
|
import AdminPage from './pages/AdminPage.jsx';
|
||||||
import AuthPage from './pages/AuthPage.jsx';
|
import AuthPage from './pages/AuthPage.jsx';
|
||||||
@@ -26,6 +23,32 @@ import {
|
|||||||
TextRun,
|
TextRun,
|
||||||
WidthType,
|
WidthType,
|
||||||
} from 'docx';
|
} 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 {
|
import {
|
||||||
saveRemoteProject,
|
saveRemoteProject,
|
||||||
@@ -54,119 +77,18 @@ import {
|
|||||||
adminDeleteProject,
|
adminDeleteProject,
|
||||||
} from './syncService.js';
|
} from './syncService.js';
|
||||||
|
|
||||||
export const COMMENTARY_OPTIONS = [
|
// COMMENTARY_OPTIONS, STUDY_TABS, CHAPTER_COUNTS imported from ./utils/bibleUtils.jsx
|
||||||
{ id: 'matthew-henry', name: 'Matthew Henry' },
|
export { COMMENTARY_OPTIONS, STUDY_TABS, CHAPTER_COUNTS };
|
||||||
{ 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 = [
|
// bookOptions, NT_BOOK_NUMBER imported from ./utils/bibleUtils.jsx
|
||||||
{ id: 'notes', label: 'Notes' },
|
export { bookOptions, NT_BOOK_NUMBER };
|
||||||
{ 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Stable utility exports (used by tests and other modules)
|
// Stable utility exports (used by tests and other modules)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const storageKey = (translation, book, chapter) => `bible-study-${translation}-${book}-${chapter}`;
|
// storageKey, makeId imported from ./utils/bibleUtils.jsx
|
||||||
export const makeId = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
|
export { storageKey, makeId };
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Migration helpers (exported for testing)
|
// Migration helpers (exported for testing)
|
||||||
@@ -202,59 +124,9 @@ export function migrateChunk(chunk) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function chunkSpansNextChapter(project, startChapterIndex, chunk) {
|
// formatChunkReference imported from ./utils/bibleUtils.jsx
|
||||||
if (!project || !Array.isArray(project.chapters)) return false;
|
export { formatChunkReference };
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
export function migrateProject(raw) {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -292,65 +164,8 @@ export function migrateProject(raw) {
|
|||||||
// Storage helpers
|
// Storage helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Pre-multi-user, un-namespaced keys. Kept around only so existing local data
|
// indexKey, projectKey, loadProjectIndex, switchStorageUser imported from ./utils/bibleUtils.jsx
|
||||||
// (from before accounts existed) can be claimed by the first user who signs in
|
export { switchStorageUser };
|
||||||
// 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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveProjectToStorage(project) {
|
function saveProjectToStorage(project) {
|
||||||
// Honour an already-stamped lastEdited (e.g. from autosave) so localStorage
|
// Honour an already-stamped lastEdited (e.g. from autosave) so localStorage
|
||||||
@@ -421,167 +236,15 @@ function buildChapterSummary(project) {
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRelativeDate(ts) {
|
// formatRelativeDate imported from ./utils/bibleUtils.jsx
|
||||||
if (!ts) return '';
|
export { formatRelativeDate };
|
||||||
const diff = Date.now() - ts;
|
|
||||||
const mins = Math.floor(diff / 60000);
|
|
||||||
if (mins < 1) return 'just now';
|
|
||||||
if (mins < 60) return `${mins}m ago`;
|
|
||||||
const hours = Math.floor(mins / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Export / prompt builders (exported for testing)
|
// Export / prompt builders (exported for testing)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const escHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
// buildExportHtml imported from ./utils/bibleUtils.jsx
|
||||||
|
export { buildExportHtml };
|
||||||
function inkStrokesToSvgHtml(strokes) {
|
|
||||||
if (!strokes?.length) return '';
|
|
||||||
const W = 700, H = 500;
|
|
||||||
const lines = [];
|
|
||||||
for (let y = 32; y < H; y += 32)
|
|
||||||
lines.push(`<line x1="0" y1="${y}" x2="${W}" y2="${y}" stroke="#dde3ec" stroke-width="1"/>`);
|
|
||||||
lines.push(`<line x1="40" y1="0" x2="40" y2="${H}" stroke="#fca5a5" stroke-width="1"/>`);
|
|
||||||
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 `<path d="${d}" fill="${escHtml(s.color)}" opacity="${s.tool === 'highlighter' ? '0.35' : '1'}"/>`;
|
|
||||||
}).filter(Boolean).join('');
|
|
||||||
return `<div class="ink-notes"><strong>INK NOTES:</strong><div style="margin-top:8px;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;background:#fff;"><svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" style="display:block;max-width:100%;height:auto;">${lines.join('')}${paths}</svg></div></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = `<h2 class="chapter-heading">${escHtml(ch.book)} ${escHtml(ch.chapter)}</h2>`;
|
|
||||||
const chunkSections = ch.chunks.map((chunk) => {
|
|
||||||
const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
|
|
||||||
const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
|
|
||||||
.map((verse) => `<p class="verse"><strong>${escHtml(verse.chapter)}:${escHtml(verse.number)}</strong> ${escHtml(verse.text)}</p>`)
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
const observation = escHtml((chunk.observation ?? '').trim()).replace(/\n/g, '<br />') || '<em>No observation.</em>';
|
|
||||||
const interpretation = escHtml((chunk.interpretation ?? '').trim()).replace(/\n/g, '<br />') || '<em>No interpretation.</em>';
|
|
||||||
const application = escHtml((chunk.application ?? '').trim()).replace(/\n/g, '<br />') || '<em>No application.</em>';
|
|
||||||
|
|
||||||
const crossRefsHtml = (chunk.crossReferences ?? []).length > 0
|
|
||||||
? `<div class="cross-refs"><strong>CROSS-REFERENCES:</strong> ${chunk.crossReferences.map(escHtml).join(', ')}</div>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const greekRows = chunk.greekWords.map((word) => `
|
|
||||||
<tr>
|
|
||||||
<td><strong>${escHtml(word.strongNumber)}</strong></td>
|
|
||||||
<td>${escHtml(word.lexeme)}</td>
|
|
||||||
<td>${escHtml(word.transliteration)}</td>
|
|
||||||
<td>${escHtml(word.partOfSpeech)}</td>
|
|
||||||
<td>${escHtml(word.shortDefinition)}</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
const greekTable = wordTableHtml(greekRows);
|
|
||||||
const extendedDefinitions = chunk.greekWords
|
|
||||||
.map((word) => {
|
|
||||||
if (word.definitionHtml) {
|
|
||||||
return `
|
|
||||||
<div class="definition-block">
|
|
||||||
<p><strong>${escHtml(word.strongNumber)} — ${escHtml(word.lexeme)}</strong></p>
|
|
||||||
<div class="definition">${word.definitionHtml}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
if ((word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query) {
|
|
||||||
const raw = word.query.trim();
|
|
||||||
const normalized = /^\d+$/.test(raw) ? `G${raw}` : raw.toUpperCase();
|
|
||||||
const isStrongs = /^G\d+$/.test(normalized);
|
|
||||||
const num = isStrongs ? normalized.slice(1) : null;
|
|
||||||
const links = isStrongs && num
|
|
||||||
? `<a href="https://biblehub.com/greek/${num}.htm">BibleHub</a> · <a href="https://www.blueletterbible.org/lexicon/g${num}/esv/0-1/">Blue Letter Bible</a> · <a href="https://www.studylight.org/lexicons/eng/greek/${num}.html">StudyLight</a>`
|
|
||||||
: `<a href="https://biblehub.com/search.php?q=${encodeURIComponent(raw)}">BibleHub</a> · <a href="https://www.blueletterbible.org/search/search.cfm?Criteria=${encodeURIComponent(raw)}&t=KJV#s=s_lexiconc">Blue Letter Bible</a>`;
|
|
||||||
return `
|
|
||||||
<div class="definition-block" style="border-color:#fcd34d;background:#fffbeb;">
|
|
||||||
<p><strong>${escHtml(raw)}</strong> — definition not found in auto-lookup</p>
|
|
||||||
<p style="margin-top:0.5rem;font-size:0.9rem;">Look it up manually: ${links}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
const inkHtml = inkStrokesToSvgHtml(chunk.inkStrokes);
|
|
||||||
|
|
||||||
return `
|
|
||||||
<section class="chunk">
|
|
||||||
<div class="scripture-ref">${escHtml(scripture)}</div>
|
|
||||||
${versesText}
|
|
||||||
<div class="oia">
|
|
||||||
<div class="oia-section"><strong>OBSERVATION:</strong><p>${observation}</p></div>
|
|
||||||
<div class="oia-section"><strong>INTERPRETATION:</strong><p>${interpretation}</p></div>
|
|
||||||
<div class="oia-section"><strong>APPLICATION:</strong><p>${application}</p></div>
|
|
||||||
</div>
|
|
||||||
${crossRefsHtml}
|
|
||||||
<div class="greek"><strong>GREEK WORDS:</strong>
|
|
||||||
${greekRows ? greekTable : '<p><em>No Greek word notes.</em></p>'}
|
|
||||||
${extendedDefinitions}
|
|
||||||
</div>
|
|
||||||
${inkHtml}
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
return chapterHeader + chunkSections;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>${escHtml(project.title)}</title>
|
|
||||||
<style>${style}</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
<h1>${escHtml(project.title)}</h1>
|
|
||||||
<p>${escHtml(project.translation)}</p>
|
|
||||||
${chunksHtml}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildMarkdownExport(project) {
|
export function buildMarkdownExport(project) {
|
||||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
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}`;
|
return `# ${project.title}\n\n*${project.translation}*\n\n${chapterSections}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wordTableHtml(rows) {
|
// wordTableHtml, extractPartOfSpeech, htmlToPlainText imported from ./utils/bibleUtils.jsx
|
||||||
return `
|
export { wordTableHtml, extractPartOfSpeech, htmlToPlainText };
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Strong's</th>
|
|
||||||
<th>Greek</th>
|
|
||||||
<th>Transliteration</th>
|
|
||||||
<th>Part of Speech</th>
|
|
||||||
<th>Short Definition</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${rows}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractPartOfSpeech(definitionHtml) {
|
|
||||||
const match = /Part\(s\) of speech:\s*([^<]+)/i.exec(definitionHtml || '');
|
|
||||||
if (match && match[1]) {
|
|
||||||
return match[1].trim();
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function htmlToPlainText(html = '') {
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const doc = parser.parseFromString(html, 'text/html');
|
|
||||||
const lines = [];
|
|
||||||
doc.body.childNodes.forEach((node) => {
|
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
|
||||||
lines.push(node.textContent || '');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (node.nodeName === 'BR' || node.nodeName === 'P') {
|
|
||||||
lines.push(node.textContent || '');
|
|
||||||
lines.push('\n');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lines.push(node.textContent || '');
|
|
||||||
});
|
|
||||||
return lines.join('').replace(/\n{2,}/g, '\n').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createParagraphsFromText(text) {
|
export function createParagraphsFromText(text) {
|
||||||
return text
|
return text
|
||||||
@@ -699,30 +319,8 @@ export function sectionLabel(text) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderVerseContent(content) {
|
// renderVerseContent imported from ./utils/bibleUtils.jsx
|
||||||
if (typeof content === 'string') {
|
export { renderVerseContent };
|
||||||
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 '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildChunkBodies(project) {
|
function buildChunkBodies(project) {
|
||||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||||
@@ -904,26 +502,8 @@ PASSAGE: ${chapterLabel}
|
|||||||
return header + buildChunkBodies(project);
|
return header + buildChunkBodies(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseBibleChapter(data) {
|
// parseBibleChapter imported from ./utils/bibleUtils.jsx
|
||||||
if (data?.verses && Array.isArray(data.verses)) {
|
export { parseBibleChapter };
|
||||||
return data.verses.map((verse) => ({
|
|
||||||
number: verse.number,
|
|
||||||
text: verse.text,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const chapterContents = data?.chapter?.content;
|
|
||||||
if (!Array.isArray(chapterContents)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return chapterContents
|
|
||||||
.filter((item) => item?.type === 'verse')
|
|
||||||
.map((item) => ({
|
|
||||||
number: item.number,
|
|
||||||
text: renderVerseContent(item.content).replace(/\s+/g, ' ').trim(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Common short-form aliases for typed-in cross-references (e.g. "Rom 3:23")
|
// Common short-form aliases for typed-in cross-references (e.g. "Rom 3:23")
|
||||||
const BOOK_NAME_ALIASES = {
|
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));
|
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
|
// BookmarkIcon, CopyIcon, CrossRefChip imported from ./utils/bibleUtils.jsx
|
||||||
// don't reliably convey "bookmark this" vs. "already bookmarked" at a glance).
|
export { BookmarkIcon, CopyIcon, CrossRefChip };
|
||||||
export function BookmarkIcon({ filled }) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 20 20" width="16" height="16" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.6">
|
|
||||||
<path d="M5 3.5C5 2.67 5.67 2 6.5 2h7c.83 0 1.5.67 1.5 1.5v14l-5-3.5-5 3.5v-14z" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CopyIcon() {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6">
|
|
||||||
<rect x="7" y="7" width="10" height="11" rx="1.5" />
|
|
||||||
<path d="M4.5 13.5h-1A1.5 1.5 0 0 1 2 12V3.5A1.5 1.5 0 0 1 3.5 2H12a1.5 1.5 0 0 1 1.5 1.5v1" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<span
|
|
||||||
className="relative inline-flex items-center gap-1 rounded-full bg-slate-200 px-3 py-1 text-sm text-slate-800"
|
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
{onRemove && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onRemove}
|
|
||||||
className="ml-1 text-slate-500 hover:text-rose-600"
|
|
||||||
aria-label={`Remove ${label}`}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{hovered && (
|
|
||||||
<span className="absolute bottom-full left-0 z-10 mb-2 w-64 rounded-xl border border-slate-300 bg-white p-3 text-xs font-normal normal-case text-slate-700 shadow-lg">
|
|
||||||
{error || verseText || 'Loading...'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// IndexedDB helpers for whole-Bible search cache
|
// IndexedDB helpers for whole-Bible search cache
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { buildExportHtml } from '../App.jsx';
|
import { buildExportHtml } from '../utils/bibleUtils.jsx';
|
||||||
import {
|
import {
|
||||||
adminDeleteUser,
|
adminDeleteUser,
|
||||||
adminDeleteProject,
|
adminDeleteProject,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
|
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
|
||||||
import { switchStorageUser } from '../App.jsx';
|
import { switchStorageUser } from '../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
export default function AuthPage() {
|
export default function AuthPage() {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
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() {
|
export default function HomePage() {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { bookOptions } from '../App.jsx';
|
import { bookOptions } from '../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
export default function ImportPage() {
|
export default function ImportPage() {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
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';
|
import DrawCanvas from './DrawCanvas.jsx';
|
||||||
|
|
||||||
export default function ReaderPage() {
|
export default function ReaderPage() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useApp } from '../context/AppContext.js';
|
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 }) {
|
function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter, errorMessage, hideTitle, onField, onTitleChange, onLoad }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { useApp } from '../context/AppContext.js';
|
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';
|
import DrawCanvas from './DrawCanvas.jsx';
|
||||||
|
|
||||||
export default function StudyPage() {
|
export default function StudyPage() {
|
||||||
|
|||||||
@@ -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, '>').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(`<line x1="0" y1="${y}" x2="${W}" y2="${y}" stroke="#dde3ec" stroke-width="1"/>`);
|
||||||
|
lines.push(`<line x1="40" y1="0" x2="40" y2="${H}" stroke="#fca5a5" stroke-width="1"/>`);
|
||||||
|
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 `<path d="${d}" fill="${escHtml(s.color)}" opacity="${s.tool === 'highlighter' ? '0.35' : '1'}"/>`;
|
||||||
|
}).filter(Boolean).join('');
|
||||||
|
return `<div class="ink-notes"><strong>INK NOTES:</strong><div style="margin-top:8px;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;background:#fff;"><svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" style="display:block;max-width:100%;height:auto;">${lines.join('')}${paths}</svg></div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<h2 class="chapter-heading">${escHtml(ch.book)} ${escHtml(ch.chapter)}</h2>`;
|
||||||
|
const chunkSections = ch.chunks.map((chunk) => {
|
||||||
|
const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
|
||||||
|
const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
|
||||||
|
.map((verse) => `<p class="verse"><strong>${escHtml(verse.chapter)}:${escHtml(verse.number)}</strong> ${escHtml(verse.text)}</p>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const observation = escHtml((chunk.observation ?? '').trim()).replace(/\n/g, '<br />') || '<em>No observation.</em>';
|
||||||
|
const interpretation = escHtml((chunk.interpretation ?? '').trim()).replace(/\n/g, '<br />') || '<em>No interpretation.</em>';
|
||||||
|
const application = escHtml((chunk.application ?? '').trim()).replace(/\n/g, '<br />') || '<em>No application.</em>';
|
||||||
|
|
||||||
|
const crossRefsHtml = (chunk.crossReferences ?? []).length > 0
|
||||||
|
? `<div class="cross-refs"><strong>CROSS-REFERENCES:</strong> ${chunk.crossReferences.map(escHtml).join(', ')}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const greekRows = chunk.greekWords.map((word) => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escHtml(word.strongNumber)}</strong></td>
|
||||||
|
<td>${escHtml(word.lexeme)}</td>
|
||||||
|
<td>${escHtml(word.transliteration)}</td>
|
||||||
|
<td>${escHtml(word.partOfSpeech)}</td>
|
||||||
|
<td>${escHtml(word.shortDefinition)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
const greekTable = wordTableHtml(greekRows);
|
||||||
|
const extendedDefinitions = chunk.greekWords
|
||||||
|
.map((word) => {
|
||||||
|
if (word.definitionHtml) {
|
||||||
|
return `
|
||||||
|
<div class="definition-block">
|
||||||
|
<p><strong>${escHtml(word.strongNumber)} — ${escHtml(word.lexeme)}</strong></p>
|
||||||
|
<div class="definition">${word.definitionHtml}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if ((word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query) {
|
||||||
|
const raw = word.query.trim();
|
||||||
|
const normalized = /^\d+$/.test(raw) ? `G${raw}` : raw.toUpperCase();
|
||||||
|
const isStrongs = /^G\d+$/.test(normalized);
|
||||||
|
const num = isStrongs ? normalized.slice(1) : null;
|
||||||
|
const links = isStrongs && num
|
||||||
|
? `<a href="https://biblehub.com/greek/${num}.htm">BibleHub</a> · <a href="https://www.blueletterbible.org/lexicon/g${num}/esv/0-1/">Blue Letter Bible</a> · <a href="https://www.studylight.org/lexicons/eng/greek/${num}.html">StudyLight</a>`
|
||||||
|
: `<a href="https://biblehub.com/search.php?q=${encodeURIComponent(raw)}">BibleHub</a> · <a href="https://www.blueletterbible.org/search/search.cfm?Criteria=${encodeURIComponent(raw)}&t=KJV#s=s_lexiconc">Blue Letter Bible</a>`;
|
||||||
|
return `
|
||||||
|
<div class="definition-block" style="border-color:#fcd34d;background:#fffbeb;">
|
||||||
|
<p><strong>${escHtml(raw)}</strong> — definition not found in auto-lookup</p>
|
||||||
|
<p style="margin-top:0.5rem;font-size:0.9rem;">Look it up manually: ${links}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const inkHtml = inkStrokesToSvgHtml(chunk.inkStrokes);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<section class="chunk">
|
||||||
|
<div class="scripture-ref">${escHtml(scripture)}</div>
|
||||||
|
${versesText}
|
||||||
|
<div class="oia">
|
||||||
|
<div class="oia-section"><strong>OBSERVATION:</strong><p>${observation}</p></div>
|
||||||
|
<div class="oia-section"><strong>INTERPRETATION:</strong><p>${interpretation}</p></div>
|
||||||
|
<div class="oia-section"><strong>APPLICATION:</strong><p>${application}</p></div>
|
||||||
|
</div>
|
||||||
|
${crossRefsHtml}
|
||||||
|
<div class="greek"><strong>GREEK WORDS:</strong>
|
||||||
|
${greekRows ? greekTable : '<p><em>No Greek word notes.</em></p>'}
|
||||||
|
${extendedDefinitions}
|
||||||
|
</div>
|
||||||
|
${inkHtml}
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
return chapterHeader + chunkSections;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>${escHtml(project.title)}</title>
|
||||||
|
<style>${style}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<h1>${escHtml(project.title)}</h1>
|
||||||
|
<p>${escHtml(project.translation)}</p>
|
||||||
|
${chunksHtml}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wordTableHtml(rows) {
|
||||||
|
return `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Strong's</th>
|
||||||
|
<th>Greek</th>
|
||||||
|
<th>Transliteration</th>
|
||||||
|
<th>Part of Speech</th>
|
||||||
|
<th>Short Definition</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPartOfSpeech(definitionHtml) {
|
||||||
|
const match = /Part\(s\) of speech:\s*([^<]+)/i.exec(definitionHtml || '');
|
||||||
|
if (match && match[1]) {
|
||||||
|
return match[1].trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function htmlToPlainText(html = '') {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
const lines = [];
|
||||||
|
doc.body.childNodes.forEach((node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
lines.push(node.textContent || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.nodeName === 'BR' || node.nodeName === 'P') {
|
||||||
|
lines.push(node.textContent || '');
|
||||||
|
lines.push('\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lines.push(node.textContent || '');
|
||||||
|
});
|
||||||
|
return lines.join('').replace(/\n{2,}/g, '\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 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 (
|
||||||
|
<svg viewBox="0 0 20 20" width="16" height="16" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.6">
|
||||||
|
<path d="M5 3.5C5 2.67 5.67 2 6.5 2h7c.83 0 1.5.67 1.5 1.5v14l-5-3.5-5 3.5v-14z" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||||
|
<rect x="7" y="7" width="10" height="11" rx="1.5" />
|
||||||
|
<path d="M4.5 13.5h-1A1.5 1.5 0 0 1 2 12V3.5A1.5 1.5 0 0 1 3.5 2H12a1.5 1.5 0 0 1 1.5 1.5v1" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<span
|
||||||
|
className="relative inline-flex items-center gap-1 rounded-full bg-slate-200 px-3 py-1 text-sm text-slate-800"
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="ml-1 text-slate-500 hover:text-rose-600"
|
||||||
|
aria-label={`Remove ${label}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hovered && (
|
||||||
|
<span className="absolute bottom-full left-0 z-10 mb-2 w-64 rounded-xl border border-slate-300 bg-white p-3 text-xs font-normal normal-case text-slate-700 shadow-lg">
|
||||||
|
{error || verseText || 'Loading...'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user