diff --git a/cmd/librenotes/web/public/app.html b/cmd/librenotes/web/public/app.html index 85ddb5d..94082b9 100644 --- a/cmd/librenotes/web/public/app.html +++ b/cmd/librenotes/web/public/app.html @@ -25,6 +25,29 @@

   
 
+  
+    
+

Welcome to librenotes

+

A few things to know before you start:

+ +

We've seeded a welcome note as a starting + point. Edit it, link from it, delete it — it's yours.

+ + + +
+
+

Sync conflict

@@ -51,6 +74,7 @@ + diff --git a/cmd/librenotes/web/public/app.js b/cmd/librenotes/web/public/app.js index 3966e13..ac50479 100644 --- a/cmd/librenotes/web/public/app.js +++ b/cmd/librenotes/web/public/app.js @@ -81,4 +81,11 @@ // Kick off the sync controller. It registers online/offline // listeners and runs an initial reconciliation pass when online. if (window.notesSync) window.notesSync.start(); + + // First-run onboarding. Skips silently for returning users. + if (window.notesOnboarding) { + window.notesOnboarding.start().catch(function (e) { + console.warn("librenotes: onboarding error", e); + }); + } })(); diff --git a/cmd/librenotes/web/public/onboarding.js b/cmd/librenotes/web/public/onboarding.js new file mode 100644 index 0000000..804ce0e --- /dev/null +++ b/cmd/librenotes/web/public/onboarding.js @@ -0,0 +1,106 @@ +// onboarding.js — first-run welcome flow. +// +// Triggered by app.js after the session is verified. Behaviour: +// 1. Read the tenant-scoped key "onboarded" from localStorage. +// If truthy, skip silently — returning users never see this. +// 2. Otherwise, seed a sample welcome note via the notes API +// (only if the tenant has zero notes — we don't overwrite an +// existing notebook that the user already populated). +// 3. Open the welcome dialog. Dismissing the dialog persists +// "onboarded" so we never show it again on this device. +// +// Concepts surfaced in the welcome content match the user guide: +// notes-as-markdown, wikilinks, graph view (forthcoming), +// offline cache. + +(function () { + "use strict"; + + const STORE_KEY = "onboarded"; + const SAMPLE_ID = "welcome"; + + async function listNotes() { + const resp = await window.authClient.apiFetch("/api/notes"); + if (!resp.ok) throw new Error("list: " + resp.status); + return resp.json(); + } + + async function seedWelcomeNote() { + const sample = { + title: "Welcome to librenotes", + content: [ + "You're in. This note exists so you have somewhere to start.", + "", + "## A few things to know", + "", + "- Notes are Markdown files. The first `# Heading` is the title.", + "- Link between notes with `[[note-id]]`. Targets that don't", + " exist yet are created when you click the link.", + "- Edits sync automatically. Offline edits queue and push when", + " you reconnect.", + "- Your data lives only in your tenant directory; no other user", + " can read it.", + "", + "## Try it", + "", + "Edit this note. Create a new one. Link them with `[[..]]`.", + "When you're ready, see the [user guide](/docs/user-guide.md)", + "for more.", + "", + ].join("\n"), + }; + const resp = await window.authClient.apiFetch( + "/api/notes/" + SAMPLE_ID, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(sample), + } + ); + if (!resp.ok) throw new Error("seed: " + resp.status); + } + + function openDialog() { + const dlg = document.getElementById("onboarding-dialog"); + if (!dlg) return; + if (typeof dlg.showModal === "function") dlg.showModal(); + else dlg.setAttribute("open", ""); + } + + function dismissOnConfirm() { + const dlg = document.getElementById("onboarding-dialog"); + if (!dlg) return; + const form = dlg.querySelector("form"); + if (!form) return; + form.addEventListener("submit", function () { + const store = window.authClient.tenantStore(); + store.set(STORE_KEY, true); + }); + } + + async function start() { + if (!window.authClient || !window.authClient.isAuthenticated()) return; + const store = window.authClient.tenantStore(); + if (store.get(STORE_KEY, false)) return; + + // Best-effort seed: only when the notebook is empty so we don't + // disturb a user who has already created their own notes (e.g. + // signing in on a second device for the first time). + try { + const notes = await listNotes(); + if (Array.isArray(notes) && notes.length === 0) { + await seedWelcomeNote(); + } + } catch (e) { + // If the seed fails (offline, transient error), still show + // the dialog — onboarding shouldn't block on a network round + // trip. We just won't have a sample note to point at. + console.warn("librenotes: onboarding seed failed", e); + } + + dismissOnConfirm(); + openDialog(); + } + + window.notesOnboarding = { start }; +})(); diff --git a/cmd/librenotes/web/public/style.css b/cmd/librenotes/web/public/style.css index 0ddb877..06ff641 100644 --- a/cmd/librenotes/web/public/style.css +++ b/cmd/librenotes/web/public/style.css @@ -319,3 +319,29 @@ a:hover { text-decoration: underline; } background: transparent; color: var(--accent); } + +/* Onboarding dialog reuses the conflict dialog visuals but + needs a slightly different size and list styling. */ +#onboarding-dialog::backdrop { background: rgba(0,0,0,0.4); } +#onboarding-dialog { + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--bg); + color: var(--fg); + padding: 1.75rem; + max-width: min(36rem, 90vw); + width: 100%; +} +#onboarding-dialog h2 { margin-top: 0; } +#onboarding-dialog ul { padding-left: 1.25rem; } +#onboarding-dialog li { margin: 0.4rem 0; } +#onboarding-dialog menu { padding: 0; margin: 1rem 0 0; } +#onboarding-dialog button { + padding: 0.55rem 1.1rem; + border-radius: 0.35rem; + border: 0; + background: var(--accent); + color: var(--accent-fg); + cursor: pointer; + font-weight: 500; +} diff --git a/cmd/librenotes/web/public/sw.js b/cmd/librenotes/web/public/sw.js index 4c633f7..aeeda57 100644 --- a/cmd/librenotes/web/public/sw.js +++ b/cmd/librenotes/web/public/sw.js @@ -22,6 +22,7 @@ const PRECACHE = [ "/auth-client.js", "/notes-cache.js", "/sync.js", + "/onboarding.js", "/login.js", "/verify.js", "/app.js",