Sync reader draw ink to server for cross-device persistence
Previously reader ink was localStorage-only so it never followed the user to a different browser or device. - New reader_ink table (user_id, book_abbrev, chapter, strokes JSON) - GET /api/reader/ink — loads all pages for the signed-in user - PUT /api/reader/ink/:book/:chapter — upserts one page - On startup, client fetches server ink and merges it over localStorage (server wins so the most-recently saved version always wins) - updateReaderPageInk debounces a PUT call 1 s after the last stroke, so rapid Apple Pencil strokes don't flood the server Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+26
-1
@@ -1199,6 +1199,8 @@ const App = () => {
|
|||||||
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
|
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
|
||||||
catch { return {}; }
|
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);
|
||||||
@@ -1558,6 +1560,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) => {
|
||||||
@@ -1610,7 +1621,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);
|
||||||
|
|||||||
Reference in New Issue
Block a user