import { Editor } from "https://esm.sh/@tiptap/core@2.11.5"; import StarterKit from "https://esm.sh/@tiptap/starter-kit@2.11.5"; import Link from "https://esm.sh/@tiptap/extension-link@2.11.5"; import Image from "https://esm.sh/@tiptap/extension-image@2.11.5"; const root = document.getElementById("root"); const turndownService = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" }); marked.setOptions({ gfm: true, breaks: false }); const state = { user: null, local: true, authed: true, editor: null, noteId: null, view: "home", space: null, userNames: null, commonSpace: "common", isCommon: false }; const THEMES = [ { id: "light", label: "Light", dark: false, dots: ["#f5f7fb", "#ffffff", "#3b82f6", "#8b5cf6"] }, { id: "summer", label: "Summer", dark: false, dots: ["#fff8f0", "#fff0e0", "#f08040", "#14b8a6"] }, { id: "max-summer", label: "Max Summer", dark: false, dots: ["#fffdf5", "#fff9e8", "#e53935", "#f59e0b"] }, { id: "autumn", label: "Autumn", dark: false, dots: ["#f4f1ec", "#eae5de", "#d59b6a", "#7a9e6e"] }, { id: "urban-slate", label: "Urban Slate", dark: false, dots: ["#eef0f3", "#ffffff", "#4a6a8a", "#c68a3c"] }, { id: "frozen-mist", label: "Frozen Mist", dark: false, dots: ["#eef0f2", "#fcfcfd", "#c96f3a", "#4a8a96"] }, { id: "yacht", label: "Yacht", dark: false, dots: ["#e9edf2", "#ffffff", "#33517a", "#b06a3c"] }, { id: "copper", label: "Copper", dark: false, dots: ["#f3ede6", "#fdfaf5", "#b5653b", "#3b9a9c"] }, { id: "marina", label: "Marina", dark: false, dots: ["#eaf0f4", "#ffffff", "#1f4e6e", "#4a97a6"] }, { id: "neptune", label: "Neptune", dark: false, dots: ["#e7f1f4", "#ffffff", "#2f7fa0", "#d98a4a"] }, { id: "sapphire", label: "Sapphire", dark: false, dots: ["#eef0f6", "#ffffff", "#5a73a6", "#c06a52"] }, { id: "tropical", label: "Tropical", dark: false, dots: ["#f2efe6", "#fdfaf2", "#e76f51", "#32a09c"] }, { id: "gossamer", label: "Gossamer", dark: false, dots: ["#eceef1", "#ffffff", "#3aa6a0", "#d0783c"] }, { id: "dark", label: "Dark", dark: true, dots: ["#1a1b1e", "#26272b", "#7dd3fc", "#f59e0b"] }, { id: "dracula", label: "Dracula", dark: true, dots: ["#0c0c1e", "#12122a", "#7c6af7", "#ff79c6"] }, { id: "nord", label: "Nord", dark: true, dots: ["#2e3440", "#3b4252", "#88c0d0", "#d08770"] }, { id: "tokyo-night", label: "Tokyo Night", dark: true, dots: ["#1a1b26", "#1f2335", "#7aa2f7", "#e0af68"] }, { id: "catppuccin", label: "Catppuccin", dark: true, dots: ["#1e1e2e", "#26263a", "#cba6f7", "#94e2d5"] }, { id: "one-dark", label: "One Dark", dark: true, dots: ["#21252b", "#282c34", "#61afef", "#98c379"] }, { id: "sunset", label: "Sunset", dark: true, dots: ["#1c1018", "#251420", "#f472b6", "#fb923c"] }, ]; const THEME_CONFIG_KEY = "snotes-theme-config"; const PREVIEW_KEY = "snotes-preview"; const themeMQ = window.matchMedia("(prefers-color-scheme: dark)"); function findTheme(id) { return THEMES.find((t) => t.id === id); } function loadThemeConfig() { let cfg = {}; try { cfg = JSON.parse(localStorage.getItem(THEME_CONFIG_KEY)) || {}; } catch (e) {} cfg.mode = cfg.mode === "manual" ? "manual" : "auto"; cfg.light = findTheme(cfg.light) ? cfg.light : "light"; cfg.dark = findTheme(cfg.dark) ? cfg.dark : "dark"; cfg.manual = findTheme(cfg.manual) ? cfg.manual : "dark"; return cfg; } let themeConfig = loadThemeConfig(); function saveThemeConfig() { try { localStorage.setItem(THEME_CONFIG_KEY, JSON.stringify(themeConfig)); } catch (e) {} } function activeThemeId() { return themeConfig.mode === "auto" ? (themeMQ.matches ? themeConfig.dark : themeConfig.light) : themeConfig.manual; } function applyTheme(labelEl, swatchEl) { const id = activeThemeId(); document.documentElement.setAttribute("data-theme", id); const t = findTheme(id); const label = labelEl || document.querySelector(".theme-label"); const swatch = swatchEl || document.querySelector(".theme-btn .theme-swatch"); if (label) label.textContent = t ? t.label : ""; if (swatch && t) swatch.innerHTML = t.dots.map((c) => ``).join(""); } function themeSelector() { const sel = el("div", { class: "theme-selector" }); const swatchEl = el("span", { class: "theme-swatch" }); const labelEl = el("span", { class: "theme-label" }); const btn = el("button", { class: "theme-btn", title: "Change theme" }, [ swatchEl, labelEl, el("span", { class: "chevron", html: "▾" }), ]); const pop = el("div", { class: "theme-popover hidden" }); sel.appendChild(btn); sel.appendChild(pop); function optionHtml(t) { return ``; } function render() { applyTheme(labelEl, swatchEl); pop.innerHTML = `
Themes
Light
${THEMES.filter((t) => !t.dark).map(optionHtml).join("")}
Dark
${THEMES.filter((t) => t.dark).map(optionHtml).join("")}
`; } btn.addEventListener("click", (e) => { e.stopPropagation(); pop.classList.toggle("hidden"); }); pop.addEventListener("click", (e) => { const modeBtn = e.target.closest(".mode-btn"); if (modeBtn) { themeConfig.mode = modeBtn.dataset.mode; saveThemeConfig(); render(); return; } const opt = e.target.closest(".theme-option"); if (opt) { themeConfig.mode = "manual"; themeConfig.manual = opt.dataset.theme; saveThemeConfig(); render(); pop.classList.add("hidden"); } }); pop.addEventListener("change", (e) => { if (e.target.matches('select[data-role="light"]')) { themeConfig.light = e.target.value; saveThemeConfig(); render(); } else if (e.target.matches('select[data-role="dark"]')) { themeConfig.dark = e.target.value; saveThemeConfig(); render(); } }); document.addEventListener("click", (e) => { if (!sel.contains(e.target)) pop.classList.add("hidden"); }); render(); return sel; } if (themeMQ.addEventListener) themeMQ.addEventListener("change", () => { if (themeConfig.mode === "auto") applyTheme(); }); else if (themeMQ.addListener) themeMQ.addListener(() => { if (themeConfig.mode === "auto") applyTheme(); }); function el(tag, attrs = {}, children = []) { const node = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === "class") node.className = v; else if (k === "text") node.textContent = v; else if (k === "html") node.innerHTML = v; else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2), v); else node.setAttribute(k, v); } for (const c of [].concat(children)) { if (c == null) continue; node.appendChild(typeof c === "string" ? document.createTextNode(c) : c); } return node; } async function api(method, path, body) { const opts = { method, headers: { "Content-Type": "application/json" } }; if (body !== undefined) opts.body = JSON.stringify(body); const resp = await fetch(path, opts); if (resp.status === 401) { showLogin(); throw new Error("unauthorized"); } if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || resp.statusText); } return resp.json(); } function escapeHtml(s) { return (s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } async function boot() { try { const me = await api("GET", "/api/me"); state.user = me.user; state.local = me.local; state.authed = me.authed; state.commonSpace = me.common_space || "common"; } catch { return; } if (!state.local && !state.authed) { showLogin(); return; } showHome(); } function showLogin() { root.innerHTML = ""; const form = el("form", { class: "login-card", onsubmit: async (e) => { e.preventDefault(); await doLogin(); } }, [ el("img", { src: "/static/snotes-new.png", class: "login-logo" }), el("h1", { text: "snotes" }), el("p", { class: "muted", text: "Remote access — enter password" }), el("input", { type: "password", id: "pw", placeholder: "Password", autofocus: true }), el("button", { type: "submit", class: "btn primary", text: "Sign in" }), el("div", { id: "login-err", class: "error" }), ]); root.appendChild(form); } async function doLogin() { const pw = document.getElementById("pw").value; try { await api("POST", "/api/auth/login", { password: pw }); location.reload(); } catch (e) { document.getElementById("login-err").textContent = "Invalid password"; } } function header() { return el("header", { class: "topbar" }, [ el("div", { class: "brand" }, [ el("img", { src: "/static/snotes-logo.png", class: "brand-logo", alt: "snotes" }), ]), el("input", { id: "search", class: "search", type: "search", placeholder: "Search notes…", oninput: debounce(onSearch, 250) }), 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: "quickSnote", 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. Tick the common checkbox to save it to the shared space everyone can see. 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("div", { class: "tools-head" }, [ el("img", { src: "/static/snotes-logo.png", class: "tools-logo", alt: "quickSnote" }), 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 = ""; state.editor?.destroy(); state.editor = null; const h = header(); root.appendChild(h); const main = el("div", { class: "layout" }, [ el("div", { class: "col-main", id: "search-results" }), el("aside", { class: "col-side", id: "sidebar" }), ]); root.appendChild(main); await populateUsers(); await loadRecent(); await loadTags(); document.getElementById("search").focus(); } async function populateUsers() { const users = await api("GET", "/api/users"); const sel = document.getElementById("user-select"); const names = users.map((u) => u.name); state.userNames = names; for (const n of names) { if (n === state.commonSpace) continue; sel.appendChild(el("option", { value: n, text: n })); } sel.value = state.user; } async function onUserChange(e) { await api("POST", "/api/users/select", { username: e.target.value }); state.user = e.target.value; await loadRecent(); } async function loadRecent() { const notes = await api("GET", "/api/notes/recent"); const box = document.getElementById("search-results"); box.innerHTML = ""; box.appendChild(el("h2", { text: "Recent notes" })); box.appendChild(noteList(notes)); } async function loadTags() { const tags = await api("GET", "/api/tags"); const box = document.getElementById("sidebar"); box.innerHTML = ""; box.appendChild(el("h3", { text: "Most-used tags" })); const cloud = el("div", { class: "tag-cloud" }); for (const t of tags) { cloud.appendChild(el("button", { class: "tag-chip", html: "#" + escapeHtml(t.name), onclick: () => searchFor("#" + t.name) })); } box.appendChild(cloud); } function noteList(notes) { if (!notes.length) return el("p", { class: "muted", text: "No notes yet." }); const ul = el("ul", { class: "note-list" }); for (const n of notes) { const tags = (n.tags || []).map((t) => `#${t}`).join(" "); const isCommon = n.username === state.commonSpace; const meta = el("div", { class: "note-meta" }); if (isCommon) meta.appendChild(el("span", { class: "common-badge", text: "common" })); meta.appendChild(document.createTextNode(`${n.username} · ${relTime(n.updated_at)}`)); if (n.attachments) meta.appendChild(el("span", { class: "attach-count", text: n.attachments + " attachment" + (n.attachments === 1 ? "" : "s") })); const bodyEl = el("div", { class: "note-body" }, [ el("div", { class: "note-title", text: n.title || "Untitled" }), meta, el("div", { class: "note-tags", text: tags }), ]); ul.appendChild(el("li", { class: isCommon ? "note-item note-common" : "note-item", onclick: () => openNote(n.id) }, [ n.image ? el("img", { class: "note-thumb", src: n.image, alt: "", loading: "lazy" }) : null, bodyEl, ])); } return ul; } async function onSearch(e) { const q = e.target.value.trim(); if (!q) { document.getElementById("search-results").innerHTML = ""; await loadRecent(); return; } const results = await api("GET", "/api/search?q=" + encodeURIComponent(q)); const box = document.getElementById("search-results"); box.innerHTML = ""; box.appendChild(el("h2", { text: `Results for "${q}"` })); box.appendChild(noteList(results)); } async function searchFor(q) { document.getElementById("search").value = q; const results = await api("GET", "/api/search?q=" + encodeURIComponent(q)); const box = document.getElementById("search-results"); box.innerHTML = ""; box.appendChild(el("h2", { text: `Results for "${q}"` })); box.appendChild(noteList(results)); } function relTime(iso) { if (!iso) return ""; const d = new Date(iso.replace(" ", "T") + "Z"); if (isNaN(d)) return iso; const diff = (Date.now() - d.getTime()) / 1000; if (diff < 60) return "just now"; if (diff < 3600) return Math.round(diff / 60) + "m ago"; if (diff < 86400) return Math.round(diff / 3600) + "h ago"; return d.toLocaleDateString(); } function debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } async function openNote(noteId) { state.view = "editor"; state.noteId = noteId; let body = "", owner = null; if (noteId) { const note = await api("GET", "/api/notes/" + noteId); body = note.body; owner = note.username; } renderEditor(body, owner); } function renderEditor(body, owner) { root.innerHTML = ""; state.editor?.destroy(); const isNew = !state.noteId; const back = el("button", { class: "btn ghost", text: "← Back", onclick: exitEditor }); const del = state.noteId ? el("button", { class: "btn danger", text: "Delete", onclick: deleteNote }) : null; const hist = state.noteId ? el("button", { class: "btn ghost", text: "History", onclick: showHistory }) : null; const realUsers = (state.userNames || [state.user]).filter((n) => n !== state.commonSpace); const spaceSel = el("select", { id: "space-select", class: "user-select", onchange: (e) => { state.space = e.target.value; } }); for (const n of realUsers) { spaceSel.appendChild(el("option", { value: n, text: n })); } state.isCommon = owner === state.commonSpace; state.space = owner && owner !== state.commonSpace ? owner : (state.user || realUsers[0]); spaceSel.value = state.space; const commonCb = el("input", { type: "checkbox", id: "common-check" }); commonCb.checked = state.isCommon; commonCb.addEventListener("change", () => { state.isCommon = commonCb.checked; }); const commonLabel = el("label", { class: "common-toggle", title: "Save to the shared common space" }, [ commonCb, el("span", { text: "common" }), ]); const toolbar = el("div", { class: "toolbar" }, [ toolbarBtn("B", "bold"), toolbarBtn("I", "italic"), sep(), toolbarBtn("H1", "heading1"), toolbarBtn("H2", "heading2"), toolbarBtn("H3", "heading3"), sep(), toolbarBtn("• List", "bulletList"), toolbarBtn("1. List", "orderedList"), sep(), toolbarBtn("Quote", "blockquote"), toolbarBtn("Code", "codeBlock"), toolbarBtn("`", "code"), sep(), toolbarBtn("—", "horizontalRule"), ]); const editorArea = el("div", { class: "editor" }); const previewArea = el("div", { class: "preview" }); const split = el("div", { class: "editor-split" }, [editorArea, previewArea]); let previewOn = true; try { previewOn = localStorage.getItem(PREVIEW_KEY) !== "off"; } catch (e) {} const previewBtn = el("button", { class: "btn ghost preview-toggle" + (previewOn ? " active" : ""), text: "Preview", title: "Toggle live preview", onclick: () => { split.classList.toggle("preview-hidden"); previewBtn.classList.toggle("active"); try { localStorage.setItem(PREVIEW_KEY, previewBtn.classList.contains("active") ? "on" : "off"); } catch (e) {} } }); if (!previewOn) split.classList.add("preview-hidden"); 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)); const attach = el("button", { class: "btn", text: "Attach", onclick: () => fileInput.click() }); const wrap = el("div", { class: "editor-wrap" }, [ el("div", { class: "editor-topbar" }, [back, ...(isNew ? [] : [spaceSel]), el("div", { class: "spacer" }), attach, hist, del, commonLabel, previewBtn]), toolbar, split, hint, status, ]); wrap.appendChild(fileInput); root.appendChild(wrap); state.editor = new Editor({ element: editorArea, extensions: [StarterKit, Link.configure({ openOnClick: false }), Image], content: marked.parse(body), autofocus: true, }); function updatePreview() { const md = turndownService.turndown(state.editor.getHTML()); previewArea.innerHTML = marked.parse(md); previewArea.querySelectorAll("a").forEach((a) => { a.target = "_blank"; a.rel = "noopener noreferrer"; }); } state.editor.on("update", updatePreview); updatePreview(); } function sep() { return el("span", { class: "sep" }); } function toolbarBtn(label, action) { return el("button", { class: "tool-btn", text: label, onclick: () => runToolbar(action) }); } function runToolbar(action) { const ed = state.editor; if (!ed) return; const cmds = { bold: () => ed.chain().focus().toggleBold().run(), italic: () => ed.chain().focus().toggleItalic().run(), heading1: () => ed.chain().focus().toggleHeading({ level: 1 }).run(), heading2: () => ed.chain().focus().toggleHeading({ level: 2 }).run(), heading3: () => ed.chain().focus().toggleHeading({ level: 3 }).run(), bulletList: () => ed.chain().focus().toggleBulletList().run(), orderedList: () => ed.chain().focus().toggleOrderedList().run(), blockquote: () => ed.chain().focus().toggleBlockquote().run(), codeBlock: () => ed.chain().focus().toggleCodeBlock().run(), code: () => ed.chain().focus().toggleCode().run(), horizontalRule: () => ed.chain().focus().setHorizontalRule().run(), }; cmds[action]?.(); } async function persistNote() { const html = state.editor.getHTML(); const body = turndownService.turndown(html); const username = state.isCommon ? state.commonSpace : state.space; const status = document.querySelector(".status"); try { let note; if (state.noteId) { note = await api("PUT", "/api/notes/" + state.noteId, { body, username }); } else { note = await api("POST", "/api/notes", { body, username }); state.noteId = note.id; } state.editor.commands.setContent(marked.parse(note.body)); status.textContent = "Saved · " + (note.tags || []).map((t) => "#" + t).join(" ") || ""; } catch (e) { status.textContent = "Error: " + e.message; } } function editorIsEmpty() { return !state.editor || state.editor.getText().trim() === ""; } async function exitEditor() { if (state.noteId || !editorIsEmpty()) { await persistNote(); } showHome(); } async function saveAndNewNote() { await persistNote(); openNote(null); } async function deleteNote() { if (!confirm("Delete this note?")) return; await api("DELETE", "/api/notes/" + state.noteId); showHome(); } async function uploadAttachment(input) { const file = input.files && input.files[0]; if (!file) return; const fd = new FormData(); fd.append("file", file); fd.append("username", state.isCommon ? state.commonSpace : state.space); const status = document.querySelector(".status"); try { const resp = await fetch("/api/attachments", { method: "POST", body: fd }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.detail || resp.statusText); } const data = await resp.json(); if (file.type.startsWith("image/")) { state.editor.chain().focus().insertContent({ type: "image", attrs: { src: data.url, alt: data.name, title: data.name }, }).run(); } else { state.editor.chain().focus().insertContent({ type: "text", text: data.name, marks: [{ type: "link", attrs: { href: data.url } }], }).run(); } if (status) status.textContent = "Attached " + data.name; } catch (e) { if (status) status.textContent = "Error: " + e.message; } input.value = ""; } async function showHistory() { const rows = await api("GET", "/api/notes/" + state.noteId + "/history"); const overlay = el("div", { class: "overlay" }, [ el("div", { class: "modal" }, [ el("h3", { text: "History" }), el("ul", { class: "history-list" }, rows.map((r) => el("li", { text: `${r.action} · ${r.username} · ${r.at} · ${r.ip || "?"} · ${r.ua || ""}` }) )), el("button", { class: "btn", text: "Close", onclick: () => overlay.remove() }), ]), ]); overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); 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 persistNote(); } } }); boot();