Compare commits
8
Commits
8c55765ae6
...
d08c701698
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d08c701698 | ||
|
|
0f1a9c3d5b | ||
|
|
bf02e071fa | ||
|
|
4b14b3fbe6 | ||
|
|
365fb1b567 | ||
|
|
a7e00a76b5 | ||
|
|
8bb3b104c8 | ||
|
|
d7bbbe60de |
No files matched your search
@@ -1,68 +0,0 @@
|
|||||||
# Study App Improvement Suggestions
|
|
||||||
|
|
||||||
_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), read-only share links, and an admin panel (`server/auth.js` `ADMIN_EMAIL`, hardcoded to the site owner's account) showing every user/project with view/delete controls._
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Study Tools
|
|
||||||
- **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
|
|
||||||
|
|
||||||
### Export / Sharing
|
|
||||||
- **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
|
|
||||||
- **Auto-chunk** — no "split by paragraph/section" button; every chunk boundary is manual or typed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UX / UI
|
|
||||||
|
|
||||||
### Study Page
|
|
||||||
|
|
||||||
### Reader
|
|
||||||
- **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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Architecture
|
|
||||||
|
|
||||||
### State Management
|
|
||||||
- **`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 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
|
|
||||||
- **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)
|
|
||||||
- **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
|
|
||||||
- **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
|
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Developer Experience
|
|
||||||
|
|
||||||
- **No ESLint config** — still true; no `.eslintrc*` or `eslint.config.*` in the repo
|
|
||||||
- **No TypeScript** — still true
|
|
||||||
- **No `docker-compose.yml`** — still true; Dockerfile exists but no one-command local dev with server + SQLite volume
|
|
||||||
+41
-2148
File diff suppressed because it is too large.
Load diff
+206
-1
@@ -2,6 +2,8 @@ import { render, screen, fireEvent, waitFor, within } from '@testing-library/rea
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
|
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import { resetLexiconCaches } from './utils/wordStudy.js';
|
||||||
|
import { switchStorageUser } from './utils/bibleUtils.jsx';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared mock data
|
// Shared mock data
|
||||||
@@ -49,6 +51,8 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
resetLexiconCaches();
|
||||||
|
switchStorageUser(null); // a signed-in test leaves the storage namespace switched
|
||||||
vi.stubGlobal('fetch', buildFetchMock());
|
vi.stubGlobal('fetch', buildFetchMock());
|
||||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||||
@@ -76,7 +80,8 @@ async function loadChapter() {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await renderApp();
|
await renderApp();
|
||||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||||
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
// SetupPage is lazy-loaded, so wait for it on first open
|
||||||
|
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||||
await screen.findByText(/Scripture & Chunks/i);
|
await screen.findByText(/Scripture & Chunks/i);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@@ -495,3 +500,203 @@ describe('Admin page', () => {
|
|||||||
expect(screen.queryByRole('button', { name: /admin/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /admin/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import page (state is local to ImportPage)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Import page', () => {
|
||||||
|
test('opens from Home, lists fetched translations, and goes back', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click(screen.getByRole('button', { name: /import session list/i }));
|
||||||
|
expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole('option', { name: 'KJV' })).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /back/i }));
|
||||||
|
expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Home page (search/sort/rename local to HomePage; library + reading plan hooks)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Home page', () => {
|
||||||
|
// Tests run in local-only mode (the mock API is unreachable), which uses the
|
||||||
|
// pre-account localStorage keys.
|
||||||
|
function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) {
|
||||||
|
const project = {
|
||||||
|
id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null,
|
||||||
|
chapters: [{
|
||||||
|
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }],
|
||||||
|
chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '',
|
||||||
|
crossReferences: [], greekWords: [], tags: [] }],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]');
|
||||||
|
index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] });
|
||||||
|
localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project));
|
||||||
|
localStorage.setItem('bible-study-index', JSON.stringify(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('searching 3+ characters finds matches inside study notes', async () => {
|
||||||
|
seedProject();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.type(screen.getByPlaceholderText(/search projects/i), 'grace');
|
||||||
|
expect(await screen.findByText(/notes matching "grace"/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renaming a project updates its title and saved index', async () => {
|
||||||
|
seedProject();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click(screen.getByRole('button', { name: /rename/i }));
|
||||||
|
const input = screen.getByDisplayValue('Titus Notes');
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, 'Titus Deep Dive');
|
||||||
|
await user.click(screen.getByRole('button', { name: /^save$/i }));
|
||||||
|
expect(await screen.findByText('Titus Deep Dive')).toBeInTheDocument();
|
||||||
|
expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting a project removes it after confirmation', async () => {
|
||||||
|
seedProject();
|
||||||
|
window.confirm.mockReturnValue(true);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click(screen.getByRole('button', { name: /^delete$/i }));
|
||||||
|
await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument());
|
||||||
|
expect(localStorage.getItem('bible-study-project-p1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sort choice is remembered after leaving Home', async () => {
|
||||||
|
seedProject();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.selectOptions(screen.getByDisplayValue('Last edited'), 'title');
|
||||||
|
await user.click(screen.getByRole('button', { name: /read bible/i }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: /back to studies/i }));
|
||||||
|
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reading plan can be started from Home', async () => {
|
||||||
|
seedProject(); // the plan card shows once there's at least one project
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click(screen.getByRole('button', { name: /set goal/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /start plan/i }));
|
||||||
|
await waitFor(() => expect(JSON.parse(localStorage.getItem('readingPlan'))?.bookAbbrev).toBe('GEN'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Project header (ProjectHeader + useShareLink) and the shared read-only view
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Project header and sharing', () => {
|
||||||
|
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||||
|
|
||||||
|
test('Export → Markdown downloads a file', async () => {
|
||||||
|
const user = await loadChapter();
|
||||||
|
await user.click(screen.getByRole('button', { name: /export/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /export markdown/i }));
|
||||||
|
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enabling sharing shows a copyable link', async () => {
|
||||||
|
const fallback = buildFetchMock();
|
||||||
|
const fetchMock = vi.fn((url, opts = {}) => {
|
||||||
|
if (url.startsWith('/api/projects/') && url.endsWith('/share')) {
|
||||||
|
return (opts.method ?? 'GET') === 'POST' ? json(200, { shareToken: 'tok123' }) : json(200, { shareToken: null });
|
||||||
|
}
|
||||||
|
return fallback(url, opts);
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const user = await loadChapter();
|
||||||
|
await user.click(screen.getByRole('button', { name: /share/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /enable sharing/i }));
|
||||||
|
expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ?share= link shows the read-only view without signing in', async () => {
|
||||||
|
window.history.pushState({}, '', '/?share=abc');
|
||||||
|
const fallback = buildFetchMock();
|
||||||
|
vi.stubGlobal('fetch', vi.fn((url, opts) => (url === '/api/share/abc'
|
||||||
|
? json(200, { id: 's1', title: 'Shared Titus', translation: 'BSB', chapters: [] })
|
||||||
|
: fallback(url, opts))));
|
||||||
|
try {
|
||||||
|
render(<App />);
|
||||||
|
expect(await screen.findByText(/read-only shared view/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByTitle('Shared study')).toBeInTheDocument();
|
||||||
|
} finally {
|
||||||
|
window.history.pushState({}, '', '/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Study editor (StudyPage local state + useCrossRefs / useWordStudy hooks)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Study editor', () => {
|
||||||
|
test('study-page messages now show as a status toast', async () => {
|
||||||
|
await goToStudyWith3Chunks();
|
||||||
|
// Titus is a New Testament book, so the Hebrew picker explains why it can't help.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /suggest hebrew/i }));
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent(/old testament/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('arrow keys move between chunks', async () => {
|
||||||
|
await goToStudyWith3Chunks();
|
||||||
|
fireEvent.keyDown(window, { key: 'ArrowRight' });
|
||||||
|
expect(await findChunkCounter(2, 3)).toBeInTheDocument();
|
||||||
|
fireEvent.keyDown(window, { key: 'ArrowLeft' });
|
||||||
|
expect(await findChunkCounter(1, 3)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a typed cross-reference is added, and the input clears on chunk change', async () => {
|
||||||
|
await goToStudyWith3Chunks();
|
||||||
|
const input = screen.getByPlaceholderText(/john 1:1 or rom 3:23/i);
|
||||||
|
fireEvent.change(input, { target: { value: 'Rom 3:23' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
expect(await screen.findByText('Rom 3:23')).toBeInTheDocument();
|
||||||
|
fireEvent.change(input, { target: { value: 'half typed' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
|
||||||
|
await waitFor(() => expect(input).toHaveValue(''));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tag can be added with Enter', async () => {
|
||||||
|
await goToStudyWith3Chunks();
|
||||||
|
const input = screen.getByPlaceholderText(/add a tag/i);
|
||||||
|
fireEvent.change(input, { target: { value: 'grace' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
expect(await screen.findByText(/#?grace/)).toBeInTheDocument();
|
||||||
|
expect(input).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chunk builder (useChunkBuilder, local to SetupPage)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Chunk builder', () => {
|
||||||
|
async function addSecondChapter(user) {
|
||||||
|
await user.click(screen.getByRole('button', { name: /add chapter/i }));
|
||||||
|
// The add-chapter form starts on the chapter after the project's last one.
|
||||||
|
expect(screen.getByDisplayValue('2')).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole('button', { name: /load chapter/i }));
|
||||||
|
return screen.findByRole('button', { name: 'Titus 2' });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('"Add Chapter" starts on the next chapter and adds it as a tab', async () => {
|
||||||
|
const user = await loadChapter();
|
||||||
|
expect(await addSecondChapter(user)).toHaveClass('bg-slate-900');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"Back to Chunks" reopens the chapter of the chunk being studied', async () => {
|
||||||
|
const user = await loadChapter();
|
||||||
|
await addSecondChapter(user);
|
||||||
|
addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Titus 1' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||||
|
await screen.findByText(/chunk editor/i);
|
||||||
|
await user.click(screen.getByRole('button', { name: /back to chunks/i }));
|
||||||
|
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveClass('bg-slate-900');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Signed-in email plus Admin / Settings / Log out buttons for page headers.
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
|
||||||
|
export default function AuthStatus() {
|
||||||
|
const { authUser, setCurrentPage, logout } = useApp();
|
||||||
|
if (!authUser) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<span className="hidden sm:inline">{authUser.email}</span>
|
||||||
|
{authUser.isAdmin && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage('admin')}
|
||||||
|
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
||||||
|
>
|
||||||
|
🛡 Admin
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage('settings')}
|
||||||
|
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
||||||
|
>
|
||||||
|
⚙ Settings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={logout}
|
||||||
|
className="rounded-xl border border-white/15 bg-white/10 px-3 py-1.5 text-xs text-white transition hover:bg-white/15"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Header for an open project (Setup and Study pages): navigation, Claude
|
||||||
|
// prompts, pronunciation guide, export menu, share link, and save/sync status.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { useShareLink } from '../hooks/useShareLink.js';
|
||||||
|
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
|
||||||
|
import {
|
||||||
|
exportProjectDocx,
|
||||||
|
exportProjectHtml,
|
||||||
|
exportProjectMarkdown,
|
||||||
|
printProjectPdf,
|
||||||
|
} from '../utils/projectExport.js';
|
||||||
|
import AuthStatus from './AuthStatus.jsx';
|
||||||
|
|
||||||
|
// loading: true while the Setup page is fetching a chapter.
|
||||||
|
export default function ProjectHeader({ loading = false }) {
|
||||||
|
const {
|
||||||
|
authUser,
|
||||||
|
currentPage, setCurrentPage,
|
||||||
|
goHome,
|
||||||
|
project,
|
||||||
|
allChunks,
|
||||||
|
saveStatus, setSaveStatus,
|
||||||
|
syncStatus,
|
||||||
|
retrySync,
|
||||||
|
} = useApp();
|
||||||
|
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||||
|
const [sharePanelOpen, setSharePanelOpen] = useState(false);
|
||||||
|
const share = useShareLink(project?.id);
|
||||||
|
|
||||||
|
// Copies a prompt, then opens claude.ai so it can be pasted straight in.
|
||||||
|
const copyPrompt = (prompt) => {
|
||||||
|
navigator.clipboard.writeText(prompt).then(() => {
|
||||||
|
setSaveStatus('Copied! Opening Claude…');
|
||||||
|
window.setTimeout(() => setSaveStatus(''), 3000);
|
||||||
|
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{currentPage !== 'home' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goHome}
|
||||||
|
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
|
||||||
|
>
|
||||||
|
← Home
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{project && currentPage === 'study' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage('setup')}
|
||||||
|
className="rounded-xl border border-white/15 bg-white/10 px-4 py-2 text-sm text-white transition hover:bg-white/15"
|
||||||
|
>
|
||||||
|
← Back to Chunks
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{project && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyPrompt(buildClaudePrompt(project))}
|
||||||
|
disabled={allChunks.length === 0}
|
||||||
|
className="rounded-md bg-violet-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-violet-400 disabled:cursor-not-allowed disabled:bg-slate-500"
|
||||||
|
title="Copies your study notes as a Claude prompt, then opens claude.ai so you can paste and go"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Study Guide → </span>Claude ↗
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName))}
|
||||||
|
disabled={allChunks.length === 0}
|
||||||
|
title="Copies a podcast script prompt for Claude, then opens claude.ai — set your show name in Account Settings first"
|
||||||
|
className="rounded-md bg-fuchsia-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-fuchsia-400 disabled:cursor-not-allowed disabled:bg-slate-500"
|
||||||
|
>
|
||||||
|
🎙<span className="hidden sm:inline"> Podcast → Claude ↗</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage('pronunciation')}
|
||||||
|
disabled={allChunks.length === 0}
|
||||||
|
title="Open the pronunciation guide — all Greek & Hebrew words with transliterations"
|
||||||
|
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:bg-slate-500"
|
||||||
|
>
|
||||||
|
🗣<span className="hidden sm:inline"> Pronunciation Guide</span>
|
||||||
|
</button>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExportMenuOpen((v) => !v)}
|
||||||
|
className="flex items-center gap-1.5 rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400"
|
||||||
|
>
|
||||||
|
⬇ Export
|
||||||
|
</button>
|
||||||
|
{exportMenuOpen && (
|
||||||
|
<div className="absolute left-0 top-full z-20 mt-2 w-56 overflow-hidden rounded-2xl border border-slate-200 bg-white py-1.5 text-left shadow-lg">
|
||||||
|
<button type="button" onClick={() => { exportProjectHtml(project); setExportMenuOpen(false); }}
|
||||||
|
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||||||
|
Export HTML
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { exportProjectDocx(project); setExportMenuOpen(false); }}
|
||||||
|
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||||||
|
Export DOCX
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { exportProjectMarkdown(project); setExportMenuOpen(false); }}
|
||||||
|
title="Handy for Obsidian, Notion, or any Markdown-based notes app"
|
||||||
|
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||||||
|
Export Markdown
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { printProjectPdf(project); setExportMenuOpen(false); }}
|
||||||
|
title="Opens a print-friendly version in a new tab — choose 'Save as PDF' in the print dialog"
|
||||||
|
className="block w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50">
|
||||||
|
🖨 Print / Save PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSharePanelOpen((v) => !v)}
|
||||||
|
className={`rounded-md px-4 py-2 text-sm font-medium shadow-sm transition ${share.shareToken ? 'bg-teal-600 text-white hover:bg-teal-500' : 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
🔗 {share.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>
|
||||||
|
{share.shareToken ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={share.shareUrl}
|
||||||
|
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={share.copy}
|
||||||
|
className="shrink-0 rounded-lg bg-sky-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-400">
|
||||||
|
{share.shareCopied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={share.revoke} disabled={share.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">
|
||||||
|
{share.shareBusy ? 'Revoking…' : 'Revoke link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={share.enable} disabled={share.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">
|
||||||
|
{share.shareBusy ? 'Enabling…' : 'Enable sharing'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-right text-sm text-slate-300 space-y-0.5">
|
||||||
|
{loading ? (
|
||||||
|
<span>Loading…</span>
|
||||||
|
) : saveStatus ? (
|
||||||
|
<span className="text-emerald-300">{saveStatus}</span>
|
||||||
|
) : (
|
||||||
|
<span> </span>
|
||||||
|
)}
|
||||||
|
{syncStatus === 'syncing' && <div className="text-xs text-slate-400">Syncing…</div>}
|
||||||
|
{syncStatus === 'synced' && <div className="text-xs text-emerald-400">Synced ✓</div>}
|
||||||
|
{syncStatus === 'error' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-rose-400">⚠ Sync failed</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={retrySync}
|
||||||
|
className="text-xs text-slate-300 underline hover:text-white"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<AuthStatus />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// The list of Bible translations offered by the HelloAO API, fetched once per
|
||||||
|
// visit and shared by every page that needs it (Setup, Import).
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
let cached = null;
|
||||||
|
let pending = null;
|
||||||
|
|
||||||
|
export function useAvailableTranslations() {
|
||||||
|
const [translations, setTranslations] = useState(cached ?? ['BSB']);
|
||||||
|
useEffect(() => {
|
||||||
|
if (cached) return;
|
||||||
|
pending ??= fetch('https://bible.helloao.org/api/available_translations.json')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data) && data.length > 0) cached = data;
|
||||||
|
return cached;
|
||||||
|
})
|
||||||
|
.catch(() => null)
|
||||||
|
.finally(() => { pending = null; });
|
||||||
|
let active = true;
|
||||||
|
pending.then((list) => { if (active && list) setTranslations(list); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, []);
|
||||||
|
return translations;
|
||||||
|
}
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
// The Setup page's chunk builder: the new-project / add-chapter form, the
|
||||||
|
// active chapter tab, and every way of creating chunks (click, shift-click,
|
||||||
|
// typed range, bulk ranges, cross-chapter spans), plus reorder and delete.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
|
// A new project starts on Titus 1. When adding to an open project, start on
|
||||||
|
// the chapter after its last one (same book and translation).
|
||||||
|
function initialSetupForm(project) {
|
||||||
|
const last = project?.chapters?.at(-1);
|
||||||
|
if (!last) {
|
||||||
|
return { translation: 'BSB', book: 'Titus', bookAbbrev: 'TIT', chapter: '1', title: 'Titus 1 Study' };
|
||||||
|
}
|
||||||
|
const next = Math.min(Number(last.chapter) + 1, CHAPTER_COUNTS[last.bookAbbrev] ?? Number(last.chapter));
|
||||||
|
return {
|
||||||
|
translation: project.translation ?? 'BSB',
|
||||||
|
book: last.book,
|
||||||
|
bookAbbrev: last.bookAbbrev,
|
||||||
|
chapter: String(next),
|
||||||
|
title: project.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open on the chapter holding the chunk last studied (e.g. after "Back to Chunks").
|
||||||
|
function initialChapterIndex(project) {
|
||||||
|
const index = project?.chapters?.findIndex((ch) => ch.chunks.some((c) => c.id === project.selectedChunkId)) ?? -1;
|
||||||
|
return index >= 0 ? index : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) {
|
||||||
|
const [setup, setSetup] = useState(() => initialSetupForm(project));
|
||||||
|
const [titleEdited, setTitleEdited] = useState(false);
|
||||||
|
const [activeChapterIndex, setActiveChapterIndex] = useState(() => initialChapterIndex(project));
|
||||||
|
const [showAddChapterForm, setShowAddChapterForm] = useState(false);
|
||||||
|
const [rangeStart, setRangeStart] = useState(null);
|
||||||
|
const [rangeEnd, setRangeEnd] = useState(null);
|
||||||
|
const [loadingChapter, setLoadingChapter] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
|
const [typedChunkStart, setTypedChunkStart] = useState('');
|
||||||
|
const [typedChunkEnd, setTypedChunkEnd] = useState('');
|
||||||
|
const [typedChunkNextEnd, setTypedChunkNextEnd] = useState('');
|
||||||
|
const [clickedSpanNextEnd, setClickedSpanNextEnd] = useState('');
|
||||||
|
const [typedChunkBulk, setTypedChunkBulk] = useState('');
|
||||||
|
const [verseSearch, setVerseSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!titleEdited) {
|
||||||
|
setSetup((current) => ({
|
||||||
|
...current,
|
||||||
|
title: `${current.book} ${current.chapter} Study`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [setup.book, setup.chapter, titleEdited]);
|
||||||
|
|
||||||
|
// Each time the add-chapter form opens, suggest the chapter after the last one.
|
||||||
|
useEffect(() => {
|
||||||
|
if (showAddChapterForm) setSetup(initialSetupForm(project));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [showAddChapterForm]);
|
||||||
|
|
||||||
|
const activeChapter = project?.chapters[activeChapterIndex] ?? null;
|
||||||
|
|
||||||
|
const handleSetupField = (field, value) => {
|
||||||
|
if (field === 'book') {
|
||||||
|
const selected = bookOptions.find((book) => book.abbrev === value) ?? bookOptions[0];
|
||||||
|
setSetup((current) => ({
|
||||||
|
...current,
|
||||||
|
book: selected.name,
|
||||||
|
bookAbbrev: selected.abbrev,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setSetup((current) => ({ ...current, [field]: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadChapter = async () => {
|
||||||
|
if (!setup.bookAbbrev || !setup.chapter) {
|
||||||
|
setErrorMessage('Please choose a book and chapter.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrorMessage('');
|
||||||
|
setLoadingChapter(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://bible.helloao.org/api/${setup.translation}/${setup.bookAbbrev}/${setup.chapter}.json`,
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error('Unable to load chapter.');
|
||||||
|
const data = await response.json();
|
||||||
|
const verses = parseBibleChapter(data);
|
||||||
|
if (!data || !Array.isArray(verses) || verses.length === 0) {
|
||||||
|
throw new Error('Invalid Bible data returned.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project) {
|
||||||
|
// Adding a chapter to an existing project
|
||||||
|
const alreadyExists = project.chapters.some(
|
||||||
|
(ch) => ch.bookAbbrev === setup.bookAbbrev && ch.chapter === setup.chapter,
|
||||||
|
);
|
||||||
|
if (alreadyExists) {
|
||||||
|
setErrorMessage('That chapter is already in this project.');
|
||||||
|
setLoadingChapter(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newChapter = {
|
||||||
|
book: setup.book,
|
||||||
|
bookAbbrev: setup.bookAbbrev,
|
||||||
|
chapter: setup.chapter,
|
||||||
|
verses,
|
||||||
|
chunks: [],
|
||||||
|
};
|
||||||
|
updateProject((current) => {
|
||||||
|
const updatedChapters = [...current.chapters, newChapter];
|
||||||
|
return { ...current, chapters: updatedChapters };
|
||||||
|
});
|
||||||
|
setActiveChapterIndex(project.chapters.length);
|
||||||
|
setShowAddChapterForm(false);
|
||||||
|
setErrorMessage('');
|
||||||
|
} else {
|
||||||
|
// Creating a brand-new project
|
||||||
|
const newProject = {
|
||||||
|
id: makeId(),
|
||||||
|
title: setup.title,
|
||||||
|
translation: setup.translation,
|
||||||
|
lastEdited: Date.now(),
|
||||||
|
selectedChunkId: null,
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
book: setup.book,
|
||||||
|
bookAbbrev: setup.bookAbbrev,
|
||||||
|
chapter: setup.chapter,
|
||||||
|
verses,
|
||||||
|
chunks: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
setProject(newProject);
|
||||||
|
setActiveChapterIndex(0);
|
||||||
|
setCurrentPage('setup');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error.message || 'Failed to load chapter.');
|
||||||
|
} finally {
|
||||||
|
setLoadingChapter(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginStudying = () => {
|
||||||
|
if (!project || allChunks.length === 0) return;
|
||||||
|
updateProject((current) => ({
|
||||||
|
...current,
|
||||||
|
selectedChunkId: current.selectedChunkId || allChunks[0]?.id || null,
|
||||||
|
}));
|
||||||
|
setCurrentPage('study');
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChunkToChapter = (chapterIndex, start, end, spilloverEndVerse = null) => {
|
||||||
|
updateProject((current) => {
|
||||||
|
const chapters = current.chapters.map((ch, idx) => {
|
||||||
|
if (idx !== chapterIndex) return ch;
|
||||||
|
const overlap = ch.chunks.find(
|
||||||
|
(c) => c.startVerse === start
|
||||||
|
&& c.endVerse === end
|
||||||
|
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
|
||||||
|
);
|
||||||
|
if (overlap) return ch;
|
||||||
|
const newChunk = {
|
||||||
|
id: makeId(),
|
||||||
|
startVerse: start,
|
||||||
|
endVerse: end,
|
||||||
|
spilloverEndVerse,
|
||||||
|
observation: '',
|
||||||
|
interpretation: '',
|
||||||
|
application: '',
|
||||||
|
crossReferences: [],
|
||||||
|
greekWords: [],
|
||||||
|
generalNotes: '',
|
||||||
|
episodeNumber: '',
|
||||||
|
episodeTitle: '',
|
||||||
|
finalScript: '',
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
return { ...ch, chunks: [...ch.chunks, newChunk] };
|
||||||
|
});
|
||||||
|
// Find the newly added chunk id
|
||||||
|
const newChunk = chapters[chapterIndex].chunks.at(-1);
|
||||||
|
return { ...current, chapters, selectedChunkId: newChunk?.id ?? current.selectedChunkId };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChunk = (start, end, spilloverEndVerse = null) => {
|
||||||
|
if (!project) return;
|
||||||
|
const chapter = activeChapter;
|
||||||
|
if (!chapter) return;
|
||||||
|
const overlap = chapter.chunks.find(
|
||||||
|
(c) => c.startVerse === start
|
||||||
|
&& c.endVerse === end
|
||||||
|
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
|
||||||
|
);
|
||||||
|
if (overlap) {
|
||||||
|
setStatusMessage('That chunk already exists.');
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 1800);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addChunkToChapter(activeChapterIndex, start, end, spilloverEndVerse);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChunkRanges = (ranges) => {
|
||||||
|
if (!project || !activeChapter || ranges.length === 0) {
|
||||||
|
return { addedCount: 0, skippedCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let addedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
let lastAddedChunkId = null;
|
||||||
|
|
||||||
|
updateProject((current) => {
|
||||||
|
const chapters = current.chapters.map((ch, idx) => {
|
||||||
|
if (idx !== activeChapterIndex) return ch;
|
||||||
|
const existing = new Set(
|
||||||
|
ch.chunks.map((c) => `${c.startVerse}-${c.endVerse}-${c.spilloverEndVerse ?? ''}`),
|
||||||
|
);
|
||||||
|
const newChunks = [];
|
||||||
|
|
||||||
|
ranges.forEach(({ start, end, spilloverEndVerse = null }) => {
|
||||||
|
const key = `${start}-${end}-${spilloverEndVerse ?? ''}`;
|
||||||
|
if (existing.has(key)) {
|
||||||
|
skippedCount += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newChunk = {
|
||||||
|
id: makeId(),
|
||||||
|
startVerse: start,
|
||||||
|
endVerse: end,
|
||||||
|
spilloverEndVerse,
|
||||||
|
observation: '',
|
||||||
|
interpretation: '',
|
||||||
|
application: '',
|
||||||
|
crossReferences: [],
|
||||||
|
greekWords: [],
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
existing.add(key);
|
||||||
|
newChunks.push(newChunk);
|
||||||
|
addedCount += 1;
|
||||||
|
lastAddedChunkId = newChunk.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return newChunks.length > 0
|
||||||
|
? { ...ch, chunks: [...ch.chunks, ...newChunks] }
|
||||||
|
: ch;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
chapters,
|
||||||
|
selectedChunkId: lastAddedChunkId ?? current.selectedChunkId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { addedCount, skippedCount };
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTypedChunk = () => {
|
||||||
|
if (!activeChapter) return;
|
||||||
|
const start = Number.parseInt(typedChunkStart, 10);
|
||||||
|
const end = Number.parseInt(typedChunkEnd, 10);
|
||||||
|
const nextEndRaw = typedChunkNextEnd.trim();
|
||||||
|
const nextEnd = nextEndRaw ? Number.parseInt(nextEndRaw, 10) : null;
|
||||||
|
const maxVerse = activeChapter.verses.at(-1)?.number ?? 0;
|
||||||
|
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
|
||||||
|
const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0;
|
||||||
|
|
||||||
|
if (!Number.isInteger(start) || !Number.isInteger(end)) {
|
||||||
|
setStatusMessage('Type a valid start and end verse.');
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (start < 1 || end < 1 || start > maxVerse || end > maxVerse) {
|
||||||
|
setStatusMessage(`Verse range must be between 1 and ${maxVerse}.`);
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (start > end) {
|
||||||
|
setStatusMessage('Start verse must be less than or equal to end verse.');
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextEndRaw) {
|
||||||
|
if (!nextChapter) {
|
||||||
|
setStatusMessage('Add the next chapter first to span chunks across chapters.');
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) {
|
||||||
|
setStatusMessage('Chapter spanning only works into the next chapter of the same book.');
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(nextEnd) || nextEnd < 1 || nextEnd > nextMaxVerse) {
|
||||||
|
setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`);
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (end !== maxVerse) {
|
||||||
|
setStatusMessage('To span chapters, set End to the last verse of this chapter.');
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse: nextEnd }]);
|
||||||
|
if (addedCount === 0) {
|
||||||
|
setStatusMessage('That chunk already exists.');
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTypedChunkStart('');
|
||||||
|
setTypedChunkEnd('');
|
||||||
|
setTypedChunkNextEnd('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const addBulkTypedChunks = () => {
|
||||||
|
if (!activeChapter) return;
|
||||||
|
const raw = typedChunkBulk.trim();
|
||||||
|
if (!raw) {
|
||||||
|
setStatusMessage('Type chunk ranges first. Example: 1-6, 7-12');
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxVerse = activeChapter.verses.at(-1)?.number ?? 0;
|
||||||
|
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
|
||||||
|
const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0;
|
||||||
|
const parts = raw
|
||||||
|
.split(/[\n,;]+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const validRanges = [];
|
||||||
|
let invalidCount = 0;
|
||||||
|
|
||||||
|
parts.forEach((part) => {
|
||||||
|
const single = /^(\d+)$/.exec(part);
|
||||||
|
if (single) {
|
||||||
|
const v = Number.parseInt(single[1], 10);
|
||||||
|
if (v >= 1 && v <= maxVerse) {
|
||||||
|
validRanges.push({ start: v, end: v });
|
||||||
|
} else {
|
||||||
|
invalidCount += 1;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = /^(\d+)\s*-\s*(\d+)(?:\s*:\s*(\d+))?$/.exec(part);
|
||||||
|
if (!range) {
|
||||||
|
invalidCount += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Number.parseInt(range[1], 10);
|
||||||
|
const end = Number.parseInt(range[2], 10);
|
||||||
|
const spilloverEndVerse = range[3] ? Number.parseInt(range[3], 10) : null;
|
||||||
|
if (start < 1 || end < 1 || start > end || start > maxVerse || end > maxVerse) {
|
||||||
|
invalidCount += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (spilloverEndVerse !== null) {
|
||||||
|
if (!nextChapter
|
||||||
|
|| nextChapter.bookAbbrev !== activeChapter.bookAbbrev
|
||||||
|
|| spilloverEndVerse < 1
|
||||||
|
|| spilloverEndVerse > nextMaxVerse
|
||||||
|
|| end !== maxVerse) {
|
||||||
|
invalidCount += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validRanges.push({ start, end, spilloverEndVerse });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validRanges.length === 0) {
|
||||||
|
setStatusMessage(`No valid ranges found. Use 1-${maxVerse} and format like 1-6, 7-12.`);
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { addedCount, skippedCount } = addChunkRanges(validRanges);
|
||||||
|
const notes = [];
|
||||||
|
if (addedCount > 0) notes.push(`Added ${addedCount} chunk${addedCount > 1 ? 's' : ''}`);
|
||||||
|
if (skippedCount > 0) notes.push(`skipped ${skippedCount} duplicate${skippedCount > 1 ? 's' : ''}`);
|
||||||
|
if (invalidCount > 0) notes.push(`ignored ${invalidCount} invalid`);
|
||||||
|
setStatusMessage(notes.join(' • '));
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2800);
|
||||||
|
|
||||||
|
if (addedCount > 0) {
|
||||||
|
setTypedChunkBulk('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addClickSpanChunk = () => {
|
||||||
|
if (!activeChapter || rangeStart === null) {
|
||||||
|
setStatusMessage('Click a start verse first, then set next chapter end.');
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null;
|
||||||
|
if (!nextChapter) {
|
||||||
|
setStatusMessage('Add the next chapter first to span into it.');
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) {
|
||||||
|
setStatusMessage('Click-based spanning only works into the next chapter of the same book.');
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMaxVerse = nextChapter.verses.at(-1)?.number ?? 0;
|
||||||
|
const spilloverEndVerse = Number.parseInt(clickedSpanNextEnd, 10);
|
||||||
|
if (!Number.isInteger(spilloverEndVerse) || spilloverEndVerse < 1 || spilloverEndVerse > nextMaxVerse) {
|
||||||
|
setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`);
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = rangeStart;
|
||||||
|
const end = activeChapter.verses.at(-1)?.number ?? start;
|
||||||
|
const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse }]);
|
||||||
|
if (addedCount === 0) {
|
||||||
|
setStatusMessage('That cross-chapter chunk already exists.');
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 2400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setClickedSpanNextEnd('');
|
||||||
|
setRangeStart(null);
|
||||||
|
setRangeEnd(null);
|
||||||
|
setStatusMessage('Cross-chapter chunk added.');
|
||||||
|
window.setTimeout(() => setStatusMessage(''), 1800);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerseClick = (verseNumber, event) => {
|
||||||
|
if (event.shiftKey && rangeStart !== null) {
|
||||||
|
const start = Math.min(rangeStart, verseNumber);
|
||||||
|
const end = Math.max(rangeStart, verseNumber);
|
||||||
|
addChunk(start, end);
|
||||||
|
setRangeStart(null);
|
||||||
|
setRangeEnd(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRangeStart(verseNumber);
|
||||||
|
setRangeEnd(verseNumber);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveChunk = (chunkId, direction) => {
|
||||||
|
updateProject((current) => ({
|
||||||
|
...current,
|
||||||
|
chapters: current.chapters.map((ch) => {
|
||||||
|
const index = ch.chunks.findIndex((c) => c.id === chunkId);
|
||||||
|
if (index < 0) return ch;
|
||||||
|
const nextIndex = index + direction;
|
||||||
|
if (nextIndex < 0 || nextIndex >= ch.chunks.length) return ch;
|
||||||
|
const updated = [...ch.chunks];
|
||||||
|
const [removed] = updated.splice(index, 1);
|
||||||
|
updated.splice(nextIndex, 0, removed);
|
||||||
|
return { ...ch, chunks: updated };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteChunk = (chunkId) => {
|
||||||
|
updateProject((current) => {
|
||||||
|
const newAllChunks = current.chapters
|
||||||
|
.flatMap((ch) => ch.chunks)
|
||||||
|
.filter((c) => c.id !== chunkId);
|
||||||
|
const nextSelected =
|
||||||
|
current.selectedChunkId === chunkId
|
||||||
|
? newAllChunks[0]?.id ?? null
|
||||||
|
: current.selectedChunkId;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
selectedChunkId: nextSelected,
|
||||||
|
chapters: current.chapters.map((ch) => ({
|
||||||
|
...ch,
|
||||||
|
chunks: ch.chunks.filter((c) => c.id !== chunkId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
setup,
|
||||||
|
titleEdited, setTitleEdited,
|
||||||
|
activeChapterIndex, setActiveChapterIndex,
|
||||||
|
activeChapter,
|
||||||
|
showAddChapterForm, setShowAddChapterForm,
|
||||||
|
rangeStart, setRangeStart,
|
||||||
|
rangeEnd, setRangeEnd,
|
||||||
|
verseSearch, setVerseSearch,
|
||||||
|
typedChunkStart, setTypedChunkStart,
|
||||||
|
typedChunkEnd, setTypedChunkEnd,
|
||||||
|
typedChunkNextEnd, setTypedChunkNextEnd,
|
||||||
|
typedChunkBulk, setTypedChunkBulk,
|
||||||
|
clickedSpanNextEnd, setClickedSpanNextEnd,
|
||||||
|
loadingChapter,
|
||||||
|
errorMessage,
|
||||||
|
statusMessage,
|
||||||
|
handleSetupField,
|
||||||
|
handleLoadChapter,
|
||||||
|
beginStudying,
|
||||||
|
handleVerseClick,
|
||||||
|
addTypedChunk,
|
||||||
|
addBulkTypedChunks,
|
||||||
|
addClickSpanChunk,
|
||||||
|
moveChunk,
|
||||||
|
deleteChunk,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Cross-references for the selected chunk: typed entry, suggestions from the
|
||||||
|
// Open Cross Ref dataset, and loading a reference's verse text.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||||
|
import { parseCrossRefString } from '../utils/importParsers.js';
|
||||||
|
import { formatCrossRef } from '../utils/wordStudy.js';
|
||||||
|
|
||||||
|
const crossRefDatasetCache = {}; // "BOOK/chapter" -> Open Cross Ref chapter data
|
||||||
|
const verseChapterCache = {}; // "translation/BOOK/chapter" -> parsed verses
|
||||||
|
|
||||||
|
// notify(message, ms) shows a short status message on the study page.
|
||||||
|
export function useCrossRefs({ project, allChunks, selectedChunk, updateChunk, notify }) {
|
||||||
|
const [crossRefInput, setCrossRefInput] = useState('');
|
||||||
|
const [suggestingCrossRefs, setSuggestingCrossRefs] = useState(false);
|
||||||
|
const [crossRefSuggestions, setCrossRefSuggestions] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCrossRefSuggestions([]);
|
||||||
|
}, [selectedChunk?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCrossRefInput('');
|
||||||
|
}, [project?.selectedChunkId]);
|
||||||
|
|
||||||
|
const addCrossRef = (chunkId) => {
|
||||||
|
const ref = crossRefInput.trim();
|
||||||
|
if (!ref) return;
|
||||||
|
updateChunk(chunkId, {
|
||||||
|
crossReferences: [...(selectedChunk?.crossReferences ?? []), ref],
|
||||||
|
});
|
||||||
|
setCrossRefInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCrossRef = (chunkId, ref) => {
|
||||||
|
updateChunk(chunkId, {
|
||||||
|
crossReferences: (selectedChunk?.crossReferences ?? []).filter((r) => r !== ref),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const suggestCrossRefsForChunk = async (chunkId) => {
|
||||||
|
const chunk = allChunks.find((c) => c.id === chunkId);
|
||||||
|
const chapterIndex = project?.chapters.findIndex((ch) =>
|
||||||
|
ch.chunks.some((c) => c.id === chunkId)
|
||||||
|
) ?? -1;
|
||||||
|
const chapter = chapterIndex >= 0 ? project.chapters[chapterIndex] : null;
|
||||||
|
if (!chunk || !chapter) return;
|
||||||
|
|
||||||
|
setSuggestingCrossRefs(true);
|
||||||
|
setCrossRefSuggestions([]);
|
||||||
|
try {
|
||||||
|
const cacheKey = `${chapter.bookAbbrev}/${chapter.chapter}`;
|
||||||
|
let chapterData = crossRefDatasetCache[cacheKey];
|
||||||
|
if (!chapterData) {
|
||||||
|
const res = await fetch(`https://bible.helloao.org/api/d/open-cross-ref/${chapter.bookAbbrev}/${chapter.chapter}.json`);
|
||||||
|
if (!res.ok) throw new Error('No cross-reference data found for this chapter.');
|
||||||
|
chapterData = await res.json();
|
||||||
|
crossRefDatasetCache[cacheKey] = chapterData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verseEntries = (chapterData.chapter?.content ?? []).filter(
|
||||||
|
(item) => item.verse >= chunk.startVerse
|
||||||
|
&& item.verse <= chunk.endVerse,
|
||||||
|
);
|
||||||
|
|
||||||
|
const existing = new Set(chunk.crossReferences ?? []);
|
||||||
|
const seen = new Set();
|
||||||
|
const refs = [];
|
||||||
|
for (const v of verseEntries) {
|
||||||
|
for (const ref of v.references ?? []) {
|
||||||
|
const formatted = formatCrossRef(ref);
|
||||||
|
if (existing.has(formatted) || seen.has(formatted)) continue;
|
||||||
|
seen.add(formatted);
|
||||||
|
refs.push({ formatted, score: ref.score ?? 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refs.sort((a, b) => b.score - a.score);
|
||||||
|
if (refs.length === 0) {
|
||||||
|
notify('No new cross-reference suggestions found for this passage.', 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCrossRefSuggestions(refs.slice(0, 12).map((r) => r.formatted));
|
||||||
|
} catch (err) {
|
||||||
|
notify(`Cross-reference suggest failed: ${err.message}`, 3000);
|
||||||
|
} finally {
|
||||||
|
setSuggestingCrossRefs(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const loadVerseText = async (refString) => {
|
||||||
|
const parsed = parseCrossRefString(refString);
|
||||||
|
if (!parsed) throw new Error('Could not parse reference.');
|
||||||
|
const translation = project?.translation ?? 'BSB';
|
||||||
|
const cacheKey = `${translation}/${parsed.bookAbbrev}/${parsed.chapter}`;
|
||||||
|
let verses = verseChapterCache[cacheKey];
|
||||||
|
if (!verses) {
|
||||||
|
const res = await fetch(`https://bible.helloao.org/api/${translation}/${parsed.bookAbbrev}/${parsed.chapter}.json`);
|
||||||
|
if (!res.ok) throw new Error('Could not load verse text.');
|
||||||
|
const data = await res.json();
|
||||||
|
verses = parseBibleChapter(data);
|
||||||
|
verseChapterCache[cacheKey] = verses;
|
||||||
|
}
|
||||||
|
const text = verses
|
||||||
|
.filter((v) => v.number >= parsed.verse && v.number <= parsed.endVerse)
|
||||||
|
.map((v) => v.text)
|
||||||
|
.join(' ');
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSuggestedCrossRef = (chunkId, ref) => {
|
||||||
|
updateChunk(chunkId, {
|
||||||
|
crossReferences: [...(selectedChunk?.crossReferences ?? []), ref],
|
||||||
|
});
|
||||||
|
setCrossRefSuggestions((cur) => cur.filter((r) => r !== ref));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
crossRefInput, setCrossRefInput,
|
||||||
|
suggestingCrossRefs,
|
||||||
|
crossRefSuggestions,
|
||||||
|
addCrossRef,
|
||||||
|
removeCrossRef,
|
||||||
|
suggestCrossRefsForChunk,
|
||||||
|
addSuggestedCrossRef,
|
||||||
|
loadVerseText,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// The signed-in user's saved projects: opening, renaming, deleting, and keeping
|
||||||
|
// this device's copies in step with the server.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
|
||||||
|
import {
|
||||||
|
saveProjectToStorage,
|
||||||
|
deleteProjectFromStorage,
|
||||||
|
loadProjectById,
|
||||||
|
} from '../utils/projectStorage.js';
|
||||||
|
import {
|
||||||
|
saveRemoteProject,
|
||||||
|
deleteRemoteProject,
|
||||||
|
listRemoteProjects,
|
||||||
|
loadRemoteProject,
|
||||||
|
} from '../syncService.js';
|
||||||
|
|
||||||
|
export function useProjectLibrary({
|
||||||
|
authUser,
|
||||||
|
project,
|
||||||
|
setProject,
|
||||||
|
setProjectIndex,
|
||||||
|
setCurrentPage,
|
||||||
|
}) {
|
||||||
|
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
|
||||||
|
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// on the server but not on this device (e.g. a brand new browser) are pulled down
|
||||||
|
// automatically instead of requiring a manual "Restore" click per project.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authUser) {
|
||||||
|
setStaleLocalProjects([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const localIndex = loadProjectIndex();
|
||||||
|
listRemoteProjects().then(async (result) => {
|
||||||
|
if (!result.ok || cancelled) return;
|
||||||
|
const localMap = new Map(localIndex.map((e) => [e.id, e]));
|
||||||
|
|
||||||
|
const missing = result.data.filter((e) => !localMap.has(e.id));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
|
||||||
|
fetched.filter((r) => r.ok).forEach((r) => saveProjectToStorage(r.data));
|
||||||
|
if (cancelled) return;
|
||||||
|
const restoredCount = fetched.filter((r) => r.ok).length;
|
||||||
|
if (restoredCount > 0) {
|
||||||
|
setProjectIndex(loadProjectIndex());
|
||||||
|
setAutoRestoredCount(restoredCount);
|
||||||
|
window.setTimeout(() => setAutoRestoredCount(null), 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stale = result.data.filter((e) => {
|
||||||
|
const local = localMap.get(e.id);
|
||||||
|
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0);
|
||||||
|
});
|
||||||
|
if (!cancelled) setStaleLocalProjects(stale);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [authUser]);
|
||||||
|
|
||||||
|
const resumeProject = (id) => {
|
||||||
|
const loaded = loadProjectById(id);
|
||||||
|
if (!loaded) return;
|
||||||
|
setProject(loaded);
|
||||||
|
const loadedChunks = loaded.chapters.flatMap((ch) => ch.chunks);
|
||||||
|
const hasSelectedChunk = loaded.selectedChunkId
|
||||||
|
&& loadedChunks.some((c) => c.id === loaded.selectedChunkId);
|
||||||
|
setCurrentPage(hasSelectedChunk ? 'study' : 'setup');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameProjectInStorage = (id, title) => {
|
||||||
|
const loaded = loadProjectById(id);
|
||||||
|
if (!loaded) return;
|
||||||
|
const updated = { ...loaded, title, lastEdited: Date.now() };
|
||||||
|
saveProjectToStorage(updated);
|
||||||
|
setProjectIndex(loadProjectIndex());
|
||||||
|
saveRemoteProject(updated);
|
||||||
|
if (project?.id === id) {
|
||||||
|
setProject((current) => (current ? { ...current, title } : current));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteProject = (id) => {
|
||||||
|
if (!window.confirm('Delete this project? This cannot be undone.')) return;
|
||||||
|
deleteProjectFromStorage(id);
|
||||||
|
setProjectIndex(loadProjectIndex());
|
||||||
|
deleteRemoteProject(id); // fire-and-forget
|
||||||
|
};
|
||||||
|
|
||||||
|
const pullLatestFromServer = async (id) => {
|
||||||
|
const result = await loadRemoteProject(id);
|
||||||
|
if (!result.ok) {
|
||||||
|
alert('Could not pull latest version from server.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveProjectToStorage(result.data);
|
||||||
|
setProjectIndex(loadProjectIndex());
|
||||||
|
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
staleLocalProjects,
|
||||||
|
autoRestoredCount,
|
||||||
|
resumeProject,
|
||||||
|
renameProjectInStorage,
|
||||||
|
deleteProject,
|
||||||
|
pullLatestFromServer,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// A simple "read this book in N weeks" plan, stored per browser.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { CHAPTER_COUNTS } from '../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
|
export function useReadingPlan() {
|
||||||
|
const [readingPlan, setReadingPlan] = useState(() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem('readingPlan') || 'null'); } catch { return null; }
|
||||||
|
});
|
||||||
|
|
||||||
|
const createReadingPlan = (bookAbbrev, bookName, weeks) => {
|
||||||
|
const totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? 1;
|
||||||
|
const plan = { bookAbbrev, bookName, totalChapters, startDate: Date.now(), targetDate: Date.now() + weeks * 7 * 24 * 60 * 60 * 1000, chaptersRead: [] };
|
||||||
|
setReadingPlan(plan);
|
||||||
|
try { localStorage.setItem('readingPlan', JSON.stringify(plan)); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const markChapterRead = (chapter) => {
|
||||||
|
setReadingPlan((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const chaptersRead = prev.chaptersRead.includes(chapter)
|
||||||
|
? prev.chaptersRead.filter((c) => c !== chapter)
|
||||||
|
: [...prev.chaptersRead, chapter].sort((a, b) => a - b);
|
||||||
|
const next = { ...prev, chaptersRead };
|
||||||
|
try { localStorage.setItem('readingPlan', JSON.stringify(next)); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearReadingPlan = () => {
|
||||||
|
setReadingPlan(null);
|
||||||
|
try { localStorage.removeItem('readingPlan'); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { readingPlan, createReadingPlan, markChapterRead, clearReadingPlan };
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Read-only share link for the open project: status, enable, revoke, copy.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { disableSharing, enableSharing, getShareStatus } from '../syncService.js';
|
||||||
|
|
||||||
|
export function useShareLink(projectId) {
|
||||||
|
const [shareToken, setShareToken] = useState(null);
|
||||||
|
const [shareBusy, setShareBusy] = useState(false);
|
||||||
|
const [shareCopied, setShareCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShareToken(null);
|
||||||
|
if (!projectId) return;
|
||||||
|
let active = true;
|
||||||
|
getShareStatus(projectId).then((result) => {
|
||||||
|
if (active && result.ok) setShareToken(result.data.shareToken);
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const shareUrl = shareToken
|
||||||
|
? `${window.location.origin}${window.location.pathname}?share=${shareToken}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const enable = async () => {
|
||||||
|
setShareBusy(true);
|
||||||
|
const result = await enableSharing(projectId);
|
||||||
|
setShareBusy(false);
|
||||||
|
if (result.ok) setShareToken(result.data.shareToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async () => {
|
||||||
|
if (!window.confirm('Revoke this share link? Anyone using it will lose access immediately.')) return;
|
||||||
|
setShareBusy(true);
|
||||||
|
const result = await disableSharing(projectId);
|
||||||
|
setShareBusy(false);
|
||||||
|
if (result.ok) setShareToken(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = () => {
|
||||||
|
if (!shareUrl) return;
|
||||||
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||||
|
setShareCopied(true);
|
||||||
|
window.setTimeout(() => setShareCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { shareToken, shareUrl, shareBusy, shareCopied, enable, revoke, copy };
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// A ?share=TOKEN URL shows a read-only view of someone else's project. No
|
||||||
|
// login is needed, so this runs independently of auth.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getSharedProject } from '../syncService.js';
|
||||||
|
|
||||||
|
export function useSharedView() {
|
||||||
|
const [token] = useState(() => new URLSearchParams(window.location.search).get('share'));
|
||||||
|
const [project, setProject] = useState(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
getSharedProject(token).then((result) => {
|
||||||
|
if (result.ok) setProject(result.data);
|
||||||
|
else setError(result.error ?? 'This share link is invalid or has been revoked.');
|
||||||
|
});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return { token, project, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Commentary and interlinear text for the chapter of the selected chunk.
|
||||||
|
// Each loads only while its section is expanded; results are cached for the visit.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const commentaryCache = {}; // "commentaryId/BOOK/chapter" -> data
|
||||||
|
const interlinearCache = {}; // BOOK -> whole-book interlinear data
|
||||||
|
|
||||||
|
export function useStudyResources({ project, selectedChunk, selectedChunkChapterIndex, collapsedSections }) {
|
||||||
|
const [commentarySource, setCommentarySource] = useState('matthew-henry');
|
||||||
|
const [commentaryData, setCommentaryData] = useState(null); // { content: [...] } for current chapter
|
||||||
|
const [commentaryLoading, setCommentaryLoading] = useState(false);
|
||||||
|
const [commentaryError, setCommentaryError] = useState('');
|
||||||
|
const [interlinearData, setInterlinearData] = useState(null);
|
||||||
|
const [interlinearLoading, setInterlinearLoading] = useState(false);
|
||||||
|
const [interlinearError, setInterlinearError] = useState('');
|
||||||
|
|
||||||
|
const loadInterlinearChapter = async (bookAbbrev, chapterNumber) => {
|
||||||
|
if (interlinearCache[bookAbbrev]) return interlinearCache[bookAbbrev];
|
||||||
|
const res = await fetch(`/interlinear/${bookAbbrev}.json`);
|
||||||
|
if (!res.ok) throw new Error('No interlinear data for this book.');
|
||||||
|
const data = await res.json();
|
||||||
|
interlinearCache[bookAbbrev] = data;
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCommentaryChapter = async (commentaryId, bookAbbrev, chapterNumber) => {
|
||||||
|
const cacheKey = `${commentaryId}/${bookAbbrev}/${chapterNumber}`;
|
||||||
|
if (commentaryCache[cacheKey]) return commentaryCache[cacheKey];
|
||||||
|
const res = await fetch(`https://bible.helloao.org/api/c/${commentaryId}/${bookAbbrev}/${chapterNumber}.json`);
|
||||||
|
const contentType = res.headers.get('content-type') || '';
|
||||||
|
if (!res.ok || !contentType.includes('json')) throw new Error(`No ${commentaryId} commentary for this chapter.`);
|
||||||
|
const data = await res.json();
|
||||||
|
commentaryCache[cacheKey] = data;
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (collapsedSections.commentary) return;
|
||||||
|
if (selectedChunkChapterIndex < 0) return;
|
||||||
|
const chapter = project.chapters[selectedChunkChapterIndex];
|
||||||
|
setCommentaryLoading(true);
|
||||||
|
setCommentaryError('');
|
||||||
|
loadCommentaryChapter(commentarySource, chapter.bookAbbrev, chapter.chapter)
|
||||||
|
.then((data) => setCommentaryData(data))
|
||||||
|
.catch((err) => {
|
||||||
|
setCommentaryData(null);
|
||||||
|
setCommentaryError(err.message);
|
||||||
|
})
|
||||||
|
.finally(() => setCommentaryLoading(false));
|
||||||
|
}, [commentarySource, selectedChunk?.id, selectedChunkChapterIndex, collapsedSections.commentary]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (collapsedSections.interlinear) return;
|
||||||
|
if (selectedChunkChapterIndex < 0) return;
|
||||||
|
const chapter = project.chapters[selectedChunkChapterIndex];
|
||||||
|
setInterlinearLoading(true);
|
||||||
|
setInterlinearError('');
|
||||||
|
loadInterlinearChapter(chapter.bookAbbrev, chapter.chapter)
|
||||||
|
.then((data) => setInterlinearData(data?.[String(chapter.chapter)] ?? null))
|
||||||
|
.catch((err) => {
|
||||||
|
setInterlinearData(null);
|
||||||
|
setInterlinearError(err.message);
|
||||||
|
})
|
||||||
|
.finally(() => setInterlinearLoading(false));
|
||||||
|
}, [selectedChunk?.id, selectedChunkChapterIndex, collapsedSections.interlinear]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
commentarySource, setCommentarySource,
|
||||||
|
commentaryData,
|
||||||
|
commentaryLoading,
|
||||||
|
commentaryError,
|
||||||
|
interlinearData,
|
||||||
|
interlinearLoading,
|
||||||
|
interlinearError,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
// Greek/Hebrew word studies on a chunk: word entries, Strong's lookups, and the
|
||||||
|
// "suggest words from this passage" picker.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
NT_BOOK_NUMBER,
|
||||||
|
chunkSpansNextChapter,
|
||||||
|
extractPartOfSpeech,
|
||||||
|
getChunkVerseEntries,
|
||||||
|
makeId,
|
||||||
|
} from '../utils/bibleUtils.jsx';
|
||||||
|
import {
|
||||||
|
ENGLISH_STOP_WORDS,
|
||||||
|
GREEK_FUNCTION_WORDS,
|
||||||
|
buildGreekDefinitionHtml,
|
||||||
|
fetchBollsDefinition,
|
||||||
|
loadGreekDict,
|
||||||
|
loadHebrewDict,
|
||||||
|
loadNtConcordance,
|
||||||
|
loadNtGloss,
|
||||||
|
shortHebrewGloss,
|
||||||
|
} from '../utils/wordStudy.js';
|
||||||
|
|
||||||
|
// notify(message, ms) shows a short status message on the study page.
|
||||||
|
export function useWordStudy({ project, allChunks, updateProject, notify }) {
|
||||||
|
const [suggestingGreekForChunkId, setSuggestingGreekForChunkId] = useState(null);
|
||||||
|
const [suggestingHebrewForChunkId, setSuggestingHebrewForChunkId] = useState(null);
|
||||||
|
// suggestModal: null | { chunkId, words: [{ strongKey, lexeme, translit, def }] }
|
||||||
|
const [suggestModal, setSuggestModal] = useState(null);
|
||||||
|
const [suggestSelection, setSuggestSelection] = useState(new Set());
|
||||||
|
|
||||||
|
const updateChunkWord = (chunkId, wordId, patch) => {
|
||||||
|
updateProject((current) => ({
|
||||||
|
...current,
|
||||||
|
chapters: current.chapters.map((ch) => ({
|
||||||
|
...ch,
|
||||||
|
chunks: ch.chunks.map((chunk) => {
|
||||||
|
if (chunk.id !== chunkId) return chunk;
|
||||||
|
return {
|
||||||
|
...chunk,
|
||||||
|
greekWords: chunk.greekWords.map((word) =>
|
||||||
|
word.id === wordId ? { ...word, ...patch } : word,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addGreekWord = (chunkId) => {
|
||||||
|
const newWord = {
|
||||||
|
id: makeId(),
|
||||||
|
query: '',
|
||||||
|
strongNumber: '',
|
||||||
|
englishGloss: '',
|
||||||
|
lexeme: '',
|
||||||
|
transliteration: '',
|
||||||
|
partOfSpeech: '',
|
||||||
|
shortDefinition: '',
|
||||||
|
definitionHtml: '',
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
updateProject((current) => ({
|
||||||
|
...current,
|
||||||
|
chapters: current.chapters.map((ch) => ({
|
||||||
|
...ch,
|
||||||
|
chunks: ch.chunks.map((chunk) =>
|
||||||
|
chunk.id === chunkId
|
||||||
|
? { ...chunk, greekWords: [...chunk.greekWords, newWord] }
|
||||||
|
: chunk,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGreekWord = (chunkId, wordId) => {
|
||||||
|
updateProject((current) => ({
|
||||||
|
...current,
|
||||||
|
chapters: current.chapters.map((ch) => ({
|
||||||
|
...ch,
|
||||||
|
chunks: ch.chunks.map((chunk) =>
|
||||||
|
chunk.id === chunkId
|
||||||
|
? { ...chunk, greekWords: chunk.greekWords.filter((w) => w.id !== wordId) }
|
||||||
|
: chunk,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestGreekWordsForChunk = async (chunkId) => {
|
||||||
|
const chunk = allChunks.find((c) => c.id === chunkId);
|
||||||
|
const chapterIndex = project?.chapters.findIndex((ch) =>
|
||||||
|
ch.chunks.some((c) => c.id === chunkId)
|
||||||
|
) ?? -1;
|
||||||
|
const chapter = chapterIndex >= 0 ? project.chapters[chapterIndex] : null;
|
||||||
|
if (!chunk || !chapter) return;
|
||||||
|
if (!NT_BOOK_NUMBER[chapter.bookAbbrev]) {
|
||||||
|
notify('Auto-suggest only works for New Testament books.', 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSuggestingGreekForChunkId(chunkId);
|
||||||
|
try {
|
||||||
|
const [concordance, dict, gloss] = await Promise.all([loadNtConcordance(), loadGreekDict(), loadNtGloss()]);
|
||||||
|
const bookData = concordance[chapter.bookAbbrev] ?? {};
|
||||||
|
const chapterData = bookData[chapter.chapter] ?? {};
|
||||||
|
const nextChapterData = chunkSpansNextChapter(project, chapterIndex, chunk)
|
||||||
|
? bookData[project.chapters[chapterIndex + 1].chapter] ?? {}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const strongsInRange = new Set();
|
||||||
|
for (let v = chunk.startVerse; v <= chunk.endVerse; v++) {
|
||||||
|
for (const s of chapterData[String(v)] ?? []) strongsInRange.add(s);
|
||||||
|
}
|
||||||
|
if (chunkSpansNextChapter(project, chapterIndex, chunk)) {
|
||||||
|
for (let v = 1; v <= chunk.spilloverEndVerse; v++) {
|
||||||
|
for (const s of nextChapterData[String(v)] ?? []) strongsInRange.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strongsInRange.size === 0) {
|
||||||
|
notify("No Strong's data found for this passage.", 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNumbers = new Set(
|
||||||
|
chunk.greekWords.map((w) => w.strongNumber.toUpperCase()).filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
const modalWords = [];
|
||||||
|
for (const strongKey of strongsInRange) {
|
||||||
|
if (existingNumbers.has(strongKey)) continue;
|
||||||
|
const entry = dict[strongKey];
|
||||||
|
modalWords.push({
|
||||||
|
strongKey,
|
||||||
|
lexeme: entry?.lemma ?? '',
|
||||||
|
translit: entry?.translit ?? '',
|
||||||
|
def: gloss[strongKey] ?? entry?.kjv_def ?? 'No definition found.',
|
||||||
|
entry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modalWords.length === 0) {
|
||||||
|
notify('All words from this passage are already added.', 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-select content words; pre-uncheck known function words
|
||||||
|
const preSelected = new Set(
|
||||||
|
modalWords
|
||||||
|
.filter((w) => !GREEK_FUNCTION_WORDS.has(w.strongKey))
|
||||||
|
.map((w) => w.strongKey)
|
||||||
|
);
|
||||||
|
setSuggestModal({
|
||||||
|
chunkId,
|
||||||
|
language: 'greek',
|
||||||
|
helperText: "Content words are pre-checked. Uncheck any you don't need.",
|
||||||
|
words: modalWords,
|
||||||
|
});
|
||||||
|
setSuggestSelection(preSelected);
|
||||||
|
} catch (err) {
|
||||||
|
notify(`Suggest failed: ${err.message}`, 3000);
|
||||||
|
} finally {
|
||||||
|
setSuggestingGreekForChunkId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestHebrewWordsForChunk = async (chunkId) => {
|
||||||
|
const chunk = allChunks.find((c) => c.id === chunkId);
|
||||||
|
const chapterIndex = project?.chapters.findIndex((ch) =>
|
||||||
|
ch.chunks.some((c) => c.id === chunkId)
|
||||||
|
) ?? -1;
|
||||||
|
const chapter = chapterIndex >= 0 ? project.chapters[chapterIndex] : null;
|
||||||
|
if (!chunk || !chapter) return;
|
||||||
|
if (NT_BOOK_NUMBER[chapter.bookAbbrev]) {
|
||||||
|
notify('Hebrew suggestions are for Old Testament passages.', 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuggestingHebrewForChunkId(chunkId);
|
||||||
|
try {
|
||||||
|
const dict = await loadHebrewDict();
|
||||||
|
const versesInChunk = getChunkVerseEntries(project, chapterIndex, chunk)
|
||||||
|
.map((v) => v.text)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
const candidateWords = Array.from(new Set(
|
||||||
|
versesInChunk
|
||||||
|
.replace(/[^a-z\s]/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((w) => w.trim())
|
||||||
|
.filter((w) => w.length >= 4 && !ENGLISH_STOP_WORDS.has(w)),
|
||||||
|
));
|
||||||
|
|
||||||
|
if (candidateWords.length === 0) {
|
||||||
|
notify('No Hebrew suggestion candidates found in this passage.', 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNumbers = new Set(
|
||||||
|
chunk.greekWords.map((w) => (w.strongNumber || '').toUpperCase()).filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const modalWords = [];
|
||||||
|
const entries = Object.entries(dict);
|
||||||
|
|
||||||
|
for (const token of candidateWords) {
|
||||||
|
const match = entries.find(([, entry]) => {
|
||||||
|
const defs = `${entry.kjv_def || ''} ${entry.strongs_def || ''}`.toLowerCase();
|
||||||
|
return defs.split(/[,;()\s]+/).includes(token);
|
||||||
|
});
|
||||||
|
if (!match) continue;
|
||||||
|
const [strongKey, entry] = match;
|
||||||
|
if (!/^H\d+$/i.test(strongKey)) continue;
|
||||||
|
const normalized = strongKey.toUpperCase();
|
||||||
|
if (existingNumbers.has(normalized) || seen.has(normalized)) continue;
|
||||||
|
seen.add(normalized);
|
||||||
|
modalWords.push({
|
||||||
|
strongKey: normalized,
|
||||||
|
lexeme: entry?.lemma ?? '',
|
||||||
|
translit: entry?.xlit ?? '',
|
||||||
|
def: shortHebrewGloss(entry?.kjv_def) || 'No definition found.',
|
||||||
|
fullDef: entry?.kjv_def ?? '',
|
||||||
|
entry,
|
||||||
|
});
|
||||||
|
if (modalWords.length >= 20) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modalWords.length === 0) {
|
||||||
|
notify('No Hebrew suggestions found from this passage text.', 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuggestModal({
|
||||||
|
chunkId,
|
||||||
|
language: 'hebrew',
|
||||||
|
helperText: 'Heuristic matches from passage text; uncheck anything not useful.',
|
||||||
|
words: modalWords,
|
||||||
|
});
|
||||||
|
setSuggestSelection(new Set(modalWords.map((w) => w.strongKey)));
|
||||||
|
} catch (err) {
|
||||||
|
notify(`Hebrew suggest failed: ${err.message}`, 3000);
|
||||||
|
} finally {
|
||||||
|
setSuggestingHebrewForChunkId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmSuggestWords = () => {
|
||||||
|
if (!suggestModal) return;
|
||||||
|
const { chunkId, words } = suggestModal;
|
||||||
|
const newWords = words
|
||||||
|
.filter((w) => suggestSelection.has(w.strongKey))
|
||||||
|
.map((w) => ({
|
||||||
|
id: makeId(),
|
||||||
|
query: w.strongKey,
|
||||||
|
strongNumber: w.strongKey,
|
||||||
|
englishGloss: w.def,
|
||||||
|
lexeme: w.lexeme,
|
||||||
|
transliteration: w.translit,
|
||||||
|
partOfSpeech: '',
|
||||||
|
shortDefinition: w.fullDef || w.def,
|
||||||
|
definitionHtml: w.entry ? buildGreekDefinitionHtml(w.strongKey, w.entry) : '',
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
if (newWords.length > 0) {
|
||||||
|
updateProject((current) => ({
|
||||||
|
...current,
|
||||||
|
chapters: current.chapters.map((ch) => ({
|
||||||
|
...ch,
|
||||||
|
chunks: ch.chunks.map((c) =>
|
||||||
|
c.id === chunkId ? { ...c, greekWords: [...c.greekWords, ...newWords] } : c
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
const label = suggestModal?.language === 'hebrew' ? 'Hebrew' : 'Greek';
|
||||||
|
notify(`Added ${newWords.length} ${label} word${newWords.length > 1 ? 's' : ''}.`, 2000);
|
||||||
|
}
|
||||||
|
setSuggestModal(null);
|
||||||
|
setSuggestSelection(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const lookupWord = async (chunkId, wordId, language = 'greek') => {
|
||||||
|
const chunk = allChunks.find((c) => c.id === chunkId);
|
||||||
|
const word = chunk?.greekWords.find((w) => w.id === wordId);
|
||||||
|
if (!word || !word.query.trim()) return;
|
||||||
|
updateChunkWord(chunkId, wordId, { loading: true });
|
||||||
|
try {
|
||||||
|
// Normalize bare numbers based on selected lookup language.
|
||||||
|
const raw = word.query.trim();
|
||||||
|
const inferredPrefix = language === 'hebrew' ? 'H' : 'G';
|
||||||
|
const normalized = /^\d+$/.test(raw)
|
||||||
|
? `${inferredPrefix}${raw}`
|
||||||
|
: /^[gGhH]\d+$/.test(raw)
|
||||||
|
? raw.toUpperCase()
|
||||||
|
: raw;
|
||||||
|
|
||||||
|
const [definitions, gloss] = await Promise.all([
|
||||||
|
fetchBollsDefinition(normalized),
|
||||||
|
loadNtGloss().catch(() => ({})),
|
||||||
|
]);
|
||||||
|
if (!definitions || definitions.length === 0) {
|
||||||
|
updateChunkWord(chunkId, wordId, {
|
||||||
|
strongNumber: '',
|
||||||
|
shortDefinition: 'No definition found.',
|
||||||
|
definitionHtml: '',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = definitions[0];
|
||||||
|
const strongKey = (first.topic || normalized).toUpperCase();
|
||||||
|
const extractedPartOfSpeech = extractPartOfSpeech(first.definition || '');
|
||||||
|
updateChunkWord(chunkId, wordId, {
|
||||||
|
strongNumber: first.topic || normalized,
|
||||||
|
englishGloss: gloss[strongKey] || first.short_definition || '',
|
||||||
|
lexeme: first.lexeme || '',
|
||||||
|
transliteration: first.transliteration || '',
|
||||||
|
partOfSpeech: extractedPartOfSpeech || word.partOfSpeech || '',
|
||||||
|
shortDefinition: first.short_definition || '',
|
||||||
|
definitionHtml: first.definition || '',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
updateChunkWord(chunkId, wordId, { shortDefinition: 'Lookup failed.' });
|
||||||
|
} finally {
|
||||||
|
updateChunkWord(chunkId, wordId, { loading: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestingGreekForChunkId,
|
||||||
|
suggestingHebrewForChunkId,
|
||||||
|
suggestModal, setSuggestModal,
|
||||||
|
suggestSelection, setSuggestSelection,
|
||||||
|
updateChunkWord,
|
||||||
|
addGreekWord,
|
||||||
|
removeGreekWord,
|
||||||
|
lookupWord,
|
||||||
|
suggestGreekWordsForChunk,
|
||||||
|
suggestHebrewWordsForChunk,
|
||||||
|
confirmSuggestWords,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import AuthStatus from '../components/AuthStatus.jsx';
|
||||||
import { buildExportHtml } from '../utils/bibleUtils.jsx';
|
import { buildExportHtml } from '../utils/bibleUtils.jsx';
|
||||||
import {
|
import {
|
||||||
adminListUsers,
|
adminListUsers,
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
} from '../syncService.js';
|
} from '../syncService.js';
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const { authUser, authStatus, goHome } = useApp();
|
const { authUser, goHome } = useApp();
|
||||||
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects'
|
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects'
|
||||||
const [adminUsers, setAdminUsers] = useState([]);
|
const [adminUsers, setAdminUsers] = useState([]);
|
||||||
const [adminProjects, setAdminProjects] = useState([]);
|
const [adminProjects, setAdminProjects] = useState([]);
|
||||||
@@ -82,7 +83,7 @@ export default function AdminPage() {
|
|||||||
>
|
>
|
||||||
← Back
|
← Back
|
||||||
</button>
|
</button>
|
||||||
{authStatus}
|
<AuthStatus />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
|
||||||
import { renderInkToCanvas } from '../utils/inkRender.js';
|
import { renderInkToCanvas } from '../utils/inkRender.js';
|
||||||
|
|
||||||
const INK_COLORS = ['#0f172a', '#ef4444', '#3b82f6', '#16a34a', '#f59e0b', '#a855f7'];
|
const INK_COLORS = ['#0f172a', '#ef4444', '#3b82f6', '#16a34a', '#f59e0b', '#a855f7'];
|
||||||
@@ -11,6 +10,9 @@ const INK_SIZES = [
|
|||||||
];
|
];
|
||||||
const PAGE_HEIGHT = 640; // px per notebook page
|
const PAGE_HEIGHT = 640; // px per notebook page
|
||||||
|
|
||||||
|
// The last pen settings, so toggling Draw mode off and on keeps your choice.
|
||||||
|
let lastPen = { tool: 'pen', color: INK_COLORS[0], size: INK_SIZES[0].value };
|
||||||
|
|
||||||
// Page count is persisted as a hidden metadata stroke at strokes[0]
|
// Page count is persisted as a hidden metadata stroke at strokes[0]
|
||||||
// so the canvas size survives navigation without a separate state key.
|
// so the canvas size survives navigation without a separate state key.
|
||||||
function extractMeta(strokes) {
|
function extractMeta(strokes) {
|
||||||
@@ -30,7 +32,10 @@ function packStrokes(realStrokes, pageCount) {
|
|||||||
// headerContent: optional JSX rendered above the notebook area.
|
// headerContent: optional JSX rendered above the notebook area.
|
||||||
// The canvas extends over it so you can draw directly on the content.
|
// The canvas extends over it so you can draw directly on the content.
|
||||||
export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerContent }) {
|
export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerContent }) {
|
||||||
const { drawTool, setDrawTool, drawColor, setDrawColor, drawSize, setDrawSize } = useApp();
|
const [drawTool, setDrawTool] = useState(lastPen.tool);
|
||||||
|
const [drawColor, setDrawColor] = useState(lastPen.color);
|
||||||
|
const [drawSize, setDrawSize] = useState(lastPen.size);
|
||||||
|
useEffect(() => { lastPen = { tool: drawTool, color: drawColor, size: drawSize }; }, [drawTool, drawColor, drawSize]);
|
||||||
|
|
||||||
const { pageCount: initPages } = extractMeta(strokes);
|
const { pageCount: initPages } = extractMeta(strokes);
|
||||||
const [pageCount, setPageCount] = useState(initPages);
|
const [pageCount, setPageCount] = useState(initPages);
|
||||||
|
|||||||
+56
-10
@@ -1,25 +1,26 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../utils/bibleUtils.jsx';
|
import AuthStatus from '../components/AuthStatus.jsx';
|
||||||
|
import {
|
||||||
|
bookOptions,
|
||||||
|
CHAPTER_COUNTS,
|
||||||
|
formatChunkReference,
|
||||||
|
formatRelativeDate,
|
||||||
|
loadProjectIndex,
|
||||||
|
} from '../utils/bibleUtils.jsx';
|
||||||
|
import { loadProjectById } from '../utils/projectStorage.js';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const {
|
const {
|
||||||
authStatus,
|
|
||||||
autoRestoredCount,
|
autoRestoredCount,
|
||||||
staleLocalProjects,
|
staleLocalProjects,
|
||||||
projectIndex,
|
projectIndex,
|
||||||
homeSearch, setHomeSearch,
|
|
||||||
homeSort, setHomeSort,
|
|
||||||
homeTagFilter, setHomeTagFilter,
|
|
||||||
homeFullTextResults,
|
|
||||||
readingPlan,
|
readingPlan,
|
||||||
createReadingPlan,
|
createReadingPlan,
|
||||||
clearReadingPlan,
|
clearReadingPlan,
|
||||||
audioBook, setAudioBook,
|
audioBook, setAudioBook,
|
||||||
audioNarrator, setAudioNarrator,
|
audioNarrator, setAudioNarrator,
|
||||||
audioState,
|
audioState,
|
||||||
renamingId, setRenamingId,
|
|
||||||
renameValue, setRenameValue,
|
|
||||||
openBibleReader,
|
openBibleReader,
|
||||||
openImportProject,
|
openImportProject,
|
||||||
openNewProject,
|
openNewProject,
|
||||||
@@ -31,6 +32,51 @@ export default function HomePage() {
|
|||||||
handleStopBookAudio,
|
handleStopBookAudio,
|
||||||
handleToggleBookAudioPause,
|
handleToggleBookAudioPause,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
const [homeSearch, setHomeSearch] = useState('');
|
||||||
|
const [homeSort, setHomeSort] = useState(() => {
|
||||||
|
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
|
||||||
|
}); // 'recent' | 'title' | 'passage'
|
||||||
|
const [homeTagFilter, setHomeTagFilter] = useState('');
|
||||||
|
const [homeFullTextResults, setHomeFullTextResults] = useState(null); // null=idle, []=no match, [...]= results
|
||||||
|
const fullTextTimerRef = useRef(null);
|
||||||
|
const [renamingId, setRenamingId] = useState(null);
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem('homeSort', homeSort); } catch {}
|
||||||
|
}, [homeSort]);
|
||||||
|
|
||||||
|
// Full-text search across all project notes (debounced, synchronous localStorage read)
|
||||||
|
useEffect(() => {
|
||||||
|
clearTimeout(fullTextTimerRef.current);
|
||||||
|
const q = homeSearch.trim().toLowerCase();
|
||||||
|
if (q.length < 3) { setHomeFullTextResults(null); return; }
|
||||||
|
fullTextTimerRef.current = window.setTimeout(() => {
|
||||||
|
const index = loadProjectIndex();
|
||||||
|
const results = [];
|
||||||
|
for (const entry of index) {
|
||||||
|
const proj = loadProjectById(entry.id);
|
||||||
|
if (!proj) continue;
|
||||||
|
const matches = [];
|
||||||
|
for (const [chIdx, ch] of (proj.chapters ?? []).entries()) {
|
||||||
|
for (const chunk of (ch.chunks ?? [])) {
|
||||||
|
for (const field of ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript']) {
|
||||||
|
const text = (chunk[field] ?? '').toLowerCase();
|
||||||
|
if (!text.includes(q)) continue;
|
||||||
|
const idx = text.indexOf(q);
|
||||||
|
const raw = chunk[field] ?? '';
|
||||||
|
const start = Math.max(0, idx - 40);
|
||||||
|
const snippet = (start > 0 ? '…' : '') + raw.slice(start, idx + q.length + 60) + (idx + q.length + 60 < raw.length ? '…' : '');
|
||||||
|
matches.push({ chunkId: chunk.id, ref: formatChunkReference(proj, chIdx, chunk, '–'), field, snippet });
|
||||||
|
break; // one match per chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches.length > 0) results.push({ projectId: entry.id, projectTitle: entry.title, matches });
|
||||||
|
}
|
||||||
|
setHomeFullTextResults(results);
|
||||||
|
}, 300);
|
||||||
|
}, [homeSearch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
@@ -63,7 +109,7 @@ export default function HomePage() {
|
|||||||
>
|
>
|
||||||
+ New Project
|
+ New Project
|
||||||
</button>
|
</button>
|
||||||
{authStatus}
|
<AuthStatus />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+148
-16
@@ -1,21 +1,153 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { bookOptions } from '../utils/bibleUtils.jsx';
|
import AuthStatus from '../components/AuthStatus.jsx';
|
||||||
|
import { useAvailableTranslations } from '../hooks/useAvailableTranslations.js';
|
||||||
|
import { bookOptions, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||||
|
import { parseEpisodeListDocx, parseEpisodePassage } from '../utils/importParsers.js';
|
||||||
|
|
||||||
export default function ImportPage() {
|
export default function ImportPage() {
|
||||||
const {
|
const { setCurrentPage, setProject } = useApp();
|
||||||
authStatus,
|
const availableTranslations = useAvailableTranslations();
|
||||||
setCurrentPage,
|
const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev);
|
||||||
availableTranslations,
|
const [importTranslation, setImportTranslation] = useState('BSB');
|
||||||
importBookAbbrev, setImportBookAbbrev,
|
const [importTitle, setImportTitle] = useState('');
|
||||||
importTranslation, setImportTranslation,
|
const [importPreview, setImportPreview] = useState(null); // parsed episode specs, before fetching chapters
|
||||||
importTitle, setImportTitle,
|
const [importBusy, setImportBusy] = useState(false);
|
||||||
importFile,
|
const [importError, setImportError] = useState('');
|
||||||
importPreview,
|
|
||||||
importBusy,
|
const handleImportFileChange = async (file) => {
|
||||||
importError,
|
setImportPreview(null);
|
||||||
handleImportFileChange,
|
setImportError('');
|
||||||
runEpisodeImport,
|
if (!file) return;
|
||||||
} = useApp();
|
setImportBusy(true);
|
||||||
|
try {
|
||||||
|
const episodes = await parseEpisodeListDocx(file);
|
||||||
|
if (episodes.length === 0) {
|
||||||
|
throw new Error('No "Session # / Title / Passage" table found in that document.');
|
||||||
|
}
|
||||||
|
const specs = episodes.map((ep) => ({ ...ep, parsed: parseEpisodePassage(ep.passage) }));
|
||||||
|
setImportPreview(specs);
|
||||||
|
} catch (error) {
|
||||||
|
setImportError(error.message || 'Failed to read that document.');
|
||||||
|
} finally {
|
||||||
|
setImportBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runEpisodeImport = async () => {
|
||||||
|
if (!importPreview || importPreview.length === 0) {
|
||||||
|
setImportError('Choose a .docx file first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImportBusy(true);
|
||||||
|
setImportError('');
|
||||||
|
try {
|
||||||
|
const selectedBook = bookOptions.find((b) => b.abbrev === importBookAbbrev) ?? bookOptions[0];
|
||||||
|
|
||||||
|
const chapterNumbersNeeded = new Set();
|
||||||
|
importPreview.forEach((spec) => {
|
||||||
|
if (spec.parsed && spec.parsed !== 'invalid') {
|
||||||
|
chapterNumbersNeeded.add(spec.parsed.startChapter);
|
||||||
|
chapterNumbersNeeded.add(spec.parsed.endChapter);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const sortedChapters = Array.from(chapterNumbersNeeded).sort((a, b) => a - b);
|
||||||
|
if (sortedChapters.length === 0) {
|
||||||
|
throw new Error('No verse passages found to import.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const versesByChapter = {};
|
||||||
|
for (const chNum of sortedChapters) {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://bible.helloao.org/api/${importTranslation}/${selectedBook.abbrev}/${chNum}.json`,
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`Unable to load ${selectedBook.name} ${chNum}.`);
|
||||||
|
const data = await response.json();
|
||||||
|
const verses = parseBibleChapter(data);
|
||||||
|
if (!verses.length) throw new Error(`No verses returned for ${selectedBook.name} ${chNum}.`);
|
||||||
|
versesByChapter[chNum] = verses;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapterIndexByNum = new Map(sortedChapters.map((n, idx) => [n, idx]));
|
||||||
|
const chapters = sortedChapters.map((chNum) => ({
|
||||||
|
book: selectedBook.name,
|
||||||
|
bookAbbrev: selectedBook.abbrev,
|
||||||
|
chapter: chNum,
|
||||||
|
verses: versesByChapter[chNum],
|
||||||
|
chunks: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Markers (rows with no passage) attach to the nearest upcoming chapter with a
|
||||||
|
// real passage, or fall back to the previous one for trailing markers.
|
||||||
|
const anchorChapterFor = importPreview.map((spec, i) => {
|
||||||
|
if (spec.parsed && spec.parsed !== 'invalid') return spec.parsed.startChapter;
|
||||||
|
for (let j = i + 1; j < importPreview.length; j += 1) {
|
||||||
|
const next = importPreview[j].parsed;
|
||||||
|
if (next && next !== 'invalid') return next.startChapter;
|
||||||
|
}
|
||||||
|
for (let j = i - 1; j >= 0; j -= 1) {
|
||||||
|
const prev = importPreview[j].parsed;
|
||||||
|
if (prev && prev !== 'invalid') return prev.startChapter;
|
||||||
|
}
|
||||||
|
return sortedChapters[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
importPreview.forEach((spec, i) => {
|
||||||
|
if (spec.parsed === 'invalid') return; // skip unparseable rows entirely
|
||||||
|
const chIdx = chapterIndexByNum.get(anchorChapterFor[i]);
|
||||||
|
if (chIdx == null) return;
|
||||||
|
const target = chapters[chIdx];
|
||||||
|
|
||||||
|
const baseChunk = {
|
||||||
|
id: makeId(),
|
||||||
|
observation: '',
|
||||||
|
interpretation: '',
|
||||||
|
application: '',
|
||||||
|
crossReferences: [],
|
||||||
|
greekWords: [],
|
||||||
|
generalNotes: '',
|
||||||
|
episodeNumber: spec.episodeNumber,
|
||||||
|
episodeTitle: spec.title,
|
||||||
|
finalScript: '',
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!spec.parsed) {
|
||||||
|
// No passage (e.g. a part intro/review) -> zero-length marker chunk.
|
||||||
|
target.chunks.push({ ...baseChunk, startVerse: 0, endVerse: 0, spilloverEndVerse: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startChapter, startVerse, endChapter, endVerse } = spec.parsed;
|
||||||
|
if (endChapter === startChapter) {
|
||||||
|
target.chunks.push({ ...baseChunk, startVerse, endVerse, spilloverEndVerse: null });
|
||||||
|
} else {
|
||||||
|
const lastVerseOfStartChapter = versesByChapter[startChapter]?.at(-1)?.number ?? endVerse;
|
||||||
|
target.chunks.push({
|
||||||
|
...baseChunk,
|
||||||
|
startVerse,
|
||||||
|
endVerse: lastVerseOfStartChapter,
|
||||||
|
spilloverEndVerse: endVerse,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const newProject = {
|
||||||
|
id: makeId(),
|
||||||
|
title: importTitle.trim() || `${selectedBook.name} Sessions`,
|
||||||
|
translation: importTranslation,
|
||||||
|
lastEdited: Date.now(),
|
||||||
|
selectedChunkId: null,
|
||||||
|
chapters,
|
||||||
|
};
|
||||||
|
setProject(newProject);
|
||||||
|
setCurrentPage('setup');
|
||||||
|
} catch (error) {
|
||||||
|
setImportError(error.message || 'Import failed.');
|
||||||
|
} finally {
|
||||||
|
setImportBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
@@ -33,7 +165,7 @@ export default function ImportPage() {
|
|||||||
>
|
>
|
||||||
← Back
|
← Back
|
||||||
</button>
|
</button>
|
||||||
{authStatus}
|
<AuthStatus />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { buildPronunciationGuide } from '../utils/bibleUtils.jsx';
|
import { buildPronunciationGuide } from '../utils/bibleUtils.jsx';
|
||||||
|
import { speakOriginalWord } from '../utils/wordStudy.js';
|
||||||
|
|
||||||
export default function PronunciationPage() {
|
export default function PronunciationPage() {
|
||||||
const { project, speakOriginalWord, setCurrentPage } = useApp();
|
const { project, setCurrentPage } = useApp();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
|
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { formatCrossRef, speakOriginalWord } from '../utils/wordStudy.js';
|
||||||
|
import AuthStatus from '../components/AuthStatus.jsx';
|
||||||
import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
|
import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
|
||||||
import DrawCanvas from './DrawCanvas.jsx';
|
import DrawCanvas from './DrawCanvas.jsx';
|
||||||
|
|
||||||
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
|
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
|
||||||
|
|
||||||
|
// Copies "Book 3:16 BSB — text" to the clipboard.
|
||||||
|
const copyVerse = (bookName, chapterNum, verseNum, text) => {
|
||||||
|
navigator.clipboard?.writeText(`${bookName} ${chapterNum}:${verseNum} BSB — ${text}`);
|
||||||
|
};
|
||||||
|
|
||||||
export default function ReaderPage() {
|
export default function ReaderPage() {
|
||||||
const {
|
const {
|
||||||
authStatus,
|
|
||||||
goHome,
|
goHome,
|
||||||
readerBookAbbrev,
|
readerBookAbbrev,
|
||||||
readerChapter, setReaderChapter,
|
readerChapter, setReaderChapter,
|
||||||
@@ -41,9 +47,6 @@ export default function ReaderPage() {
|
|||||||
handlePlayReaderAudio,
|
handlePlayReaderAudio,
|
||||||
handleToggleReaderAudioPause,
|
handleToggleReaderAudioPause,
|
||||||
handleStopBookAudio,
|
handleStopBookAudio,
|
||||||
speakOriginalWord,
|
|
||||||
copyVerse,
|
|
||||||
formatCrossRef,
|
|
||||||
setReaderCrossRefs,
|
setReaderCrossRefs,
|
||||||
readerInkByPage,
|
readerInkByPage,
|
||||||
updateReaderPageInk,
|
updateReaderPageInk,
|
||||||
@@ -270,7 +273,7 @@ export default function ReaderPage() {
|
|||||||
>
|
>
|
||||||
← Back to Studies
|
← Back to Studies
|
||||||
</button>
|
</button>
|
||||||
{authStatus}
|
<AuthStatus />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import AuthStatus from '../components/AuthStatus.jsx';
|
||||||
import {
|
import {
|
||||||
startMfaSetup,
|
startMfaSetup,
|
||||||
confirmMfaSetup,
|
confirmMfaSetup,
|
||||||
@@ -11,7 +12,6 @@ import {
|
|||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const {
|
const {
|
||||||
authUser, setAuthUser,
|
authUser, setAuthUser,
|
||||||
authStatus,
|
|
||||||
goHome,
|
goHome,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
|
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
|
||||||
@@ -123,7 +123,7 @@ export default function SettingsPage() {
|
|||||||
>
|
>
|
||||||
← Back
|
← Back
|
||||||
</button>
|
</button>
|
||||||
{authStatus}
|
<AuthStatus />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
|
import { useChunkBuilder } from '../hooks/useChunkBuilder.js';
|
||||||
|
import ProjectHeader from '../components/ProjectHeader.jsx';
|
||||||
|
import { useAvailableTranslations } from '../hooks/useAvailableTranslations.js';
|
||||||
import { bookOptions, formatChunkReference } from '../utils/bibleUtils.jsx';
|
import { bookOptions, formatChunkReference } from '../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter, errorMessage, hideTitle, onField, onTitleChange, onLoad }) {
|
function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter, errorMessage, hideTitle, onField, onTitleChange, onLoad }) {
|
||||||
@@ -79,11 +82,9 @@ function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter,
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SetupPage() {
|
export default function SetupPage() {
|
||||||
|
const { project, setProject, allChunks, updateProject, setCurrentPage } = useApp();
|
||||||
const {
|
const {
|
||||||
headerButtons,
|
|
||||||
project,
|
|
||||||
setup,
|
setup,
|
||||||
availableTranslations,
|
|
||||||
titleEdited, setTitleEdited,
|
titleEdited, setTitleEdited,
|
||||||
activeChapterIndex, setActiveChapterIndex,
|
activeChapterIndex, setActiveChapterIndex,
|
||||||
activeChapter,
|
activeChapter,
|
||||||
@@ -99,7 +100,6 @@ export default function SetupPage() {
|
|||||||
loadingChapter,
|
loadingChapter,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
statusMessage,
|
statusMessage,
|
||||||
allChunks,
|
|
||||||
handleSetupField,
|
handleSetupField,
|
||||||
handleLoadChapter,
|
handleLoadChapter,
|
||||||
beginStudying,
|
beginStudying,
|
||||||
@@ -109,8 +109,8 @@ export default function SetupPage() {
|
|||||||
addClickSpanChunk,
|
addClickSpanChunk,
|
||||||
moveChunk,
|
moveChunk,
|
||||||
deleteChunk,
|
deleteChunk,
|
||||||
updateProject,
|
} = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage });
|
||||||
} = useApp();
|
const availableTranslations = useAvailableTranslations();
|
||||||
|
|
||||||
const chapterTabs = project?.chapters ?? [];
|
const chapterTabs = project?.chapters ?? [];
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ export default function SetupPage() {
|
|||||||
{project ? project.title : 'New Study'}
|
{project ? project.title : 'New Study'}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
{headerButtons}
|
<ProjectHeader loading={loadingChapter} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
+160
-37
@@ -1,62 +1,180 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { STUDY_TABS, COMMENTARY_OPTIONS, NT_BOOK_NUMBER, formatChunkReference, CrossRefChip } from '../utils/bibleUtils.jsx';
|
import { useCrossRefs } from '../hooks/useCrossRefs.js';
|
||||||
|
import { useStudyResources } from '../hooks/useStudyResources.js';
|
||||||
|
import { useWordStudy } from '../hooks/useWordStudy.js';
|
||||||
|
import { buildGreekDefinitionHtml, externalLookupLinks, speakOriginalWord } from '../utils/wordStudy.js';
|
||||||
|
import ProjectHeader from '../components/ProjectHeader.jsx';
|
||||||
|
import {
|
||||||
|
STUDY_TABS,
|
||||||
|
COMMENTARY_OPTIONS,
|
||||||
|
NT_BOOK_NUMBER,
|
||||||
|
formatChunkReference,
|
||||||
|
getChunkVerseEntries,
|
||||||
|
CrossRefChip,
|
||||||
|
} from '../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
export default function StudyPage() {
|
export default function StudyPage() {
|
||||||
|
const { project, allChunks, projectIndex, updateProject, updateChunk } = useApp();
|
||||||
|
|
||||||
|
// Short-lived status line (word suggestions, DOCX import, lookups, …)
|
||||||
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
|
const statusTimerRef = useRef(null);
|
||||||
|
const notify = (message, ms = 2000) => {
|
||||||
|
window.clearTimeout(statusTimerRef.current);
|
||||||
|
setStatusMessage(message);
|
||||||
|
statusTimerRef.current = window.setTimeout(() => setStatusMessage(''), ms);
|
||||||
|
};
|
||||||
|
useEffect(() => () => window.clearTimeout(statusTimerRef.current), []);
|
||||||
|
|
||||||
|
const selectedChunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
|
||||||
|
const selectedChunkChapterIndex = selectedChunk
|
||||||
|
? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === selectedChunk.id))
|
||||||
|
: -1;
|
||||||
|
const selectedChunkChapter = selectedChunkChapterIndex >= 0
|
||||||
|
? project.chapters[selectedChunkChapterIndex]
|
||||||
|
: null;
|
||||||
|
const selectedChunkVerses = selectedChunk
|
||||||
|
? getChunkVerseEntries(project, selectedChunkChapterIndex, selectedChunk)
|
||||||
|
: [];
|
||||||
|
const selectedChunkGlobalIndex = allChunks.findIndex((c) => c.id === project?.selectedChunkId);
|
||||||
|
|
||||||
|
const [studyLayout, setStudyLayout] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('studyLayout');
|
||||||
|
return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed
|
||||||
|
}); // 'stacked' | 'split'
|
||||||
|
const [activeStudyTab, setActiveStudyTab] = useState(
|
||||||
|
() => localStorage.getItem('activeStudyTab') || 'notes',
|
||||||
|
);
|
||||||
|
const [mobileStudyTab, setMobileStudyTab] = useState('scripture');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('studyLayout', studyLayout);
|
||||||
|
}, [studyLayout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('activeStudyTab', activeStudyTab);
|
||||||
|
}, [activeStudyTab]);
|
||||||
|
const [collapsedSections, setCollapsedSections] = useState({});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
headerButtons,
|
|
||||||
project,
|
|
||||||
allChunks,
|
|
||||||
selectedChunk,
|
|
||||||
selectedChunkChapterIndex,
|
|
||||||
selectedChunkChapter,
|
|
||||||
selectedChunkVerses,
|
|
||||||
selectedChunkGlobalIndex,
|
|
||||||
studyLayout, setStudyLayout,
|
|
||||||
activeStudyTab, setActiveStudyTab,
|
|
||||||
mobileStudyTab, setMobileStudyTab,
|
|
||||||
collapsedSections, setCollapsedSections,
|
|
||||||
interlinearData,
|
|
||||||
interlinearLoading,
|
|
||||||
interlinearError,
|
|
||||||
commentarySource, setCommentarySource,
|
commentarySource, setCommentarySource,
|
||||||
commentaryData,
|
commentaryData,
|
||||||
commentaryLoading,
|
commentaryLoading,
|
||||||
commentaryError,
|
commentaryError,
|
||||||
|
interlinearData,
|
||||||
|
interlinearLoading,
|
||||||
|
interlinearError,
|
||||||
|
} = useStudyResources({ project, selectedChunk, selectedChunkChapterIndex, collapsedSections });
|
||||||
|
|
||||||
|
const {
|
||||||
crossRefInput, setCrossRefInput,
|
crossRefInput, setCrossRefInput,
|
||||||
suggestingCrossRefs,
|
suggestingCrossRefs,
|
||||||
crossRefSuggestions,
|
crossRefSuggestions,
|
||||||
tagInput, setTagInput,
|
|
||||||
suggestModal, setSuggestModal,
|
|
||||||
suggestSelection, setSuggestSelection,
|
|
||||||
suggestingGreekForChunkId,
|
|
||||||
suggestingHebrewForChunkId,
|
|
||||||
projectIndex,
|
|
||||||
goToPreviousChunk,
|
|
||||||
goToNextChunk,
|
|
||||||
updateProject,
|
|
||||||
updateChunk,
|
|
||||||
addCrossRef,
|
addCrossRef,
|
||||||
removeCrossRef,
|
removeCrossRef,
|
||||||
suggestCrossRefsForChunk,
|
suggestCrossRefsForChunk,
|
||||||
addSuggestedCrossRef,
|
addSuggestedCrossRef,
|
||||||
addTag,
|
loadVerseText,
|
||||||
removeTag,
|
} = useCrossRefs({ project, allChunks, selectedChunk, updateChunk, notify });
|
||||||
|
|
||||||
|
const {
|
||||||
|
suggestingGreekForChunkId,
|
||||||
|
suggestingHebrewForChunkId,
|
||||||
|
suggestModal, setSuggestModal,
|
||||||
|
suggestSelection, setSuggestSelection,
|
||||||
|
updateChunkWord,
|
||||||
addGreekWord,
|
addGreekWord,
|
||||||
removeGreekWord,
|
removeGreekWord,
|
||||||
updateChunkWord,
|
|
||||||
lookupWord,
|
lookupWord,
|
||||||
suggestGreekWordsForChunk,
|
suggestGreekWordsForChunk,
|
||||||
suggestHebrewWordsForChunk,
|
suggestHebrewWordsForChunk,
|
||||||
confirmSuggestWords,
|
confirmSuggestWords,
|
||||||
speakOriginalWord,
|
} = useWordStudy({ project, allChunks, updateProject, notify });
|
||||||
externalLookupLinks,
|
|
||||||
buildGreekDefinitionHtml,
|
const [tagInput, setTagInput] = useState('');
|
||||||
loadVerseText,
|
|
||||||
importFinalScriptDocx,
|
const addTag = (chunkId) => {
|
||||||
formatChunkReference: _ignored,
|
const tag = tagInput.trim();
|
||||||
} = useApp();
|
if (!tag) return;
|
||||||
|
const existing = selectedChunk?.tags ?? [];
|
||||||
|
if (existing.some((t) => t.toLowerCase() === tag.toLowerCase())) {
|
||||||
|
setTagInput('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateChunk(chunkId, { tags: [...existing, tag] });
|
||||||
|
setTagInput('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (chunkId, tag) => {
|
||||||
|
updateChunk(chunkId, {
|
||||||
|
tags: (selectedChunk?.tags ?? []).filter((t) => t !== tag),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTagInput('');
|
||||||
|
}, [selectedChunk?.id]);
|
||||||
|
|
||||||
|
const importFinalScriptDocx = async (chunkId, file) => {
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const { default: mammoth } = await import('mammoth');
|
||||||
|
const { value } = await mammoth.extractRawText({ arrayBuffer });
|
||||||
|
updateChunk(chunkId, { finalScript: value.trim() });
|
||||||
|
notify('Imported script from DOCX.', 1800);
|
||||||
|
} catch {
|
||||||
|
notify('Could not read that DOCX file.', 1800);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPreviousChunk = () => {
|
||||||
|
if (selectedChunkGlobalIndex <= 0) return;
|
||||||
|
updateProject((current) => ({
|
||||||
|
...current,
|
||||||
|
selectedChunkId: allChunks[selectedChunkGlobalIndex - 1].id,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNextChunk = () => {
|
||||||
|
if (selectedChunkGlobalIndex < 0 || selectedChunkGlobalIndex >= allChunks.length - 1) return;
|
||||||
|
updateProject((current) => ({
|
||||||
|
...current,
|
||||||
|
selectedChunkId: allChunks[selectedChunkGlobalIndex + 1].id,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcuts: arrow keys navigate chunks on the study page, Esc closes modals
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
const tag = event.target?.tagName;
|
||||||
|
const isEditable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable;
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (suggestModal) {
|
||||||
|
setSuggestModal(null);
|
||||||
|
setSuggestSelection(new Set());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditable || suggestModal) return;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
event.preventDefault();
|
||||||
|
goToPreviousChunk();
|
||||||
|
} else if (event.key === 'ArrowRight') {
|
||||||
|
event.preventDefault();
|
||||||
|
goToNextChunk();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [suggestModal, selectedChunkGlobalIndex, allChunks]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
@@ -71,7 +189,7 @@ export default function StudyPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{headerButtons}
|
<ProjectHeader />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -1000,6 +1118,11 @@ export default function StudyPage() {
|
|||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
{statusMessage && (
|
||||||
|
<div role="status" className="fixed bottom-20 left-1/2 z-40 -translate-x-1/2 rounded-full bg-slate-900 px-4 py-2 text-sm text-white shadow-lg">
|
||||||
|
{statusMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// File exports of a study project: HTML, Markdown, DOCX, and print-to-PDF.
|
||||||
|
import { buildExportHtml } from './bibleUtils.jsx';
|
||||||
|
import { buildMarkdownExport } from './exportBuilders.js';
|
||||||
|
import { buildChapterSummary } from './projectStorage.js';
|
||||||
|
|
||||||
|
function downloadBlob(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBase = (project) => `${project.title.replace(/\s+/g, '-')}-study`;
|
||||||
|
|
||||||
|
export function exportProjectHtml(project) {
|
||||||
|
const html = buildExportHtml(project);
|
||||||
|
downloadBlob(new Blob([html], { type: 'text/html;charset=utf-8' }), `${fileBase(project)}.html`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportProjectMarkdown(project) {
|
||||||
|
const markdown = buildMarkdownExport(project);
|
||||||
|
downloadBlob(new Blob([markdown], { type: 'text/markdown;charset=utf-8' }), `${fileBase(project)}.md`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportProjectDocx(project) {
|
||||||
|
// docx is large, so it's only downloaded when someone actually exports.
|
||||||
|
const { buildStudyDocxBlob } = await import('./docxExport.js');
|
||||||
|
const blob = await buildStudyDocxBlob(project, buildChapterSummary(project));
|
||||||
|
downloadBlob(blob, `${fileBase(project)}.docx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printProjectPdf(project) {
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// Word-study helpers: Strong's dictionaries, lexicon lookups, and speech.
|
||||||
|
// Dictionaries are cached at module level so they download once per visit,
|
||||||
|
// no matter how often the study page is opened.
|
||||||
|
import { bookOptions } from './bibleUtils.jsx';
|
||||||
|
|
||||||
|
// Loads a JSON-ish resource once. Concurrent callers share one request; a
|
||||||
|
// failed request is forgotten so the next call can retry.
|
||||||
|
function cachedLoader(load) {
|
||||||
|
let value = null;
|
||||||
|
let pending = null;
|
||||||
|
const get = () => {
|
||||||
|
if (value) return Promise.resolve(value);
|
||||||
|
if (!pending) {
|
||||||
|
pending = load()
|
||||||
|
.then((data) => { value = data; return data; })
|
||||||
|
.finally(() => { pending = null; });
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
get.reset = () => { value = null; pending = null; };
|
||||||
|
return get;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The OpenScriptures dictionaries are JS files wrapping one big object literal.
|
||||||
|
const parseStrongsJs = (text) => JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1));
|
||||||
|
|
||||||
|
// NT Strong's→English gloss map (from the macula-greek dataset)
|
||||||
|
export const loadNtGloss = cachedLoader(() => fetch('/nt-strongs-gloss.json').then((res) => res.json()));
|
||||||
|
// NT Strong's concordance (verse → Strong's numbers)
|
||||||
|
export const loadNtConcordance = cachedLoader(() => fetch('/nt-strongs-concordance.json').then((res) => res.json()));
|
||||||
|
export const loadHebrewDict = cachedLoader(() =>
|
||||||
|
fetch('https://cdn.jsdelivr.net/gh/openscriptures/strongs@master/hebrew/strongs-hebrew-dictionary.js')
|
||||||
|
.then((res) => res.text())
|
||||||
|
.then(parseStrongsJs));
|
||||||
|
// Greek Strong's dictionary (~1.2 MB)
|
||||||
|
export const loadGreekDict = cachedLoader(() =>
|
||||||
|
fetch('https://cdn.jsdelivr.net/gh/openscriptures/strongs@master/greek/strongs-greek-dictionary.js')
|
||||||
|
.then((res) => res.text())
|
||||||
|
.then(parseStrongsJs));
|
||||||
|
|
||||||
|
// Tests mock fetch differently per case, so they need a clean cache each time.
|
||||||
|
export function resetLexiconCaches() {
|
||||||
|
[loadNtGloss, loadNtConcordance, loadHebrewDict, loadGreekDict].forEach((load) => load.reset());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common Greek NT function words to pre-uncheck in the picker
|
||||||
|
// (articles, prepositions, conjunctions, particles, common pronouns)
|
||||||
|
export const GREEK_FUNCTION_WORDS = new Set([
|
||||||
|
'G3588','G3739','G3748', // ὁ (the), ὅς (who/which), ὅστις
|
||||||
|
'G2532','G1161','G1063','G235','G3767','G2443','G5037','G3303', // καί, δέ, γάρ, ἀλλά, οὖν, ἵνα, τέ, μέν
|
||||||
|
'G1487','G1437','G3754','G5613','G2531','G5618', // εἰ, ἐάν, ὅτι, ὡς, καθώς, ὥσπερ
|
||||||
|
'G1722','G1519','G1537','G575','G4314','G3326','G2596','G1223', // ἐν, εἰς, ἐκ, ἀπό, πρός, μετά, κατά, διά
|
||||||
|
'G1909','G5228','G5259','G4862','G3844','G4253','G4012','G1722', // ἐπί, ὑπέρ, ὑπό, σύν, παρά, πρό, περί
|
||||||
|
'G3756','G3361','G3780','G3762','G3763', // οὐ, μή, οὐχί, οὐδείς, οὐδέποτε
|
||||||
|
'G846','G1473','G4771','G2249','G5210', // αὐτός, ἐγώ, σύ, ἡμεῖς, ὑμεῖς
|
||||||
|
'G3778','G1565','G3588', // οὗτος, ἐκεῖνος
|
||||||
|
'G5100','G5101','G3956','G3745','G3748', // τις, τίς, πᾶς, ὅσος
|
||||||
|
'G3779','G3568','G5119','G1161','G3767', // οὕτως, νῦν, τότε
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Extract a single short English gloss from a verbose kjv_def string.
|
||||||
|
// e.g. "[idiom] burn (incense), be mindful..." → "burn"
|
||||||
|
// "another, [idiom] (blood-) thirsty..." → "another"
|
||||||
|
export const shortHebrewGloss = (kjvDef) => {
|
||||||
|
if (!kjvDef) return '';
|
||||||
|
let s = kjvDef.trim();
|
||||||
|
// strip leading [tag] tokens like [idiom], [phrase]
|
||||||
|
s = s.replace(/^(\[[^\]]+\]\s*)+/, '').trim();
|
||||||
|
// take first chunk before comma, semicolon, or opening paren
|
||||||
|
s = s.split(/[,;(]/)[0].trim();
|
||||||
|
// strip trailing punctuation
|
||||||
|
s = s.replace(/[.\-]+$/, '').trim();
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENGLISH_STOP_WORDS = new Set([
|
||||||
|
'the', 'and', 'for', 'that', 'with', 'this', 'from', 'were', 'was', 'have', 'has',
|
||||||
|
'had', 'are', 'but', 'not', 'you', 'your', 'his', 'her', 'their', 'they', 'them',
|
||||||
|
'our', 'out', 'into', 'over', 'under', 'upon', 'then', 'than', 'who', 'what', 'when',
|
||||||
|
'where', 'why', 'how', 'also', 'there', 'here', 'all', 'any', 'one', 'two', 'three',
|
||||||
|
'he', 'she', 'it', 'we', 'i', 'me', 'my', 'mine', 'ours', 'theirs', 'its',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const isGreekStrongNumber = (query) => /^G\d+$/i.test(query.trim());
|
||||||
|
export const isHebrewStrongNumber = (query) => /^H\d+$/i.test(query.trim());
|
||||||
|
|
||||||
|
export const externalLookupLinks = (query) => {
|
||||||
|
const raw = query.trim();
|
||||||
|
const normalized = raw.toUpperCase();
|
||||||
|
const isGreek = /^G\d+$/.test(normalized);
|
||||||
|
const isHebrew = /^H\d+$/.test(normalized);
|
||||||
|
const num = (isGreek || isHebrew) ? normalized.slice(1) : null;
|
||||||
|
if (isGreek && num) {
|
||||||
|
return [
|
||||||
|
{ label: 'BibleHub', url: `https://biblehub.com/greek/${num}.htm` },
|
||||||
|
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/lexicon/g${num}/esv/0-1/` },
|
||||||
|
{ label: 'StudyLight', url: `https://www.studylight.org/lexicons/eng/greek/${num}.html` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (isHebrew && num) {
|
||||||
|
return [
|
||||||
|
{ label: 'BibleHub', url: `https://biblehub.com/hebrew/${num}.htm` },
|
||||||
|
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/lexicon/h${num}/kjv/wlc/0-1/` },
|
||||||
|
{ label: 'StudyLight', url: `https://www.studylight.org/lexicons/eng/hebrew/${num}.html` },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const enc = encodeURIComponent(raw);
|
||||||
|
return [
|
||||||
|
{ label: 'BibleHub', url: `https://biblehub.com/search.php?q=${enc}` },
|
||||||
|
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/search/search.cfm?Criteria=${enc}&t=KJV#s=s_lexiconc` },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildGreekDefinitionHtml = (key, entry) => {
|
||||||
|
let html = '';
|
||||||
|
if (entry.strongs_def) html += `<p>${entry.strongs_def.trim()}</p>`;
|
||||||
|
if (entry.derivation) html += `<p><em>Derivation:</em> ${entry.derivation}</p>`;
|
||||||
|
if (entry.kjv_def) html += `<p><em>KJV uses:</em> ${entry.kjv_def}</p>`;
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchBollsDefinition = async (query) => {
|
||||||
|
const encoded = encodeURIComponent(query.trim());
|
||||||
|
|
||||||
|
if (isHebrewStrongNumber(query)) {
|
||||||
|
const response = await fetch(`https://bolls.life/dictionary-definition/BDBT/${encoded}/`);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
let defs;
|
||||||
|
try { defs = await response.json(); } catch { return null; }
|
||||||
|
return Array.isArray(defs) && defs.length > 0 ? defs : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Greek — look up in the cached OpenScriptures Strong's dictionary.
|
||||||
|
const dict = await loadGreekDict();
|
||||||
|
const key = isGreekStrongNumber(query) ? query.trim().toUpperCase() : null;
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
const entry = dict[key];
|
||||||
|
if (!entry) return null;
|
||||||
|
return [{
|
||||||
|
topic: key,
|
||||||
|
lexeme: entry.lemma || '',
|
||||||
|
transliteration: entry.translit || '',
|
||||||
|
short_definition: entry.kjv_def || '',
|
||||||
|
definition: buildGreekDefinitionHtml(key, entry),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// English word search — scan kjv_def and strongs_def for the query term.
|
||||||
|
const lowerQ = query.trim().toLowerCase();
|
||||||
|
const match = Object.entries(dict).find(([, entry]) =>
|
||||||
|
(entry.kjv_def || '').toLowerCase().split(/[,\s]+/).some((w) => w === lowerQ) ||
|
||||||
|
(entry.strongs_def || '').toLowerCase().includes(lowerQ),
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
const [matchKey, entry] = match;
|
||||||
|
return [{
|
||||||
|
topic: matchKey,
|
||||||
|
lexeme: entry.lemma || '',
|
||||||
|
transliteration: entry.translit || '',
|
||||||
|
short_definition: entry.kjv_def || '',
|
||||||
|
definition: buildGreekDefinitionHtml(matchKey, entry),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatCrossRef = (ref) => {
|
||||||
|
const book = bookOptions.find((b) => b.abbrev === ref.book);
|
||||||
|
const bookName = book?.name ?? ref.book;
|
||||||
|
const verseRange = ref.endVerse && ref.endVerse !== ref.verse
|
||||||
|
? `${ref.verse}-${ref.endVerse}`
|
||||||
|
: `${ref.verse}`;
|
||||||
|
return `${bookName} ${ref.chapter}:${verseRange}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const speakOriginalWord = (word, strongsNum) => {
|
||||||
|
if (!window.speechSynthesis) return;
|
||||||
|
if (window.speechSynthesis.speaking) window.speechSynthesis.cancel();
|
||||||
|
const lang = strongsNum?.startsWith('H') ? 'he-IL' : 'el-GR';
|
||||||
|
const voices = window.speechSynthesis.getVoices();
|
||||||
|
const match = voices.find((v) => v.lang.startsWith(lang.split('-')[0]));
|
||||||
|
const utt = new SpeechSynthesisUtterance(word + '.');
|
||||||
|
utt.lang = lang;
|
||||||
|
utt.rate = 0.85;
|
||||||
|
if (match) utt.voice = match;
|
||||||
|
// Chain a silent tail so the audio session fades rather than clicks shut
|
||||||
|
utt.onend = () => {
|
||||||
|
const tail = new SpeechSynthesisUtterance(' ');
|
||||||
|
tail.volume = 0;
|
||||||
|
tail.lang = lang;
|
||||||
|
if (match) tail.voice = match;
|
||||||
|
window.speechSynthesis.speak(tail);
|
||||||
|
};
|
||||||
|
window.speechSynthesis.speak(utt);
|
||||||
|
};
|
||||||
Reference in new issue
Block a user