5b0d0a8f33
Stream view with date grouping, card view sorted by usage, capture bar with client-side grammar parsing, tag rail filter, detail pane with card affordances (template slot fill, checklist toggle, link open), promote modal with auto-detect, keyboard shortcuts (j/k/n/p/ Enter/dd/1/2). Dark theme, responsive layout. Embedded in Go binary.
598 lines
18 KiB
JavaScript
598 lines
18 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 state = {
|
||
view: 'stream',
|
||
entities: [],
|
||
tags: [],
|
||
selectedIndex: -1,
|
||
activeTag: null,
|
||
};
|
||
|
||
const $ = (sel) => document.querySelector(sel);
|
||
const $$ = (sel) => document.querySelectorAll(sel);
|
||
|
||
// ========== API ==========
|
||
|
||
const api = {
|
||
async listEntities(params = {}) {
|
||
const q = new URLSearchParams();
|
||
if (params.tag) q.set('tag', params.tag);
|
||
if (params.date) q.set('date', params.date);
|
||
if (params.cards_only) q.set('cards_only', 'true');
|
||
if (params.sort) q.set('sort', params.sort);
|
||
if (params.order) q.set('order', params.order);
|
||
const resp = await fetch('/api/entities?' + q);
|
||
return resp.json();
|
||
},
|
||
async createEntity(data) {
|
||
const resp = await fetch('/api/entities', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data),
|
||
});
|
||
return resp.json();
|
||
},
|
||
async getEntity(id) {
|
||
const resp = await fetch('/api/entities/' + id);
|
||
return resp.json();
|
||
},
|
||
async updateEntity(id, data) {
|
||
const resp = await fetch('/api/entities/' + id, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data),
|
||
});
|
||
return resp.json();
|
||
},
|
||
async deleteEntity(id) {
|
||
return fetch('/api/entities/' + id, { method: 'DELETE' });
|
||
},
|
||
async promoteEntity(id, cardType, cardData) {
|
||
const resp = await fetch('/api/entities/' + id + '/promote', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ card_type: cardType, card_data: cardData }),
|
||
});
|
||
return resp.json();
|
||
},
|
||
async demoteEntity(id) {
|
||
const resp = await fetch('/api/entities/' + id + '/demote', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({}),
|
||
});
|
||
return resp.json();
|
||
},
|
||
async useEntity(id) {
|
||
const resp = await fetch('/api/entities/' + id + '/use', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({}),
|
||
});
|
||
return resp.json();
|
||
},
|
||
async listTags() {
|
||
const resp = await fetch('/api/tags');
|
||
return resp.json();
|
||
},
|
||
};
|
||
|
||
// ========== Grammar parser (mirrors Go parser) ==========
|
||
|
||
const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' };
|
||
|
||
function parseInput(input) {
|
||
input = input.trim();
|
||
if (!input) return null;
|
||
|
||
const tokens = input.split(/\s+/);
|
||
let glyph = 'note';
|
||
|
||
const first = tokens[0];
|
||
if (first === '-' || first === '▸') { glyph = 'todo'; tokens.shift(); }
|
||
else if (first === '*' || first === '◇') { glyph = 'event'; tokens.shift(); }
|
||
|
||
const bodyParts = [];
|
||
let timeAnchor = null;
|
||
const tags = [];
|
||
const seenTags = {};
|
||
let cardSuffix = null;
|
||
|
||
for (const tok of tokens) {
|
||
if (tok.startsWith('@') && tok.length > 1) {
|
||
timeAnchor = tok.slice(1);
|
||
} else if (tok.startsWith('#') && tok.length > 1) {
|
||
const tag = tok.slice(1);
|
||
if (!seenTags[tag]) { tags.push(tag); seenTags[tag] = true; }
|
||
} else if (tok.startsWith('^') && tok.length > 1) {
|
||
const suffix = tok.slice(1);
|
||
if (VALID_CARDS[suffix]) cardSuffix = VALID_CARDS[suffix];
|
||
} else {
|
||
bodyParts.push(tok);
|
||
}
|
||
}
|
||
|
||
const body = bodyParts.join(' ');
|
||
if (!body) return null;
|
||
|
||
return { body, glyph, timeAnchor, tags, cardSuffix };
|
||
}
|
||
|
||
function detectCardType(body) {
|
||
if (/\$\{.+\}/.test(body)) return 'template';
|
||
if (/\bchose:|why:/.test(body)) return 'decision';
|
||
if (/\[ \]|\d+\./.test(body)) return 'checklist';
|
||
if (/https?:\/\//.test(body)) return 'link';
|
||
return null;
|
||
}
|
||
|
||
// ========== Rendering ==========
|
||
|
||
function displayGlyph(entity) {
|
||
return entity.card_type ? GLYPHS[entity.card_type] : GLYPHS[entity.glyph];
|
||
}
|
||
|
||
function glyphClass(entity) {
|
||
return entity.card_type ? GLYPH_CLASSES[entity.card_type] : GLYPH_CLASSES[entity.glyph];
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
const d = new Date(dateStr);
|
||
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||
return months[d.getMonth()] + ' ' + d.getDate();
|
||
}
|
||
|
||
function renderTagRail() {
|
||
const rail = $('#tag-rail');
|
||
const allItem = `<div class="tag-item ${!state.activeTag ? 'active' : ''}" data-tag="">
|
||
<span class="tag-name" style="font-style: italic">all</span>
|
||
</div>`;
|
||
|
||
rail.innerHTML = allItem + state.tags.map(t =>
|
||
`<div class="tag-item ${state.activeTag === t.tag ? 'active' : ''}" data-tag="${t.tag}">
|
||
<span class="tag-name">${t.tag}</span>
|
||
<span class="tag-count">${t.count}</span>
|
||
</div>`
|
||
).join('');
|
||
|
||
rail.querySelectorAll('.tag-item').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
state.activeTag = el.dataset.tag || null;
|
||
loadEntities();
|
||
renderTagRail();
|
||
});
|
||
});
|
||
}
|
||
|
||
function groupByDate(entities) {
|
||
const groups = [];
|
||
let current = null;
|
||
for (const e of entities) {
|
||
const label = formatDate(e.created_at);
|
||
if (!current || current.label !== label) {
|
||
if (current) groups.push(current);
|
||
current = { label, entities: [] };
|
||
}
|
||
current.entities.push(e);
|
||
}
|
||
if (current) groups.push(current);
|
||
return groups;
|
||
}
|
||
|
||
function renderEntityList() {
|
||
const list = $('#entity-list');
|
||
|
||
if (state.entities.length === 0) {
|
||
list.innerHTML = '<div class="detail-empty" style="margin-top:40px">no entities yet</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
if (state.view === 'stream') {
|
||
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 {
|
||
state.entities.forEach((e, idx) => {
|
||
html += renderEntityItem(e, idx);
|
||
});
|
||
}
|
||
|
||
list.innerHTML = html;
|
||
|
||
list.querySelectorAll('.entity-item').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
selectEntity(parseInt(el.dataset.index));
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderEntityItem(e, idx) {
|
||
const glyph = displayGlyph(e);
|
||
const gc = glyphClass(e);
|
||
const selected = idx === state.selectedIndex ? 'selected' : '';
|
||
const tags = (e.tags || []).map(t => `<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>` : '';
|
||
|
||
return `<div class="entity-item ${selected}" data-index="${idx}" data-id="${e.id}">
|
||
<span class="entity-glyph ${gc}">${glyph}</span>
|
||
<span class="entity-body">${escHtml(e.body)}</span>
|
||
${time}
|
||
<span class="entity-tags">${tags}</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" 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 danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
|
||
|
||
pane.innerHTML = `
|
||
<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>
|
||
<div class="detail-body">${escHtml(e.body)}</div>
|
||
${tags ? `<div class="detail-tags">${tags}</div>` : ''}
|
||
${cardContent}
|
||
<div class="detail-actions">${actions}</div>
|
||
`;
|
||
}
|
||
|
||
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) {
|
||
return `<div style="margin-bottom:12px">
|
||
<button class="action-btn" onclick="window.open('${escAttr(data.url)}', '_blank')">open link</button>
|
||
</div>`;
|
||
}
|
||
return '';
|
||
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
// ========== Actions ==========
|
||
|
||
function selectEntity(idx) {
|
||
state.selectedIndex = idx;
|
||
renderEntityList();
|
||
renderDetailPane();
|
||
}
|
||
|
||
async function loadEntities() {
|
||
const params = {};
|
||
if (state.activeTag) params.tag = state.activeTag;
|
||
if (state.view === 'cards') {
|
||
params.cards_only = true;
|
||
params.sort = 'use_count';
|
||
params.order = 'desc';
|
||
} else {
|
||
params.sort = 'created';
|
||
params.order = 'desc';
|
||
}
|
||
|
||
state.entities = await api.listEntities(params);
|
||
state.selectedIndex = -1;
|
||
renderEntityList();
|
||
renderDetailPane();
|
||
}
|
||
|
||
async function loadTags() {
|
||
state.tags = await api.listTags();
|
||
renderTagRail();
|
||
}
|
||
|
||
function switchView(view) {
|
||
state.view = view;
|
||
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
|
||
window.location.hash = view === 'cards' ? '/cards' : '/';
|
||
loadEntities();
|
||
}
|
||
|
||
// ========== Public API (for inline handlers) ==========
|
||
|
||
window.nibApp = {
|
||
async copyEntity(id) {
|
||
const e = state.entities.find(x => x.id === id);
|
||
if (!e) return;
|
||
try {
|
||
await navigator.clipboard.writeText(e.body);
|
||
await api.useEntity(id);
|
||
await loadEntities();
|
||
} catch (err) {
|
||
console.error('clipboard:', err);
|
||
}
|
||
},
|
||
|
||
showPromote(id) {
|
||
const e = state.entities.find(x => x.id === id);
|
||
if (!e || e.card_type) return;
|
||
|
||
const modal = $('#promote-modal');
|
||
modal.classList.remove('hidden');
|
||
modal.classList.add('visible');
|
||
modal.dataset.entityId = id;
|
||
|
||
const suggested = detectCardType(e.body);
|
||
$$('.type-btn').forEach(btn => {
|
||
btn.classList.toggle('suggested', btn.dataset.type === suggested);
|
||
});
|
||
},
|
||
|
||
async demoteEntity(id) {
|
||
await api.demoteEntity(id);
|
||
await loadEntities();
|
||
await loadTags();
|
||
},
|
||
|
||
async deleteEntity(id) {
|
||
await api.deleteEntity(id);
|
||
await loadEntities();
|
||
await loadTags();
|
||
},
|
||
|
||
async resolveTemplate(id) {
|
||
const e = state.entities.find(x => x.id === id);
|
||
if (!e) return;
|
||
let resolved = e.body;
|
||
$$('.slot-input').forEach(input => {
|
||
const name = input.dataset.slot;
|
||
const val = input.value || input.placeholder;
|
||
resolved = resolved.replace(new RegExp('\\$\\{' + name + '\\}', 'g'), val);
|
||
});
|
||
try {
|
||
await navigator.clipboard.writeText(resolved);
|
||
await api.useEntity(id);
|
||
await loadEntities();
|
||
} catch (err) {
|
||
console.error('clipboard:', err);
|
||
}
|
||
},
|
||
|
||
async toggleStep(id, stepIdx) {
|
||
const e = state.entities.find(x => x.id === id);
|
||
if (!e || !e.card_data) return;
|
||
const data = JSON.parse(e.card_data);
|
||
data.steps[stepIdx].done = !data.steps[stepIdx].done;
|
||
await api.updateEntity(id, { card_data: JSON.stringify(data) });
|
||
await loadEntities();
|
||
selectEntity(state.entities.findIndex(x => x.id === id));
|
||
},
|
||
};
|
||
|
||
// ========== Capture bar ==========
|
||
|
||
$('#capture-bar').addEventListener('submit', async (ev) => {
|
||
ev.preventDefault();
|
||
const input = $('#capture-input');
|
||
const val = input.value.trim();
|
||
if (!val) return;
|
||
|
||
const parsed = parseInput(val);
|
||
if (!parsed) return;
|
||
|
||
const data = {
|
||
body: parsed.body,
|
||
glyph: parsed.glyph,
|
||
tags: parsed.tags,
|
||
};
|
||
if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor;
|
||
if (parsed.cardSuffix) data.card_type = parsed.cardSuffix;
|
||
|
||
await api.createEntity(data);
|
||
input.value = '';
|
||
await loadEntities();
|
||
await loadTags();
|
||
});
|
||
|
||
// ========== Promote modal ==========
|
||
|
||
$$('.type-btn').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
const modal = $('#promote-modal');
|
||
const id = modal.dataset.entityId;
|
||
modal.classList.add('hidden');
|
||
modal.classList.remove('visible');
|
||
|
||
await api.promoteEntity(id, btn.dataset.type);
|
||
await loadEntities();
|
||
await loadTags();
|
||
});
|
||
});
|
||
|
||
$('.modal-backdrop').addEventListener('click', closeModal);
|
||
$('.modal-close').addEventListener('click', closeModal);
|
||
|
||
function closeModal() {
|
||
const modal = $('#promote-modal');
|
||
modal.classList.add('hidden');
|
||
modal.classList.remove('visible');
|
||
}
|
||
|
||
// ========== Keyboard shortcuts ==========
|
||
|
||
let lastDTime = 0;
|
||
const captureInput = $('#capture-input');
|
||
|
||
document.addEventListener('keydown', (ev) => {
|
||
if (document.activeElement === captureInput) {
|
||
if (ev.key === 'Escape') captureInput.blur();
|
||
return;
|
||
}
|
||
|
||
if ($('#promote-modal').classList.contains('visible')) {
|
||
if (ev.key === 'Escape') closeModal();
|
||
return;
|
||
}
|
||
|
||
switch (ev.key) {
|
||
case 'j':
|
||
ev.preventDefault();
|
||
selectEntity(Math.min(state.selectedIndex + 1, state.entities.length - 1));
|
||
scrollSelectedIntoView();
|
||
break;
|
||
case 'k':
|
||
ev.preventDefault();
|
||
selectEntity(Math.max(state.selectedIndex - 1, 0));
|
||
scrollSelectedIntoView();
|
||
break;
|
||
case 'n':
|
||
ev.preventDefault();
|
||
captureInput.focus();
|
||
break;
|
||
case 'p': {
|
||
const e = state.entities[state.selectedIndex];
|
||
if (e && !e.card_type) nibApp.showPromote(e.id);
|
||
break;
|
||
}
|
||
case 'Enter': {
|
||
const e = state.entities[state.selectedIndex];
|
||
if (e) nibApp.copyEntity(e.id);
|
||
break;
|
||
}
|
||
case 'd': {
|
||
const now = Date.now();
|
||
if (now - lastDTime < 400) {
|
||
const e = state.entities[state.selectedIndex];
|
||
if (e) nibApp.deleteEntity(e.id);
|
||
lastDTime = 0;
|
||
} else {
|
||
lastDTime = now;
|
||
}
|
||
break;
|
||
}
|
||
case '1': switchView('stream'); break;
|
||
case '2': switchView('cards'); break;
|
||
}
|
||
});
|
||
|
||
function scrollSelectedIntoView() {
|
||
const el = $(`.entity-item[data-index="${state.selectedIndex}"]`);
|
||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
|
||
// ========== View nav buttons ==========
|
||
|
||
$$('.nav-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => switchView(btn.dataset.view));
|
||
});
|
||
|
||
// ========== Hash routing ==========
|
||
|
||
function handleHash() {
|
||
const hash = window.location.hash;
|
||
if (hash === '#/cards') {
|
||
state.view = 'cards';
|
||
} else {
|
||
state.view = 'stream';
|
||
}
|
||
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === state.view));
|
||
loadEntities();
|
||
}
|
||
|
||
window.addEventListener('hashchange', handleHash);
|
||
|
||
// ========== Utils ==========
|
||
|
||
function escHtml(s) {
|
||
if (!s) return '';
|
||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
function escAttr(s) {
|
||
return escHtml(s).replace(/'/g, ''');
|
||
}
|
||
|
||
// ========== Init ==========
|
||
|
||
async function init() {
|
||
await Promise.all([loadEntities(), loadTags()]);
|
||
handleHash();
|
||
}
|
||
|
||
init();
|
||
})();
|