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,
|
||||
"autoPort": false
|
||||
},
|
||||
{
|
||||
"name": "study-app-alt",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5174"],
|
||||
"port": 5174,
|
||||
"autoPort": false
|
||||
},
|
||||
{
|
||||
"name": "study-app-server",
|
||||
"runtimeExecutable": "npm",
|
||||
|
||||
@@ -22,10 +22,8 @@ _Refreshed 2026-07-06 (multiple passes) — items already shipped have been remo
|
||||
## UX / UI
|
||||
|
||||
### 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
|
||||
- **Whole-Bible search index isn't persisted** — the ~7MB `complete.json` fetch is cached in-memory only for the session; a page reload re-downloads it. Worth persisting to IndexedDB (not localStorage — too small) if this gets used often
|
||||
- **Bookmark color picker is still an emoji button** (🎨) — the bookmark/copy icons became proper SVGs, but color-cycling didn't get the same treatment
|
||||
|
||||
### Home Page
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
|
||||
<!-- iOS PWA -->
|
||||
<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" />
|
||||
<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');
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
@@ -146,6 +158,30 @@ export function setBackupCodeHashes(userId, backupCodeHashes) {
|
||||
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)
|
||||
* to the given user. Intended to run once, right after the first account is created.
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||
setUserPassword, destroyAllSessionsForUser,
|
||||
getAllReaderInk, setReaderInkPage,
|
||||
} from './db.js';
|
||||
import { SqliteSessionStore } from './sessionStore.js';
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+202
-9
@@ -71,6 +71,18 @@ export const STUDY_TABS = [
|
||||
{ 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 = [
|
||||
{ name: 'Genesis', abbrev: 'GEN' },
|
||||
{ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1157,6 +1219,11 @@ const App = () => {
|
||||
const [homeSearch, setHomeSearch] = useState('');
|
||||
const [homeSort, setHomeSort] = useState('recent'); // 'recent' | 'title' | 'passage'
|
||||
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 [renameValue, setRenameValue] = useState('');
|
||||
const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev);
|
||||
@@ -1199,6 +1266,14 @@ const App = () => {
|
||||
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
|
||||
catch { return {}; }
|
||||
});
|
||||
const [readerTextHighlights, setReaderTextHighlights] = useState(() => {
|
||||
try {
|
||||
const val = JSON.parse(localStorage.getItem('readerTextHighlights') || '[]');
|
||||
return Array.isArray(val) ? val : [];
|
||||
} catch { return []; }
|
||||
});
|
||||
// Debounce timer ref so rapid stroke updates don't flood the server
|
||||
const inkSaveTimerRef = useRef({});
|
||||
const audioRef = useRef(null);
|
||||
const audioModeRef = useRef('book'); // 'book' | 'reader' — which player owns audioRef
|
||||
const audioBookRef = useRef(audioBook);
|
||||
@@ -1319,7 +1394,7 @@ const App = () => {
|
||||
); // 'stacked' | 'split' | 'annotate'
|
||||
const [drawTool, setDrawTool] = useState('pen');
|
||||
const [drawColor, setDrawColor] = useState('#0f172a');
|
||||
const [drawSize, setDrawSize] = useState(0.012);
|
||||
const [drawSize, setDrawSize] = useState(0.003);
|
||||
const [activeStudyTab, setActiveStudyTab] = useState(
|
||||
() => localStorage.getItem('activeStudyTab') || 'notes',
|
||||
);
|
||||
@@ -1332,6 +1407,39 @@ const App = () => {
|
||||
useEffect(() => {
|
||||
localStorage.setItem('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 [collapsedSections, setCollapsedSections] = useState({});
|
||||
const [commentarySource, setCommentarySource] = useState('matthew-henry');
|
||||
@@ -1380,6 +1488,14 @@ const App = () => {
|
||||
}
|
||||
setBibleIndexStatus('loading');
|
||||
try {
|
||||
// Check IndexedDB cache before hitting the network (~7 MB fetch)
|
||||
const cached = await getBibleIndexFromIDB('BSB');
|
||||
if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
|
||||
_bibleIndexCacheRef.current.BSB = cached.flat;
|
||||
setBibleIndexStatus('ready');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch('https://bible.helloao.org/api/BSB/complete.json');
|
||||
if (!res.ok) throw new Error('Unable to load the full Bible for search.');
|
||||
const data = await res.json();
|
||||
@@ -1396,6 +1512,8 @@ const App = () => {
|
||||
}
|
||||
_bibleIndexCacheRef.current.BSB = flat;
|
||||
setBibleIndexStatus('ready');
|
||||
// Persist for future visits — non-blocking
|
||||
saveBibleIndexToIDB('BSB', flat);
|
||||
} catch {
|
||||
setBibleIndexStatus('error');
|
||||
}
|
||||
@@ -1558,6 +1676,15 @@ const App = () => {
|
||||
try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
// Debounce server sync — wait 1 s after last stroke before sending
|
||||
clearTimeout(inkSaveTimerRef.current[key]);
|
||||
inkSaveTimerRef.current[key] = setTimeout(() => {
|
||||
fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ strokes }),
|
||||
}).catch(() => {}); // silent — localStorage already saved locally
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const cycleBookmarkColor = (verseKey) => {
|
||||
@@ -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 cacheKey = `${bookAbbrev}/${chapterNumber}`;
|
||||
if (_readerCrossRefCacheRef.current[cacheKey]) {
|
||||
@@ -1610,7 +1754,21 @@ const App = () => {
|
||||
getCurrentUser().then((result) => {
|
||||
if (result.ok) {
|
||||
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 {
|
||||
// Genuine network failure (server unreachable) — fall back to local-only mode.
|
||||
setAuthServerDown(true);
|
||||
@@ -3220,8 +3378,9 @@ const App = () => {
|
||||
if (!project) return;
|
||||
const prompt = buildClaudePrompt(project);
|
||||
navigator.clipboard.writeText(prompt).then(() => {
|
||||
setSaveStatus('Copied for Claude!');
|
||||
window.setTimeout(() => setSaveStatus(''), 2000);
|
||||
setSaveStatus('Copied! Opening Claude…');
|
||||
window.setTimeout(() => setSaveStatus(''), 3000);
|
||||
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3229,11 +3388,36 @@ const App = () => {
|
||||
if (!project) return;
|
||||
const prompt = buildPodcastPrompt(project, authUser?.podcastName);
|
||||
navigator.clipboard.writeText(prompt).then(() => {
|
||||
setSaveStatus('Copied podcast prep!');
|
||||
window.setTimeout(() => setSaveStatus(''), 2000);
|
||||
setSaveStatus('Copied! Opening Claude…');
|
||||
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 = () => {
|
||||
if (!project) return;
|
||||
const guide = buildPronunciationGuide(project);
|
||||
@@ -3521,17 +3705,18 @@ const deleteProject = (id) => {
|
||||
onClick={copyForClaude}
|
||||
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">Prepare for </span>Claude
|
||||
<span className="hidden sm:inline">Study Guide → </span>Claude ↗
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyForPodcast}
|
||||
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"
|
||||
>
|
||||
🎙<span className="hidden sm:inline"> Prepare for Podcast</span>
|
||||
🎙<span className="hidden sm:inline"> Podcast → Claude ↗</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -3700,6 +3885,11 @@ const deleteProject = (id) => {
|
||||
homeSearch, setHomeSearch,
|
||||
homeSort, setHomeSort,
|
||||
homeTagFilter, setHomeTagFilter,
|
||||
homeFullTextResults,
|
||||
readingPlan,
|
||||
createReadingPlan,
|
||||
markChapterRead,
|
||||
clearReadingPlan,
|
||||
renamingId, setRenamingId,
|
||||
renameValue, setRenameValue,
|
||||
openBibleReader,
|
||||
@@ -3747,6 +3937,9 @@ const deleteProject = (id) => {
|
||||
readerAudioState,
|
||||
readerInkByPage,
|
||||
updateReaderPageInk,
|
||||
readerTextHighlights,
|
||||
addTextHighlight,
|
||||
removeTextHighlight,
|
||||
bibleIndexStatus,
|
||||
loadReaderChapter,
|
||||
_bibleIndexCacheRef,
|
||||
|
||||
@@ -30,6 +30,13 @@ select {
|
||||
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 {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { renderInkToCanvas } from '../utils/inkRender.js';
|
||||
|
||||
const INK_COLORS = ['#0f172a', '#ef4444', '#3b82f6', '#16a34a', '#f59e0b', '#a855f7'];
|
||||
const INK_SIZES = [
|
||||
{ label: 'XS', value: 0.003 },
|
||||
{ label: 'S', value: 0.006 },
|
||||
{ label: 'M', value: 0.012 },
|
||||
{ label: 'L', value: 0.025 },
|
||||
@@ -38,6 +39,7 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
||||
const { realStrokes } = extractMeta(strokes);
|
||||
|
||||
const canvasRef = useRef(null);
|
||||
const hoverRef = useRef(null);
|
||||
const activeStrokeRef = useRef([]);
|
||||
const isDrawingRef = useRef(false);
|
||||
|
||||
@@ -78,14 +80,56 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
||||
return () => obs.disconnect();
|
||||
}, [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 canvas = canvasRef.current;
|
||||
if (!canvas) return [0, 0, 0.5];
|
||||
if (!canvas) return [0, 0, 0.5, 0];
|
||||
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 [
|
||||
(e.clientX - rect.left) / rect.width,
|
||||
(e.clientY - rect.top) / rect.height,
|
||||
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) {
|
||||
function pointFromTouch(t) {
|
||||
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 [
|
||||
(t.clientX - rect.left) / rect.width,
|
||||
(t.clientY - rect.top) / rect.height,
|
||||
t.force ?? 0.5,
|
||||
tilt,
|
||||
];
|
||||
}
|
||||
|
||||
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) {
|
||||
if (t.touchType !== 'stylus') continue;
|
||||
e.preventDefault();
|
||||
if (hoverRef.current) hoverRef.current.style.display = 'none';
|
||||
isDrawingRef.current = true;
|
||||
activeStrokeRef.current = [pointFromTouch(t)];
|
||||
render();
|
||||
@@ -350,6 +407,20 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
||||
{!headerContent && (
|
||||
<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) */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
|
||||
+147
-1
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { bookOptions, formatRelativeDate } from '../App.jsx';
|
||||
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../App.jsx';
|
||||
|
||||
export default function HomePage() {
|
||||
const {
|
||||
@@ -10,6 +11,10 @@ export default function HomePage() {
|
||||
homeSearch, setHomeSearch,
|
||||
homeSort, setHomeSort,
|
||||
homeTagFilter, setHomeTagFilter,
|
||||
homeFullTextResults,
|
||||
readingPlan,
|
||||
createReadingPlan,
|
||||
clearReadingPlan,
|
||||
audioBook, setAudioBook,
|
||||
audioNarrator, setAudioNarrator,
|
||||
audioState,
|
||||
@@ -145,6 +150,41 @@ export default function HomePage() {
|
||||
);
|
||||
})()}
|
||||
</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="flex-1">
|
||||
<h3 className="text-base font-semibold text-slate-900">Listen to BSB Audio</h3>
|
||||
@@ -317,3 +357,109 @@ export default function HomePage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+577
-124
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { bookOptions, BookmarkIcon, CopyIcon } from '../App.jsx';
|
||||
import DrawCanvas from './DrawCanvas.jsx';
|
||||
@@ -45,11 +45,180 @@ export default function ReaderPage() {
|
||||
setReaderCrossRefs,
|
||||
readerInkByPage,
|
||||
updateReaderPageInk,
|
||||
readerTextHighlights,
|
||||
addTextHighlight,
|
||||
removeTextHighlight,
|
||||
readingPlan,
|
||||
markChapterRead,
|
||||
} = useApp();
|
||||
|
||||
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
|
||||
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
||||
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
|
||||
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? [];
|
||||
const [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 [bAbbrev, chapterStr, verseStr] = key.split('-');
|
||||
@@ -84,7 +253,14 @@ export default function ReaderPage() {
|
||||
</div>
|
||||
</div>
|
||||
</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 */}
|
||||
<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">
|
||||
@@ -112,6 +288,19 @@ export default function ReaderPage() {
|
||||
</select>
|
||||
</label>
|
||||
<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}
|
||||
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
|
||||
@@ -216,8 +405,35 @@ export default function ReaderPage() {
|
||||
>
|
||||
✏ Draw
|
||||
</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>{/* end collapsible toolbar */}
|
||||
|
||||
{/* Whole-Bible search results */}
|
||||
{readerSearchActive && readerSearchScope === 'bible' && (
|
||||
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-6 shadow-panel">
|
||||
@@ -299,152 +515,299 @@ export default function ReaderPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verses — hidden when draw mode is active */}
|
||||
<div className={`rounded-3xl border border-slate-200 bg-white p-6 shadow-panel${readerDrawMode ? ' hidden' : ''}`}>
|
||||
<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="mb-4 text-xs text-slate-400">Click a verse number to see original words & pronunciation · bookmark icon to save · copy icon to copy</p>
|
||||
)}
|
||||
{!readerInterlinear && (
|
||||
<p className="mb-4 text-xs text-slate-400">Hover a verse for actions</p>
|
||||
)}
|
||||
{readerLoading && <p className="text-sm text-slate-500">Loading…</p>}
|
||||
{readerError && <p className="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="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3 leading-relaxed text-slate-800" style={{ fontSize: `${readerFontSize}em` }}>
|
||||
{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];
|
||||
{/* Verses — normal scroll mode */}
|
||||
{!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">
|
||||
{readerBook?.name} {readerChapter} <span className="text-sm font-normal text-slate-500">(BSB)</span>
|
||||
</h2>
|
||||
{readerInterlinear && (
|
||||
<p className="mb-4 text-xs text-slate-400">Click a verse number to see original words & pronunciation · bookmark icon to save · copy icon to copy</p>
|
||||
)}
|
||||
{!readerInterlinear && (
|
||||
<p className="mb-4 text-xs text-slate-400">Hover a verse for actions</p>
|
||||
)}
|
||||
{readerLoading && <p className="text-sm text-slate-500">Loading…</p>}
|
||||
{readerError && <p className="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="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
|
||||
}
|
||||
return (
|
||||
<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) => {
|
||||
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];
|
||||
|
||||
const highlightText = (text) => {
|
||||
if (!searchLower) return text;
|
||||
const idx = text.toLowerCase().indexOf(searchLower);
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<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' } : {}}>
|
||||
<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">{highlightText(verse.text)}</p>
|
||||
<span className="ml-1 mt-0.5 flex shrink-0 items-center gap-1">
|
||||
<div key={verse.number} id={`reader-verse-${verse.number}`} data-verse-num={verse.number}
|
||||
className="group break-inside-avoid rounded-xl transition mb-3"
|
||||
style={bmColor ? { backgroundColor: bmColor + '55', borderLeft: `3px solid ${bmColor}`, paddingLeft: '0.5rem' } : {}}>
|
||||
<div className="flex items-start 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} />
|
||||
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>
|
||||
{bmColor && (
|
||||
<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={() => cycleBookmarkColor(verseKey)}
|
||||
className="rounded p-1 text-sm leading-none hover:bg-slate-100"
|
||||
title="Change highlight colour">
|
||||
🎨
|
||||
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>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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');
|
||||
{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 (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
})()}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</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 */}
|
||||
{readerDrawMode && (
|
||||
<div className="flex flex-col items-start gap-4 sm:flex-row">
|
||||
{/* 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">
|
||||
<span className="font-semibold text-slate-900">{readerBook?.name} {readerChapter}</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>
|
||||
{!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>{' '}
|
||||
{v.text}
|
||||
{renderVerseText(v.number, v.text, '')}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
@@ -459,6 +822,96 @@ export default function ReaderPage() {
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+69
-23
@@ -76,7 +76,7 @@ export default function StudyPage() {
|
||||
</div>
|
||||
</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]'}`}>
|
||||
{/* 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' : ''}`}>
|
||||
@@ -515,6 +515,47 @@ export default function StudyPage() {
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
{/* Inline ink notes — collapsible, always available without switching to annotate mode */}
|
||||
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'notes' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsedSections((c) => ({ ...c, inlineInk: !c.inlineInk }))}
|
||||
className="flex w-full items-center justify-between gap-2 text-left"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">Ink Notes</h3>
|
||||
<p className="text-xs text-slate-500">Draw diagrams or handwritten notes for this chunk.</p>
|
||||
</div>
|
||||
<span className="text-slate-400">{collapsedSections.inlineInk ? '▸' : '▾'}</span>
|
||||
</button>
|
||||
{!collapsedSections.inlineInk && (
|
||||
<div className="mt-4">
|
||||
<DrawCanvas
|
||||
strokes={selectedChunk.inkStrokes ?? []}
|
||||
onStrokesChange={(s) => updateChunk(selectedChunk.id, { inkStrokes: s })}
|
||||
headerContent={
|
||||
selectedChunkVerses.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Scripture · draw on me</span>
|
||||
<span className="rounded-full bg-slate-200 px-2.5 py-0.5 text-xs font-semibold text-slate-600">
|
||||
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '–')}
|
||||
</span>
|
||||
</div>
|
||||
{selectedChunkVerses.map((verse) => (
|
||||
<p key={`${verse.chapter}-${verse.number}`} className="mb-1">
|
||||
<span className="font-semibold text-slate-900">{verse.chapter}:{verse.number}.</span>{' '}
|
||||
{verse.text}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cross-references */}
|
||||
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'crossRefs' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'crossRefs' ? 'hidden' : ''}`}>
|
||||
<button
|
||||
@@ -902,28 +943,6 @@ export default function StudyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prev / Next — sticky */}
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToPreviousChunk}
|
||||
disabled={selectedChunkGlobalIndex <= 0}
|
||||
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
← Previous Chunk
|
||||
</button>
|
||||
<span className="text-xs text-slate-400">
|
||||
{selectedChunkGlobalIndex + 1} / {allChunks.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToNextChunk}
|
||||
disabled={selectedChunkGlobalIndex >= allChunks.length - 1}
|
||||
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Next Chunk →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-3xl border border-slate-200 bg-slate-50 p-8 text-center text-slate-500">
|
||||
@@ -935,6 +954,33 @@ export default function StudyPage() {
|
||||
</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
|
||||
type="button"
|
||||
onClick={goToPreviousChunk}
|
||||
disabled={selectedChunkGlobalIndex <= 0}
|
||||
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
← Previous Chunk
|
||||
</button>
|
||||
<span className="text-xs text-slate-400">
|
||||
{selectedChunkGlobalIndex + 1} / {allChunks.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToNextChunk}
|
||||
disabled={selectedChunkGlobalIndex >= allChunks.length - 1}
|
||||
className="rounded-2xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Next Chunk →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Greek word picker modal */}
|
||||
{suggestModal && createPortal(
|
||||
<div
|
||||
|
||||
+15
-12
@@ -33,14 +33,22 @@ export function renderInkToCanvas(
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
for (const s of strokes) {
|
||||
const pts = s.points.map(([x, y, p]) => [x * w, y * h, p]);
|
||||
const outline = getStroke(pts, {
|
||||
size: s.size * h,
|
||||
thinning: s.tool === 'highlighter' ? 0 : 0.5,
|
||||
function strokeOpts(tool, baseSize, points) {
|
||||
const avgTilt = points.length
|
||||
? points.reduce((s, p) => s + (p[3] ?? 0), 0) / points.length
|
||||
: 0;
|
||||
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,
|
||||
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.fillStyle = s.color;
|
||||
ctx.fill(new Path2D(pathFromStroke(outline)));
|
||||
@@ -48,12 +56,7 @@ export function renderInkToCanvas(
|
||||
|
||||
if (activeStroke.length > 1 && activeTool !== 'eraser') {
|
||||
const pts = activeStroke.map(([x, y, p]) => [x * w, y * h, p]);
|
||||
const outline = getStroke(pts, {
|
||||
size: activeSize * h,
|
||||
thinning: activeTool === 'highlighter' ? 0 : 0.5,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.4,
|
||||
});
|
||||
const outline = getStroke(pts, strokeOpts(activeTool, activeSize * h, activeStroke));
|
||||
ctx.globalAlpha = activeTool === 'highlighter' ? 0.35 : 1;
|
||||
ctx.fillStyle = activeColor;
|
||||
ctx.fill(new Path2D(pathFromStroke(outline)));
|
||||
|
||||
Reference in new issue
Block a user