(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 PAGE_SIZE = 50; const state = { view: 'stream', entities: [], tags: [], selectedIndex: -1, activeTag: null, hasMore: false, activeMonth: 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.from) q.set('from', params.from); if (params.to) q.set('to', params.to); 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); if (params.limit) q.set('limit', String(params.limit)); if (params.offset) q.set('offset', String(params.offset)); 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 absorbEntity(targetId, sourceId) { const resp = await fetch('/api/entities/' + targetId + '/absorb', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source_id: sourceId }), }); 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); }); } if (state.hasMore) { html += '
'; } list.innerHTML = html; list.querySelectorAll('.entity-item').forEach(el => { el.addEventListener('click', () => { selectEntity(parseInt(el.dataset.index)); }); }); const loadMoreBtn = list.querySelector('.load-more-btn'); if (loadMoreBtn) loadMoreBtn.addEventListener('click', loadMore); } 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 += ``; } actions += ``; pane.innerHTML = `
${glyph} ${shortId} ${e.time_anchor ? `@${e.time_anchor}` : ''}
${escHtml(e.body)}
${tags ? `
${tags}
` : ''} ${cardContent}
${actions}
`; 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 `
${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 ''; } } // ========== 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(); } function buildListParams(offset) { const params = { limit: PAGE_SIZE, offset: offset || 0 }; 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'; } if (state.activeMonth) { const [y, m] = state.activeMonth.split('-').map(Number); params.from = state.activeMonth + '-01'; const last = new Date(y, m, 0).getDate(); params.to = state.activeMonth + '-' + String(last).padStart(2, '0'); } return params; } async function loadEntities() { const params = buildListParams(0); const results = await api.listEntities(params); state.entities = results; state.hasMore = results.length === PAGE_SIZE; state.selectedIndex = -1; renderEntityList(); renderDetailPane(); } async function loadMore() { const params = buildListParams(state.entities.length); const results = await api.listEntities(params); state.entities = state.entities.concat(results); state.hasMore = results.length === PAGE_SIZE; renderEntityList(); } function renderMonthNav() { const nav = $('#month-nav'); if (state.view !== 'stream') { nav.innerHTML = ''; return; } const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const label = state.activeMonth ? (() => { const [y, m] = state.activeMonth.split('-'); return MONTHS[parseInt(m) - 1] + ' ' + y; })() : 'all time'; nav.innerHTML = ` ${label} ${state.activeMonth ? '' : ''} `; $('#month-prev').addEventListener('click', () => shiftMonth(-1)); $('#month-next').addEventListener('click', () => shiftMonth(1)); const clearBtn = nav.querySelector('.month-nav-clear'); if (clearBtn) clearBtn.addEventListener('click', () => { state.activeMonth = null; loadEntities(); renderMonthNav(); }); } function shiftMonth(dir) { if (!state.activeMonth) { const now = new Date(); state.activeMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0'); } else { const [y, m] = state.activeMonth.split('-').map(Number); const d = new Date(y, m - 1 + dir, 1); state.activeMonth = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); } loadEntities(); renderMonthNav(); } async function loadTags() { state.tags = await api.listTags(); renderTagRail(); } function switchView(view) { state.view = view; state.activeMonth = null; $$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view)); window.location.hash = view === 'cards' ? '/cards' : '/'; loadEntities(); renderMonthNav(); } // ========== 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); } }, showAbsorb(targetId) { const target = state.entities.find(x => x.id === targetId); if (!target) return; if (target.card_type) return; const modal = $('#absorb-modal'); modal.dataset.targetId = targetId; const list = $('#absorb-source-list'); const sources = state.entities.filter(x => x.id !== targetId); if (!sources.length) { list.innerHTML = '
no other entities
'; } else { list.innerHTML = sources.map(e => { const g = displayGlyph(e); const gc = glyphClass(e); return `
${g} ${escHtml(e.body)}
`; }).join(''); } list.querySelectorAll('.absorb-source-item').forEach(el => { el.addEventListener('click', async () => { modal.classList.add('hidden'); modal.classList.remove('visible'); await api.absorbEntity(targetId, el.dataset.id); await loadEntities(); await loadTags(); const idx = state.entities.findIndex(x => x.id === targetId); if (idx >= 0) selectEntity(idx); }); }); modal.classList.remove('hidden'); modal.classList.add('visible'); }, 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').forEach(el => el.addEventListener('click', closeModal)); $$('.modal-close').forEach(el => el.addEventListener('click', closeModal)); function closeModal() { $$('.modal.visible').forEach(m => { m.classList.add('hidden'); m.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') || $('#absorb-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 'a': { const e = state.entities[state.selectedIndex]; if (e && !e.card_type) nibApp.showAbsorb(e.id); 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, '''); } // ========== Theme ========== const themeToggle = $('#theme-toggle'); let nibTheme = localStorage.getItem('nib:theme') || 'dark'; document.documentElement.setAttribute('data-theme', nibTheme); themeToggle.textContent = nibTheme === 'paper' ? '◐' : '◑'; themeToggle.addEventListener('click', () => { nibTheme = nibTheme === 'dark' ? 'paper' : 'dark'; document.documentElement.setAttribute('data-theme', nibTheme); localStorage.setItem('nib:theme', nibTheme); themeToggle.textContent = nibTheme === 'paper' ? '◐' : '◑'; }); // ========== Init ========== async function init() { await Promise.all([loadEntities(), loadTags()]); handleHash(); renderMonthNav(); } init(); })();