feat(ui): phase 2 — card rows, affordance badges, cards sub-header
- Rich card row rendering: title — preview — affordance badges — tags — pin — use count - Affordance detection (code, fill, steps, decide, link) from entity shape - Cards sub-header with scope label, count, sort dropdown - Section labels (★ pinned / recent) in cards view - Flash animation on copy (--a-str pulse) - Tag pill styling for card rows - Progress bar mini-display for checklists in card preview
This commit is contained in:
+112
-4
@@ -27,6 +27,7 @@
|
||||
hasMore: false,
|
||||
activeMonth: null,
|
||||
intent: 'grab',
|
||||
flashId: null,
|
||||
};
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
@@ -214,6 +215,42 @@
|
||||
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) {
|
||||
@@ -368,11 +405,13 @@
|
||||
|
||||
if (state.entities.length === 0) {
|
||||
list.innerHTML = '<div class="detail-empty" style="margin-top:40px">no entities yet</div>';
|
||||
renderCardsHeader(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
if (state.view === 'stream') {
|
||||
renderCardsHeader(false);
|
||||
const groups = groupByDate(state.entities);
|
||||
let idx = 0;
|
||||
for (const g of groups) {
|
||||
@@ -383,9 +422,22 @@
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.entities.forEach((e, idx) => {
|
||||
html += renderEntityItem(e, idx);
|
||||
});
|
||||
renderCardsHeader(true);
|
||||
const pinned = state.entities.filter(e => e.pinned);
|
||||
const rest = state.entities.filter(e => !e.pinned);
|
||||
let idx = 0;
|
||||
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) {
|
||||
@@ -400,10 +452,63 @@
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
hdr.innerHTML = `
|
||||
<span class="cards-scope">${scope}</span>
|
||||
<span class="cards-count">${state.entities.length} cards</span>
|
||||
<select class="cards-sort"><option>newest</option><option>most used</option></select>
|
||||
`;
|
||||
}
|
||||
|
||||
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 renderEntityItem(e, idx) {
|
||||
const glyph = displayGlyph(e);
|
||||
const gc = glyphClass(e);
|
||||
@@ -652,7 +757,8 @@
|
||||
|
||||
function renderMonthNav() {
|
||||
const nav = $('#month-nav');
|
||||
if (state.view !== 'stream') { nav.innerHTML = ''; return; }
|
||||
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
|
||||
@@ -720,8 +826,10 @@
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user