ad44d35d9b
Use renderMd instead of escHtml for exp-body content. Add .md class for consistent markdown styling.
1854 lines
67 KiB
JavaScript
1854 lines
67 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
const GLYPHS = {
|
||
note: '—', todo: '○', event: '◇', reminder: '△',
|
||
snippet: '◆', template: '◈', checklist: '☐',
|
||
decision: '⚖', link: '↗',
|
||
};
|
||
|
||
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',
|
||
};
|
||
|
||
const PAGE_SIZE = 50;
|
||
|
||
const INTENT_HINTS = { grab: 'scan + copy', read: 'expand + study', fill: 'templates only' };
|
||
|
||
const state = {
|
||
view: 'stream',
|
||
entities: [],
|
||
tags: [],
|
||
selectedIndex: -1,
|
||
activeTag: null,
|
||
hasMore: false,
|
||
activeMonth: null,
|
||
intent: 'grab',
|
||
flashId: null,
|
||
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' };
|
||
|
||
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.length : k === 'read' ? state.entities.filter(e => e.card_data).length : state.entities.filter(e => e.body && /\$\{.+\}/.test(e.body)).length;
|
||
html += `<button class="rail-item${on}" data-intent="${k}">`;
|
||
html += `<span class="rail-arrow">${state.intent === k ? '▸' : ''}</span>`;
|
||
html += '<span class="rail-dot"></span>';
|
||
html += `<span class="rail-name">${k}</span>`;
|
||
html += `<span class="rail-count">${count}</span>`;
|
||
if (state.intent === k) html += `<span class="rail-hint">${INTENT_HINTS[k]}</span>`;
|
||
html += '</button>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
|
||
html += '<div class="rail-sec">';
|
||
html += '<div class="rail-lbl">tags</div>';
|
||
|
||
const allOn = !state.activeTag ? ' on' : '';
|
||
html += `<button class="rail-item${allOn}" data-tag="">`;
|
||
html += `<span class="rail-arrow">${!state.activeTag ? '▸' : ''}</span>`;
|
||
html += '<span class="rail-dot"></span>';
|
||
html += `<span class="rail-name">#all</span>`;
|
||
html += `<span class="rail-count">${total}</span>`;
|
||
html += '</button>';
|
||
|
||
for (const t of state.tags) {
|
||
const on = state.activeTag === t.tag ? ' on' : '';
|
||
html += `<button class="rail-item${on}" data-tag="${t.tag}">`;
|
||
html += `<span class="rail-arrow">${state.activeTag === t.tag ? '▸' : ''}</span>`;
|
||
html += '<span class="rail-dot"></span>';
|
||
html += `<span class="rail-name">#${t.tag}</span>`;
|
||
html += `<span class="rail-count">${t.count}</span>`;
|
||
html += '</button>';
|
||
}
|
||
|
||
html += '</div></div>';
|
||
rail.innerHTML = html;
|
||
|
||
rail.querySelectorAll('.rail-item[data-tag]').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
state.activeTag = el.dataset.tag || null;
|
||
loadEntities();
|
||
renderTagRail();
|
||
});
|
||
});
|
||
|
||
rail.querySelectorAll('.rail-item[data-intent]').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
state.intent = el.dataset.intent;
|
||
renderTagRail();
|
||
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}">
|
||
<span class="card-row-title">${escHtml(title)}</span>
|
||
<span class="card-row-dash">—</span>
|
||
<span class="card-row-preview">${preview}</span>
|
||
<div class="card-row-right">
|
||
${affHtml}
|
||
${tags}
|
||
${e.pinned ? '<span class="card-row-pin">★</span>' : ''}
|
||
${e.use_count > 0 ? `<span class="card-row-use">${e.use_count}×</span>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function 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 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>`;
|
||
}
|
||
return `<div class="exp-inner">
|
||
<div class="exp-body md">${renderMd(e.body || '')}</div>
|
||
${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').forEach(el => el.classList.remove('selected'));
|
||
if (state.selectedIndex >= 0) {
|
||
const target = $(`.entity-item[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 = `
|
||
<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) {
|
||
await api.deleteEntity(id);
|
||
await loadEntities();
|
||
await loadTags();
|
||
showToast('deleted');
|
||
},
|
||
|
||
async resolveTemplate(id) {
|
||
const e = state.entities.find(x => x.id === id);
|
||
if (!e) return;
|
||
let resolved = e.body;
|
||
$$('.slot-input').forEach(input => {
|
||
const name = input.dataset.slot;
|
||
const val = input.value || input.placeholder;
|
||
resolved = resolved.replace(new RegExp('\\$\\{' + name + '\\}', 'g'), val);
|
||
});
|
||
try {
|
||
await navigator.clipboard.writeText(resolved);
|
||
await api.useEntity(id);
|
||
await loadEntities();
|
||
showToast('copied');
|
||
} catch (err) {
|
||
console.error('clipboard:', err);
|
||
}
|
||
},
|
||
|
||
showAbsorb(targetId) {
|
||
const target = state.entities.find(x => x.id === targetId);
|
||
if (!target) return;
|
||
if (target.card_type) return;
|
||
|
||
const modal = $('#absorb-modal');
|
||
modal.dataset.targetId = targetId;
|
||
const list = $('#absorb-source-list');
|
||
|
||
const sources = state.entities.filter(x => x.id !== targetId);
|
||
if (!sources.length) { list.innerHTML = '<div class="detail-empty">no other entities</div>'; }
|
||
else {
|
||
list.innerHTML = sources.map(e => {
|
||
const g = displayGlyph(e);
|
||
const gc = glyphClass(e);
|
||
const 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; }
|
||
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`);
|
||
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`);
|
||
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`);
|
||
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' || state.intent === 'grab') return entities;
|
||
if (state.intent === 'read') return entities.filter(e => e.card_data);
|
||
if (state.intent === 'fill') return entities.filter(e => e.body && /\$\{.+\}/.test(e.body));
|
||
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, '>').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'];
|
||
const THEME_ICONS = { dark: '◑', paper: '◐', tinycard: '◈' };
|
||
|
||
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();
|
||
})();
|