diff --git a/cmd/librenotes/web/public/app.html b/cmd/librenotes/web/public/app.html index 7c500d3..85ddb5d 100644 --- a/cmd/librenotes/web/public/app.html +++ b/cmd/librenotes/web/public/app.html @@ -13,6 +13,7 @@
librenotes + idle
@@ -20,11 +21,36 @@

Welcome

You're signed in. The full notes UI ships in a later phase; this - page exists to verify the auth + tenant-context wiring end to end.

+ page exercises auth, tenant context, offline cache, and sync.


   
+ +
+

Sync conflict

+

Note was modified both locally + and on the server since the last sync. Choose which version + to keep.

+
+
+

Local

+

+        
+
+

Server

+

+        
+
+ + + + +
+
+ + + diff --git a/cmd/librenotes/web/public/app.js b/cmd/librenotes/web/public/app.js index dccc4e8..3966e13 100644 --- a/cmd/librenotes/web/public/app.js +++ b/cmd/librenotes/web/public/app.js @@ -41,4 +41,44 @@ .catch(function (e) { document.getElementById("whoami-output").textContent = "error: " + e.message; }); + + // Sync state badge. + const $state = document.getElementById("sync-state"); + if ($state) { + window.addEventListener("librenotes:sync-state", function (ev) { + const s = ev.detail.state; + $state.textContent = s; + $state.dataset.state = s; + }); + } + + // Conflict dialog wiring. + const $dialog = document.getElementById("conflict-dialog"); + const $form = document.getElementById("conflict-form"); + let activeConflict = null; + if ($dialog) { + window.addEventListener("librenotes:sync-conflict", function (ev) { + activeConflict = ev.detail; + document.getElementById("conflict-id").textContent = activeConflict.id; + document.getElementById("conflict-local").textContent = + ((activeConflict.local && activeConflict.local.title) || "") + "\n\n" + + ((activeConflict.local && activeConflict.local.content) || ""); + document.getElementById("conflict-remote").textContent = + ((activeConflict.remote && activeConflict.remote.title) || "") + "\n\n" + + ((activeConflict.remote && activeConflict.remote.content) || ""); + if (typeof $dialog.showModal === "function") $dialog.showModal(); + }); + $form.addEventListener("submit", function (ev) { + const choice = ev.submitter && ev.submitter.value; + if (!activeConflict || !choice) return; + window.notesSync.resolveConflict(activeConflict.id, choice).catch(function (e) { + console.warn("resolve failed", e); + }); + activeConflict = null; + }); + } + + // Kick off the sync controller. It registers online/offline + // listeners and runs an initial reconciliation pass when online. + if (window.notesSync) window.notesSync.start(); })(); diff --git a/cmd/librenotes/web/public/style.css b/cmd/librenotes/web/public/style.css index 00bd2a7..0ddb877 100644 --- a/cmd/librenotes/web/public/style.css +++ b/cmd/librenotes/web/public/style.css @@ -258,3 +258,64 @@ a:hover { text-decoration: underline; } .auth-card { padding: 1.25rem; } .auth-card h1 { font-size: 1.25rem; } } + +/* Sync state badge in the app header. */ +.sync-state { + margin-left: auto; + font-size: 0.85rem; + padding: 0.2rem 0.6rem; + border-radius: 1rem; + background: var(--card); + color: var(--muted); + border: 1px solid var(--border); +} +.sync-state[data-state="syncing"] { color: var(--accent); } +.sync-state[data-state="synced"] { color: var(--success); } +.sync-state[data-state="offline"] { color: var(--muted); } +.sync-state[data-state="error"] { color: var(--error); } +.app-page .app-header .who { margin-left: 0; } + +/* Conflict dialog. */ +#conflict-dialog::backdrop { background: rgba(0,0,0,0.4); } +#conflict-dialog { + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--bg); + color: var(--fg); + padding: 1.5rem; + max-width: min(48rem, 90vw); + width: 100%; +} +.conflict-cols { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} +@media (max-width: 600px) { .conflict-cols { grid-template-columns: 1fr; } } +.conflict-cols pre { + background: var(--card); + border: 1px solid var(--border); + border-radius: 0.4rem; + padding: 0.75rem; + max-height: 30vh; + overflow: auto; + white-space: pre-wrap; +} +#conflict-form menu { + display: flex; + gap: 0.5rem; + margin: 1rem 0 0; + padding: 0; +} +#conflict-form button { + padding: 0.5rem 1rem; + border-radius: 0.35rem; + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-fg); + cursor: pointer; +} +#conflict-form button[value="remote"] { + background: transparent; + color: var(--accent); +} diff --git a/cmd/librenotes/web/public/sync.js b/cmd/librenotes/web/public/sync.js new file mode 100644 index 0000000..10c3119 --- /dev/null +++ b/cmd/librenotes/web/public/sync.js @@ -0,0 +1,208 @@ +// sync.js — background sync controller. +// +// Lifecycle: +// 1. start() registers online/offline listeners and kicks an +// initial sync if we are online. +// 2. push() walks notesCache.pending() and PUT/DELETEs each row +// against the server. ?base= drives optimistic +// locking; a 409 raises a conflict event. +// 3. pull() lists /api/notes and merges any remote rows whose +// server updated_at is newer than the local synced_at, or +// that are missing locally entirely. +// 4. After both pass, we publish a "synced" state. +// +// Conflicts are surfaced as DOM events on window: +// "librenotes:sync-state" detail = { state: "online" | "offline" | +// "syncing" | "synced" | +// "error" } +// "librenotes:sync-conflict" detail = { id, local, remote } +// +// The app shell listens for these and displays state badges + +// conflict modals. The conflict UI calls resolveConflict(...) to +// pick a side or merge. + +(function () { + "use strict"; + + let running = false; + let conflictsById = {}; + + function emit(name, detail) { + window.dispatchEvent(new CustomEvent("librenotes:" + name, { detail: detail })); + } + + function setState(s) { emit("sync-state", { state: s }); } + + function isOnline() { + return typeof navigator !== "undefined" ? navigator.onLine !== false : true; + } + + async function pushOne(note) { + const base = note.synced_at != null ? "?base=" + note.synced_at : ""; + if (note.deleted) { + const resp = await window.authClient.apiFetch( + "/api/notes/" + encodeURIComponent(note.id) + base, + { method: "DELETE" } + ); + if (resp.status === 409) { + // Server has a newer version. Pull will refetch it; meanwhile + // surface as a conflict so the user can decide. + conflictsById[note.id] = { id: note.id, local: note, remote: null }; + emit("sync-conflict", conflictsById[note.id]); + return; + } + if (resp.status === 204 || resp.status === 404) { + await window.notesCache.markSynced(note.id, null); + } + return; + } + const resp = await window.authClient.apiFetch( + "/api/notes/" + encodeURIComponent(note.id) + base, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: note.title || "", content: note.content || "" }), + } + ); + if (resp.status === 200) { + const body = await resp.json(); + await window.notesCache.markSynced(note.id, body.updated_at); + return; + } + if (resp.status === 409) { + const remote = await resp.json(); + conflictsById[note.id] = { id: note.id, local: note, remote: remote }; + emit("sync-conflict", conflictsById[note.id]); + return; + } + throw new Error("push " + note.id + " failed: " + resp.status); + } + + async function push() { + const queue = await window.notesCache.pending(); + for (const note of queue) { + try { await pushOne(note); } + catch (e) { console.warn("librenotes: push failed", note.id, e); throw e; } + } + } + + async function pull() { + const resp = await window.authClient.apiFetch("/api/notes"); + if (!resp.ok) throw new Error("pull list: " + resp.status); + const summaries = await resp.json(); + for (const s of summaries) { + const local = await window.notesCache.get(s.id); + // Skip rows the user has open dirty edits on; the conflict + // detector during push() will handle those. + if (local && local.dirty) continue; + if (local && local.synced_at === s.updated_at) continue; + const full = await window.authClient.apiFetch("/api/notes/" + encodeURIComponent(s.id)); + if (!full.ok) continue; + const note = await full.json(); + await window.notesCache.put({ + id: note.id, + title: note.title, + content: note.content, + updated_at: note.updated_at, + synced_at: note.updated_at, + dirty: false, + deleted: false, + }); + } + } + + async function syncOnce() { + if (!isOnline()) { + setState("offline"); + return; + } + if (running) return; + running = true; + setState("syncing"); + try { + await push(); + await pull(); + setState("synced"); + } catch (e) { + console.warn("librenotes: sync error", e); + setState("error"); + } finally { + running = false; + } + } + + function start() { + window.addEventListener("online", function () { + setState("online"); + syncOnce(); + }); + window.addEventListener("offline", function () { setState("offline"); }); + if (isOnline()) { + syncOnce(); + } else { + setState("offline"); + } + } + + // resolveConflict(id, choice, mergedFields) + // choice = "local" -> keep the local copy. Re-PUT with the + // latest server base so the server accepts. + // choice = "remote" -> keep the server copy. Overwrite local + // without dirty. + // choice = "merge" -> caller supplies mergedFields {title, content}. + // Re-PUT with the merged content using the + // latest server base. + async function resolveConflict(id, choice, mergedFields) { + const conflict = conflictsById[id]; + if (!conflict) throw new Error("no pending conflict for " + id); + const remote = conflict.remote; + const local = conflict.local; + + if (choice === "remote") { + await window.notesCache.put({ + id: id, + title: remote.title, + content: remote.content, + updated_at: remote.updated_at, + synced_at: remote.updated_at, + dirty: false, + deleted: false, + }); + } else { + const out = choice === "merge" && mergedFields + ? { title: mergedFields.title, content: mergedFields.content } + : { title: local.title, content: local.content }; + const base = remote ? remote.updated_at : ""; + const resp = await window.authClient.apiFetch( + "/api/notes/" + encodeURIComponent(id) + (base ? "?base=" + base : ""), + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(out), + } + ); + if (!resp.ok) throw new Error("resolve push: " + resp.status); + const body = await resp.json(); + await window.notesCache.put({ + id: id, + title: out.title, + content: out.content, + updated_at: body.updated_at, + synced_at: body.updated_at, + dirty: false, + deleted: false, + }); + } + delete conflictsById[id]; + setState("synced"); + } + + function pendingConflicts() { return Object.values(conflictsById); } + + window.notesSync = { + start, + syncOnce, + resolveConflict, + pendingConflicts, + }; +})();