Basking: sign in with Authentik only, pages that nest and link
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>
This commit is contained in:
1 parent
b548faa258
commit
98f7730913
19 files changed
+3291
-1578
No files matched your search
+384
-516
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,276 @@
|
||||
'use strict';
|
||||
|
||||
// ---------- text helpers: edits go through the browser so Undo keeps working ----------
|
||||
function replaceRange(ta, from, to, text) {
|
||||
ta.focus(); ta.setSelectionRange(from, to);
|
||||
const ok = text === '' ? document.execCommand('delete') : document.execCommand('insertText', false, text);
|
||||
if (!ok) { ta.setRangeText(text, from, to, 'end'); ta.dispatchEvent(new Event('input', { bubbles: true })); }
|
||||
}
|
||||
const lineStart = (v, pos) => v.lastIndexOf('\n', pos - 1) + 1;
|
||||
const lineEnd = (v, pos) => { const k = v.indexOf('\n', pos); return k < 0 ? v.length : k; };
|
||||
|
||||
// Where the caret is, in pixels inside the text box, found by laying the same text out in a hidden copy.
|
||||
function caretXY(ta, pos) {
|
||||
const cs = getComputedStyle(ta), mirror = h('div', { class: 'mirror' }), mark = h('span', {}, '.');
|
||||
for (const p of ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'paddingTop', 'paddingLeft', 'paddingRight', 'tabSize'])
|
||||
mirror.style[p] = cs[p];
|
||||
mirror.style.width = ta.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight) + 'px';
|
||||
mirror.append(ta.value.slice(0, pos), mark);
|
||||
document.body.append(mirror);
|
||||
const at = { x: mark.offsetLeft, y: mark.offsetTop, lh: parseFloat(cs.lineHeight) || 24 };
|
||||
mirror.remove();
|
||||
return at;
|
||||
}
|
||||
|
||||
// ---------- the "/" commands and the "[[" page picker ----------
|
||||
const SLASH = [
|
||||
{ name: 'Heading 1', hint: '#', keys: 'h1 heading title', text: '# ' },
|
||||
{ name: 'Heading 2', hint: '##', keys: 'h2 heading', text: '## ' },
|
||||
{ name: 'Heading 3', hint: '###', keys: 'h3 heading', text: '### ' },
|
||||
{ name: 'To-do list', hint: '- [ ]', keys: 'todo task checkbox check', text: '- [ ] ' },
|
||||
{ name: 'Bulleted list', hint: '-', keys: 'bullet list ul', text: '- ' },
|
||||
{ name: 'Numbered list', hint: '1.', keys: 'number list ol', text: '1. ' },
|
||||
{ name: 'Quote', hint: '>', keys: 'quote callout', text: '> ' },
|
||||
{ name: 'Code block', hint: '```', keys: 'code pre', text: '```\n\n```', caret: 4 },
|
||||
{ name: 'Divider', hint: '---', keys: 'divider line hr rule', text: '---\n' },
|
||||
{ name: 'Link to a page', hint: '[[', keys: 'link page wiki mention', text: '[[', again: true },
|
||||
{ name: 'Link to today', hint: () => isoDay(new Date()), keys: 'today date journal day', text: () => '[[' + isoDay(new Date()) + ']]' },
|
||||
{ name: 'Current time', hint: () => hhmm(new Date()), keys: 'time clock now', text: () => hhmm(new Date()) + ' ' },
|
||||
];
|
||||
|
||||
function makePopup(ta, wrap) {
|
||||
const el = h('div', { class: 'popup', role: 'listbox', 'aria-label': 'Suggestions', hidden: true });
|
||||
wrap.append(el);
|
||||
let items = [], sel = 0, onPick = null;
|
||||
function draw() {
|
||||
fill(el, items.map((it, i) => h('div', {
|
||||
class: 'popup-row' + (i === sel ? ' on' : ''), role: 'option', 'aria-selected': String(i === sel),
|
||||
onmousedown: e => { e.preventDefault(); sel = i; pop.pick(); },
|
||||
}, h('span', { class: 'popup-name' }, it.name), it.hint ? h('span', { class: 'popup-hint' }, typeof it.hint === 'function' ? it.hint() : it.hint) : null)));
|
||||
el.querySelector('.on')?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
const pop = {
|
||||
get open() { return !el.hidden; },
|
||||
show(list, pick, pos) {
|
||||
if (!list.length) return pop.hide();
|
||||
items = list; onPick = pick; sel = 0; draw(); el.hidden = false;
|
||||
const c = caretXY(ta, pos);
|
||||
el.style.left = Math.max(0, Math.min(ta.offsetLeft + c.x, wrap.clientWidth - 270)) + 'px';
|
||||
el.style.top = (ta.offsetTop + c.y + c.lh) + 'px';
|
||||
},
|
||||
hide() { el.hidden = true; items = []; },
|
||||
move(d) { sel = (sel + d + items.length) % items.length; draw(); },
|
||||
pick() { const it = items[sel]; pop.hide(); if (it) onPick(it); },
|
||||
};
|
||||
return pop;
|
||||
}
|
||||
|
||||
function pageChoices(q) {
|
||||
const lq = q.trim().toLowerCase();
|
||||
const rank = p => { const t = p.title.toLowerCase(); return t === lq ? 0 : t.startsWith(lq) ? 1 : 2; };
|
||||
const list = S.pages.filter(p => p.title && (!lq || p.title.toLowerCase().includes(lq)))
|
||||
.sort((a, b) => rank(a) - rank(b) || a.title.localeCompare(b.title)).slice(0, 7)
|
||||
.map(p => ({ name: p.title, hint: p.daily ? 'Journal' : S.byId.get(p.parent)?.name || '' }));
|
||||
if (lq && !S.byTitle.has(lq)) list.push({ name: 'Create "' + q.trim() + '"', hint: 'New page', create: q.trim() });
|
||||
return list;
|
||||
}
|
||||
|
||||
function enhance(ta, wrap) {
|
||||
const pop = makePopup(ta, wrap);
|
||||
|
||||
function check() {
|
||||
if (ta.selectionStart !== ta.selectionEnd) return pop.hide();
|
||||
const pos = ta.selectionStart, ls = lineStart(ta.value, pos), before = ta.value.slice(ls, pos);
|
||||
let m;
|
||||
if ((m = /\[\[([^\[\]|\n]*)$/.exec(before))) {
|
||||
const from = pos - m[1].length;
|
||||
pop.show(pageChoices(m[1]), it => {
|
||||
const closed = ta.value.startsWith(']]', pos);
|
||||
replaceRange(ta, from, closed ? pos + 2 : pos, (it.create || it.name) + ']]');
|
||||
}, pos);
|
||||
} else if ((m = /^(\s*)\/([a-z0-9]{0,16})$/i.exec(before))) {
|
||||
const q = m[2].toLowerCase(), from = ls + m[1].length;
|
||||
pop.show(SLASH.filter(c => !q || c.name.toLowerCase().includes(q) || c.keys.includes(q)), c => {
|
||||
const text = typeof c.text === 'function' ? c.text() : c.text;
|
||||
replaceRange(ta, from, pos, text);
|
||||
if (c.caret) ta.setSelectionRange(from + c.caret, from + c.caret);
|
||||
if (c.again) check();
|
||||
}, pos);
|
||||
} else pop.hide();
|
||||
}
|
||||
|
||||
function continueList(e) {
|
||||
if (ta.selectionStart !== ta.selectionEnd) return false;
|
||||
const pos = ta.selectionStart, v = ta.value, ls = lineStart(v, pos), le = lineEnd(v, pos), line = v.slice(ls, le);
|
||||
const m = /^(\s*)([-*+]|(\d+)([.)]))\s+(\[[ xX]\]\s+)?/.exec(line);
|
||||
if (!m || pos < ls + m[0].length) return false;
|
||||
e.preventDefault();
|
||||
if (line.trim() === m[0].trim()) { replaceRange(ta, ls, le, ''); return true; } // an empty item ends the list
|
||||
const marker = m[3] ? (Number(m[3]) + 1) + m[4] : m[2];
|
||||
replaceRange(ta, pos, pos, '\n' + m[1] + marker + ' ' + (m[5] ? '[ ] ' : ''));
|
||||
return true;
|
||||
}
|
||||
function indentList(e) {
|
||||
if (ta.selectionStart !== ta.selectionEnd) return false;
|
||||
const pos = ta.selectionStart, v = ta.value, ls = lineStart(v, pos), line = v.slice(ls, lineEnd(v, pos));
|
||||
if (!/^\s*([-*+]|\d+[.)])\s/.test(line)) return false; // anywhere else, Tab keeps moving focus
|
||||
e.preventDefault();
|
||||
if (!e.shiftKey) { replaceRange(ta, ls, ls, ' '); ta.setSelectionRange(pos + 2, pos + 2); }
|
||||
else {
|
||||
const n = line.startsWith(' ') ? 2 : /^[ \t]/.test(line) ? 1 : 0;
|
||||
if (n) { replaceRange(ta, ls, ls + n, ''); ta.setSelectionRange(pos - n, pos - n); }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function wrapSelection(mark) {
|
||||
const s = ta.selectionStart, e = ta.selectionEnd, sel = ta.value.slice(s, e);
|
||||
replaceRange(ta, s, e, mark + sel + mark);
|
||||
ta.setSelectionRange(s + mark.length, s + mark.length + sel.length);
|
||||
}
|
||||
|
||||
ta.addEventListener('keydown', e => {
|
||||
if (pop.open) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); pop.move(e.key === 'ArrowDown' ? 1 : -1); return; }
|
||||
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); pop.pick(); return; }
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); pop.hide(); return; }
|
||||
}
|
||||
if (e.isComposing) return;
|
||||
const plain = !e.ctrlKey && !e.metaKey && !e.altKey;
|
||||
if (e.key === 'Enter' && plain && !e.shiftKey) continueList(e);
|
||||
else if (e.key === 'Tab' && plain) indentList(e);
|
||||
else if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'b' || e.key === 'i')) { e.preventDefault(); wrapSelection(e.key === 'b' ? '**' : '*'); }
|
||||
});
|
||||
ta.addEventListener('input', check);
|
||||
ta.addEventListener('click', check);
|
||||
ta.addEventListener('keyup', e => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) check(); });
|
||||
ta.addEventListener('blur', () => setTimeout(pop.hide, 150));
|
||||
return pop;
|
||||
}
|
||||
|
||||
// ---------- the page editor: autosaves title and text, and notices when a page changed elsewhere ----------
|
||||
// With no note it starts a new page, which is only created once something is typed.
|
||||
function pageEditor({ note, parent = '', fixedTitle, onCreated, onSaved }) {
|
||||
let cur = note || null, titleDirty = false, bodyDirty = false, saving = false, queued = false, blocked = false;
|
||||
let timer = null, retry = null, badTitle = null, mode = 'write';
|
||||
|
||||
const titleEl = h('input', { class: 'title-input', type: 'text', placeholder: 'Untitled', 'aria-label': 'Page title', maxlength: 120, autocomplete: 'off', spellcheck: 'true' });
|
||||
titleEl.value = fixedTitle ?? note?.title ?? '';
|
||||
if (fixedTitle != null) { titleEl.readOnly = true; titleEl.tabIndex = -1; }
|
||||
const titleErr = h('p', { class: 'err small', role: 'alert', hidden: true });
|
||||
const ta = h('textarea', { class: 'write big', 'aria-label': 'Page text', placeholder: "Write something, or type '/' for commands, '[[' to link a page" });
|
||||
ta.value = note?.body ?? '';
|
||||
const view = h('div', { class: 'view', hidden: true });
|
||||
const wrap = h('div', { class: 'editor-wrap' }, ta, view);
|
||||
const status = h('span', { class: 'sub', role: 'status' });
|
||||
const notice = h('div', { class: 'notice notice-warning', hidden: true });
|
||||
const say = t => { status.textContent = t; };
|
||||
const fit = () => { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 2 + 'px'; };
|
||||
enhance(ta, wrap);
|
||||
|
||||
const dirty = () => titleDirty || bodyDirty;
|
||||
const ctl = { save, dirty };
|
||||
S.editors.add(ctl);
|
||||
|
||||
function flag(msg) { notice.hidden = !msg; if (msg) fill(notice, icon('alert', 16), msg); }
|
||||
|
||||
async function save() {
|
||||
clearTimeout(timer); timer = null; clearTimeout(retry); retry = null;
|
||||
if (blocked) return;
|
||||
const t = titleEl.value, b = ta.value;
|
||||
const sendTitle = titleDirty && t !== badTitle, sendBody = bodyDirty;
|
||||
if (!sendTitle && !sendBody) return;
|
||||
if (saving) { queued = true; return; }
|
||||
if (!cur && !t.trim() && !b.trim()) { titleDirty = bodyDirty = false; return; } // nothing worth keeping
|
||||
saving = true; say('Saving');
|
||||
try {
|
||||
let n;
|
||||
if (!cur) n = await api('POST', '/api/notes', { title: sendTitle ? t : '', body: b, parent }, { keepalive: b.length < 15000 });
|
||||
else {
|
||||
const payload = { base: cur.updated };
|
||||
if (sendTitle) payload.title = t;
|
||||
if (sendBody) payload.body = b;
|
||||
n = await api('PUT', '/api/notes/' + cur.id, payload, { keepalive: b.length < 15000 });
|
||||
}
|
||||
const created = !cur;
|
||||
cur = n;
|
||||
if (sendTitle) { if (titleEl.value === t) titleDirty = false; badTitle = null; titleErr.hidden = true; }
|
||||
if (sendBody && ta.value === b) bodyDirty = false;
|
||||
flag(null);
|
||||
say(dirty() ? 'Unsaved' : 'Saved');
|
||||
if (created) onCreated?.(cur);
|
||||
onSaved?.(cur, { renamed: sendTitle });
|
||||
} catch (e) {
|
||||
if (e.status === 409) conflict(e.data.note);
|
||||
else if (e.status === 422 && e.data?.code === 'title_taken') {
|
||||
badTitle = t; titleErr.textContent = 'Another page already has this title. Pick a different one.'; titleErr.hidden = false;
|
||||
say('Unsaved'); queued = bodyDirty || !cur && !!b.trim();
|
||||
} else if (e.status === 401) {
|
||||
say('Not saved');
|
||||
flag([h('span', { class: 'grow' }, 'Your session ended, so your latest changes are not saved yet. Your text is still here.'),
|
||||
h('a', { class: 'btn-link', href: '/auth/login?next=' + encodeURIComponent(location.hash), target: '_blank', rel: 'noopener' }, 'Sign in again'),
|
||||
h('span', { class: 'sub' }, 'Opens in a new tab. Come back afterwards and this page saves itself.')]);
|
||||
retry = setTimeout(save, 4000);
|
||||
} else {
|
||||
say('Not saved');
|
||||
if (e.offline || e.status >= 500) { flag(e.message + ' Trying again.'); retry = setTimeout(save, 5000); }
|
||||
else toast(e.message, true);
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
if (queued) { queued = false; save(); }
|
||||
}
|
||||
}
|
||||
|
||||
function conflict(theirs) {
|
||||
blocked = true; say('Not saved');
|
||||
flag([h('span', { class: 'grow' }, 'This page changed somewhere else, probably on another device.'),
|
||||
h('button', { class: 'ghost', onclick: () => { cur = theirs; blocked = false; titleDirty = titleEl.value !== theirs.title; bodyDirty = ta.value !== theirs.body; flag(null); save(); } }, 'Keep my version'),
|
||||
h('button', { class: 'ghost', onclick: () => {
|
||||
cur = theirs; blocked = false; titleDirty = bodyDirty = false; titleEl.value = theirs.title; ta.value = theirs.body;
|
||||
flag(null); say('Saved'); fit(); if (mode === 'read') render(); onSaved?.(cur, { renamed: true });
|
||||
} }, 'Use the other version')]);
|
||||
}
|
||||
|
||||
const later = () => { clearTimeout(timer); timer = setTimeout(save, 700); };
|
||||
titleEl.addEventListener('input', () => { titleDirty = true; titleErr.hidden = true; if (badTitle !== null && titleEl.value !== badTitle) badTitle = null; say('Unsaved'); later(); });
|
||||
titleEl.addEventListener('blur', () => save());
|
||||
titleEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); setMode('write'); ta.focus(); ta.setSelectionRange(0, 0); }
|
||||
});
|
||||
ta.addEventListener('input', () => { bodyDirty = true; say('Unsaved'); fit(); later(); });
|
||||
|
||||
// Write shows the text box; Read shows the page as it will look, with tasks you can tick and links you can follow.
|
||||
function render() { view.replaceChildren(ta.value.trim() ? md(ta.value, ticked, true) : h('p', { class: 'dim' }, 'Nothing here yet. Click to start writing.')); }
|
||||
function ticked(line, checked) {
|
||||
const lines = ta.value.split('\n');
|
||||
lines[line] = lines[line].replace(/\[( |x|X)\]/, checked ? '[x]' : '[ ]');
|
||||
ta.value = lines.join('\n'); bodyDirty = true; say('Unsaved'); render(); later();
|
||||
}
|
||||
function setMode(m) {
|
||||
mode = m;
|
||||
if (m === 'read') { render(); fit(); }
|
||||
ta.hidden = m === 'read'; view.hidden = m === 'write';
|
||||
if (m === 'write') fit();
|
||||
ctl.onMode?.(m);
|
||||
}
|
||||
view.addEventListener('click', e => {
|
||||
if (e.target.closest('a,input,button') || inSelection()) return;
|
||||
setMode('write'); ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', fit);
|
||||
// Getters must be defined on the object itself; Object.assign would copy their values once.
|
||||
Object.defineProperties(ctl, { mode: { get: () => mode }, note: { get: () => cur }, body: { get: () => ta.value } });
|
||||
return Object.assign(ctl, {
|
||||
titleEl, titleErr, ta, view, wrap, status, notice, setMode,
|
||||
fit, flush: () => save(),
|
||||
// Insert the time where the caret is, on a line of its own.
|
||||
stamp() {
|
||||
setMode('write'); ta.focus();
|
||||
const at = ta.selectionStart, before = ta.value.slice(0, at), line = before.slice(before.lastIndexOf('\n') + 1);
|
||||
const lead = line.trim() ? '\n\n' : before && !before.endsWith('\n\n') && before.endsWith('\n') ? '\n' : '';
|
||||
replaceRange(ta, at, ta.selectionEnd, lead + hhmm(new Date()) + ' ');
|
||||
},
|
||||
dispose() { S.editors.delete(ctl); window.removeEventListener('resize', fit); clearTimeout(timer); clearTimeout(retry); },
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="toolkit.js"></script>
|
||||
<script src="markdown.js"></script>
|
||||
<script src="editor.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
// ---------- markdown ----------
|
||||
// A small, safe renderer. It builds elements (never HTML strings), and only http(s) links are links.
|
||||
// [[Page title]] and [[Page title|text]] link to other pages; pageByTitle (app.js) says which exist.
|
||||
const INLINE_SRC = '`([^`\\n]+)`|\\*\\*([^*\\n]+)\\*\\*|(?<![\\w*])\\*([^*\\s][^*\\n]*?)\\*(?![\\w*])|\\[\\[([^\\[\\]|\\n]+?)(?:\\|([^\\[\\]\\n]*))?\\]\\]|\\[([^\\]\\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 wikiLink(target, text) {
|
||||
const title = target.trim(), page = pageByTitle(title);
|
||||
if (page) return h('a', { class: 'wikilink', href: '#/p/' + page.id }, text || title);
|
||||
return h('a', { class: 'wikilink missing', href: '#/link/' + encodeURIComponent(title), title: 'No page called "' + title + '" yet. Click to create it.' }, text || title);
|
||||
}
|
||||
|
||||
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(wikiLink(m[4], m[5]));
|
||||
else if (m[6] != null) out.push(extLink(m[7], m[6]));
|
||||
else if (m[8] != null) out.push(extLink(m[8], m[8]));
|
||||
else {
|
||||
const t = m[9].replace(/[/-]+$/, '');
|
||||
if (!t || /^\d+$/.test(t)) out.push('#' + m[9]);
|
||||
else out.push(tagChip(t.toLowerCase()), m[9].slice(t.length));
|
||||
}
|
||||
}
|
||||
if (last < text.length) out.push(text.slice(last));
|
||||
return out;
|
||||
}
|
||||
|
||||
// md renders a page. onTask(lineIndex, checked) makes the checkboxes work; without it they are read-only.
|
||||
function md(src, onTask, big) {
|
||||
const root = h('div', { class: 'md' + (big ? ' md-big' : '') }), 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;
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
|
||||
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
|
||||
--sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
|
||||
--sidebar-w:272px; --content-max:1120px; --touch:44px; --control-h:38px;
|
||||
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
|
||||
color-scheme:light;
|
||||
}
|
||||
@@ -219,8 +219,6 @@
|
||||
.toast-act { background:transparent; border:0; color:inherit; font:inherit; font-weight:600; text-decoration:underline; cursor:pointer; padding:0; }
|
||||
.toolbar .push { margin-left:auto; }
|
||||
.toolbar input[type=search] { max-width:360px; }
|
||||
.snippet { margin:0 0 var(--s4); padding:var(--s3); background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); overflow-x:auto; }
|
||||
.snippet code { white-space:pre; }
|
||||
|
||||
/* rendered markdown */
|
||||
.md > *:first-child { margin-top:0; } .md > *:last-child { margin-bottom:0; }
|
||||
@@ -237,3 +235,93 @@
|
||||
.md pre { margin:0 0 var(--s3); padding:var(--s3); background:var(--bg); border:var(--border-w) solid var(--border); border-radius:var(--radius); overflow-x:auto; }
|
||||
.md code { background:var(--surface-2); padding:1px 4px; border-radius:4px; } .md pre code { background:none; padding:0; }
|
||||
.md hr { border:0; border-top:var(--border-w) solid var(--border); margin:var(--s4) 0; }
|
||||
|
||||
/* sign in */
|
||||
.btn-link { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2); min-height:var(--control-h); padding:0 var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; text-decoration:none; white-space:nowrap; background:var(--surface); border:var(--border-w) solid var(--border-strong); color:var(--text); }
|
||||
.btn-link:hover { background:var(--surface-2); text-decoration:none; }
|
||||
.btn-link.primary { background:var(--accent); border-color:var(--accent); color:var(--accent-contrast); }
|
||||
.btn-link.primary:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
|
||||
.btn-link.wide { display:flex; width:100%; min-height:var(--touch); margin-top:var(--s4); }
|
||||
.login-h { font-size:var(--text-xl); text-align:center; margin-bottom:var(--s2); }
|
||||
.center-text { text-align:center; }
|
||||
kbd { font:inherit; font-family:var(--font-mono); font-size:var(--text-xs); padding:1px 6px; border:var(--border-w) solid var(--border-strong); border-radius:4px; background:var(--surface-2); color:var(--text-muted); white-space:nowrap; }
|
||||
|
||||
/* sidebar: search, pages tree */
|
||||
.navitem.find { border:var(--border-w) solid var(--border); margin-bottom:var(--s2); }
|
||||
.navitem.find kbd { margin-left:auto; }
|
||||
.navsec-row { display:flex; align-items:center; justify-content:space-between; padding-right:var(--s1); }
|
||||
button.tiny { width:26px; min-height:26px; height:26px; }
|
||||
.treerow { display:flex; align-items:center; gap:2px; }
|
||||
.treerow.d1 { padding-left:var(--s4); } .treerow.d2 { padding-left:var(--s5); } .treerow.d3 { padding-left:calc(var(--s5) + var(--s3)); }
|
||||
.treerow.d4 { padding-left:var(--s6); } .treerow.d5 { padding-left:calc(var(--s6) + var(--s3)); } .treerow.d6 { padding-left:calc(var(--s6) + var(--s4)); }
|
||||
.tree-link { flex:1 1 auto; min-width:0; min-height:32px; padding:var(--s1) var(--s2); }
|
||||
.twist, .twist-gap { width:24px; height:24px; flex:none; }
|
||||
.twist { display:inline-flex; align-items:center; justify-content:center; border:0; background:none; color:var(--text-faint); cursor:pointer; border-radius:var(--radius); padding:0; }
|
||||
.twist:hover { background:var(--surface-2); color:var(--text); }
|
||||
.twist .icon { transition:transform var(--ease); }
|
||||
.twist.open .icon { transform:rotate(90deg); }
|
||||
.sub-add { display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; flex:none; border:0; background:none; border-radius:var(--radius); color:var(--text-faint); cursor:pointer; opacity:0; padding:0; }
|
||||
.treerow:hover .sub-add, .sub-add:focus-visible { opacity:1; }
|
||||
.sub-add:hover { background:var(--surface-2); color:var(--text); }
|
||||
.navhint { margin:0; padding:var(--s1) var(--s3); font-size:var(--text-sm); color:var(--text-muted); }
|
||||
@media (pointer: coarse) { .sub-add { opacity:1; } .navitem.find kbd { display:none; } }
|
||||
|
||||
/* a page */
|
||||
.page { max-width:780px; margin:0 auto; }
|
||||
.page-top { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; margin-bottom:var(--s5); }
|
||||
.crumbs { display:flex; align-items:center; gap:var(--s2); font-size:var(--text-sm); flex-wrap:wrap; min-width:0; }
|
||||
.crumbs a { color:var(--text-muted); }
|
||||
.crumbs .sep { color:var(--text-faint); }
|
||||
.page-actions { display:flex; align-items:center; gap:var(--s2); flex-wrap:wrap; flex:1 1 auto; justify-content:flex-end; }
|
||||
.page-actions .sub { min-width:4.5em; }
|
||||
.daynav { margin-bottom:var(--s4); }
|
||||
.seg { display:inline-flex; border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); overflow:hidden; }
|
||||
.seg-b { min-height:30px; padding:0 var(--s3); font:inherit; font-size:var(--text-sm); font-weight:500; background:var(--surface); color:var(--text-muted); border:0; cursor:pointer; }
|
||||
.seg-b + .seg-b { border-left:var(--border-w) solid var(--border-strong); }
|
||||
.seg-b.on { background:var(--accent-soft); color:var(--accent-text); }
|
||||
.title-wrap { margin-bottom:var(--s2); }
|
||||
.title-input { font-size:var(--text-2xl); font-weight:700; letter-spacing:-.02em; line-height:1.25; border:0; padding:0; background:transparent; min-height:0; width:100%; }
|
||||
.title-input:focus { outline:none; border:0; }
|
||||
.title-input::placeholder { color:var(--text-faint); }
|
||||
.day-title { font-size:var(--text-2xl); letter-spacing:-.02em; margin-bottom:var(--s2); }
|
||||
.editor-wrap { position:relative; margin-top:var(--s3); }
|
||||
.page textarea.write.big { min-height:45vh; line-height:1.7; font-family:var(--font); font-size:var(--text-base); resize:none; overflow:hidden; }
|
||||
.view { min-height:45vh; cursor:text; overflow-wrap:anywhere; }
|
||||
.md-big { font-size:var(--text-base); line-height:1.7; }
|
||||
.md-big h3.md-h { font-size:var(--text-xl); margin-top:var(--s5); } .md-big h4.md-h { font-size:var(--text-lg); margin-top:var(--s4); }
|
||||
.tagrow { display:flex; flex-wrap:wrap; gap:var(--s2); margin-top:var(--s4); }
|
||||
.tagrow:empty { display:none; }
|
||||
.aside { margin-top:var(--s6); display:flex; flex-direction:column; gap:var(--s5); }
|
||||
.aside:empty { display:none; }
|
||||
.aside-sec h2 { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); margin-bottom:var(--s2); }
|
||||
.linklist { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:2px; }
|
||||
.linklist a { display:inline-flex; align-items:center; gap:var(--s2); padding:var(--s1) 0; }
|
||||
.page-meta { margin-top:var(--s6); }
|
||||
.wikilink { text-decoration:none; border-bottom:var(--border-w) solid var(--accent-text); }
|
||||
.wikilink:hover { background:var(--accent-soft); }
|
||||
.wikilink.missing { color:var(--text-muted); border-bottom-style:dashed; border-bottom-color:var(--text-faint); }
|
||||
.popup { position:absolute; z-index:var(--z-sticky); min-width:240px; max-width:320px; max-height:260px; overflow-y:auto; padding:var(--s1); background:var(--surface); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius-lg); box-shadow:var(--shadow); }
|
||||
.popup-row { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); padding:var(--s2) var(--s3); border-radius:var(--radius); cursor:pointer; }
|
||||
.popup-row.on, .popup-row:hover { background:var(--accent-soft); }
|
||||
.popup-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.popup-hint { color:var(--text-faint); font-size:var(--text-xs); font-family:var(--font-mono); white-space:nowrap; }
|
||||
.mirror { position:absolute; left:-9999px; top:0; visibility:hidden; white-space:pre-wrap; overflow-wrap:break-word; box-sizing:content-box; }
|
||||
.notice .btn-link { min-height:30px; }
|
||||
|
||||
/* quick find and the move picker */
|
||||
.shade-top { align-items:start; padding-top:12vh; }
|
||||
.finder-input { font-size:var(--text-lg); min-height:44px; margin-bottom:var(--s2); }
|
||||
.finder-list { display:flex; flex-direction:column; gap:2px; max-height:50vh; overflow-y:auto; }
|
||||
.finder-row { display:flex; align-items:center; gap:var(--s3); width:100%; padding:var(--s2) var(--s3); border:0; border-radius:var(--radius); background:none; color:var(--text); font:inherit; text-align:left; cursor:pointer; }
|
||||
.finder-row.on, .finder-row:hover { background:var(--accent-soft); }
|
||||
.finder-name { flex:0 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.finder-sub { flex:1 1 0; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; }
|
||||
.pad { padding:var(--s3); margin:0; }
|
||||
|
||||
/* note cards and settings */
|
||||
.note.openable { cursor:pointer; }
|
||||
.note-title { font-size:var(--text-lg); margin-bottom:var(--s2); }
|
||||
.note-title a { color:inherit; text-decoration:none; }
|
||||
.note-title a:hover { text-decoration:underline; }
|
||||
.keys { list-style:none; margin:0; padding:0; display:grid; gap:var(--s2); }
|
||||
@media (max-width:640px) { .page-actions { justify-content:flex-start; } .shade-top { padding-top:var(--s4); } }
|
||||
@@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
|
||||
// ---------- tiny toolkit ----------
|
||||
// h builds an element. Strings become text nodes, never HTML, so page 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.';
|
||||
|
||||
// onSignedOut is set by the app; it decides what a 401 means while someone is writing
|
||||
let onSignedOut = () => { };
|
||||
async function api(method, url, body, opt = {}) {
|
||||
const init = { method, headers: { 'X-Basking': '1' }, credentials: 'same-origin', keepalive: !!opt.keepalive };
|
||||
if (body !== undefined) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); }
|
||||
let r;
|
||||
try { r = await fetch(url, init); }
|
||||
catch { throw Object.assign(new Error('Could not reach Basking. Check your connection.'), { offline: true }); }
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (r.status === 401) { onSignedOut(); throw Object.assign(new Error(SIGNED_OUT), { status: 401 }); }
|
||||
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.status !== 401) 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 isDay = s => /^\d{4}-\d{2}-\d{2}$/.test(s);
|
||||
// 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);
|
||||
const inSelection = () => { const s = getSelection(); return s && !s.isCollapsed; };
|
||||
|
||||
// ---------- 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"/>',
|
||||
home: '<path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 22V12h6v10"/>',
|
||||
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"/>',
|
||||
chev: '<path d="m9 18 6-6-6-6"/>',
|
||||
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
||||
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
move: '<path d="M5 9l-3 3 3 3M9 5l3-3 3 3M15 19l-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20"/>',
|
||||
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"/>',
|
||||
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
};
|
||||
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' + (opts.top ? ' shade-top' : '') });
|
||||
const box = h('div', { class: 'modal' + (opts.wide ? ' wide' : ''), role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': id, tabindex: '-1' },
|
||||
h('h2', { id, class: opts.hideTitle ? 'sr-only' : '' }, 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) });
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user