Files
study-bible-project/src/components/settings/DataSection.jsx
T
nmemmertandClaude Sonnet 5.5 d13f55faf7 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>
2026-10-01 14:08:08 -04:00

114 lines
5.7 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Settings → "Your data": download everything as one file, restore from one, and save Bibles for offline.
import { useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { useToast } from '../Toasts.jsx';
import { DownloadProgress } from '../OfflineSave.jsx';
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
import { backupFileName, buildBackup, restoreProjects, validateBackup } from '../../utils/backup.js';
import { saveRemoteProject } from '../../syncService.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
export default function DataSection() {
const {
authUser, readerBookmarks, readerTextHighlights, readerColorLabels, readerInkByPage, readingPlansState,
importReaderMarks, importReadingPlans, refreshProjectIndex,
} = useApp();
const { toast } = useToast();
const offline = useOfflineSave();
const fileRef = useRef(null);
const [pending, setPending] = useState(null); // { name, backup, summary } waiting for "Restore"
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const download = () => {
const backup = buildBackup({
email: authUser?.email ?? null,
reader: { bookmarks: readerBookmarks, highlights: readerTextHighlights, colorLabels: readerColorLabels, ink: readerInkByPage },
readingPlans: readingPlansState,
});
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = backupFileName();
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
toast(`Backup downloaded: ${plural(backup.projects.length, 'study', 'studies')} and your reading marks`);
};
const choose = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
setError('');
setPending(null);
if (!file) return;
let data;
try { data = JSON.parse(await file.text()); } catch { setError('That file couldn’t be read as a backup.'); return; }
const checked = validateBackup(data);
if (!checked.ok) { setError(checked.error); return; }
setPending({ name: file.name, ...checked });
};
const restore = async () => {
setBusy(true);
const { backup } = pending;
const written = restoreProjects(backup.projects);
importReaderMarks(backup.reader);
importReadingPlans(backup.readingPlans);
refreshProjectIndex();
// Studies restored here also go to the account, so they follow you to other devices
if (authUser) await Promise.all(written.map((p) => saveRemoteProject(p)));
setBusy(false);
setPending(null);
toast(`Restored ${plural(written.length, 'study', 'studies')}, plus your bookmarks, highlights and plans`);
};
const s = pending?.summary;
return (
<section id="backup" aria-label="Your data" className="scroll-mt-16 space-y-5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
<div>
<h2 className="text-base font-medium">Your data</h2>
<p className="mt-1 text-sm text-[var(--r-sub)]">Everything you’ve made — studies and notes, bookmarks, highlights, ink and reading plans — in one file you keep.</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={download} className={btn}>Download backup</button>
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
</div>
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{pending && (
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 text-sm">
<p className="font-medium">{pending.name}</p>
<p className="mt-1 text-[var(--r-sub)]">
Contains {plural(s.projects, 'study', 'studies')}, {plural(s.bookmarks, 'bookmark')}, {plural(s.highlights, 'highlight')}, {plural(s.inkPages, 'ink page')} and {plural(s.plans, 'reading plan')}.
Restoring adds these to what you have — nothing is deleted, and a study on this device is only replaced if the backup’s copy is newer.
</p>
<div className="mt-3 flex gap-2">
<button type="button" onClick={restore} disabled={busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{busy ? 'Restoring…' : 'Restore'}</button>
<button type="button" onClick={() => setPending(null)} className={btn}>Cancel</button>
</div>
</div>
)}
<div className="border-t border-[var(--r-line)] pt-4">
<h3 className="text-sm font-medium">Read offline</h3>
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.</p>
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
<div className="flex flex-wrap gap-3">
{['BSB', 'NET'].map((t) => (
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
</button>
))}
</div>
)}
</div>
</section>
);
}