Files
study-bible-project/src/components/settings/DataSection.jsx
T
nmemmertandClaude Sonnet 5.5 207bfcb919
Build and Push Docker Image / build-and-push (push) Successful in 5m13s
Version history, notes on highlights and bookmarks, "you've studied this", prayer journal
- Earlier versions of a study: the server keeps compressed copies (about one per ten minutes of
  editing, the latest 30); Export shows what differs and puts one back (the replaced copy is kept)
- Notes on highlights and bookmarks: synced, merged without loss, in exports and backups
- Reader verse panel lists your studies and chunks that cover the verse, and opens them
- Prayer journal: requests, who for, linked verses, days prayed, answers; synced, in backups
  and the account export; daily prayer reminder through ntfy
- TODO.md lists the ideas not built yet

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 10:45:32 -04:00

119 lines
6.3 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, readerBookmarkNotes, readerTextHighlights, readerColorLabels, readerInkByPage, readingPlansState,
importReaderMarks, importReadingPlans, refreshProjectIndex, prayers, importPrayers,
} = 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, bookmarkNotes: readerBookmarkNotes, highlights: readerTextHighlights, colorLabels: readerColorLabels, ink: readerInkByPage },
readingPlans: readingPlansState,
prayers,
});
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);
importPrayers?.(backup.prayers);
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.{authUser ? ' “Download backup” is what’s on this device; “Download from my account” is everything the server holds. The server also keeps a daily snapshot for the administrator.' : ''}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={download} className={btn}>Download backup</button>
{authUser && (
<a href="/api/account/export" download className={`${btn} inline-block`} title="Everything saved to your account on the server, including studies from other devices">Download from my account</a>
)}
<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')}, {plural(s.plans, 'reading plan')} and {plural(s.prayers ?? 0, 'prayer')}.
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>
);
}