diff --git a/server/db.js b/server/db.js index 249ef0f..a8af867 100644 --- a/server/db.js +++ b/server/db.js @@ -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. diff --git a/server/index.js b/server/index.js index dcc7b9c..ed0e664 100644 --- a/server/index.js +++ b/server/index.js @@ -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 // --------------------------------------------------------------------------- diff --git a/src/App.jsx b/src/App.jsx index ffe54bb..e46216e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1199,6 +1199,8 @@ const App = () => { try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); } 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); @@ -1558,6 +1560,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) => { @@ -1610,7 +1621,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);