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"; 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 }; 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 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; } 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) }), themeSelector(), ]), ]); } 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) { 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 = "", 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 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 spaceSel = el("select", { id: "space-select", class: "user-select", onchange: (e) => { state.space = e.target.value; } }); for (const n of (state.userNames || [state.user])) { spaceSel.appendChild(el("option", { value: n, text: n })); } const initialSpace = owner || state.space || state.user; spaceSel.value = initialSpace; state.space = initialSpace; 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: "" }); 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, spaceSel, el("div", { class: "spacer" }), attach, hist, del, save]), toolbar, editorArea, status, ]); wrap.appendChild(fileInput); root.appendChild(wrap); state.editor = new Editor({ element: editorArea, extensions: [StarterKit, Link.configure({ openOnClick: false })], 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 { let note; if (state.noteId) { note = await api("PUT", "/api/notes/" + state.noteId, { body, username: state.space }); } else { note = await api("POST", "/api/notes", { body, username: state.space }); 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; } } 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.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(); 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); } boot();