'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': 'View' }, h('button', { class: 'seg-b' + (ed.mode === 'write' ? ' on' : ''), 'aria-pressed': String(ed.mode === 'write'), onclick: () => ed.setMode('write') }, 'Write'), h('button', { class: 'seg-b' + (ed.mode === 'read' ? ' on' : ''), 'aria-pressed': String(ed.mode === 'read'), onclick: () => ed.setMode('read') }, 'Read')); 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 === 'write' ? 'read' : 'write'); if (n?.backlinks) backlinks = n.backlinks; const pref = store.get('mode'); const start = n && n.body.trim() ? pref || 'read' : 'write'; // 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 === 'write') setTimeout(() => ed.ta.focus()); } 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 Write and Read'), 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();