Compare commits
10
Commits
692595c629
...
2106595976
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2106595976 | ||
|
|
902fb5da02 | ||
|
|
837b81b038 | ||
|
|
708f293939 | ||
|
|
f2de985190 | ||
|
|
90fd5c4e9e | ||
|
|
22ad12e677 | ||
|
|
b4ccc15f98 | ||
|
|
1a586db785 | ||
|
|
5668686f59 |
No files matched your search
@@ -8,6 +8,13 @@
|
|||||||
"port": 5173,
|
"port": 5173,
|
||||||
"autoPort": false
|
"autoPort": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "study-app-alt",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--", "--port", "5174"],
|
||||||
|
"port": 5174,
|
||||||
|
"autoPort": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "study-app-server",
|
"name": "study-app-server",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
|
|||||||
@@ -22,10 +22,8 @@ _Refreshed 2026-07-06 (multiple passes) — items already shipped have been remo
|
|||||||
## UX / UI
|
## UX / UI
|
||||||
|
|
||||||
### Study Page
|
### Study Page
|
||||||
- **Sticky bottom nav** — top Prev/Next chunk nav shipped (commit `103e20c`); a matching sticky bottom bar for long chunks would avoid scroll-back
|
|
||||||
|
|
||||||
### Reader
|
### Reader
|
||||||
- **Whole-Bible search index isn't persisted** — the ~7MB `complete.json` fetch is cached in-memory only for the session; a page reload re-downloads it. Worth persisting to IndexedDB (not localStorage — too small) if this gets used often
|
|
||||||
- **Bookmark color picker is still an emoji button** (🎨) — the bookmark/copy icons became proper SVGs, but color-cycling didn't get the same treatment
|
- **Bookmark color picker is still an emoji button** (🎨) — the bookmark/copy icons became proper SVGs, but color-cycling didn't get the same treatment
|
||||||
|
|
||||||
### Home Page
|
### Home Page
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<!-- iOS PWA -->
|
<!-- iOS PWA -->
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Bible Study" />
|
<meta name="apple-mobile-web-app-title" content="Bible Study" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ export function initDb() {
|
|||||||
}
|
}
|
||||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
|
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
|
||||||
|
|
||||||
|
// Reader ink — one row per user + book + chapter, stored as JSON
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS reader_ink (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
book_abbrev TEXT NOT NULL,
|
||||||
|
chapter INTEGER NOT NULL,
|
||||||
|
strokes TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, book_abbrev, chapter)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
console.log(`SQLite database ready at ${DB_PATH}`);
|
console.log(`SQLite database ready at ${DB_PATH}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +158,30 @@ export function setBackupCodeHashes(userId, backupCodeHashes) {
|
|||||||
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
|
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reader ink — cross-device sync for draw-mode annotations in the reader
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Returns all saved reader ink pages for a user as { "BOOK_CH": strokes[] }. */
|
||||||
|
export function getAllReaderInk(userId) {
|
||||||
|
const rows = db.prepare(
|
||||||
|
'SELECT book_abbrev AS book, chapter, strokes FROM reader_ink WHERE user_id = ?'
|
||||||
|
).all(userId);
|
||||||
|
return Object.fromEntries(
|
||||||
|
rows.map((r) => [`${r.book}_${r.chapter}`, JSON.parse(r.strokes)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upserts the ink strokes for one reader page. */
|
||||||
|
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO reader_ink (user_id, book_abbrev, chapter, strokes, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, book_abbrev, chapter)
|
||||||
|
DO UPDATE SET strokes = excluded.strokes, updated_at = excluded.updated_at
|
||||||
|
`).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assigns any pre-existing, unowned projects (from before multi-user support)
|
* Assigns any pre-existing, unowned projects (from before multi-user support)
|
||||||
* to the given user. Intended to run once, right after the first account is created.
|
* to the given user. Intended to run once, right after the first account is created.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||||
setUserPassword, destroyAllSessionsForUser,
|
setUserPassword, destroyAllSessionsForUser,
|
||||||
|
getAllReaderInk, setReaderInkPage,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { SqliteSessionStore } from './sessionStore.js';
|
import { SqliteSessionStore } from './sessionStore.js';
|
||||||
import {
|
import {
|
||||||
@@ -359,6 +360,34 @@ app.delete('/api/projects/:id', requireAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reader ink — cross-device draw annotations on Bible chapters
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GET /api/reader/ink — all saved ink pages for the current user
|
||||||
|
app.get('/api/reader/ink', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(getAllReaderInk(req.session.userId));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GET /api/reader/ink error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load reader ink.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/reader/ink/:book/:chapter — save (upsert) one page's ink
|
||||||
|
app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { book, chapter } = req.params;
|
||||||
|
const { strokes } = req.body;
|
||||||
|
if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' });
|
||||||
|
setReaderInkPage(req.session.userId, book, chapter, strokes);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PUT /api/reader/ink error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to save reader ink.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Read-only share links
|
// Read-only share links
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+202
-9
@@ -71,6 +71,18 @@ export const STUDY_TABS = [
|
|||||||
{ id: 'script', label: 'Script' },
|
{ id: 'script', label: 'Script' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const CHAPTER_COUNTS = {
|
||||||
|
GEN:50,EXO:40,LEV:27,NUM:36,DEU:34,JOS:24,JDG:21,RUT:4,
|
||||||
|
'1SA':31,'2SA':24,'1KI':22,'2KI':25,'1CH':29,'2CH':36,
|
||||||
|
EZR:10,NEH:13,EST:10,JOB:42,PSA:150,PRO:31,ECC:12,SNG:8,
|
||||||
|
ISA:66,JER:52,LAM:5,EZK:48,DAN:12,HOS:14,JOL:3,AMO:9,
|
||||||
|
OBA:1,JON:4,MIC:7,NAM:3,HAB:3,ZEP:3,HAG:2,ZEC:14,MAL:4,
|
||||||
|
MAT:28,MRK:16,LUK:24,JHN:21,ACT:28,ROM:16,'1CO':16,'2CO':13,
|
||||||
|
GAL:6,EPH:6,PHP:4,COL:4,'1TH':5,'2TH':3,'1TI':6,'2TI':4,
|
||||||
|
TIT:3,PHM:1,HEB:13,JAS:5,'1PE':5,'2PE':3,'1JN':5,'2JN':1,
|
||||||
|
'3JN':1,JUD:1,REV:22,
|
||||||
|
};
|
||||||
|
|
||||||
export const bookOptions = [
|
export const bookOptions = [
|
||||||
{ name: 'Genesis', abbrev: 'GEN' },
|
{ name: 'Genesis', abbrev: 'GEN' },
|
||||||
{ name: 'Exodus', abbrev: 'EXO' },
|
{ name: 'Exodus', abbrev: 'EXO' },
|
||||||
@@ -1072,6 +1084,56 @@ export function CrossRefChip({ label, onRemove, loadVerseText }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IndexedDB helpers for whole-Bible search cache
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const BIBLE_IDB_NAME = 'study-app-bible-index';
|
||||||
|
const BIBLE_IDB_STORE = 'bible-index';
|
||||||
|
const BIBLE_IDB_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
|
function openBibleIndexDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(BIBLE_IDB_NAME, 1);
|
||||||
|
req.onupgradeneeded = (e) => {
|
||||||
|
const db = e.target.result;
|
||||||
|
if (!db.objectStoreNames.contains(BIBLE_IDB_STORE)) {
|
||||||
|
db.createObjectStore(BIBLE_IDB_STORE, { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onsuccess = (e) => resolve(e.target.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBibleIndexFromIDB(translation) {
|
||||||
|
try {
|
||||||
|
const db = await openBibleIndexDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(BIBLE_IDB_STORE, 'readonly');
|
||||||
|
const req = tx.objectStore(BIBLE_IDB_STORE).get(translation);
|
||||||
|
req.onsuccess = () => resolve(req.result ?? null);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBibleIndexToIDB(translation, flat) {
|
||||||
|
try {
|
||||||
|
const db = await openBibleIndexDB();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(BIBLE_IDB_STORE, 'readwrite');
|
||||||
|
const req = tx.objectStore(BIBLE_IDB_STORE).put({ id: translation, flat, cachedAt: Date.now() });
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Non-fatal; in-memory cache still works for this session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App component
|
// App component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1157,6 +1219,11 @@ const App = () => {
|
|||||||
const [homeSearch, setHomeSearch] = useState('');
|
const [homeSearch, setHomeSearch] = useState('');
|
||||||
const [homeSort, setHomeSort] = useState('recent'); // 'recent' | 'title' | 'passage'
|
const [homeSort, setHomeSort] = useState('recent'); // 'recent' | 'title' | 'passage'
|
||||||
const [homeTagFilter, setHomeTagFilter] = useState('');
|
const [homeTagFilter, setHomeTagFilter] = useState('');
|
||||||
|
const [homeFullTextResults, setHomeFullTextResults] = useState(null); // null=idle, []=no match, [...]= results
|
||||||
|
const fullTextTimerRef = useRef(null);
|
||||||
|
const [readingPlan, setReadingPlan] = useState(() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem('readingPlan') || 'null'); } catch { return null; }
|
||||||
|
});
|
||||||
const [renamingId, setRenamingId] = useState(null);
|
const [renamingId, setRenamingId] = useState(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev);
|
const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev);
|
||||||
@@ -1199,6 +1266,14 @@ const App = () => {
|
|||||||
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
|
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
|
||||||
catch { return {}; }
|
catch { return {}; }
|
||||||
});
|
});
|
||||||
|
const [readerTextHighlights, setReaderTextHighlights] = useState(() => {
|
||||||
|
try {
|
||||||
|
const val = JSON.parse(localStorage.getItem('readerTextHighlights') || '[]');
|
||||||
|
return Array.isArray(val) ? val : [];
|
||||||
|
} catch { return []; }
|
||||||
|
});
|
||||||
|
// Debounce timer ref so rapid stroke updates don't flood the server
|
||||||
|
const inkSaveTimerRef = useRef({});
|
||||||
const audioRef = useRef(null);
|
const audioRef = useRef(null);
|
||||||
const audioModeRef = useRef('book'); // 'book' | 'reader' — which player owns audioRef
|
const audioModeRef = useRef('book'); // 'book' | 'reader' — which player owns audioRef
|
||||||
const audioBookRef = useRef(audioBook);
|
const audioBookRef = useRef(audioBook);
|
||||||
@@ -1319,7 +1394,7 @@ const App = () => {
|
|||||||
); // 'stacked' | 'split' | 'annotate'
|
); // 'stacked' | 'split' | 'annotate'
|
||||||
const [drawTool, setDrawTool] = useState('pen');
|
const [drawTool, setDrawTool] = useState('pen');
|
||||||
const [drawColor, setDrawColor] = useState('#0f172a');
|
const [drawColor, setDrawColor] = useState('#0f172a');
|
||||||
const [drawSize, setDrawSize] = useState(0.012);
|
const [drawSize, setDrawSize] = useState(0.003);
|
||||||
const [activeStudyTab, setActiveStudyTab] = useState(
|
const [activeStudyTab, setActiveStudyTab] = useState(
|
||||||
() => localStorage.getItem('activeStudyTab') || 'notes',
|
() => localStorage.getItem('activeStudyTab') || 'notes',
|
||||||
);
|
);
|
||||||
@@ -1332,6 +1407,39 @@ const App = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('activeStudyTab', activeStudyTab);
|
localStorage.setItem('activeStudyTab', activeStudyTab);
|
||||||
}, [activeStudyTab]);
|
}, [activeStudyTab]);
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
const [verseSearch, setVerseSearch] = useState('');
|
const [verseSearch, setVerseSearch] = useState('');
|
||||||
const [collapsedSections, setCollapsedSections] = useState({});
|
const [collapsedSections, setCollapsedSections] = useState({});
|
||||||
const [commentarySource, setCommentarySource] = useState('matthew-henry');
|
const [commentarySource, setCommentarySource] = useState('matthew-henry');
|
||||||
@@ -1380,6 +1488,14 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
setBibleIndexStatus('loading');
|
setBibleIndexStatus('loading');
|
||||||
try {
|
try {
|
||||||
|
// Check IndexedDB cache before hitting the network (~7 MB fetch)
|
||||||
|
const cached = await getBibleIndexFromIDB('BSB');
|
||||||
|
if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
|
||||||
|
_bibleIndexCacheRef.current.BSB = cached.flat;
|
||||||
|
setBibleIndexStatus('ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch('https://bible.helloao.org/api/BSB/complete.json');
|
const res = await fetch('https://bible.helloao.org/api/BSB/complete.json');
|
||||||
if (!res.ok) throw new Error('Unable to load the full Bible for search.');
|
if (!res.ok) throw new Error('Unable to load the full Bible for search.');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -1396,6 +1512,8 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
_bibleIndexCacheRef.current.BSB = flat;
|
_bibleIndexCacheRef.current.BSB = flat;
|
||||||
setBibleIndexStatus('ready');
|
setBibleIndexStatus('ready');
|
||||||
|
// Persist for future visits — non-blocking
|
||||||
|
saveBibleIndexToIDB('BSB', flat);
|
||||||
} catch {
|
} catch {
|
||||||
setBibleIndexStatus('error');
|
setBibleIndexStatus('error');
|
||||||
}
|
}
|
||||||
@@ -1558,6 +1676,15 @@ const App = () => {
|
|||||||
try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {}
|
try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
// Debounce server sync — wait 1 s after last stroke before sending
|
||||||
|
clearTimeout(inkSaveTimerRef.current[key]);
|
||||||
|
inkSaveTimerRef.current[key] = setTimeout(() => {
|
||||||
|
fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ strokes }),
|
||||||
|
}).catch(() => {}); // silent — localStorage already saved locally
|
||||||
|
}, 1000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cycleBookmarkColor = (verseKey) => {
|
const cycleBookmarkColor = (verseKey) => {
|
||||||
@@ -1571,6 +1698,23 @@ const App = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addTextHighlight = (highlight) => {
|
||||||
|
const id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||||
|
setReaderTextHighlights(prev => {
|
||||||
|
const next = [...prev, { id, ...highlight }];
|
||||||
|
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTextHighlight = (id) => {
|
||||||
|
setReaderTextHighlights(prev => {
|
||||||
|
const next = prev.filter(h => h.id !== id);
|
||||||
|
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const loadReaderCrossRefs = async (bookAbbrev, chapterNumber) => {
|
const loadReaderCrossRefs = async (bookAbbrev, chapterNumber) => {
|
||||||
const cacheKey = `${bookAbbrev}/${chapterNumber}`;
|
const cacheKey = `${bookAbbrev}/${chapterNumber}`;
|
||||||
if (_readerCrossRefCacheRef.current[cacheKey]) {
|
if (_readerCrossRefCacheRef.current[cacheKey]) {
|
||||||
@@ -1610,7 +1754,21 @@ const App = () => {
|
|||||||
getCurrentUser().then((result) => {
|
getCurrentUser().then((result) => {
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
setAuthUser(result.user);
|
setAuthUser(result.user);
|
||||||
if (result.user) setProjectIndex(switchStorageUser(result.user.id));
|
if (result.user) {
|
||||||
|
setProjectIndex(switchStorageUser(result.user.id));
|
||||||
|
// Load reader ink from the server; server wins over any stale localStorage copy
|
||||||
|
fetch('/api/reader/ink')
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((serverInk) => {
|
||||||
|
if (!serverInk) return;
|
||||||
|
setReaderInkByPage((local) => {
|
||||||
|
const merged = { ...local, ...serverInk };
|
||||||
|
try { localStorage.setItem('readerInkByPage', JSON.stringify(merged)); } catch {}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Genuine network failure (server unreachable) — fall back to local-only mode.
|
// Genuine network failure (server unreachable) — fall back to local-only mode.
|
||||||
setAuthServerDown(true);
|
setAuthServerDown(true);
|
||||||
@@ -3220,8 +3378,9 @@ const App = () => {
|
|||||||
if (!project) return;
|
if (!project) return;
|
||||||
const prompt = buildClaudePrompt(project);
|
const prompt = buildClaudePrompt(project);
|
||||||
navigator.clipboard.writeText(prompt).then(() => {
|
navigator.clipboard.writeText(prompt).then(() => {
|
||||||
setSaveStatus('Copied for Claude!');
|
setSaveStatus('Copied! Opening Claude…');
|
||||||
window.setTimeout(() => setSaveStatus(''), 2000);
|
window.setTimeout(() => setSaveStatus(''), 3000);
|
||||||
|
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3229,11 +3388,36 @@ const App = () => {
|
|||||||
if (!project) return;
|
if (!project) return;
|
||||||
const prompt = buildPodcastPrompt(project, authUser?.podcastName);
|
const prompt = buildPodcastPrompt(project, authUser?.podcastName);
|
||||||
navigator.clipboard.writeText(prompt).then(() => {
|
navigator.clipboard.writeText(prompt).then(() => {
|
||||||
setSaveStatus('Copied podcast prep!');
|
setSaveStatus('Copied! Opening Claude…');
|
||||||
window.setTimeout(() => setSaveStatus(''), 2000);
|
window.setTimeout(() => setSaveStatus(''), 3000);
|
||||||
|
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
};
|
||||||
|
|
||||||
const copyPronunciationGuide = () => {
|
const copyPronunciationGuide = () => {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
const guide = buildPronunciationGuide(project);
|
const guide = buildPronunciationGuide(project);
|
||||||
@@ -3521,17 +3705,18 @@ const deleteProject = (id) => {
|
|||||||
onClick={copyForClaude}
|
onClick={copyForClaude}
|
||||||
disabled={allChunks.length === 0}
|
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"
|
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">Prepare for </span>Claude
|
<span className="hidden sm:inline">Study Guide → </span>Claude ↗
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={copyForPodcast}
|
onClick={copyForPodcast}
|
||||||
disabled={allChunks.length === 0}
|
disabled={allChunks.length === 0}
|
||||||
title="Copy a prompt for Claude to write a full spoken-word episode script from your notes, ready to record. Optional — only useful if you're producing a podcast or similar audio series. Set your show name in Account Settings first."
|
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"
|
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"> Prepare for Podcast</span>
|
🎙<span className="hidden sm:inline"> Podcast → Claude ↗</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3700,6 +3885,11 @@ const deleteProject = (id) => {
|
|||||||
homeSearch, setHomeSearch,
|
homeSearch, setHomeSearch,
|
||||||
homeSort, setHomeSort,
|
homeSort, setHomeSort,
|
||||||
homeTagFilter, setHomeTagFilter,
|
homeTagFilter, setHomeTagFilter,
|
||||||
|
homeFullTextResults,
|
||||||
|
readingPlan,
|
||||||
|
createReadingPlan,
|
||||||
|
markChapterRead,
|
||||||
|
clearReadingPlan,
|
||||||
renamingId, setRenamingId,
|
renamingId, setRenamingId,
|
||||||
renameValue, setRenameValue,
|
renameValue, setRenameValue,
|
||||||
openBibleReader,
|
openBibleReader,
|
||||||
@@ -3747,6 +3937,9 @@ const deleteProject = (id) => {
|
|||||||
readerAudioState,
|
readerAudioState,
|
||||||
readerInkByPage,
|
readerInkByPage,
|
||||||
updateReaderPageInk,
|
updateReaderPageInk,
|
||||||
|
readerTextHighlights,
|
||||||
|
addTextHighlight,
|
||||||
|
removeTextHighlight,
|
||||||
bibleIndexStatus,
|
bibleIndexStatus,
|
||||||
loadReaderChapter,
|
loadReaderChapter,
|
||||||
_bibleIndexCacheRef,
|
_bibleIndexCacheRef,
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ select {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* When installed as a PWA, push header content below the status bar */
|
||||||
|
@media all and (display-mode: standalone) {
|
||||||
|
header {
|
||||||
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar {
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { renderInkToCanvas } from '../utils/inkRender.js';
|
|||||||
|
|
||||||
const INK_COLORS = ['#0f172a', '#ef4444', '#3b82f6', '#16a34a', '#f59e0b', '#a855f7'];
|
const INK_COLORS = ['#0f172a', '#ef4444', '#3b82f6', '#16a34a', '#f59e0b', '#a855f7'];
|
||||||
const INK_SIZES = [
|
const INK_SIZES = [
|
||||||
|
{ label: 'XS', value: 0.003 },
|
||||||
{ label: 'S', value: 0.006 },
|
{ label: 'S', value: 0.006 },
|
||||||
{ label: 'M', value: 0.012 },
|
{ label: 'M', value: 0.012 },
|
||||||
{ label: 'L', value: 0.025 },
|
{ label: 'L', value: 0.025 },
|
||||||
@@ -38,6 +39,7 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
const { realStrokes } = extractMeta(strokes);
|
const { realStrokes } = extractMeta(strokes);
|
||||||
|
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
|
const hoverRef = useRef(null);
|
||||||
const activeStrokeRef = useRef([]);
|
const activeStrokeRef = useRef([]);
|
||||||
const isDrawingRef = useRef(false);
|
const isDrawingRef = useRef(false);
|
||||||
|
|
||||||
@@ -78,14 +80,56 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
return () => obs.disconnect();
|
return () => obs.disconnect();
|
||||||
}, [render]);
|
}, [render]);
|
||||||
|
|
||||||
|
// Hover cursor — reads refs so no stale-closure risk; no React re-renders on each move
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
function onMove(e) {
|
||||||
|
if (e.pointerType !== 'pen') return;
|
||||||
|
const el = hoverRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (e.pressure > 0) { el.style.display = 'none'; return; }
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const sizePx = Math.max(6, drawSizeRef.current * rect.height);
|
||||||
|
const tool = drawToolRef.current;
|
||||||
|
const color = tool === 'eraser' ? '#94a3b8' : drawColorRef.current;
|
||||||
|
el.style.display = 'block';
|
||||||
|
el.style.width = `${sizePx}px`;
|
||||||
|
el.style.height = `${sizePx}px`;
|
||||||
|
el.style.left = `${x}px`;
|
||||||
|
el.style.top = `${y}px`;
|
||||||
|
el.style.borderColor = color;
|
||||||
|
el.style.borderRadius = tool === 'eraser' ? '2px' : '50%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeave() {
|
||||||
|
if (hoverRef.current) hoverRef.current.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('pointermove', onMove);
|
||||||
|
canvas.addEventListener('pointerleave', onLeave);
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('pointermove', onMove);
|
||||||
|
canvas.removeEventListener('pointerleave', onLeave);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const getPoint = useCallback((e) => {
|
const getPoint = useCallback((e) => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return [0, 0, 0.5];
|
if (!canvas) return [0, 0, 0.5, 0];
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
// tiltX/tiltY range ±90°; normalize to 0-1 where 1 = fully flat (60° threshold)
|
||||||
|
const tilt = e.pointerType === 'pen'
|
||||||
|
? Math.min(Math.hypot(e.tiltX || 0, e.tiltY || 0) / 60, 1)
|
||||||
|
: 0;
|
||||||
return [
|
return [
|
||||||
(e.clientX - rect.left) / rect.width,
|
(e.clientX - rect.left) / rect.width,
|
||||||
(e.clientY - rect.top) / rect.height,
|
(e.clientY - rect.top) / rect.height,
|
||||||
e.pressure ?? 0.5,
|
e.pressure ?? 0.5,
|
||||||
|
tilt,
|
||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -132,17 +176,30 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
if (typeof Touch !== 'undefined' && 'touchType' in Touch.prototype) {
|
if (typeof Touch !== 'undefined' && 'touchType' in Touch.prototype) {
|
||||||
function pointFromTouch(t) {
|
function pointFromTouch(t) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
// radiusX grows as the pencil tilts flat; ~10px radius ≈ fully tilted
|
||||||
|
const tilt = Math.min((t.radiusX || 0) / 10, 1);
|
||||||
return [
|
return [
|
||||||
(t.clientX - rect.left) / rect.width,
|
(t.clientX - rect.left) / rect.width,
|
||||||
(t.clientY - rect.top) / rect.height,
|
(t.clientY - rect.top) / rect.height,
|
||||||
t.force ?? 0.5,
|
t.force ?? 0.5,
|
||||||
|
tilt,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function tStart(e) {
|
function tStart(e) {
|
||||||
|
// Two-finger tap (both non-stylus) = undo last stroke
|
||||||
|
if (e.touches.length === 2 && Array.from(e.touches).every((t) => t.touchType !== 'stylus')) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (strokesRef.current.length > 0) {
|
||||||
|
onStrokesChangeRef.current(packStrokes(strokesRef.current.slice(0, -1), pageCountRef.current));
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const t of e.changedTouches) {
|
for (const t of e.changedTouches) {
|
||||||
if (t.touchType !== 'stylus') continue;
|
if (t.touchType !== 'stylus') continue;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (hoverRef.current) hoverRef.current.style.display = 'none';
|
||||||
isDrawingRef.current = true;
|
isDrawingRef.current = true;
|
||||||
activeStrokeRef.current = [pointFromTouch(t)];
|
activeStrokeRef.current = [pointFromTouch(t)];
|
||||||
render();
|
render();
|
||||||
@@ -350,6 +407,20 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
{!headerContent && (
|
{!headerContent && (
|
||||||
<div className="absolute bottom-0 left-10 top-0 w-px bg-rose-200" style={{ zIndex: 1 }} />
|
<div className="absolute bottom-0 left-10 top-0 w-px bg-rose-200" style={{ zIndex: 1 }} />
|
||||||
)}
|
)}
|
||||||
|
{/* Hover cursor — positioned by the pointermove handler, never by React */}
|
||||||
|
<div
|
||||||
|
ref={hoverRef}
|
||||||
|
style={{
|
||||||
|
display: 'none',
|
||||||
|
position: 'absolute',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 3,
|
||||||
|
border: '1.5px solid',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* Single canvas spanning the entire area (scripture + notebook) */}
|
{/* Single canvas spanning the entire area (scripture + notebook) */}
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
|
|||||||
+147
-1
@@ -1,5 +1,6 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { bookOptions, formatRelativeDate } from '../App.jsx';
|
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../App.jsx';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const {
|
const {
|
||||||
@@ -10,6 +11,10 @@ export default function HomePage() {
|
|||||||
homeSearch, setHomeSearch,
|
homeSearch, setHomeSearch,
|
||||||
homeSort, setHomeSort,
|
homeSort, setHomeSort,
|
||||||
homeTagFilter, setHomeTagFilter,
|
homeTagFilter, setHomeTagFilter,
|
||||||
|
homeFullTextResults,
|
||||||
|
readingPlan,
|
||||||
|
createReadingPlan,
|
||||||
|
clearReadingPlan,
|
||||||
audioBook, setAudioBook,
|
audioBook, setAudioBook,
|
||||||
audioNarrator, setAudioNarrator,
|
audioNarrator, setAudioNarrator,
|
||||||
audioState,
|
audioState,
|
||||||
@@ -145,6 +150,41 @@ export default function HomePage() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Full-text note search results */}
|
||||||
|
{homeFullTextResults !== null && (
|
||||||
|
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
|
||||||
|
<p className="mb-3 text-sm font-semibold text-slate-700">
|
||||||
|
{homeFullTextResults.length === 0
|
||||||
|
? 'No notes match your search.'
|
||||||
|
: `Notes matching "${homeSearch.trim()}" — ${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0)} result${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0) !== 1 ? 's' : ''} across ${homeFullTextResults.length} project${homeFullTextResults.length !== 1 ? 's' : ''}`}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{homeFullTextResults.map(({ projectId, projectTitle, matches }) => (
|
||||||
|
<div key={projectId}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => resumeProject(projectId)}
|
||||||
|
className="mb-1.5 text-sm font-semibold text-violet-700 hover:underline"
|
||||||
|
>
|
||||||
|
{projectTitle} →
|
||||||
|
</button>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{matches.map(({ chunkId, ref, field, snippet }) => (
|
||||||
|
<div key={chunkId} className="rounded-2xl bg-slate-50 px-3 py-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-slate-400">{ref} · {field}</span>
|
||||||
|
<p className="mt-0.5 text-sm text-slate-700 leading-snug">{snippet}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reading plan card */}
|
||||||
|
<ReadingPlanCard readingPlan={readingPlan} createReadingPlan={createReadingPlan} clearReadingPlan={clearReadingPlan} openBibleReader={openBibleReader} />
|
||||||
|
|
||||||
<div className="mb-4 flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel sm:flex-row sm:items-center sm:justify-between">
|
<div className="mb-4 flex flex-col gap-3 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-base font-semibold text-slate-900">Listen to BSB Audio</h3>
|
<h3 className="text-base font-semibold text-slate-900">Listen to BSB Audio</h3>
|
||||||
@@ -317,3 +357,109 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReadingPlanCard({ readingPlan, createReadingPlan, clearReadingPlan, openBibleReader }) {
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [planBook, setPlanBook] = useState(bookOptions[0].abbrev);
|
||||||
|
const [planWeeks, setPlanWeeks] = useState(4);
|
||||||
|
|
||||||
|
if (readingPlan) {
|
||||||
|
const pct = readingPlan.totalChapters > 0
|
||||||
|
? Math.round((readingPlan.chaptersRead.length / readingPlan.totalChapters) * 100)
|
||||||
|
: 0;
|
||||||
|
const daysLeft = Math.max(0, Math.ceil((readingPlan.targetDate - Date.now()) / 86400000));
|
||||||
|
const chapLeft = readingPlan.totalChapters - readingPlan.chaptersRead.length;
|
||||||
|
const paceNeeded = daysLeft > 0 ? (chapLeft / daysLeft).toFixed(1) : '—';
|
||||||
|
return (
|
||||||
|
<div className="mb-4 rounded-3xl border border-emerald-200 bg-emerald-50 p-5 shadow-panel">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-600">Reading Plan</p>
|
||||||
|
<h3 className="mt-1 text-base font-semibold text-slate-900">{readingPlan.bookName}</h3>
|
||||||
|
<p className="mt-0.5 text-sm text-slate-600">
|
||||||
|
{readingPlan.chaptersRead.length} / {readingPlan.totalChapters} chapters · {daysLeft} day{daysLeft !== 1 ? 's' : ''} left · {paceNeeded} ch/day needed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openBibleReader}
|
||||||
|
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
||||||
|
>
|
||||||
|
Read
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { if (window.confirm('Clear reading plan?')) clearReadingPlan(); }}
|
||||||
|
className="rounded-xl border border-emerald-300 px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-emerald-200">
|
||||||
|
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
|
||||||
|
{readingPlan.chaptersRead.length > 0 && (
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Read: ch. {readingPlan.chaptersRead.slice(0, 12).join(', ')}{readingPlan.chaptersRead.length > 12 ? '…' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
|
||||||
|
{showForm ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="text-sm font-semibold text-slate-700">Read</span>
|
||||||
|
<select
|
||||||
|
value={planBook}
|
||||||
|
onChange={(e) => setPlanBook(e.target.value)}
|
||||||
|
className="rounded-xl border border-slate-300 bg-slate-50 px-2 py-1.5 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||||
|
>
|
||||||
|
{bookOptions.map((b) => (
|
||||||
|
<option key={b.abbrev} value={b.abbrev}>{b.name} ({CHAPTER_COUNTS[b.abbrev] ?? '?'} ch)</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-sm text-slate-600">in</span>
|
||||||
|
<select
|
||||||
|
value={planWeeks}
|
||||||
|
onChange={(e) => setPlanWeeks(Number(e.target.value))}
|
||||||
|
className="rounded-xl border border-slate-300 bg-slate-50 px-2 py-1.5 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
|
||||||
|
>
|
||||||
|
{[1,2,3,4,6,8,12,16,26,52].map((w) => <option key={w} value={w}>{w} week{w !== 1 ? 's' : ''}</option>)}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const book = bookOptions.find((b) => b.abbrev === planBook);
|
||||||
|
createReadingPlan(planBook, book?.name ?? planBook, planWeeks);
|
||||||
|
setShowForm(false);
|
||||||
|
}}
|
||||||
|
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
|
||||||
|
>
|
||||||
|
Start plan
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setShowForm(false)} className="text-sm text-slate-400 hover:text-slate-600">Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-700">Reading Plan</p>
|
||||||
|
<p className="text-xs text-slate-500">Set a goal to read through a book, track chapters as you go.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="shrink-0 rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Set goal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+476
-23
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { bookOptions, BookmarkIcon, CopyIcon } from '../App.jsx';
|
import { bookOptions, BookmarkIcon, CopyIcon } from '../App.jsx';
|
||||||
import DrawCanvas from './DrawCanvas.jsx';
|
import DrawCanvas from './DrawCanvas.jsx';
|
||||||
@@ -45,11 +45,180 @@ export default function ReaderPage() {
|
|||||||
setReaderCrossRefs,
|
setReaderCrossRefs,
|
||||||
readerInkByPage,
|
readerInkByPage,
|
||||||
updateReaderPageInk,
|
updateReaderPageInk,
|
||||||
|
readerTextHighlights,
|
||||||
|
addTextHighlight,
|
||||||
|
removeTextHighlight,
|
||||||
|
readingPlan,
|
||||||
|
markChapterRead,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
|
||||||
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
|
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
|
||||||
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
||||||
|
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
|
||||||
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? [];
|
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? [];
|
||||||
|
const [selectionPicker, setSelectionPicker] = useState(null);
|
||||||
|
const swipeTouchRef = useRef(null);
|
||||||
|
|
||||||
|
const [toolbarVisible, setToolbarVisible] = useState(true);
|
||||||
|
const [readerPageMode, setReaderPageMode] = useState(() => localStorage.getItem('reader-page-mode') === '1');
|
||||||
|
const [flipPage, setFlipPage] = useState(0);
|
||||||
|
const [flipTotalPages, setFlipTotalPages] = useState(1);
|
||||||
|
const [flipPageWidth, setFlipPageWidth] = useState(0);
|
||||||
|
const flipContainerRef = useRef(null);
|
||||||
|
const flipContentRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('reader-wide', readerWideLayout ? '1' : '0');
|
||||||
|
// Auto-collapse toolbar when entering wide mode, restore when leaving
|
||||||
|
setToolbarVisible(!readerWideLayout);
|
||||||
|
}, [readerWideLayout]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('reader-page-mode', readerPageMode ? '1' : '0');
|
||||||
|
}, [readerPageMode]);
|
||||||
|
|
||||||
|
// Reset to page 0 when navigating chapters
|
||||||
|
useEffect(() => {
|
||||||
|
setFlipPage(0);
|
||||||
|
}, [readerChapter, readerBookAbbrev]);
|
||||||
|
|
||||||
|
// Track container width for CSS column sizing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!readerPageMode || !flipContainerRef.current) return;
|
||||||
|
const el = flipContainerRef.current;
|
||||||
|
const update = () => setFlipPageWidth(el.clientWidth);
|
||||||
|
update();
|
||||||
|
const ro = new ResizeObserver(update);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [readerPageMode, readerVerses]);
|
||||||
|
|
||||||
|
// Count pages after content renders
|
||||||
|
useEffect(() => {
|
||||||
|
if (!readerPageMode || !flipContentRef.current || !flipPageWidth) return;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
const sw = flipContentRef.current?.scrollWidth ?? 0;
|
||||||
|
setFlipTotalPages(Math.max(1, Math.round(sw / flipPageWidth)));
|
||||||
|
}, 120);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [readerPageMode, readerVerses, readerFontSize, flipPageWidth]);
|
||||||
|
|
||||||
|
// Swipe left/right to navigate chapters
|
||||||
|
const handleTouchStart = (e) => {
|
||||||
|
if (readerDrawMode) return;
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
swipeTouchRef.current = { x: t.clientX, y: t.clientY };
|
||||||
|
};
|
||||||
|
const handleTouchEnd = (e) => {
|
||||||
|
if (readerDrawMode || !swipeTouchRef.current) return;
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
const dx = t.clientX - swipeTouchRef.current.x;
|
||||||
|
const dy = t.clientY - swipeTouchRef.current.y;
|
||||||
|
swipeTouchRef.current = null;
|
||||||
|
if (Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx) * 0.8) return;
|
||||||
|
if (dx < 0) {
|
||||||
|
if (readerPageMode && flipPage < flipTotalPages - 1) setFlipPage((p) => p + 1);
|
||||||
|
else readerGoToNextChapter();
|
||||||
|
} else {
|
||||||
|
if (readerPageMode && flipPage > 0) setFlipPage((p) => p - 1);
|
||||||
|
else readerGoToPreviousChapter();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Arrow keys for desktop
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e) => {
|
||||||
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
if (readerPageMode && flipPage > 0) setFlipPage((p) => p - 1);
|
||||||
|
else readerGoToPreviousChapter();
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
if (readerPageMode && flipPage < flipTotalPages - 1) setFlipPage((p) => p + 1);
|
||||||
|
else readerGoToNextChapter();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKey);
|
||||||
|
return () => window.removeEventListener('keydown', handleKey);
|
||||||
|
}, [readerGoToPreviousChapter, readerGoToNextChapter, readerPageMode, flipPage, flipTotalPages]);
|
||||||
|
|
||||||
|
// Dismiss the highlight picker when tapping outside it
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectionPicker) return;
|
||||||
|
const dismiss = () => setSelectionPicker(null);
|
||||||
|
document.addEventListener('pointerdown', dismiss);
|
||||||
|
return () => document.removeEventListener('pointerdown', dismiss);
|
||||||
|
}, [selectionPicker]);
|
||||||
|
|
||||||
|
// Render verse text with colored highlights and optional search match highlight
|
||||||
|
function renderVerseText(verseNum, text, searchLow) {
|
||||||
|
const spans = [];
|
||||||
|
for (const h of (readerTextHighlights || [])) {
|
||||||
|
if (h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum) {
|
||||||
|
spans.push({ start: h.startOffset, end: h.endOffset, type: 'hl', id: h.id, color: h.color });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (searchLow) {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
let i = 0;
|
||||||
|
while ((i = lower.indexOf(searchLow, i)) !== -1) {
|
||||||
|
spans.push({ start: i, end: i + searchLow.length, type: 'search' });
|
||||||
|
i += searchLow.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!spans.length) return text;
|
||||||
|
spans.sort((a, b) => a.start - b.start);
|
||||||
|
const parts = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const s of spans) {
|
||||||
|
if (s.start < cursor) continue;
|
||||||
|
if (s.start > cursor) parts.push(text.slice(cursor, s.start));
|
||||||
|
if (s.type === 'hl') {
|
||||||
|
parts.push(
|
||||||
|
<mark key={s.id} style={{ backgroundColor: s.color, borderRadius: '2px', cursor: 'pointer' }}
|
||||||
|
onClick={() => removeTextHighlight(s.id)} title="Click to remove highlight">
|
||||||
|
{text.slice(s.start, s.end)}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push(
|
||||||
|
<mark key={`srch${s.start}`} className="bg-yellow-200 rounded px-0.5">
|
||||||
|
{text.slice(s.start, s.end)}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cursor = s.end;
|
||||||
|
}
|
||||||
|
if (cursor < text.length) parts.push(text.slice(cursor));
|
||||||
|
return <>{parts}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect text selection and show the highlight color picker
|
||||||
|
function handleSelectionEnd() {
|
||||||
|
setTimeout(() => {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
if (!rect.width) return;
|
||||||
|
let node = range.commonAncestorContainer;
|
||||||
|
if (node.nodeType !== 1) node = node.parentNode;
|
||||||
|
while (node && !(node.dataset && node.dataset.verseNum)) node = node.parentElement;
|
||||||
|
if (!node) return;
|
||||||
|
const verseNum = Number(node.dataset.verseNum);
|
||||||
|
const verse = readerVerses.find(v => v.number === verseNum);
|
||||||
|
if (!verse) return;
|
||||||
|
const selectedText = sel.toString();
|
||||||
|
const startOffset = verse.text.indexOf(selectedText);
|
||||||
|
if (startOffset === -1) return;
|
||||||
|
setSelectionPicker({
|
||||||
|
x: (rect.left + rect.right) / 2,
|
||||||
|
y: rect.top,
|
||||||
|
verse: verseNum,
|
||||||
|
startOffset,
|
||||||
|
endOffset: startOffset + selectedText.length,
|
||||||
|
});
|
||||||
|
}, 20);
|
||||||
|
}
|
||||||
|
|
||||||
const bookmarkEntries = Object.entries(readerBookmarks).map(([key, color]) => {
|
const bookmarkEntries = Object.entries(readerBookmarks).map(([key, color]) => {
|
||||||
const [bAbbrev, chapterStr, verseStr] = key.split('-');
|
const [bAbbrev, chapterStr, verseStr] = key.split('-');
|
||||||
@@ -84,7 +253,14 @@ export default function ReaderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className={`mx-auto px-4 py-8 sm:px-6 lg:px-8 ${readerDrawMode ? 'max-w-full' : 'max-w-3xl'}`}>
|
<main
|
||||||
|
className={`mx-auto px-4 py-8 sm:px-6 lg:px-8 ${readerDrawMode ? 'max-w-full' : readerWideLayout ? 'max-w-7xl' : 'max-w-3xl'}`}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
{/* Collapsible toolbar — hidden in wide focus mode */}
|
||||||
|
<div className={`transition-all duration-200 ${!toolbarVisible ? 'hidden' : ''}`}>
|
||||||
|
|
||||||
{/* Navigation + tools bar */}
|
{/* Navigation + tools bar */}
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel">
|
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-3xl border border-slate-200 bg-white p-4 shadow-panel">
|
||||||
<label className="text-sm text-slate-600">
|
<label className="text-sm text-slate-600">
|
||||||
@@ -112,6 +288,19 @@ export default function ReaderPage() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{readingPlan && readingPlan.bookAbbrev === readerBookAbbrev && (() => {
|
||||||
|
const done = readingPlan.chaptersRead.includes(readerChapter);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => markChapterRead(readerChapter)}
|
||||||
|
className={`rounded-xl px-3 py-1.5 text-sm font-semibold transition ${done ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'border border-emerald-300 bg-white text-emerald-700 hover:bg-emerald-50'}`}
|
||||||
|
title={done ? 'Click to unmark' : 'Mark this chapter as read in your reading plan'}
|
||||||
|
>
|
||||||
|
{done ? '✓ Read' : 'Mark as read'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<button type="button" onClick={readerGoToPreviousChapter} disabled={readerChapter <= 1}
|
<button type="button" onClick={readerGoToPreviousChapter} disabled={readerChapter <= 1}
|
||||||
className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
|
className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
|
||||||
‹ Prev
|
‹ Prev
|
||||||
@@ -216,8 +405,35 @@ export default function ReaderPage() {
|
|||||||
>
|
>
|
||||||
✏ Draw
|
✏ Draw
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReaderWideLayout((v) => !v)}
|
||||||
|
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerWideLayout ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
⊞ Wide
|
||||||
|
</button>
|
||||||
|
{readerWideLayout && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setToolbarVisible(false)}
|
||||||
|
className="rounded-lg px-3 py-1 text-xs font-semibold border border-slate-300 text-slate-600 hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
|
✕ Hide
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!readerDrawMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReaderPageMode((v) => !v)}
|
||||||
|
className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerPageMode ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
⎕ Page
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>{/* end collapsible toolbar */}
|
||||||
|
|
||||||
{/* Whole-Bible search results */}
|
{/* Whole-Bible search results */}
|
||||||
{readerSearchActive && readerSearchScope === 'bible' && (
|
{readerSearchActive && readerSearchScope === 'bible' && (
|
||||||
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
||||||
@@ -299,8 +515,9 @@ export default function ReaderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Verses — hidden when draw mode is active */}
|
{/* Verses — normal scroll mode */}
|
||||||
<div className={`rounded-3xl border border-slate-200 bg-white p-6 shadow-panel${readerDrawMode ? ' hidden' : ''}`}>
|
{!readerDrawMode && !readerPageMode && (
|
||||||
|
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
||||||
<h2 className="mb-1 text-xl font-semibold text-slate-900">
|
<h2 className="mb-1 text-xl font-semibold text-slate-900">
|
||||||
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">(BSB)</span>
|
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">(BSB)</span>
|
||||||
</h2>
|
</h2>
|
||||||
@@ -321,7 +538,9 @@ export default function ReaderPage() {
|
|||||||
return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
|
return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 leading-relaxed text-slate-800" style={{ fontSize: `${readerFontSize}em` }}>
|
<div className={`leading-relaxed text-slate-800 ${readerWideLayout ? 'md:columns-2 md:gap-x-10 space-y-0' : 'space-y-3'}`}
|
||||||
|
style={{ fontSize: `${readerFontSize}em` }}
|
||||||
|
onPointerUp={handleSelectionEnd}>
|
||||||
{filtered.map((verse) => {
|
{filtered.map((verse) => {
|
||||||
const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
|
const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
|
||||||
const verseWords = chapterInterlinear?.[String(verse.number)];
|
const verseWords = chapterInterlinear?.[String(verse.number)];
|
||||||
@@ -330,21 +549,9 @@ export default function ReaderPage() {
|
|||||||
const bmColor = readerBookmarks[verseKey];
|
const bmColor = readerBookmarks[verseKey];
|
||||||
const crossRefs = readerCrossRefs?.[verse.number];
|
const crossRefs = readerCrossRefs?.[verse.number];
|
||||||
|
|
||||||
const highlightText = (text) => {
|
|
||||||
if (!searchLower) return text;
|
|
||||||
const idx = text.toLowerCase().indexOf(searchLower);
|
|
||||||
if (idx === -1) return text;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div key={verse.number} id={`reader-verse-${verse.number}`} data-verse-num={verse.number}
|
||||||
{text.slice(0, idx)}
|
className="group break-inside-avoid rounded-xl transition mb-3"
|
||||||
<mark className="bg-yellow-200 rounded px-0.5">{text.slice(idx, idx + searchLower.length)}</mark>
|
|
||||||
{text.slice(idx + searchLower.length)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={verse.number} id={`reader-verse-${verse.number}`} className="group rounded-xl transition"
|
|
||||||
style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: '0.5rem' } : {}}>
|
style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: '0.5rem' } : {}}>
|
||||||
<div className="flex items-start gap-1">
|
<div className="flex items-start gap-1">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@@ -357,7 +564,7 @@ export default function ReaderPage() {
|
|||||||
title={verseWords ? 'Show original words' : undefined}>
|
title={verseWords ? 'Show original words' : undefined}>
|
||||||
{verse.number}
|
{verse.number}
|
||||||
</button>
|
</button>
|
||||||
<p className="flex-1">{highlightText(verse.text)}</p>
|
<p className="flex-1">{renderVerseText(verse.number, verse.text, searchLower)}</p>
|
||||||
<span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1">
|
<span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onClick={() => toggleReaderBookmark(verseKey)}
|
onClick={() => toggleReaderBookmark(verseKey)}
|
||||||
@@ -431,20 +638,176 @@ export default function ReaderPage() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verses — page-flip mode */}
|
||||||
|
{!readerDrawMode && readerPageMode && (
|
||||||
|
<div ref={flipContainerRef}
|
||||||
|
className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-panel"
|
||||||
|
style={{ height: toolbarVisible ? 'calc(100dvh - 380px)' : 'calc(100dvh - 120px)' }}>
|
||||||
|
{readerLoading && <p className="p-6 text-sm text-slate-500">Loading…</p>}
|
||||||
|
{readerError && <p className="p-6 text-sm text-rose-600">{readerError}</p>}
|
||||||
|
{!readerLoading && !readerError && (() => {
|
||||||
|
const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
|
||||||
|
const filtered = searchLower
|
||||||
|
? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower))
|
||||||
|
: readerVerses;
|
||||||
|
if (searchLower && filtered.length === 0) {
|
||||||
|
return <p className="p-6 text-sm text-slate-500">No verses match "{readerSearch}".</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div ref={flipContentRef}
|
||||||
|
className="h-full leading-relaxed text-slate-800"
|
||||||
|
style={{
|
||||||
|
fontSize: `${readerFontSize}em`,
|
||||||
|
columnWidth: flipPageWidth ? `${flipPageWidth}px` : '100%',
|
||||||
|
columnGap: 0,
|
||||||
|
paddingBottom: '3.5rem',
|
||||||
|
transform: flipPageWidth ? `translateX(${-flipPage * flipPageWidth}px)` : undefined,
|
||||||
|
transition: 'transform 0.3s ease',
|
||||||
|
}}
|
||||||
|
onPointerUp={handleSelectionEnd}>
|
||||||
|
<div className="px-6 pt-6 pb-3 break-inside-avoid">
|
||||||
|
<h2 className="mb-1 text-xl font-semibold text-slate-900">
|
||||||
|
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">(BSB)</span>
|
||||||
|
</h2>
|
||||||
|
{readerInterlinear
|
||||||
|
? <p className="text-xs text-slate-400">Click a verse number to see original words & pronunciation</p>
|
||||||
|
: <p className="text-xs text-slate-400">Hover a verse for actions</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{filtered.map((verse) => {
|
||||||
|
const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
|
||||||
|
const verseWords = chapterInterlinear?.[String(verse.number)];
|
||||||
|
const isOpen = readerSelectedVerse === verse.number;
|
||||||
|
const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`;
|
||||||
|
const bmColor = readerBookmarks[verseKey];
|
||||||
|
const crossRefs = readerCrossRefs?.[verse.number];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={verse.number} id={`reader-verse-${verse.number}`} data-verse-num={verse.number}
|
||||||
|
className="group break-inside-avoid rounded-xl transition mb-3 px-6"
|
||||||
|
style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: 'calc(1.5rem + 0.5rem)' } : {}}>
|
||||||
|
<div className="flex items-start gap-1">
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => setReaderSelectedVerse(isOpen ? null : verse.number)}
|
||||||
|
className={`mt-0.5 shrink-0 rounded px-1 text-xs font-bold transition ${
|
||||||
|
verseWords
|
||||||
|
? isOpen ? 'bg-sky-600 text-white' : 'text-sky-600 hover:bg-sky-50'
|
||||||
|
: 'cursor-default text-slate-400'
|
||||||
|
}`}
|
||||||
|
title={verseWords ? 'Show original words' : undefined}>
|
||||||
|
{verse.number}
|
||||||
|
</button>
|
||||||
|
<p className="flex-1">{renderVerseText(verse.number, verse.text, searchLower)}</p>
|
||||||
|
<span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1">
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => toggleReaderBookmark(verseKey)}
|
||||||
|
className={`rounded p-1 leading-none hover:bg-slate-100 ${bmColor ? 'text-amber-600' : 'text-slate-400'}`}
|
||||||
|
title={bmColor ? 'Remove bookmark' : 'Bookmark this verse'}>
|
||||||
|
<BookmarkIcon filled={!!bmColor} />
|
||||||
|
</button>
|
||||||
|
{bmColor && (
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => cycleBookmarkColor(verseKey)}
|
||||||
|
className="rounded p-1 text-sm leading-none hover:bg-slate-100"
|
||||||
|
title="Change highlight colour">
|
||||||
|
🎨
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => copyVerse(readerBook?.name, readerChapter, verse.number, verse.text)}
|
||||||
|
className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||||||
|
title="Copy verse">
|
||||||
|
<CopyIcon />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{readerShowCrossRefs && crossRefs && (
|
||||||
|
<div className="mt-1 ml-6 flex flex-wrap gap-1">
|
||||||
|
{crossRefs.slice(0, 8).map((ref, i) => {
|
||||||
|
const label = formatCrossRef(ref);
|
||||||
|
return (
|
||||||
|
<span key={i} className="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800 cursor-default" title={`Score: ${ref.score ?? '?'}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOpen && verseWords && (
|
||||||
|
<div className="mt-2 mb-1 ml-6 rounded-2xl border border-sky-100 bg-sky-50 p-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{verseWords.map((w, i) => {
|
||||||
|
const isHebrew = w.s?.startsWith('H');
|
||||||
|
const canSpeak = isHebrew || w.s?.startsWith('G');
|
||||||
|
return (
|
||||||
|
<div key={i} className="rounded-xl border border-sky-200 bg-white p-2 text-center shadow-sm"
|
||||||
|
style={{ minWidth: '4.5rem', maxWidth: '9rem' }}>
|
||||||
|
<div className={`text-lg font-medium leading-tight ${isHebrew ? 'font-serif' : ''}`} dir={isHebrew ? 'rtl' : 'ltr'}>
|
||||||
|
{w.o}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-slate-500 italic">{w.t}</div>
|
||||||
|
<div className="mt-1 text-xs font-semibold text-slate-800">{w.g}</div>
|
||||||
|
{w.p && <div className="mt-0.5 text-[10px] text-slate-400 leading-tight">{w.p}</div>}
|
||||||
|
{w.s && <div className="mt-0.5 text-[10px] text-slate-400">{w.s}</div>}
|
||||||
|
{canSpeak && (
|
||||||
|
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
|
||||||
|
className="mt-1.5 rounded-lg bg-sky-100 px-2 py-0.5 text-[11px] font-medium text-sky-700 hover:bg-sky-200 transition"
|
||||||
|
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>
|
||||||
|
🔊 Speak
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{/* Page navigation bar */}
|
||||||
|
<div className="absolute bottom-0 inset-x-0 flex items-center justify-between border-t border-slate-100 bg-white/95 px-6 py-2.5 backdrop-blur-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => flipPage > 0 ? setFlipPage((p) => p - 1) : readerGoToPreviousChapter()}
|
||||||
|
disabled={flipPage === 0 && readerChapter <= 1}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white px-4 py-1.5 text-sm font-semibold text-slate-600 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
|
||||||
|
{flipPage === 0 ? '← Prev chapter' : '← Back'}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
{readerBook?.name} {readerChapter} · {flipPage + 1} / {flipTotalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => flipPage < flipTotalPages - 1 ? setFlipPage((p) => p + 1) : readerGoToNextChapter()}
|
||||||
|
disabled={flipPage === flipTotalPages - 1 && readerChapter >= readerTotalChapters}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white px-4 py-1.5 text-sm font-semibold text-slate-600 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
|
||||||
|
{flipPage === flipTotalPages - 1 ? 'Next chapter →' : 'Next →'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Draw mode — Bible text on left, notebook canvas on right */}
|
{/* Draw mode — Bible text on left, notebook canvas on right */}
|
||||||
{readerDrawMode && (
|
{readerDrawMode && (
|
||||||
<div className="flex flex-col items-start gap-4 sm:flex-row">
|
<div className="flex flex-col items-start gap-4 sm:flex-row">
|
||||||
{/* Scripture panel — sticky so it stays in view while notebook scrolls */}
|
{/* Scripture panel — sticky so it stays in view while notebook scrolls */}
|
||||||
<div className="w-full rounded-3xl border border-slate-200 bg-slate-50 p-5 font-serif text-sm leading-relaxed text-slate-800 sm:w-72 sm:shrink-0 sm:sticky sm:top-6 sm:max-h-[80vh] sm:overflow-y-auto">
|
<div className="w-full rounded-3xl border border-slate-200 bg-slate-50 p-5 font-serif text-sm leading-relaxed text-slate-800 sm:w-72 sm:shrink-0 sm:sticky sm:top-6 sm:max-h-[80vh] sm:overflow-y-auto"
|
||||||
|
onPointerUp={handleSelectionEnd}>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<span className="font-semibold text-slate-900">{readerBook?.name} {readerChapter}</span>
|
<span className="font-semibold text-slate-900">{readerBook?.name} {readerChapter}</span>
|
||||||
<span className="ml-2 text-xs text-slate-400">BSB</span>
|
<span className="ml-2 text-xs text-slate-400">BSB</span>
|
||||||
|
<p className="mt-1 text-xs text-slate-400 font-sans">Select text to highlight</p>
|
||||||
</div>
|
</div>
|
||||||
{!readerLoading && readerVerses.map((v) => (
|
{!readerLoading && readerVerses.map((v) => (
|
||||||
<p key={v.number} className="mb-2">
|
<p key={v.number} className="mb-2" data-verse-num={v.number}>
|
||||||
<span className="font-semibold text-slate-700">{v.number}.</span>{' '}
|
<span className="font-semibold text-slate-700">{v.number}.</span>{' '}
|
||||||
{v.text}
|
{renderVerseText(v.number, v.text, '')}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -459,6 +822,96 @@ export default function ReaderPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Wide focus mode — fixed top bar to restore toolbar */}
|
||||||
|
{readerWideLayout && !toolbarVisible && (
|
||||||
|
<div className="fixed top-0 inset-x-0 z-30 flex items-center justify-between gap-4 border-b border-slate-200/60 bg-white/80 px-4 py-2 backdrop-blur-sm">
|
||||||
|
<span className="text-sm font-semibold text-slate-700">
|
||||||
|
{readerBook?.name} {readerChapter}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" onClick={readerGoToPreviousChapter} disabled={readerChapter <= 1}
|
||||||
|
className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40">
|
||||||
|
‹ Prev
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={readerGoToNextChapter} disabled={readerChapter >= readerTotalChapters}
|
||||||
|
className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40">
|
||||||
|
Next ›
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setToolbarVisible(true)}
|
||||||
|
className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
|
||||||
|
⚙ Tools
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Side chapter nav — fixed left/right arrows */}
|
||||||
|
{!readerDrawMode && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={readerGoToPreviousChapter}
|
||||||
|
disabled={readerChapter <= 1}
|
||||||
|
aria-label="Previous chapter"
|
||||||
|
className="fixed left-3 top-1/2 -translate-y-1/2 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-500 shadow-md backdrop-blur-sm transition hover:bg-white hover:text-slate-900 disabled:opacity-0 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={readerGoToNextChapter}
|
||||||
|
disabled={readerChapter >= readerTotalChapters}
|
||||||
|
aria-label="Next chapter"
|
||||||
|
className="fixed right-3 top-1/2 -translate-y-1/2 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-500 shadow-md backdrop-blur-sm transition hover:bg-white hover:text-slate-900 disabled:opacity-0 disabled:pointer-events-none"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Highlight color picker — appears above text selection */}
|
||||||
|
{selectionPicker && (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 flex items-center gap-1.5 rounded-2xl bg-white px-2.5 py-2 shadow-2xl border border-slate-200"
|
||||||
|
style={{
|
||||||
|
left: Math.max(8, Math.min(selectionPicker.x - 105, window.innerWidth - 220)),
|
||||||
|
top: selectionPicker.y > 80 ? selectionPicker.y - 56 : selectionPicker.y + 28,
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa'].map(color => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
className="h-7 w-7 rounded-full border-2 border-white shadow-sm hover:scale-110 transition-transform active:scale-95"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
addTextHighlight({
|
||||||
|
book: readerBookAbbrev,
|
||||||
|
chapter: readerChapter,
|
||||||
|
verse: selectionPicker.verse,
|
||||||
|
startOffset: selectionPicker.startOffset,
|
||||||
|
endOffset: selectionPicker.endOffset,
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
setSelectionPicker(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="ml-1 h-7 w-7 rounded-full bg-slate-100 text-slate-500 text-xs flex items-center justify-center hover:bg-slate-200 transition"
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
setSelectionPicker(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+57
-11
@@ -76,7 +76,7 @@ export default function StudyPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto max-w-7xl px-4 py-4 sm:py-8 sm:px-6 lg:px-8">
|
<main className="mx-auto max-w-7xl px-4 py-4 pb-20 sm:py-8 sm:pb-20 sm:px-6 lg:px-8">
|
||||||
<section className={`grid min-w-0 gap-6 ${studyLayout === 'split' ? '' : 'lg:grid-cols-[260px_1fr]'}`}>
|
<section className={`grid min-w-0 gap-6 ${studyLayout === 'split' ? '' : 'lg:grid-cols-[260px_1fr]'}`}>
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className={`min-w-0 rounded-3xl border border-slate-200 bg-white p-4 sm:p-6 shadow-panel max-sm:hidden ${studyLayout === 'split' ? 'hidden' : ''}`}>
|
<aside className={`min-w-0 rounded-3xl border border-slate-200 bg-white p-4 sm:p-6 shadow-panel max-sm:hidden ${studyLayout === 'split' ? 'hidden' : ''}`}>
|
||||||
@@ -515,6 +515,47 @@ export default function StudyPage() {
|
|||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Inline ink notes — collapsible, always available without switching to annotate mode */}
|
||||||
|
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'notes' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCollapsedSections((c) => ({ ...c, inlineInk: !c.inlineInk }))}
|
||||||
|
className="flex w-full items-center justify-between gap-2 text-left"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">Ink Notes</h3>
|
||||||
|
<p className="text-xs text-slate-500">Draw diagrams or handwritten notes for this chunk.</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-400">{collapsedSections.inlineInk ? '▸' : '▾'}</span>
|
||||||
|
</button>
|
||||||
|
{!collapsedSections.inlineInk && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<DrawCanvas
|
||||||
|
strokes={selectedChunk.inkStrokes ?? []}
|
||||||
|
onStrokesChange={(s) => updateChunk(selectedChunk.id, { inkStrokes: s })}
|
||||||
|
headerContent={
|
||||||
|
selectedChunkVerses.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Scripture · draw on me</span>
|
||||||
|
<span className="rounded-full bg-slate-200 px-2.5 py-0.5 text-xs font-semibold text-slate-600">
|
||||||
|
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '–')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{selectedChunkVerses.map((verse) => (
|
||||||
|
<p key={`${verse.chapter}-${verse.number}`} className="mb-1">
|
||||||
|
<span className="font-semibold text-slate-900">{verse.chapter}:{verse.number}.</span>{' '}
|
||||||
|
{verse.text}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Cross-references */}
|
{/* Cross-references */}
|
||||||
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'crossRefs' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'crossRefs' ? 'hidden' : ''}`}>
|
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'crossRefs' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'crossRefs' ? 'hidden' : ''}`}>
|
||||||
<button
|
<button
|
||||||
@@ -902,8 +943,21 @@ export default function StudyPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Prev / Next — sticky */}
|
</div>
|
||||||
<div className="sticky bottom-0 z-10 -mx-4 sm:-mx-6 mt-6 flex items-center justify-between gap-3 border-t border-slate-200 bg-white/95 px-4 py-3 backdrop-blur-sm sm:px-6">
|
) : (
|
||||||
|
<div className="mt-6 rounded-3xl border border-slate-200 bg-slate-50 p-8 text-center text-slate-500">
|
||||||
|
<span className="sm:hidden">Use ‹ Prev / Next › above to pick a chunk.</span>
|
||||||
|
<span className="hidden sm:inline">Select a chunk from the left panel to edit its notes and Greek studies.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Prev / Next — fixed bottom bar, visible while scrolling long chunks */}
|
||||||
|
{selectedChunk && studyLayout !== 'annotate' && (
|
||||||
|
<div className="fixed bottom-0 inset-x-0 z-20 border-t border-slate-200 bg-white/95 backdrop-blur-sm">
|
||||||
|
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-4 py-3 sm:px-6 lg:px-8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={goToPreviousChunk}
|
onClick={goToPreviousChunk}
|
||||||
@@ -925,15 +979,7 @@ export default function StudyPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="mt-6 rounded-3xl border border-slate-200 bg-slate-50 p-8 text-center text-slate-500">
|
|
||||||
<span className="sm:hidden">Use ‹ Prev / Next › above to pick a chunk.</span>
|
|
||||||
<span className="hidden sm:inline">Select a chunk from the left panel to edit its notes and Greek studies.</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Greek word picker modal */}
|
{/* Greek word picker modal */}
|
||||||
{suggestModal && createPortal(
|
{suggestModal && createPortal(
|
||||||
|
|||||||
+15
-12
@@ -33,14 +33,22 @@ export function renderInkToCanvas(
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
for (const s of strokes) {
|
function strokeOpts(tool, baseSize, points) {
|
||||||
const pts = s.points.map(([x, y, p]) => [x * w, y * h, p]);
|
const avgTilt = points.length
|
||||||
const outline = getStroke(pts, {
|
? points.reduce((s, p) => s + (p[3] ?? 0), 0) / points.length
|
||||||
size: s.size * h,
|
: 0;
|
||||||
thinning: s.tool === 'highlighter' ? 0 : 0.5,
|
return {
|
||||||
|
// Tilt widens the stroke and reduces pressure-thinning (pencil-shading feel)
|
||||||
|
size: tool === 'pen' ? baseSize * (1 + avgTilt * 2.5) : baseSize,
|
||||||
|
thinning: tool === 'highlighter' ? 0 : 0.5 * (1 - (tool === 'pen' ? avgTilt * 0.8 : 0)),
|
||||||
smoothing: 0.5,
|
smoothing: 0.5,
|
||||||
streamline: 0.4,
|
streamline: 0.4,
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of strokes) {
|
||||||
|
const pts = s.points.map(([x, y, p]) => [x * w, y * h, p]);
|
||||||
|
const outline = getStroke(pts, strokeOpts(s.tool, s.size * h, s.points));
|
||||||
ctx.globalAlpha = s.tool === 'highlighter' ? 0.35 : 1;
|
ctx.globalAlpha = s.tool === 'highlighter' ? 0.35 : 1;
|
||||||
ctx.fillStyle = s.color;
|
ctx.fillStyle = s.color;
|
||||||
ctx.fill(new Path2D(pathFromStroke(outline)));
|
ctx.fill(new Path2D(pathFromStroke(outline)));
|
||||||
@@ -48,12 +56,7 @@ export function renderInkToCanvas(
|
|||||||
|
|
||||||
if (activeStroke.length > 1 && activeTool !== 'eraser') {
|
if (activeStroke.length > 1 && activeTool !== 'eraser') {
|
||||||
const pts = activeStroke.map(([x, y, p]) => [x * w, y * h, p]);
|
const pts = activeStroke.map(([x, y, p]) => [x * w, y * h, p]);
|
||||||
const outline = getStroke(pts, {
|
const outline = getStroke(pts, strokeOpts(activeTool, activeSize * h, activeStroke));
|
||||||
size: activeSize * h,
|
|
||||||
thinning: activeTool === 'highlighter' ? 0 : 0.5,
|
|
||||||
smoothing: 0.5,
|
|
||||||
streamline: 0.4,
|
|
||||||
});
|
|
||||||
ctx.globalAlpha = activeTool === 'highlighter' ? 0.35 : 1;
|
ctx.globalAlpha = activeTool === 'highlighter' ? 0.35 : 1;
|
||||||
ctx.fillStyle = activeColor;
|
ctx.fillStyle = activeColor;
|
||||||
ctx.fill(new Path2D(pathFromStroke(outline)));
|
ctx.fill(new Path2D(pathFromStroke(outline)));
|
||||||
|
|||||||
Reference in new issue
Block a user