Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more

Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 14:08:08 -04:00
1 parent 1edbd364ea
commit d13f55faf7
65 files changed
+2947 -208

No files matched your search

+11 -6
View File
@@ -10,18 +10,19 @@
const BASE = '/api';
async function request(method, path, body) {
async function request(method, path, body, { keepalive = false, headers = {} } = {}) {
try {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...headers },
credentials: 'include',
};
if (keepalive) opts.keepalive = true; // lets the request finish even as the tab closes
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts);
const data = await res.json().catch(() => null);
if (!res.ok) {
return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}` };
return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}`, data };
}
return { ok: true, data };
} catch (err) {
@@ -51,10 +52,14 @@ export async function loadRemoteProject(id) {
/**
* Save (create or update) a project on the server.
* `baseEdited` is the server copy's lastEdited this device last saw: if the server has moved on, the
* save is refused with { ok: false, status: 409, data: { serverEdited } }. `force` overwrites anyway.
* Returns { ok, data: summary }
*/
export async function saveRemoteProject(project) {
return request('PUT', `/projects/${project.id}`, project);
export async function saveRemoteProject(project, { baseEdited, force = false } = {}) {
return request('PUT', `/projects/${project.id}${force ? '?force=1' : ''}`, project, {
headers: Number.isFinite(baseEdited) ? { 'X-Base-Edited': String(baseEdited) } : {},
});
}
/**
@@ -62,7 +67,7 @@ export async function saveRemoteProject(project) {
* Returns { ok }
*/
export async function deleteRemoteProject(id) {
return request('DELETE', `/projects/${id}`);
return request('DELETE', `/projects/${id}`, undefined, { keepalive: true });
}
// ---------------------------------------------------------------------------