(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 INTENT_HINTS = { grab: 'scan + copy', read: 'expand + study', fill: 'templates only' }; const state = { view: 'stream', entities: [], tags: [], selectedIndex: -1, activeTag: null, hasMore: false, activeMonth: null, intent: 'grab', flashId: null, peekMode: 'preview', runChecked: new Set(), fillValues: {}, fillActive: 0, searchQuery: '', }; 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; let glyph = 'note'; let remaining = input; const sp = remaining.indexOf(' '); if (sp >= 0) { const first = remaining.slice(0, sp); if (first === '-' || first === '▸') { glyph = 'todo'; remaining = remaining.slice(sp + 1).trim(); } else if (first === '*' || first === '◇') { glyph = 'event'; remaining = remaining.slice(sp + 1).trim(); } } else { if (remaining === '-' || remaining === '▸') { glyph = 'todo'; remaining = ''; } else if (remaining === '*' || remaining === '◇') { glyph = 'event'; remaining = ''; } } let titleRaw = null, descRaw = null, hasTitle = false; const lines = remaining.split('\n'); const firstLine = (lines[0] || '').trim(); if (firstLine.startsWith('|')) { hasTitle = true; const titleContent = firstLine.slice(1); const descIdx = titleContent.indexOf(' // '); if (descIdx >= 0) { titleRaw = titleContent.slice(0, descIdx).trim(); descRaw = titleContent.slice(descIdx + 4).trim(); } else { titleRaw = titleContent.trim(); } remaining = lines.slice(1).join('\n'); } else { let descParts = [], startBody = 0; for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim(); if (trimmed.startsWith('// ') || trimmed === '//') { descParts.push(trimmed.slice(2).trim()); startBody = i + 1; } else { break; } } if (descParts.length) { descRaw = descParts.join(' '); remaining = lines.slice(startBody).join('\n'); } else if (!firstLine.includes('://')) { const dIdx = firstLine.indexOf(' // '); if (dIdx >= 0) { descRaw = firstLine.slice(dIdx + 4).trim(); remaining = firstLine.slice(0, dIdx).trim(); if (lines.length > 1) remaining += '\n' + lines.slice(1).join('\n'); } } } let timeAnchor = null, cardSuffix = null; const tags = [], seenTags = {}; function extract(text) { const tokens = text.split(/\s+/).filter(Boolean); const parts = []; 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 { parts.push(tok); } } return parts.join(' '); } let title = null, description = null; if (hasTitle) { const clean = extract(titleRaw || ''); if (clean) title = clean; } if (descRaw) { const clean = extract(descRaw); if (clean) description = clean; } const body = extract(remaining); if (!body && !title) return null; return { body, glyph, title, description, 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; } function detectAffordances(entity) { const affs = []; const body = entity.body || ''; const data = entity.card_data ? (() => { try { return JSON.parse(entity.card_data); } catch { return {}; } })() : {}; if (data.lang || entity.card_type === 'snippet') affs.push('code'); if (/\$\{[^}]+\}/.test(body)) affs.push('fill'); if (data.steps && data.steps.length) affs.push('steps'); if (data.chose != null || entity.card_type === 'decision') affs.push('decide'); if (data.url || entity.card_type === 'link') affs.push('link'); return affs; } const AFF_LABELS = { code: 'code', fill: 'tpl', steps: 'steps', decide: 'dec', link: 'link' }; const AFF_CLASSES = { code: 'aff-code', fill: 'aff-fill', steps: 'aff-steps', decide: 'aff-decide', link: 'aff-link' }; function cardPreview(entity) { const data = entity.card_data ? (() => { try { return JSON.parse(entity.card_data); } catch { return {}; } })() : {}; if (data.chose) return `▸ ${escHtml(data.chose)}`; if (data.steps && data.steps.length) { const done = data.steps.filter(s => s.done).length; const total = data.steps.length; const pct = total > 0 ? Math.round(done / total * 100) : 0; return ` ${done}/${total} steps`; } if (/\$\{[^}]+\}/.test(entity.body || '')) { const slots = []; const re = /\$\{([^}]+)\}/g; let m; while ((m = re.exec(entity.body)) && slots.length < 2) slots.push(m[1]); return slots.map(s => `\${${escHtml(s)}}`).join(' '); } if (data.url) return `${escHtml(data.url.replace(/^https?:\/\//, ''))}`; const first = (entity.body || '').split('\n')[0] || ''; return escHtml(first.slice(0, 60)); } // ========== 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(); } // ── Tag Rail ── function renderTagRail() { const rail = $('#tag-rail'); const total = state.tags.reduce((s, t) => s + t.count, 0); let html = `
nib
`; html += '
'; if (state.view === 'cards') { html += '
'; html += '
intent
'; for (const k of ['grab', 'read', 'fill']) { const on = state.intent === k ? ' on' : ''; const count = k === 'grab' ? state.entities.length : k === 'read' ? state.entities.filter(e => e.card_data).length : state.entities.filter(e => e.body && /\$\{.+\}/.test(e.body)).length; html += `'; } html += '
'; } html += '
'; html += '
tags
'; const allOn = !state.activeTag ? ' on' : ''; html += `'; for (const t of state.tags) { const on = state.activeTag === t.tag ? ' on' : ''; html += `'; } html += '
'; rail.innerHTML = html; rail.querySelectorAll('.rail-item[data-tag]').forEach(el => { el.addEventListener('click', () => { state.activeTag = el.dataset.tag || null; loadEntities(); renderTagRail(); }); }); rail.querySelectorAll('.rail-item[data-intent]').forEach(el => { el.addEventListener('click', () => { state.intent = el.dataset.intent; renderTagRail(); }); }); } // ── Capture Bar ── function renderCaptureBar() { const bar = $('#capture-bar'); const placeholder = state.view === 'stream' ? 'capture · - todo @time event !time reminder #tag |title' : '|title // desc #tag ${slot} 1. step'; bar.innerHTML = `
⏎ save
`; const input = $('#capture-input'); input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); handleCapture(); } }); } async function handleCapture() { 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.title) data.title = parsed.title; if (parsed.description) data.description = parsed.description; 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(); showToast('captured'); } // ── Entity List ── 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'); const filtered = filterBySearch(state.entities); if (filtered.length === 0) { list.innerHTML = `
${state.searchQuery ? 'no matches' : 'no entities yet'}
`; renderCardsHeader(state.view === 'cards'); return; } let html = ''; if (state.view === 'stream') { renderCardsHeader(false); const groups = groupByDate(filtered); let idx = 0; for (const g of groups) { html += `
${g.label}
`; for (const e of g.entities) { const realIdx = state.entities.indexOf(e); html += renderEntityItem(e, realIdx); idx++; } } } else { renderCardsHeader(true); const pinned = filtered.filter(e => e.pinned); const rest = filtered.filter(e => !e.pinned); if (pinned.length) { html += '
★ pinned
'; for (const e of pinned) { html += renderCardRow(e, state.entities.indexOf(e)); } } if (rest.length) { if (pinned.length) html += '
recent
'; for (const e of rest) { html += renderCardRow(e, state.entities.indexOf(e)); } } } if (state.hasMore) { html += '
'; } list.innerHTML = html; list.querySelectorAll('.entity-item').forEach(el => { el.addEventListener('click', () => { selectEntity(parseInt(el.dataset.index)); }); }); list.querySelectorAll('.card-row').forEach(el => { el.addEventListener('click', (ev) => { if (!ev.target.closest('.aff')) { selectEntity(parseInt(el.dataset.index)); } }); }); const loadMoreBtn = list.querySelector('.load-more-btn'); if (loadMoreBtn) loadMoreBtn.addEventListener('click', loadMore); } function renderCardsHeader(show) { let hdr = $('#cards-hdr'); if (!show) { if (hdr) hdr.remove(); return; } if (!hdr) { hdr = document.createElement('div'); hdr.id = 'cards-hdr'; hdr.className = 'cards-hdr'; const panel = $('#entity-panel'); const list = $('#entity-list'); panel.insertBefore(hdr, list); } const scope = state.activeTag ? `${state.intent} · #${state.activeTag}` : state.intent; hdr.innerHTML = ` ${scope} ${state.entities.length} cards `; } function renderCardRow(e, idx) { const selected = idx === state.selectedIndex ? ' selected' : ''; const pinCls = e.pinned ? ' pinned' : ''; const flashCls = state.flashId === e.id ? ' flashing' : ''; const title = e.title || (e.body || '').split('\n')[0].slice(0, 50); const affs = detectAffordances(e); const preview = cardPreview(e); const tags = (e.tags || []).slice(0, 2).map(t => `#${t}`).join(''); const affHtml = affs.map(a => `${AFF_LABELS[a]}`).join(''); return `
${escHtml(title)} ${preview}
${affHtml} ${tags} ${e.pinned ? '' : ''} ${e.use_count > 0 ? `${e.use_count}×` : ''}
`; } function renderEntityItem(e, idx) { const glyph = displayGlyph(e); const gc = glyphClass(e); const selected = idx === state.selectedIndex ? ' selected' : ''; const isCard = e.card_type ? ' is-card' : ''; const tags = (e.tags || []).slice(0, 2).map(t => `${t}`).join(''); const time = e.time_anchor ? `@${e.time_anchor}` : ''; const useBadge = e.use_count > 0 ? `${e.use_count}×` : ''; const cardBadge = e.card_type ? `${e.card_type}` : ''; let label; if (e.title) { const preview = e.body ? `${escHtml(e.body)}` : ''; label = `${escHtml(e.title)}${preview}`; } else { label = `${escHtml(e.body)}`; } return `
${glyph} ${label} ${time} ${tags}${cardBadge} ${useBadge}
`; } function fmtDateLong(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()}, ${d.getFullYear()} · ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } function renderDetailPane() { const pane = $('#detail-pane'); const e = state.entities[state.selectedIndex]; if (!e) { pane.innerHTML = renderPeekIdle(); pane.classList.remove('visible'); return; } pane.classList.add('visible'); if (state.view === 'stream' || !e.card_type) { pane.innerHTML = renderStreamPeek(e); } else if (state.peekMode === 'run') { pane.innerHTML = renderRunMode(e); } else if (state.peekMode === 'fill') { pane.innerHTML = renderFillMode(e); } else if (state.peekMode === 'edit') { pane.innerHTML = renderEditMode(e); } else { pane.innerHTML = renderCardPeek(e); } bindPeekEvents(e); } function renderPeekIdle() { const v = state.view; return `
peek
Select ${v === 'cards' ? 'a card' : 'an entry'}.
${v === 'cards' ? 'Full detail lives here. Run checklists, fill templates, edit in place.' : 'Entry detail lives here. Promote any capture to a card when it earns a permanent home.'}
navigate
jknext / prev
12stream / cards
${v === 'stream' ? `
stream grammar
(bare text) = thought
- todo · @time event · !time reminder
#tag · |title · // desc · !pin
` : `
act
copy
rrun checklist
ffill template
eedit
ppin
`}
`; } function renderStreamPeek(e) { const kind = e.card_type || e.glyph; const glyph = displayGlyph(e); const gc = glyphClass(e); const kindLbl = { note: 'thought', todo: 'todo', event: 'event', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' }[kind] || kind; const tags = (e.tags || []).map(t => `#${t}`).join(''); let actions = ''; if (!e.card_type) { actions += ``; } actions += ``; return `
${glyph} ${kindLbl} · ${e.id.slice(-10)} ${fmtDateLong(e.created_at)}
${e.title ? `
${escHtml(e.title)}
` : ''}
${escHtml(e.body)}
${tags ? `
tags
${tags}
` : ''}
context
created${fmtDateLong(e.created_at)} ${e.time_anchor ? `time@${e.time_anchor}` : ''} ${e.card_type ? `statuspromoted → ${e.card_type}` : ''}
${actions}
`; } function renderCardPeek(e) { const glyph = GLYPHS[e.card_type] || '◆'; const gc = GLYPH_CLASSES[e.card_type] || 'glyph-snippet'; const affs = detectAffordances(e); const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {}; const tags = (e.tags || []).map(t => `#${t}`).join(''); const affHtml = affs.map(a => `${AFF_LABELS[a]}`).join(''); const hasSteps = data.steps && data.steps.length; const hasDecision = data.chose != null; const hasFill = /\$\{[^}]+\}/.test(e.body || ''); const hasLink = !!data.url; let sections = ''; if (hasDecision) { const rejected = (data.rejected || []).map(r => `${escHtml(r)}`).join(''); sections += `
decision${data.status || 'decided'}
${escHtml(data.chose)}
why${escHtml(data.why || '')}
${rejected ? `
considered
${rejected}
` : ''}
`; } if (hasLink && !hasDecision) { sections += `
link
`; } if (hasSteps) { const steps = data.steps.map((s, i) => `
${escHtml(s.text || s)}
`).join(''); sections += `
steps · ${data.steps.length}
${steps}
`; } if (!hasDecision && e.body) { const lang = data.lang || ''; sections += `
content${lang ? `${lang}` : ''}${hasFill ? `` : ''}
${escHtml(e.body)}
`; } let actions = ``; if (hasFill) actions += ``; if (hasSteps) actions += ``; actions += ``; actions += ``; actions += ``; return `
${glyph} ${e.card_type} · ${e.id.slice(-10)} ${e.use_count > 0 ? `${e.use_count}× used` : ''}
${escHtml(e.title || '')}
${e.description ? `
${escHtml(e.description)}
` : ''}
${affHtml}${tags}${e.pinned ? '' : ''}
${sections}
${actions}
`; } function renderRunMode(e) { const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {}; if (!data.steps) return renderCardPeek(e); const total = data.steps.length; const checked = state.runChecked || new Set(); const done = checked.size; const pct = total > 0 ? Math.round(done / total * 100) : 0; const steps = data.steps.map((s, i) => { const isDone = checked.has(i); const text = s.text || s; return `
${isDone ? '●' : '○'} ${escHtml(text)}
`; }).join(''); return `
▶ running ${done}/${total} done
${escHtml(e.title || '')}
${e.description ? `
${escHtml(e.description)}
` : ''}
${pct}%
${steps}
Space toggler resetEsc exit
`; } function renderFillMode(e) { const slots = []; const re = /\$\{([^}]+)\}/g; let m; const seen = new Set(); while ((m = re.exec(e.body || '')) !== null) { const name = m[1].trim(); if (!seen.has(name)) { seen.add(name); slots.push(name); } } if (!slots.length) return renderCardPeek(e); const fill = state.fillValues || {}; const active = state.fillActive || 0; let content = escHtml(e.body); for (const name of slots) { const val = fill[name] || ''; const idx = slots.indexOf(name); const cls = idx === active ? 'fill-slot active' : (val ? 'fill-slot filled' : 'fill-slot'); const width = Math.max(name.length, val.length, 4) * 8 + 16; content = content.replace(`\${${name}}`, ``); } const allFilled = slots.every(s => fill[s]); return `
⤓ filling slot ${active + 1} / ${slots.length}
${escHtml(e.title || '')}
${e.description ? `
${escHtml(e.description)}
` : ''}
${content}
Tab next⇧Tab prev copyEsc cancel
`; } function renderEditMode(e) { return `
✎ editing
${escHtml(e.title || 'untitled')}
⌘⏎ saveEsc cancel
`; } function bindPeekEvents(e) { const pane = $('#detail-pane'); if (!e) return; if (state.peekMode === 'run') { pane.querySelectorAll('.peek-run-step').forEach(el => { el.addEventListener('click', () => { const idx = parseInt(el.dataset.step); if (!state.runChecked) state.runChecked = new Set(); if (state.runChecked.has(idx)) state.runChecked.delete(idx); else state.runChecked.add(idx); renderDetailPane(); }); }); } if (state.peekMode === 'fill') { pane.querySelectorAll('.fill-slot input').forEach(input => { input.addEventListener('input', () => { if (!state.fillValues) state.fillValues = {}; state.fillValues[input.dataset.slot] = input.value; }); input.addEventListener('focus', () => { state.fillActive = parseInt(input.dataset.idx); }); input.addEventListener('keydown', (ev) => { if (ev.key === 'Tab') { ev.preventDefault(); const slots = pane.querySelectorAll('.fill-slot input'); const cur = parseInt(input.dataset.idx); const next = ev.shiftKey ? Math.max(0, cur - 1) : Math.min(slots.length - 1, cur + 1); state.fillActive = next; renderDetailPane(); setTimeout(() => { const el = pane.querySelector(`.fill-slot input[data-idx="${next}"]`); if (el) el.focus(); }, 0); } else if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); nibApp.completeFill(); } else if (ev.key === 'Escape') { ev.preventDefault(); nibApp.exitMode(); } }); }); setTimeout(() => { const el = pane.querySelector(`.fill-slot input[data-idx="${state.fillActive || 0}"]`); if (el) el.focus(); }, 0); } if (state.peekMode === 'edit') { const bodyTa = pane.querySelector('#edit-body'); if (bodyTa) { bodyTa.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey)) { ev.preventDefault(); nibApp.saveEdit(e.id); } if (ev.key === 'Escape') { ev.preventDefault(); nibApp.exitMode(); } }); } } // Double-click to edit (stream peek) const titleEl = pane.querySelector('.peek-title[data-id]'); if (titleEl) titleEl.addEventListener('dblclick', () => startEditField('title')); const bodyEl = pane.querySelector('.peek-body[data-id]'); if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody); } // ========== Inline edit ========== function startEditBody() { const e = state.entities[state.selectedIndex]; if (!e) return; const el = $(`.peek-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(); } }); } function startEditField(field) { const e = state.entities[state.selectedIndex]; if (!e) return; const el = $(`.peek-title[data-id="${e.id}"]`); if (!el || el.tagName === 'INPUT') return; const input = document.createElement('input'); input.type = 'text'; input.className = 'detail-field-edit'; input.value = e[field] || ''; input.placeholder = field; el.replaceWith(input); input.focus(); async function save() { const val = input.value.trim(); if (val !== (e[field] || '')) { await api.updateEntity(e.id, { [field]: val || null }); await loadEntities(); const idx = state.entities.findIndex(x => x.id === e.id); if (idx >= 0) selectEntity(idx); } else { renderDetailPane(); } } input.addEventListener('blur', save); input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); input.removeEventListener('blur', save); save(); } if (ev.key === 'Escape') { ev.preventDefault(); input.removeEventListener('blur', save); renderDetailPane(); } }); } // ========== Actions ========== function selectEntity(idx) { state.selectedIndex = idx; state.peekMode = 'preview'; state.runChecked = new Set(); state.fillValues = {}; state.fillActive = 0; 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 = ''; nav.style.display = 'none'; return; } nav.style.display = ''; 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} `; $('#month-prev').addEventListener('click', () => shiftMonth(-1)); $('#month-next').addEventListener('click', () => shiftMonth(1)); } 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; state.selectedIndex = -1; $$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view)); window.location.hash = view === 'cards' ? '/cards' : '/'; loadEntities(); renderMonthNav(); renderTagRail(); renderCaptureBar(); } // ========== Toast ========== function showToast(msg) { let el = $('.toast'); if (el) el.remove(); el = document.createElement('div'); el.className = 'toast'; el.textContent = msg; document.body.appendChild(el); setTimeout(() => el.remove(), 1600); } // ========== 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); state.flashId = id; await loadEntities(); showToast('copied'); setTimeout(() => { state.flashId = null; renderEntityList(); }, 360); } 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 sub = $('#promote-sub'); const label = (e.body || '').slice(0, 64) + ((e.body || '').length > 64 ? '…' : ''); sub.textContent = label; 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(); showToast('demoted'); }, async deleteEntity(id) { await api.deleteEntity(id); await loadEntities(); await loadTags(); showToast('deleted'); }, 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(); showToast('copied'); } 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); const label = e.title ? escHtml(e.title) : escHtml(e.body); return `
${g} ${label}
`; }).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); showToast('absorbed'); }); }); 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)); }, enterMode(mode) { state.peekMode = mode; if (mode === 'run') state.runChecked = new Set(); if (mode === 'fill') { state.fillValues = {}; state.fillActive = 0; } renderDetailPane(); }, exitMode() { state.peekMode = 'preview'; renderDetailPane(); }, resetRun() { state.runChecked = new Set(); renderDetailPane(); }, async completeFill() { const e = state.entities[state.selectedIndex]; if (!e) return; let resolved = e.body || ''; const fill = state.fillValues || {}; for (const [name, val] of Object.entries(fill)) { resolved = resolved.replace(new RegExp('\\$\\{' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\}', 'g'), val); } try { await navigator.clipboard.writeText(resolved); await api.useEntity(e.id); state.peekMode = 'preview'; await loadEntities(); showToast('copied resolved'); } catch (err) { console.error('clipboard:', err); } }, async saveEdit(id) { const title = ($('#edit-title') || {}).value || null; const desc = ($('#edit-desc') || {}).value || null; const body = ($('#edit-body') || {}).value || ''; const tagsStr = ($('#edit-tags') || {}).value || ''; const tags = tagsStr.split(/\s+/).filter(Boolean); await api.updateEntity(id, { body, title, description: desc, tags }); state.peekMode = 'preview'; await loadEntities(); await loadTags(); const idx = state.entities.findIndex(x => x.id === id); if (idx >= 0) selectEntity(idx); showToast('saved'); }, async togglePin(id) { const e = state.entities.find(x => x.id === id); if (!e) return; await api.updateEntity(id, { pinned: !e.pinned }); await loadEntities(); const idx = state.entities.findIndex(x => x.id === id); if (idx >= 0) { state.selectedIndex = idx; renderEntityList(); renderDetailPane(); } showToast(e.pinned ? 'unpinned' : 'pinned'); }, }; // ========== 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(); showToast('promoted → ' + btn.dataset.type); }); }); $$('.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; document.addEventListener('keydown', (ev) => { const tag = (ev.target.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea') { if (ev.key === 'Escape') ev.target.blur(); return; } if ($('#promote-modal').classList.contains('visible') || $('#absorb-modal').classList.contains('visible')) { if (ev.key === 'Escape') closeModal(); return; } if (state.peekMode !== 'preview' && ev.key === 'Escape') { nibApp.exitMode(); return; } const sel = state.entities[state.selectedIndex]; 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(); $('#capture-input').focus(); break; case 'p': if (sel && sel.card_type && state.view === 'cards') { nibApp.togglePin(sel.id); } else if (sel && !sel.card_type) { nibApp.showPromote(sel.id); } break; case 'Enter': if (sel) nibApp.copyEntity(sel.id); break; case 'r': if (sel && sel.card_type && state.view === 'cards') nibApp.enterMode('run'); break; case 'f': if (sel && sel.card_type && state.view === 'cards') nibApp.enterMode('fill'); break; case 'e': if (sel && sel.card_type && state.view === 'cards') { nibApp.enterMode('edit'); } else { startEditBody(); } break; case 'd': { const now = Date.now(); if (now - lastDTime < 400) { if (sel) nibApp.deleteEntity(sel.id); lastDTime = 0; } else { lastDTime = now; } break; } case 'a': if (sel && !sel.card_type) nibApp.showAbsorb(sel.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(); renderMonthNav(); renderTagRail(); renderCaptureBar(); } window.addEventListener('hashchange', handleHash); // ========== Search ========== const searchInput = $('#search-input'); let searchDebounce = null; searchInput.addEventListener('input', () => { clearTimeout(searchDebounce); searchDebounce = setTimeout(() => { state.searchQuery = searchInput.value.trim().toLowerCase(); renderEntityList(); }, 150); }); searchInput.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { searchInput.value = ''; state.searchQuery = ''; renderEntityList(); searchInput.blur(); } }); function filterBySearch(entities) { if (!state.searchQuery) return entities; let query = state.searchQuery; let filterTags = []; query = query.replace(/#(\S+)/g, (_, tag) => { filterTags.push(tag); return ''; }).trim(); return entities.filter(e => { if (filterTags.length) { const eTags = (e.tags || []).map(t => t.toLowerCase()); if (!filterTags.every(ft => eTags.includes(ft))) return false; } if (!query) return true; const haystack = ((e.body || '') + ' ' + (e.title || '') + ' ' + (e.description || '')).toLowerCase(); return haystack.includes(query); }); } // ========== Utils ========== function escHtml(s) { if (!s) return ''; return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function escAttr(s) { return escHtml(s).replace(/'/g, '''); } function isSafeUrl(url) { return /^https?:\/\//i.test(url); } // ========== 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() { renderCaptureBar(); await Promise.all([loadEntities(), loadTags()]); handleHash(); renderMonthNav(); } init(); })();