Speed up the app, run production build, remove chunk ink, start App.jsx split
Performance: - Lazy-load pages, docx, and mammoth (initial JS 1,298 KB -> ~290 KB) - Gzip responses and cache hashed assets for a year - Memoize DrawCanvas strokes so ink isn't redrawn on every render - Precompute whole-Bible search index and defer the query while typing - Reload once if a lazy chunk is missing after a deploy Deployment: - setup.sh builds and runs one production server on APP_PORT (default 5173); --dev keeps the old Vite dev setup - Session cookie uses secure: 'auto' so login works behind any proxy Chunk editor: - Remove the Draw layout, Ink Notes panel, and ink in HTML export (existing inkStrokes data is left in saved projects) Refactor: - Move module-level helpers out of App.jsx into src/utils/* - Extract Bible reader state and logic into hooks/useBibleReader Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
00932dee4a
commit
ade7f1af6f
20 files changed
+1314
-1252
No files matched your search
+52
-1098
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -130,7 +130,7 @@ describe('Initial render', () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
expect(screen.getByText('Project Setup')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Project Setup')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
// State, data loading, and helpers for the read-only Bible reader page.
|
||||
// Audio playback stays in App because the study page's book player shares
|
||||
// the same <audio> element.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { bookOptions, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
|
||||
|
||||
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
|
||||
|
||||
export function useBibleReader(currentPage) {
|
||||
const [readerBookAbbrev, setReaderBookAbbrev] = useState(bookOptions[0].abbrev);
|
||||
const [readerChapter, setReaderChapter] = useState(1);
|
||||
const [readerVerses, setReaderVerses] = useState([]);
|
||||
const [readerTotalChapters, setReaderTotalChapters] = useState(1);
|
||||
const [readerLoading, setReaderLoading] = useState(false);
|
||||
const [readerError, setReaderError] = useState('');
|
||||
const [readerInterlinear, setReaderInterlinear] = useState(null); // full book interlinear
|
||||
const [readerInterlinearLoading, setReaderInterlinearLoading] = useState(false);
|
||||
const [readerSelectedVerse, setReaderSelectedVerse] = useState(null); // verse number with open interlinear panel
|
||||
const _readerInterlinearCacheRef = useRef({});
|
||||
const [readerFontSize, setReaderFontSize] = useState(1); // em multiplier: 0.875 | 1 | 1.125 | 1.25
|
||||
const [readerBookmarks, setReaderBookmarks] = useState(() => {
|
||||
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
|
||||
});
|
||||
const [readerBookmarksPanelOpen, setReaderBookmarksPanelOpen] = useState(false);
|
||||
const [readerJumpVerse, setReaderJumpVerse] = useState(null); // verse number to scroll to + flash after a jump
|
||||
const [readerCrossRefs, setReaderCrossRefs] = useState(null);
|
||||
const [readerCrossRefsLoading, setReaderCrossRefsLoading] = useState(false);
|
||||
const [readerShowCrossRefs, setReaderShowCrossRefs] = useState(false);
|
||||
const _readerCrossRefCacheRef = useRef({});
|
||||
const [readerSearch, setReaderSearch] = useState('');
|
||||
const [readerSearchActive, setReaderSearchActive] = useState(false);
|
||||
const [readerSearchScope, setReaderSearchScope] = useState('chapter'); // 'chapter' | 'bible'
|
||||
const [bibleIndexStatus, setBibleIndexStatus] = useState('idle'); // 'idle' | 'loading' | 'ready' | 'error'
|
||||
const _bibleIndexCacheRef = useRef({}); // translation -> flat verse array
|
||||
const [readerInkByPage, setReaderInkByPage] = useState(() => {
|
||||
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
|
||||
catch { return {}; }
|
||||
});
|
||||
const [readerTextHighlights, setReaderTextHighlights] = useState(() => {
|
||||
try {
|
||||
const val = JSON.parse(localStorage.getItem('readerTextHighlights') || '[]');
|
||||
return Array.isArray(val) ? val : [];
|
||||
} catch { return []; }
|
||||
});
|
||||
// Debounce timer ref so rapid stroke updates don't flood the server
|
||||
const inkSaveTimerRef = useRef({});
|
||||
|
||||
// The HelloAO API has no search endpoint, so whole-Bible search fetches the
|
||||
// entire translation once (~7MB) and searches an in-memory flat verse index
|
||||
// client-side. Cached per translation for the rest of the session (not persisted
|
||||
// across reloads, to avoid eating into localStorage/IndexedDB quota for a cache
|
||||
// that's cheap enough to rebuild once per visit).
|
||||
const loadBibleIndex = async () => {
|
||||
if (_bibleIndexCacheRef.current.BSB) {
|
||||
setBibleIndexStatus('ready');
|
||||
return;
|
||||
}
|
||||
setBibleIndexStatus('loading');
|
||||
try {
|
||||
// Check IndexedDB cache before hitting the network (~7 MB fetch)
|
||||
const cached = await getBibleIndexFromIDB('BSB');
|
||||
if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
|
||||
_bibleIndexCacheRef.current.BSB = cached.flat;
|
||||
setBibleIndexStatus('ready');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch('https://bible.helloao.org/api/BSB/complete.json');
|
||||
if (!res.ok) throw new Error('Unable to load the full Bible for search.');
|
||||
const data = await res.json();
|
||||
const flat = [];
|
||||
for (const book of data.books ?? []) {
|
||||
for (const ch of book.chapters ?? []) {
|
||||
const verses = parseBibleChapter({ chapter: ch.chapter });
|
||||
for (const v of verses) {
|
||||
if (v.text) {
|
||||
flat.push({ bookAbbrev: book.id, bookName: book.commonName || book.name, chapter: ch.chapter.number, verse: v.number, text: v.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_bibleIndexCacheRef.current.BSB = flat;
|
||||
setBibleIndexStatus('ready');
|
||||
// Persist for future visits — non-blocking
|
||||
saveBibleIndexToIDB('BSB', flat);
|
||||
} catch {
|
||||
setBibleIndexStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const loadReaderChapter = async (bookAbbrev, chapterNumber) => {
|
||||
setReaderLoading(true);
|
||||
setReaderError('');
|
||||
try {
|
||||
const res = await fetch(`https://bible.helloao.org/api/BSB/${bookAbbrev}/${chapterNumber}.json`);
|
||||
if (!res.ok) throw new Error('Unable to load chapter.');
|
||||
const data = await res.json();
|
||||
const verses = parseBibleChapter(data);
|
||||
if (!Array.isArray(verses) || verses.length === 0) {
|
||||
throw new Error('Invalid Bible data returned.');
|
||||
}
|
||||
setReaderVerses(verses);
|
||||
setReaderTotalChapters(data.book?.numberOfChapters ?? chapterNumber);
|
||||
} catch (err) {
|
||||
setReaderError(err.message || 'Failed to load chapter.');
|
||||
setReaderVerses([]);
|
||||
} finally {
|
||||
setReaderLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage !== 'reader') return;
|
||||
loadReaderChapter(readerBookAbbrev, readerChapter);
|
||||
}, [currentPage, readerBookAbbrev, readerChapter]);
|
||||
|
||||
// After navigating to a verse (from the bookmarks panel or a whole-Bible search
|
||||
// result), scroll to it and briefly flash its background so it's easy to spot.
|
||||
useEffect(() => {
|
||||
if (readerJumpVerse == null || readerLoading) return;
|
||||
const el = document.getElementById(`reader-verse-${readerJumpVerse}`);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
const prevTransition = el.style.transition;
|
||||
const prevBackground = el.style.backgroundColor;
|
||||
el.style.transition = 'background-color 0.4s';
|
||||
el.style.backgroundColor = '#fef08a';
|
||||
const t = window.setTimeout(() => {
|
||||
el.style.backgroundColor = prevBackground;
|
||||
window.setTimeout(() => { el.style.transition = prevTransition; }, 400);
|
||||
}, 1200);
|
||||
setReaderJumpVerse(null);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [readerVerses, readerLoading, readerJumpVerse]);
|
||||
|
||||
// Navigates the reader to a specific verse (bookmarks panel, search results) and
|
||||
// scrolls/flashes it once that chapter's verses have loaded.
|
||||
const jumpToReaderVerse = (bookAbbrev, chapter, verseNumber) => {
|
||||
setReaderBookAbbrev(bookAbbrev);
|
||||
setReaderChapter(chapter);
|
||||
setReaderSelectedVerse(null);
|
||||
setReaderInterlinear(null);
|
||||
setReaderCrossRefs(null);
|
||||
setReaderSearch('');
|
||||
setReaderSearchActive(false);
|
||||
setReaderBookmarksPanelOpen(false);
|
||||
setReaderJumpVerse(verseNumber);
|
||||
};
|
||||
|
||||
const readerGoToPreviousChapter = () => {
|
||||
if (readerChapter > 1) { setReaderChapter((c) => c - 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
|
||||
};
|
||||
|
||||
const readerGoToNextChapter = () => {
|
||||
if (readerChapter < readerTotalChapters) { setReaderChapter((c) => c + 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
|
||||
};
|
||||
|
||||
const handleReaderBookChange = (abbrev) => {
|
||||
setReaderBookAbbrev(abbrev);
|
||||
setReaderChapter(1);
|
||||
setReaderSelectedVerse(null);
|
||||
setReaderInterlinear(null);
|
||||
setReaderCrossRefs(null);
|
||||
setReaderSearch('');
|
||||
setReaderSearchActive(false);
|
||||
};
|
||||
|
||||
const loadReaderInterlinear = async (bookAbbrev) => {
|
||||
if (_readerInterlinearCacheRef.current[bookAbbrev]) {
|
||||
setReaderInterlinear(_readerInterlinearCacheRef.current[bookAbbrev]);
|
||||
return;
|
||||
}
|
||||
setReaderInterlinearLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/interlinear/${bookAbbrev}.json`);
|
||||
if (!res.ok) throw new Error('no interlinear');
|
||||
const data = await res.json();
|
||||
_readerInterlinearCacheRef.current[bookAbbrev] = data;
|
||||
setReaderInterlinear(data);
|
||||
} catch {
|
||||
setReaderInterlinear(null);
|
||||
} finally {
|
||||
setReaderInterlinearLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage !== 'reader') return;
|
||||
loadReaderInterlinear(readerBookAbbrev);
|
||||
setReaderSelectedVerse(null);
|
||||
}, [currentPage, readerBookAbbrev]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage !== 'reader' || !readerShowCrossRefs) return;
|
||||
loadReaderCrossRefs(readerBookAbbrev, readerChapter);
|
||||
}, [currentPage, readerBookAbbrev, readerChapter, readerShowCrossRefs]);
|
||||
|
||||
// Bookmarks, ink, highlights, cross-refs
|
||||
const toggleReaderBookmark = (verseKey) => {
|
||||
setReaderBookmarks((prev) => {
|
||||
const next = { ...prev };
|
||||
if (next[verseKey]) {
|
||||
delete next[verseKey];
|
||||
} else {
|
||||
const usedColors = Object.values(next);
|
||||
const color = BOOKMARK_COLORS.find((c) => !usedColors.includes(c)) ?? BOOKMARK_COLORS[0];
|
||||
next[verseKey] = color;
|
||||
}
|
||||
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
|
||||
const key = `${bookAbbrev}_${chapter}`;
|
||||
setReaderInkByPage((prev) => {
|
||||
const next = { ...prev, [key]: strokes };
|
||||
try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
// Debounce server sync — wait 1 s after last stroke before sending
|
||||
clearTimeout(inkSaveTimerRef.current[key]);
|
||||
inkSaveTimerRef.current[key] = setTimeout(() => {
|
||||
fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ strokes }),
|
||||
}).catch(() => {}); // silent — localStorage already saved locally
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const cycleBookmarkColor = (verseKey) => {
|
||||
setReaderBookmarks((prev) => {
|
||||
const cur = prev[verseKey];
|
||||
if (!cur) return prev;
|
||||
const idx = BOOKMARK_COLORS.indexOf(cur);
|
||||
const next = { ...prev, [verseKey]: BOOKMARK_COLORS[(idx + 1) % BOOKMARK_COLORS.length] };
|
||||
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addTextHighlight = (highlight) => {
|
||||
const id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
setReaderTextHighlights(prev => {
|
||||
const next = [...prev, { id, ...highlight }];
|
||||
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeTextHighlight = (id) => {
|
||||
setReaderTextHighlights(prev => {
|
||||
const next = prev.filter(h => h.id !== id);
|
||||
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const loadReaderCrossRefs = async (bookAbbrev, chapterNumber) => {
|
||||
const cacheKey = `${bookAbbrev}/${chapterNumber}`;
|
||||
if (_readerCrossRefCacheRef.current[cacheKey]) {
|
||||
setReaderCrossRefs(_readerCrossRefCacheRef.current[cacheKey]);
|
||||
return;
|
||||
}
|
||||
setReaderCrossRefsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`https://bible.helloao.org/api/d/open-cross-ref/${bookAbbrev}/${chapterNumber}.json`);
|
||||
if (!res.ok) throw new Error('no cross-refs');
|
||||
const data = await res.json();
|
||||
const byVerse = {};
|
||||
for (const item of data.chapter?.content ?? []) {
|
||||
if (item.references?.length) byVerse[item.verse] = item.references;
|
||||
}
|
||||
_readerCrossRefCacheRef.current[cacheKey] = byVerse;
|
||||
setReaderCrossRefs(byVerse);
|
||||
} catch {
|
||||
setReaderCrossRefs(null);
|
||||
} finally {
|
||||
setReaderCrossRefsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Merges reader ink loaded from the server at sign-in; server wins over local.
|
||||
const mergeServerInk = useCallback((serverInk) => {
|
||||
setReaderInkByPage((local) => {
|
||||
const merged = { ...local, ...serverInk };
|
||||
try { localStorage.setItem('readerInkByPage', JSON.stringify(merged)); } catch {}
|
||||
return merged;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
readerBookAbbrev, setReaderBookAbbrev,
|
||||
readerChapter, setReaderChapter,
|
||||
readerVerses,
|
||||
readerTotalChapters,
|
||||
readerLoading,
|
||||
readerError,
|
||||
readerInterlinear,
|
||||
readerSelectedVerse, setReaderSelectedVerse,
|
||||
readerFontSize, setReaderFontSize,
|
||||
readerBookmarks, setReaderBookmarks,
|
||||
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
|
||||
readerCrossRefs, setReaderCrossRefs,
|
||||
readerCrossRefsLoading,
|
||||
readerShowCrossRefs, setReaderShowCrossRefs,
|
||||
readerSearch, setReaderSearch,
|
||||
readerSearchActive, setReaderSearchActive,
|
||||
readerSearchScope, setReaderSearchScope,
|
||||
readerInkByPage,
|
||||
updateReaderPageInk,
|
||||
mergeServerInk,
|
||||
readerTextHighlights,
|
||||
addTextHighlight,
|
||||
removeTextHighlight,
|
||||
bibleIndexStatus,
|
||||
loadBibleIndex,
|
||||
_bibleIndexCacheRef,
|
||||
loadReaderChapter,
|
||||
handleReaderBookChange,
|
||||
readerGoToPreviousChapter,
|
||||
readerGoToNextChapter,
|
||||
jumpToReaderVerse,
|
||||
toggleReaderBookmark,
|
||||
cycleBookmarkColor,
|
||||
loadReaderCrossRefs,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,15 @@ if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
|
||||
// Pages are lazy-loaded chunks. If a deploy happened while this tab was open,
|
||||
// the old chunk files are gone — reload once to pick up the new build.
|
||||
window.addEventListener('vite:preloadError', () => {
|
||||
if (sessionStorage.getItem('chunk-reload')) return;
|
||||
sessionStorage.setItem('chunk-reload', '1');
|
||||
window.location.reload();
|
||||
});
|
||||
window.addEventListener('load', () => sessionStorage.removeItem('chunk-reload'));
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { renderInkToCanvas } from '../utils/inkRender.js';
|
||||
|
||||
@@ -35,8 +35,9 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
||||
const { pageCount: initPages } = extractMeta(strokes);
|
||||
const [pageCount, setPageCount] = useState(initPages);
|
||||
|
||||
// realStrokes = strokes without the metadata header
|
||||
const { realStrokes } = extractMeta(strokes);
|
||||
// realStrokes = strokes without the metadata header. Memoized so the canvas
|
||||
// only redraws when the strokes actually change, not on every parent re-render.
|
||||
const realStrokes = useMemo(() => extractMeta(strokes).realStrokes, [strokes]);
|
||||
|
||||
const canvasRef = useRef(null);
|
||||
const hoverRef = useRef(null);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
|
||||
import DrawCanvas from './DrawCanvas.jsx';
|
||||
|
||||
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
|
||||
|
||||
export default function ReaderPage() {
|
||||
const {
|
||||
authStatus,
|
||||
@@ -55,8 +57,27 @@ export default function ReaderPage() {
|
||||
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
|
||||
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
||||
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
|
||||
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? [];
|
||||
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
|
||||
const [selectionPicker, setSelectionPicker] = useState(null);
|
||||
|
||||
// Whole-Bible search: lowercase the ~31k-verse index once (not per keystroke),
|
||||
// and let typing stay responsive while results catch up via useDeferredValue.
|
||||
const bibleIndex = bibleIndexStatus === 'ready' ? (_bibleIndexCacheRef.current.BSB ?? null) : null;
|
||||
const bibleIndexLower = useMemo(
|
||||
() => (bibleIndex ? bibleIndex.map((v) => v.text.toLowerCase()) : null),
|
||||
[bibleIndex],
|
||||
);
|
||||
const deferredBibleQuery = useDeferredValue(
|
||||
readerSearchActive && readerSearchScope === 'bible' ? readerSearch.trim().toLowerCase() : '',
|
||||
);
|
||||
const bibleMatches = useMemo(() => {
|
||||
if (!bibleIndex || !deferredBibleQuery) return [];
|
||||
const out = [];
|
||||
for (let i = 0; i < bibleIndex.length; i++) {
|
||||
if (bibleIndexLower[i].includes(deferredBibleQuery)) out.push(bibleIndex[i]);
|
||||
}
|
||||
return out;
|
||||
}, [bibleIndex, bibleIndexLower, deferredBibleQuery]);
|
||||
const swipeTouchRef = useRef(null);
|
||||
|
||||
const [toolbarVisible, setToolbarVisible] = useState(true);
|
||||
@@ -444,10 +465,9 @@ export default function ReaderPage() {
|
||||
<p className="text-sm text-rose-600">Couldn't load the full Bible for search. <button type="button" onClick={loadBibleIndex} className="underline">Try again</button></p>
|
||||
)}
|
||||
{bibleIndexStatus === 'ready' && (() => {
|
||||
const q = readerSearch.trim().toLowerCase();
|
||||
const q = deferredBibleQuery;
|
||||
if (!q) return <p className="text-sm text-slate-500">Type at least a word to search all 66 books.</p>;
|
||||
const index = _bibleIndexCacheRef.current.BSB ?? [];
|
||||
const matches = index.filter((v) => v.text.toLowerCase().includes(q));
|
||||
const matches = bibleMatches;
|
||||
if (matches.length === 0) return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
|
||||
const shown = matches.slice(0, 100);
|
||||
return (
|
||||
|
||||
+1
-68
@@ -2,7 +2,6 @@ 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 '../utils/bibleUtils.jsx';
|
||||
import DrawCanvas from './DrawCanvas.jsx';
|
||||
|
||||
export default function StudyPage() {
|
||||
const {
|
||||
@@ -197,7 +196,6 @@ export default function StudyPage() {
|
||||
{[
|
||||
{ id: 'stacked', label: '☰ Stacked' },
|
||||
{ id: 'split', label: '◫ Split' },
|
||||
{ id: 'annotate', label: '✏ Draw' },
|
||||
].map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
@@ -243,29 +241,6 @@ export default function StudyPage() {
|
||||
|
||||
{selectedChunk ? (
|
||||
<div className="mt-6 space-y-6">
|
||||
{studyLayout === 'annotate' ? (
|
||||
<DrawCanvas
|
||||
strokes={selectedChunk.inkStrokes ?? []}
|
||||
onStrokesChange={(s) => updateChunk(selectedChunk.id, { inkStrokes: s })}
|
||||
onDone={() => setStudyLayout('stacked')}
|
||||
headerContent={
|
||||
<>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Scripture · draw on me</span>
|
||||
<span className="rounded-full bg-slate-200 px-2.5 py-0.5 text-xs font-semibold text-slate-600">
|
||||
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '–')}
|
||||
</span>
|
||||
</div>
|
||||
{selectedChunkVerses.map((verse) => (
|
||||
<p key={`${verse.chapter}-${verse.number}`} className="mb-1">
|
||||
<span className="font-semibold text-slate-900">{verse.chapter}:{verse.number}.</span>{' '}
|
||||
{verse.text}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className={`space-y-6 ${studyLayout === 'split' ? 'lg:flex lg:items-start lg:gap-6 lg:space-y-0' : ''}`}>
|
||||
{/* Scripture */}
|
||||
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'scripture' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' ? 'lg:sticky lg:top-6 lg:flex-1 lg:basis-0 lg:min-w-0 lg:self-start' : ''}`}>
|
||||
@@ -515,47 +490,6 @@ export default function StudyPage() {
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
{/* Inline ink notes — collapsible, always available without switching to annotate mode */}
|
||||
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'notes' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsedSections((c) => ({ ...c, inlineInk: !c.inlineInk }))}
|
||||
className="flex w-full items-center justify-between gap-2 text-left"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">Ink Notes</h3>
|
||||
<p className="text-xs text-slate-500">Draw diagrams or handwritten notes for this chunk.</p>
|
||||
</div>
|
||||
<span className="text-slate-400">{collapsedSections.inlineInk ? '▸' : '▾'}</span>
|
||||
</button>
|
||||
{!collapsedSections.inlineInk && (
|
||||
<div className="mt-4">
|
||||
<DrawCanvas
|
||||
strokes={selectedChunk.inkStrokes ?? []}
|
||||
onStrokesChange={(s) => updateChunk(selectedChunk.id, { inkStrokes: s })}
|
||||
headerContent={
|
||||
selectedChunkVerses.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Scripture · draw on me</span>
|
||||
<span className="rounded-full bg-slate-200 px-2.5 py-0.5 text-xs font-semibold text-slate-600">
|
||||
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '–')}
|
||||
</span>
|
||||
</div>
|
||||
{selectedChunkVerses.map((verse) => (
|
||||
<p key={`${verse.chapter}-${verse.number}`} className="mb-1">
|
||||
<span className="font-semibold text-slate-900">{verse.chapter}:{verse.number}.</span>{' '}
|
||||
{verse.text}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cross-references */}
|
||||
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'crossRefs' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'crossRefs' ? 'hidden' : ''}`}>
|
||||
<button
|
||||
@@ -941,7 +875,6 @@ export default function StudyPage() {
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
) : (
|
||||
@@ -955,7 +888,7 @@ export default function StudyPage() {
|
||||
</main>
|
||||
|
||||
{/* Prev / Next — fixed bottom bar, visible while scrolling long chunks */}
|
||||
{selectedChunk && studyLayout !== 'annotate' && (
|
||||
{selectedChunk && (
|
||||
<div className="fixed bottom-0 inset-x-0 z-20 border-t border-slate-200 bg-white/95 backdrop-blur-sm">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-4 py-3 sm:px-6 lg:px-8">
|
||||
<button
|
||||
|
||||
+4
-6
@@ -7,12 +7,10 @@ import {
|
||||
parseBibleChapter,
|
||||
wordTableHtml,
|
||||
buildExportHtml,
|
||||
buildMarkdownExport,
|
||||
buildClaudePrompt,
|
||||
createParagraphsFromText,
|
||||
migrateChunk,
|
||||
migrateProject,
|
||||
} from './App.jsx';
|
||||
} from './utils/bibleUtils.jsx';
|
||||
import { buildMarkdownExport, buildClaudePrompt } from './utils/exportBuilders.js';
|
||||
import { migrateChunk, migrateProject } from './utils/projectStorage.js';
|
||||
import { createParagraphsFromText } from './utils/docxExport.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// storageKey
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// IndexedDB cache for the whole-Bible search index.
|
||||
const BIBLE_IDB_NAME = 'study-app-bible-index';
|
||||
const BIBLE_IDB_STORE = 'bible-index';
|
||||
export const BIBLE_IDB_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
function openBibleIndexDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(BIBLE_IDB_NAME, 1);
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains(BIBLE_IDB_STORE)) {
|
||||
db.createObjectStore(BIBLE_IDB_STORE, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
req.onsuccess = (e) => resolve(e.target.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBibleIndexFromIDB(translation) {
|
||||
try {
|
||||
const db = await openBibleIndexDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(BIBLE_IDB_STORE, 'readonly');
|
||||
const req = tx.objectStore(BIBLE_IDB_STORE).get(translation);
|
||||
req.onsuccess = () => resolve(req.result ?? null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBibleIndexToIDB(translation, flat) {
|
||||
try {
|
||||
const db = await openBibleIndexDB();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(BIBLE_IDB_STORE, 'readwrite');
|
||||
const req = tx.objectStore(BIBLE_IDB_STORE).put({ id: translation, flat, cachedAt: Date.now() });
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal; in-memory cache still works for this session
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStroke } from 'perfect-freehand';
|
||||
import { pathFromStroke } from './inkRender.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Study constants
|
||||
@@ -192,28 +190,6 @@ export function getChunkVerseEntries(project, startChapterIndex, chunk) {
|
||||
|
||||
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; }
|
||||
@@ -224,7 +200,7 @@ export function buildExportHtml(project) {
|
||||
.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, .cross-refs, .greek { 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; }
|
||||
@@ -295,8 +271,6 @@ export function buildExportHtml(project) {
|
||||
})
|
||||
.join('');
|
||||
|
||||
const inkHtml = inkStrokesToSvgHtml(chunk.inkStrokes);
|
||||
|
||||
return `
|
||||
<section class="chunk">
|
||||
<div class="scripture-ref">${escHtml(scripture)}</div>
|
||||
@@ -311,7 +285,6 @@ export function buildExportHtml(project) {
|
||||
${greekRows ? greekTable : '<p><em>No Greek word notes.</em></p>'}
|
||||
${extendedDefinitions}
|
||||
</div>
|
||||
${inkHtml}
|
||||
</section>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// DOCX export — kept in its own module so the (large) `docx` library is only
|
||||
// downloaded when the user actually exports, instead of on every page load.
|
||||
import {
|
||||
BorderStyle,
|
||||
Document,
|
||||
HeadingLevel,
|
||||
Packer,
|
||||
Paragraph,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from 'docx';
|
||||
import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx';
|
||||
|
||||
export function createParagraphsFromText(text) {
|
||||
return text
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => new Paragraph({ children: [new TextRun({ text: line })] }));
|
||||
}
|
||||
|
||||
const DOCX_ACCENT = 'C9A84C'; // antique gold
|
||||
const DOCX_HEADING_FONT = 'Cormorant Garamond';
|
||||
const DOCX_BODY_FONT = 'Lora';
|
||||
|
||||
export function sectionLabel(text) {
|
||||
return new Paragraph({
|
||||
spacing: { before: 240, after: 80 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: text.toUpperCase(),
|
||||
bold: true,
|
||||
color: DOCX_ACCENT,
|
||||
font: DOCX_BODY_FONT,
|
||||
size: 20,
|
||||
characterSpacing: 20,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildStudyDocxBlob(project, chapterSummary) {
|
||||
const children = [
|
||||
new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `${project.translation} — ${chapterSummary}`,
|
||||
italics: true,
|
||||
color: '7A7060',
|
||||
}),
|
||||
],
|
||||
spacing: { after: 300 },
|
||||
}),
|
||||
];
|
||||
|
||||
const noBorder = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' };
|
||||
|
||||
project.chapters.forEach((ch, chapterIndex) => {
|
||||
children.push(new Paragraph({
|
||||
text: `${ch.book} ${ch.chapter}`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: DOCX_ACCENT, space: 4 } },
|
||||
spacing: { before: 360, after: 160 },
|
||||
}));
|
||||
ch.chunks.forEach((chunk) => {
|
||||
const scriptureHeading = formatChunkReference(project, chapterIndex, chunk, '-');
|
||||
|
||||
const headingRuns = [new TextRun({ text: scriptureHeading, bold: true, size: 28, color: DOCX_ACCENT, font: DOCX_HEADING_FONT })];
|
||||
if (chunk.episodeNumber || chunk.episodeTitle) {
|
||||
headingRuns.push(new TextRun({
|
||||
text: ` (Session ${chunk.episodeNumber || '—'}${chunk.episodeTitle ? `: ${chunk.episodeTitle}` : ''})`,
|
||||
italics: true,
|
||||
size: 22,
|
||||
color: '7A7060',
|
||||
}));
|
||||
}
|
||||
children.push(new Paragraph({ children: headingRuns, spacing: { before: 320, after: 120 } }));
|
||||
|
||||
// Scripture text in a left-bordered table cell for a "callout" look (no fill)
|
||||
const scriptureParas = getChunkVerseEntries(project, chapterIndex, chunk).map((verse) => new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: `${verse.chapter}:${verse.number} `, bold: true, color: DOCX_ACCENT }),
|
||||
new TextRun({ text: verse.text, italics: true }),
|
||||
],
|
||||
spacing: { after: 80 },
|
||||
}));
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
margins: { top: 120, bottom: 120, left: 180, right: 180 },
|
||||
borders: {
|
||||
...{ top: noBorder, right: noBorder, bottom: noBorder },
|
||||
left: { style: BorderStyle.SINGLE, size: 24, color: DOCX_ACCENT },
|
||||
},
|
||||
children: scriptureParas,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}));
|
||||
children.push(new Paragraph({ text: '', spacing: { after: 120 } }));
|
||||
|
||||
if (chunk.generalNotes?.trim()) {
|
||||
children.push(sectionLabel('Background / General Notes'));
|
||||
children.push(...createParagraphsFromText(chunk.generalNotes));
|
||||
}
|
||||
|
||||
children.push(sectionLabel('Observation'));
|
||||
children.push(...createParagraphsFromText(chunk.observation || 'No observation.'));
|
||||
children.push(sectionLabel('Interpretation'));
|
||||
children.push(...createParagraphsFromText(chunk.interpretation || 'No interpretation.'));
|
||||
children.push(sectionLabel('Application'));
|
||||
children.push(...createParagraphsFromText(chunk.application || 'No application.'));
|
||||
|
||||
if ((chunk.tags ?? []).length > 0) {
|
||||
children.push(sectionLabel('Tags'));
|
||||
children.push(new Paragraph({
|
||||
children: chunk.tags.map((tag, i) => new TextRun({
|
||||
text: i === 0 ? `#${tag}` : ` #${tag}`,
|
||||
bold: true,
|
||||
color: DOCX_ACCENT,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
if ((chunk.crossReferences ?? []).length > 0) {
|
||||
children.push(sectionLabel('Cross-References'));
|
||||
children.push(new Paragraph({ text: chunk.crossReferences.join(' • ') }));
|
||||
}
|
||||
|
||||
if (chunk.greekWords.length > 0) {
|
||||
children.push(sectionLabel('Word Studies'));
|
||||
const headerCellStyle = (label) => new TableCell({
|
||||
width: { size: 20, type: WidthType.PERCENTAGE },
|
||||
borders: { bottom: { style: BorderStyle.SINGLE, size: 8, color: DOCX_ACCENT } },
|
||||
children: [new Paragraph({ children: [new TextRun({ text: label, bold: true, color: DOCX_ACCENT })] })],
|
||||
});
|
||||
const tableRows = [
|
||||
new TableRow({
|
||||
tableHeader: true,
|
||||
children: ['Strong', 'Greek', 'Transliteration', 'Part of Speech', 'Short Definition'].map(headerCellStyle),
|
||||
}),
|
||||
...chunk.greekWords.map((word) => new TableRow({
|
||||
children: [
|
||||
new TableCell({ children: [new Paragraph(word.strongNumber || '')] }),
|
||||
new TableCell({ children: [new Paragraph(word.lexeme || '')] }),
|
||||
new TableCell({ children: [new Paragraph(word.transliteration || '')] }),
|
||||
new TableCell({ children: [new Paragraph(word.partOfSpeech || '')] }),
|
||||
new TableCell({ children: [new Paragraph(word.shortDefinition || '')] }),
|
||||
],
|
||||
})),
|
||||
];
|
||||
children.push(new Table({ rows: tableRows, width: { size: 100, type: WidthType.PERCENTAGE } }));
|
||||
chunk.greekWords.forEach((word) => {
|
||||
if (word.definitionHtml) {
|
||||
children.push(new Paragraph({
|
||||
spacing: { before: 180, after: 80 },
|
||||
children: [new TextRun({ text: `${word.strongNumber} — ${word.lexeme || ''}`, bold: true, color: DOCX_ACCENT })],
|
||||
}));
|
||||
children.push(...createParagraphsFromText(htmlToPlainText(word.definitionHtml)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (chunk.finalScript?.trim()) {
|
||||
children.push(sectionLabel('Final Script'));
|
||||
children.push(...createParagraphsFromText(chunk.finalScript));
|
||||
}
|
||||
|
||||
children.push(new Paragraph({
|
||||
text: '',
|
||||
spacing: { after: 200 },
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: '2A2518', space: 8 } },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
const doc = new Document({
|
||||
styles: {
|
||||
default: {
|
||||
document: { run: { font: DOCX_BODY_FONT, color: '0A0A08' } },
|
||||
},
|
||||
paragraphStyles: [
|
||||
{
|
||||
id: 'Title',
|
||||
name: 'Title',
|
||||
basedOn: 'Normal',
|
||||
next: 'Normal',
|
||||
run: { font: DOCX_HEADING_FONT, size: 56, bold: true, color: '0A0A08' },
|
||||
},
|
||||
{
|
||||
id: 'Heading1',
|
||||
name: 'Heading 1',
|
||||
basedOn: 'Normal',
|
||||
next: 'Normal',
|
||||
run: { font: DOCX_HEADING_FONT, size: 36, bold: true, color: '0A0A08' },
|
||||
},
|
||||
{
|
||||
id: 'Heading2',
|
||||
name: 'Heading 2',
|
||||
basedOn: 'Normal',
|
||||
next: 'Normal',
|
||||
run: { font: DOCX_HEADING_FONT, size: 30, bold: true, color: '0A0A08' },
|
||||
},
|
||||
],
|
||||
},
|
||||
sections: [{ children }],
|
||||
});
|
||||
return Packer.toBlob(doc);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Markdown export and Claude prompt builders.
|
||||
import {
|
||||
formatChunkReference,
|
||||
getChunkVerseEntries,
|
||||
htmlToPlainText,
|
||||
} from './bibleUtils.jsx';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export / prompt builders (exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildMarkdownExport(project) {
|
||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||
|
||||
const chapterSections = chapters.map((ch, chapterIndex) => {
|
||||
const chunkSections = ch.chunks.map((chunk) => {
|
||||
const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
|
||||
const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
|
||||
.map((verse) => `> **${verse.chapter}:${verse.number}** ${verse.text}`)
|
||||
.join('\n>\n');
|
||||
|
||||
const observation = (chunk.observation ?? '').trim() || '_No observation._';
|
||||
const interpretation = (chunk.interpretation ?? '').trim() || '_No interpretation._';
|
||||
const application = (chunk.application ?? '').trim() || '_No application._';
|
||||
const generalNotes = (chunk.generalNotes ?? '').trim();
|
||||
|
||||
const crossRefsLine = (chunk.crossReferences ?? []).length > 0
|
||||
? `**Cross-References:** ${chunk.crossReferences.join(', ')}\n\n`
|
||||
: '';
|
||||
|
||||
const greekSection = chunk.greekWords.length === 0
|
||||
? ''
|
||||
: `**Greek/Hebrew Words**\n\n${chunk.greekWords.map((word) => {
|
||||
const summary = [
|
||||
word.strongNumber,
|
||||
word.lexeme,
|
||||
word.transliteration && `(${word.transliteration})`,
|
||||
word.partOfSpeech,
|
||||
word.shortDefinition,
|
||||
].filter(Boolean).join(' — ');
|
||||
const definition = word.definitionHtml ? `\n ${htmlToPlainText(word.definitionHtml)}` : '';
|
||||
return `- ${summary}${definition}`;
|
||||
}).join('\n')}\n\n`;
|
||||
|
||||
return `### ${scripture}\n\n${versesText}\n\n${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
|
||||
}).join('---\n\n');
|
||||
|
||||
return `## ${ch.book} ${ch.chapter}\n\n${chunkSections}`;
|
||||
}).join('\n');
|
||||
|
||||
return `# ${project.title}\n\n*${project.translation}*\n\n${chapterSections}`;
|
||||
}
|
||||
|
||||
export function buildChunkBodies(project) {
|
||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||
let chunkIndex = 0;
|
||||
return chapters.map((ch, chapterIndex) => {
|
||||
return ch.chunks.map((chunk) => {
|
||||
chunkIndex += 1;
|
||||
const ref = formatChunkReference(project, chapterIndex, chunk, '–');
|
||||
|
||||
const verses = getChunkVerseEntries(project, chapterIndex, chunk)
|
||||
.map((v) => `${v.chapter}:${v.number} ${v.text}`)
|
||||
.join('\n');
|
||||
|
||||
const observation = (chunk.observation ?? '').trim() || 'No observation.';
|
||||
const interpretation = (chunk.interpretation ?? '').trim() || 'No interpretation.';
|
||||
const application = (chunk.application ?? '').trim() || 'No application.';
|
||||
|
||||
const crossRefs = (chunk.crossReferences ?? []).length > 0
|
||||
? chunk.crossReferences.join(', ')
|
||||
: 'None.';
|
||||
|
||||
const generalNotes = (chunk.generalNotes ?? '').trim();
|
||||
const episodeLabelLine = (chunk.episodeNumber ?? '').trim() || (chunk.episodeTitle ?? '').trim()
|
||||
? `Session: ${[chunk.episodeNumber?.trim(), chunk.episodeTitle?.trim()].filter(Boolean).join(' — ')}\n`
|
||||
: '';
|
||||
|
||||
const greekWords = chunk.greekWords.length === 0
|
||||
? 'None.'
|
||||
: chunk.greekWords.map((word) => {
|
||||
const summary = [
|
||||
word.strongNumber,
|
||||
word.lexeme,
|
||||
word.transliteration && `(${word.transliteration})`,
|
||||
word.partOfSpeech,
|
||||
word.shortDefinition,
|
||||
].filter(Boolean).join(' | ');
|
||||
const definition = word.definitionHtml
|
||||
? `\n ${htmlToPlainText(word.definitionHtml)}`
|
||||
: '';
|
||||
return `• ${summary}${definition}`;
|
||||
}).join('\n');
|
||||
|
||||
return `===
|
||||
|
||||
CHUNK ${chunkIndex} — ${ref}
|
||||
${episodeLabelLine}
|
||||
Scripture:
|
||||
${verses}
|
||||
${generalNotes ? `\nBackground / General Notes:\n${generalNotes}\n` : ''}
|
||||
Observation:
|
||||
${observation}
|
||||
|
||||
Interpretation:
|
||||
${interpretation}
|
||||
|
||||
Application:
|
||||
${application}
|
||||
|
||||
Cross-References: ${crossRefs}
|
||||
|
||||
Greek Words:
|
||||
${greekWords}
|
||||
`;
|
||||
}).join('\n');
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
export function buildClaudePrompt(project) {
|
||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||
const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', ');
|
||||
|
||||
const header = `I've prepared a Bible study on ${chapterLabel} (${project.translation}) and need your help turning my notes into a polished study guide.
|
||||
|
||||
Below is my work organised by passage chunk, including my OIA notes and Greek word research. Please create a clear, structured study guide that:
|
||||
- Synthesises my notes into coherent teaching points
|
||||
- Naturally integrates the Greek word insights
|
||||
- Includes 2–3 reflection questions per chunk
|
||||
- Preserves the passage-by-passage structure
|
||||
|
||||
---
|
||||
|
||||
PROJECT: ${project.title}
|
||||
TRANSLATION: ${project.translation}
|
||||
PASSAGE: ${chapterLabel}
|
||||
`;
|
||||
|
||||
return header + buildChunkBodies(project);
|
||||
}
|
||||
|
||||
export function buildPodcastPrompt(project, podcastName) {
|
||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||
const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', ');
|
||||
const episodeLabel = 'EPISODE';
|
||||
const showName = (podcastName ?? '').trim();
|
||||
const showLine = showName || 'MY BIBLE STUDY PODCAST';
|
||||
const signOffLine = showName
|
||||
? `Introduce yourself, then sign off with: "...and this is ${showName}."`
|
||||
: 'Introduce yourself and the name of the show as a sign-off.';
|
||||
|
||||
const header = `I'm recording an episode of my Bible study podcast${showName ? ` "${showName}"` : ''} and need a full script written from my study notes below.
|
||||
|
||||
Please write the script in this exact structure, using the section markers and tone shown:
|
||||
|
||||
${showLine.toUpperCase()}
|
||||
${episodeLabel}
|
||||
${chapterLabel} (${project.translation}) · [estimate XX–XX minutes based on content]
|
||||
|
||||
✝ OPENING PRAYER
|
||||
[Pause before opening the text — invite God into the study]
|
||||
A short prayer (4-6 sentences) tying into the themes of this passage.
|
||||
|
||||
— ✦ —
|
||||
|
||||
🎙️ COLD OPEN
|
||||
[Co-host delivers the cold open solo, if there is one — hands off to the host at the end]
|
||||
A few short punchy lines previewing the passage and its hook, ending with a hand-off line introducing the host and the show.
|
||||
|
||||
— ✦ —
|
||||
|
||||
📖 SEGMENT [N] — [SEGMENT TITLE]
|
||||
[Brief stage direction in brackets]
|
||||
One segment per passage chunk (use the chunk reference, observation, interpretation, and application notes below as the raw material). Conversational, spoken-word style — not academic prose. Work through the text the way the host would talk it through out loud, weaving in the OIA notes and cross-references naturally.
|
||||
|
||||
— ✦ —
|
||||
|
||||
📖 SEGMENT [N] — GREEK WORD STUDY
|
||||
[Work through the key terms — keep it vivid, help the words come alive]
|
||||
For each Greek/Hebrew word collected below, a block in this format:
|
||||
|
||||
[English Gloss] — [English meaning]
|
||||
[DO NOT READ: original-language word (transliteration) — part of speech]
|
||||
2-4 sentences unpacking the word's meaning and why it matters for this passage, conversational tone.
|
||||
|
||||
— ✦ —
|
||||
|
||||
💬 DISCUSSION QUESTIONS
|
||||
[From the study guide — ${chapterLabel}]
|
||||
3 reflection questions drawn from the application notes, numbered.
|
||||
|
||||
— ✦ —
|
||||
|
||||
✦ CLOSING
|
||||
[Grounded and direct — send them away with something to carry]
|
||||
A closing reflection that ties the segments together, a short pull-quote from the passage with its reference, then close out. ${signOffLine}
|
||||
"Until next time — keep studying verse by verse."
|
||||
|
||||
— End of Episode —
|
||||
|
||||
${showLine} · ${episodeLabel} · ${chapterLabel}
|
||||
|
||||
---
|
||||
|
||||
Here are my study notes, organised by passage chunk, including my OIA notes, cross-references, and Greek/Hebrew word research:
|
||||
|
||||
PROJECT: ${project.title}
|
||||
TRANSLATION: ${project.translation}
|
||||
PASSAGE: ${chapterLabel}
|
||||
`;
|
||||
|
||||
return header + buildChunkBodies(project);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Parsers for typed cross-references and imported DOCX session lists.
|
||||
import {
|
||||
bookOptions,
|
||||
} from './bibleUtils.jsx';
|
||||
|
||||
// Common short-form aliases for typed-in cross-references (e.g. "Rom 3:23")
|
||||
export const BOOK_NAME_ALIASES = {
|
||||
'gen': 'GEN', 'genesis': 'GEN', 'exo': 'EXO', 'exod': 'EXO', 'exodus': 'EXO',
|
||||
'lev': 'LEV', 'leviticus': 'LEV', 'num': 'NUM', 'numbers': 'NUM',
|
||||
'deut': 'DEU', 'deuteronomy': 'DEU', 'josh': 'JOS', 'joshua': 'JOS',
|
||||
'judg': 'JDG', 'judges': 'JDG', 'ruth': 'RUT',
|
||||
'1 sam': '1SA', '1 samuel': '1SA', '2 sam': '2SA', '2 samuel': '2SA',
|
||||
'1 kgs': '1KI', '1 kings': '1KI', '2 kgs': '2KI', '2 kings': '2KI',
|
||||
'1 chr': '1CH', '1 chronicles': '1CH', '2 chr': '2CH', '2 chronicles': '2CH',
|
||||
'ezra': 'EZR', 'neh': 'NEH', 'nehemiah': 'NEH', 'esth': 'EST', 'esther': 'EST',
|
||||
'job': 'JOB', 'ps': 'PSA', 'psa': 'PSA', 'psalm': 'PSA', 'psalms': 'PSA',
|
||||
'prov': 'PRO', 'proverbs': 'PRO', 'eccl': 'ECC', 'ecclesiastes': 'ECC',
|
||||
'song': 'SNG', 'isa': 'ISA', 'isaiah': 'ISA', 'jer': 'JER', 'jeremiah': 'JER',
|
||||
'lam': 'LAM', 'lamentations': 'LAM', 'ezek': 'EZK', 'ezekiel': 'EZK',
|
||||
'dan': 'DAN', 'daniel': 'DAN', 'hos': 'HOS', 'hosea': 'HOS', 'joel': 'JOL',
|
||||
'amos': 'AMO', 'obad': 'OBA', 'obadiah': 'OBA', 'jonah': 'JON', 'mic': 'MIC',
|
||||
'micah': 'MIC', 'nah': 'NAM', 'nahum': 'NAM', 'hab': 'HAB', 'habakkuk': 'HAB',
|
||||
'zeph': 'ZEP', 'zephaniah': 'ZEP', 'hag': 'HAG', 'haggai': 'HAG',
|
||||
'zech': 'ZEC', 'zechariah': 'ZEC', 'mal': 'MAL', 'malachi': 'MAL',
|
||||
'matt': 'MAT', 'mt': 'MAT', 'matthew': 'MAT', 'mark': 'MRK', 'mk': 'MRK',
|
||||
'luke': 'LUK', 'lk': 'LUK', 'john': 'JHN', 'jn': 'JHN', 'acts': 'ACT',
|
||||
'rom': 'ROM', 'romans': 'ROM', '1 cor': '1CO', '1 corinthians': '1CO',
|
||||
'2 cor': '2CO', '2 corinthians': '2CO', 'gal': 'GAL', 'galatians': 'GAL',
|
||||
'eph': 'EPH', 'ephesians': 'EPH', 'phil': 'PHP', 'philippians': 'PHP',
|
||||
'col': 'COL', 'colossians': 'COL', '1 thess': '1TH', '1 thessalonians': '1TH',
|
||||
'2 thess': '2TH', '2 thessalonians': '2TH', '1 tim': '1TI', '1 timothy': '1TI',
|
||||
'2 tim': '2TI', '2 timothy': '2TI', 'titus': 'TIT', 'phlm': 'PHM', 'philemon': 'PHM',
|
||||
'heb': 'HEB', 'hebrews': 'HEB', 'jas': 'JAS', 'james': 'JAS',
|
||||
'1 pet': '1PE', '1 peter': '1PE', '2 pet': '2PE', '2 peter': '2PE',
|
||||
'1 jn': '1JN', '1 john': '1JN', '2 jn': '2JN', '2 john': '2JN',
|
||||
'3 jn': '3JN', '3 john': '3JN', 'jude': 'JUD', 'rev': 'REV', 'revelation': 'REV',
|
||||
};
|
||||
|
||||
bookOptions.forEach((b) => {
|
||||
BOOK_NAME_ALIASES[b.name.toLowerCase()] = b.abbrev;
|
||||
});
|
||||
|
||||
// Parses strings like "Romans 3:23", "1 Timothy 3:2-7" into { bookAbbrev, chapter, verse, endVerse }
|
||||
export function parseCrossRefString(ref) {
|
||||
const match = ref.trim().match(/^(.+?)\s+(\d+):(\d+)(?:-(\d+))?$/);
|
||||
if (!match) return null;
|
||||
const [, bookPart, chapter, verse, endVerse] = match;
|
||||
const abbrev = BOOK_NAME_ALIASES[bookPart.trim().toLowerCase()];
|
||||
if (!abbrev) return null;
|
||||
return {
|
||||
bookAbbrev: abbrev,
|
||||
chapter: Number(chapter),
|
||||
verse: Number(verse),
|
||||
endVerse: endVerse ? Number(endVerse) : Number(verse),
|
||||
};
|
||||
}
|
||||
|
||||
// Parses a "Passage" cell like "1:1–2", "9:32–10:23", "9:1–19a", or "—" (no specific passage)
|
||||
export function parseEpisodePassage(raw) {
|
||||
const str = (raw ?? '').trim().replace(/(\d)[a-z]\b/g, '$1'); // strip "19a"/"19b" verse-letter suffixes
|
||||
if (!str || /^[-–—]+$/.test(str)) return null;
|
||||
const match = str.match(/^(\d+):(\d+)\s*[-–—]\s*(?:(\d+):)?(\d+)$/)
|
||||
?? str.match(/^(\d+):(\d+)$/);
|
||||
if (!match) return 'invalid';
|
||||
const [, ch1, v1, ch2, v2raw] = match;
|
||||
const endChapter = ch2 ? Number(ch2) : Number(ch1);
|
||||
const endVerse = v2raw !== undefined ? Number(v2raw) : Number(v1);
|
||||
return {
|
||||
startChapter: Number(ch1),
|
||||
startVerse: Number(v1),
|
||||
endChapter,
|
||||
endVerse,
|
||||
};
|
||||
}
|
||||
|
||||
// Extracts a "Session # / Title / Passage" list from a docx, including both
|
||||
// table rows and standalone "Ep. N — Title" / "Session N — Title" paragraphs (e.g. part intros).
|
||||
export async function parseEpisodeListDocx(file) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const { default: mammoth } = await import('mammoth');
|
||||
const { value: html } = await mammoth.convertToHtml({ arrayBuffer });
|
||||
const parsedDoc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
const episodes = new Map(); // episodeNumber -> { episodeNumber, title, passage }
|
||||
|
||||
parsedDoc.querySelectorAll('table tr').forEach((row) => {
|
||||
const cells = Array.from(row.querySelectorAll('td,th')).map((c) => c.textContent.trim());
|
||||
if (cells.length < 3) return;
|
||||
const [epRaw, title, passage] = cells;
|
||||
if (!/^\d+$/.test(epRaw)) return; // header row
|
||||
episodes.set(epRaw, { episodeNumber: epRaw, title, passage });
|
||||
});
|
||||
|
||||
parsedDoc.querySelectorAll('p').forEach((p) => {
|
||||
const match = p.textContent.trim().match(/^(?:Ep\.|Episode|Session)\s*(\d+)\s*[-–—]\s*(.+)$/i);
|
||||
if (!match) return;
|
||||
const [, epRaw, title] = match;
|
||||
if (!episodes.has(epRaw)) {
|
||||
episodes.set(epRaw, { episodeNumber: epRaw, title: title.trim(), passage: '—' });
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(episodes.values()).sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber));
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Project persistence in localStorage, plus migrations from older data shapes.
|
||||
import {
|
||||
makeId,
|
||||
indexKey,
|
||||
projectKey,
|
||||
loadProjectIndex,
|
||||
} from './bibleUtils.jsx';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migration helpers (exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function migrateChunk(chunk) {
|
||||
if (chunk.observation !== undefined) {
|
||||
return {
|
||||
...chunk,
|
||||
spilloverEndVerse: Number.isInteger(chunk.spilloverEndVerse) ? chunk.spilloverEndVerse : null,
|
||||
generalNotes: chunk.generalNotes ?? '',
|
||||
episodeNumber: chunk.episodeNumber ?? '',
|
||||
episodeTitle: chunk.episodeTitle ?? '',
|
||||
finalScript: chunk.finalScript ?? '',
|
||||
tags: chunk.tags ?? [],
|
||||
}; // already new format
|
||||
}
|
||||
return {
|
||||
...chunk,
|
||||
observation: chunk.notes ?? '',
|
||||
interpretation: '',
|
||||
application: '',
|
||||
crossReferences: [],
|
||||
tags: [],
|
||||
spilloverEndVerse: null,
|
||||
generalNotes: '',
|
||||
episodeNumber: '',
|
||||
episodeTitle: '',
|
||||
finalScript: '',
|
||||
notes: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateProject(raw) {
|
||||
if (!raw) return null;
|
||||
// Already new format
|
||||
if (Array.isArray(raw.chapters)) {
|
||||
return {
|
||||
...raw,
|
||||
chapters: raw.chapters.map((ch) => ({
|
||||
...ch,
|
||||
chunks: ch.chunks.map(migrateChunk),
|
||||
})),
|
||||
};
|
||||
}
|
||||
// Old flat format → wrap in chapters array
|
||||
const id = raw.id ?? makeId();
|
||||
return {
|
||||
id,
|
||||
title: raw.title ?? '',
|
||||
translation: raw.translation ?? 'BSB',
|
||||
lastEdited: raw.lastEdited ?? Date.now(),
|
||||
selectedChunkId: raw.selectedChunkId ?? null,
|
||||
chapters: [
|
||||
{
|
||||
book: raw.book ?? '',
|
||||
bookAbbrev: raw.bookAbbrev ?? '',
|
||||
chapter: raw.chapter ?? '1',
|
||||
verses: raw.verses ?? [],
|
||||
chunks: (raw.chunks ?? []).map(migrateChunk),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Storage helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function saveProjectToStorage(project) {
|
||||
// Honour an already-stamped lastEdited (e.g. from autosave) so localStorage
|
||||
// and the server always receive the identical timestamp.
|
||||
const updated = { ...project, lastEdited: project.lastEdited ?? Date.now() };
|
||||
window.localStorage.setItem(projectKey(updated.id), JSON.stringify(updated));
|
||||
const index = loadProjectIndex();
|
||||
const existing = index.findIndex((e) => e.id === updated.id);
|
||||
const tags = Array.from(
|
||||
new Set(
|
||||
(updated.chapters ?? [])
|
||||
.flatMap((ch) => ch.chunks ?? [])
|
||||
.flatMap((chunk) => chunk.tags ?? []),
|
||||
),
|
||||
);
|
||||
const summary = {
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
lastEdited: updated.lastEdited,
|
||||
chapterSummary: buildChapterSummary(updated),
|
||||
tags,
|
||||
};
|
||||
if (existing >= 0) {
|
||||
index[existing] = summary;
|
||||
} else {
|
||||
index.push(summary);
|
||||
}
|
||||
window.localStorage.setItem(indexKey(), JSON.stringify(index));
|
||||
return updated; // caller can use this exact object for the server PUT
|
||||
}
|
||||
|
||||
export function deleteProjectFromStorage(id) {
|
||||
window.localStorage.removeItem(projectKey(id));
|
||||
const index = loadProjectIndex().filter((e) => e.id !== id);
|
||||
window.localStorage.setItem(indexKey(), JSON.stringify(index));
|
||||
}
|
||||
|
||||
export function loadProjectById(id) {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(projectKey(id));
|
||||
return raw ? migrateProject(JSON.parse(raw)) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateOldStorageKeys() {
|
||||
const keys = Object.keys(window.localStorage);
|
||||
keys.forEach((key) => {
|
||||
if (!key.startsWith('bible-study-') || key.startsWith('bible-study-index') || key.startsWith('bible-study-project')) return;
|
||||
try {
|
||||
const raw = JSON.parse(window.localStorage.getItem(key));
|
||||
if (!raw || !raw.book) return;
|
||||
const migrated = migrateProject(raw);
|
||||
if (!migrated) return;
|
||||
saveProjectToStorage(migrated);
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// skip corrupt entries
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function buildChapterSummary(project) {
|
||||
if (!Array.isArray(project.chapters)) return '';
|
||||
return project.chapters
|
||||
.map((ch) => `${ch.book} ${ch.chapter}`)
|
||||
.join(', ');
|
||||
}
|
||||
Reference in new issue
Block a user