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() { const menu = el("div", { class: "topbar-right", id: "mobile-menu" }, [ 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 }), el("button", { class: "btn ghost", text: "CLI", onclick: showCli }), themeSelector(), ]); const burger = el("button", { class: "menu-toggle", title: "Menu", "aria-label": "Menu", onclick: () => menu.classList.toggle("open") }, [ el("span", {}), el("span", {}), el("span", {}), ]); return el("header", { class: "topbar" }, [ el("div", { class: "brand", onclick: showHome, title: "Home", role: "button" }, [ el("img", { src: "/static/snotes-logo.png", class: "brand-logo", alt: "snotes" }), ]), burger, el("input", { id: "search", class: "search", type: "search", placeholder: "Search notes…", oninput: debounce(onSearch, 250) }), menu, ]); } function showHelp() { state.view = "help"; root.innerHTML = ""; state.editor?.destroy(); state.editor = null; const back = backButton(showHome); const wrap = el("div", { class: "editor-wrap" }, [ el("div", { class: "editor-topbar" }, [back]), el("div", { class: "help-card" }, [ el("div", { class: "tools-head" }, [ el("img", { src: "/static/snotes-logo.png", class: "tools-logo", alt: "snotes" }), 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 showCli() { state.view = "cli"; root.innerHTML = ""; state.editor?.destroy(); state.editor = null; const back = backButton(showHome); const installCmd = "curl -fsSL " + window.location.origin + "/" + state.user + "/cli | bash"; const cmdEl = el("code", { class: "cli-install-cmd", text: installCmd }); const copyBtn = el("button", { class: "btn", text: "Copy", onclick: () => { navigator.clipboard?.writeText(installCmd); copyBtn.textContent = "Copied!"; setTimeout(() => { copyBtn.textContent = "Copy"; }, 2000); }, }); const card = el("div", { class: "help-card" }, [ el("div", { class: "tools-head" }, [ el("img", { src: "/static/snotes-logo.png", class: "tools-logo", alt: "snotes CLI" }), el("h1", { text: "snotes CLI" }), ]), el("p", { html: "A terminal client for snotes: TUI browser, quick capture, and templates." }), el("p", { html: "Requires uv. Install or update for user " + escapeHtml(state.user) + " with:" }), el("div", { class: "tools-row cli-install-row" }, [cmdEl, copyBtn]), el("p", { class: "muted", text: "The script downloads the CLI to ~/.local/bin/snotes and saves this server's URL and your userspace to ~/.config/snotes/config.json." }), el("h2", { text: "Commands" }), el("table", { class: "help-table" }, [ el("tr", {}, [el("th", { text: "Command" }), el("th", { text: "Action" })]), helpRow("snotes", "Open the TUI: recent notes, list, search (n = new, enter = edit, d = delete)"), helpRow("snotes recent [-n N]", "Print recent notes"), helpRow("snotes find ", "Open the TUI with a search for "), helpRow("snotes new", "Create a note in $EDITOR"), helpRow("snotes