(function () {
'use strict';
const GLYPHS = {
note: '—', todo: '○', event: '◇', reminder: '△',
snippet: '◆', template: '◈', checklist: '☐',
decision: '⚖', link: '↗', note: '¶',
};
const GLYPH_CLASSES = {
note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event', reminder: 'glyph-reminder',
snippet: 'glyph-snippet', template: 'glyph-template',
checklist: 'glyph-checklist', decision: 'glyph-decision',
link: 'glyph-link', note: 'glyph-note',
};
const PAGE_SIZE = 50;
const INTENT_HINTS = { grab: 'scan + copy', read: 'expand + study', fill: 'templates only' };
const READ_TYPES = ['note', 'link', 'decision'];
const FILL_TYPES = ['template', 'checklist'];
const GRAB_TYPES = ['snippet'];
const state = {
view: 'stream',
entities: [],
tags: [],
selectedIndex: -1,
activeTag: null,
hasMore: false,
activeMonth: null,
intent: 'grab',
flashId: null,
peekMode: 'preview',
runChecked: new Set(),
fillValues: {},
fillActive: 0,
searchQuery: '',
cardsSort: 'newest',
};
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(params = {}) {
const q = new URLSearchParams();
if (params.cards_only) q.set('cards_only', 'true');
const resp = await fetch('/api/tags?' + q);
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', note: 'note', n: 'note' };
function validateTime(s) {
const parts = s.split(':');
if (parts.length !== 2) return false;
const h = parseInt(parts[0], 10), m = parseInt(parts[1], 10);
return !isNaN(h) && !isNaN(m) && h >= 0 && h <= 23 && m >= 0 && m <= 59;
}
function parseInput(input) {
input = input.trim();
if (!input) return null;
let glyph = 'note';
let remaining = input;
let timeAnchor = null, cardSuffix = null, pin = false, query = false;
const tags = [], seenTags = {}, filterTags = [];
// Step 1: Escape check — `\` prefix → thought, skip prefix detection
if (remaining.startsWith('\\')) {
remaining = remaining.slice(1);
const result = extractModifiers(remaining, true);
if (!result.body) return null;
return { body: result.body, glyph: 'note', title: null, description: null, timeAnchor: result.timeAnchor, tags: result.tags, cardSuffix: result.cardSuffix, pin: result.pin, query: false, filterTags: [] };
}
// Step 2: Query check — `?` prefix → search mode
if (remaining.startsWith('?')) {
remaining = remaining.slice(1).trim();
const tokens = remaining.split(/\s+/).filter(Boolean);
const bodyParts = [];
for (const tok of tokens) {
if (tok.startsWith('#') && tok.length > 1 && !tok.startsWith('##')) {
filterTags.push(tok.slice(1).toLowerCase());
} else {
bodyParts.push(tok);
}
}
return { body: bodyParts.join(' '), glyph: '', title: null, description: null, timeAnchor: null, tags: [], cardSuffix: null, pin: false, query: true, filterTags };
}
// Step 3: Kind prefix — `-`, `@time`, `!time`
if (remaining.startsWith('- ')) {
glyph = 'todo';
remaining = remaining.slice(2).trim();
} else if (remaining === '-') {
glyph = 'todo';
remaining = '';
} else if (remaining.startsWith('@')) {
const afterAt = remaining.slice(1).trim();
const sp = afterAt.indexOf(' ');
const timeTok = sp >= 0 ? afterAt.slice(0, sp) : afterAt;
if (validateTime(timeTok)) {
glyph = 'event';
timeAnchor = timeTok;
remaining = sp >= 0 ? afterAt.slice(sp + 1).trim() : '';
}
} else if (remaining.startsWith('!')) {
const afterBang = remaining.slice(1).trim();
const firstWord = afterBang.split(/\s+/)[0] || '';
if (firstWord.toLowerCase() !== 'pin') {
const sp = afterBang.indexOf(' ');
const timeTok = sp >= 0 ? afterBang.slice(0, sp) : afterBang;
if (validateTime(timeTok)) {
glyph = 'reminder';
timeAnchor = timeTok;
remaining = sp >= 0 ? afterBang.slice(sp + 1).trim() : '';
}
}
}
// Steps 4-5: Title and description extraction
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');
}
}
}
// Steps 6-8: Extract flags, tags, time, card suffix
function extractModifiers(text, handleFlags) {
let localTime = timeAnchor, localPin = pin, localCard = cardSuffix;
const localTags = [...tags];
const localSeen = { ...seenTags };
const outLines = [];
for (const line of text.split('\n')) {
const tokens = line.split(/[ \t]+/).filter(Boolean);
const lineParts = [];
for (const tok of tokens) {
if (handleFlags && tok.toLowerCase() === '!pin') {
localPin = true;
} else if (tok.startsWith('##') && tok.length > 2) {
lineParts.push('#' + tok.slice(2));
} else if (tok.startsWith('@') && tok.length > 1) {
const ts = tok.slice(1);
if (validateTime(ts) && localTime === null) {
localTime = ts;
} else {
lineParts.push(tok);
}
} else if (tok.startsWith('#') && tok.length > 1) {
const tag = tok.slice(1).toLowerCase();
if (!localSeen[tag]) { localTags.push(tag); localSeen[tag] = true; }
} else if (tok.startsWith('^') && tok.length > 1) {
const suffix = tok.slice(1);
if (VALID_CARDS[suffix] && localCard === null) localCard = VALID_CARDS[suffix];
else lineParts.push(tok);
} else {
lineParts.push(tok);
}
}
outLines.push(lineParts.join(' '));
}
return { body: outLines.join('\n'), timeAnchor: localTime, tags: localTags, seen: localSeen, cardSuffix: localCard, pin: localPin };
}
let title = null, description = null;
if (hasTitle) {
const r = extractModifiers(titleRaw || '', false);
if (r.body) title = r.body;
timeAnchor = r.timeAnchor; Object.assign(seenTags, r.seen); tags.length = 0; tags.push(...r.tags); cardSuffix = r.cardSuffix; pin = r.pin;
}
if (descRaw) {
const r = extractModifiers(descRaw, false);
if (r.body) description = r.body;
timeAnchor = r.timeAnchor; Object.assign(seenTags, r.seen); tags.length = 0; tags.push(...r.tags); cardSuffix = r.cardSuffix; pin = r.pin;
}
const bodyResult = extractModifiers(remaining, true);
const body = bodyResult.body;
timeAnchor = bodyResult.timeAnchor; tags.length = 0; tags.push(...bodyResult.tags); cardSuffix = bodyResult.cardSuffix; pin = bodyResult.pin;
if (!body && !title) return null;
return { body, glyph, title, description, timeAnchor, tags, cardSuffix, pin, query: false, filterTags: [] };
}
function detectCardType(body) {
if (/\$\{.+\}/.test(body)) return 'template';
if (/\bchose:|why:/.test(body)) return 'decision';
if (/\[ \]|\d+\./.test(body)) return 'checklist';
if (/https?:\/\//.test(body)) return 'link';
return null;
}
function detectAffordances(entity) {
const affs = [];
const body = entity.body || '';
const data = entity.card_data ? (() => { try { return JSON.parse(entity.card_data); } catch { return {}; } })() : {};
if (data.lang || entity.card_type === 'snippet') affs.push('code');
if (/\$\{[^}]+\}/.test(body)) affs.push('fill');
if (data.steps && data.steps.length) affs.push('steps');
if (data.chose != null || entity.card_type === 'decision') affs.push('decide');
if (data.url || entity.card_type === 'link') affs.push('link');
return affs;
}
const AFF_LABELS = { code: 'code', fill: 'tpl', steps: 'steps', decide: 'dec', link: 'link' };
const AFF_CLASSES = { code: 'aff-code', fill: 'aff-fill', steps: 'aff-steps', decide: 'aff-decide', link: 'aff-link' };
function cardPreview(entity) {
const data = entity.card_data ? (() => { try { return JSON.parse(entity.card_data); } catch { return {}; } })() : {};
if (data.chose) return `▸ ${escHtml(data.chose)} `;
if (data.steps && data.steps.length) {
const done = data.steps.filter(s => s.done).length;
const total = data.steps.length;
const pct = total > 0 ? Math.round(done / total * 100) : 0;
return ` ${done}/${total} steps`;
}
if (/\$\{[^}]+\}/.test(entity.body || '')) {
const slots = [];
const re = /\$\{([^}]+)\}/g;
let m;
while ((m = re.exec(entity.body)) && slots.length < 2) slots.push(m[1]);
return slots.map(s => `\${${escHtml(s)}} `).join(' ');
}
if (data.url) return `${escHtml(data.url.replace(/^https?:\/\//, ''))} `;
const first = (entity.body || '').split('\n')[0] || '';
return escHtml(first.slice(0, 60));
}
// ========== Rendering ==========
function displayGlyph(entity) {
return entity.card_type ? GLYPHS[entity.card_type] : GLYPHS[entity.glyph];
}
function glyphClass(entity) {
return entity.card_type ? GLYPH_CLASSES[entity.card_type] : GLYPH_CLASSES[entity.glyph];
}
function formatDate(dateStr) {
const d = new Date(dateStr);
const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
return months[d.getMonth()] + ' ' + d.getDate();
}
// ── Tag Rail ──
function renderTagRail() {
const rail = $('#tag-rail');
const total = state.tags.reduce((s, t) => s + t.count, 0);
let html = `
nib
`;
html += '';
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();
renderEntityList();
});
});
}
// ── Capture Bar ──
function renderCaptureBar() {
const bar = $('#capture-bar');
const placeholder = state.view === 'stream'
? 'capture · - todo @time event !time reminder #tag |title'
: '|title // desc #tag ${slot} 1. step';
bar.innerHTML = `
›
⏎ save
`;
const input = $('#capture-input');
function autoResize() {
input.style.height = 'auto';
input.style.height = input.scrollHeight + 'px';
}
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
handleCapture();
}
});
input.addEventListener('input', () => { autoResize(); updateCapturePreview(input.value); });
}
function updateCapturePreview(val) {
const el = $('#cap-preview');
if (!el) return;
val = val.trim();
if (!val) { el.innerHTML = ''; el.classList.remove('visible'); return; }
const parsed = parseInput(val);
if (!parsed) { el.innerHTML = ''; el.classList.remove('visible'); return; }
const pills = [];
if (parsed.query) {
pills.push('search ');
} else {
pills.push(`${escHtml(parsed.glyph)} `);
}
if (parsed.title) pills.push(`|${escHtml(parsed.title)} `);
if (parsed.description) pills.push(`${escHtml(parsed.description)} `);
for (const t of (parsed.query ? parsed.filterTags : parsed.tags)) {
pills.push(`#${escHtml(t)} `);
}
if (parsed.timeAnchor) pills.push(`@${escHtml(parsed.timeAnchor)} `);
if (parsed.pin) pills.push('pin ');
if (parsed.cardSuffix) pills.push(`^${escHtml(parsed.cardSuffix)} `);
el.innerHTML = pills.join('');
el.classList.add('visible');
}
async function handleCapture() {
const input = $('#capture-input');
const val = input.value.trim();
if (!val) return;
const parsed = parseInput(val);
if (!parsed) return;
// Query mode → switch to search
if (parsed.query) {
state.searchQuery = parsed.body;
const searchInput = $('#search-input');
if (searchInput) searchInput.value = parsed.body + (parsed.filterTags.length ? ' ' + parsed.filterTags.map(t => '#' + t).join(' ') : '');
input.value = '';
input.style.height = 'auto';
updateCapturePreview('');
renderEntityList();
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;
if (parsed.pin) data.pinned = true;
await api.createEntity(data);
input.value = '';
input.style.height = 'auto';
updateCapturePreview('');
await loadEntities();
await loadTags();
showToast('captured');
}
// ── Entity List ──
function groupByDate(entities) {
const groups = [];
let current = null;
for (const e of entities) {
const label = formatDate(e.created_at);
if (!current || current.label !== label) {
if (current) groups.push(current);
current = { label, entities: [] };
}
current.entities.push(e);
}
if (current) groups.push(current);
return groups;
}
function renderEntityList() {
const list = $('#entity-list');
const filtered = filterBySearch(state.entities);
if (filtered.length === 0) {
list.innerHTML = `${state.searchQuery ? 'no matches' : 'no entities yet'}
`;
renderCardsHeader(state.view === 'cards');
return;
}
let html = '';
if (state.view === 'stream') {
renderCardsHeader(false);
const groups = groupByDate(filtered);
let idx = 0;
for (const g of groups) {
html += ``;
for (const e of g.entities) {
const realIdx = state.entities.indexOf(e);
html += renderEntityItem(e, realIdx);
idx++;
}
}
} else {
renderCardsHeader(true);
const pinned = filtered.filter(e => e.pinned);
const rest = filtered.filter(e => !e.pinned);
if (pinned.length) {
html += '★ pinned
';
for (const e of pinned) {
html += renderCardRow(e, state.entities.indexOf(e));
}
}
if (rest.length) {
if (pinned.length) html += 'recent
';
for (const e of rest) {
html += renderCardRow(e, state.entities.indexOf(e));
}
}
}
if (state.hasMore) {
html += 'load more
';
}
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;
const sorts = ['newest', 'oldest', 'most used'];
const options = sorts.map(s => `${s} `).join('');
hdr.innerHTML = `
${scope}
${state.entities.length} cards
${options}
`;
hdr.querySelector('.cards-sort').addEventListener('change', (ev) => {
state.cardsSort = ev.target.value;
loadEntities();
});
}
function renderCardRow(e, idx) {
const selected = idx === state.selectedIndex ? ' selected' : '';
const pinCls = e.pinned ? ' pinned' : '';
const flashCls = state.flashId === e.id ? ' flashing' : '';
const title = e.title || (e.body || '').split('\n')[0].slice(0, 50);
const affs = detectAffordances(e);
const preview = cardPreview(e);
const tags = (e.tags || []).slice(0, 2).map(t => `#${t} `).join('');
const affHtml = affs.map(a => `${AFF_LABELS[a]} `).join('');
return `
${escHtml(title)}
—
${preview}
${affHtml}
${tags}
${e.pinned ? '★ ' : ''}
${e.use_count > 0 ? `${e.use_count}× ` : ''}
`;
}
function renderInlineDetail(e) {
const tags = (e.tags || []).map(t => `#${t} `).join('');
let actions = '';
actions += `edit `;
if (!e.card_type) {
actions += `absorb `;
actions += `promote `;
}
if (e.card_type) {
actions += `copy `;
actions += `demote `;
} else {
actions += `delete `;
}
let content = '';
if (e.card_type) {
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
const hasDecision = data.chose != null;
const hasSteps = data.steps && data.steps.length;
const hasLink = !!data.url;
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
if (hasDecision) {
const rejected = (data.rejected || []).map(r => `${escHtml(r)} `).join('');
content += `
decision${data.status || 'decided'}
${escHtml(data.chose)}
why ${escHtml(data.why || '')}
${rejected ? `
` : ''}
`;
}
if (hasLink && !hasDecision) {
content += ``;
}
if (hasSteps) {
const steps = data.steps.map(s => `○ ${escHtml(s.text || s)}
`).join('');
content += `
steps · ${data.steps.length}▶ run
`;
actions += `run `;
}
if (hasFill) {
actions += `fill `;
}
if (!hasDecision && e.body) {
const lang = data.lang || '';
const isCode = lang || e.card_type === 'snippet';
const bodyHtml = isCode
? ``
: `${renderMd(e.body)}
`;
content += `
content${lang ? `${lang} ` : ''}
${bodyHtml}
`;
}
} else {
content = `${renderMd(e.body || '')}
`;
}
return `
${content}
${tags ? `
${tags}
` : ''}
${actions}
↑
×
`;
}
function renderEntityItem(e, idx) {
const glyph = displayGlyph(e);
const gc = glyphClass(e);
const selected = idx === state.selectedIndex ? ' selected' : '';
const isCard = e.card_type ? ' is-card' : '';
const tags = (e.tags || []).slice(0, 2).map(t => `${t} `).join('');
const time = e.time_anchor ? `@${e.time_anchor} ` : '';
const useBadge = e.use_count > 0 ? `${e.use_count}× ` : '';
const cardBadge = e.card_type ? `${e.card_type} ` : '';
let label;
const descSnip = e.description ? `${escHtml(e.description)} ` : '';
if (e.title) {
const preview = e.body ? `${escHtml(e.body)} ` : '';
label = `${escHtml(e.title)} ${descSnip}${preview} `;
} else {
label = `${escHtml(e.body)} ${descSnip} `;
}
return `
${glyph}
${label}
${time}
${tags}${cardBadge}
${useBadge}
`;
}
function fmtDateLong(dateStr) {
const d = new Date(dateStr);
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()} · ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function renderDetailPane() {
const pane = $('#detail-pane');
const e = state.entities[state.selectedIndex];
if (!e) {
pane.innerHTML = renderPeekIdle();
pane.classList.remove('visible');
return;
}
pane.classList.add('visible');
if (state.peekMode === 'edit') {
pane.innerHTML = renderEditMode(e);
} else if (state.view === 'stream' || !e.card_type) {
pane.innerHTML = renderStreamPeek(e);
} else if (state.peekMode === 'run') {
pane.innerHTML = renderRunMode(e);
} else if (state.peekMode === 'fill') {
pane.innerHTML = renderFillMode(e);
} else {
pane.innerHTML = renderCardPeek(e);
}
bindPeekEvents(e);
}
function renderPeekIdle() {
const v = state.view;
return `
peek
Select ${v === 'cards' ? 'a card' : 'an entry'}.
${v === 'cards'
? 'Full detail lives here. Run checklists, fill templates, edit in place.'
: 'Entry detail lives here. Promote any capture to a card when it earns a permanent home.'}
navigate
j k next / prev
1 2 stream / cards
${v === 'stream' ? `
stream grammar
(bare text) = thought
- todo · @time event · !time reminder
#tag · |title · // desc · !pin
` : `
act
⏎ copy
r run checklist
f fill template
e edit
p pin
`}
`;
}
function renderStreamPeek(e) {
const kind = e.card_type || e.glyph;
const glyph = displayGlyph(e);
const gc = glyphClass(e);
const kindLbl = { note: 'thought', todo: 'todo', event: 'event', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' }[kind] || kind;
const tags = (e.tags || []).map(t => `#${t} `).join('');
let actions = '';
actions += `edit e `;
if (!e.card_type) {
actions += `absorb a `;
actions += `promote → `;
}
if (e.card_type) {
actions += `demote `;
} else {
actions += `delete `;
}
return ``;
}
function renderCardPeek(e) {
const glyph = GLYPHS[e.card_type] || '◆';
const gc = GLYPH_CLASSES[e.card_type] || 'glyph-snippet';
const affs = detectAffordances(e);
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
const tags = (e.tags || []).map(t => `#${t} `).join('');
const affHtml = affs.map(a => `${AFF_LABELS[a]} `).join('');
const hasSteps = data.steps && data.steps.length;
const hasDecision = data.chose != null;
const hasFill = /\$\{[^}]+\}/.test(e.body || '');
const hasLink = !!data.url;
let sections = '';
if (hasDecision) {
const rejected = (data.rejected || []).map(r => `${escHtml(r)} `).join('');
sections += `
decision${data.status || 'decided'}
${escHtml(data.chose)}
why ${escHtml(data.why || '')}
${rejected ? `
` : ''}
`;
}
if (hasLink && !hasDecision) {
sections += ``;
}
if (hasSteps) {
const steps = data.steps.map((s, i) => `○ ${escHtml(s.text || s)}
`).join('');
sections += `
steps · ${data.steps.length}▶ run
`;
}
if (!hasDecision && e.body) {
const lang = data.lang || '';
const isCode = lang || e.card_type === 'snippet';
const bodyHtml = isCode
? ``
: `${renderMd(e.body)}
`;
sections += `
content${lang ? `${lang} ` : ''}${hasFill ? `⤓ fill ` : ''}
${bodyHtml}
`;
}
let actions = `copy ⏎ `;
if (hasFill) actions += `fill f `;
if (hasSteps) actions += `run r `;
actions += `edit e `;
actions += `${e.pinned ? 'unpin' : 'pin'} p `;
actions += `demote `;
return ``;
}
function renderRunMode(e) {
const data = e.card_data ? (() => { try { return JSON.parse(e.card_data); } catch { return {}; } })() : {};
if (!data.steps) return renderCardPeek(e);
const total = data.steps.length;
const checked = state.runChecked || new Set();
const done = checked.size;
const pct = total > 0 ? Math.round(done / total * 100) : 0;
const steps = data.steps.map((s, i) => {
const isDone = checked.has(i);
const text = s.text || s;
return `
${isDone ? '●' : '○'}
${escHtml(text)}
`;
}).join('');
return ``;
}
function renderFillMode(e) {
const slots = [];
const re = /\$\{([^}]+)\}/g;
let m;
const seen = new Set();
while ((m = re.exec(e.body || '')) !== null) {
const name = m[1].trim();
if (!seen.has(name)) { seen.add(name); slots.push(name); }
}
if (!slots.length) return renderCardPeek(e);
const fill = state.fillValues || {};
const active = state.fillActive || 0;
let content = escHtml(e.body);
for (const name of slots) {
const val = fill[name] || '';
const idx = slots.indexOf(name);
const cls = idx === active ? 'fill-slot active' : (val ? 'fill-slot filled' : 'fill-slot');
const width = Math.max(name.length, val.length, 4) * 8 + 16;
content = content.replace(`\${${name}}`, ` `);
}
const allFilled = slots.every(s => fill[s]);
return ``;
}
function renderEditMode(e) {
return ``;
}
function bindPeekEvents(e) {
const pane = $('#detail-pane');
if (!e) return;
if (state.peekMode === 'run') {
pane.querySelectorAll('.peek-run-step').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.step);
if (!state.runChecked) state.runChecked = new Set();
if (state.runChecked.has(idx)) state.runChecked.delete(idx);
else state.runChecked.add(idx);
renderDetailPane();
});
});
}
if (state.peekMode === 'fill') {
pane.querySelectorAll('.fill-slot input').forEach(input => {
input.addEventListener('input', () => {
if (!state.fillValues) state.fillValues = {};
state.fillValues[input.dataset.slot] = input.value;
});
input.addEventListener('focus', () => {
state.fillActive = parseInt(input.dataset.idx);
});
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Tab') {
ev.preventDefault();
const slots = pane.querySelectorAll('.fill-slot input');
const cur = parseInt(input.dataset.idx);
const next = ev.shiftKey ? Math.max(0, cur - 1) : Math.min(slots.length - 1, cur + 1);
state.fillActive = next;
renderDetailPane();
setTimeout(() => {
const el = pane.querySelector(`.fill-slot input[data-idx="${next}"]`);
if (el) el.focus();
}, 0);
} else if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
nibApp.completeFill();
} else if (ev.key === 'Escape') {
ev.preventDefault();
nibApp.exitMode();
}
});
});
setTimeout(() => {
const el = pane.querySelector(`.fill-slot input[data-idx="${state.fillActive || 0}"]`);
if (el) el.focus();
}, 0);
}
if (state.peekMode === 'edit') {
const bodyTa = pane.querySelector('#edit-body');
if (bodyTa) {
bodyTa.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && (ev.metaKey || ev.ctrlKey)) { ev.preventDefault(); nibApp.saveEdit(e.id); }
if (ev.key === 'Escape') { ev.preventDefault(); nibApp.exitMode(); }
});
}
}
// Double-click to edit (stream peek)
const titleEl = pane.querySelector('.peek-title[data-id]');
if (titleEl) titleEl.addEventListener('dblclick', () => startEditField('title'));
const bodyEl = pane.querySelector('.peek-body[data-id]');
if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody);
}
// ========== Inline edit ==========
function startEditBody() {
const e = state.entities[state.selectedIndex];
if (!e) return;
const el = $(`.peek-body[data-id="${e.id}"]`);
if (!el || el.tagName === 'TEXTAREA') return;
const ta = document.createElement('textarea');
ta.className = 'detail-body-edit';
ta.value = e.body;
el.replaceWith(ta);
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
async function save() {
const newBody = ta.value.trim();
if (newBody && newBody !== e.body) {
await api.updateEntity(e.id, { body: newBody });
await loadEntities();
const idx = state.entities.findIndex(x => x.id === e.id);
if (idx >= 0) selectEntity(idx);
} else {
renderDetailPane();
}
}
ta.addEventListener('blur', save);
ta.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && ev.ctrlKey) { ev.preventDefault(); ta.removeEventListener('blur', save); save(); }
if (ev.key === 'Escape') { ev.preventDefault(); ta.removeEventListener('blur', save); renderDetailPane(); }
});
}
function startEditField(field) {
const e = state.entities[state.selectedIndex];
if (!e) return;
const el = $(`.peek-title[data-id="${e.id}"]`);
if (!el || el.tagName === 'INPUT') return;
const input = document.createElement('input');
input.type = 'text';
input.className = 'detail-field-edit';
input.value = e[field] || '';
input.placeholder = field;
el.replaceWith(input);
input.focus();
async function save() {
const val = input.value.trim();
if (val !== (e[field] || '')) {
await api.updateEntity(e.id, { [field]: val || null });
await loadEntities();
const idx = state.entities.findIndex(x => x.id === e.id);
if (idx >= 0) selectEntity(idx);
} else {
renderDetailPane();
}
}
input.addEventListener('blur', save);
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') { ev.preventDefault(); input.removeEventListener('blur', save); save(); }
if (ev.key === 'Escape') { ev.preventDefault(); input.removeEventListener('blur', save); renderDetailPane(); }
});
}
// ========== Actions ==========
function selectEntity(idx) {
if (isMobileBreakpoint()) {
const prev = state.selectedIndex;
if (prev === idx) {
state.selectedIndex = -1;
} else {
state.selectedIndex = idx;
}
$$('.entity-item.selected, .card-row.selected').forEach(el => el.classList.remove('selected'));
if (state.selectedIndex >= 0) {
const target = $(`.entity-item[data-index="${state.selectedIndex}"], .card-row[data-index="${state.selectedIndex}"]`);
if (target) target.classList.add('selected');
}
return;
}
state.selectedIndex = idx;
state.peekMode = 'preview';
state.runChecked = new Set();
state.fillValues = {};
state.fillActive = 0;
renderEntityList();
renderDetailPane();
}
function buildListParams(offset) {
const params = { limit: PAGE_SIZE, offset: offset || 0 };
if (state.activeTag) params.tag = state.activeTag;
if (state.view === 'cards') {
params.cards_only = true;
if (state.cardsSort === 'oldest') {
params.sort = 'created';
params.order = 'asc';
} else if (state.cardsSort === 'most used') {
params.sort = 'use_count';
params.order = 'desc';
} else {
params.sort = 'created';
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();
renderTagRail();
}
async function loadMore() {
const params = buildListParams(state.entities.length);
const results = await api.listEntities(params);
state.entities = state.entities.concat(results);
state.hasMore = results.length === PAGE_SIZE;
renderEntityList();
}
function renderMonthNav() {
const nav = $('#month-nav');
if (state.view !== 'stream') { nav.innerHTML = ''; nav.style.display = 'none'; return; }
nav.style.display = '';
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const label = state.activeMonth
? (() => { const [y, m] = state.activeMonth.split('-'); return MONTHS[parseInt(m) - 1] + ' ' + y; })()
: 'all time';
nav.innerHTML = `
◂
${label}
▸
`;
$('#month-prev').addEventListener('click', () => shiftMonth(-1));
$('#month-next').addEventListener('click', () => shiftMonth(1));
}
function shiftMonth(dir) {
if (!state.activeMonth) {
const now = new Date();
state.activeMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0');
} else {
const [y, m] = state.activeMonth.split('-').map(Number);
const d = new Date(y, m - 1 + dir, 1);
state.activeMonth = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
}
loadEntities();
renderMonthNav();
}
async function loadTags() {
const params = {};
if (state.view === 'cards') params.cards_only = true;
state.tags = await api.listTags(params);
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' : '/';
renderMonthNav();
renderCaptureBar();
loadEntities();
loadTags();
}
// ========== Toast ==========
function showToast(msg) {
let el = $('.toast');
if (el) el.remove();
el = document.createElement('div');
el.className = 'toast';
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 1600);
}
// ========== Public API (for inline handlers) ==========
window.nibApp = {
async copyEntity(id) {
const e = state.entities.find(x => x.id === id);
if (!e) return;
try {
await navigator.clipboard.writeText(e.body);
await api.useEntity(id);
state.flashId = id;
await loadEntities();
showToast('copied');
setTimeout(() => { state.flashId = null; renderEntityList(); }, 360);
} catch (err) {
console.error('clipboard:', err);
}
},
showPromote(id) {
const e = state.entities.find(x => x.id === id);
if (!e || e.card_type) return;
const modal = $('#promote-modal');
modal.classList.remove('hidden');
modal.classList.add('visible');
modal.dataset.entityId = id;
const sub = $('#promote-sub');
const label = (e.body || '').slice(0, 64) + ((e.body || '').length > 64 ? '…' : '');
sub.textContent = label;
const suggested = detectCardType(e.body);
$$('.type-btn').forEach(btn => {
btn.classList.toggle('suggested', btn.dataset.type === suggested);
});
},
async demoteEntity(id) {
await api.demoteEntity(id);
await loadEntities();
await loadTags();
showToast('demoted');
},
async deleteEntity(id) {
const prevIdx = state.selectedIndex;
await api.deleteEntity(id);
await loadEntities();
await loadTags();
if (state.entities.length > 0) {
selectEntity(Math.min(prevIdx, state.entities.length - 1));
}
showToast('deleted');
},
async resolveTemplate(id) {
const e = state.entities.find(x => x.id === id);
if (!e) return;
let resolved = e.body;
$$('.slot-input').forEach(input => {
const name = input.dataset.slot;
const val = input.value || input.placeholder;
resolved = resolved.replace(new RegExp('\\$\\{' + name + '\\}', 'g'), val);
});
try {
await navigator.clipboard.writeText(resolved);
await api.useEntity(id);
await loadEntities();
showToast('copied');
} catch (err) {
console.error('clipboard:', err);
}
},
showAbsorb(targetId) {
const target = state.entities.find(x => x.id === targetId);
if (!target) return;
if (target.card_type) return;
const modal = $('#absorb-modal');
modal.dataset.targetId = targetId;
const list = $('#absorb-source-list');
const sources = state.entities.filter(x => x.id !== targetId);
if (!sources.length) { list.innerHTML = 'no other entities
'; }
else {
list.innerHTML = sources.map(e => {
const g = displayGlyph(e);
const gc = glyphClass(e);
const rawLabel = e.title || (e.body || '').split('\n').find(l => l.trim()) || '';
const label = escHtml(rawLabel.length > 80 ? rawLabel.slice(0, 80) + '…' : rawLabel);
return `
${g}
${label}
`;
}).join('');
}
list.querySelectorAll('.absorb-source-item').forEach(el => {
el.addEventListener('click', async () => {
modal.classList.add('hidden');
modal.classList.remove('visible');
await api.absorbEntity(targetId, el.dataset.id);
await loadEntities();
await loadTags();
const idx = state.entities.findIndex(x => x.id === targetId);
if (idx >= 0) selectEntity(idx);
showToast('absorbed');
});
});
modal.classList.remove('hidden');
modal.classList.add('visible');
},
async toggleStep(id, stepIdx) {
const e = state.entities.find(x => x.id === id);
if (!e || !e.card_data) return;
const data = JSON.parse(e.card_data);
data.steps[stepIdx].done = !data.steps[stepIdx].done;
await api.updateEntity(id, { card_data: JSON.stringify(data) });
await loadEntities();
selectEntity(state.entities.findIndex(x => x.id === id));
},
enterMode(mode) {
state.peekMode = mode;
if (mode === 'run') state.runChecked = new Set();
if (mode === 'fill') { state.fillValues = {}; state.fillActive = 0; }
renderDetailPane();
},
exitMode() {
state.peekMode = 'preview';
renderDetailPane();
},
resetRun() {
state.runChecked = new Set();
renderDetailPane();
},
async completeFill() {
const e = state.entities[state.selectedIndex];
if (!e) return;
let resolved = e.body || '';
const fill = state.fillValues || {};
for (const [name, val] of Object.entries(fill)) {
resolved = resolved.replace(new RegExp('\\$\\{' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\}', 'g'), val);
}
try {
await navigator.clipboard.writeText(resolved);
await api.useEntity(e.id);
state.peekMode = 'preview';
await loadEntities();
showToast('copied resolved');
} catch (err) {
console.error('clipboard:', err);
}
},
async saveEdit(id) {
const title = ($('#edit-title') || {}).value || null;
const desc = ($('#edit-desc') || {}).value || null;
const body = ($('#edit-body') || {}).value || '';
const tagsStr = ($('#edit-tags') || {}).value || '';
const tags = tagsStr.split(/\s+/).filter(Boolean);
await api.updateEntity(id, { body, title, description: desc, tags });
state.peekMode = 'preview';
await loadEntities();
await loadTags();
const idx = state.entities.findIndex(x => x.id === id);
if (idx >= 0) selectEntity(idx);
showToast('saved');
},
async togglePin(id) {
const e = state.entities.find(x => x.id === id);
if (!e) return;
await api.updateEntity(id, { pinned: !e.pinned });
await loadEntities();
const idx = state.entities.findIndex(x => x.id === id);
if (idx >= 0) { state.selectedIndex = idx; renderEntityList(); renderDetailPane(); }
showToast(e.pinned ? 'unpinned' : 'pinned');
},
togglePeekFull() {
if (isMobileBreakpoint()) {
this.expandInline();
return;
}
const pane = $('#detail-pane');
pane.classList.toggle('peek-full');
const btn = pane.querySelector('.peek-mobile-btn');
if (btn) btn.textContent = pane.classList.contains('peek-full') ? '↓' : '↑';
},
expandInline() {
const sel = $(`.entity-item.selected, .card-row.selected`);
if (!sel) return;
sel.classList.toggle('exp-full');
const btn = sel.querySelector('.exp-toolbar .peek-mobile-btn');
if (btn) btn.textContent = sel.classList.contains('exp-full') ? '↓' : '↑';
},
dismissPeek() {
if (isMobileBreakpoint()) {
const sel = $(`.entity-item.selected, .card-row.selected`);
if (sel) sel.classList.remove('selected', 'exp-full');
state.selectedIndex = -1;
return;
}
const pane = $('#detail-pane');
pane.classList.remove('visible', 'peek-full');
state.selectedIndex = -1;
renderEntityList();
},
};
// ========== Promote modal ==========
$$('.type-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const modal = $('#promote-modal');
const id = modal.dataset.entityId;
modal.classList.add('hidden');
modal.classList.remove('visible');
await api.promoteEntity(id, btn.dataset.type);
await loadEntities();
await loadTags();
showToast('promoted → ' + btn.dataset.type);
});
});
$$('.modal-backdrop').forEach(el => el.addEventListener('click', closeModal));
$$('.modal-close').forEach(el => el.addEventListener('click', closeModal));
function closeModal() {
$$('.modal.visible').forEach(m => {
m.classList.add('hidden');
m.classList.remove('visible');
});
}
// ========== Keyboard shortcuts ==========
let lastDTime = 0;
document.addEventListener('keydown', (ev) => {
const tag = (ev.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea') {
if (ev.key === 'Escape') ev.target.blur();
return;
}
if ($('#promote-modal').classList.contains('visible') ||
$('#absorb-modal').classList.contains('visible')) {
if (ev.key === 'Escape') closeModal();
return;
}
if (state.peekMode !== 'preview' && ev.key === 'Escape') {
nibApp.exitMode();
return;
}
if (ev.key === 'Escape') {
if (isMobileBreakpoint()) {
if (state.selectedIndex >= 0) {
const sel = $(`.entity-item.selected, .card-row.selected`);
if (sel) sel.classList.remove('selected', 'exp-full');
state.selectedIndex = -1;
return;
}
}
const pane = $('#detail-pane');
if ($('main').classList.contains('focus-peek')) {
exitFocusPeek();
}
if (pane.classList.contains('visible')) {
pane.classList.remove('visible', 'peek-full');
state.selectedIndex = -1;
renderEntityList();
renderDetailPane();
return;
}
}
const sel = state.entities[state.selectedIndex];
switch (ev.key) {
case 'j': {
ev.preventDefault();
const visible = filterBySearch(state.entities);
const sel = state.entities[state.selectedIndex];
const curPos = sel ? visible.indexOf(sel) : -1;
const nextPos = Math.min(curPos + 1, visible.length - 1);
if (visible.length > 0 && nextPos >= 0) {
selectEntity(state.entities.indexOf(visible[nextPos]));
}
scrollSelectedIntoView();
break;
}
case 'k': {
ev.preventDefault();
const visible = filterBySearch(state.entities);
const sel = state.entities[state.selectedIndex];
const curPos = sel ? visible.indexOf(sel) : -1;
const prevPos = Math.max(curPos - 1, 0);
if (visible.length > 0) {
selectEntity(state.entities.indexOf(visible[prevPos]));
}
scrollSelectedIntoView();
break;
}
case 'n':
ev.preventDefault();
$('#capture-input').focus();
break;
case 'p':
if (sel && sel.card_type && state.view === 'cards') {
nibApp.togglePin(sel.id);
} else if (sel && !sel.card_type) {
nibApp.showPromote(sel.id);
}
break;
case 'Enter':
if (sel) nibApp.copyEntity(sel.id);
break;
case 'r':
if (sel && sel.card_type && state.view === 'cards') nibApp.enterMode('run');
break;
case 'f':
if (sel && sel.card_type && state.view === 'cards') nibApp.enterMode('fill');
break;
case 'e':
if (sel) nibApp.enterMode('edit');
break;
case 'd': {
const now = Date.now();
if (now - lastDTime < 400) {
if (sel) {
if (sel.card_type) nibApp.demoteEntity(sel.id);
else nibApp.deleteEntity(sel.id);
}
lastDTime = 0;
} else {
lastDTime = now;
}
break;
}
case 'a':
if (sel && !sel.card_type) nibApp.showAbsorb(sel.id);
break;
case '1': switchView('stream'); break;
case '2': switchView('cards'); break;
case 'z': toggleZen(); break;
case '[': togglePanel('rail'); break;
case ']': togglePanel('peek'); break;
}
});
function togglePanel(panel) {
const m = $('main');
const cls = panel === 'rail' ? 'hide-rail' : 'hide-peek';
m.classList.toggle(cls);
localStorage.setItem('nib:' + cls, m.classList.contains(cls) ? '1' : '');
}
function isMobileBreakpoint() {
return window.matchMedia('(max-width: 900px)').matches;
}
function toggleZen() {
if (isMobileBreakpoint()) {
if (state.selectedIndex >= 0) nibApp.togglePeekFull();
return;
}
const m = $('main');
if (m.classList.contains('focus-peek')) {
exitFocusPeek();
return;
}
if (state.selectedIndex >= 0) {
m.classList.add('focus-peek');
return;
}
const isZen = m.classList.contains('hide-rail') && m.classList.contains('hide-peek');
if (isZen) {
m.classList.remove('hide-rail', 'hide-peek');
localStorage.setItem('nib:hide-rail', '');
localStorage.setItem('nib:hide-peek', '');
} else {
m.classList.add('hide-rail', 'hide-peek');
localStorage.setItem('nib:hide-rail', '1');
localStorage.setItem('nib:hide-peek', '1');
}
}
function exitFocusPeek() {
$('main').classList.remove('focus-peek');
}
(function restorePanels() {
const m = $('main');
if (localStorage.getItem('nib:hide-rail')) m.classList.add('hide-rail');
if (localStorage.getItem('nib:hide-peek')) m.classList.add('hide-peek');
const railW = localStorage.getItem('nib:rail-w');
const peekW = localStorage.getItem('nib:peek-w');
if (railW) m.style.setProperty('--rail-w', railW + 'px');
if (peekW) m.style.setProperty('--peek-w', peekW + 'px');
})();
// ========== Resize handles ==========
$$('.resize-handle').forEach(handle => {
let startX, startW, panel;
handle.addEventListener('mousedown', (ev) => {
ev.preventDefault();
panel = handle.dataset.panel;
startX = ev.clientX;
const m = $('main');
m.classList.add('resizing');
handle.classList.add('active');
if (panel === 'rail') {
startW = $('#tag-rail').offsetWidth;
} else {
startW = $('#detail-pane').offsetWidth;
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
function onMove(ev) {
const m = $('main');
const dx = ev.clientX - startX;
let newW;
if (panel === 'rail') {
newW = Math.max(120, Math.min(360, startW + dx));
m.style.setProperty('--rail-w', newW + 'px');
} else {
newW = Math.max(250, Math.min(700, startW - dx));
m.style.setProperty('--peek-w', newW + 'px');
}
}
function onUp() {
const m = $('main');
m.classList.remove('resizing');
handle.classList.remove('active');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (panel === 'rail') {
localStorage.setItem('nib:rail-w', $('#tag-rail').offsetWidth);
} else {
localStorage.setItem('nib:peek-w', $('#detail-pane').offsetWidth);
}
}
});
function scrollSelectedIntoView() {
const el = $(`.entity-item[data-index="${state.selectedIndex}"], .card-row[data-index="${state.selectedIndex}"]`);
if (el) el.scrollIntoView({ block: 'nearest' });
}
// ========== View nav buttons ==========
$$('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => switchView(btn.dataset.view));
});
// ========== Hash routing ==========
function handleHash() {
const hash = window.location.hash;
if (hash === '#/cards') {
state.view = 'cards';
} else {
state.view = 'stream';
}
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === state.view));
loadEntities();
renderMonthNav();
renderTagRail();
renderCaptureBar();
}
window.addEventListener('hashchange', handleHash);
// ========== Search ==========
const searchInput = $('#search-input');
let searchDebounce = null;
searchInput.addEventListener('input', () => {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
state.searchQuery = searchInput.value.trim().toLowerCase();
renderEntityList();
}, 150);
});
searchInput.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') { searchInput.value = ''; state.searchQuery = ''; renderEntityList(); searchInput.blur(); }
});
function filterByIntent(entities) {
if (state.view !== 'cards') return entities;
if (state.intent === 'grab') return entities.filter(e => !e.card_type || GRAB_TYPES.includes(e.card_type));
if (state.intent === 'read') return entities.filter(e => READ_TYPES.includes(e.card_type));
if (state.intent === 'fill') return entities.filter(e => FILL_TYPES.includes(e.card_type));
return entities;
}
function filterBySearch(entities) {
const intentFiltered = filterByIntent(entities);
if (!state.searchQuery) return intentFiltered;
let query = state.searchQuery;
let filterTags = [];
query = query.replace(/#(\S+)/g, (_, tag) => { filterTags.push(tag); return ''; }).trim();
return intentFiltered.filter(e => {
if (filterTags.length) {
const eTags = (e.tags || []).map(t => t.toLowerCase());
if (!filterTags.every(ft => eTags.includes(ft))) return false;
}
if (!query) return true;
const haystack = ((e.body || '') + ' ' + (e.title || '') + ' ' + (e.description || '')).toLowerCase();
return haystack.includes(query);
});
}
// ========== Utils ==========
function escHtml(s) {
if (!s) return '';
return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function escAttr(s) {
return escHtml(s).replace(/'/g, ''');
}
function renderMd(s) {
if (!s) return '';
if (typeof marked === 'undefined') return escHtml(s);
return marked.parse(s, { breaks: true });
}
function isSafeUrl(url) {
return /^https?:\/\//i.test(url);
}
// ========== Theme ==========
const THEMES = ['dark', 'paper', 'tinycard', 'catppuccin', 'nord', 'dracula'];
const THEME_ICONS = { dark: '◑', paper: '◐', tinycard: '◈', catppuccin: '◕', nord: '◓', dracula: '◒' };
const themeToggle = $('#theme-toggle');
let nibTheme = localStorage.getItem('nib:theme') || 'dark';
if (!THEMES.includes(nibTheme)) nibTheme = 'dark';
document.documentElement.setAttribute('data-theme', nibTheme);
themeToggle.textContent = THEME_ICONS[nibTheme];
themeToggle.addEventListener('click', () => {
nibTheme = THEMES[(THEMES.indexOf(nibTheme) + 1) % THEMES.length];
document.documentElement.setAttribute('data-theme', nibTheme);
localStorage.setItem('nib:theme', nibTheme);
themeToggle.textContent = THEME_ICONS[nibTheme];
});
// ========== Init ==========
async function init() {
renderCaptureBar();
await Promise.all([loadEntities(), loadTags()]);
handleHash();
renderMonthNav();
}
init();
})();