adding backup and data perstance accross devices
This commit is contained in:
+73
-15
@@ -11,6 +11,13 @@ import {
|
||||
WidthType,
|
||||
} from 'docx';
|
||||
|
||||
import {
|
||||
saveRemoteProject,
|
||||
deleteRemoteProject,
|
||||
listRemoteProjects,
|
||||
loadRemoteProject,
|
||||
} from './syncService.js';
|
||||
|
||||
const bookOptions = [
|
||||
{ name: 'Matthew', abbrev: 'MAT' },
|
||||
{ name: 'Mark', abbrev: 'MRK' },
|
||||
@@ -512,13 +519,24 @@ const App = () => {
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const saveTimerRef = useRef(null);
|
||||
|
||||
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
||||
const [remoteOnlyProjects, setRemoteOnlyProjects] = useState([]); // projects on server not in localStorage
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup: migrate old keys and load index
|
||||
// ---------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
migrateOldStorageKeys();
|
||||
setProjectIndex(loadProjectIndex());
|
||||
const localIndex = loadProjectIndex();
|
||||
setProjectIndex(localIndex);
|
||||
|
||||
// Check server for any projects not present locally (cross-device restore)
|
||||
listRemoteProjects().then((result) => {
|
||||
if (!result.ok) return;
|
||||
const localIds = new Set(localIndex.map((e) => e.id));
|
||||
const missing = result.data.filter((e) => !localIds.has(e.id));
|
||||
if (missing.length > 0) setRemoteOnlyProjects(missing);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -555,14 +573,21 @@ const App = () => {
|
||||
}, [project?.selectedChunkId]);
|
||||
|
||||
// Autosave
|
||||
useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!project) return;
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = window.setTimeout(() => {
|
||||
saveTimerRef.current = window.setTimeout(async () => {
|
||||
// 1. Always save locally first
|
||||
saveProjectToStorage(project);
|
||||
setProjectIndex(loadProjectIndex());
|
||||
setSaveStatus('Saved');
|
||||
window.setTimeout(() => setSaveStatus(''), 1400);
|
||||
|
||||
// 2. Then sync to server (non-blocking — failures are silent)
|
||||
setSyncStatus('syncing');
|
||||
const result = await saveRemoteProject(project);
|
||||
setSyncStatus(result.ok ? 'synced' : 'error');
|
||||
window.setTimeout(() => setSyncStatus(''), 2500);
|
||||
}, 1000);
|
||||
return () => {
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
@@ -1145,12 +1170,24 @@ const App = () => {
|
||||
setCurrentPage('setup');
|
||||
};
|
||||
|
||||
const deleteProject = (id) => {
|
||||
const deleteProject = (id) => {
|
||||
if (!window.confirm('Delete this project? This cannot be undone.')) return;
|
||||
deleteProjectFromStorage(id);
|
||||
setProjectIndex(loadProjectIndex());
|
||||
deleteRemoteProject(id); // fire-and-forget
|
||||
};
|
||||
|
||||
const restoreRemoteProject = async (id) => {
|
||||
const result = await loadRemoteProject(id);
|
||||
if (!result.ok) {
|
||||
alert('Could not restore project from server.');
|
||||
return;
|
||||
}
|
||||
saveProjectToStorage(result.data);
|
||||
setProjectIndex(loadProjectIndex());
|
||||
setRemoteOnlyProjects((prev) => prev.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
setProjectIndex(loadProjectIndex());
|
||||
setCurrentPage('home');
|
||||
@@ -1212,16 +1249,18 @@ const App = () => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-right text-sm text-slate-300">
|
||||
{loadingChapter ? (
|
||||
<span>Loading…</span>
|
||||
) : saveStatus ? (
|
||||
<span className="text-emerald-300">{saveStatus}</span>
|
||||
) : (
|
||||
<span> </span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-slate-300 space-y-0.5">
|
||||
{loadingChapter ? (
|
||||
<span>Loading…</span>
|
||||
) : saveStatus ? (
|
||||
<span className="text-emerald-300">{saveStatus}</span>
|
||||
) : (
|
||||
<span> </span>
|
||||
)}
|
||||
{syncStatus === 'syncing' && <div className="text-xs text-slate-400">Syncing…</div>}
|
||||
{syncStatus === 'synced' && <div className="text-xs text-emerald-400">Synced ✓</div>}
|
||||
{syncStatus === 'error' && <div className="text-xs text-amber-400">Sync failed (saved locally)</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1246,6 +1285,25 @@ const App = () => {
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{remoteOnlyProjects.length > 0 && (
|
||||
<div className="mb-6 rounded-2xl border border-sky-200 bg-sky-50 p-4">
|
||||
<p className="mb-3 text-sm font-semibold text-sky-800">
|
||||
📥 {remoteOnlyProjects.length} project{remoteOnlyProjects.length > 1 ? 's' : ''} found on the server that aren't saved locally:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{remoteOnlyProjects.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => restoreRemoteProject(entry.id)}
|
||||
className="rounded-xl bg-sky-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sky-600"
|
||||
>
|
||||
Restore "{entry.title}"
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{projectIndex.length === 0 ? (
|
||||
<div className="mx-auto max-w-xl rounded-3xl border border-dashed border-slate-300 bg-white p-10 text-center shadow-panel">
|
||||
<p className="text-lg font-semibold text-slate-700">No projects yet</p>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* syncService.js
|
||||
*
|
||||
* Thin wrapper around the /api/projects endpoints.
|
||||
* All functions are fire-and-forget friendly: they never throw — they
|
||||
* return { ok: true, data } or { ok: false, error }.
|
||||
*
|
||||
* The caller decides whether to surface the error to the user.
|
||||
*/
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
async function request(method, path, body) {
|
||||
try {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
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, error: data?.error ?? `HTTP ${res.status}` };
|
||||
}
|
||||
return { ok: true, data };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err?.message ?? 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List all remote project summaries.
|
||||
* Returns { ok, data: Array<{ id, title, lastEdited, chapterSummary }> }
|
||||
*/
|
||||
export async function listRemoteProjects() {
|
||||
return request('GET', '/projects');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single full project by id.
|
||||
* Returns { ok, data: project }
|
||||
*/
|
||||
export async function loadRemoteProject(id) {
|
||||
return request('GET', `/projects/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save (create or update) a project on the server.
|
||||
* Returns { ok, data: summary }
|
||||
*/
|
||||
export async function saveRemoteProject(project) {
|
||||
return request('PUT', `/projects/${project.id}`, project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project from the server.
|
||||
* Returns { ok }
|
||||
*/
|
||||
export async function deleteRemoteProject(id) {
|
||||
return request('DELETE', `/projects/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the server is reachable.
|
||||
* Returns true / false.
|
||||
*/
|
||||
export async function isServerReachable() {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/health`, { method: 'GET' });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user