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:
+12
-22
@@ -1,21 +1,17 @@
|
||||
# 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
|
||||
|
||||
### 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
|
||||
- **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
|
||||
- **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
|
||||
- **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)
|
||||
- **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
|
||||
|
||||
### Navigation
|
||||
- **Breadcrumb in header** — study page header only shows the project title (`App.jsx:4172`); no persistent "Genesis 1:1–5" reference next to it so users can tell at a glance where they are without checking the scripture panel
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
- 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
|
||||
|
||||
### State Management
|
||||
- **`App.jsx` is now ~5,100 lines** (up from ~2,250 when this doc was last written) — still one component from line 903–5044. 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
|
||||
- **`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
|
||||
- **`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
|
||||
|
||||
### 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; 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 or 2FA code; 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
|
||||
- **"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
|
||||
|
||||
### 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`
|
||||
- **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
|
||||
|
||||
- **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
|
||||
- **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
|
||||
- **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`)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- Still missing: autosave debounce behavior, and coverage for the newer features (DOCX episode import, cross-ref auto-suggest, commentary loading)
|
||||
- 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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -63,6 +63,12 @@ export function initDb() {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -213,6 +219,38 @@ export function deleteProject(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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
initDb, getAllProjects, getProject, upsertProject, deleteProject,
|
||||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||
} from './db.js';
|
||||
import { SqliteSessionStore } from './sessionStore.js';
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+463
-14
@@ -31,6 +31,10 @@ import {
|
||||
confirmMfaSetup,
|
||||
disableMfa,
|
||||
updateProfile,
|
||||
getShareStatus,
|
||||
enableSharing,
|
||||
disableSharing,
|
||||
getSharedProject,
|
||||
} from './syncService.js';
|
||||
|
||||
const COMMENTARY_OPTIONS = [
|
||||
@@ -419,6 +423,11 @@ export function buildExportHtml(project) {
|
||||
.greek th { background: #f8fafc; }
|
||||
.definition { margin-top: 0.75rem; font-size: 0.95rem; line-height: 1.6; }
|
||||
.definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; }
|
||||
@media print {
|
||||
body { padding: 0; }
|
||||
.chunk { break-inside: avoid; border-color: #cbd5e1; }
|
||||
.chapter-heading { break-after: avoid; }
|
||||
}
|
||||
`;
|
||||
|
||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||
@@ -516,6 +525,48 @@ export function buildExportHtml(project) {
|
||||
</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) {
|
||||
return `
|
||||
<table>
|
||||
@@ -915,6 +966,25 @@ async function parseEpisodeListDocx(file) {
|
||||
return Array.from(episodes.values()).sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber));
|
||||
}
|
||||
|
||||
// Small, unambiguous icons for the reader's verse actions (replaces emoji, which
|
||||
// don't reliably convey "bookmark this" vs. "already bookmarked" at a glance).
|
||||
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
|
||||
function CrossRefChip({ label, onRemove, loadVerseText }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
@@ -990,6 +1060,14 @@ const App = () => {
|
||||
const [podcastNameInput, setPodcastNameInput] = useState('');
|
||||
const [podcastNameSaving, setPodcastNameSaving] = 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);
|
||||
// 'home' | 'setup' | 'study' | 'settings'
|
||||
const [currentPage, setCurrentPage] = useState('home');
|
||||
@@ -1043,12 +1121,17 @@ const App = () => {
|
||||
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 [audioBook, setAudioBook] = useState(bookOptions[0].abbrev);
|
||||
const [audioNarrator, setAudioNarrator] = useState('souer');
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 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) => {
|
||||
setReaderLoading(true);
|
||||
setReaderError('');
|
||||
@@ -1244,10 +1360,43 @@ const App = () => {
|
||||
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]);
|
||||
|
||||
const openBibleReader = () => {
|
||||
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 = () => {
|
||||
if (readerChapter > 1) { setReaderChapter((c) => c - 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
|
||||
};
|
||||
@@ -1399,6 +1548,28 @@ const App = () => {
|
||||
setPodcastNameInput(authUser?.podcastName ?? '');
|
||||
}, [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.
|
||||
// Runs on every authUser change (including logout -> different login) so a previous
|
||||
// account's stale suggestions never linger after switching users. Projects that exist
|
||||
@@ -2712,6 +2883,62 @@ const App = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const printChapterPdf = () => {
|
||||
if (!project) return;
|
||||
const html = buildExportHtml(project);
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) {
|
||||
alert('Please allow pop-ups for this site to print or save as PDF.');
|
||||
return;
|
||||
}
|
||||
printWindow.document.write(html);
|
||||
printWindow.document.close();
|
||||
printWindow.onload = () => {
|
||||
printWindow.focus();
|
||||
printWindow.print();
|
||||
};
|
||||
};
|
||||
|
||||
const handleEnableSharing = async () => {
|
||||
if (!project) return;
|
||||
setShareBusy(true);
|
||||
const result = await enableSharing(project.id);
|
||||
setShareBusy(false);
|
||||
if (result.ok) setShareToken(result.data.shareToken);
|
||||
};
|
||||
|
||||
const handleDisableSharing = async () => {
|
||||
if (!project) return;
|
||||
if (!window.confirm('Revoke this share link? Anyone using it will lose access immediately.')) return;
|
||||
setShareBusy(true);
|
||||
const result = await disableSharing(project.id);
|
||||
setShareBusy(false);
|
||||
if (result.ok) setShareToken(null);
|
||||
};
|
||||
|
||||
const handleCopyShareLink = () => {
|
||||
if (!shareToken) return;
|
||||
const url = `${window.location.origin}${window.location.pathname}?share=${shareToken}`;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setShareCopied(true);
|
||||
window.setTimeout(() => setShareCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const exportChapterMarkdown = () => {
|
||||
if (!project) return;
|
||||
const markdown = buildMarkdownExport(project);
|
||||
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${project.title.replace(/\s+/g, '-')}-study.md`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const exportChapterDocx = async () => {
|
||||
if (!project) return;
|
||||
const children = [
|
||||
@@ -3226,6 +3453,65 @@ const deleteProject = (id) => {
|
||||
>
|
||||
Export DOCX
|
||||
</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 className="text-right text-sm text-slate-300 space-y-0.5">
|
||||
@@ -3263,6 +3549,44 @@ const deleteProject = (id) => {
|
||||
</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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -3951,6 +4275,18 @@ const deleteProject = (id) => {
|
||||
// ---------------------------------------------------------------------------
|
||||
if (currentPage === 'reader') {
|
||||
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 (
|
||||
<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">
|
||||
@@ -4029,15 +4365,65 @@ const deleteProject = (id) => {
|
||||
{readerCrossRefsLoading ? 'Loading refs…' : '🔗 Cross-Refs'}
|
||||
</button>
|
||||
<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 ? (
|
||||
<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
|
||||
autoFocus
|
||||
type="text"
|
||||
value={readerSearch}
|
||||
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"
|
||||
/>
|
||||
<button type="button" onClick={() => { setReaderSearch(''); setReaderSearchActive(false); }}
|
||||
@@ -4051,6 +4437,50 @@ const deleteProject = (id) => {
|
||||
)}
|
||||
</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 */}
|
||||
<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">
|
||||
@@ -4102,7 +4532,9 @@ const deleteProject = (id) => {
|
||||
{readerLoading && <p className="text-sm text-slate-500">Loading…</p>}
|
||||
{readerError && <p className="text-sm text-rose-600">{readerError}</p>}
|
||||
{!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
|
||||
? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower))
|
||||
: readerVerses;
|
||||
@@ -4133,7 +4565,7 @@ const deleteProject = (id) => {
|
||||
};
|
||||
|
||||
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' } : {}}>
|
||||
<div className="flex items-start gap-1">
|
||||
{/* Verse number / interlinear toggle */}
|
||||
@@ -4149,27 +4581,27 @@ const deleteProject = (id) => {
|
||||
</button>
|
||||
{/* Verse text */}
|
||||
<p className="flex-1">{highlightText(verse.text)}</p>
|
||||
{/* Action icons — visible on hover */}
|
||||
<span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{/* 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">
|
||||
<button type="button"
|
||||
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'}>
|
||||
{bmColor ? '🔖' : '🏷️'}
|
||||
<BookmarkIcon filled={!!bmColor} />
|
||||
</button>
|
||||
{bmColor && (
|
||||
<button type="button"
|
||||
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">
|
||||
🎨
|
||||
</button>
|
||||
)}
|
||||
<button type="button"
|
||||
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">
|
||||
📋
|
||||
<CopyIcon />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
@@ -4701,6 +5133,11 @@ const deleteProject = (id) => {
|
||||
<div>
|
||||
<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>
|
||||
{selectedChunk && selectedChunkChapterIndex >= 0 && (
|
||||
<p className="mt-1 text-sm text-slate-300">
|
||||
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '–')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{headerButtons}
|
||||
</div>
|
||||
@@ -4982,9 +5419,21 @@ const deleteProject = (id) => {
|
||||
<span className="text-slate-400">{collapsedSections.oia ? '▸' : '▾'}</span>
|
||||
</button>
|
||||
{!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: 'application', label: 'Application', placeholder: 'How does it apply? Personal response, life change…' },
|
||||
{
|
||||
field: 'observation',
|
||||
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 }) => (
|
||||
<div key={field}>
|
||||
<label className="mb-1 block text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
|
||||
@@ -65,6 +65,30 @@ export async function deleteRemoteProject(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.
|
||||
* Returns true / false.
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
parseBibleChapter,
|
||||
wordTableHtml,
|
||||
buildExportHtml,
|
||||
buildMarkdownExport,
|
||||
buildClaudePrompt,
|
||||
createParagraphsFromText,
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user