Files
Mako/internal/web/static/app.js
T
VeillaxandClaude Sonnet 5.5 cec2702260 Basking: live preview editing
Pages open in Live mode, where every line is rendered except the one being
edited, with Edit (plain markdown) and View (fully rendered) beside it.
Arrow keys, Backspace and Delete move between lines, code blocks are edited
as a whole, and edits are spliced back into the one text the rest of the
editor already saves.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 22:59:14 -06:00

579 lines
35 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 Basking to add you.',
failed: 'Signing in did not finish. Please try again.',
expired: 'That sign-in took too long. Please try again.',
unavailable: 'Basking 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 refreshPages().catch(() => { });
S.ready = true;
buildShell();
show();
}
function showLogin(why) {
S.editors.clear(); S.view = null; document.title = 'Sign in · Basking';
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));
}
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 '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 + ' · ' : '') + 'Basking';
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('Delete', 'trash', act(trashIt), 'ghost danger'));
}
ed = null;
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') + ' · Basking'; drawActions(); drawCrumbs(); refreshPages(); },
onSaved(c, { renamed }) {
note = c; if (c.backlinks) backlinks = c.backlinks;
drawTags(); drawAside(); drawMeta(); drawActions();
document.title = (c.name || 'New page') + ' · Basking';
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 + ' · Basking';
// 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' }, 'Basking 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 stored as plain markdown files on the machine running Basking, under a folder only you map to. They are not encrypted on disk, so anyone who can read that machine can read them.'))),
};
}
// ---------- 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();