Menu
Public repository / master
AkurAI-Notes / site/frontend/notes-app.js
Latest change 3452d8a620647be68842e0998dd1887d66a207a4 - AKURAI-NOTES-9: add aria-label to dynamic rows for agent browser tool compatibility by Ólafur Búi Ólafsson
// AkurAI-Notes — Notion-like workspace. Native ESM, zero dependencies.
// Full 1:1 client for the REST API: notebooks (nested), notes, tags, search.
(function () {
"use strict";
// ── DOM helpers ────────────────────────────────────────────────────────────
function h(tag, attrs, children) {
const e = document.createElement(tag);
if (attrs) {
for (const k in attrs) {
const v = attrs[k];
if (v == null || v === false) continue;
if (k === "class") e.className = v;
else if (k === "style") e.setAttribute("style", v);
else if (k === "html") e.innerHTML = v;
else if (k.slice(0, 2) === "on" && typeof v === "function") e.addEventListener(k.slice(2), v);
else if (k === "dataset") for (const d in v) e.dataset[d] = v[d];
else e.setAttribute(k, v === true ? "" : v);
}
}
if (children != null) append(e, children);
return e;
}
function append(parent, child) {
if (child == null) return;
if (Array.isArray(child)) child.forEach((c) => append(parent, c));
else if (child instanceof Node) parent.appendChild(child);
else parent.appendChild(document.createTextNode(String(child)));
}
function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); }
function $(id) { return document.getElementById(id); }
// SVG icons (stroke-based, 24-grid). Returns an <svg> element.
const ICON = {
doc: "M8 3h6l4 4v14H6V3z M14 3v4h4",
docLine: "M8 3h6l4 4v14H6V3z M14 3v4h4 M9 12h6 M9 16h4",
book: "M4 5a2 2 0 0 1 2-2h12v18H6a2 2 0 0 1-2-2z M8 3v18",
chevron: "m9 6 6 6-6 6",
plus: "M12 5v14M5 12h14",
search: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14z M21 21l-4.3-4.3",
layers: "M12 3 2 8l10 5 10-5-10-5z M2 13l10 5 10-5 M2 18l10 5 10-5",
clock: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z M12 7v5l3 2",
archive: "M3 5h18v4H3z M5 9v11h14V9 M9 13h6",
tag: "M20 12 12 20l-8-8V4h8z M8 8h.01",
trash: "M4 7h16 M9 7V5h6v2 M6 7l1 13h10l1-13",
edit: "M4 20h4L20 8l-4-4L4 16z",
move: "M5 9 2 12l3 3 M9 5l3-3 3 3 M15 19l-3 3-3-3 M19 9l3 3-3 3 M2 12h20 M12 2v20",
x: "M6 6l12 12M18 6 6 18",
check: "m5 12 5 5 9-11",
settings: "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19 12a7 7 0 0 0-.1-1l2-1.5-2-3.5-2.4 1a7 7 0 0 0-1.7-1L14.5 3h-5l-.3 2.5a7 7 0 0 0-1.7 1l-2.4-1-2 3.5L2.6 11a7 7 0 0 0 0 2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 1.7 1l.3 2.5h5l.3-2.5a7 7 0 0 0 1.7-1l2.4 1 2-3.5-2-1.5c.1-.3.1-.7.1-1z",
logout: "M15 4h4v16h-4 M10 8l-4 4 4 4 M6 12h9",
palette: "M12 3a9 9 0 1 0 0 18c1 0 1.5-.8 1.5-1.5 0-1 .7-1.5 1.5-1.5H18a3 3 0 0 0 3-3 8 8 0 0 0-9-9z",
dots: "M12 6h.01M12 12h.01M12 18h.01",
eye: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z",
pen: "M4 20h4L20 8l-4-4L4 16z M14 6l4 4",
box: "M4 5h16v4H4z M6 9v11h12V9",
info: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z M12 8h.01 M11 12h1v4h1",
warn: "M12 3 2 20h20z M12 9v5 M12 17h.01",
grip: "M9 5h.01M15 5h.01M9 12h.01M15 12h.01M9 19h.01M15 19h.01",
};
function icon(name, cls) {
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.9");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("aria-hidden", "true");
if (cls) svg.setAttribute("class", cls);
const p = document.createElementNS(ns, "path");
p.setAttribute("d", ICON[name] || "");
svg.appendChild(p);
return svg;
}
const COLORS = ["#5b8cff", "#7af0d0", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#3b82f6", "#14b8a6", "#64748b"];
// ── API ─────────────────────────────────────────────────────────────────────
async function api(method, path, body) {
const opts = { method, credentials: "same-origin", headers: { accept: "application/json" } };
if (body !== undefined) {
opts.headers["content-type"] = "application/json";
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
const text = await res.text();
let data = null;
if (text) { try { data = JSON.parse(text); } catch (_) { data = null; } }
if (data && data.error === "unauthorized") {
window.location.assign("/auth/login");
throw new Error("unauthorized");
}
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
return data;
}
// ── Markdown (small, safe subset) ────────────────────────────────────────────
function escapeHtml(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, (m) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[m]));
}
function inlineMd(s) {
let t = escapeHtml(s);
t = t.replace(/`([^`]+)`/g, "<code>$1</code>");
t = t.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
t = t.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
return t;
}
function renderMarkdown(src) {
const lines = String(src || "").split("\n");
let html = "";
let i = 0;
let list = null; // 'ul' | 'ol'
const closeList = () => { if (list) { html += `</${list}>`; list = null; } };
while (i < lines.length) {
let line = lines[i];
if (/^```/.test(line)) {
closeList();
i++;
let code = "";
while (i < lines.length && !/^```/.test(lines[i])) { code += lines[i] + "\n"; i++; }
i++;
html += `<pre><code>${escapeHtml(code.replace(/\n$/, ""))}</code></pre>`;
continue;
}
let m;
if ((m = line.match(/^(#{1,3})\s+(.*)$/))) { closeList(); html += `<h${m[1].length}>${inlineMd(m[2])}</h${m[1].length}>`; i++; continue; }
if (/^\s*>\s?/.test(line)) { closeList(); html += `<blockquote>${inlineMd(line.replace(/^\s*>\s?/, ""))}</blockquote>`; i++; continue; }
if (/^\s*([-*+])\s+/.test(line)) { if (list !== "ul") { closeList(); html += "<ul>"; list = "ul"; } html += `<li>${inlineMd(line.replace(/^\s*[-*+]\s+/, ""))}</li>`; i++; continue; }
if (/^\s*\d+\.\s+/.test(line)) { if (list !== "ol") { closeList(); html += "<ol>"; list = "ol"; } html += `<li>${inlineMd(line.replace(/^\s*\d+\.\s+/, ""))}</li>`; i++; continue; }
if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) { closeList(); html += "<hr>"; i++; continue; }
if (line.trim() === "") { closeList(); i++; continue; }
closeList();
let para = inlineMd(line);
i++;
while (i < lines.length && lines[i].trim() !== "" && !/^(#{1,3}\s|\s*[-*+]\s|\s*\d+\.\s|\s*>|```|---)/.test(lines[i])) {
para += "<br>" + inlineMd(lines[i]); i++;
}
html += `<p>${para}</p>`;
}
closeList();
return html;
}
function fmtDate(sec) {
if (!sec) return "";
const d = new Date(sec * 1000);
const now = Date.now();
const diff = (now - d.getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return Math.floor(diff / 60) + "m ago";
if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
if (diff < 604800) return Math.floor(diff / 86400) + "d ago";
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: d.getFullYear() === new Date().getFullYear() ? undefined : "numeric" });
}
function wordCount(s) { const t = String(s || "").trim(); return t ? t.split(/\s+/).length : 0; }
// ── State ─────────────────────────────────────────────────────────────────
const S = {
me: null,
notebooks: [],
notes: [],
tags: [],
view: { type: "all" }, // all | recent | archived | notebook | tag | note | search
expanded: new Set(),
current: null, // active note object being edited
dirty: false,
saveTimer: null,
};
const nbById = (id) => S.notebooks.find((n) => Number(n.id) === Number(id));
const tagById = (id) => S.tags.find((t) => Number(t.id) === Number(id));
const parentOf = (nb) => (nb.parentId != null && Number(nb.parentId) > 0 ? Number(nb.parentId) : 0);
function childNotebooks(pid) {
return S.notebooks
.filter((n) => parentOf(n) === Number(pid))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || String(a.name).localeCompare(String(b.name)));
}
function notesIn(nbId, includeArchived) {
return S.notes
.filter((n) => Number(n.notebookId) === Number(nbId) && (includeArchived || !n.archived))
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}
function noteTagIds(n) {
return String(n.tagIds || "").split(",").map((s) => s.trim()).filter(Boolean).map(Number);
}
// ── Toasts ──────────────────────────────────────────────────────────────────
function toast(msg, kind) {
const wrap = $("ws-toasts");
if (!wrap) return;
const t = h("div", { class: "ws-toast " + (kind || "info"), role: "status" }, [
h("span", { class: "tico" }, icon(kind === "err" ? "warn" : kind === "ok" ? "check" : "info")),
h("span", { class: "tmsg" }, msg),
h("button", { class: "tclose", "aria-label": "Dismiss", onclick: () => dismiss() }, icon("x")),
]);
function dismiss() { t.classList.add("leaving"); setTimeout(() => t.remove(), 180); }
wrap.appendChild(t);
setTimeout(dismiss, kind === "err" ? 5200 : 3200);
}
// ── Popover menu ─────────────────────────────────────────────────────────────
let openMenu = null;
function closeMenu() { if (openMenu) { openMenu.remove(); openMenu = null; document.removeEventListener("mousedown", onDocDown, true); } }
function onDocDown(e) { if (openMenu && !openMenu.contains(e.target)) closeMenu(); }
function menu(anchorRect, items) {
closeMenu();
const m = h("div", { class: "ws-menu", role: "menu" });
items.forEach((it) => {
if (it.type === "sep") { m.appendChild(h("div", { class: "sep" })); return; }
if (it.type === "label") { m.appendChild(h("div", { class: "mlabel" }, it.text)); return; }
if (it.type === "swatches") {
const row = h("div", { class: "swatch-row" });
COLORS.forEach((c) => row.appendChild(h("button", {
class: "ws-swatch" + (it.value === c ? " sel" : ""), style: `background:${c}`, title: c,
"aria-label": "Color " + c, onclick: () => { closeMenu(); it.onpick(c); },
})));
m.appendChild(row); return;
}
m.appendChild(h("button", {
class: it.danger ? "danger" : "", role: "menuitem",
onclick: () => { closeMenu(); it.onclick(); },
}, [it.icon ? icon(it.icon) : null, h("span", null, it.label), it.checked ? icon("check", "menu-check") : null]));
});
document.body.appendChild(m);
// position
const mw = m.offsetWidth, mh = m.offsetHeight;
let left = anchorRect.left, top = anchorRect.bottom + 4;
if (left + mw > window.innerWidth - 8) left = window.innerWidth - mw - 8;
if (top + mh > window.innerHeight - 8) top = anchorRect.top - mh - 4;
m.style.left = Math.max(8, left) + "px";
m.style.top = Math.max(8, top) + "px";
openMenu = m;
setTimeout(() => document.addEventListener("mousedown", onDocDown, true), 0);
}
// ── Dialogs ─────────────────────────────────────────────────────────────────
function closeOverlay(ov) { ov.classList.add("leaving"); ov.remove(); document.removeEventListener("keydown", ov._esc, true); }
function overlay(cls, build) {
const ov = h("div", { class: "ws-overlay " + (cls || ""), role: "dialog", "aria-modal": "true" });
ov._esc = (e) => { if (e.key === "Escape") { e.stopPropagation(); closeOverlay(ov); } };
ov.addEventListener("mousedown", (e) => { if (e.target === ov) closeOverlay(ov); });
document.addEventListener("keydown", ov._esc, true);
document.body.appendChild(ov);
build(ov, () => closeOverlay(ov));
return ov;
}
function confirmDialog({ title, body, confirmLabel, danger }) {
return new Promise((resolve) => {
overlay("", (ov, close) => {
const dlg = h("div", { class: "ws-dialog" }, [
h("h2", null, title),
body ? h("p", { class: "sub" }, body) : null,
h("div", { class: "ws-dialog-actions" }, [
h("button", { class: "ws-btn", onclick: () => { close(); resolve(false); } }, "Cancel"),
h("button", { class: "ws-btn " + (danger ? "danger" : "primary"), onclick: () => { close(); resolve(true); } }, confirmLabel || "Confirm"),
]),
]);
ov.appendChild(dlg);
dlg.querySelector(".ws-dialog-actions .ws-btn:last-child").focus();
});
});
}
function promptDialog({ title, label, value, placeholder, multiline, confirmLabel }) {
return new Promise((resolve) => {
overlay("", (ov, close) => {
const input = multiline
? h("textarea", { class: "ws-textarea", placeholder: placeholder || "" })
: h("input", { class: "ws-input", type: "text", placeholder: placeholder || "" });
input.value = value || "";
const submit = () => { const v = input.value.trim(); close(); resolve(v); };
input.addEventListener("keydown", (e) => { if (!multiline && e.key === "Enter") { e.preventDefault(); submit(); } });
const dlg = h("div", { class: "ws-dialog" }, [
h("h2", null, title),
h("div", { class: "ws-field" }, [label ? h("label", null, label) : null, input]),
h("div", { class: "ws-dialog-actions" }, [
h("button", { class: "ws-btn", onclick: () => { close(); resolve(null); } }, "Cancel"),
h("button", { class: "ws-btn primary", onclick: submit }, confirmLabel || "Save"),
]),
]);
ov.appendChild(dlg);
input.focus(); input.select();
});
});
}
// Pick a notebook (styled list, no native select)
function pickNotebook({ title, excludeId, excludeSubtree, allowRoot }) {
const blocked = new Set();
if (excludeSubtree != null) {
const walk = (id) => { blocked.add(Number(id)); childNotebooks(id).forEach((c) => walk(c.id)); };
walk(excludeSubtree);
} else if (excludeId != null) {
blocked.add(Number(excludeId));
}
return new Promise((resolve) => {
overlay("", (ov, close) => {
const list = h("div", { class: "ws-menu", style: "position:static;box-shadow:none;border:1px solid var(--border);max-height:46vh;overflow:auto;min-width:0;" });
if (allowRoot) list.appendChild(h("button", { onclick: () => { close(); resolve(0); } }, [icon("layers"), h("span", null, "Top level (no parent)")]));
const add = (nb, depth) => {
if (blocked.has(Number(nb.id))) return; // skip self + descendants to prevent cycles
list.appendChild(h("button", { style: `padding-left:${9 + depth * 16}px`, onclick: () => { close(); resolve(Number(nb.id)); } }, [
h("span", { class: "ws-dot", style: `background:${nb.color || "#5b8cff"}` }), h("span", null, nb.name),
]));
childNotebooks(nb.id).forEach((c) => add(c, depth + 1));
};
childNotebooks(0).forEach((nb) => add(nb, 0));
const dlg = h("div", { class: "ws-dialog" }, [
h("h2", null, title),
list,
h("div", { class: "ws-dialog-actions" }, [h("button", { class: "ws-btn", onclick: () => { close(); resolve(null); } }, "Cancel")]),
]);
ov.appendChild(dlg);
});
});
}
// ── Data loading ────────────────────────────────────────────────────────────
async function loadAll() {
const [me, notebooks, notes, tags] = await Promise.all([
api("GET", "/api/me").catch(() => ({ authenticated: false })),
api("GET", "/api/notebooks").catch(() => []),
api("GET", "/api/notes").catch(() => []),
api("GET", "/api/tags").catch(() => []),
]);
S.me = me;
S.notebooks = Array.isArray(notebooks) ? notebooks : [];
S.notes = Array.isArray(notes) ? notes : [];
S.tags = Array.isArray(tags) ? tags : [];
}
// ── Sidebar render ───────────────────────────────────────────────────────────
function sideItem({ ico, label, count, active, onclick, depth }) {
return h("button", { class: "ws-item" + (active ? " active" : ""), style: depth ? `padding-left:${8 + depth * 15}px` : null, onclick }, [
h("span", { class: "ico" }, icon(ico)),
h("span", { class: "label" }, label),
count != null ? h("span", { class: "count" }, String(count)) : null,
]);
}
function renderSidebar() {
// user
const email = (S.me && S.me.email) || "";
$("ws-user-name").textContent = email ? email.split("@")[0] : "Workspace";
$("ws-user-sub").textContent = email || "Not signed in";
$("ws-avatar").textContent = (email ? email[0] : "A").toUpperCase();
// quick views
const views = $("ws-views");
clear(views);
const activeNotes = S.notes.filter((n) => !n.archived);
views.appendChild(sideItem({ ico: "layers", label: "All notes", count: activeNotes.length, active: S.view.type === "all", onclick: () => setView({ type: "all" }) }));
views.appendChild(sideItem({ ico: "clock", label: "Recent", active: S.view.type === "recent", onclick: () => setView({ type: "recent" }) }));
views.appendChild(sideItem({ ico: "archive", label: "Archived", count: S.notes.filter((n) => n.archived).length, active: S.view.type === "archived", onclick: () => setView({ type: "archived" }) }));
// notebook tree
const tree = $("ws-tree");
clear(tree);
const roots = childNotebooks(0);
if (!roots.length) tree.appendChild(h("div", { class: "ws-item", style: "color:var(--dim);cursor:default" }, "No notebooks yet"));
roots.forEach((nb) => renderNbNode(tree, nb, 0));
// tags
const tagsEl = $("ws-tags");
clear(tagsEl);
if (!S.tags.length) tagsEl.appendChild(h("div", { class: "ws-item", style: "color:var(--dim);cursor:default" }, "No tags yet"));
S.tags.slice().sort((a, b) => String(a.name).localeCompare(String(b.name))).forEach((tg) => {
const cnt = S.notes.filter((n) => !n.archived && noteTagIds(n).includes(Number(tg.id))).length;
const row = h("div", { class: "ws-item ws-row" + (S.view.type === "tag" && Number(S.view.id) === Number(tg.id) ? " active" : ""), style: "--depth:0", "aria-label": "Tag: " + tg.name, onclick: () => setView({ type: "tag", id: tg.id }) }, [
h("span", { class: "ws-dot", style: `background:${tg.color || "#7af0d0"};border-radius:50%` }),
h("span", { class: "label" }, tg.name),
h("span", { class: "count" }, String(cnt)),
h("span", { class: "row-actions" }, [
menuBtn((r) => tagMenu(r, tg)),
]),
]);
tagsEl.appendChild(row);
});
// footer
const foot = $("ws-foot");
clear(foot);
foot.appendChild(sideItem({ ico: "settings", label: "Settings", onclick: openSettings }));
foot.appendChild(h("a", { class: "ws-item", href: "/auth/logout" }, [h("span", { class: "ico" }, icon("logout")), h("span", { class: "label" }, "Sign out")]));
}
function menuBtn(onOpen) {
return h("button", { class: "ws-icon-btn", "aria-label": "More actions", onclick: (e) => { e.stopPropagation(); onOpen(e.currentTarget.getBoundingClientRect()); } }, icon("dots"));
}
function renderNbNode(container, nb, depth) {
if (depth > 20) return; // defensive: never infinite-recurse on cyclic data
const kids = childNotebooks(nb.id);
const nbNotes = notesIn(nb.id, false);
const hasChildren = kids.length > 0 || nbNotes.length > 0;
const isOpen = S.expanded.has(Number(nb.id));
const twist = h("span", { class: "ws-twist" + (isOpen ? " open" : "") + (hasChildren ? "" : " leaf"), onclick: (e) => { e.stopPropagation(); toggleExpand(nb.id); } }, icon("chevron"));
const row = h("div", {
class: "ws-item ws-row" + (S.view.type === "notebook" && Number(S.view.id) === Number(nb.id) ? " active" : ""),
style: `--depth:${depth}`, "aria-label": "Open notebook: " + nb.name, onclick: () => setView({ type: "notebook", id: nb.id }),
}, [
twist,
h("span", { class: "ws-dot", style: `background:${nb.color || "#5b8cff"}` }),
h("span", { class: "label" }, nb.name),
h("span", { class: "row-actions" }, [
h("button", { class: "ws-icon-btn", "aria-label": "New note", title: "New note", onclick: (e) => { e.stopPropagation(); createNote(nb.id); } }, icon("plus")),
menuBtn((r) => notebookMenu(r, nb)),
]),
h("span", { class: "count" }, nbNotes.length ? String(nbNotes.length) : ""),
]);
container.appendChild(row);
if (isOpen) {
kids.forEach((c) => renderNbNode(container, c, depth + 1));
nbNotes.forEach((note) => {
const leaf = h("div", {
class: "ws-item ws-row" + (S.view.type === "note" && S.current && Number(S.current.id) === Number(note.id) ? " active" : ""),
style: `--depth:${depth + 1}`, "aria-label": "Open note: " + (note.title || "Untitled"), onclick: () => openNote(note.id),
}, [
h("span", { class: "ws-twist leaf" }),
h("span", { class: "ico" }, icon("doc")),
h("span", { class: "label" }, note.title || "Untitled"),
h("span", { class: "row-actions" }, [menuBtn((r) => noteMenu(r, note))]),
]);
container.appendChild(leaf);
});
}
}
function toggleExpand(id) { id = Number(id); if (S.expanded.has(id)) S.expanded.delete(id); else S.expanded.add(id); renderSidebar(); }
// ── Views ────────────────────────────────────────────────────────────────────
function setView(v) {
if (S.dirty) flushSave();
S.view = v;
S.current = null;
closeDrawer();
render();
location.hash = viewHash(v);
}
function viewHash(v) {
if (v.type === "note") return "#note/" + v.id;
if (v.type === "notebook") return "#notebook/" + v.id;
if (v.type === "tag") return "#tag/" + v.id;
return "#" + v.type;
}
function render() {
renderSidebar();
const body = $("ws-body");
clear(body);
const crumbs = $("ws-crumbs");
clear(crumbs);
const actions = $("ws-topbar-actions");
clear(actions);
if (S.view.type === "note" && S.current) return renderEditor(body, crumbs, actions);
if (S.view.type === "notebook") return renderNotebookView(body, crumbs, actions);
if (S.view.type === "tag") return renderTagView(body, crumbs, actions);
return renderListView(body, crumbs, actions);
}
function crumb(label, onclick) { return h("span", { class: "crumb", onclick, role: onclick ? "button" : null, tabindex: onclick ? "0" : null }, label); }
function nbPath(nb) {
const chain = [];
let cur = nb;
const seen = new Set();
while (cur && !seen.has(Number(cur.id))) { seen.add(Number(cur.id)); chain.unshift(cur); cur = nbById(parentOf(cur)); }
return chain;
}
function noteRow(note, opts) {
opts = opts || {};
const tagDots = noteTagIds(note).map((id) => tagById(id)).filter(Boolean).slice(0, 4)
.map((t) => h("span", { class: "nr-tag", style: `background:${t.color || "#7af0d0"}`, title: t.name }));
return h("div", { class: "note-row", role: "button", tabindex: "0", "aria-label": "Open note: " + (note.title || "Untitled"), onclick: () => openNote(note.id), onkeydown: (e) => { if (e.key === "Enter") openNote(note.id); } }, [
h("span", { class: "nr-ico" }, icon("docLine")),
h("span", { class: "nr-main" }, [
h("span", { class: "nr-title" }, [note.title || "Untitled", note.archived ? h("span", { class: "archived-badge" }, "Archived") : null]),
h("span", { class: "nr-snip" }, (note.body || "").replace(/[#*`>\-]/g, "").slice(0, 140) || "Empty note"),
]),
h("span", { class: "nr-side" }, [
opts.showNotebook && nbById(note.notebookId) ? h("span", null, nbById(note.notebookId).name) : null,
tagDots.length ? h("span", { class: "nr-tags" }, tagDots) : null,
opts.score != null ? h("span", { class: "score-pill" }, "score " + opts.score) : h("span", { class: "nr-date" }, fmtDate(note.updatedAt)),
]),
]);
}
function emptyState(ico, title, msg, actionEl) {
return h("div", { class: "ws-empty" }, [h("div", { class: "ei" }, icon(ico)), h("h2", null, title), msg ? h("p", null, msg) : null, actionEl || null]);
}
function renderCollectionView({ title, description, dot, notes, noteOptions, subContent, empty }) {
const canvas = h("div", { class: "ws-canvas" });
const heading = [];
if (dot) heading.push(h("span", { class: "lh-dot", style: dot }));
heading.push(h("div", null, [h("h1", null, title), description ? h("p", { class: "lh-desc" }, description) : null]));
canvas.appendChild(h("div", { class: "ws-listhead" }, heading));
if (subContent) subContent.forEach((el) => canvas.appendChild(el));
canvas.appendChild(h("div", { class: "ws-listmeta" }, [String(notes.length) + " note" + (notes.length === 1 ? "" : "s")]));
if (!notes.length) {
if (empty && !subContent) canvas.appendChild(empty);
} else {
canvas.appendChild(h("div", { class: "ws-notelist" }, notes.map((n) => noteRow(n, noteOptions))));
}
return canvas;
}
function renderListView(body, crumbs, actions) {
let notes, title, desc;
if (S.view.type === "recent") {
notes = S.notes.filter((n) => !n.archived).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).slice(0, 50);
title = "Recent"; desc = "Your 50 most recently edited notes.";
} else if (S.view.type === "archived") {
notes = S.notes.filter((n) => n.archived).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
title = "Archived"; desc = "Notes you've archived. Restore them anytime.";
} else {
notes = S.notes.filter((n) => !n.archived).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
title = "All notes"; desc = "Every note across your notebooks.";
}
crumbs.appendChild(crumb(title));
if (S.view.type !== "archived") actions.appendChild(h("button", { class: "ws-btn primary", onclick: () => createNote() }, [icon("plus"), "New note"]));
body.appendChild(renderCollectionView({
title,
description: desc,
notes,
noteOptions: { showNotebook: true },
empty: emptyState("docLine", "Nothing here yet", S.view.type === "archived" ? "Archived notes will appear here." : "Create your first note to get started.", S.view.type !== "archived" ? h("button", { class: "ws-btn primary", onclick: () => createNote() }, [icon("plus"), "New note"]) : null),
}));
}
function renderNotebookView(body, crumbs, actions) {
const nb = nbById(S.view.id);
if (!nb) return renderListView(body, crumbs, actions);
nbPath(nb).forEach((p, i, arr) => {
crumbs.appendChild(crumb(p.name, () => setView({ type: "notebook", id: p.id })));
if (i < arr.length - 1) crumbs.appendChild(h("span", { class: "sep" }, "/"));
});
actions.appendChild(h("button", { class: "ws-btn", onclick: (e) => notebookMenu(e.currentTarget.getBoundingClientRect(), nb) }, [icon("settings"), "Manage"]));
actions.appendChild(h("button", { class: "ws-btn primary", onclick: () => createNote(nb.id) }, [icon("plus"), "New note"]));
const notes = notesIn(nb.id, true);
const subs = childNotebooks(nb.id);
const subContent = subs.length ? [
h("div", { class: "ws-listmeta" }, "Sub-notebooks"),
h("div", { class: "ws-notelist" }, subs.map((s) => h("div", { class: "note-row", role: "button", tabindex: "0", "aria-label": "Open notebook: " + s.name, onclick: () => setView({ type: "notebook", id: s.id }) }, [
h("span", { class: "nr-ico" }, [h("span", { class: "ws-dot", style: `background:${s.color || "#5b8cff"}` })]),
h("span", { class: "nr-main" }, [h("span", { class: "nr-title" }, s.name), h("span", { class: "nr-snip" }, (s.description || "") + " · " + notesIn(s.id, false).length + " notes")]),
h("span", { class: "nr-side" }, [icon("chevron")]),
]))),
] : null;
body.appendChild(renderCollectionView({
title: nb.name,
description: nb.description,
dot: `background:${nb.color || "#5b8cff"}`,
notes,
subContent,
empty: emptyState("docLine", "Empty notebook", "Add a note or a sub-notebook to fill it up.", h("button", { class: "ws-btn primary", onclick: () => createNote(nb.id) }, [icon("plus"), "New note"])),
}));
}
function renderTagView(body, crumbs, actions) {
const tg = tagById(S.view.id);
if (!tg) return renderListView(body, crumbs, actions);
crumbs.appendChild(crumb("Tags"));
crumbs.appendChild(h("span", { class: "sep" }, "/"));
crumbs.appendChild(crumb(tg.name));
const notes = S.notes.filter((n) => noteTagIds(n).includes(Number(tg.id))).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
body.appendChild(renderCollectionView({
title: "#" + tg.name,
description: "Notes tagged " + tg.name,
dot: `background:${tg.color || "#7af0d0"};border-radius:50%`,
notes,
noteOptions: { showNotebook: true },
empty: emptyState("tag", "No notes with this tag", "Tag a note to see it here."),
}));
}
// ── Editor ────────────────────────────────────────────────────────────────────
function autoGrow(el) { el.style.height = "auto"; el.style.height = el.scrollHeight + "px"; }
function renderEditor(body, crumbs, actions) {
const note = S.current;
const nb = nbById(note.notebookId);
nbPath(nb || {}).forEach((p) => { crumbs.appendChild(crumb(p.name, () => setView({ type: "notebook", id: p.id }))); crumbs.appendChild(h("span", { class: "sep" }, "/")); });
crumbs.appendChild(crumb(note.title || "Untitled"));
const saveStatus = h("span", { class: "save-status saved", id: "ed-save" }, "Saved");
actions.appendChild(saveStatus);
actions.appendChild(h("button", { class: "ws-btn", "aria-label": "Note options", onclick: (e) => noteMenu(e.currentTarget.getBoundingClientRect(), note) }, [icon("dots"), "Options"]));
const canvas = h("div", { class: "ws-canvas" });
const titleEl = h("textarea", { class: "ed-title", rows: "1", placeholder: "Untitled", spellcheck: "false" });
titleEl.value = note.title || "";
titleEl.addEventListener("input", () => { note.title = titleEl.value; autoGrow(titleEl); markDirty(); renderSidebarSoon(); });
titleEl.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); bodyEl.focus(); } });
// meta bar
const meta = h("div", { class: "ed-meta" });
const tagsWrap = h("span", { class: "ed-tags" });
function renderTags() {
clear(tagsWrap);
noteTagIds(note).map((id) => tagById(id)).filter(Boolean).forEach((t) => {
tagsWrap.appendChild(h("span", { class: "tag-chip", style: `--chip:${t.color || "#7af0d0"}` }, [
h("span", { class: "dot" }), t.name,
h("button", { "aria-label": "Remove tag " + t.name, onclick: () => toggleNoteTag(note, t.id, false).then(renderTags) }, icon("x")),
]));
});
tagsWrap.appendChild(h("button", { class: "ed-metabtn", onclick: (e) => tagPickerMenu(e.currentTarget.getBoundingClientRect(), note, renderTags) }, [icon("tag"), "Tag"]));
}
renderTags();
const nbBtn = h("button", { class: "ed-metabtn", onclick: () => moveNote(note) }, [icon("book"), nb ? nb.name : "No notebook"]);
const archBtn = h("button", { class: "ed-metabtn" + (note.archived ? " on" : ""), onclick: () => toggleArchive(note) }, [icon("archive"), note.archived ? "Archived" : "Archive"]);
const fmtBtn = h("button", { class: "ed-metabtn", onclick: () => toggleFormat(note) }, [icon("box"), note.format === "plain" ? "Plain text" : "Markdown"]);
const previewBtn = h("button", { class: "ed-metabtn", onclick: () => togglePreview() }, [icon("eye"), "Preview"]);
append(meta, [tagsWrap, nbBtn, fmtBtn, note.format !== "plain" ? previewBtn : null, archBtn]);
const bodyEl = h("textarea", { class: "ed-body" + (note.format === "plain" ? " mono" : ""), placeholder: "Start writing… Markdown supported.", spellcheck: "true" });
bodyEl.value = note.body || "";
bodyEl.addEventListener("input", () => { note.body = bodyEl.value; autoGrow(bodyEl); markDirty(); });
const preview = h("div", { class: "md", style: "display:none" });
let previewing = false;
function togglePreview() {
previewing = !previewing;
previewBtn.classList.toggle("on", previewing);
if (previewing) { preview.innerHTML = renderMarkdown(note.body || "") || '<p style="color:var(--dim)">Nothing to preview.</p>'; preview.style.display = ""; bodyEl.style.display = "none"; }
else { preview.style.display = "none"; bodyEl.style.display = ""; }
}
const footMeta = h("div", { class: "ws-listmeta", style: "margin-top:26px;border-top:1px solid var(--border);padding-top:14px" }, [
h("span", null, wordCount(note.body) + " words"),
h("span", null, "Updated " + fmtDate(note.updatedAt)),
h("span", null, "Created " + fmtDate(note.createdAt)),
]);
append(canvas, [titleEl, meta, bodyEl, preview, footMeta]);
body.appendChild(canvas);
requestAnimationFrame(() => { autoGrow(titleEl); autoGrow(bodyEl); if (!note.title) titleEl.focus(); });
// expose for external updates
S._ed = { renderTags, refreshMeta: () => {
nbBtn.lastChild.textContent = (nbById(note.notebookId) || {}).name || "No notebook";
archBtn.classList.toggle("on", !!note.archived); archBtn.lastChild.textContent = note.archived ? "Archived" : "Archive";
fmtBtn.lastChild.textContent = note.format === "plain" ? "Plain text" : "Markdown";
bodyEl.classList.toggle("mono", note.format === "plain");
} };
}
let sidebarTimer = null;
function renderSidebarSoon() { clearTimeout(sidebarTimer); sidebarTimer = setTimeout(renderSidebar, 400); }
function markDirty() {
S.dirty = true;
const st = $("ed-save");
if (st) { st.className = "save-status saving"; st.textContent = "Saving…"; }
clearTimeout(S.saveTimer);
S.saveTimer = setTimeout(flushSave, 800);
}
async function flushSave() {
clearTimeout(S.saveTimer);
if (!S.dirty || !S.current) return;
const note = S.current;
S.dirty = false;
try {
const updated = await api("PUT", "/api/notes/" + note.id, { title: note.title, body: note.body, format: note.format });
Object.assign(note, updated);
const idx = S.notes.findIndex((n) => Number(n.id) === Number(note.id));
if (idx >= 0) Object.assign(S.notes[idx], updated);
const st = $("ed-save"); if (st && !S.dirty) { st.className = "save-status saved"; st.textContent = "Saved"; }
} catch (e) {
S.dirty = true;
const st = $("ed-save"); if (st) { st.className = "save-status"; st.textContent = "Save failed"; }
toast("Couldn't save note: " + e.message, "err");
}
}
// ── Mutations ────────────────────────────────────────────────────────────────
async function createNote(notebookId) {
if (notebookId == null) {
if (S.view.type === "notebook") notebookId = S.view.id;
else notebookId = (childNotebooks(0)[0] || {}).id;
}
if (notebookId == null) { toast("Create a notebook first.", "info"); return newNotebook(0); }
try {
const note = await api("POST", "/api/notes", { title: "", body: "", format: "markdown", notebookId: Number(notebookId), tagIds: "", archived: false });
S.notes.push(note);
S.expanded.add(Number(notebookId));
openNote(note.id);
toast("Note created", "ok");
} catch (e) { toast("Couldn't create note: " + e.message, "err"); }
}
function openNote(id) {
if (S.dirty) flushSave();
const note = S.notes.find((n) => Number(n.id) === Number(id));
if (!note) { toast("Note not found", "err"); return; }
S.current = note;
S.view = { type: "note", id: Number(id) };
closeDrawer();
render();
location.hash = "#note/" + id;
}
async function toggleArchive(note) {
try {
const updated = await api("PUT", "/api/notes/" + note.id, { archived: !note.archived });
Object.assign(note, updated);
if (S._ed) S._ed.refreshMeta();
renderSidebar();
toast(note.archived ? "Note archived" : "Note restored", "ok");
} catch (e) { toast("Couldn't update note: " + e.message, "err"); }
}
async function toggleFormat(note) {
note.format = note.format === "plain" ? "markdown" : "plain";
if (S._ed) S._ed.refreshMeta();
markDirty();
}
async function moveNote(note) {
const dest = await pickNotebook({ title: "Move note to…", excludeId: null });
if (dest == null) return;
try {
const updated = await api("PUT", "/api/notes/" + note.id, { notebookId: Number(dest) });
Object.assign(note, updated);
if (S._ed) S._ed.refreshMeta();
renderSidebar();
toast("Moved to " + (nbById(dest) || {}).name, "ok");
} catch (e) { toast("Couldn't move note: " + e.message, "err"); }
}
async function deleteNote(note) {
const ok = await confirmDialog({ title: "Delete note?", body: `“${note.title || "Untitled"}” will be permanently deleted. This cannot be undone.`, confirmLabel: "Delete note", danger: true });
if (!ok) return;
try {
await api("DELETE", "/api/notes/" + note.id);
S.notes = S.notes.filter((n) => Number(n.id) !== Number(note.id));
if (S.current && Number(S.current.id) === Number(note.id)) { S.current = null; setView({ type: "notebook", id: note.notebookId }); }
else render();
toast("Note deleted", "ok");
} catch (e) { toast("Couldn't delete note: " + e.message, "err"); }
}
async function toggleNoteTag(note, tagId, add) {
const ids = new Set(noteTagIds(note));
if (add) ids.add(Number(tagId)); else ids.delete(Number(tagId));
const tagIds = Array.from(ids).join(",");
try {
const updated = await api("PUT", "/api/notes/" + note.id, { tagIds });
Object.assign(note, updated);
const idx = S.notes.findIndex((n) => Number(n.id) === Number(note.id));
if (idx >= 0) S.notes[idx].tagIds = tagIds;
renderSidebarSoon();
} catch (e) { toast("Couldn't update tags: " + e.message, "err"); }
}
function noteMenu(rect, note) {
menu(rect, [
{ label: "Open", icon: "docLine", onclick: () => openNote(note.id) },
{ label: "Move to notebook…", icon: "move", onclick: () => moveNote(note) },
{ label: note.archived ? "Restore from archive" : "Archive", icon: "archive", onclick: () => toggleArchive(note) },
{ type: "sep" },
{ label: "Delete", icon: "trash", danger: true, onclick: () => deleteNote(note) },
]);
}
function tagPickerMenu(rect, note, after) {
const has = new Set(noteTagIds(note));
const items = [{ type: "label", text: "Toggle tags" }];
S.tags.slice().sort((a, b) => String(a.name).localeCompare(String(b.name))).forEach((t) => {
items.push({ label: t.name, icon: "tag", checked: has.has(Number(t.id)), onclick: () => toggleNoteTag(note, t.id, !has.has(Number(t.id))).then(after) });
});
items.push({ type: "sep" });
items.push({ label: "Create new tag…", icon: "plus", onclick: () => newTag().then((t) => { if (t) toggleNoteTag(note, t.id, true).then(after); }) });
menu(rect, items);
}
// Notebooks
async function newNotebook(parentId) {
const name = await promptDialog({ title: parentId ? "New sub-notebook" : "New notebook", label: "Name", placeholder: "e.g. Projects" });
if (!name) return null;
try {
const body = { name, description: "", color: COLORS[S.notebooks.length % COLORS.length] };
if (parentId) body.parentId = Number(parentId);
const nb = await api("POST", "/api/notebooks", body);
S.notebooks.push(nb);
if (parentId) S.expanded.add(Number(parentId));
setView({ type: "notebook", id: nb.id });
toast("Notebook created", "ok");
return nb;
} catch (e) { toast("Couldn't create notebook: " + e.message, "err"); return null; }
}
async function renameNotebook(nb) {
const name = await promptDialog({ title: "Rename notebook", label: "Name", value: nb.name });
if (!name || name === nb.name) return;
await patchNotebook(nb, { name });
}
async function editNotebookDesc(nb) {
const description = await promptDialog({ title: "Notebook description", label: "Description", value: nb.description, placeholder: "What's this notebook for?", multiline: true });
if (description == null) return;
await patchNotebook(nb, { description });
}
async function patchNotebook(nb, fields) {
try { const u = await api("PUT", "/api/notebooks/" + nb.id, fields); Object.assign(nb, u); render(); toast("Notebook updated", "ok"); }
catch (e) { toast("Couldn't update notebook: " + e.message, "err"); }
}
async function moveNotebook(nb) {
const dest = await pickNotebook({ title: "Move notebook under…", excludeSubtree: nb.id, allowRoot: true });
if (dest == null) return;
await patchNotebook(nb, { parentId: Number(dest) });
if (dest) S.expanded.add(Number(dest));
}
async function deleteNotebook(nb) {
const n = notesIn(nb.id, true).length, subs = childNotebooks(nb.id).length;
const warn = (n || subs) ? ` It still contains ${n} note(s)${subs ? ` and ${subs} sub-notebook(s)` : ""}, which will be orphaned.` : "";
const ok = await confirmDialog({ title: "Delete notebook?", body: `“${nb.name}” will be deleted.${warn} This cannot be undone.`, confirmLabel: "Delete notebook", danger: true });
if (!ok) return;
try {
await api("DELETE", "/api/notebooks/" + nb.id);
S.notebooks = S.notebooks.filter((x) => Number(x.id) !== Number(nb.id));
if (S.view.type === "notebook" && Number(S.view.id) === Number(nb.id)) setView({ type: "all" });
else render();
toast("Notebook deleted", "ok");
} catch (e) { toast("Couldn't delete notebook: " + e.message, "err"); }
}
function notebookMenu(rect, nb) {
menu(rect, [
{ label: "New note", icon: "plus", onclick: () => createNote(nb.id) },
{ label: "New sub-notebook", icon: "book", onclick: () => newNotebook(nb.id) },
{ type: "sep" },
{ label: "Rename", icon: "edit", onclick: () => renameNotebook(nb) },
{ label: "Edit description", icon: "pen", onclick: () => editNotebookDesc(nb) },
{ label: "Move…", icon: "move", onclick: () => moveNotebook(nb) },
{ type: "label", text: "Color" },
{ type: "swatches", value: nb.color, onpick: (c) => patchNotebook(nb, { color: c }) },
{ type: "sep" },
{ label: "Delete", icon: "trash", danger: true, onclick: () => deleteNotebook(nb) },
]);
}
// Tags
async function newTag() {
const name = await promptDialog({ title: "New tag", label: "Name", placeholder: "e.g. important" });
if (!name) return null;
try { const t = await api("POST", "/api/tags", { name, color: COLORS[S.tags.length % COLORS.length] }); S.tags.push(t); renderSidebar(); toast("Tag created", "ok"); return t; }
catch (e) { toast("Couldn't create tag: " + e.message, "err"); return null; }
}
async function renameTag(tg) {
const name = await promptDialog({ title: "Rename tag", label: "Name", value: tg.name });
if (!name || name === tg.name) return;
try { const u = await api("PUT", "/api/tags/" + tg.id, { name }); Object.assign(tg, u); render(); toast("Tag renamed", "ok"); }
catch (e) { toast("Couldn't rename tag: " + e.message, "err"); }
}
async function recolorTag(tg, c) {
try { const u = await api("PUT", "/api/tags/" + tg.id, { color: c }); Object.assign(tg, u); render(); }
catch (e) { toast("Couldn't update tag: " + e.message, "err"); }
}
async function deleteTag(tg) {
const ok = await confirmDialog({ title: "Delete tag?", body: `“${tg.name}” will be removed. Notes keep their text but lose this tag.`, confirmLabel: "Delete tag", danger: true });
if (!ok) return;
try {
await api("DELETE", "/api/tags/" + tg.id);
S.tags = S.tags.filter((x) => Number(x.id) !== Number(tg.id));
if (S.view.type === "tag" && Number(S.view.id) === Number(tg.id)) setView({ type: "all" });
else render();
toast("Tag deleted", "ok");
} catch (e) { toast("Couldn't delete tag: " + e.message, "err"); }
}
function tagMenu(rect, tg) {
menu(rect, [
{ label: "View tagged notes", icon: "tag", onclick: () => setView({ type: "tag", id: tg.id }) },
{ label: "Rename", icon: "edit", onclick: () => renameTag(tg) },
{ type: "label", text: "Color" },
{ type: "swatches", value: tg.color, onpick: (c) => recolorTag(tg, c) },
{ type: "sep" },
{ label: "Delete", icon: "trash", danger: true, onclick: () => deleteTag(tg) },
]);
}
// ── Command palette / search ──────────────────────────────────────────────────
function openPalette() {
let sel = 0, results = [], timer = null;
overlay("ws-palette", (ov, close) => {
const input = h("input", { type: "text", placeholder: "Search notes by title and text…", autocomplete: "off", spellcheck: "false", "aria-label": "Search notes" });
const mode = h("span", { class: "ws-palmode" }, "");
const list = h("div", { class: "ws-palresults" });
const box = h("div", { class: "ws-palbox" }, [
h("div", { class: "ws-palhead" }, [icon("search"), input, mode]),
list,
]);
ov.appendChild(box);
input.focus();
function renderResults() {
clear(list);
if (!results.length) { list.appendChild(h("div", { class: "ws-empty", style: "padding:36px 20px" }, [h("p", null, input.value.trim() ? "No matches." : "Type to search your notes.")])); return; }
results.forEach((r, i) => {
list.appendChild(h("div", { class: "pal-item" + (i === sel ? " sel" : ""), onmouseenter: () => { sel = i; renderResults(); }, onclick: () => choose(i) }, [
h("span", { class: "pi-ico" }, icon("docLine")),
h("span", { style: "min-width:0" }, [h("div", { class: "pi-title" }, r.title || "Untitled"), h("div", { class: "pi-snip" }, r.snippet || "")]),
r.score != null ? h("span", { class: "score-pill" }, String(r.score)) : null,
]));
});
}
function choose(i) { const r = results[i]; if (!r) return; close(); openNote(r.id); }
async function run() {
const q = input.value.trim();
if (!q) { results = []; mode.textContent = ""; renderResults(); return; }
try {
const data = await api("GET", "/api/search?q=" + encodeURIComponent(q) + "&k=25");
results = (data && data.results) || [];
mode.textContent = (data && data.mode ? data.mode : "") + (results.length ? " · " + results.length : "");
sel = 0; renderResults();
} catch (e) { results = []; mode.textContent = "error"; renderResults(); }
}
input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(run, 180); });
input.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown") { e.preventDefault(); sel = Math.min(sel + 1, results.length - 1); renderResults(); }
else if (e.key === "ArrowUp") { e.preventDefault(); sel = Math.max(sel - 1, 0); renderResults(); }
else if (e.key === "Enter") { e.preventDefault(); choose(sel); }
});
renderResults();
});
}
// ── Top bar search dropdown ─────────────────────────────────────────────────
function initTopSearch() {
const btn = $("ws-top-search");
const drop = $("ws-search-drop");
const input = $("ws-search-input");
const results = $("ws-search-results");
let sel = 0, items = [], timer = null;
let open = false;
function closeDrop() { open = false; drop.hidden = true; }
function openDrop() { open = true; drop.hidden = false; input.focus(); input.select(); }
function toggleDrop() { if (open) closeDrop(); else openDrop(); }
function render() {
clear(results);
if (!items.length) { results.appendChild(h("div", { class: "ws-empty" }, [h("p", null, input.value.trim() ? "No matches." : "Type to search your notes.")])); return; }
items.forEach((r, i) => {
results.appendChild(h("div", { class: "sr-item" + (i === sel ? " sel" : ""), onmouseenter: () => { sel = i; render(); }, onclick: () => choose(i) }, [
h("span", { class: "sr-ico" }, icon("docLine")),
h("span", { style: "min-width:0" }, [h("div", { class: "sr-title" }, r.title || "Untitled"), h("div", { class: "sr-snip" }, r.snippet || "")]),
r.score != null ? h("span", { class: "sr-score" }, String(r.score)) : null,
]));
});
}
function choose(i) { const r = items[i]; if (!r) return; closeDrop(); openNote(r.id); }
async function run() {
const q = input.value.trim();
if (!q) { items = []; render(); return; }
try {
const data = await api("GET", "/api/search?q=" + encodeURIComponent(q) + "&k=25");
items = (data && data.results) || [];
sel = 0; render();
} catch (e) { items = []; render(); }
}
btn.addEventListener("click", toggleDrop);
input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(run, 180); });
input.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown") { e.preventDefault(); sel = Math.min(sel + 1, items.length - 1); render(); }
else if (e.key === "ArrowUp") { e.preventDefault(); sel = Math.max(sel - 1, 0); render(); }
else if (e.key === "Enter") { e.preventDefault(); choose(sel); }
else if (e.key === "Escape") { e.preventDefault(); closeDrop(); btn.focus(); }
});
document.addEventListener("mousedown", (e) => { if (open && !drop.contains(e.target) && e.target !== btn) closeDrop(); });
}
// ── Settings ──────────────────────────────────────────────────────────────────
function openSettings() {
overlay("", (ov, close) => {
const row = (label, val) => h("div", { class: "setting-row", style: "border:1px solid var(--border);border-radius:9px;padding:12px 14px;display:flex;justify-content:space-between;gap:16px;margin-bottom:8px" }, [h("span", { class: "muted" }, label), h("strong", { style: "font-size:13.5px;text-align:right" }, val)]);
const dlg = h("div", { class: "ws-dialog", style: "width:min(520px,100%)" }, [
h("h2", null, "Settings"),
h("p", { class: "sub" }, "Account and service details."),
row("Signed in as", (S.me && S.me.email) || "Not signed in"),
row("Notebooks", String(S.notebooks.length)),
row("Notes", String(S.notes.length)),
row("Tags", String(S.tags.length)),
row("Storage", "B+tree notes database"),
row("Search", "Semantic with lexical fallback"),
row("MCP endpoint", "/mcp"),
h("div", { style: "display:flex;gap:8px;margin-top:6px" }, [
h("a", { class: "ws-btn", href: "/", style: "flex:1;justify-content:center" }, [icon("info"), "About"]),
h("a", { class: "ws-btn danger", href: "/auth/logout", style: "flex:1;justify-content:center" }, [icon("logout"), "Sign out"]),
]),
h("div", { class: "ws-dialog-actions" }, [h("button", { class: "ws-btn primary", onclick: close }, "Done")]),
]);
ov.appendChild(dlg);
});
}
// ── Drawer (mobile) ────────────────────────────────────────────────────────────
function openDrawer() { document.body.classList.add("drawer-open"); }
function closeDrawer() { document.body.classList.remove("drawer-open"); }
// ── Hash routing ────────────────────────────────────────────────────────────────
function applyHash() {
const hash = location.hash.replace(/^#/, "");
if (!hash || hash === "all") { S.view = { type: "all" }; S.current = null; return render(); }
const [kind, id] = hash.split("/");
if (kind === "note" && id) { const n = S.notes.find((x) => Number(x.id) === Number(id)); if (n) { S.current = n; S.view = { type: "note", id: Number(id) }; return render(); } }
if (kind === "notebook" && id && nbById(id)) { S.view = { type: "notebook", id: Number(id) }; S.current = null; return render(); }
if (kind === "tag" && id && tagById(id)) { S.view = { type: "tag", id: Number(id) }; S.current = null; return render(); }
if (kind === "recent" || kind === "archived") { S.view = { type: kind }; S.current = null; return render(); }
S.view = { type: "all" }; S.current = null; render();
}
// ── Init ──────────────────────────────────────────────────────────────────────
async function initWorkspace() {
const ws = $("workspace");
// skeleton
$("ws-body").appendChild(h("div", { class: "ws-canvas" }, h("div", { class: "ws-skeleton" }, [h("div", { class: "sk" }), h("div", { class: "sk" }), h("div", { class: "sk" })])));
// global wiring
$("ws-open-search").addEventListener("click", openPalette);
$("ws-add-notebook").addEventListener("click", () => newNotebook(0));
$("ws-add-tag").addEventListener("click", () => newTag());
$("ws-drawer").addEventListener("click", openDrawer);
$("ws-scrim").addEventListener("click", closeDrawer);
window.addEventListener("hashchange", applyHash);
window.addEventListener("beforeunload", () => { if (S.dirty) flushSave(); });
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); openPalette(); }
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { e.preventDefault(); if (S.dirty) flushSave(); }
});
initTopSearch();
window.addEventListener("resize", closeMenu);
try {
await loadAll();
} catch (e) {
clear($("ws-body"));
$("ws-body").appendChild(emptyState("warn", "Couldn't load your workspace", e.message));
ws.setAttribute("aria-busy", "false");
return;
}
if (S.me && S.me.authenticated === false) { window.location.assign("/auth/login"); return; }
// expand notebooks that contain the current hash target, else first root
const roots = childNotebooks(0);
if (roots[0]) S.expanded.add(Number(roots[0].id));
ws.setAttribute("aria-busy", "false");
applyHash();
}
// Legacy fallback pages (search.html / settings.html) — send users to the app.
function initSearchPage() { window.location.replace("/app#all"); }
function initSettingsPage() { window.location.replace("/app#all"); }
window.NotesApp = { initWorkspace, initSearchPage, initSettingsPage };
})();