adding backup and data perstance accross devices

This commit is contained in:
nmemmert
2026-05-28 08:32:33 -04:00
parent 48b3790cd2
commit d8da3744ca
9 changed files with 479 additions and 66 deletions
+73 -15
View File
@@ -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>&nbsp;</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>&nbsp;</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>
+78
View File
@@ -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;
}
}