Add study templates, exports, breadcrumb, whole-Bible search, bookmarks UX, and share links

- Study templates: richer multi-line guiding questions in the OIA placeholders
- PDF/print export: reuses buildExportHtml in a new tab + window.print()
- Markdown export: new buildMarkdownExport() with matching tests
- Breadcrumb: current chunk's passage reference shown in the Study page header
- Reader bookmarks: SVG icons instead of ambiguous emoji, always visible
  (not hover-only, so it works on touch devices), plus a Bookmarks panel
  that lists all saved verses across every book and jumps + scrolls to them
- Whole-Bible search: no server-side search endpoint exists, so this fetches
  the full translation once (~7MB) and searches an in-memory flat verse
  index client-side, with results linking back into the reader
- Read-only share links: per-project share token, a public unauthenticated
  /api/share/:token endpoint, and a ?share=TOKEN view that bypasses the auth
  gate entirely and renders the export HTML in a script-sandboxed iframe

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-06 10:12:00 -04:00
parent 2735ef216c
commit 37cfcd55a0
6 changed files with 632 additions and 36 deletions
+12 -22
View File
@@ -1,21 +1,17 @@
# Study App Improvement Suggestions # Study App Improvement Suggestions
_Refreshed 2026-07-06 — the previous version of this file predated ~40 commits of feature work (OT support, tagging, DOCX import/export, split-view, commentary, cross-ref suggestions, and the full Bible Reader with audio/bookmarks/interlinear/search). Items already shipped have been removed; this reflects what's actually still open._ _Refreshed 2026-07-06 (multiple passes) — items already shipped have been removed; this reflects what's actually still open. Recent additions: multi-user auth with per-account data scoping, TOTP 2FA with backup codes, podcast terminology generalized to "Session" for study-only users (with a configurable podcast/show name), auto-restore on new devices, study templates (richer OIA guiding prompts), PDF/print export, Markdown export, a passage breadcrumb, whole-Bible search, better bookmark UX (always-visible SVG icons + a jump-to panel), and read-only share links._
## Features ## Features
### Study Tools ### Study Tools
- **Bible comparison mode** — show two translations side-by-side. `availableTranslations` is already fetched and a translation is already selectable per project (`App.jsx:904`), but only one renders at a time — no split/parallel view - **Bible comparison mode** — show two translations side-by-side. `availableTranslations` is already fetched and a translation is already selectable per project, but only one renders at a time — no split/parallel view
- **Verse-level notes** — annotations are still chunk-level only (OIA fields); no way to attach a note to a single verse within a chunk - **Verse-level notes** — annotations are still chunk-level only (OIA fields); no way to attach a note to a single verse within a chunk
- **Progress tracking** — no "in progress"/"complete" marker per chunk and no progress bar on the home project card - **Progress tracking** — no "in progress"/"complete" marker per chunk and no progress bar on the home project card
- **Study templates** — no guiding prompts pre-filled for new users starting their first OIA entry
- **Word/character count** on the OIA and Final Script textareas — encourages note depth, useful for episode-length planning
### Export / Sharing ### Export / Sharing
- **PDF export** / print stylesheet — still no `window.print()` CSS or PDF button anywhere in the app
- **Share link** — no read-only shareable URL for a project (useful for co-teachers reviewing an episode)
- **Markdown export** — HTML/DOCX/Claude-prompt exports exist; no plain Markdown output for Obsidian-style tools
- **Episode length estimate** — Final Script field exists per chunk; a word-count-based "~X minutes read aloud" estimate would help podcast planning - **Episode length estimate** — Final Script field exists per chunk; a word-count-based "~X minutes read aloud" estimate would help podcast planning
- **Share link is single-use-case** — one share token per project, all-or-nothing (whole project, all chunks). A per-chunk or per-chapter share might be worth it for someone who only wants to share one episode's notes rather than the whole series
### Chunk Builder (Setup Page) ### Chunk Builder (Setup Page)
- **Drag-to-select verses** — still click-then-shift-click; no click-and-drag range selection - **Drag-to-select verses** — still click-then-shift-click; no click-and-drag range selection
@@ -25,13 +21,13 @@ _Refreshed 2026-07-06 — the previous version of this file predated ~40 commits
## UX / UI ## UX / UI
### Navigation
- **Breadcrumb in header** — study page header only shows the project title (`App.jsx:4172`); no persistent "Genesis 1:15" reference next to it so users can tell at a glance where they are without checking the scripture panel
### Study Page ### Study Page
- **Word/char counters** — see above
- **Sticky bottom nav** — top Prev/Next chunk nav shipped (commit `103e20c`); a matching sticky bottom bar for long chunks would avoid scroll-back - **Sticky bottom nav** — top Prev/Next chunk nav shipped (commit `103e20c`); a matching sticky bottom bar for long chunks would avoid scroll-back
### Reader
- **Whole-Bible search index isn't persisted** — the ~7MB `complete.json` fetch is cached in-memory only for the session; a page reload re-downloads it. Worth persisting to IndexedDB (not localStorage — too small) if this gets used often
- **Bookmark color picker is still an emoji button** (🎨) — the bookmark/copy icons became proper SVGs, but color-cycling didn't get the same treatment
### Home Page ### Home Page
- Search/filter/sort/rename are all implemented — nothing open here currently - Search/filter/sort/rename are all implemented — nothing open here currently
@@ -40,36 +36,30 @@ _Refreshed 2026-07-06 — the previous version of this file predated ~40 commits
## Code Architecture ## Code Architecture
### State Management ### State Management
- **`App.jsx` is now ~5,100 lines** (up from ~2,250 when this doc was last written) — still one component from line 9035044. Splitting into `pages/HomePage.jsx`, `pages/SetupPage.jsx`, `pages/StudyPage.jsx`, `pages/BibleReaderPage.jsx`, plus extracted hooks (`useProject`, `useGreekLookup`, `useAutosave`) is more valuable now than it was before, given the size increase - **`App.jsx` is now ~5,700+ lines** — still one component. Splitting into `pages/HomePage.jsx`, `pages/SetupPage.jsx`, `pages/StudyPage.jsx`, `pages/BibleReaderPage.jsx`, plus extracted hooks (`useProject`, `useGreekLookup`, `useAutosave`) is more valuable now than ever given the continued size growth
- **`commentarySource` doesn't persist** — resets to `'matthew-henry'` every session (`App.jsx:1107`), unlike `studyLayout`/`activeStudyTab` which do persist to localStorage via the same pattern
### Sync / Persistence ### Sync / Persistence
- ~~No auth on the backend~~ — fixed; email/password accounts with httpOnly cookie sessions (`server/auth.js`, `server/sessionStore.js`), projects scoped per-user in both SQLite (`server/db.js`) and localStorage (`App.jsx` `switchStorageUser`/namespaced keys), and pre-existing local projects auto-claimed by the first registered account - No rate-limiting on `/api/auth/*` — a determined attacker could brute-force a weak password or 2FA code; worth adding if this is ever reachable beyond a small trusted group
- No rate-limiting on `/api/auth/*` — a determined attacker could brute-force a weak password; worth adding if this is ever reachable beyond a small trusted group
- **Conflict resolution is still last-write-wins** — only `lastEdited` timestamps are compared; no "which version do you want to keep?" UI - **Conflict resolution is still last-write-wins** — only `lastEdited` timestamps are compared; no "which version do you want to keep?" UI
- **"Restore"/"Pull latest from server" don't open the project** (`App.jsx:2997-3017`) — they refresh the local index but leave the user on the Home page instead of jumping into the study
- **Offline-first** — still no service worker; app requires a live connection to `bible.helloao.org` for chapter/audio/commentary loads with no cached fallback if that API is down - **Offline-first** — still no service worker; app requires a live connection to `bible.helloao.org` for chapter/audio/commentary loads with no cached fallback if that API is down
### Security (OWASP) ### Security (OWASP)
- ~~XSS via `dangerouslySetInnerHTML`~~ — fixed; `DOMPurify.sanitize()` now wraps both render paths (`App.jsx:4784`, `App.jsx:5020`)
- **No input validation on server** — still no max-length/character validation on `id`/`title` in `server/index.js` - **No input validation on server** — still no max-length/character validation on `id`/`title` in `server/index.js`
- **CORS** — still no CORS headers configured - **CORS** — still no CORS headers configured
- **Shared HTML view is sandboxed but not escaped** — `buildExportHtml` interpolates OIA notes into HTML without escaping `<`/`>`/`&`; the public share view mitigates this by rendering in a `sandbox="allow-popups"` iframe (no `allow-scripts`, so injected `<script>`/event handlers can't execute), but the underlying string-building still isn't defense-in-depth. Worth properly HTML-escaping user text in `buildExportHtml` itself
--- ---
## Performance ## Performance
- **Verse data stored in project JSON** — still true; full verse text is saved per chapter in both localStorage and SQLite - **Verse data stored in project JSON** — still true; full verse text is saved per chapter in both localStorage and SQLite
- **Reader bookmark icon is unclear** — shows a 🏷️ tag emoji before bookmarking and only switches to 🔖 after (`App.jsx:3641`), but the help text says "bookmark icon to save" — a plain outline bookmark icon would read more clearly from the start - **Hardcoded external API, no fallback** — audio and commentary both call `bible.helloao.org` directly with no retry UI if the free API is briefly down; whole-Bible search now adds a third hard dependency on this API (`/complete.json`)
- **No audio playback speed control** — chapter/reader audio only has play/pause/stop (`App.jsx:1037`); a 0.75x/1x/1.5x toggle would help slow, careful study listening
- **Hardcoded external API, no fallback** — audio and commentary both call `bible.helloao.org` directly (`App.jsx:985`, `App.jsx:1129`) with no retry UI if the free API is briefly down
--- ---
## Testing ## Testing
- Migration and prompt-building tests now exist (`migrateChunk`, `migrateProject`, `buildClaudePrompt`, `parseBibleChapter` are all covered in `src/utils.test.js`) — this section is essentially done - Missing: autosave debounce behavior, DOCX session-list import, cross-ref auto-suggest, commentary loading, and no coverage yet for the newer share-link/2FA/whole-Bible-search flows (all verified manually in-browser instead)
- Still missing: autosave debounce behavior, and coverage for the newer features (DOCX episode import, cross-ref auto-suggest, commentary loading)
--- ---
+38
View File
@@ -63,6 +63,12 @@ export function initDb() {
db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT'); db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT');
} }
// Older databases predate shareable read-only links.
if (!projectCols.some((c) => c.name === 'share_token')) {
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
console.log(`SQLite database ready at ${DB_PATH}`); console.log(`SQLite database ready at ${DB_PATH}`);
} }
@@ -213,6 +219,38 @@ export function deleteProject(id, userId) {
db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId); db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId);
} }
// ---------------------------------------------------------------------------
// Read-only share links
// ---------------------------------------------------------------------------
/** Returns the current share token for a project owned by userId, or null. */
export function getShareToken(id, userId) {
const row = db.prepare('SELECT share_token AS shareToken FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
return row?.shareToken ?? null;
}
/** Sets a project's share token (enabling its public read-only link), scoped to userId. */
export function setShareToken(id, userId, token) {
const result = db.prepare('UPDATE projects SET share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId);
return result.changes > 0;
}
/** Revokes a project's share link, scoped to userId. */
export function clearShareToken(id, userId) {
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
}
/** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */
export function getProjectByShareToken(token) {
const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token);
if (!row) return null;
try {
return JSON.parse(row.data);
} catch {
return null;
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Session store backing (used by server/sessionStore.js) // Session store backing (used by server/sessionStore.js)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+54
View File
@@ -8,6 +8,7 @@ import {
initDb, getAllProjects, getProject, upsertProject, deleteProject, initDb, getAllProjects, getProject, upsertProject, deleteProject,
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects, countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName, enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
} from './db.js'; } from './db.js';
import { SqliteSessionStore } from './sessionStore.js'; import { SqliteSessionStore } from './sessionStore.js';
import { import {
@@ -304,6 +305,59 @@ app.delete('/api/projects/:id', requireAuth, (req, res) => {
} }
}); });
// ---------------------------------------------------------------------------
// Read-only share links
// ---------------------------------------------------------------------------
// GET /api/projects/:id/share — current share status for the project owner
app.get('/api/projects/:id/share', requireAuth, (req, res) => {
try {
const project = getProject(req.params.id, req.session.userId);
if (!project) return res.status(404).json({ error: 'Project not found.' });
res.json({ shareToken: getShareToken(req.params.id, req.session.userId) });
} catch (err) {
console.error('GET /api/projects/:id/share error:', err);
res.status(500).json({ error: 'Failed to load share status.' });
}
});
// POST /api/projects/:id/share — enable sharing, returns the (new or existing) token
app.post('/api/projects/:id/share', requireAuth, (req, res) => {
try {
const existing = getShareToken(req.params.id, req.session.userId);
const token = existing || randomUUID().replace(/-/g, '');
const ok = setShareToken(req.params.id, req.session.userId, token);
if (!ok) return res.status(404).json({ error: 'Project not found.' });
res.json({ shareToken: token });
} catch (err) {
console.error('POST /api/projects/:id/share error:', err);
res.status(500).json({ error: 'Failed to enable sharing.' });
}
});
// DELETE /api/projects/:id/share — revoke the share link
app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
try {
clearShareToken(req.params.id, req.session.userId);
res.json({ ok: true });
} catch (err) {
console.error('DELETE /api/projects/:id/share error:', err);
res.status(500).json({ error: 'Failed to revoke sharing.' });
}
});
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only
app.get('/api/share/:token', (req, res) => {
try {
const project = getProjectByShareToken(req.params.token);
if (!project) return res.status(404).json({ error: 'This share link is invalid or has been revoked.' });
res.json(project);
} catch (err) {
console.error('GET /api/share/:token error:', err);
res.status(500).json({ error: 'Failed to load shared project.' });
}
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Serve Vite production build (when NODE_ENV=production) // Serve Vite production build (when NODE_ENV=production)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+463 -14
View File
@@ -31,6 +31,10 @@ import {
confirmMfaSetup, confirmMfaSetup,
disableMfa, disableMfa,
updateProfile, updateProfile,
getShareStatus,
enableSharing,
disableSharing,
getSharedProject,
} from './syncService.js'; } from './syncService.js';
const COMMENTARY_OPTIONS = [ const COMMENTARY_OPTIONS = [
@@ -419,6 +423,11 @@ export function buildExportHtml(project) {
.greek th { background: #f8fafc; } .greek th { background: #f8fafc; }
.definition { margin-top: 0.75rem; font-size: 0.95rem; line-height: 1.6; } .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; } .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 chapters = Array.isArray(project.chapters) ? project.chapters : [];
@@ -516,6 +525,48 @@ export function buildExportHtml(project) {
</html>`; </html>`;
} }
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 wordTableHtml(rows) { export function wordTableHtml(rows) {
return ` return `
<table> <table>
@@ -915,6 +966,25 @@ 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
// don't reliably convey "bookmark this" vs. "already bookmarked" at a glance).
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>
);
}
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 // A cross-reference chip that fetches and shows the referenced verse text on hover
function CrossRefChip({ label, onRemove, loadVerseText }) { function CrossRefChip({ label, onRemove, loadVerseText }) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
@@ -990,6 +1060,14 @@ const App = () => {
const [podcastNameInput, setPodcastNameInput] = useState(''); const [podcastNameInput, setPodcastNameInput] = useState('');
const [podcastNameSaving, setPodcastNameSaving] = useState(false); const [podcastNameSaving, setPodcastNameSaving] = useState(false);
const [podcastNameSaved, setPodcastNameSaved] = useState(false); const [podcastNameSaved, setPodcastNameSaved] = useState(false);
// Read-only share links
const [shareToken, setShareToken] = useState(null);
const [sharePanelOpen, setSharePanelOpen] = useState(false);
const [shareBusy, setShareBusy] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
const [sharedViewToken] = useState(() => new URLSearchParams(window.location.search).get('share'));
const [sharedProject, setSharedProject] = useState(null);
const [sharedError, setSharedError] = useState('');
const [project, setProject] = useState(null); const [project, setProject] = useState(null);
// 'home' | 'setup' | 'study' | 'settings' // 'home' | 'setup' | 'study' | 'settings'
const [currentPage, setCurrentPage] = useState('home'); const [currentPage, setCurrentPage] = useState('home');
@@ -1043,12 +1121,17 @@ const App = () => {
const [readerBookmarks, setReaderBookmarks] = useState(() => { const [readerBookmarks, setReaderBookmarks] = useState(() => {
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; } 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 [readerCrossRefs, setReaderCrossRefs] = useState(null);
const [readerCrossRefsLoading, setReaderCrossRefsLoading] = useState(false); const [readerCrossRefsLoading, setReaderCrossRefsLoading] = useState(false);
const [readerShowCrossRefs, setReaderShowCrossRefs] = useState(false); const [readerShowCrossRefs, setReaderShowCrossRefs] = useState(false);
const _readerCrossRefCacheRef = useRef({}); const _readerCrossRefCacheRef = useRef({});
const [readerSearch, setReaderSearch] = useState(''); const [readerSearch, setReaderSearch] = useState('');
const [readerSearchActive, setReaderSearchActive] = useState(false); 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 [audioBook, setAudioBook] = useState(bookOptions[0].abbrev); const [audioBook, setAudioBook] = useState(bookOptions[0].abbrev);
const [audioNarrator, setAudioNarrator] = useState('souer'); const [audioNarrator, setAudioNarrator] = useState('souer');
const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 }); const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 });
@@ -1218,6 +1301,39 @@ const App = () => {
// Bible reader (read-only browsing, separate from study projects) // Bible reader (read-only browsing, separate from study projects)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 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 {
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');
} catch {
setBibleIndexStatus('error');
}
};
const loadReaderChapter = async (bookAbbrev, chapterNumber) => { const loadReaderChapter = async (bookAbbrev, chapterNumber) => {
setReaderLoading(true); setReaderLoading(true);
setReaderError(''); setReaderError('');
@@ -1244,10 +1360,43 @@ const App = () => {
loadReaderChapter(readerBookAbbrev, readerChapter); loadReaderChapter(readerBookAbbrev, readerChapter);
}, [currentPage, 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]);
const openBibleReader = () => { const openBibleReader = () => {
setCurrentPage('reader'); setCurrentPage('reader');
}; };
// 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 = () => { const readerGoToPreviousChapter = () => {
if (readerChapter > 1) { setReaderChapter((c) => c - 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); } if (readerChapter > 1) { setReaderChapter((c) => c - 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
}; };
@@ -1399,6 +1548,28 @@ const App = () => {
setPodcastNameInput(authUser?.podcastName ?? ''); setPodcastNameInput(authUser?.podcastName ?? '');
}, [authUser?.id]); }, [authUser?.id]);
// A ?share=TOKEN URL loads a read-only view of someone else's project no
// login required, so this runs independent of auth state entirely.
useEffect(() => {
if (!sharedViewToken) return;
getSharedProject(sharedViewToken).then((result) => {
if (result.ok) {
setSharedProject(result.data);
} else {
setSharedError(result.error ?? 'This share link is invalid or has been revoked.');
}
});
}, [sharedViewToken]);
// Loads current share status whenever the study page's project changes.
useEffect(() => {
if (!project?.id || currentPage !== 'study') return;
setShareToken(null);
getShareStatus(project.id).then((result) => {
if (result.ok) setShareToken(result.data.shareToken);
});
}, [project?.id, currentPage]);
// Once we know who's signed in, reconcile the local project index against the server. // Once we know who's signed in, reconcile the local project index against the server.
// Runs on every authUser change (including logout -> different login) so a previous // Runs on every authUser change (including logout -> different login) so a previous
// account's stale suggestions never linger after switching users. Projects that exist // account's stale suggestions never linger after switching users. Projects that exist
@@ -2712,6 +2883,62 @@ const App = () => {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
const printChapterPdf = () => {
if (!project) return;
const html = buildExportHtml(project);
const printWindow = window.open('', '_blank');
if (!printWindow) {
alert('Please allow pop-ups for this site to print or save as PDF.');
return;
}
printWindow.document.write(html);
printWindow.document.close();
printWindow.onload = () => {
printWindow.focus();
printWindow.print();
};
};
const handleEnableSharing = async () => {
if (!project) return;
setShareBusy(true);
const result = await enableSharing(project.id);
setShareBusy(false);
if (result.ok) setShareToken(result.data.shareToken);
};
const handleDisableSharing = async () => {
if (!project) return;
if (!window.confirm('Revoke this share link? Anyone using it will lose access immediately.')) return;
setShareBusy(true);
const result = await disableSharing(project.id);
setShareBusy(false);
if (result.ok) setShareToken(null);
};
const handleCopyShareLink = () => {
if (!shareToken) return;
const url = `${window.location.origin}${window.location.pathname}?share=${shareToken}`;
navigator.clipboard.writeText(url).then(() => {
setShareCopied(true);
window.setTimeout(() => setShareCopied(false), 2000);
});
};
const exportChapterMarkdown = () => {
if (!project) return;
const markdown = buildMarkdownExport(project);
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${project.title.replace(/\s+/g, '-')}-study.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const exportChapterDocx = async () => { const exportChapterDocx = async () => {
if (!project) return; if (!project) return;
const children = [ const children = [
@@ -3226,6 +3453,65 @@ const deleteProject = (id) => {
> >
Export DOCX Export DOCX
</button> </button>
<button
type="button"
onClick={exportChapterMarkdown}
title="Download as a .md file — handy for Obsidian, Notion, or any Markdown-based notes app"
className="rounded-md bg-slate-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-slate-500"
>
Export Markdown
</button>
<button
type="button"
onClick={printChapterPdf}
title="Opens a print-friendly version in a new tab — choose 'Save as PDF' in the print dialog"
className="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50"
>
🖨 Print / Save PDF
</button>
<div className="relative">
<button
type="button"
onClick={() => setSharePanelOpen((v) => !v)}
className={`rounded-md px-4 py-2 text-sm font-medium shadow-sm transition ${shareToken ? 'bg-teal-600 text-white hover:bg-teal-500' : 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'}`}
>
🔗 {shareToken ? 'Shared' : 'Share'}
</button>
{sharePanelOpen && (
<div className="absolute right-0 top-full z-20 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-lg">
<h3 className="text-sm font-semibold text-slate-900">Read-only share link</h3>
<p className="mt-1 text-xs text-slate-500">
Anyone with this link can view (not edit) this study no account needed. Good for a co-teacher
or a listener who wants to follow along.
</p>
{shareToken ? (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<input
readOnly
value={`${window.location.origin}${window.location.pathname}?share=${shareToken}`}
onFocus={(e) => e.target.select()}
className="w-full flex-1 truncate rounded-lg border border-slate-300 bg-slate-50 px-2 py-1.5 text-xs text-slate-600"
/>
<button type="button" onClick={handleCopyShareLink}
className="shrink-0 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-400">
{shareCopied ? 'Copied!' : 'Copy'}
</button>
</div>
<button type="button" onClick={handleDisableSharing} disabled={shareBusy}
className="w-full rounded-lg border border-rose-200 px-3 py-1.5 text-xs font-semibold text-rose-600 hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50">
{shareBusy ? 'Revoking…' : 'Revoke link'}
</button>
</div>
) : (
<button type="button" onClick={handleEnableSharing} disabled={shareBusy}
className="mt-3 w-full rounded-lg bg-teal-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-teal-500 disabled:cursor-not-allowed disabled:opacity-50">
{shareBusy ? 'Enabling…' : 'Enable sharing'}
</button>
)}
</div>
)}
</div>
</div> </div>
)} )}
<div className="text-right text-sm text-slate-300 space-y-0.5"> <div className="text-right text-sm text-slate-300 space-y-0.5">
@@ -3263,6 +3549,44 @@ const deleteProject = (id) => {
</div> </div>
); );
// ---------------------------------------------------------------------------
// Shared read-only view a ?share=TOKEN link, no login required at all.
// Renders in a sandboxed iframe (scripts disabled) since the HTML embeds
// user-authored notes that aren't HTML-escaped.
// ---------------------------------------------------------------------------
if (sharedViewToken) {
if (sharedError) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-900 px-4">
<div className="w-full max-w-sm rounded-3xl border border-white/10 bg-white p-8 text-center shadow-panel">
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
<p className="mt-4 text-sm text-rose-600">{sharedError}</p>
</div>
</div>
);
}
if (!sharedProject) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-900">
<p className="text-sm text-slate-400">Loading shared study</p>
</div>
);
}
return (
<div className="min-h-screen bg-slate-100">
<div className="border-b border-slate-200 bg-slate-900 px-4 py-3 text-center text-xs text-slate-300">
🔗 Read-only shared view <a href="/" className="underline hover:text-white">go to Bible Study Project</a>
</div>
<iframe
title="Shared study"
srcDoc={buildExportHtml(sharedProject)}
sandbox="allow-popups"
className="h-[calc(100vh-2.5rem)] w-full border-0"
/>
</div>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Auth gate shown when the server is reachable but no session is present // Auth gate shown when the server is reachable but no session is present
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -3951,6 +4275,18 @@ const deleteProject = (id) => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
if (currentPage === 'reader') { if (currentPage === 'reader') {
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev); const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
const bookmarkEntries = Object.entries(readerBookmarks).map(([key, color]) => {
const [bAbbrev, chapterStr, verseStr] = key.split('-');
const bookIndex = bookOptions.findIndex((b) => b.abbrev === bAbbrev);
return {
key, color,
bookAbbrev: bAbbrev,
bookName: bookOptions[bookIndex]?.name ?? bAbbrev,
chapter: Number(chapterStr),
verse: Number(verseStr),
bookIndex,
};
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse);
return ( return (
<div className="min-h-screen bg-slate-50 text-slate-900"> <div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm"> <header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
@@ -4029,15 +4365,65 @@ const deleteProject = (id) => {
{readerCrossRefsLoading ? 'Loading refs…' : '🔗 Cross-Refs'} {readerCrossRefsLoading ? 'Loading refs…' : '🔗 Cross-Refs'}
</button> </button>
<div className="mx-2 h-4 w-px bg-slate-200" /> <div className="mx-2 h-4 w-px bg-slate-200" />
{/* In-chapter search */} {/* Bookmarks panel */}
<div className="relative">
<button type="button"
onClick={() => setReaderBookmarksPanelOpen((v) => !v)}
className={`flex items-center gap-1.5 rounded-lg px-3 py-1 text-xs font-semibold transition ${readerBookmarksPanelOpen ? 'bg-sky-100 text-sky-800' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
<BookmarkIcon filled={bookmarkEntries.length > 0} />
Bookmarks{bookmarkEntries.length > 0 ? ` (${bookmarkEntries.length})` : ''}
</button>
{readerBookmarksPanelOpen && (
<div className="absolute left-0 top-full z-20 mt-2 w-80 rounded-2xl border border-slate-200 bg-white p-3 shadow-lg">
{bookmarkEntries.length === 0 ? (
<p className="p-2 text-sm text-slate-500">
No bookmarks yet. Tap the bookmark icon next to any verse to save it here.
</p>
) : (
<div className="max-h-80 space-y-1 overflow-y-auto">
{bookmarkEntries.map((entry) => (
<div key={entry.key} className="flex items-center gap-2 rounded-xl px-2 py-1.5 hover:bg-slate-50">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: entry.color }} />
<button type="button"
onClick={() => jumpToReaderVerse(entry.bookAbbrev, entry.chapter, entry.verse)}
className="flex-1 truncate text-left text-sm font-medium text-slate-700 hover:text-sky-700">
{entry.bookName} {entry.chapter}:{entry.verse}
</button>
<button type="button"
onClick={() => toggleReaderBookmark(entry.key)}
title="Remove bookmark"
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-rose-600">
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
<div className="mx-2 h-4 w-px bg-slate-200" />
{/* Search — this chapter or the whole Bible */}
{readerSearchActive ? ( {readerSearchActive ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div className="flex overflow-hidden rounded-lg border border-slate-300">
<button type="button"
onClick={() => setReaderSearchScope('chapter')}
className={`px-2 py-1 text-xs font-semibold transition ${readerSearchScope === 'chapter' ? 'bg-slate-900 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}>
This chapter
</button>
<button type="button"
onClick={() => { setReaderSearchScope('bible'); if (bibleIndexStatus === 'idle') loadBibleIndex(); }}
className={`px-2 py-1 text-xs font-semibold transition ${readerSearchScope === 'bible' ? 'bg-slate-900 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}>
Whole Bible
</button>
</div>
<input <input
autoFocus autoFocus
type="text" type="text"
value={readerSearch} value={readerSearch}
onChange={(e) => setReaderSearch(e.target.value)} onChange={(e) => setReaderSearch(e.target.value)}
placeholder="Search this chapter" placeholder={readerSearchScope === 'bible' ? 'Search the whole Bible…' : 'Search this chapter…'}
className="rounded-xl border border-slate-300 bg-slate-50 px-3 py-1 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" className="rounded-xl border border-slate-300 bg-slate-50 px-3 py-1 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/> />
<button type="button" onClick={() => { setReaderSearch(''); setReaderSearchActive(false); }} <button type="button" onClick={() => { setReaderSearch(''); setReaderSearchActive(false); }}
@@ -4051,6 +4437,50 @@ const deleteProject = (id) => {
)} )}
</div> </div>
{/* Whole-Bible search results */}
{readerSearchActive && readerSearchScope === 'bible' && (
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
{bibleIndexStatus === 'loading' && (
<p className="text-sm text-slate-500">Loading the full Bible for search this happens once per visit (~7MB)</p>
)}
{bibleIndexStatus === 'error' && (
<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();
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));
if (matches.length === 0) return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
const shown = matches.slice(0, 100);
return (
<div className="space-y-1">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
{matches.length} match{matches.length === 1 ? '' : 'es'}{matches.length > shown.length ? ` (showing first ${shown.length})` : ''}
</p>
<div className="max-h-96 space-y-1 overflow-y-auto">
{shown.map((v) => {
const idx = v.text.toLowerCase().indexOf(q);
return (
<button key={`${v.bookAbbrev}-${v.chapter}-${v.verse}`} type="button"
onClick={() => jumpToReaderVerse(v.bookAbbrev, v.chapter, v.verse)}
className="block w-full rounded-xl px-3 py-2 text-left text-sm hover:bg-slate-50">
<span className="font-semibold text-slate-700">{v.bookName} {v.chapter}:{v.verse}</span>{' '}
<span className="text-slate-600">
{v.text.slice(0, idx)}
<mark className="rounded bg-yellow-200 px-0.5">{v.text.slice(idx, idx + q.length)}</mark>
{v.text.slice(idx + q.length)}
</span>
</button>
);
})}
</div>
</div>
);
})()}
</div>
)}
{/* Audio player */} {/* Audio player */}
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel"> <div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel">
<div className="flex-1"> <div className="flex-1">
@@ -4102,7 +4532,9 @@ const deleteProject = (id) => {
{readerLoading && <p className="text-sm text-slate-500">Loading</p>} {readerLoading && <p className="text-sm text-slate-500">Loading</p>}
{readerError && <p className="text-sm text-rose-600">{readerError}</p>} {readerError && <p className="text-sm text-rose-600">{readerError}</p>}
{!readerLoading && !readerError && (() => { {!readerLoading && !readerError && (() => {
const searchLower = readerSearch.trim().toLowerCase(); // Whole-Bible matches render in their own panel above; this list stays
// un-filtered in that mode so jumping to a result shows full context.
const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
const filtered = searchLower const filtered = searchLower
? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower)) ? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower))
: readerVerses; : readerVerses;
@@ -4133,7 +4565,7 @@ const deleteProject = (id) => {
}; };
return ( return (
<div key={verse.number} className="group rounded-xl transition" <div key={verse.number} id={`reader-verse-${verse.number}`} className="group rounded-xl transition"
style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: '0.5rem' } : {}}> style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: '0.5rem' } : {}}>
<div className="flex items-start gap-1"> <div className="flex items-start gap-1">
{/* Verse number / interlinear toggle */} {/* Verse number / interlinear toggle */}
@@ -4149,27 +4581,27 @@ const deleteProject = (id) => {
</button> </button>
{/* Verse text */} {/* Verse text */}
<p className="flex-1">{highlightText(verse.text)}</p> <p className="flex-1">{highlightText(verse.text)}</p>
{/* Action icons — visible on hover */} {/* Action icons — always visible so bookmarking works on touch devices too */}
<span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1">
<button type="button" <button type="button"
onClick={() => toggleReaderBookmark(verseKey)} onClick={() => toggleReaderBookmark(verseKey)}
className="rounded p-0.5 text-base leading-none hover:bg-slate-100" className={`rounded p-1 leading-none hover:bg-slate-100 ${bmColor ? 'text-amber-600' : 'text-slate-400'}`}
title={bmColor ? 'Remove bookmark' : 'Bookmark this verse'}> title={bmColor ? 'Remove bookmark' : 'Bookmark this verse'}>
{bmColor ? '🔖' : '🏷'} <BookmarkIcon filled={!!bmColor} />
</button> </button>
{bmColor && ( {bmColor && (
<button type="button" <button type="button"
onClick={() => cycleBookmarkColor(verseKey)} onClick={() => cycleBookmarkColor(verseKey)}
className="rounded p-0.5 text-base leading-none hover:bg-slate-100" className="rounded p-1 text-sm leading-none hover:bg-slate-100"
title="Change highlight colour"> title="Change highlight colour">
🎨 🎨
</button> </button>
)} )}
<button type="button" <button type="button"
onClick={() => copyVerse(readerBook?.name, readerChapter, verse.number, verse.text)} onClick={() => copyVerse(readerBook?.name, readerChapter, verse.number, verse.text)}
className="rounded p-0.5 text-base leading-none hover:bg-slate-100" className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
title="Copy verse"> title="Copy verse">
📋 <CopyIcon />
</button> </button>
</span> </span>
</div> </div>
@@ -4701,6 +5133,11 @@ const deleteProject = (id) => {
<div> <div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p> <p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">{project?.title ?? ''}</h1> <h1 className="mt-2 text-2xl font-semibold">{project?.title ?? ''}</h1>
{selectedChunk && selectedChunkChapterIndex >= 0 && (
<p className="mt-1 text-sm text-slate-300">
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '')}
</p>
)}
</div> </div>
{headerButtons} {headerButtons}
</div> </div>
@@ -4982,9 +5419,21 @@ const deleteProject = (id) => {
<span className="text-slate-400">{collapsedSections.oia ? '▸' : '▾'}</span> <span className="text-slate-400">{collapsedSections.oia ? '▸' : '▾'}</span>
</button> </button>
{!collapsedSections.oia && [ {!collapsedSections.oia && [
{ field: 'observation', label: 'Observation', placeholder: 'What does the text say? List facts, details, key words…' }, {
{ field: 'interpretation', label: 'Interpretation', placeholder: 'What does it mean? Context, cross-references, theology…' }, field: 'observation',
{ field: 'application', label: 'Application', placeholder: 'How does it apply? Personal response, life change…' }, label: 'Observation',
placeholder: 'What does the text actually say?\n• Who is speaking, and to whom?\n• What key words or phrases repeat?\n• What\'s the tone, structure, or literary style?',
},
{
field: 'interpretation',
label: 'Interpretation',
placeholder: 'What did this mean to its original audience?\n• What\'s the historical/cultural context?\n• How does it fit the surrounding argument?\n• What does it reveal about God\'s character?',
},
{
field: 'application',
label: 'Application',
placeholder: 'How should this shape your life today?\n• What attitude or action does this call for?\n• Is there a promise to trust or a warning to heed?\n• Who could you share this with?',
},
].map(({ field, label, placeholder }) => ( ].map(({ field, label, placeholder }) => (
<div key={field}> <div key={field}>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wider text-slate-500"> <label className="mb-1 block text-xs font-semibold uppercase tracking-wider text-slate-500">
+24
View File
@@ -65,6 +65,30 @@ export async function deleteRemoteProject(id) {
return request('DELETE', `/projects/${id}`); return request('DELETE', `/projects/${id}`);
} }
// ---------------------------------------------------------------------------
// Read-only share links
// ---------------------------------------------------------------------------
/** Returns { shareToken } — the project's current share token, or null if sharing is off. */
export async function getShareStatus(id) {
return request('GET', `/projects/${id}/share`);
}
/** Enables sharing (or returns the existing token if already enabled). Returns { shareToken }. */
export async function enableSharing(id) {
return request('POST', `/projects/${id}/share`);
}
/** Revokes a project's share link. */
export async function disableSharing(id) {
return request('DELETE', `/projects/${id}/share`);
}
/** Public lookup — no session required. Returns the full project for a valid share token. */
export async function getSharedProject(token) {
return request('GET', `/share/${token}`);
}
/** /**
* Check whether the server is reachable. * Check whether the server is reachable.
* Returns true / false. * Returns true / false.
+41
View File
@@ -7,6 +7,7 @@ import {
parseBibleChapter, parseBibleChapter,
wordTableHtml, wordTableHtml,
buildExportHtml, buildExportHtml,
buildMarkdownExport,
buildClaudePrompt, buildClaudePrompt,
createParagraphsFromText, createParagraphsFromText,
migrateChunk, migrateChunk,
@@ -469,6 +470,46 @@ describe('buildExportHtml', () => {
}); });
}); });
// ---------------------------------------------------------------------------
// buildMarkdownExport
// ---------------------------------------------------------------------------
describe('buildMarkdownExport', () => {
test('includes the project title and translation as Markdown headers', () => {
const md = buildMarkdownExport(baseProject);
expect(md).toContain('# Titus 1 Study');
expect(md).toContain('*BSB*');
});
test('includes a chapter heading and chunk reference', () => {
const md = buildMarkdownExport(baseProject);
expect(md).toContain('## Titus 1');
expect(md).toContain('### Titus 1:1-2');
});
test('includes verse text and OIA notes', () => {
const md = buildMarkdownExport(baseProject);
expect(md).toContain('Paul, a servant of God.');
expect(md).toContain('Key observations.');
expect(md).toContain('Theological meaning.');
expect(md).toContain('Live it out.');
});
test('includes cross-references and Greek word data', () => {
const md = buildMarkdownExport(baseProject);
expect(md).toContain('John 1:1');
expect(md).toContain('G1401');
expect(md).toContain('δοῦλος');
});
test('shows placeholder text when observation is empty', () => {
const project = {
...baseProject,
chapters: [{ ...baseProject.chapters[0], chunks: [{ ...baseChunk, observation: '', interpretation: '', application: '' }] }],
};
expect(buildMarkdownExport(project)).toContain('_No observation._');
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// createParagraphsFromText // createParagraphsFromText
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------