Files
VeillaxandClaude Sonnet 5.5 994927c704
CI / test (push) Successful in 2m12s
Release / release (push) Successful in 3m42s
Move pages to and from the vault
A page, including a journal day, can be moved into the unlocked vault with its id kept. Links to it elsewhere become [[locked:id]], shown as Locked Page until the vault is unlocked, so its title is kept only in the vault. Move out reverses it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:58:29 -07:00

596 lines
36 KiB
JavaScript

'use strict';
// ---------- state ----------
const S = {
ready: false, me: null, seq: 0, view: null,
pages: [], byId: new Map(), byTitle: new Map(), counts: { all: 0, pinned: 0, daily: 0, trash: 0 }, tags: [],
editors: new Set(),
};
const pageByTitle = t => S.byTitle.get(t.trim().toLowerCase());
const hrefOf = n => n.daily ? '#/day/' + n.daily : '#/p/' + n.id;
const childrenOf = id => S.pages.filter(p => p.parent === id && !p.daily);
async function refreshPages() {
const r = await api('GET', '/api/pages');
S.pages = r.pages; S.counts = r.counts; S.tags = r.tags;
S.byId = new Map(r.pages.map(p => [p.id, p]));
S.byTitle = new Map();
for (const p of r.pages) if (p.title && !S.byTitle.has(p.title.toLowerCase())) S.byTitle.set(p.title.toLowerCase(), p);
updateNav();
S.view?.pagesChanged?.();
}
let refreshTimer = null;
const refreshSoon = () => { clearTimeout(refreshTimer); refreshTimer = setTimeout(() => refreshPages().catch(() => { }), 2500); };
// Anything still waiting to autosave is saved before the page changes or the tab is hidden or closed.
const flushAll = () => Promise.all([...S.editors].map(e => e.save()));
document.addEventListener('visibilitychange', () => { if (document.hidden) flushAll(); });
window.addEventListener('pagehide', () => { flushAll(); });
window.addEventListener('beforeunload', e => { if ([...S.editors].some(x => x.dirty())) { e.preventDefault(); e.returnValue = ''; } });
// A 401 while someone is writing must not throw their text away: the editor keeps it and offers to sign in again.
onSignedOut = () => { if (S.ready && ![...S.editors].some(e => e.dirty())) { S.ready = false; showLogin(); } };
// ---------- signing in ----------
const SIGNIN_MESSAGES = {
denied: 'This account is not allowed to open this notebook. Ask whoever runs Mako to add you.',
failed: 'Signing in did not finish. Please try again.',
expired: 'That sign-in took too long. Please try again.',
unavailable: 'Mako could not reach Authentik. Try again in a moment.',
};
async function boot() {
const why = new URLSearchParams(location.search).get('signin');
if (why) history.replaceState(null, '', location.pathname + location.hash);
try { S.me = await api('GET', '/api/me'); }
catch (e) {
if (e.status === 401) return showLogin(why);
return $app.replaceChildren(h('div', { class: 'center' }, card('', brand(), h('p', {}, e.message), btn('Try again', 'restore', boot, 'btn'))));
}
await Promise.all([refreshPages().catch(() => { }), refreshVaultNames()]);
S.ready = true;
buildShell();
show();
}
function showLogin(why) {
S.editors.clear(); S.view = null; S.vaultNames = null; document.title = 'Sign in · Mako';
const next = location.hash.length > 2 ? location.hash : '';
$app.replaceChildren(h('main', { class: 'center' }, card('', brand(),
h('h1', { class: 'login-h' }, 'Your private notebook'),
h('p', { class: 'dim center-text' }, 'Pages, notes and a journal that only you can open. Sign in with your Authentik account to continue.'),
SIGNIN_MESSAGES[why] ? h('div', { class: 'notice notice-warning', role: 'alert' }, icon('alert', 16), SIGNIN_MESSAGES[why]) : null,
h('a', { class: 'btn-link primary wide', href: '/auth/login' + (next ? '?next=' + encodeURIComponent(next) : '') }, icon('lock', 16), 'Continue with Authentik'))));
}
// ---------- shell ----------
function setDrawer(open) {
const sb = document.getElementById('sidebar'), bd = document.getElementById('backdrop'), mb = document.getElementById('menu-btn');
if (!sb) return;
sb.classList.toggle('open', open); bd.classList.toggle('open', open); mb.setAttribute('aria-expanded', open);
if (open) sb.querySelector('a,button')?.focus(); else mb.focus();
}
document.addEventListener('keydown', e => { if (e.key === 'Escape' && !modalOpen()) document.getElementById('sidebar')?.classList.contains('open') && setDrawer(false); });
const route = () => {
const p = location.hash.slice(2).split('/');
let arg = p.slice(1).join('/');
try { arg = decodeURIComponent(arg); } catch { /* keep it as typed */ }
return { name: p[0] || 'home', arg };
};
const routeKey = r => r.name === 'tag' ? 'tag/' + r.arg : r.name === 'p' ? 'p/' + r.arg : r.name === 'day' ? (r.arg === isoDay(new Date()) ? 'today' : 'journal') : r.name;
function navItem(href, key, label, ic, count) {
const cur = routeKey(route()) === key;
return h('a', { class: 'navitem' + (cur ? ' on' : ''), href, 'aria-current': cur ? 'page' : null, onclick: () => setDrawer(false) },
ic ? icon(ic) : null, h('span', { class: 'label' }, label), count == null ? null : h('span', { class: 'count' }, count));
}
const openSet = () => { try { return new Set(JSON.parse(store.get('open') || '[]')); } catch { return new Set(); } };
function toggleOpen(id) { const s = openSet(); s.has(id) ? s.delete(id) : s.add(id); store.set('open', JSON.stringify([...s])); }
// The pages in the sidebar, nested the way they were made. A page holding the one you are on stays open.
function tree(here) {
const kids = new Map();
for (const p of S.pages) {
if (p.daily) continue;
const par = p.parent && S.byId.has(p.parent) && !S.byId.get(p.parent).daily ? p.parent : '';
if (!kids.has(par)) kids.set(par, []);
kids.get(par).push(p);
}
const open = openSet(), forced = new Set(), cur = here.startsWith('p/') ? here.slice(2) : '';
for (let id = S.byId.get(cur)?.parent, n = 0; id && n < 50; id = S.byId.get(id)?.parent, n++) forced.add(id);
const rows = [];
(function walk(par, depth) {
for (const p of kids.get(par) || []) {
const has = (kids.get(p.id) || []).length > 0, isOpen = open.has(p.id) || forced.has(p.id), on = cur === p.id;
rows.push(h('div', { class: 'treerow d' + Math.min(depth, 6) },
has ? h('button', { class: 'twist' + (isOpen ? ' open' : ''), 'aria-label': (isOpen ? 'Collapse ' : 'Expand ') + p.name, 'aria-expanded': String(isOpen), onclick: () => { toggleOpen(p.id); updateNav(); } }, icon('chev', 16)) : h('span', { class: 'twist-gap' }),
h('a', { class: 'navitem tree-link' + (on ? ' on' : ''), href: '#/p/' + p.id, 'aria-current': on ? 'page' : null, onclick: () => setDrawer(false) }, h('span', { class: 'label' }, p.name)),
h('button', { class: 'sub-add', 'aria-label': 'Add a subpage to ' + p.name, title: 'Add a subpage', onclick: () => { setDrawer(false); location.hash = '#/new/' + p.id; } }, icon('add', 16))));
if (has && isOpen && depth < 30) walk(p.id, depth + 1);
}
})('', 0);
return rows.length ? rows : h('p', { class: 'navhint' }, 'Pages you title show up here. Use + to make one.');
}
// The sidebar's lists and counts change as pages do, so they are rebuilt on their own.
function updateNav() {
const box = document.getElementById('nav-dynamic');
if (!box) return;
const here = routeKey(route()), pinned = S.pages.filter(p => p.pinned && !p.daily);
fill(box,
h('button', { class: 'navitem find', onclick: () => { setDrawer(false); quickFind(); } }, icon('search'), h('span', { class: 'label' }, 'Search'), h('kbd', {}, 'Ctrl K')),
navItem('#/', 'home', 'Home', 'home'),
navItem('#/today', 'today', 'Today', 'today'),
navItem('#/journal', 'journal', 'Journal', 'journal', S.counts.daily || null),
pinned.length ? [h('div', { class: 'navsec' }, 'Pinned'), pinned.map(p => navItem('#/p/' + p.id, 'p/' + p.id, p.name, 'pin'))] : null,
h('div', { class: 'navsec navsec-row' }, 'Pages', h('button', { class: 'ghost quiet btn-icon tiny', 'aria-label': 'New page', title: 'New page (c)', onclick: () => { setDrawer(false); location.hash = '#/new'; } }, icon('add', 16))),
tree(here),
S.tags.length ? [h('div', { class: 'navsec' }, 'Tags'), S.tags.map(t => navItem('#/tag/' + encodeURIComponent(t.tag), 'tag/' + t.tag, t.tag, 'tag', t.count))] : null,
h('div', { class: 'navsec' }, 'Other'),
navItem('#/trash', 'trash', 'Trash', 'trash', S.counts.trash || null),
navItem('#/vault', 'vault', 'Vault', 'lock'));
}
function buildShell() {
const main = h('main', { id: 'view', tabindex: '-1' });
$app.replaceChildren(h('a', { class: 'skip', href: '#view', onclick: e => { e.preventDefault(); main.focus(); } }, 'Skip to content'), h('div', { class: 'shell' },
h('div', { class: 'topbar' }, brand(), h('div', { class: 'actions' },
h('button', { class: 'ghost quiet btn-icon', 'aria-label': 'Search', onclick: () => quickFind() }, icon('search')),
h('button', { class: 'ghost quiet btn-icon', id: 'menu-btn', 'aria-label': 'Open menu', 'aria-controls': 'sidebar', 'aria-expanded': 'false', onclick: () => setDrawer(true) }, icon('menu')))),
h('div', { class: 'backdrop', id: 'backdrop', onclick: () => setDrawer(false) }),
h('aside', { class: 'sidebar', id: 'sidebar', 'aria-label': 'Main' },
h('div', { class: 'sidebar-head' }, brand(), h('button', { class: 'ghost quiet btn-icon sidebar-close', 'aria-label': 'Close menu', onclick: () => setDrawer(false) }, icon('close'))),
h('nav', { id: 'nav-dynamic', class: 'nav', 'aria-label': 'Pages' }),
h('div', { class: 'grow' }),
h('div', { class: 'userbox' },
h('div', { class: 'who', title: S.me.email || '' }, S.me.name),
navItem('#/settings', 'settings', 'Settings', 'settings'),
h('button', { class: 'navitem', onclick: toggleTheme }, icon('dark'), 'Switch theme'),
h('button', { class: 'navitem', onclick: act(async () => { await flushAll(); await api('POST', '/api/logout'); S.ready = false; S.editors.clear(); showLogin(); }) }, icon('out'), 'Sign out'))),
h('div', { class: 'content' }, main)));
updateNav();
}
window.addEventListener('hashchange', () => { if (S.ready) { updateNav(); show(); } });
function pageHeader(title, desc, primary) {
return h('div', { class: 'page-h' }, h('div', {}, h('h1', {}, title), desc ? h('p', {}, desc) : null),
primary ? h('button', { class: 'btn', onclick: primary[1] }, icon(primary[2] || 'add', 16), primary[0]) : null);
}
async function show() {
const mine = ++S.seq;
await flushAll();
if (mine !== S.seq) return;
S.view?.dispose?.(); S.editors.clear();
const r = route(), main = document.getElementById('view');
let page;
switch (r.name) {
case 'today': location.replace('#/day/' + isoDay(new Date())); return;
case 'day': page = isDay(r.arg) ? pageView({ date: r.arg }) : feed({ view: 'all', composer: true, title: 'Home' }); break;
case 'p': page = pageView({ id: r.arg }); break;
case 'new': page = pageView({ parent: r.arg }); break;
case 'link': page = linkPage(r.arg); break;
case 'journal': page = journalPage(); break;
case 'pinned': page = feed({ view: 'pinned', title: 'Pinned', desc: 'Notes you pinned to keep close.' }); break;
case 'tag': page = feed({ view: 'all', tag: r.arg, title: '#' + r.arg, desc: 'Every page with this tag.' }); break;
case 'trash': page = feed({ view: 'trash', title: 'Trash', desc: 'Deleted pages wait here until you empty the trash.' }); break;
case 'vault': page = vaultView(r.arg); break;
case 'settings': page = settingsPage(); break;
default: page = feed({ view: 'all', composer: true, title: 'Home', desc: 'Write anything down. Give it a title and a place later, or never.' });
}
S.view = page;
main.replaceChildren(page.el);
document.title = (page.title ? page.title + ' · ' : '') + 'Mako';
window.scrollTo(0, 0);
page.start?.();
}
// ---------- a page: title, text, subpages and what links here ----------
function pageView({ id, date, parent = '' }) {
const root = h('div', { class: 'page' });
let ed = null, note = null, backlinks = [], dead = false;
const crumbs = h('nav', { class: 'crumbs', 'aria-label': 'Where this page is' });
const actions = h('div', { class: 'page-actions' });
const tagsRow = h('div', { class: 'tagrow' });
const asideBox = h('div', { class: 'aside' });
const meta = h('p', { class: 'sub page-meta' });
const body = h('div');
const view = { el: root, title: date ? dayLabel(date) : '', dispose() { dead = true; ed?.dispose(); }, pagesChanged() { if (ed) { drawCrumbs(); drawAside(); } } };
const parentId = () => note ? note.parent : parent;
function drawCrumbs() {
const chain = [];
for (let pid = parentId(), n = 0; pid && S.byId.has(pid) && n < 30; pid = S.byId.get(pid).parent, n++) chain.unshift(S.byId.get(pid));
fill(crumbs, date ? h('a', { href: '#/journal' }, 'Journal') : chain.map((p, k) => [k ? h('span', { class: 'sep', 'aria-hidden': 'true' }, '/') : null, h('a', { href: '#/p/' + p.id }, p.name)]));
}
function drawTags() {
const tags = ed?.note?.tags || note?.tags || [];
fill(tagsRow, tags.map(t => tagChip(t)));
}
function drawAside() {
const kids = note ? childrenOf(note.id) : [];
fill(asideBox,
kids.length ? h('section', { class: 'aside-sec' }, h('h2', {}, 'Subpages'),
h('ul', { class: 'linklist' }, kids.map(k => h('li', {}, h('a', { href: '#/p/' + k.id }, icon('notes', 16), k.name))))) : null,
note ? h('section', { class: 'aside-sec' }, h('h2', {}, 'Linked from'), backlinks.length
? h('ul', { class: 'linklist' }, backlinks.map(b => h('li', {}, h('a', { href: hrefOf(b) }, icon(b.daily ? 'today' : 'link', 16), b.daily ? dayLabel(b.daily) : b.name))))
: h('p', { class: 'sub' }, note.title ? 'No page links here yet. Type [[ in another page and pick this one.' : 'Give this page a title so other pages can link to it.')) : null);
}
function drawMeta() { meta.textContent = note ? 'Edited ' + fmtTime(note.updated) : ''; }
function drawActions() {
const have = !!(note && note.id);
const seg = h('div', { class: 'seg', role: 'group', 'aria-label': 'How the page is shown' },
[['live', 'Live', 'Rendered, except the line you are on'], ['write', 'Edit', 'Plain markdown'], ['read', 'View', 'The finished page']].map(([m, label, tip]) =>
h('button', { class: 'seg-b' + (ed.mode === m ? ' on' : ''), 'aria-pressed': String(ed.mode === m), title: tip, onclick: () => ed.setMode(m) }, label)));
fill(actions, ed.status, h('span', { class: 'grow' }), seg,
date ? [btn('Insert time', 'clock', () => ed.stamp())] : null,
btn(note?.pinned ? 'Unpin' : 'Pin', 'pin', act(async () => {
if (!have) return toast('Type something first, then you can pin the page');
note = await api('PUT', '/api/notes/' + note.id, { pinned: !note.pinned }); drawActions(); refreshPages();
})),
date ? null : btn('Move', 'move', () => have ? movePage(note, async () => { await refreshPages(); note = await api('GET', '/api/notes/' + note.id); drawCrumbs(); }) : toast('Type something first, then you can move the page')),
date ? null : btn('Subpage', 'add', act(async () => { if (!have) return toast('Type something first, then you can add a subpage'); await ed.flush(); location.hash = '#/new/' + note.id; })),
btn('To vault', 'lock', act(toVault), 'ghost'),
btn('Delete', 'trash', act(trashIt), 'ghost danger'));
}
ed = null;
// Moving a page to the vault needs the vault to be unlocked. Links to the page elsewhere read "Locked Page".
async function toVault() {
if (!note || !note.id) return toast('Type something first, then you can move the page to the vault');
await ed.flush();
const v = await api('GET', '/api/vault');
if (!v.unlocked) return toast(v.exists ? 'Unlock your vault first, then move the page.' : 'Make your vault first, then move the page.', false, { label: 'Open vault', fn: () => { location.hash = '#/vault'; } });
const n = note;
if (!await confirmDialog({ title: 'Move this page to the vault?', body: `"${n.daily ? dayLabel(n.daily) : n.name}" will be locked away in your vault. Pages that link to it will show "Locked Page" until you unlock the vault.`, confirm: 'Move to vault', danger: false })) return;
await api('POST', `/api/notes/${n.id}/vault`);
await Promise.all([refreshPages(), refreshVaultNames()]);
toast('Moved to the vault', false, { label: 'Undo', fn: act(async () => { await api('POST', `${VAULT}/notes/${n.id}/out`); await Promise.all([refreshPages(), refreshVaultNames()]); location.hash = hrefOf(n); }) });
location.hash = date ? '#/journal' : n.parent && S.byId.has(n.parent) ? '#/p/' + n.parent : '#/';
}
async function trashIt() {
if (!note) { location.hash = '#/'; return; }
await ed.flush();
const n = note, kids = childrenOf(n.id).length;
await api('DELETE', '/api/notes/' + n.id);
toast(kids ? 'Moved to the trash. Its subpages moved up a level.' : 'Moved to the trash', false, { label: 'Undo', fn: act(async () => { await api('POST', `/api/notes/${n.id}/restore`); await refreshPages(); location.hash = hrefOf(n); }) });
await refreshPages();
location.hash = n.parent && S.byId.has(n.parent) ? '#/p/' + n.parent : '#/';
}
function build(n) {
note = n;
view.title = date ? dayLabel(date) : n?.name || 'New page';
ed = pageEditor({
note: n, parent, fixedTitle: date ? parseDay(date).toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) : undefined,
onCreated(c) { note = c; history.replaceState(null, '', '#/p/' + c.id); document.title = (c.name || 'New page') + ' · Mako'; drawActions(); drawCrumbs(); refreshPages(); },
onSaved(c, { renamed }) {
note = c; if (c.backlinks) backlinks = c.backlinks;
drawTags(); drawAside(); drawMeta(); drawActions();
document.title = (c.name || 'New page') + ' · Mako';
renamed ? refreshPages().catch(() => { }) : refreshSoon();
},
});
ed.onMode = () => drawActions();
view.toggleMode = () => ed.setMode(ed.mode === 'read' ? ed.lastEdit : 'read');
if (n?.backlinks) backlinks = n.backlinks;
const pref = store.get('mode');
const start = pref === 'read' && !(n && n.body.trim()) ? 'live' : pref || 'live'; // an empty page is for writing
const title = date ? null : h('div', { class: 'title-wrap' }, ed.titleEl, ed.titleErr);
fill(body, ed.notice, title, date ? h('h1', { class: 'day-title' }, ed.titleEl.value) : null, ed.wrap, tagsRow, asideBox, meta);
ed.setMode(start);
drawCrumbs(); drawTags(); drawAside(); drawMeta(); drawActions();
document.title = view.title + ' · Mako';
// keep the person's choice of Write or Read once they have made one
const set = ed.setMode; ed.setMode = m => { store.set('mode', m); set(m); };
if (!n) setTimeout(() => ed.titleEl.focus());
else if (!date && !n.body.trim() && !n.title) setTimeout(() => ed.titleEl.focus());
else if (date && !n.body.trim() && start !== 'read') setTimeout(() => ed.focusBody(true));
}
function trashedView(n) {
view.title = n.name;
fill(body, h('div', { class: 'notice notice-warning' }, icon('trash', 16), h('span', { class: 'grow' }, 'This page is in the trash.'),
btn('Restore', 'restore', act(async () => { await api('POST', `/api/notes/${n.id}/restore`); await refreshPages(); show(); })),
btn('Delete for good', 'trash', act(async () => {
if (!await confirmDialog({ title: 'Delete this page for good?', body: `"${n.name}" will be deleted and cannot be brought back.`, confirm: 'Delete page' })) return;
await api('DELETE', `/api/notes/${n.id}?purge=1`); await refreshPages(); location.hash = '#/trash';
}), 'ghost danger')),
h('h1', { class: 'day-title' }, n.name), md(n.body, null, true));
}
view.start = act(async () => {
fill(body, skeleton());
if (id || date) {
let n;
try { n = date ? await api('POST', '/api/daily', { date }) : await api('GET', '/api/notes/' + encodeURIComponent(id)); }
catch (e) {
if (e.status === 404) { fill(body, emptyState('notes', 'This page is not here', 'It may have been deleted for good, or the link is wrong.', btn('Back to Home', 'home', () => { location.hash = '#/'; }, 'btn'))); return; }
throw e;
}
if (dead) return;
if (n.trashed) return trashedView(n);
build(n);
} else build(null);
});
if (date) {
const today = isoDay(new Date());
fill(root, h('div', { class: 'page-top' }, crumbs, actions), body);
const nav = h('div', { class: 'actions daynav' },
btn('Previous day', 'prev', () => { location.hash = '#/day/' + addDays(date, -1); }),
date === today ? null : btn('Today', 'today', () => { location.hash = '#/day/' + today; }),
date >= today ? null : btn('Next day', 'next', () => { location.hash = '#/day/' + addDays(date, 1); }));
root.insertBefore(nav, body);
} else fill(root, h('div', { class: 'page-top' }, crumbs, actions), body);
return view;
}
// A [[link]] to a page that does not exist yet: make it, then go there.
function linkPage(title) {
return {
title, el: h('div', {}, skeleton()),
start: act(async () => {
if (isDay(title)) { location.replace('#/day/' + title); return; }
let p = pageByTitle(title);
if (!p) { await refreshPages(); p = pageByTitle(title); }
if (!p) {
try { const n = await api('POST', '/api/notes', { title }); await refreshPages(); location.replace('#/p/' + n.id); return; }
catch (e) { if (e.data?.code !== 'title_taken') throw e; await refreshPages(); p = pageByTitle(title); }
}
if (p) location.replace(hrefOf(p)); else location.replace('#/');
}),
};
}
// Pick where a page goes: a list of every other page it can sit inside, or the top level.
function movePage(n, done) {
const below = new Set([n.id]);
for (let grew = true; grew;) { grew = false; for (const p of S.pages) if (!below.has(p.id) && below.has(p.parent)) { below.add(p.id); grew = true; } }
const input = h('input', { type: 'search', placeholder: 'Search pages', 'aria-label': 'Search pages' }), list = h('div', { class: 'finder-list' });
let close;
const pick = act(async parentId => {
close();
await api('PUT', '/api/notes/' + n.id, { parent: parentId });
toast(parentId ? 'Moved inside ' + S.byId.get(parentId).name : 'Moved to the top level');
await done();
});
function draw() {
const q = input.value.trim().toLowerCase();
const rows = S.pages.filter(p => !p.daily && !below.has(p.id) && (!q || p.name.toLowerCase().includes(q)));
fill(list, !q ? h('button', { class: 'finder-row', onclick: () => pick('') }, icon('home', 16), h('span', { class: 'finder-name' }, 'Top level'), n.parent ? null : h('span', { class: 'sub' }, 'now')) : null,
rows.slice(0, 40).map(p => h('button', { class: 'finder-row', onclick: () => pick(p.id) }, icon('notes', 16), h('span', { class: 'finder-name' }, p.name), p.id === n.parent ? h('span', { class: 'sub' }, 'now') : null)),
!rows.length && q ? h('p', { class: 'sub pad' }, 'No page matches.') : null);
}
input.addEventListener('input', draw);
close = modal('Move "' + n.name + '"', h('div', { class: 'stack' }, input, list), { wide: true });
draw();
}
// ---------- quick find: Ctrl+K, or / when not typing ----------
function quickFind() {
if (document.querySelector('.finder')) return;
const input = h('input', { type: 'search', class: 'finder-input', placeholder: 'Search pages, or type a title to make a new one', 'aria-label': 'Search pages', autocomplete: 'off' });
const list = h('div', { class: 'finder-list', role: 'listbox' });
let hits = [], sel = 0, timer, token = 0, close;
const items = () => {
const q = input.value.trim();
const out = hits.map(x => ({ name: x.daily ? dayLabel(x.daily) : x.name, sub: x.snippet || (x.daily ? 'Journal' : S.byId.get(x.parent)?.name || ''), href: hrefOf(x), ic: x.daily ? 'today' : 'notes' }));
if (q && !hits.some(x => x.title && x.title.toLowerCase() === q.toLowerCase())) out.push({ name: 'Create "' + q + '"', sub: 'New page', href: '#/link/' + encodeURIComponent(q), ic: 'add' });
return out;
};
const go = it => { close(); location.hash = it.href; };
function draw() {
const rows = items();
sel = Math.min(sel, Math.max(0, rows.length - 1));
fill(list, rows.length ? rows.map((it, i) => h('div', { class: 'finder-row' + (i === sel ? ' on' : ''), role: 'option', 'aria-selected': String(i === sel), onmousedown: e => { e.preventDefault(); go(it); } },
icon(it.ic, 16), h('span', { class: 'finder-name' }, it.name), it.sub ? h('span', { class: 'sub finder-sub' }, it.sub) : null))
: h('p', { class: 'sub pad' }, 'Nothing yet. Write a page and it will show up here.'));
list.querySelector('.on')?.scrollIntoView({ block: 'nearest' });
}
const load = act(async () => {
const mine = ++token;
const r = await api('GET', '/api/find?q=' + encodeURIComponent(input.value.trim()));
if (mine !== token) return;
hits = r.hits; sel = 0; draw();
});
input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(load, 120); });
input.addEventListener('keydown', e => {
const rows = items();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); if (rows.length) { sel = (sel + (e.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length; draw(); } }
else if (e.key === 'Enter' && rows[sel]) { e.preventDefault(); go(rows[sel]); }
});
close = modal('Search', h('div', { class: 'finder' }, input, list), { wide: true, top: true, hideTitle: true });
draw(); load();
}
// ---------- note cards (Home, Pinned, tags, Trash) ----------
const LONG_LINES = 14, LONG_CHARS = 1200;
function noteCard(n, ctx) {
const el = h('article', { class: 'note' + (n.pinned ? ' pinned' : '') + (n.trashed ? '' : ' openable'), 'data-id': n.id });
const toggleTask = act(async (line, checked) => {
const lines = n.body.split('\n');
lines[line] = lines[line].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]');
n = await api('PUT', '/api/notes/' + n.id, { body: lines.join('\n'), base: n.updated });
ctx.changed(n);
});
const long = n.body.split('\n').length > LONG_LINES || n.body.length > LONG_CHARS;
let open = false;
const body = n.body.trim() ? md(n.body, n.trashed ? null : toggleTask) : h('p', { class: 'dim' }, 'Empty page');
const wrap = h('div', { class: 'note-body' + (long ? ' clamp' : '') }, body);
const more = long ? h('button', { class: 'ghost quiet small-btn', 'aria-expanded': 'false', onclick: e => {
open = !open; wrap.classList.toggle('clamp', !open); e.currentTarget.setAttribute('aria-expanded', open); e.currentTarget.textContent = open ? 'Show less' : 'Show more';
} }, 'Show more') : null;
const heading = n.daily ? dayLabel(n.daily) : n.title;
const when = h('time', { datetime: n.created, title: 'Written ' + fmtTime(n.created) + (n.updated !== n.created ? '. Edited ' + fmtTime(n.updated) : '') }, hhmm(n.created));
const actions = n.trashed
? [btn('Restore', 'restore', act(() => ctx.restore(n))), btn('Delete for good', 'trash', act(() => ctx.purge(n)), 'ghost danger')]
: [btn('Open', 'edit', () => { location.hash = hrefOf(n); }), btn(n.pinned ? 'Unpin' : 'Pin', 'pin', act(async () => { n = await api('PUT', '/api/notes/' + n.id, { pinned: !n.pinned }); ctx.changed(n); })), btn('Delete', 'trash', act(() => ctx.trash(n)))];
fill(el, heading ? h('h3', { class: 'note-title' }, n.trashed ? heading : h('a', { href: hrefOf(n) }, heading)) : null, wrap, more,
h('div', { class: 'note-foot' }, when, n.pinned ? badge('Pinned', 'accent') : null, h('span', { class: 'grow' }), h('div', { class: 'actions' }, actions)));
if (!n.trashed) el.addEventListener('click', e => { if (!e.target.closest('a,input,button') && !inSelection()) location.hash = hrefOf(n); });
return el;
}
// ---------- the quick-note box ----------
function composer(onSaved) {
const ta = h('textarea', { class: 'write', rows: 3, placeholder: 'Jot something down. Press Ctrl+Enter to save.', 'aria-label': 'New note' });
ta.value = store.get('draft');
const fit = () => { ta.style.height = 'auto'; ta.style.height = Math.min(Math.max(ta.scrollHeight + 2, 84), window.innerHeight * 0.5) + 'px'; };
const go = h('button', { class: 'btn' }, icon('check', 16), 'Save note');
const save = async () => {
const text = ta.value.trim();
if (!text) { ta.focus(); return; }
go.disabled = true;
try {
await api('POST', '/api/notes', { body: text });
ta.value = ''; store.set('draft', ''); fit();
await onSaved();
} catch (e) { if (e.status !== 401) toast(e.message, true); }
go.disabled = false; ta.focus();
};
go.addEventListener('click', save);
ta.addEventListener('input', () => { store.set('draft', ta.value); fit(); });
ta.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); } });
setTimeout(fit);
return Object.assign(h('div', { class: 'card composer' }, ta, h('div', { class: 'note-foot' },
h('span', { class: 'sub' }, 'A quick note. Add #tags to find it later, or open it to give it a title.'), h('span', { class: 'grow' }),
btn('New page', 'add', () => { location.hash = '#/new'; }), go)), { ta });
}
// ---------- lists of notes ----------
function feed({ view, tag = '', title, desc, composer: withComposer }) {
const list = h('div', { class: 'notes' }), moreBox = h('div', { class: 'more' });
const search = h('input', { type: 'search', placeholder: 'Filter these notes, or type #tag', 'aria-label': 'Filter notes' });
let items = [], total = 0, timer = null, token = 0;
async function load(reset) {
const mine = ++token, offset = reset ? 0 : items.length;
const q = new URLSearchParams({ view, tag, q: search.value.trim(), offset, limit: 50 });
const r = await api('GET', '/api/notes?' + q);
if (mine !== token) return;
items = reset ? r.notes : items.concat(r.notes); total = r.total;
draw();
}
const reload = act(() => Promise.all([load(true), refreshPages()]));
const ctx = {
changed(n) {
const k = items.findIndex(x => x.id === n.id);
if (k >= 0) items[k] = n;
if (view === 'pinned' && !n.pinned) { items.splice(k, 1); total--; }
draw(); refreshPages().catch(() => { });
},
async trash(n) {
await api('DELETE', '/api/notes/' + n.id);
toast('Moved to the trash', false, { label: 'Undo', fn: act(async () => { await api('POST', `/api/notes/${n.id}/restore`); await reload(); }) });
await reload();
},
async restore(n) { await api('POST', `/api/notes/${n.id}/restore`); toast('Restored'); await reload(); },
async purge(n) {
if (!await confirmDialog({ title: 'Delete this for good?', body: `"${n.name}" will be deleted and cannot be brought back.`, confirm: 'Delete' })) return;
await api('DELETE', `/api/notes/${n.id}?purge=1`); toast('Deleted'); await reload();
},
};
function draw() {
list.replaceChildren(); moreBox.replaceChildren();
if (!items.length) {
const searching = search.value.trim() || tag;
const [ic, t, p] = view === 'trash' ? ['trash', 'The trash is empty', 'Pages you delete wait here until you empty the trash.']
: view === 'pinned' ? ['pin', 'Nothing pinned', 'Pin a page to keep it here.']
: searching ? ['view', 'Nothing matches', 'Try fewer words, or clear the filter.']
: ['notes', 'Nothing here yet', 'Jot something in the box above, or make a page with New page.'];
list.append(emptyState(ic, t, p));
} else {
let day = '', group;
for (const n of items) {
const d = isoDay(new Date(n.created));
if (d !== day && view !== 'trash') {
day = d; group = h('section', { class: 'day', 'aria-label': dayLabel(d) }, h('h2', { class: 'day-h' }, dayLabel(d)));
list.append(group);
} else if (view === 'trash' && !group) { group = h('section', { class: 'day' }); list.append(group); }
group.append(noteCard(n, ctx));
}
}
if (items.length < total) moreBox.append(h('button', { class: 'ghost', onclick: act(() => load(false)) }, `Show more (${total - items.length} left)`));
}
const comp = withComposer ? composer(async () => { search.value = ''; await load(true); await refreshPages(); }) : null;
const emptyBtn = view === 'trash' ? btn('Empty trash', 'trash', act(async () => {
if (!total) { toast('The trash is already empty'); return; }
if (!await confirmDialog({ title: 'Empty the trash?', body: `${total} ${total === 1 ? 'page' : 'pages'} will be deleted for good. This cannot be undone.`, confirm: 'Empty trash' })) return;
await api('DELETE', '/api/trash'); toast('Trash emptied'); await reload();
}), 'ghost danger') : null;
search.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(act(() => load(true)), 250); });
search.addEventListener('keydown', e => { if (e.key === 'Escape') { search.value = ''; load(true); search.blur(); } });
const el = h('div', {}, pageHeader(title || 'Home', desc), comp,
h('div', { class: 'toolbar' }, search, emptyBtn ? h('span', { class: 'push' }, emptyBtn) : null), list, moreBox);
list.append(skeleton());
return { el, title, search, composer: comp, start: act(() => load(true)) };
}
// ---------- journal ----------
function journalPage() {
const list = h('div', { class: 'notes' });
return {
title: 'Journal',
el: h('div', {}, pageHeader('Journal', 'One page for each day. Open a day to write it, or read back over the ones before.', ['Write today', () => { location.hash = '#/day/' + isoDay(new Date()); }, 'edit']), list),
start: act(async () => {
const r = await api('GET', '/api/notes?view=daily&limit=200');
list.replaceChildren();
const filled = r.notes.filter(n => n.body.trim());
if (!filled.length) { list.append(emptyState('journal', 'No journal entries yet', 'Open today and write a few lines. Each day gets its own page.')); return; }
for (const n of filled) {
list.append(h('article', { class: 'note openable' }, h('h2', { class: 'day-h' }, h('a', { href: '#/day/' + n.daily }, dayLabel(n.daily))),
h('div', { class: 'note-body clamp' }, md(n.body)),
h('div', { class: 'note-foot' }, h('span', { class: 'grow' }), h('div', { class: 'actions' }, btn('Open', 'edit', () => { location.hash = '#/day/' + n.daily; })))));
}
}),
};
}
// ---------- settings ----------
function settingsPage() {
const row = (k, v) => h('li', {}, h('kbd', {}, k), ' ', v);
return {
title: 'Settings',
el: h('div', {}, pageHeader('Settings', 'Who you are signed in as, and a copy of everything.'),
card('Account', h('p', {}, 'You are signed in as ', h('strong', {}, S.me.name), S.me.email ? ' (' + S.me.email + ')' : '', ' through Authentik.'),
h('p', { class: 'dim' }, 'Mako has no password of its own. Who can sign in is decided in Authentik, and your pages are visible to you alone.'),
btn('Sign out', 'out', act(async () => { await flushAll(); await api('POST', '/api/logout'); S.ready = false; S.editors.clear(); showLogin(); }))),
card('Your notes', h('p', { class: 'dim' }, 'Download every page as a folder of markdown files. It opens as a vault in Obsidian, with your [[links]] and journal intact.'),
btn('Download all pages', 'download', () => { location.href = '/api/export'; })),
card('Shortcuts', h('ul', { class: 'keys' },
row('Ctrl K', 'Search pages, or make a new one'), row('c', 'New page'), row('n', 'Jot a quick note'),
row('Ctrl E', 'Switch between editing and View'), row('/', 'In a page: commands. Anywhere else: search'),
row('[[', 'In a page: link to another page'), row('Ctrl B, Ctrl I', 'Bold, italic'), row('Tab', 'In a list: indent'))),
card('Privacy', h('p', { class: 'dim' }, 'Pages are encrypted on the machine running Mako, with a key made for you alone from the server\'s key and your Authentik account. A copy of the disk or a backup of the notes folder cannot be read without that key. Whoever runs the server can still read pages while it is running, and your download above is plain markdown.'))),
};
}
// ---------- keyboard ----------
document.addEventListener('keydown', e => {
if (!S.ready) return;
const mod = e.ctrlKey || e.metaKey;
if (mod && !e.altKey && !e.shiftKey && (e.key === 'k' || e.key === 'o')) { e.preventDefault(); if (!modalOpen()) quickFind(); return; }
if (mod && !e.altKey && !e.shiftKey && e.key === 'e' && S.view?.toggleMode) { e.preventDefault(); S.view.toggleMode(); return; }
if (modalOpen() || mod || e.altKey) return;
if (e.target.matches?.('input,textarea,select,[contenteditable]')) return;
if (e.key === '/') { e.preventDefault(); quickFind(); }
else if (e.key === 'c') { e.preventDefault(); location.hash = '#/new'; }
else if (e.key === 'n') {
e.preventDefault();
if (S.view?.composer) S.view.composer.ta.focus(); else { location.hash = '#/'; setTimeout(() => S.view?.composer?.ta.focus(), 500); }
}
});
boot();