Basking: a place to dump thoughts, jot notes and keep a journal
Plain markdown files, tags by typing #hashtags, a page per day, search, pin and trash with undo, autosave with conflict warnings, API tokens, zip export, and the Lightwell dashboard. Includes install.sh, CI and release workflows. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b548faa258
23 files changed
+4026
No files matched your search
@@ -0,0 +1,710 @@
|
||||
'use strict';
|
||||
|
||||
// ---------- tiny toolkit ----------
|
||||
// h builds an element. Strings become text nodes, never HTML, so note text can never inject markup.
|
||||
function h(tag, props, ...kids) {
|
||||
const el = document.createElement(tag);
|
||||
let value;
|
||||
for (const [k, v] of Object.entries(props || {})) {
|
||||
if (v == null || v === false) continue;
|
||||
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
||||
else if (k === 'class') el.className = v;
|
||||
else if (k === 'value') value = v;
|
||||
else if (k === 'checked') el.checked = true;
|
||||
else el.setAttribute(k, v === true ? '' : v);
|
||||
}
|
||||
for (const k of kids.flat(Infinity)) if (k != null && k !== false) el.append(k.nodeType ? k : document.createTextNode(String(k)));
|
||||
if (value !== undefined) el.value = value;
|
||||
return el;
|
||||
}
|
||||
const $app = document.getElementById('app');
|
||||
|
||||
// theme: follows the system unless the person picked one
|
||||
try { const t = localStorage.getItem('theme'); if (t) document.documentElement.dataset.theme = t; } catch (e) { /* storage is off */ }
|
||||
function toggleTheme() {
|
||||
const dark = document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const next = dark ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
try { localStorage.setItem('theme', next); } catch (e) { /* storage is off */ }
|
||||
}
|
||||
const store = {
|
||||
get(k) { try { return localStorage.getItem(k) || ''; } catch (e) { return ''; } },
|
||||
set(k, v) { try { v ? localStorage.setItem(k, v) : localStorage.removeItem(k); } catch (e) { /* storage is off */ } },
|
||||
};
|
||||
|
||||
// short confirmations at the bottom right; errors stay longer and are announced as alerts
|
||||
let toastBox;
|
||||
function toast(msg, bad, action) {
|
||||
if (!toastBox) { toastBox = h('div', { class: 'toasts', 'aria-live': 'polite' }); document.body.append(toastBox); }
|
||||
const t = h('div', { class: 'toast' + (bad ? ' toast-error' : ''), role: bad ? 'alert' : 'status' }, h('span', {}, msg),
|
||||
action ? h('button', { class: 'toast-act', onclick: () => { t.remove(); action.fn(); } }, action.label) : null);
|
||||
toastBox.append(t);
|
||||
setTimeout(() => t.remove(), bad ? 7000 : action ? 6000 : 3000);
|
||||
}
|
||||
const cap = s => s ? s[0].toUpperCase() + s.slice(1) : s;
|
||||
const SIGNED_OUT = 'Your session ended. Sign in again to continue.';
|
||||
|
||||
async function api(method, url, body) {
|
||||
const opt = { method, headers: { 'X-Basking': '1' }, credentials: 'same-origin' };
|
||||
if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
|
||||
const r = await fetch(url, opt);
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (r.status === 401 && url !== '/api/login' && url !== '/api/me') {
|
||||
if (S.ready) { S.ready = false; boot(); }
|
||||
throw new Error(SIGNED_OUT);
|
||||
}
|
||||
if (!r.ok) throw Object.assign(new Error(cap(j.error || r.statusText)), { status: r.status, data: j });
|
||||
return j;
|
||||
}
|
||||
// run an action; show errors as a toast instead of throwing into the void
|
||||
const act = fn => async (...a) => { try { await fn(...a); } catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); } };
|
||||
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const hhmm = d => { const t = new Date(d); return `${pad(t.getHours())}:${pad(t.getMinutes())}`; };
|
||||
const fmtTime = d => new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||||
const isoDay = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const parseDay = s => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); };
|
||||
const addDays = (s, n) => { const d = parseDay(s); d.setDate(d.getDate() + n); return isoDay(d); };
|
||||
function dayLabel(d) {
|
||||
const day = isoDay(d instanceof Date ? d : parseDay(d)), now = new Date();
|
||||
if (day === isoDay(now)) return 'Today';
|
||||
if (day === addDays(isoDay(now), -1)) return 'Yesterday';
|
||||
const dt = parseDay(day);
|
||||
return dt.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: dt.getFullYear() === now.getFullYear() ? undefined : 'numeric' });
|
||||
}
|
||||
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', { class: 'sub' }, hint) : null);
|
||||
const chk = (label, checked) => { const i = h('input', { type: 'checkbox', checked: !!checked }); return Object.assign(h('label', { class: 'chk' }, i, label), { box: i }); };
|
||||
// status pill: color always travels with a text label
|
||||
const badge = (text, tone) => h('span', { class: 'badge' + (tone ? ' badge-' + tone : '') }, text);
|
||||
const emptyState = (name, title, text, action) => h('div', { class: 'empty' }, icon(name, 22), h('h3', {}, title), h('p', {}, text), action || null);
|
||||
// placeholder rows while a page loads, so the layout does not jump
|
||||
const skeleton = () => h('div', { class: 'card', role: 'status', 'aria-busy': 'true' }, h('span', { class: 'sr-only' }, 'Loading'),
|
||||
['w60', 'w100', 'w90', 'w100', 'w75'].map(w => h('div', { class: 'skeleton ' + w })));
|
||||
// replaceChildren stringifies null and arrays, so everything goes through here
|
||||
const fill = (el, ...kids) => el.replaceChildren(...kids.flat(Infinity).filter(k => k != null && k !== false));
|
||||
const card = (title, ...kids) => h('div', { class: 'card' }, title ? h('h2', {}, title) : null, ...kids);
|
||||
|
||||
// ---------- icons (lucide-style, static strings; see docs/design.md for the approved names) ----------
|
||||
const ICONS = {
|
||||
fin: '<path d="M3 20c4-1 7-5 8-14 3 5 8 7 10 14z"/>',
|
||||
notes: '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/>',
|
||||
today: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
|
||||
journal: '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
|
||||
pin: '<path d="M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
|
||||
tag: '<path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18"/>',
|
||||
trash: '<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/>',
|
||||
edit: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>',
|
||||
restore: '<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>',
|
||||
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
|
||||
view: '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>',
|
||||
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>',
|
||||
copy: '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
prev: '<path d="m15 18-6-6 6-6"/>',
|
||||
next: '<path d="m9 18 6-6-6-6"/>',
|
||||
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
|
||||
out: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>',
|
||||
menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
||||
close: '<path d="M18 6 6 18M6 6l12 12"/>',
|
||||
add: '<path d="M5 12h14M12 5v14"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
light: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
|
||||
dark: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||
alert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
|
||||
};
|
||||
function icon(name, size = 18) {
|
||||
const s = h('span', { class: 'inline-icon', 'aria-hidden': 'true' });
|
||||
s.innerHTML = `<svg class="icon icon-${size}" xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] || ''}</svg>`;
|
||||
return s;
|
||||
}
|
||||
const brand = () => h('div', { class: 'brand' }, icon('fin', 22), 'Basking');
|
||||
const btn = (label, ic, onclick, cls = 'ghost') => h('button', { class: cls, onclick }, ic ? icon(ic, 16) : null, label);
|
||||
|
||||
// ---------- dialogs ----------
|
||||
// A dialog: focus moves in, Tab stays inside, Escape closes, and focus goes back to what opened it.
|
||||
let dialogSeq = 0;
|
||||
function modal(title, body, opts = {}) {
|
||||
const opener = document.activeElement, id = 'dialog-title-' + (++dialogSeq);
|
||||
const shade = h('div', { class: 'shade' });
|
||||
const box = h('div', { class: 'modal' + (opts.wide ? ' wide' : ''), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': id, tabindex: '-1' }, h('h2', { id }, title), body);
|
||||
let closed = false;
|
||||
const close = () => {
|
||||
if (closed) return; closed = true;
|
||||
document.removeEventListener('keydown', onKey, true); shade.remove();
|
||||
if (opener && opener.isConnected && opener.focus) opener.focus();
|
||||
if (opts.onClose) opts.onClose();
|
||||
};
|
||||
const focusables = () => [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(e => e.offsetParent !== null);
|
||||
function onKey(e) {
|
||||
if ([...document.querySelectorAll('.shade')].pop() !== shade) return;
|
||||
if (e.key === 'Escape') { e.stopPropagation(); close(); }
|
||||
else if (e.key === 'Tab') {
|
||||
const f = focusables(); if (!f.length) { e.preventDefault(); return; }
|
||||
const first = f[0], last = f[f.length - 1];
|
||||
if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
}
|
||||
shade.addEventListener('mousedown', e => { if (e.target === shade && !opts.sticky) close(); });
|
||||
shade.append(box);
|
||||
document.body.append(shade);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
(box.querySelector('input,select,textarea') || focusables()[0] || box).focus();
|
||||
return close;
|
||||
}
|
||||
const modalOpen = () => !!document.querySelector('.shade');
|
||||
|
||||
// A confirmation that names what it affects and says what is lost. Resolves true only when confirmed.
|
||||
function confirmDialog({ title, body, confirm = 'Confirm', cancel = 'Cancel', danger = true }) {
|
||||
return new Promise(resolve => {
|
||||
let close;
|
||||
const done = v => { resolve(v); close(); };
|
||||
close = modal(title, h('div', { class: 'stack' }, h('p', { class: 'dim' }, body),
|
||||
h('div', { class: 'actions form-actions' }, h('button', { class: 'ghost', onclick: () => done(false) }, cancel),
|
||||
h('button', { class: danger ? 'ghost danger' : 'btn', onclick: () => done(true) }, confirm))), { onClose: () => resolve(false) });
|
||||
});
|
||||
}
|
||||
|
||||
// formDialog shows a form and resolves with its values, or null if it was cancelled.
|
||||
// Field types: text, password, textarea, note.
|
||||
function formDialog({ title, intro, fields, submit = 'Save' }) {
|
||||
return new Promise(resolve => {
|
||||
const parts = {};
|
||||
const err = h('p', { class: 'err', role: 'alert' });
|
||||
const rows = fields.map(f => {
|
||||
if (f.type === 'note') return h('p', { class: 'dim' }, f.text);
|
||||
let el, get;
|
||||
if (f.type === 'textarea') {
|
||||
el = h('textarea', { placeholder: f.placeholder, rows: 5 }); el.value = f.value || ''; get = () => el.value.trim();
|
||||
} else {
|
||||
el = h('input', { type: f.type || 'text', placeholder: f.placeholder, autocomplete: f.type === 'password' ? 'new-password' : 'off' });
|
||||
el.value = f.value ?? ''; get = () => f.type === 'password' ? el.value : el.value.trim();
|
||||
}
|
||||
parts[f.name] = { f, get, el };
|
||||
return field(f.label, el, f.hint);
|
||||
});
|
||||
let close;
|
||||
const send = () => {
|
||||
const out = {};
|
||||
for (const [name, p] of Object.entries(parts)) {
|
||||
const v = p.get();
|
||||
if (p.f.required && !v) { err.textContent = `${p.f.label} is required.`; p.el.setAttribute('aria-invalid', 'true'); p.el.focus(); return; }
|
||||
out[name] = v;
|
||||
}
|
||||
done(out);
|
||||
};
|
||||
const done = v => { resolve(v); close(); };
|
||||
const form = h('form', { class: 'stack', onsubmit: e => { e.preventDefault(); send(); } }, intro ? h('p', { class: 'dim' }, intro) : null, rows, err,
|
||||
h('div', { class: 'actions form-actions' }, h('button', { type: 'button', class: 'ghost', onclick: () => done(null) }, 'Cancel'), h('button', { type: 'submit', class: 'btn' }, submit)));
|
||||
close = modal(title, form, { sticky: true, onClose: () => resolve(null) });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- markdown ----------
|
||||
// A small, safe renderer. It builds elements (never HTML strings), and only http(s) links are links.
|
||||
const INLINE_SRC = '`([^`\\n]+)`|\\*\\*([^*\\n]+)\\*\\*|(?<![\\w*])\\*([^*\\s][^*\\n]*?)\\*(?![\\w*])|\\[([^\\]\\n]+)\\]\\((https?:\\/\\/[^\\s)]+)\\)|(https?:\\/\\/[^\\s<]*[^\\s<.,;:!?)\\]\'"])|(?<=^|[\\s(])#([\\p{L}\\p{N}_][\\p{L}\\p{N}_/-]*)';
|
||||
const tagChip = t => h('a', { class: 'chip', href: '#/tag/' + encodeURIComponent(t) }, '#' + t);
|
||||
const extLink = (url, label) => h('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, label);
|
||||
|
||||
function inline(text) {
|
||||
const re = new RegExp(INLINE_SRC, 'gu'), out = [];
|
||||
let last = 0, m;
|
||||
while ((m = re.exec(text))) {
|
||||
if (m.index > last) out.push(text.slice(last, m.index));
|
||||
last = re.lastIndex;
|
||||
if (m[1] != null) out.push(h('code', {}, m[1]));
|
||||
else if (m[2] != null) out.push(h('strong', {}, inline(m[2])));
|
||||
else if (m[3] != null) out.push(h('em', {}, inline(m[3])));
|
||||
else if (m[4] != null) out.push(extLink(m[5], m[4]));
|
||||
else if (m[6] != null) out.push(extLink(m[6], m[6]));
|
||||
else {
|
||||
const t = m[7].replace(/[/-]+$/, '');
|
||||
if (!t || /^\d+$/.test(t)) out.push('#' + m[7]);
|
||||
else out.push(tagChip(t.toLowerCase()), m[7].slice(t.length));
|
||||
}
|
||||
}
|
||||
if (last < text.length) out.push(text.slice(last));
|
||||
return out;
|
||||
}
|
||||
|
||||
// md renders a note. onTask(lineIndex, checked) makes the checkboxes work; without it they are read-only.
|
||||
function md(src, onTask) {
|
||||
const root = h('div', { class: 'md' }), lines = src.split('\n');
|
||||
let para = [], i = 0, m;
|
||||
const flush = () => {
|
||||
if (!para.length) return;
|
||||
const p = h('p');
|
||||
para.forEach((t, k) => { if (k) p.append(h('br')); p.append(...inline(t)); });
|
||||
root.append(p); para = [];
|
||||
};
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (/^\s*```/.test(line)) {
|
||||
flush(); const code = []; i++;
|
||||
while (i < lines.length && !/^\s*```/.test(lines[i])) code.push(lines[i++]);
|
||||
i++; root.append(h('pre', {}, h('code', {}, code.join('\n')))); continue;
|
||||
}
|
||||
if (!line.trim()) { flush(); i++; continue; }
|
||||
if ((m = /^(#{1,3})\s+(.+)$/.exec(line))) { flush(); root.append(h('h' + (m[1].length + 2), { class: 'md-h' }, inline(m[2]))); i++; continue; }
|
||||
if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) { flush(); root.append(h('hr')); i++; continue; }
|
||||
if (/^>/.test(line)) {
|
||||
flush(); const q = [];
|
||||
while (i < lines.length && /^>/.test(lines[i])) q.push(lines[i++].replace(/^>\s?/, ''));
|
||||
const bq = h('blockquote'); q.forEach((t, k) => { if (k) bq.append(h('br')); bq.append(...inline(t)); }); root.append(bq); continue;
|
||||
}
|
||||
if (/^(\s*)([-*+]|\d+[.)])\s+/.test(line)) {
|
||||
flush(); const ordered = /^\s*\d/.test(line), list = h(ordered ? 'ol' : 'ul');
|
||||
while (i < lines.length && (m = /^(\s*)([-*+]|\d+[.)])\s+(?:\[([ xX])\]\s+)?(.*)$/.exec(lines[i]))) {
|
||||
const lvl = Math.min(3, Math.floor(m[1].replace(/\t/g, ' ').length / 2)), at = i;
|
||||
const li = h('li', { class: 'ind-' + lvl + (m[3] != null ? ' task' : '') });
|
||||
if (m[3] != null) {
|
||||
const box = h('input', { type: 'checkbox', checked: m[3] !== ' ', disabled: !onTask, 'aria-label': m[4] || 'Task' });
|
||||
if (onTask) box.addEventListener('change', () => onTask(at, box.checked));
|
||||
li.append(box, h('span', { class: m[3] !== ' ' ? 'done' : '' }, inline(m[4])));
|
||||
} else li.append(...inline(m[4]));
|
||||
list.append(li); i++;
|
||||
}
|
||||
root.append(list); continue;
|
||||
}
|
||||
para.push(line); i++;
|
||||
}
|
||||
flush();
|
||||
return root;
|
||||
}
|
||||
|
||||
// ---------- state ----------
|
||||
const S = { ready: false, counts: { all: 0, pinned: 0, daily: 0, trash: 0 }, tags: [], view: null, q: '', pending: new Set(), nav: {} };
|
||||
|
||||
async function refreshSummary() {
|
||||
const r = await api('GET', '/api/summary');
|
||||
S.counts = r.counts; S.tags = r.tags; updateNav();
|
||||
}
|
||||
// Anything still waiting to autosave is saved before the page changes or the tab is hidden.
|
||||
const flushAll = () => Promise.all([...S.pending].map(f => f()));
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) flushAll(); });
|
||||
window.addEventListener('pagehide', () => { flushAll(); });
|
||||
|
||||
async function boot() {
|
||||
S.view = null; S.pending.clear();
|
||||
try { await api('GET', '/api/me'); } catch { return $app.replaceChildren(loginView()); }
|
||||
await refreshSummary().catch(() => { });
|
||||
S.ready = true;
|
||||
render();
|
||||
}
|
||||
|
||||
function loginView() {
|
||||
const pw = h('input', { type: 'password', autocomplete: 'current-password' });
|
||||
const go = act(async () => {
|
||||
await api('POST', '/api/login', { password: pw.value });
|
||||
if (toastBox) toastBox.replaceChildren(); // an earlier failed attempt no longer applies
|
||||
boot();
|
||||
});
|
||||
pw.addEventListener('keydown', e => e.key === 'Enter' && go());
|
||||
setTimeout(() => pw.focus());
|
||||
return h('div', { class: 'center' }, card('', brand(), h('h2', {}, 'Sign in'), h('div', { class: 'stack' },
|
||||
field('Password', pw, 'Basking printed it in its log the first time it started.'), h('button', { class: 'btn', onclick: go }, 'Sign in'))));
|
||||
}
|
||||
|
||||
// ---------- 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('/'); return { name: p[0] || 'notes', arg: decodeURIComponent(p.slice(1).join('/')) }; };
|
||||
const routeKey = r => r.name === 'tag' ? 'tag/' + 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;
|
||||
const el = h('a', { class: 'navitem' + (cur ? ' on' : ''), href, 'aria-current': cur ? 'page' : null, onclick: () => setDrawer(false) },
|
||||
icon(ic), h('span', { class: 'label' }, label), count == null ? null : h('span', { class: 'count' }, count));
|
||||
return el;
|
||||
}
|
||||
// The sidebar's lists and counts change as notes do, so they are rebuilt on their own.
|
||||
function updateNav() {
|
||||
const box = document.getElementById('nav-dynamic');
|
||||
if (!box) return;
|
||||
fill(box,
|
||||
navItem('#/', 'notes', 'Notes', 'notes', S.counts.all),
|
||||
navItem('#/today', 'today', 'Today', 'today'),
|
||||
navItem('#/journal', 'journal', 'Journal', 'journal', S.counts.daily),
|
||||
navItem('#/pinned', 'pinned', 'Pinned', 'pin', S.counts.pinned),
|
||||
S.tags.length ? h('div', { class: 'navsec' }, 'Tags') : null,
|
||||
S.tags.map(t => navItem('#/tag/' + encodeURIComponent(t.tag), 'tag/' + t.tag, t.tag, 'tag', t.count)),
|
||||
h('div', { class: 'navsec' }, 'Other'),
|
||||
navItem('#/trash', 'trash', 'Trash', 'trash', S.counts.trash || null));
|
||||
}
|
||||
|
||||
async function render() {
|
||||
await flushAll();
|
||||
S.pending.clear();
|
||||
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('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': 'Notes' }),
|
||||
h('div', { class: 'grow' }),
|
||||
h('div', { class: 'userbox' },
|
||||
navItemLink('#/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; boot(); }) }, icon('out'), 'Sign out'))),
|
||||
h('div', { class: 'content' }, main)));
|
||||
updateNav();
|
||||
show();
|
||||
}
|
||||
function navItemLink(href, key, label, ic) { return navItem(href, key, label, ic); }
|
||||
window.addEventListener('hashchange', () => { if (S.ready) render(); });
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function show() {
|
||||
const r = route(), main = document.getElementById('view');
|
||||
S.q = '';
|
||||
let page;
|
||||
switch (r.name) {
|
||||
case 'today': location.replace('#/day/' + isoDay(new Date())); return;
|
||||
case 'day': page = /^\d{4}-\d{2}-\d{2}$/.test(r.arg) ? dayPage(r.arg) : notesPage({ view: 'all' }); break;
|
||||
case 'journal': page = journalPage(); break;
|
||||
case 'pinned': page = notesPage({ view: 'pinned', title: 'Pinned', desc: 'Notes you pinned to keep close.' }); break;
|
||||
case 'tag': page = notesPage({ view: 'all', tag: r.arg, title: '#' + r.arg, desc: 'Every note with this tag.' }); break;
|
||||
case 'trash': page = notesPage({ view: 'trash', title: 'Trash', desc: 'Deleted notes wait here until you empty the trash.' }); break;
|
||||
case 'settings': page = settingsPage(); break;
|
||||
default: page = notesPage({ view: 'all', composer: true, title: 'Notes', desc: 'Write anything down. Sort it out later, or never.' });
|
||||
}
|
||||
S.view = page;
|
||||
main.replaceChildren(page.el);
|
||||
document.title = (page.title ? page.title + ' · ' : '') + 'Basking';
|
||||
page.start?.();
|
||||
}
|
||||
|
||||
// ---------- the editor: autosaves, and notices when a note changed somewhere else ----------
|
||||
function makeEditor(note, { rows = 4, onSaved, big } = {}) {
|
||||
let cur = note, dirty = false, saving = false, queued = false, blocked = false, timer = null;
|
||||
const ta = h('textarea', { class: 'write' + (big ? ' big' : ''), rows, 'aria-label': 'Note text', placeholder: big ? 'Write about your day...' : '' });
|
||||
ta.value = note.body;
|
||||
const status = h('span', { class: 'sub', role: 'status' });
|
||||
const notice = h('div', { class: 'notice notice-warning', hidden: true });
|
||||
const fit = () => { if (big) return; ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight + 2, window.innerHeight * 0.7) + 'px'; };
|
||||
|
||||
async function save() {
|
||||
clearTimeout(timer); timer = null;
|
||||
if (!dirty || blocked) return;
|
||||
if (saving) { queued = true; return; }
|
||||
saving = true; dirty = false; status.textContent = 'Saving';
|
||||
try {
|
||||
cur = await api('PUT', '/api/notes/' + cur.id, { body: ta.value, base: cur.updated });
|
||||
if (!dirty) status.textContent = 'Saved';
|
||||
onSaved?.(cur);
|
||||
} catch (e) {
|
||||
dirty = true;
|
||||
if (e.status === 409) conflict(e.data.note);
|
||||
else { status.textContent = 'Not saved'; if (e.message !== SIGNED_OUT) toast(e.message, true); }
|
||||
} finally {
|
||||
saving = false;
|
||||
if (queued) { queued = false; save(); }
|
||||
}
|
||||
}
|
||||
function conflict(theirs) {
|
||||
blocked = true; status.textContent = 'Not saved';
|
||||
notice.hidden = false;
|
||||
notice.replaceChildren(icon('alert', 16), h('span', { class: 'grow' }, 'This note changed somewhere else, probably on another device.'),
|
||||
h('button', { class: 'ghost', onclick: () => { cur = theirs; blocked = false; notice.hidden = true; save(); } }, 'Keep my version'),
|
||||
h('button', { class: 'ghost', onclick: () => { cur = theirs; ta.value = theirs.body; dirty = false; blocked = false; notice.hidden = true; status.textContent = 'Saved'; fit(); onSaved?.(cur); } }, 'Use the other version'));
|
||||
}
|
||||
ta.addEventListener('input', () => {
|
||||
dirty = true; status.textContent = 'Unsaved'; fit();
|
||||
clearTimeout(timer); timer = setTimeout(save, 700);
|
||||
});
|
||||
S.pending.add(save);
|
||||
return {
|
||||
ta, status, notice, save,
|
||||
get note() { return cur; },
|
||||
flush: async () => { await save(); },
|
||||
close() { S.pending.delete(save); },
|
||||
fit,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- note cards ----------
|
||||
const LONG_LINES = 14, LONG_CHARS = 1200;
|
||||
|
||||
function noteCard(n, ctx) {
|
||||
const el = h('article', { class: 'note' + (n.pinned ? ' pinned' : ''), 'data-id': n.id });
|
||||
let ed = null;
|
||||
|
||||
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, true);
|
||||
});
|
||||
|
||||
function view() {
|
||||
ed = null;
|
||||
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 note');
|
||||
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 when = n.daily ? h('a', { href: '#/day/' + n.daily }, dayLabel(n.daily)) : 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('Edit', 'edit', edit), btn(n.pinned ? 'Unpin' : 'Pin', 'pin', act(async () => { n = await api('PUT', '/api/notes/' + n.id, { pinned: !n.pinned }); ctx.changed(n, true); })), btn('Delete', 'trash', act(() => ctx.trash(n)))];
|
||||
el.className = 'note' + (n.pinned ? ' pinned' : '');
|
||||
fill(el, wrap, more, h('div', { class: 'note-foot' }, when, n.pinned ? badge('Pinned', 'accent') : null, h('span', { class: 'grow' }), h('div', { class: 'actions' }, actions)));
|
||||
}
|
||||
|
||||
function edit() {
|
||||
ed = makeEditor(n, { rows: 3, onSaved: s => { n = s; ctx.changed(n, false); } });
|
||||
el.className = 'note editing';
|
||||
el.replaceChildren(ed.notice, ed.ta, h('div', { class: 'note-foot' }, ed.status, h('span', { class: 'grow' }), h('div', { class: 'actions' },
|
||||
btn('Done', 'check', act(done), 'btn'))));
|
||||
ed.fit(); ed.ta.focus(); ed.ta.setSelectionRange(ed.ta.value.length, ed.ta.value.length);
|
||||
ed.ta.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) { e.preventDefault(); e.stopPropagation(); done(); }
|
||||
});
|
||||
}
|
||||
async function done() {
|
||||
const e = ed; await e.flush(); e.close(); n = e.note;
|
||||
view(); ctx.changed(n, true); el.querySelector('button')?.focus();
|
||||
}
|
||||
|
||||
view();
|
||||
el.edit = edit;
|
||||
return el;
|
||||
}
|
||||
|
||||
// ---------- the composer ----------
|
||||
function composer(onSaved) {
|
||||
const ta = h('textarea', { class: 'write', rows: 3, placeholder: 'What is on your mind?', '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.message !== SIGNED_OUT) 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' }, 'Ctrl+Enter saves. Add #tags to find things later.'), h('span', { class: 'grow' }), go)), { ta });
|
||||
}
|
||||
|
||||
// ---------- notes list ----------
|
||||
function notesPage({ view, tag = '', title, desc, composer: withComposer }) {
|
||||
const list = h('div', { class: 'notes' }), moreBox = h('div', { class: 'more' });
|
||||
const search = h('input', { type: 'search', placeholder: 'Search notes, or type #tag', 'aria-label': 'Search notes' });
|
||||
let items = [], total = 0, loaded = false, 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; loaded = true; S.q = search.value.trim();
|
||||
draw();
|
||||
}
|
||||
const reload = act(() => Promise.all([load(true), refreshSummary()]));
|
||||
|
||||
const ctx = {
|
||||
changed(n, redraw) {
|
||||
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--; }
|
||||
if (redraw) { draw(); } refreshSummary().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('Note restored'); await reload(); },
|
||||
async purge(n) {
|
||||
const ok = await confirmDialog({ title: 'Delete this note for good?', body: `"${preview(n.body)}" will be deleted and cannot be brought back.`, confirm: 'Delete note' });
|
||||
if (!ok) return;
|
||||
await api('DELETE', `/api/notes/${n.id}?purge=1`); toast('Note 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', 'Notes you delete wait here until you empty the trash.']
|
||||
: view === 'pinned' ? ['pin', 'Nothing pinned', 'Pin a note to keep it here.']
|
||||
: searching ? ['view', 'No notes match', 'Try fewer words, or clear the search.']
|
||||
: ['notes', 'No notes yet', 'Write anything in the box above. It is saved when you press Ctrl+Enter.'];
|
||||
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 refreshSummary(); }) : null;
|
||||
const emptyBtn = view === 'trash' ? btn('Empty trash', 'trash', act(async () => {
|
||||
if (!total) { toast('The trash is already empty'); return; }
|
||||
const ok = await confirmDialog({ title: 'Empty the trash?', body: `${total} ${total === 1 ? 'note' : 'notes'} will be deleted for good. This cannot be undone.`, confirm: 'Empty trash' });
|
||||
if (!ok) 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 || 'Notes', 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)), newNote: () => comp?.ta.focus() };
|
||||
}
|
||||
const preview = s => { const t = s.replace(/\s+/g, ' ').trim(); return t.length > 60 ? t.slice(0, 60) + '...' : t; };
|
||||
|
||||
// ---------- 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' }, 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; })))));
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- one day ----------
|
||||
function dayPage(date) {
|
||||
const body = h('div'), today = isoDay(new Date());
|
||||
let ed = null, previewing = false;
|
||||
const nav = h('div', { class: 'actions' },
|
||||
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); }));
|
||||
|
||||
function stamp() {
|
||||
const t = ed.ta, at = t.selectionStart, before = t.value.slice(0, at), line = before.slice(before.lastIndexOf('\n') + 1);
|
||||
const text = (line.trim() ? '\n\n' : before && !before.endsWith('\n\n') && before.endsWith('\n') ? '\n' : '') + hhmm(new Date()) + ' ';
|
||||
t.setRangeText(text, at, t.selectionEnd, 'end'); t.focus(); t.dispatchEvent(new Event('input'));
|
||||
}
|
||||
function draw(n) {
|
||||
ed = makeEditor(n, { rows: 14, big: true });
|
||||
const rendered = h('div', { class: 'card note-body', hidden: true });
|
||||
const toggle = btn('Preview', 'view', () => {
|
||||
previewing = !previewing;
|
||||
if (previewing) { rendered.replaceChildren(ed.ta.value.trim() ? md(ed.ta.value) : h('p', { class: 'dim' }, 'Nothing written yet')); }
|
||||
rendered.hidden = !previewing; ed.ta.hidden = previewing;
|
||||
toggle.replaceChildren(icon(previewing ? 'edit' : 'view', 16), previewing ? 'Edit' : 'Preview');
|
||||
});
|
||||
body.replaceChildren(ed.notice, ed.ta, rendered, h('div', { class: 'note-foot' }, ed.status, h('span', { class: 'grow' }),
|
||||
h('div', { class: 'actions' }, btn('Insert time', 'clock', stamp), toggle)));
|
||||
setTimeout(() => ed.ta.focus());
|
||||
}
|
||||
return {
|
||||
title: dayLabel(date),
|
||||
el: h('div', {}, pageHeader(dayLabel(date), date === today ? parseDay(date).toLocaleDateString(undefined, { dateStyle: 'full' }) : 'Your page for this day. It saves as you type.'),
|
||||
h('div', { class: 'toolbar' }, nav), h('div', { class: 'card' }, body)),
|
||||
start: act(async () => { body.replaceChildren(skeleton()); draw(await api('POST', '/api/daily', { date })); }),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- settings ----------
|
||||
async function copyText(text, input) {
|
||||
try { await navigator.clipboard.writeText(text); toast('Copied'); }
|
||||
catch { input.select(); toast('Press Ctrl+C to copy'); }
|
||||
}
|
||||
|
||||
function settingsPage() {
|
||||
const tokens = h('div');
|
||||
const loadTokens = act(async () => {
|
||||
const rows = await api('GET', '/api/tokens');
|
||||
tokens.replaceChildren(rows.length ? h('div', { class: 'scroll' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'Created'), h('th', {}))),
|
||||
h('tbody', {}, rows.map(t => h('tr', {}, h('td', {}, t.name), h('td', {}, fmtTime(t.created)), h('td', { class: 'actions-cell' }, btn('Revoke', null, act(async () => {
|
||||
const ok = await confirmDialog({ title: `Revoke ${t.name}?`, body: 'Anything using this token stops working at once. You can make another one later.', confirm: 'Revoke token' });
|
||||
if (!ok) return;
|
||||
await api('DELETE', '/api/tokens/' + encodeURIComponent(t.name)); toast('Token revoked'); loadTokens();
|
||||
}), 'ghost danger'))))))) : emptyState('tag', 'No tokens yet', 'Make a token to send notes from a script or a phone shortcut.'));
|
||||
});
|
||||
|
||||
const newToken = act(async () => {
|
||||
const r = await formDialog({ title: 'New API token', submit: 'Make token', fields: [{ name: 'name', label: 'Name', required: true, placeholder: 'phone', hint: 'Something that tells you where it is used.' }] });
|
||||
if (!r) return;
|
||||
const t = await api('POST', '/api/tokens', r);
|
||||
const box = h('input', { type: 'text', readonly: true, class: 'mono' }); box.value = t.token;
|
||||
let close;
|
||||
close = modal('Your new token', h('div', { class: 'stack' }, h('p', { class: 'dim' }, 'This is the only time it is shown. Copy it now.'), box,
|
||||
h('div', { class: 'actions form-actions' }, btn('Copy token', 'copy', () => copyText(t.token, box)), h('button', { class: 'btn', onclick: () => close() }, 'Done'))), { sticky: true });
|
||||
box.select(); loadTokens();
|
||||
});
|
||||
|
||||
const changePassword = act(async () => {
|
||||
const r = await formDialog({ title: 'Change password', submit: 'Change password', fields: [
|
||||
{ name: 'current', label: 'Current password', type: 'password', required: true },
|
||||
{ name: 'next', label: 'New password', type: 'password', required: true, hint: 'At least 8 characters.' },
|
||||
{ name: 'again', label: 'New password again', type: 'password', required: true }] });
|
||||
if (!r) return;
|
||||
if (r.next !== r.again) { toast('The two new passwords do not match', true); return; }
|
||||
await api('POST', '/api/password', { current: r.current, new: r.next });
|
||||
toast('Password changed');
|
||||
});
|
||||
|
||||
const origin = location.origin;
|
||||
const curl = `curl -H "Authorization: Bearer $TOKEN" \\\n -H "Content-Type: text/plain" \\\n -d "Call the dentist #errands" \\\n ${origin}/api/notes`;
|
||||
return {
|
||||
title: 'Settings',
|
||||
el: h('div', {}, pageHeader('Settings', 'Your password, tokens for scripts, and a copy of everything.'),
|
||||
card('Password', h('p', { class: 'dim' }, 'Changing it signs out every other browser.'), btn('Change password', 'edit', changePassword)),
|
||||
card('', h('div', { class: 'card-h' }, h('h2', {}, 'API tokens'), btn('New token', 'add', newToken)),
|
||||
h('p', { class: 'dim' }, 'A token lets a script or a phone shortcut add notes without signing in.'), tokens),
|
||||
card('Send a note from anywhere', h('p', { class: 'dim' }, 'With a token, post the text of a note to Basking:'), h('pre', { class: 'snippet' }, h('code', {}, curl)),
|
||||
h('p', { class: 'dim' }, 'Or, from a terminal with the basking command:'), h('pre', { class: 'snippet' }, h('code', {}, `BASKING_URL=${origin} BASKING_TOKEN=... basking add "an idea"`))),
|
||||
card('Your notes', h('p', { class: 'dim' }, 'Every note is a plain markdown file in the folder Basking keeps its data in, so you can back that folder up or sync it as it is. This downloads the same files as one zip.'),
|
||||
btn('Download all notes', 'download', () => { location.href = '/api/export'; }))),
|
||||
start: loadTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- keyboard ----------
|
||||
document.addEventListener('keydown', e => {
|
||||
if (!S.ready || modalOpen() || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const t = e.target;
|
||||
if (t.matches?.('input,textarea,select,[contenteditable]')) return;
|
||||
if (e.key === '/' && S.view?.search) { e.preventDefault(); S.view.search.focus(); }
|
||||
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(), 400); }
|
||||
}
|
||||
});
|
||||
|
||||
boot();
|
||||
Reference in new issue
Block a user