'use strict'; // ---------- tiny toolkit ---------- // h builds an element. Strings become text nodes, never HTML, so page text can never inject markup. function h(tag, props, ...kids) { const el = document.createElement(tag); let value; for (const [k, v] of Object.entries(props || {})) { if (v == null || v === false) continue; if (k.startsWith('on')) el.addEventListener(k.slice(2), v); else if (k === 'class') el.className = v; else if (k === 'value') value = v; else if (k === 'checked') el.checked = true; else el.setAttribute(k, v === true ? '' : v); } for (const k of kids.flat(Infinity)) if (k != null && k !== false) el.append(k.nodeType ? k : document.createTextNode(String(k))); if (value !== undefined) el.value = value; return el; } const $app = document.getElementById('app'); // theme: follows the system unless the person picked one try { const t = localStorage.getItem('theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) { /* storage is off */ } function toggleTheme() { const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches; const next = dark ? 'light' : 'dark'; document.documentElement.dataset.theme = next; try { localStorage.setItem('theme', next); } catch (e) { /* storage is off */ } } const store = { get(k) { try { return localStorage.getItem(k) || ''; } catch (e) { return ''; } }, set(k, v) { try { v ? localStorage.setItem(k, v) : localStorage.removeItem(k); } catch (e) { /* storage is off */ } }, }; // short confirmations at the bottom right; errors stay longer and are announced as alerts let toastBox; function toast(msg, bad, action) { if (!toastBox) { toastBox = h('div', { class: 'toasts', 'aria-live': 'polite' }); document.body.append(toastBox); } const t = h('div', { class: 'toast' + (bad ? ' toast-error' : ''), role: bad ? 'alert' : 'status' }, h('span', {}, msg), action ? h('button', { class: 'toast-act', onclick: () => { t.remove(); action.fn(); } }, action.label) : null); toastBox.append(t); setTimeout(() => t.remove(), bad ? 7000 : action ? 6000 : 3000); } const cap = s => s ? s[0].toUpperCase() + s.slice(1) : s; const SIGNED_OUT = 'Your session ended. Sign in again to continue.'; // onSignedOut is set by the app; it decides what a 401 means while someone is writing let onSignedOut = () => { }; async function api(method, url, body, opt = {}) { const init = { method, headers: { 'X-Mako': '1' }, credentials: 'same-origin', keepalive: !!opt.keepalive }; if (body !== undefined) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } let r; try { r = await fetch(url, init); } catch { throw Object.assign(new Error('Could not reach Mako. Check your connection.'), { offline: true }); } const j = await r.json().catch(() => ({})); if (r.status === 401) { onSignedOut(); throw Object.assign(new Error(SIGNED_OUT), { status: 401 }); } if (!r.ok) throw Object.assign(new Error(cap(j.error || r.statusText)), { status: r.status, data: j }); return j; } // run an action; show errors as a toast instead of throwing into the void const act = fn => async (...a) => { try { await fn(...a); } catch (e) { if (e.status !== 401) toast(e.message, true); } }; const pad = n => String(n).padStart(2, '0'); const hhmm = d => { const t = new Date(d); return `${pad(t.getHours())}:${pad(t.getMinutes())}`; }; const fmtTime = d => new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }); const isoDay = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; const parseDay = s => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); }; const addDays = (s, n) => { const d = parseDay(s); d.setDate(d.getDate() + n); return isoDay(d); }; function dayLabel(d) { const day = isoDay(d instanceof Date ? d : parseDay(d)), now = new Date(); if (day === isoDay(now)) return 'Today'; if (day === addDays(isoDay(now), -1)) return 'Yesterday'; const dt = parseDay(day); return dt.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: dt.getFullYear() === now.getFullYear() ? undefined : 'numeric' }); } const isDay = s => /^\d{4}-\d{2}-\d{2}$/.test(s); // status pill: color always travels with a text label const badge = (text, tone) => h('span', { class: 'badge' + (tone ? ' badge-' + tone : '') }, text); const emptyState = (name, title, text, action) => h('div', { class: 'empty' }, icon(name, 22), h('h3', {}, title), h('p', {}, text), action || null); // placeholder rows while a page loads, so the layout does not jump const skeleton = () => h('div', { class: 'card', role: 'status', 'aria-busy': 'true' }, h('span', { class: 'sr-only' }, 'Loading'), ['w60', 'w100', 'w90', 'w100', 'w75'].map(w => h('div', { class: 'skeleton ' + w }))); // replaceChildren stringifies null and arrays, so everything goes through here const fill = (el, ...kids) => el.replaceChildren(...kids.flat(Infinity).filter(k => k != null && k !== false)); const card = (title, ...kids) => h('div', { class: 'card' }, title ? h('h2', {}, title) : null, ...kids); const inSelection = () => { const s = getSelection(); return s && !s.isCollapsed; }; // ---------- icons (lucide-style, static strings; see docs/design.md for the approved names) ---------- const ICONS = { fin: '', notes: '', home: '', today: '', journal: '', pin: '', tag: '', trash: '', edit: '', restore: '', clock: '', view: '', download: '', copy: '', prev: '', next: '', chev: '', search: '', link: '', move: '', settings: '', out: '', menu: '', close: '', add: '', check: '', light: '', dark: '', alert: '', lock: '', }; function icon(name, size = 18) { const s = h('span', { class: 'inline-icon', 'aria-hidden': 'true' }); s.innerHTML = `${ICONS[name] || ''}`; return s; } const brand = () => h('div', { class: 'brand' }, icon('fin', 22), 'Mako'); const btn = (label, ic, onclick, cls = 'ghost') => h('button', { class: cls, onclick }, ic ? icon(ic, 16) : null, label); // ---------- dialogs ---------- // A dialog: focus moves in, Tab stays inside, Escape closes, and focus goes back to what opened it. let dialogSeq = 0; function modal(title, body, opts = {}) { const opener = document.activeElement, id = 'dialog-title-' + (++dialogSeq); const shade = h('div', { class: 'shade' + (opts.top ? ' shade-top' : '') }); const box = h('div', { class: 'modal' + (opts.wide ? ' wide' : ''), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': id, tabindex: '-1' }, h('h2', { id, class: opts.hideTitle ? 'sr-only' : '' }, title), body); let closed = false; const close = () => { if (closed) return; closed = true; document.removeEventListener('keydown', onKey, true); shade.remove(); if (opener && opener.isConnected && opener.focus) opener.focus(); if (opts.onClose) opts.onClose(); }; const focusables = () => [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e => e.offsetParent !== null); function onKey(e) { if ([...document.querySelectorAll('.shade')].pop() !== shade) return; if (e.key === 'Escape') { e.stopPropagation(); close(); } else if (e.key === 'Tab') { const f = focusables(); if (!f.length) { e.preventDefault(); return; } const first = f[0], last = f[f.length - 1]; if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } } shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); }); shade.append(box); document.body.append(shade); document.addEventListener('keydown', onKey, true); (box.querySelector('input,select,textarea') || focusables()[0] || box).focus(); return close; } const modalOpen = () => !!document.querySelector('.shade'); // A confirmation that names what it affects and says what is lost. Resolves true only when confirmed. function confirmDialog({ title, body, confirm = 'Confirm', cancel = 'Cancel', danger = true }) { return new Promise(resolve => { let close; const done = v => { resolve(v); close(); }; close = modal(title, h('div', { class: 'stack' }, h('p', { class: 'dim' }, body), h('div', { class: 'actions form-actions' }, h('button', { class: 'ghost', onclick: () => done(false) }, cancel), h('button', { class: danger ? 'ghost danger' : 'btn', onclick: () => done(true) }, confirm))), { onClose: () => resolve(false) }); }); }