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 = `
#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 $name defines a template — e.g. put $journal in a note titled # Journal for %date% and snotes journal opens today's journal (existing entries are reopened, not duplicated)." }),
el("p", { html: "Template variables: %date%, %time%, %datetime%, %user%." }),
]);
const wrap = el("div", { class: "editor-wrap" }, [
el("div", { class: "editor-topbar" }, [back]),
card,
]);
root.appendChild(wrap);
}
function showTools() {
state.view = "tools";
root.innerHTML = "";
state.editor?.destroy(); state.editor = null;
const back = backButton(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: "QUICKsnote" }),
]),
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 applySettings() {
window.postMessage({ kind: "snotes-settings", serverUrl: window.location.origin }, "*");
}
function onDetected() {
const wasDetected = detected;
detected = true;
stopPing();
extStatus.textContent = "✓ Extension detected";
installBtn.textContent = "Extension installed";
if (!wasDetected) setTimeout(applySettings, 300);
}
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", () => {
applySettings();
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} · edited ${relTime(n.updated_at)} · created ${fmtDate(n.created_at)}`));
if (n.attachments) meta.appendChild(el("span", { class: "attach-count", text: n.attachments + " attachment" + (n.attachments === 1 ? "" : "s") }));
let snippetHtml = n.snippet ? marked.parse(n.snippet) : "";
if (n.image) snippetHtml = snippetHtml.replace(/#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 backButton(onclick) {
return el("button", { class: "back-btn", title: "Back", "aria-label": "Back", onclick }, [
el("span", { class: "back-icon", html: '' }),
]);
}
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();