Files
nib-v1/web/app.js
T
lerko 156ea6ea1c feat(ui): phase 2 — card rows, affordance badges, cards sub-header
- Rich card row rendering: title — preview — affordance badges — tags — pin — use count
- Affordance detection (code, fill, steps, decide, link) from entity shape
- Cards sub-header with scope label, count, sort dropdown
- Section labels (★ pinned / recent) in cards view
- Flash animation on copy (--a-str pulse)
- Tag pill styling for card rows
- Progress bar mini-display for checklists in card preview
2026-05-16 09:29:51 -04:00

1099 lines
36 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 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,
};
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 `<span class="choice">▸ ${escHtml(data.chose)}</span>`;
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 `<span class="card-row-prog"><span style="width:${pct}%"></span></span> ${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 => `<span class="slot">\${${escHtml(s)}}</span>`).join(' ');
}
if (data.url) return `<span class="link">${escHtml(data.url.replace(/^https?:\/\//, ''))}</span>`;
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 = `<div class="rail-head"><span class="rail-brand">nib</span></div>`;
html += '<div class="rail-scroll">';
if (state.view === 'cards') {
html += '<div class="rail-sec">';
html += '<div class="rail-lbl">intent</div>';
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 += `<button class="rail-item${on}" data-intent="${k}">`;
html += `<span class="rail-arrow">${state.intent === k ? '▸' : ''}</span>`;
html += '<span class="rail-dot"></span>';
html += `<span class="rail-name">${k}</span>`;
html += `<span class="rail-count">${count}</span>`;
if (state.intent === k) html += `<span class="rail-hint">${INTENT_HINTS[k]}</span>`;
html += '</button>';
}
html += '</div>';
}
html += '<div class="rail-sec">';
html += '<div class="rail-lbl">tags</div>';
const allOn = !state.activeTag ? ' on' : '';
html += `<button class="rail-item${allOn}" data-tag="">`;
html += `<span class="rail-arrow">${!state.activeTag ? '▸' : ''}</span>`;
html += '<span class="rail-dot"></span>';
html += `<span class="rail-name">#all</span>`;
html += `<span class="rail-count">${total}</span>`;
html += '</button>';
for (const t of state.tags) {
const on = state.activeTag === t.tag ? ' on' : '';
html += `<button class="rail-item${on}" data-tag="${t.tag}">`;
html += `<span class="rail-arrow">${state.activeTag === t.tag ? '▸' : ''}</span>`;
html += '<span class="rail-dot"></span>';
html += `<span class="rail-name">#${t.tag}</span>`;
html += `<span class="rail-count">${t.count}</span>`;
html += '</button>';
}
html += '</div></div>';
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 = `
<div class="cap-row">
<span class="cap-prompt"></span>
<textarea id="capture-input" rows="1" placeholder="${placeholder}" spellcheck="false"></textarea>
<span class="cap-hint">⏎ save</span>
</div>
`;
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');
if (state.entities.length === 0) {
list.innerHTML = '<div class="detail-empty" style="margin-top:40px">no entities yet</div>';
renderCardsHeader(false);
return;
}
let html = '';
if (state.view === 'stream') {
renderCardsHeader(false);
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 {
renderCardsHeader(true);
const pinned = state.entities.filter(e => e.pinned);
const rest = state.entities.filter(e => !e.pinned);
let idx = 0;
if (pinned.length) {
html += '<div class="list-sec-lbl">★ pinned</div>';
for (const e of pinned) {
html += renderCardRow(e, state.entities.indexOf(e));
}
}
if (rest.length) {
if (pinned.length) html += '<div class="list-sec-lbl">recent</div>';
for (const e of rest) {
html += renderCardRow(e, state.entities.indexOf(e));
}
}
}
if (state.hasMore) {
html += '<div class="load-more-wrap"><button class="load-more-btn">load more</button></div>';
}
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 = `
<span class="cards-scope">${scope}</span>
<span class="cards-count">${state.entities.length} cards</span>
<select class="cards-sort"><option>newest</option><option>most used</option></select>
`;
}
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 => `<span class="tag-pill">#${t}</span>`).join('');
const affHtml = affs.map(a => `<span class="aff clickable ${AFF_CLASSES[a]}">${AFF_LABELS[a]}</span>`).join('');
return `<div class="card-row${selected}${pinCls}${flashCls}" data-index="${idx}" data-id="${e.id}">
<span class="card-row-title">${escHtml(title)}</span>
<span class="card-row-dash">—</span>
<span class="card-row-preview">${preview}</span>
<div class="card-row-right">
${affHtml}
${tags}
${e.pinned ? '<span class="card-row-pin">★</span>' : ''}
${e.use_count > 0 ? `<span class="card-row-use">${e.use_count}×</span>` : ''}
</div>
</div>`;
}
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 => `<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>` : '';
const cardBadge = e.card_type ? `<span class="card-badge">${e.card_type}</span>` : '';
let label;
if (e.title) {
const preview = e.body ? `<span class="entity-preview">${escHtml(e.body)}</span>` : '';
label = `<span class="entity-title">${escHtml(e.title)}</span>${preview}`;
} else {
label = `<span class="entity-body">${escHtml(e.body)}</span>`;
}
return `<div class="entity-item${selected}${isCard}" data-index="${idx}" data-id="${e.id}">
<span class="entity-glyph ${gc}">${glyph}</span>
${label}
${time}
<span class="entity-tags">${tags}${cardBadge}</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 primary" 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" onclick="nibApp.showAbsorb('${e.id}')">absorb</button>`;
}
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
const descHtml = e.description ? `<div class="detail-desc" data-id="${e.id}">${escHtml(e.description)}</div>` : '';
const titleHtml = e.title ? `<h2 class="detail-title" data-id="${e.id}">${escHtml(e.title)}</h2>` : '';
pane.innerHTML = `
<div class="detail-scroll">
<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>
${descHtml}
${titleHtml}
<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>
</div>
`;
const titleEl = pane.querySelector('.detail-title');
if (titleEl) titleEl.addEventListener('dblclick', () => startEditField('title'));
const descEl = pane.querySelector('.detail-desc');
if (descEl) descEl.addEventListener('dblclick', () => startEditField('description'));
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 && isSafeUrl(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(); }
});
}
function startEditField(field) {
const e = state.entities[state.selectedIndex];
if (!e) return;
const cls = field === 'title' ? '.detail-title' : '.detail-desc';
const el = $(`${cls}[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;
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 = `
<button class="month-nav-btn" id="month-prev">◂</button>
<span class="month-nav-label">${label}</span>
<button class="month-nav-btn" id="month-next">▸</button>
`;
$('#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 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 = '<div class="detail-empty">no other entities</div>'; }
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 `<div class="absorb-source-item" data-id="${e.id}">
<span class="entity-glyph ${gc}">${g}</span>
<span class="entity-body">${label}</span>
</div>`;
}).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));
},
};
// ========== 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;
}
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': {
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();
renderMonthNav();
renderTagRail();
renderCaptureBar();
}
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;');
}
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();
})();