From 04a40840d132ff111aad037fa441835e67b57812 Mon Sep 17 00:00:00 2001 From: schmeeve Date: Wed, 23 Sep 2026 13:36:24 -0700 Subject: [PATCH] quickSnote --- AGENTS.md | 49 ++++++++ browser-extension/background.js | 57 +++++++++ browser-extension/content.js | 37 ++++++ browser-extension/icons/icon-48.png | Bin 0 -> 3224 bytes browser-extension/icons/icon-96.png | Bin 0 -> 7611 bytes browser-extension/manifest.json | 55 +++++++++ browser-extension/options.html | 47 +++++++ browser-extension/options.js | 32 +++++ browser-extension/popup.css | 115 +++++++++++++++++ browser-extension/popup.html | 24 ++++ browser-extension/popup.js | 125 +++++++++++++++++++ browser-extension/release-firefox.sh | 112 +++++++++++++++++ static/app.css | 16 +++ static/app.js | 176 ++++++++++++++++++++++++++- 14 files changed, 844 insertions(+), 1 deletion(-) create mode 100644 browser-extension/background.js create mode 100644 browser-extension/content.js create mode 100644 browser-extension/icons/icon-48.png create mode 100644 browser-extension/icons/icon-96.png create mode 100644 browser-extension/manifest.json create mode 100644 browser-extension/options.html create mode 100644 browser-extension/options.js create mode 100644 browser-extension/popup.css create mode 100644 browser-extension/popup.html create mode 100644 browser-extension/popup.js create mode 100755 browser-extension/release-firefox.sh diff --git a/AGENTS.md b/AGENTS.md index 99af2ba..48bac35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,3 +82,52 @@ snotes/ app needs internet access on first editor load (or vendor these locally later). - `notes.py` opens a new `aiosqlite` connection per operation (same pattern as `tasky`); fine for a small personal corpus, not for hot paths. + +## Browser extension (browser-extension/) + +A Firefox MV3 "quick capture" extension — mirrors the pve-max extension pattern. +No backend changes: it reuses `GET /api/users` and `POST/PUT /api/notes`. + +``` +browser-extension/ + manifest.json # MV3, gecko id snotes-capture@schmeeve.com + background.js # Relays fetch() to snotes API (credentials:'include') + content.js # postMessage ping/pong + settings handshake; get_context msg + popup.html/js/css # Capture form: textarea, user select, Save, Page button + options.html/js # Server URL config (auto-filled via ping/pong) + release-firefox.sh # Package + --sign; copies .xpi to static/ + icons/ # 48px + 96px PNGs (from assets/snotes-favicon.png) +``` + +### How it works + +1. **Content script** runs on all pages. It answers `snotes-ping` → `snotes-pong` + and `snotes-settings` → writes `serverUrl` to `browser.storage.sync`, plus a + `get_context` message returning the page's selected text, title, and URL. +2. **Popup** prefills the note from the active tab's selection, lists users via + `list_users`, and Save creates (`POST`) then updates (`PUT`) via `save_note`. + A **Page** button inserts `[title](url)`; the note **autosaves 8s** after the + last keystroke. +3. **Helper page** — the SPA's "Capture" view (`showTools()` in `static/app.js`) + pings the extension, offers one-click install from + `/static/snotes-capture-firefox.xpi`, and sends `snotes-settings` with + `location.origin` to auto-configure. + +### Install / release + +```bash +# Manual dev install: about:debugging → Load Temporary Add-on → manifest.json +bash browser-extension/release-firefox.sh # package .zip only +bash browser-extension/release-firefox.sh --sign # sign via AMO (auto-bumps version) +``` + +The signed `.xpi` is copied to `static/snotes-capture-firefox.xpi` (git-tracked) +so the helper page serves it. Signing needs `AMO_JWT_ISSUER` / `AMO_JWT_SECRET` +and `npm install -g web-ext`. + +### Gotchas + +- The extension's `fetch` uses `credentials:'include'`, so on remote hosts it + only works if the user has already logged into snotes in that browser (the + `snotes_auth` cookie is reused). On LAN no auth is needed. +- `dist/` is gitignored; committed artifact lives in `static/` instead. diff --git a/browser-extension/background.js b/browser-extension/background.js new file mode 100644 index 0000000..b3dba90 --- /dev/null +++ b/browser-extension/background.js @@ -0,0 +1,57 @@ +let serverUrl = ''; + +browser.storage.sync.get('serverUrl').then(r => { + if (r.serverUrl) serverUrl = r.serverUrl.replace(/\/+$/, ''); +}); + +browser.storage.onChanged.addListener(changes => { + if (changes.serverUrl) { + serverUrl = changes.serverUrl.newValue.replace(/\/+$/, ''); + } +}); + +browser.runtime.onMessage.addListener((msg, sender) => { + switch (msg.kind) { + case 'list_users': + return listUsers(); + case 'save_note': + return saveNote(msg); + default: + return Promise.reject(new Error('Unknown message kind')); + } +}); + +async function request(path, opts) { + if (!serverUrl) throw new Error('Server URL not configured'); + opts = opts || {}; + opts.credentials = 'include'; + const res = await fetch(serverUrl + path, opts); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.detail || 'HTTP ' + res.status); + } + return res.json(); +} + +async function listUsers() { + const users = await request('/api/users', { headers: { 'Accept': 'application/json' } }); + return { users: users }; +} + +async function saveNote(msg) { + const body = { body: msg.body }; + if (msg.username) body.username = msg.username; + + if (msg.id) { + return request('/api/notes/' + msg.id, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + return request('/api/notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} diff --git a/browser-extension/content.js b/browser-extension/content.js new file mode 100644 index 0000000..f4ff8af --- /dev/null +++ b/browser-extension/content.js @@ -0,0 +1,37 @@ +(function () { + 'use strict'; + + // ── Page→Extension messaging (used by the snotes helper page) ───────────── + window.addEventListener('message', function (event) { + if (event.source !== window) return; + var msg = event.data; + if (!msg || typeof msg.kind !== 'string') return; + + switch (msg.kind) { + case 'snotes-ping': + window.postMessage({ kind: 'snotes-pong', version: '1.0.0' }, '*'); + break; + case 'snotes-settings': { + var data = {}; + if (msg.serverUrl !== undefined) data.serverUrl = msg.serverUrl; + if (Object.keys(data).length > 0) browser.storage.sync.set(data); + window.postMessage({ kind: 'snotes-settings-ack', ok: true }, '*'); + break; + } + } + }); + + // ── Extension→Page messaging (capture context for the popup) ────────────── + browser.runtime.onMessage.addListener(function (msg) { + if (msg && msg.kind === 'get_context') { + var selection = ''; + try { selection = window.getSelection().toString().trim(); } catch (e) {} + return Promise.resolve({ + selection: selection, + title: document.title || '', + url: window.location.href || '', + }); + } + return Promise.resolve(undefined); + }); +})(); diff --git a/browser-extension/icons/icon-48.png b/browser-extension/icons/icon-48.png new file mode 100644 index 0000000000000000000000000000000000000000..87030d48faa7ba858f736a83971a4b5d00152788 GIT binary patch literal 3224 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 + + + + + quickSnote Options + + + +

quickSnote

+ +
+ + +
The address of your snotes app, including port. Auto-filled from the helper page.
+
+ +
+ +
+ +
+ + + + diff --git a/browser-extension/options.js b/browser-extension/options.js new file mode 100644 index 0000000..03818c5 --- /dev/null +++ b/browser-extension/options.js @@ -0,0 +1,32 @@ +const urlInput = document.getElementById('serverUrl'); +const statusEl = document.getElementById('status'); + +function showStatus(msg, ok) { + statusEl.textContent = msg; + statusEl.style.color = ok ? '#a6e3a1' : '#f38ba8'; + setTimeout(() => { statusEl.textContent = ''; }, 3000); +} + +browser.storage.sync.get('serverUrl').then(r => { + if (r.serverUrl) urlInput.value = r.serverUrl; +}); + +let saveTimer = null; + +function scheduleSave() { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(save, 600); +} + +async function save() { + const url = urlInput.value.trim().replace(/\/+$/, ''); + await browser.storage.sync.set({ serverUrl: url }); + showStatus('Saved.', true); +} + +document.getElementById('save-close').addEventListener('click', async () => { + await save(); + window.close(); +}); + +urlInput.addEventListener('input', scheduleSave); diff --git a/browser-extension/popup.css b/browser-extension/popup.css new file mode 100644 index 0000000..01db904 --- /dev/null +++ b/browser-extension/popup.css @@ -0,0 +1,115 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #1e1e2e; + color: #cdd6f4; + width: 340px; + margin: 0; + padding: 0.75rem; + box-sizing: border-box; +} + +.header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.logo { + width: 24px; + height: 24px; + border-radius: 5px; +} + +#user { + flex: 1; + padding: 0.35rem 0.5rem; + font-size: 0.82rem; + border: 1px solid #45475a; + border-radius: 6px; + background: #313244; + color: #cdd6f4; +} + +textarea { + width: 100%; + height: 150px; + padding: 0.6rem 0.75rem; + border: 1px solid #45475a; + border-radius: 6px; + background: #181825; + color: #cdd6f4; + font-size: 0.88rem; + line-height: 1.45; + resize: vertical; + box-sizing: border-box; + font-family: inherit; +} + +textarea:focus { + outline: 2px solid #cba6f7; + outline-offset: -1px; +} + +.actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.spacer { + flex: 1; +} + +button { + padding: 0.4rem 0.8rem; + font-size: 0.82rem; + font-weight: 600; + border: 1px solid #45475a; + border-radius: 6px; + background: #313244; + color: #cdd6f4; + cursor: pointer; +} + +button:hover:not(:disabled) { + background: #45475a; +} + +button.primary { + background: #cba6f7; + color: #1e1e2e; + border-color: #cba6f7; +} + +button:disabled { + opacity: 0.5; + cursor: default; +} + +.status { + margin-top: 0.5rem; + font-size: 0.78rem; + min-height: 1.1rem; +} + +.status.ok { color: #a6e3a1; } +.status.err { color: #f38ba8; } + +.footer { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid #313244; + text-align: right; +} + +.footer a { + color: #6c7086; + font-size: 0.72rem; + text-decoration: none; +} + +.footer a:hover { + color: #a6adc8; +} diff --git a/browser-extension/popup.html b/browser-extension/popup.html new file mode 100644 index 0000000..b0c2b61 --- /dev/null +++ b/browser-extension/popup.html @@ -0,0 +1,24 @@ + + + + + + + +
+ + +
+ +
+ +
+ +
+
+ + + + diff --git a/browser-extension/popup.js b/browser-extension/popup.js new file mode 100644 index 0000000..dfdc070 --- /dev/null +++ b/browser-extension/popup.js @@ -0,0 +1,125 @@ +(function () { + 'use strict'; + + var serverUrl = ''; + var noteId = null; + var lastBody = ''; + var autosaveTimer = null; + + var noteEl = document.getElementById('note'); + var userEl = document.getElementById('user'); + var saveBtn = document.getElementById('save'); + var captureBtn = document.getElementById('capture-page'); + var statusEl = document.getElementById('status'); + var userOptsBtn = document.getElementById('open-options'); + + function showStatus(msg, ok) { + statusEl.textContent = msg; + statusEl.className = 'status ' + (ok ? 'ok' : 'err'); + if (ok) setTimeout(function () { if (statusEl.textContent === msg) statusEl.textContent = ''; }, 3000); + } + + function currentBody() { return noteEl.value; } + + async function loadUsers() { + try { + const r = await browser.runtime.sendMessage({ kind: 'list_users' }); + const users = (r && r.users) || []; + userEl.innerHTML = ''; + for (const u of users) { + const opt = document.createElement('option'); + opt.value = u.name; + opt.textContent = u.name; + userEl.appendChild(opt); + } + } catch (e) { + showStatus('Cannot load users: ' + e.message, false); + } + } + + async function captureContext() { + try { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + if (!tabs.length || tabs[0].id == null) return; + const ctx = await browser.tabs.sendMessage(tabs[0].id, { kind: 'get_context' }); + if (ctx && ctx.selection && !noteEl.value) { + noteEl.value = ctx.selection; + } + if (ctx) { + noteEl.dataset.pageTitle = ctx.title || ''; + noteEl.dataset.pageUrl = ctx.url || ''; + } + } catch (e) { + // no selection / content script unavailable on this page — ignore + } + } + + function capturePage() { + var title = noteEl.dataset.pageTitle || ''; + var url = noteEl.dataset.pageUrl || ''; + if (!title && !url) { showStatus('No page to capture', false); return; } + var link = '[' + (title || url) + '](' + (url || '') + ')'; + var v = noteEl.value; + if (v && !/\n$/.test(v)) v += '\n'; + noteEl.value = v + link + '\n'; + noteEl.focus(); + scheduleAutosave(); + } + + async function save() { + var body = currentBody(); + if (!body.trim()) { showStatus('Nothing to save', false); return; } + if (body === lastBody && noteId) return; + saveBtn.disabled = true; + try { + const note = await browser.runtime.sendMessage({ + kind: 'save_note', + id: noteId, + body: body, + username: userEl.value, + }); + noteId = note.id; + lastBody = body; + var tags = (note.tags || []).map(function (t) { return '#' + t; }).join(' '); + showStatus('Saved' + (tags ? ' · ' + tags : ''), true); + } catch (e) { + showStatus('Error: ' + e.message, false); + } finally { + saveBtn.disabled = false; + } + } + + function scheduleAutosave() { + if (autosaveTimer) clearTimeout(autosaveTimer); + autosaveTimer = setTimeout(function () { + autosaveTimer = null; + save(); + }, 8000); + } + + noteEl.addEventListener('input', scheduleAutosave); + + saveBtn.addEventListener('click', function () { + if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; } + save(); + }); + + captureBtn.addEventListener('click', function () { + if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; } + capturePage(); + }); + + userOptsBtn.addEventListener('click', function (e) { + e.preventDefault(); + browser.runtime.openOptionsPage(); + }); + + browser.storage.sync.get('serverUrl').then(function (r) { + serverUrl = (r.serverUrl || '').replace(/\/+$/, ''); + if (!serverUrl) showStatus('Set the server URL in Options', false); + }); + + loadUsers(); + captureContext(); + noteEl.focus(); +})(); diff --git a/browser-extension/release-firefox.sh b/browser-extension/release-firefox.sh new file mode 100755 index 0000000..af82cec --- /dev/null +++ b/browser-extension/release-firefox.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# release-firefox.sh — Package + optionally sign the quickSnote extension. +# +# Usage: +# ./release-firefox.sh # Package .zip only +# ./release-firefox.sh --sign # Package + sign via web-ext (auto-bumps patch version) +# ./release-firefox.sh --sign --no-bump # Sign without bumping version +# ./release-firefox.sh --bump # Bump version only, no signing +# ./release-firefox.sh --channel=beta # Sign on beta channel +# +# Prerequisites for --sign: +# - npm install -g web-ext +# - AMO API keys set in environment: +# export AMO_JWT_ISSUER="user:12345678" +# export AMO_JWT_SECRET="..." +# +# The signed .xpi is copied to static/snotes-capture-firefox.xpi so the snotes +# helper page can serve it for one-click install. + +set -euo pipefail + +SRC="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$SRC"/.. && pwd)" +DIST="$REPO/dist" +STATIC="$REPO/static" +OUT_ZIP="$DIST/snotes-capture-firefox.zip" +OUT_XPI="$STATIC/snotes-capture-firefox.xpi" +MANIFEST="$SRC/manifest.json" +SIGN=false +CHANNEL="unlisted" +BUMP="" + +for arg in "$@"; do + case "$arg" in + --sign) SIGN=true ;; + --channel=*) CHANNEL="${arg#*=}" ;; + --bump) BUMP=true ;; + --no-bump) BUMP=false ;; + esac +done + +# Auto-bump patch version when signing (AMO rejects duplicate versions) +if [ "$SIGN" = true ] && [ "$BUMP" != false ]; then + BUMP=true +fi + +if [ "$BUMP" = true ]; then + CURRENT_VERSION="$(python3 -c "import json,sys; print(json.load(open('$MANIFEST'))['version'])")" + NEW_VERSION="$(python3 -c " +v = '$CURRENT_VERSION'.split('.') +v[-1] = str(int(v[-1]) + 1) +print('.'.join(v)) +")" + python3 -c " +import json +with open('$MANIFEST') as f: + m = json.load(f) +m['version'] = '$NEW_VERSION' +with open('$MANIFEST', 'w') as f: + json.dump(m, f, indent=2) + f.write('\n') +" + echo "==> Bumped version: $CURRENT_VERSION -> $NEW_VERSION" +fi + +rm -f "$OUT_ZIP" +mkdir -p "$DIST" + +echo "==> Packaging Firefox extension..." +cd "$SRC" +zip -q -r "$OUT_ZIP" \ + manifest.json \ + background.js \ + content.js \ + popup.html \ + popup.js \ + popup.css \ + options.html \ + options.js \ + icons/icon-48.png \ + icons/icon-96.png + +echo " Created: $OUT_ZIP" +echo " Size: $(du -h "$OUT_ZIP" | cut -f1)" + +if [ "$SIGN" = true ]; then + if ! command -v web-ext &>/dev/null; then + echo "!! web-ext not found. Install with: npm install -g web-ext" + exit 1 + fi + if [ -z "${AMO_JWT_ISSUER:-}" ] || [ -z "${AMO_JWT_SECRET:-}" ]; then + echo "!! AMO_JWT_ISSUER and AMO_JWT_SECRET must be set for signing." + echo " Get keys at: https://addons.mozilla.org/en-US/developers/addon/api/key/" + exit 1 + fi + echo "==> Signing via addons.mozilla.org (channel: $CHANNEL)..." + web-ext sign \ + --source-dir "$SRC" \ + --artifacts-dir "$DIST" \ + --api-key "$AMO_JWT_ISSUER" \ + --api-secret "$AMO_JWT_SECRET" \ + --channel "$CHANNEL" \ + --ignore-files "*.sh" + echo " Signed .xpi in: $DIST/" + SIGNED_XPI="$(ls -t "$DIST"/*.xpi 2>/dev/null | head -1)" + if [ -n "$SIGNED_XPI" ]; then + cp "$SIGNED_XPI" "$OUT_XPI" + echo " Copied to: $OUT_XPI" + fi +fi + +echo "==> Done." diff --git a/static/app.css b/static/app.css index 783acce..9dcb77e 100644 --- a/static/app.css +++ b/static/app.css @@ -310,6 +310,22 @@ body { .editor a { color: var(--secondary); text-decoration: underline; cursor: pointer; } .status { margin-top: 10px; color: var(--muted); font-size: 13px; min-height: 20px; } +.editor-hint { margin-top: 12px; padding: 10px 14px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); font-size: 13px; color: var(--muted); line-height: 1.6; } +.editor-hint p { margin: 0.25em 0; } +.editor-hint code { background: color-mix(in srgb, var(--border) 55%, transparent); padding: 1px 4px; border-radius: 4px; font-size: 0.9em; } + +.help-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; line-height: 1.6; } +.help-card h1 { margin-top: 0; } +.help-card h2 { margin-top: 1.5em; margin-bottom: 0.4em; font-size: 18px; color: var(--secondary); } +.help-card p { margin: 0.4em 0; color: var(--text); } +.help-card code { background: color-mix(in srgb, var(--border) 55%, transparent); padding: 1px 4px; border-radius: 4px; font-size: 0.9em; } +.help-table { border-collapse: collapse; width: 100%; max-width: 480px; margin-top: 8px; } +.help-table th, .help-table td { text-align: left; padding: 6px 12px; border: 1px solid var(--border); font-size: 14px; } +.help-table th { background: var(--bg); color: var(--muted); font-weight: 600; } +.help-table code { white-space: nowrap; } + +.tools-row { display: flex; align-items: center; gap: 12px; margin-top: 8px; flex-wrap: wrap; } +.tools-row .muted { min-height: 1.2em; } .login-card { display: flex; diff --git a/static/app.js b/static/app.js index 9b14857..7927b51 100644 --- a/static/app.js +++ b/static/app.js @@ -194,11 +194,150 @@ function header() { el("div", { class: "topbar-right" }, [ el("select", { id: "user-select", class: "user-select", onchange: onUserChange }), el("button", { class: "btn primary", text: "+ New note", onclick: () => openNote(null) }), + el("button", { class: "btn ghost", text: "Help", onclick: showHelp }), + el("button", { class: "btn ghost", text: "Capture", onclick: showTools }), themeSelector(), ]), ]); } +function showHelp() { + state.view = "help"; + root.innerHTML = ""; + state.editor?.destroy(); state.editor = null; + + const back = el("button", { class: "btn ghost", text: "← Back", onclick: showHome }); + const wrap = el("div", { class: "editor-wrap" }, [ + el("div", { class: "editor-topbar" }, [back]), + el("div", { class: "help-card" }, [ + el("h1", { text: "Help" }), + + el("h2", { text: "Keyboard shortcuts" }), + el("table", { class: "help-table" }, [ + el("tr", {}, [el("th", { text: "Shortcut" }), el("th", { text: "Action" })]), + helpRow("n", "New note (from the home page)"), + helpRow("Ctrl/Cmd + S", "Save the current note"), + helpRow("Ctrl/Cmd + Shift + S", "Save and open a new note"), + ]), + + el("h2", { text: "Tags" }), + el("p", { html: 'Type #tag anywhere in your note to tag it. Tags are normalized to lowercase and shown in the sidebar, search, and note list.' }), + + el("h2", { text: "Lists" }), + el("p", { html: 'Type - at the start of a line for a bullet list, or 1. for a numbered list. You can also use the • List and 1. List toolbar buttons.' }), + + el("h2", { text: "Editing" }), + el("p", { html: 'Use the toolbar to format text with bold, italic, headings, quotes, code, and horizontal rules. The editor also accepts standard Markdown shortcuts as you type.' }), + + el("h2", { text: "Notes & sharing" }), + el("p", { html: 'Notes are plain Markdown files. A note lives in a space (a user), chosen with the dropdown at the top of the editor. Changing the space moves the note to that user.' }), + el("p", { html: 'Use Attach to upload a file and insert a link to it. History shows every change, and Delete soft-deletes a note.' }), + ]), + ]); + root.appendChild(wrap); +} + +function helpRow(keys, action) { + return el("tr", {}, [el("td", { html: "" + keys + "" }), el("td", { text: action })]); +} + +function showTools() { + state.view = "tools"; + root.innerHTML = ""; + state.editor?.destroy(); state.editor = null; + + const back = el("button", { class: "btn ghost", text: "← Back", onclick: showHome }); + + const extStatus = el("div", { class: "muted", text: "" }); + const installBtn = el("button", { class: "btn primary", text: "Install Browser Extension" }); + const qsStatus = el("span", { class: "muted", text: "" }); + const qsApply = el("button", { class: "btn", text: "Apply to Extension" }); + + const card = el("div", { class: "help-card" }, [ + el("h1", { text: "Quick capture" }), + el("p", { html: 'The quickSnote browser extension lets you jot a note from any page. It prefills the note with the text you\'ve selected, and a Page button inserts the current page\'s title and link.' }), + + el("h2", { text: "Firefox" }), + el("div", { class: "tools-row" }, [installBtn, extStatus]), + + el("h2", { text: "Quick settings" }), + el("p", { text: "Point the extension at this snotes server (" + window.location.origin + ")." }), + el("div", { class: "tools-row" }, [qsApply, qsStatus]), + + el("h2", { text: "Manual install" }), + el("p", { text: "Open about:debugging#/runtime/this-firefox → Load Temporary Add-on… → select browser-extension/manifest.json." }), + + el("h2", { text: "Using it" }), + el("p", { html: 'Open the toolbar icon, pick a user, type, and Save — or just stop typing for 8s and it autosaves.' }), + ]); + + const wrap = el("div", { class: "editor-wrap" }, [ + el("div", { class: "editor-topbar" }, [back]), + card, + ]); + root.appendChild(wrap); + + // ── Extension detection (ping/pong) ────────────────────────────────────── + const xpiUrl = "/static/snotes-capture-firefox.xpi"; + let detected = false; + let pingCount = 0; + let pingInterval = null; + let slowInterval = null; + + function stopPing() { + if (pingInterval) clearInterval(pingInterval), pingInterval = null; + if (slowInterval) clearInterval(slowInterval), slowInterval = null; + } + + function onDetected() { + detected = true; + stopPing(); + extStatus.textContent = "✓ Extension detected"; + installBtn.textContent = "Extension installed"; + } + + function doPing() { + window.postMessage({ kind: "snotes-ping" }, "*"); + pingCount++; + if (pingCount >= 8) { + if (pingInterval) clearInterval(pingInterval), pingInterval = null; + if (!detected && !slowInterval) { + extStatus.textContent = "Extension not detected"; + slowInterval = setInterval(() => window.postMessage({ kind: "snotes-ping" }, "*"), 2000); + } + } + } + + installBtn.addEventListener("click", () => { + if (detected) return; + if (window.InstallTrigger) InstallTrigger.install({ "quickSnote": xpiUrl }); + else window.location.href = xpiUrl; + extStatus.textContent = "Installing… check toolbar for prompt"; + stopPing(); + pingCount = 0; + pingInterval = setInterval(doPing, 500); + }); + + qsApply.addEventListener("click", () => { + window.postMessage({ kind: "snotes-settings", serverUrl: window.location.origin }, "*"); + qsStatus.textContent = "Applying…"; + setTimeout(() => { if (qsStatus.textContent === "Applying…") qsStatus.textContent = "No response — is the extension installed?"; }, 5000); + }); + + window.addEventListener("message", (event) => { + if (event.source !== window) return; + const msg = event.data; + if (msg && msg.kind === "snotes-pong") onDetected(); + if (msg && msg.kind === "snotes-settings-ack") { + qsStatus.textContent = "✓ Applied"; + setTimeout(() => { if (qsStatus.textContent === "✓ Applied") qsStatus.textContent = ""; }, 3000); + } + }); + + pingInterval = setInterval(doPing, 500); + doPing(); +} + async function showHome() { state.view = "home"; root.innerHTML = ""; @@ -341,6 +480,11 @@ function renderEditor(body, owner) { ]); const editorArea = el("div", { class: "editor" }); const status = el("div", { class: "status", text: "" }); + const hint = el("div", { class: "editor-hint" }, [ + el("p", { html: 'Tags: type #tag anywhere in your note to add a tag.' }), + el("p", { html: 'Lists: type - for a bullet list or 1. for a numbered list, or use the toolbar buttons.' }), + el("p", { html: 'Shortcuts: Ctrl/Cmd+S save · Ctrl/Cmd+Shift+S save & new note.' }), + ]); const fileInput = el("input", { type: "file", style: "display:none" }); fileInput.addEventListener("change", () => uploadAttachment(fileInput)); @@ -350,6 +494,7 @@ function renderEditor(body, owner) { el("div", { class: "editor-topbar" }, [back, spaceSel, el("div", { class: "spacer" }), attach, hist, del, save]), toolbar, editorArea, + hint, status, ]); wrap.appendChild(fileInput); @@ -387,7 +532,7 @@ function runToolbar(action) { cmds[action]?.(); } -async function saveNote() { +async function persistNote() { const html = state.editor.getHTML(); const body = turndownService.turndown(html); const status = document.querySelector(".status"); @@ -404,6 +549,17 @@ async function saveNote() { } catch (e) { status.textContent = "Error: " + e.message; } } +async function saveNote() { + const isNew = !state.noteId; + await persistNote(); + if (isNew) showHome(); +} + +async function saveAndNewNote() { + await persistNote(); + openNote(null); +} + async function deleteNote() { if (!confirm("Delete this note?")) return; await api("DELETE", "/api/notes/" + state.noteId); @@ -451,4 +607,22 @@ async function showHistory() { root.appendChild(overlay); } +document.addEventListener("keydown", (e) => { + const mod = e.ctrlKey || e.metaKey; + if (state.view === "home") { + if (e.key === "n" && !mod) { + const active = document.activeElement; + if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA" || active.isContentEditable)) return; + e.preventDefault(); + openNote(null); + } + } else if (state.view === "editor") { + if (mod && e.key.toLowerCase() === "s") { + e.preventDefault(); + if (e.shiftKey) saveAndNewNote(); + else saveNote(); + } + } +}); + boot();