Sign-in is now OpenID Connect against Authentik (authorization code flow with PKCE, state and nonce). The password, API tokens and the add command are gone; each person who signs in gets a private notebook in a folder named from a hash of who they are. An optional allowed list is checked on every request. Notes become pages with titles, a tree of subpages, [[links]] with backlinks that follow renames, a Ctrl+K finder, slash commands, list continuation, and Write and Read modes. Autosave keeps the text through a dropped connection or an expired session. Export opens as an Obsidian vault. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
579 lines
34 KiB
JavaScript
579 lines
34 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': '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();
|