(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 = `
all
`; rail.innerHTML = allItem + state.tags.map(t => `
${t.tag} ${t.count}
` ).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 = '
no entities yet
'; return; } let html = ''; if (state.view === 'stream') { const groups = groupByDate(state.entities); let idx = 0; for (const g of groups) { html += `
── ${g.label} ──
`; 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 => `${t}`).join(''); const time = e.time_anchor ? `@${e.time_anchor}` : ''; const useBadge = e.use_count > 0 ? `${e.use_count}×` : ''; return `
${glyph} ${escHtml(e.body)} ${time} ${tags} ${useBadge}
`; } function renderDetailPane() { const pane = $('#detail-pane'); const e = state.entities[state.selectedIndex]; if (!e) { pane.innerHTML = '
select an entity
'; pane.classList.remove('visible'); return; } pane.classList.add('visible'); const glyph = displayGlyph(e); const gc = glyphClass(e); const tags = (e.tags || []).map(t => `#${t}`).join(''); const shortId = e.id.slice(0, 12); let cardContent = ''; let actions = ''; if (e.card_type) { cardContent = renderCardContent(e); actions += ``; actions += ``; } else { actions += ``; } actions += ``; pane.innerHTML = `
${glyph} ${shortId} ${e.time_anchor ? `@${e.time_anchor}` : ''}
${escHtml(e.body)}
${tags ? `
${tags}
` : ''} ${cardContent}
${actions}
`; } 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 `
${data.slots.map(s => `
\${${s.name}}
`).join('')}
`; case 'checklist': if (!data.steps || !data.steps.length) return ''; return `
${data.steps.map((s, i) => `
${escHtml(s.text)}
`).join('')}
`; case 'decision': return `
chose
${escHtml(data.chose || '—')}
why
${escHtml(data.why || '—')}
${data.rejected && data.rejected.length ? `
rejected
${data.rejected.map(escHtml).join(', ') || '—'}
` : ''}
`; case 'link': if (data.url) { return `
`; } return ''; default: return ''; } } // ========== 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) { if (ev.key === 'Escape') captureInput.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 '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, '&').replace(//g, '>').replace(/"/g, '"'); } function escAttr(s) { return escHtml(s).replace(/'/g, '''); } // ========== Init ========== async function init() { await Promise.all([loadEntities(), loadTags()]); handleHash(); } init(); })();