156ea6ea1c
- 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
1099 lines
36 KiB
JavaScript
1099 lines
36 KiB
JavaScript
(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, '&').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();
|
||
})();
|