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

+26 -4
View File
@@ -5,7 +5,10 @@ import { useRef, useState } from 'react';
import Sheet from '../reader/Sheet.jsx';
import TranslationSelect from './TranslationSelect.jsx';
import { resizeImageToDataUrl } from './CustomPlanBuilder.jsx';
import { formatDayReadings, getPlanSchedule, daysBehindPace } from '../../utils/readingPlans.js';
import { formatDayReadings, getPlanSchedule, daysBehindPace, nextUnreadDayIndex } from '../../utils/readingPlans.js';
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
import { DownloadProgress } from '../OfflineSave.jsx';
import { canSaveOffline } from '../../utils/offline.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm hover:bg-black/5';
const primary = 'w-full rounded-xl bg-[var(--r-accent)] py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50';
@@ -14,6 +17,13 @@ function ActiveBody({ plan, progress, onClose, actions }) {
const schedule = getPlanSchedule(plan.id);
const behind = daysBehindPace(plan, progress);
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
const offline = useOfflineSave();
const translation = progress.translation ?? actions.defaultTranslation;
// The next week of readings, so a trip or a bad connection doesn't interrupt the plan
const upcoming = (() => {
const from = nextUnreadDayIndex(plan, progress.completedDays);
return from == null ? [] : schedule.slice(from, from + 7).flat();
})();
return (
<>
<div className="mb-3 flex items-center justify-between text-sm">
@@ -42,9 +52,21 @@ function ActiveBody({ plan, progress, onClose, actions }) {
);
})}
</div>
{upcoming.length > 0 && canSaveOffline(translation) && (
<div className="mt-3">
{offline.job
? <DownloadProgress job={offline.job} onCancel={offline.cancel} />
: (
<button type="button" onClick={() => offline.saveRefs('the next 7 days', translation, upcoming)}
className="w-full rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs text-[var(--r-sub)] hover:bg-black/5">
Save the next 7 days for offline ({translation})
</button>
)}
</div>
)}
<div className="mt-4 flex items-center justify-between text-xs">
<button type="button" onClick={() => { if (window.confirm('Restart this plan from day 1?')) { actions.restart(progress.id); onClose(); } }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
<button type="button" onClick={() => { if (window.confirm('Remove this plan?')) { actions.remove(progress.id); onClose(); } }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
<button type="button" onClick={() => { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
<button type="button" onClick={() => { actions.remove(progress.id); onClose(); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
</div>
</>
);
@@ -92,7 +114,7 @@ function CatalogBody({ plan, active, completedCount, onClose, actions }) {
<button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button>
<button type="button" onClick={() => fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}</button>
{plan.image && <button type="button" onClick={() => actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove cover</button>}
<button type="button" onClick={() => { if (window.confirm('Delete this plan? Its progress history will be removed too.')) { actions.delete(plan.id); onClose(); } }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete</button>
<button type="button" onClick={() => { actions.delete(plan.id); onClose(); }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete</button>
<input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
{imageError && <p className="w-full text-[var(--r-danger,#b42318)]">{imageError}</p>}
</div>