Build and Push Docker Image / build-and-push (push) Successful in 5m13s
- 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>
119 lines
6.3 KiB
React
119 lines
6.3 KiB
React
// 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>
|
||
);
|
||
}
|