Files
nib-v1/web/app.js
T
lerko 03094706c3 fix: batch tag queries, inline edit, delete response, SPA catch-all, link glyph
- Fix N+1 tag query in List() with batched IN clause
- Add inline body editing in web detail pane (dblclick or e key)
- Delete API returns {result: "soft"|"hard"} with 200 instead of 204
- SPA handler serves index.html for all extensionless paths
- Link glyph changed from emoji 🔗 to unicode ↗ for terminal alignment
- Capture bar contrast and hover glow increased
- Comment on load-bearing "--" in root.go
2026-05-14 12:37:13 -04:00

640 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
'use strict';
const GLYPHS = {
note: '◦', todo: '▸', event: '◇',
snippet: '◆', template: '◈', checklist: '☐',
decision: '⚖', link: '↗',
};
const GLYPH_CLASSES = {
note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event',
snippet: 'glyph-snippet', template: 'glyph-template',
checklist: 'glyph-checklist', decision: 'glyph-decision',
link: 'glyph-link',
};
const state = {
view: 'stream',
entities: [],
tags: [],
selectedIndex: -1,
activeTag: null,
};
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
// ========== API ==========
const api = {
async listEntities(params = {}) {
const q = new URLSearchParams();
if (params.tag) q.set('tag', params.tag);
if (params.date) q.set('date', params.date);
if (params.cards_only) q.set('cards_only', 'true');
if (params.sort) q.set('sort', params.sort);
if (params.order) q.set('order', params.order);
const resp = await fetch('/api/entities?' + q);
return resp.json();
},
async createEntity(data) {
const resp = await fetch('/api/entities', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return resp.json();
},
async getEntity(id) {
const resp = await fetch('/api/entities/' + id);
return resp.json();
},
async updateEntity(id, data) {
const resp = await fetch('/api/entities/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return resp.json();
},
async deleteEntity(id) {
return fetch('/api/entities/' + id, { method: 'DELETE' });
},
async promoteEntity(id, cardType, cardData) {
const resp = await fetch('/api/entities/' + id + '/promote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ card_type: cardType, card_data: cardData }),
});
return resp.json();
},
async demoteEntity(id) {
const resp = await fetch('/api/entities/' + id + '/demote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
return resp.json();
},
async useEntity(id) {
const resp = await fetch('/api/entities/' + id + '/use', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
return resp.json();
},
async listTags() {
const resp = await fetch('/api/tags');
return resp.json();
},
};
// ========== Grammar parser (mirrors Go parser) ==========
const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' };
function parseInput(input) {
input = input.trim();
if (!input) return null;
const tokens = input.split(/\s+/);
let glyph = 'note';
const first = tokens[0];
if (first === '-' || first === '▸') { glyph = 'todo'; tokens.shift(); }
else if (first === '*' || first === '◇') { glyph = 'event'; tokens.shift(); }
const bodyParts = [];
let timeAnchor = null;
const tags = [];
const seenTags = {};
let cardSuffix = null;
for (const tok of tokens) {
if (tok.startsWith('@') && tok.length > 1) {
timeAnchor = tok.slice(1);
} else if (tok.startsWith('#') && tok.length > 1) {
const tag = tok.slice(1);
if (!seenTags[tag]) { tags.push(tag); seenTags[tag] = true; }
} else if (tok.startsWith('^') && tok.length > 1) {
const suffix = tok.slice(1);
if (VALID_CARDS[suffix]) cardSuffix = VALID_CARDS[suffix];
} else {
bodyParts.push(tok);
}
}
const body = bodyParts.join(' ');
if (!body) return null;
return { body, glyph, timeAnchor, tags, cardSuffix };
}
function detectCardType(body) {
if (/\$\{.+\}/.test(body)) return 'template';
if (/\bchose:|why:/.test(body)) return 'decision';
if (/\[ \]|\d+\./.test(body)) return 'checklist';
if (/https?:\/\//.test(body)) return 'link';
return null;
}
// ========== Rendering ==========
function displayGlyph(entity) {
return entity.card_type ? GLYPHS[entity.card_type] : GLYPHS[entity.glyph];
}
function glyphClass(entity) {
return entity.card_type ? GLYPH_CLASSES[entity.card_type] : GLYPH_CLASSES[entity.glyph];
}
function formatDate(dateStr) {
const d = new Date(dateStr);
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
return months[d.getMonth()] + ' ' + d.getDate();
}
function renderTagRail() {
const rail = $('#tag-rail');
const allItem = `<div class="tag-item ${!state.activeTag ? 'active' : ''}" data-tag="">
<span class="tag-name" style="font-style: italic">all</span>
</div>`;
rail.innerHTML = allItem + state.tags.map(t =>
`<div class="tag-item ${state.activeTag === t.tag ? 'active' : ''}" data-tag="${t.tag}">
<span class="tag-name">${t.tag}</span>
<span class="tag-count">${t.count}</span>
</div>`
).join('');
rail.querySelectorAll('.tag-item').forEach(el => {
el.addEventListener('click', () => {
state.activeTag = el.dataset.tag || null;
loadEntities();
renderTagRail();
});
});
}
function groupByDate(entities) {
const groups = [];
let current = null;
for (const e of entities) {
const label = formatDate(e.created_at);
if (!current || current.label !== label) {
if (current) groups.push(current);
current = { label, entities: [] };
}
current.entities.push(e);
}
if (current) groups.push(current);
return groups;
}
function renderEntityList() {
const list = $('#entity-list');
if (state.entities.length === 0) {
list.innerHTML = '<div class="detail-empty" style="margin-top:40px">no entities yet</div>';
return;
}
let html = '';
if (state.view === 'stream') {
const groups = groupByDate(state.entities);
let idx = 0;
for (const g of groups) {
html += `<div class="date-header">── ${g.label} ──</div>`;
for (const e of g.entities) {
html += renderEntityItem(e, idx);
idx++;
}
}
} else {
state.entities.forEach((e, idx) => {
html += renderEntityItem(e, idx);
});
}
list.innerHTML = html;
list.querySelectorAll('.entity-item').forEach(el => {
el.addEventListener('click', () => {
selectEntity(parseInt(el.dataset.index));
});
});
}
function renderEntityItem(e, idx) {
const glyph = displayGlyph(e);
const gc = glyphClass(e);
const selected = idx === state.selectedIndex ? 'selected' : '';
const tags = (e.tags || []).map(t => `<span class="entity-tag">${t}</span>`).join('');
const time = e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : '';
const useBadge = e.use_count > 0 ? `<span class="use-badge">${e.use_count}×</span>` : '';
return `<div class="entity-item ${selected}" data-index="${idx}" data-id="${e.id}">
<span class="entity-glyph ${gc}">${glyph}</span>
<span class="entity-body">${escHtml(e.body)}</span>
${time}
<span class="entity-tags">${tags}</span>
<span class="entity-meta">${useBadge}</span>
</div>`;
}
function renderDetailPane() {
const pane = $('#detail-pane');
const e = state.entities[state.selectedIndex];
if (!e) {
pane.innerHTML = '<div class="detail-empty">select an entity</div>';
pane.classList.remove('visible');
return;
}
pane.classList.add('visible');
const glyph = displayGlyph(e);
const gc = glyphClass(e);
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
const shortId = e.id.slice(0, 12);
let cardContent = '';
let actions = '';
if (e.card_type) {
cardContent = renderCardContent(e);
actions += `<button class="action-btn" onclick="nibApp.copyEntity('${e.id}')">copy</button>`;
actions += `<button class="action-btn" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
} else {
actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote</button>`;
}
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
pane.innerHTML = `
<div class="detail-header">
<span class="detail-glyph ${gc}">${glyph}</span>
<span class="detail-id">${shortId}</span>
${e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : ''}
</div>
<div class="detail-body" data-id="${e.id}">${escHtml(e.body)}</div>
${tags ? `<div class="detail-tags">${tags}</div>` : ''}
${cardContent}
<div class="detail-actions">${actions}</div>
`;
const bodyEl = pane.querySelector('.detail-body');
if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody);
}
function renderCardContent(e) {
if (!e.card_data) return '';
let data;
try { data = JSON.parse(e.card_data); } catch { return ''; }
switch (e.card_type) {
case 'template':
if (!data.slots || !data.slots.length) return '';
return `<div class="slot-form">
${data.slots.map(s => `
<div class="slot-field">
<span class="slot-label">\${${s.name}}</span>
<input class="slot-input" data-slot="${s.name}" placeholder="${s.default || s.name}" value="${s.default || ''}">
</div>
`).join('')}
<button class="action-btn primary" onclick="nibApp.resolveTemplate('${e.id}')">resolve & copy</button>
</div>`;
case 'checklist':
if (!data.steps || !data.steps.length) return '';
return `<div class="checklist">
${data.steps.map((s, i) => `
<div class="checklist-step ${s.done ? 'done' : ''}">
<input type="checkbox" ${s.done ? 'checked' : ''} onchange="nibApp.toggleStep('${e.id}', ${i})">
<span>${escHtml(s.text)}</span>
</div>
`).join('')}
</div>`;
case 'decision':
return `<div>
<div class="decision-field"><div class="decision-label">chose</div><div class="decision-value">${escHtml(data.chose || '—')}</div></div>
<div class="decision-field"><div class="decision-label">why</div><div class="decision-value">${escHtml(data.why || '—')}</div></div>
${data.rejected && data.rejected.length ? `<div class="decision-field"><div class="decision-label">rejected</div><div class="decision-value">${data.rejected.map(escHtml).join(', ') || '—'}</div></div>` : ''}
</div>`;
case 'link':
if (data.url) {
return `<div style="margin-bottom:12px">
<button class="action-btn" onclick="window.open('${escAttr(data.url)}', '_blank')">open link</button>
</div>`;
}
return '';
default:
return '';
}
}
// ========== Inline edit ==========
function startEditBody() {
const e = state.entities[state.selectedIndex];
if (!e) return;
const el = $(`.detail-body[data-id="${e.id}"]`);
if (!el || el.tagName === 'TEXTAREA') return;
const ta = document.createElement('textarea');
ta.className = 'detail-body-edit';
ta.value = e.body;
el.replaceWith(ta);
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
async function save() {
const newBody = ta.value.trim();
if (newBody && newBody !== e.body) {
await api.updateEntity(e.id, { body: newBody });
await loadEntities();
const idx = state.entities.findIndex(x => x.id === e.id);
if (idx >= 0) selectEntity(idx);
} else {
renderDetailPane();
}
}
ta.addEventListener('blur', save);
ta.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && ev.ctrlKey) { ev.preventDefault(); ta.removeEventListener('blur', save); save(); }
if (ev.key === 'Escape') { ev.preventDefault(); ta.removeEventListener('blur', save); renderDetailPane(); }
});
}
// ========== Actions ==========
function selectEntity(idx) {
state.selectedIndex = idx;
renderEntityList();
renderDetailPane();
}
async function loadEntities() {
const params = {};
if (state.activeTag) params.tag = state.activeTag;
if (state.view === 'cards') {
params.cards_only = true;
params.sort = 'use_count';
params.order = 'desc';
} else {
params.sort = 'created';
params.order = 'desc';
}
state.entities = await api.listEntities(params);
state.selectedIndex = -1;
renderEntityList();
renderDetailPane();
}
async function loadTags() {
state.tags = await api.listTags();
renderTagRail();
}
function switchView(view) {
state.view = view;
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
window.location.hash = view === 'cards' ? '/cards' : '/';
loadEntities();
}
// ========== Public API (for inline handlers) ==========
window.nibApp = {
async copyEntity(id) {
const e = state.entities.find(x => x.id === id);
if (!e) return;
try {
await navigator.clipboard.writeText(e.body);
await api.useEntity(id);
await loadEntities();
} catch (err) {
console.error('clipboard:', err);
}
},
showPromote(id) {
const e = state.entities.find(x => x.id === id);
if (!e || e.card_type) return;
const modal = $('#promote-modal');
modal.classList.remove('hidden');
modal.classList.add('visible');
modal.dataset.entityId = id;
const suggested = detectCardType(e.body);
$$('.type-btn').forEach(btn => {
btn.classList.toggle('suggested', btn.dataset.type === suggested);
});
},
async demoteEntity(id) {
await api.demoteEntity(id);
await loadEntities();
await loadTags();
},
async deleteEntity(id) {
await api.deleteEntity(id);
await loadEntities();
await loadTags();
},
async resolveTemplate(id) {
const e = state.entities.find(x => x.id === id);
if (!e) return;
let resolved = e.body;
$$('.slot-input').forEach(input => {
const name = input.dataset.slot;
const val = input.value || input.placeholder;
resolved = resolved.replace(new RegExp('\\$\\{' + name + '\\}', 'g'), val);
});
try {
await navigator.clipboard.writeText(resolved);
await api.useEntity(id);
await loadEntities();
} catch (err) {
console.error('clipboard:', err);
}
},
async toggleStep(id, stepIdx) {
const e = state.entities.find(x => x.id === id);
if (!e || !e.card_data) return;
const data = JSON.parse(e.card_data);
data.steps[stepIdx].done = !data.steps[stepIdx].done;
await api.updateEntity(id, { card_data: JSON.stringify(data) });
await loadEntities();
selectEntity(state.entities.findIndex(x => x.id === id));
},
};
// ========== Capture bar ==========
$('#capture-bar').addEventListener('submit', async (ev) => {
ev.preventDefault();
const input = $('#capture-input');
const val = input.value.trim();
if (!val) return;
const parsed = parseInput(val);
if (!parsed) return;
const data = {
body: parsed.body,
glyph: parsed.glyph,
tags: parsed.tags,
};
if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor;
if (parsed.cardSuffix) data.card_type = parsed.cardSuffix;
await api.createEntity(data);
input.value = '';
await loadEntities();
await loadTags();
});
// ========== Promote modal ==========
$$('.type-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const modal = $('#promote-modal');
const id = modal.dataset.entityId;
modal.classList.add('hidden');
modal.classList.remove('visible');
await api.promoteEntity(id, btn.dataset.type);
await loadEntities();
await loadTags();
});
});
$('.modal-backdrop').addEventListener('click', closeModal);
$('.modal-close').addEventListener('click', closeModal);
function closeModal() {
const modal = $('#promote-modal');
modal.classList.add('hidden');
modal.classList.remove('visible');
}
// ========== Keyboard shortcuts ==========
let lastDTime = 0;
const captureInput = $('#capture-input');
document.addEventListener('keydown', (ev) => {
if (document.activeElement === captureInput ||
document.activeElement.classList.contains('detail-body-edit')) {
if (ev.key === 'Escape') document.activeElement.blur();
return;
}
if ($('#promote-modal').classList.contains('visible')) {
if (ev.key === 'Escape') closeModal();
return;
}
switch (ev.key) {
case 'j':
ev.preventDefault();
selectEntity(Math.min(state.selectedIndex + 1, state.entities.length - 1));
scrollSelectedIntoView();
break;
case 'k':
ev.preventDefault();
selectEntity(Math.max(state.selectedIndex - 1, 0));
scrollSelectedIntoView();
break;
case 'n':
ev.preventDefault();
captureInput.focus();
break;
case 'p': {
const e = state.entities[state.selectedIndex];
if (e && !e.card_type) nibApp.showPromote(e.id);
break;
}
case 'Enter': {
const e = state.entities[state.selectedIndex];
if (e) nibApp.copyEntity(e.id);
break;
}
case 'd': {
const now = Date.now();
if (now - lastDTime < 400) {
const e = state.entities[state.selectedIndex];
if (e) nibApp.deleteEntity(e.id);
lastDTime = 0;
} else {
lastDTime = now;
}
break;
}
case 'e': {
startEditBody();
break;
}
case '1': switchView('stream'); break;
case '2': switchView('cards'); break;
}
});
function scrollSelectedIntoView() {
const el = $(`.entity-item[data-index="${state.selectedIndex}"]`);
if (el) el.scrollIntoView({ block: 'nearest' });
}
// ========== View nav buttons ==========
$$('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => switchView(btn.dataset.view));
});
// ========== Hash routing ==========
function handleHash() {
const hash = window.location.hash;
if (hash === '#/cards') {
state.view = 'cards';
} else {
state.view = 'stream';
}
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === state.view));
loadEntities();
}
window.addEventListener('hashchange', handleHash);
// ========== Utils ==========
function escHtml(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function escAttr(s) {
return escHtml(s).replace(/'/g, '&#39;');
}
// ========== Init ==========
async function init() {
await Promise.all([loadEntities(), loadTags()]);
handleHash();
}
init();
})();