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, 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 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 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)}`));
ul.appendChild(el("li", { class: isCommon ? "note-item note-common" : "note-item", onclick: () => openNote(n.id) }, [
el("div", { class: "note-title", text: n.title || "Untitled" }),
meta,
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 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 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, spaceSel, el("div", { class: "spacer" }), attach, hist, del, commonLabel, save]),
toolbar,
editorArea,
hint,
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 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; }
}
async function saveNote() {
const isNew = !state.noteId;
await persistNote();
if (isNew) 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();
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 saveNote();
}
}
});
boot();