174 lines
11 KiB
JavaScript
174 lines
11 KiB
JavaScript
'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: '<path d="M3 20c4-1 7-5 8-14 3 5 8 7 10 14z"/>',
|
|
notes: '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/>',
|
|
home: '<path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/>',
|
|
today: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
|
|
journal: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
|
pin: '<path d="M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
|
|
tag: '<path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/>',
|
|
trash: '<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/>',
|
|
edit: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
|
|
restore: '<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>',
|
|
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
|
|
view: '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>',
|
|
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>',
|
|
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
|
prev: '<path d="m15 18-6-6 6-6"/>',
|
|
next: '<path d="m9 18 6-6-6-6"/>',
|
|
chev: '<path d="m9 18 6-6-6-6"/>',
|
|
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
|
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
|
move: '<path d="M5 9l-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20"/>',
|
|
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
|
|
out: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>',
|
|
menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
|
close: '<path d="M18 6 6 18M6 6l12 12"/>',
|
|
add: '<path d="M5 12h14M12 5v14"/>',
|
|
check: '<path d="M20 6 9 17l-5-5"/>',
|
|
light: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
|
|
dark: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
|
alert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
|
|
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
|
};
|
|
function icon(name, size = 18) {
|
|
const s = h('span', { class: 'inline-icon', 'aria-hidden': 'true' });
|
|
s.innerHTML = `<svg class="icon icon-${size}" xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] || ''}</svg>`;
|
|
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) });
|
|
});
|
|
}
|