Files
nib-v1/web/app.js
T
lerko 7d1e0f895c fix(web): mobile edit via inline fullscreen instead of hidden detail pane
Detail pane is display:none on mobile, so edit mode was unreachable.
Render edit fields directly in the inline expansion with exp-full
takeover. ESC and Cmd+Enter work from within inputs.

Closes #32
2026-05-20 19:14:02 -04:00

2053 lines
76 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
'use strict';
const GLYPHS = {
note: '—', todo: '○', event: '◇', 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 `<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.filter(e => !e.card_type || GRAB_TYPES.includes(e.card_type)).length : k === 'read' ? state.entities.filter(e => READ_TYPES.includes(e.card_type)).length : state.entities.filter(e => FILL_TYPES.includes(e.card_type)).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();
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 = `
<div class="cap-preview" id="cap-preview"></div>
<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');
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('<span class="cap-pill cap-pill-query">search</span>');
} else {
pills.push(`<span class="cap-pill cap-pill-glyph">${escHtml(parsed.glyph)}</span>`);
}
if (parsed.title) pills.push(`<span class="cap-pill cap-pill-title">|${escHtml(parsed.title)}</span>`);
if (parsed.description) pills.push(`<span class="cap-pill cap-pill-desc">${escHtml(parsed.description)}</span>`);
for (const t of (parsed.query ? parsed.filterTags : parsed.tags)) {
pills.push(`<span class="cap-pill cap-pill-tag">#${escHtml(t)}</span>`);
}
if (parsed.timeAnchor) pills.push(`<span class="cap-pill cap-pill-time">@${escHtml(parsed.timeAnchor)}</span>`);
if (parsed.pin) pills.push('<span class="cap-pill cap-pill-pin">pin</span>');
if (parsed.cardSuffix) pills.push(`<span class="cap-pill cap-pill-card">^${escHtml(parsed.cardSuffix)}</span>`);
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 = `<div class="detail-empty" style="margin-top:40px">${state.searchQuery ? 'no matches' : 'no entities yet'}</div>`;
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 += `<div class="date-header">${g.label}</div>`;
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 += '<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;
const sorts = ['newest', 'oldest', 'most used'];
const options = sorts.map(s => `<option${s === state.cardsSort ? ' selected' : ''}>${s}</option>`).join('');
hdr.innerHTML = `
<span class="cards-scope">${scope}</span>
<span class="cards-count">${state.entities.length} cards</span>
<select class="cards-sort">${options}</select>
`;
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 => `<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}">
<div class="card-head">
<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>
<div class="entity-exp">
<div class="entity-exp-clip">
${renderInlineDetail(e)}
</div>
</div>
</div>`;
}
function renderInlineEditMode(e) {
return `<div class="exp-inner exp-inner--edit">
<div class="peek-edit-fields">
<div class="peek-edit-field"><label class="peek-edit-lbl">title</label>
<input class="peek-edit-in" id="edit-title" value="${escAttr(e.title || '')}"></div>
<div class="peek-edit-field"><label class="peek-edit-lbl">description</label>
<input class="peek-edit-in" id="edit-desc" value="${escAttr(e.description || '')}"></div>
<div class="peek-edit-field"><label class="peek-edit-lbl">content</label>
<textarea class="peek-edit-ta" id="edit-body" rows="7">${escHtml(e.body || '')}</textarea></div>
<div class="peek-edit-field"><label class="peek-edit-lbl">tags</label>
<input class="peek-edit-in" id="edit-tags" value="${escAttr((e.tags || []).join(' '))}" placeholder="space-separated"></div>
</div>
<div class="exp-acts">
<button class="action-btn primary" onclick="event.stopPropagation();nibApp.saveEdit('${e.id}')">save</button>
<button class="action-btn" onclick="event.stopPropagation();nibApp.exitMode()">cancel</button>
</div>
</div>`;
}
function renderInlineDetail(e) {
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
let actions = '';
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('edit')">edit</button>`;
if (!e.card_type) {
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.showAbsorb('${e.id}')">absorb</button>`;
actions += `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.showPromote('${e.id}')">promote</button>`;
}
if (e.card_type) {
actions += `<button class="action-btn primary" onclick="event.stopPropagation();nibApp.copyEntity('${e.id}')">copy</button>`;
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.demoteEntity('${e.id}')">demote</button>`;
} else {
actions += `<button class="action-btn danger" onclick="event.stopPropagation();nibApp.deleteEntity('${e.id}')">delete</button>`;
}
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 => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
content += `<div class="peek-sec">
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
<div class="peek-sec-inner peek-decision">
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
</div>
</div>`;
}
if (hasLink && !hasDecision) {
content += `<div class="peek-sec">
<div class="peek-sec-lbl">link</div>
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
</div>`;
}
if (hasSteps) {
const steps = data.steps.map(s => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
content += `<div class="peek-sec">
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="event.stopPropagation();nibApp.enterMode('run')">▶ run</button></div>
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
</div>`;
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('run')">run</button>`;
}
if (hasFill) {
actions += `<button class="action-btn" onclick="event.stopPropagation();nibApp.enterMode('fill')">fill</button>`;
}
if (!hasDecision && e.body) {
const lang = data.lang || '';
const isCode = lang || e.card_type === 'snippet';
const bodyHtml = isCode
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
: `<div class="exp-body md">${renderMd(e.body)}</div>`;
content += `<div class="peek-sec">
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}</div>
<div class="peek-sec-inner">${bodyHtml}</div>
</div>`;
}
} else {
content = `<div class="exp-body md">${renderMd(e.body || '')}</div>`;
}
return `<div class="exp-inner">
${content}
${tags ? `<div class="exp-tags">${tags}</div>` : ''}
<div class="exp-acts">${actions}</div>
<div class="exp-toolbar">
<button class="peek-mobile-btn" onclick="event.stopPropagation();nibApp.expandInline()">↑</button>
<button class="peek-mobile-btn" onclick="event.stopPropagation();nibApp.dismissPeek()">×</button>
</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;
const descSnip = e.description ? `<span class="entity-desc">${escHtml(e.description)}</span>` : '';
if (e.title) {
const preview = e.body ? `<span class="entity-preview">${escHtml(e.body)}</span>` : '';
label = `<span class="entity-content"><span class="entity-title">${escHtml(e.title)}</span>${descSnip}${preview}</span>`;
} else {
label = `<span class="entity-content"><span class="entity-body">${escHtml(e.body)}</span>${descSnip}</span>`;
}
return `<div class="entity-item${selected}${isCard}" data-index="${idx}" data-id="${e.id}">
<div class="entity-head">
<span class="entity-glyph ${gc}">${glyph}</span>
${label}
${time}
<span class="entity-tags">${tags}${cardBadge}</span>
<span class="entity-meta">${useBadge}</span>
</div>
<div class="entity-exp">
<div class="entity-exp-clip">
${renderInlineDetail(e)}
</div>
</div>
</div>`;
}
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 `<div class="peek-idle">
<div class="peek-idle-eyebrow">peek</div>
<div class="peek-idle-title">Select ${v === 'cards' ? 'a card' : 'an entry'}.</div>
<div class="peek-idle-sub">${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.'}</div>
<div class="peek-shortcuts">
<div class="peek-sc-sec">
<div class="peek-sc-lbl">navigate</div>
<div class="peek-sc-row"><kbd>j</kbd><kbd>k</kbd><span>next / prev</span></div>
<div class="peek-sc-row"><kbd>1</kbd><kbd>2</kbd><span>stream / cards</span></div>
</div>
${v === 'stream' ? `<div class="peek-sc-sec">
<div class="peek-sc-lbl">stream grammar</div>
<div class="peek-sc-code">(bare text) = thought</div>
<div class="peek-sc-hint">- todo · @time event · !time reminder</div>
<div class="peek-sc-hint">#tag · |title · // desc · !pin</div>
</div>` : `<div class="peek-sc-sec">
<div class="peek-sc-lbl">act</div>
<div class="peek-sc-row"><kbd>⏎</kbd><span>copy</span></div>
<div class="peek-sc-row"><kbd>r</kbd><span>run checklist</span></div>
<div class="peek-sc-row"><kbd>f</kbd><span>fill template</span></div>
<div class="peek-sc-row"><kbd>e</kbd><span>edit</span></div>
<div class="peek-sc-row"><kbd>p</kbd><span>pin</span></div>
</div>`}
</div>
</div>`;
}
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 => `<span class="detail-tag">#${t}</span>`).join('');
let actions = '';
actions += `<button class="action-btn" onclick="nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
if (!e.card_type) {
actions += `<button class="action-btn" onclick="nibApp.showAbsorb('${e.id}')">absorb <kbd>a</kbd></button>`;
actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote →</button>`;
}
if (e.card_type) {
actions += `<button class="action-btn danger" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
} else {
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
}
return `<div class="peek-scroll">
<div class="peek-brow">
<span class="peek-brow-g ${gc}">${glyph}</span>
<span class="peek-brow-kind">${kindLbl}</span>
<span class="peek-brow-sep">·</span>
<span class="peek-brow-id">${e.id.slice(-10)}</span>
<span class="peek-brow-ts">${fmtDateLong(e.created_at)}</span>
</div>
${e.title ? `<div class="peek-title" data-id="${e.id}">${escHtml(e.title)}</div>` : ''}
<div class="peek-body md" data-id="${e.id}">${renderMd(e.body)}</div>
${tags ? `<div class="peek-sec"><div class="peek-sec-lbl">tags</div><div class="peek-sec-inner tag-pills">${tags}</div></div>` : ''}
<div class="peek-sec">
<div class="peek-sec-lbl">context</div>
<div class="peek-sec-inner peek-ctx">
<span><span class="peek-ctx-lbl">created</span>${fmtDateLong(e.created_at)}</span>
${e.time_anchor ? `<span><span class="peek-ctx-lbl">time</span>@${e.time_anchor}</span>` : ''}
${e.card_type ? `<span><span class="peek-ctx-lbl">status</span><span class="peek-ctx-promoted">promoted → ${e.card_type}</span></span>` : ''}
</div>
</div>
<div class="peek-acts">${actions}</div>
</div>`;
}
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 => `<span class="detail-tag">#${t}</span>`).join('');
const affHtml = affs.map(a => `<span class="aff ${AFF_CLASSES[a]}">${AFF_LABELS[a]}</span>`).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 => `<span class="peek-dec-rej">${escHtml(r)}</span>`).join('');
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">decision<span class="peek-sec-status">${data.status || 'decided'}</span></div>
<div class="peek-sec-inner peek-decision">
<div class="peek-dec-choice">${escHtml(data.chose)}</div>
<div class="peek-dec-why"><span class="peek-dec-key">why</span>${escHtml(data.why || '')}</div>
${rejected ? `<div><span class="peek-dec-key">considered</span><div class="peek-dec-rejected">${rejected}</div></div>` : ''}
</div>
</div>`;
}
if (hasLink && !hasDecision) {
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">link</div>
<div class="peek-sec-inner"><div class="peek-link-url">↗ ${escHtml(data.url)}</div></div>
</div>`;
}
if (hasSteps) {
const steps = data.steps.map((s, i) => `<div class="peek-step"><span class="peek-step-mark" style="color:var(--dim)">○</span><span class="peek-step-text">${escHtml(s.text || s)}</span></div>`).join('');
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">steps · ${data.steps.length}<button class="peek-sec-run" onclick="nibApp.enterMode('run')">▶ run</button></div>
<div class="peek-sec-inner"><div class="peek-steps">${steps}</div></div>
</div>`;
}
if (!hasDecision && e.body) {
const lang = data.lang || '';
const isCode = lang || e.card_type === 'snippet';
const bodyHtml = isCode
? `<div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div>`
: `<div class="peek-body md">${renderMd(e.body)}</div>`;
sections += `<div class="peek-sec">
<div class="peek-sec-lbl">content${lang ? `<span class="peek-sec-lang">${lang}</span>` : ''}${hasFill ? `<button class="peek-sec-run" onclick="nibApp.enterMode('fill')">⤓ fill</button>` : ''}</div>
<div class="peek-sec-inner">${bodyHtml}</div>
</div>`;
}
let actions = `<button class="action-btn primary" onclick="nibApp.copyEntity('${e.id}')">copy <kbd>⏎</kbd></button>`;
if (hasFill) actions += `<button class="action-btn" onclick="nibApp.enterMode('fill')">fill <kbd>f</kbd></button>`;
if (hasSteps) actions += `<button class="action-btn" onclick="nibApp.enterMode('run')">run <kbd>r</kbd></button>`;
actions += `<button class="action-btn" onclick="nibApp.enterMode('edit')">edit <kbd>e</kbd></button>`;
actions += `<button class="action-btn" onclick="nibApp.togglePin('${e.id}')">${e.pinned ? 'unpin' : 'pin'} <kbd>p</kbd></button>`;
actions += `<button class="action-btn danger" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
return `<div class="peek-scroll">
<div class="peek-card">
<div class="peek-card-head">
<div class="peek-brow" style="padding:14px 16px 0">
<span class="peek-brow-g ${gc}">${glyph}</span>
<span class="peek-brow-kind">${e.card_type}</span>
<span class="peek-brow-sep">·</span>
<span class="peek-brow-id">${e.id.slice(-10)}</span>
${e.use_count > 0 ? `<span class="peek-brow-ts">${e.use_count}× used</span>` : ''}
</div>
<div class="peek-title" style="padding:9px 16px 4px">${escHtml(e.title || '')}</div>
${e.description ? `<div class="peek-desc" style="padding:0 16px 10px">${escHtml(e.description)}</div>` : ''}
<div class="peek-meta" style="padding:0 16px 12px">${affHtml}${tags}${e.pinned ? '<span class="peek-pin">★</span>' : ''}</div>
</div>
${sections}
</div>
<div class="peek-acts">${actions}</div>
</div>`;
}
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 `<div class="peek-run-step${isDone ? ' done' : ''}" data-step="${i}">
<span class="peek-run-mark" style="color:${isDone ? 'var(--ok)' : 'var(--dim)'}">${isDone ? '●' : '○'}</span>
<span class="peek-run-text">${escHtml(text)}</span>
</div>`;
}).join('');
return `<div class="peek-scroll">
<div class="peek-brow">
<span class="peek-run-pill">▶ running</span>
<span class="peek-brow-ts">${done}/${total} done</span>
</div>
<div class="peek-title">${escHtml(e.title || '')}</div>
${e.description ? `<div class="peek-desc">${escHtml(e.description)}</div>` : ''}
<div class="peek-run-prog-wrap">
<div class="peek-run-prog-track"><div class="peek-run-prog" style="width:${pct}%"></div></div>
<span class="peek-run-pct">${pct}%</span>
</div>
<div class="peek-run-steps">${steps}</div>
<div class="peek-hints"><span><kbd>Space</kbd> toggle</span><span><kbd>r</kbd> reset</span><span><kbd>Esc</kbd> exit</span></div>
<div class="peek-acts">
<button class="action-btn primary" onclick="nibApp.exitMode()">done</button>
<button class="action-btn" onclick="nibApp.resetRun()">reset</button>
</div>
</div>`;
}
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}}`, `<span class="${cls}"><input type="text" data-slot="${name}" data-idx="${idx}" placeholder="${escHtml(name)}" value="${escHtml(val)}" style="width:${width}px"></span>`);
}
const allFilled = slots.every(s => fill[s]);
return `<div class="peek-scroll">
<div class="peek-brow">
<span class="peek-fill-pill">⤓ filling</span>
<span class="peek-brow-ts">slot ${active + 1} / ${slots.length}</span>
</div>
<div class="peek-title">${escHtml(e.title || '')}</div>
${e.description ? `<div class="peek-desc">${escHtml(e.description)}</div>` : ''}
<div class="peek-fill-canvas"><code>${content}</code></div>
<div class="peek-hints"><span><kbd>Tab</kbd> next</span><span><kbd>⇧Tab</kbd> prev</span><span><kbd>⏎</kbd> copy</span><span><kbd>Esc</kbd> cancel</span></div>
<div class="peek-acts">
<button class="action-btn primary${allFilled ? '' : ' dim'}" onclick="nibApp.completeFill()">copy resolved</button>
<button class="action-btn" onclick="nibApp.exitMode()">cancel</button>
</div>
</div>`;
}
function renderEditMode(e) {
return `<div class="peek-scroll">
<div class="peek-brow"><span class="peek-edit-pill">✎ editing</span></div>
<div class="peek-title" style="opacity:.45">${escHtml(e.title || 'untitled')}</div>
<div class="peek-edit-fields">
<div class="peek-edit-field"><label class="peek-edit-lbl">title</label>
<input class="peek-edit-in" id="edit-title" value="${escAttr(e.title || '')}"></div>
<div class="peek-edit-field"><label class="peek-edit-lbl">description</label>
<input class="peek-edit-in" id="edit-desc" value="${escAttr(e.description || '')}"></div>
<div class="peek-edit-field"><label class="peek-edit-lbl">content</label>
<textarea class="peek-edit-ta" id="edit-body" rows="7">${escHtml(e.body || '')}</textarea></div>
<div class="peek-edit-field"><label class="peek-edit-lbl">tags</label>
<input class="peek-edit-in" id="edit-tags" value="${escAttr((e.tags || []).join(' '))}" placeholder="space-separated"></div>
</div>
<div class="peek-hints"><span><kbd>⌘⏎</kbd> save</span><span><kbd>Esc</kbd> cancel</span></div>
<div class="peek-acts">
<button class="action-btn primary" onclick="nibApp.saveEdit('${e.id}')">save</button>
<button class="action-btn" onclick="nibApp.exitMode()">cancel</button>
</div>
</div>`;
}
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 resp = await api.listEntities(params);
state.entities = resp.data;
state.hasMore = (resp.offset + resp.data.length) < resp.total;
state.selectedIndex = -1;
renderEntityList();
renderDetailPane();
renderTagRail();
}
async function loadMore() {
const params = buildListParams(state.entities.length);
const resp = await api.listEntities(params);
state.entities = state.entities.concat(resp.data);
state.hasMore = (resp.offset + resp.data.length) < resp.total;
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() {
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 = '<div class="detail-empty">no other entities</div>'; }
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 `<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));
},
enterMode(mode) {
state.peekMode = mode;
if (mode === 'run') state.runChecked = new Set();
if (mode === 'fill') { state.fillValues = {}; state.fillActive = 0; }
if (mode === 'edit' && isMobileBreakpoint()) {
const e = state.entities[state.selectedIndex];
const sel = $(`.entity-item.selected, .card-row.selected`);
if (!e || !sel) return;
const clip = sel.querySelector('.entity-exp-clip');
if (clip) clip.innerHTML = renderInlineEditMode(e);
sel.classList.add('exp-full');
const titleInput = sel.querySelector('#edit-title');
if (titleInput) titleInput.focus();
return;
}
renderDetailPane();
},
exitMode() {
state.peekMode = 'preview';
if (isMobileBreakpoint()) {
const e = state.entities[state.selectedIndex];
const sel = $(`.entity-item.selected, .card-row.selected`);
if (sel && e) {
const clip = sel.querySelector('.entity-exp-clip');
if (clip) clip.innerHTML = renderInlineDetail(e);
}
return;
}
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) {
if (isMobileBreakpoint()) {
state.selectedIndex = idx;
renderEntityList();
const sel = $(`.entity-item[data-id="${id}"], .card-row[data-id="${id}"]`);
if (sel) {
sel.classList.add('selected');
const clip = sel.querySelector('.entity-exp-clip');
const e = state.entities[idx];
if (clip && e) clip.innerHTML = renderInlineDetail(e);
}
} else {
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' && state.peekMode === 'edit') {
ev.target.blur();
nibApp.exitMode();
return;
}
if (ev.key === 'Escape') { ev.target.blur(); return; }
if ((ev.metaKey || ev.ctrlKey) && ev.key === 'Enter' && state.peekMode === 'edit') {
const e = state.entities[state.selectedIndex];
if (e) nibApp.saveEdit(e.id);
return;
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function escAttr(s) {
return escHtml(s).replace(/'/g, '&#39;');
}
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 = [
{ id: 'dark', label: 'Noir', swatch: '#c8942a' },
{ id: 'tinycard', label: 'Tinycard', swatch: '#ad8ee6' },
{ id: 'catppuccin', label: 'Catppuccin', swatch: '#cba6f7' },
{ id: 'nord', label: 'Nord', swatch: '#88c0d0' },
{ id: 'dracula', label: 'Dracula', swatch: '#bd93f9' },
{ id: 'gruvbox', label: 'Gruvbox', swatch: '#fabd2f' },
{ id: 'rosepine', label: 'Rosé Pine', swatch: '#c4a7e7' },
{ id: 'tokyonight', label: 'Tokyo Night', swatch: '#7aa2f7' },
{ id: 'solarized', label: 'Solarized', swatch: '#268bd2' },
];
const THEMES_LIGHT = [
{ id: 'paper', label: 'Paper', swatch: '#8a6018' },
{ id: 'catppuccin-latte', label: 'Catppuccin Latte', swatch: '#8839ef' },
{ id: 'rosepine-dawn', label: 'Rosé Pine Dawn', swatch: '#907aa9' },
{ id: 'solarized-light', label: 'Solarized Light', swatch: '#268bd2' },
];
const ALL_THEME_IDS = [...THEMES_DARK, ...THEMES_LIGHT].map(t => t.id);
const themeToggle = $('#theme-toggle');
let nibTheme = localStorage.getItem('nib:theme') || 'dark';
if (!ALL_THEME_IDS.includes(nibTheme)) nibTheme = 'dark';
document.documentElement.setAttribute('data-theme', nibTheme);
themeToggle.textContent = '◑';
const popover = document.createElement('div');
popover.className = 'theme-popover';
function buildPopover() {
popover.innerHTML = '';
const addSection = (label, themes) => {
const lbl = document.createElement('div');
lbl.className = 'theme-popover-label';
lbl.textContent = label;
popover.appendChild(lbl);
themes.forEach(t => {
const item = document.createElement('div');
item.className = 'theme-popover-item' + (t.id === nibTheme ? ' active' : '');
item.dataset.theme = t.id;
item.innerHTML = `<span class="theme-popover-swatch" style="background:${t.swatch}"></span>${t.label}`;
popover.appendChild(item);
});
};
addSection('Dark', THEMES_DARK);
addSection('Light', THEMES_LIGHT);
}
buildPopover();
themeToggle.appendChild(popover);
let previewTheme = null;
themeToggle.addEventListener('click', (e) => {
if (e.target.closest('.theme-popover-item')) return;
popover.classList.toggle('open');
});
popover.addEventListener('mouseover', (e) => {
const item = e.target.closest('.theme-popover-item');
if (!item) return;
previewTheme = item.dataset.theme;
document.documentElement.setAttribute('data-theme', previewTheme);
});
popover.addEventListener('mouseleave', () => {
previewTheme = null;
document.documentElement.setAttribute('data-theme', nibTheme);
});
popover.addEventListener('click', (e) => {
const item = e.target.closest('.theme-popover-item');
if (!item) return;
nibTheme = item.dataset.theme;
previewTheme = null;
document.documentElement.setAttribute('data-theme', nibTheme);
localStorage.setItem('nib:theme', nibTheme);
popover.classList.remove('open');
buildPopover();
});
document.addEventListener('click', (e) => {
if (!themeToggle.contains(e.target)) popover.classList.remove('open');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') popover.classList.remove('open');
});
// ========== Init ==========
async function init() {
renderCaptureBar();
await Promise.all([loadEntities(), loadTags()]);
handleHash();
renderMonthNav();
}
init();
})();