feat(web): add vanilla JS/CSS SPA with embed.FS
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.
This commit is contained in:
+597
@@ -0,0 +1,597 @@
|
||||
(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();
|
||||
})();
|
||||
Reference in New Issue
Block a user