import { Editor } from "https://esm.sh/@tiptap/core@2.11.5"; import StarterKit from "https://esm.sh/@tiptap/starter-kit@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" }; 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; } 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-main.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-main.png", class: "brand-logo" }), el("span", { text: "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) }), ]), ]); } 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).concat(["common"]); for (const n of names) { 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(" "); ul.appendChild(el("li", { class: "note-item", onclick: () => openNote(n.id) }, [ el("div", { class: "note-title", text: n.title || "Untitled" }), el("div", { class: "note-meta", text: `${n.username} · ${relTime(n.updated_at)}` }), el("div", { class: "note-tags", text: tags }), ])); } 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 = ""; if (noteId) { const note = await api("GET", "/api/notes/" + noteId); body = note.body; } renderEditor(body); } function renderEditor(body) { root.innerHTML = ""; state.editor?.destroy(); const back = el("button", { class: "btn ghost", text: "← Back", onclick: showHome }); const save = el("button", { class: "btn primary", text: "Save", onclick: saveNote }); 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 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 status = el("div", { class: "status", text: "" }); root.appendChild(el("div", { class: "editor-wrap" }, [ el("div", { class: "editor-topbar" }, [back, el("div", { class: "spacer" }), hist, del, save]), toolbar, editorArea, status, ])); state.editor = new Editor({ element: editorArea, extensions: [StarterKit], content: marked.parse(body), autofocus: true, }); } 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 saveNote() { const html = state.editor.getHTML(); const body = turndownService.turndown(html); const status = document.querySelector(".status"); try { if (state.noteId) { const note = await api("PUT", "/api/notes/" + state.noteId, { body }); status.textContent = "Saved · " + (note.tags || []).map((t) => "#" + t).join(" ") || ""; } else { const note = await api("POST", "/api/notes", { body }); state.noteId = note.id; status.textContent = "Saved · " + (note.tags || []).map((t) => "#" + t).join(" ") || ""; } } catch (e) { status.textContent = "Error: " + e.message; } } async function deleteNote() { if (!confirm("Delete this note?")) return; await api("DELETE", "/api/notes/" + state.noteId); showHome(); } 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); } boot();