feat(ui): redesign to match design handoff prototype #9
+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);
|
||||
}
|
||||
|
||||
+172
@@ -436,6 +436,178 @@ main {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* ── CARDS SUB-HEADER ──────────────────────────────── */
|
||||
.cards-hdr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 14px;
|
||||
border-bottom: 1px solid var(--soft);
|
||||
background: var(--surf);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cards-scope {
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cards-count {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--dim);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.cards-sort {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r2);
|
||||
padding: 3px 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── CARD ROWS ──────────────────────────────────────── */
|
||||
.card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 12px 9px 14px;
|
||||
margin: 2px 10px;
|
||||
background: var(--surf);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r2);
|
||||
cursor: pointer;
|
||||
min-height: 40px;
|
||||
position: relative;
|
||||
transition: border-color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
|
||||
.card-row:hover { border-color: var(--muted); }
|
||||
.card-row.selected { border-color: var(--accent); background: var(--a-bg); }
|
||||
.card-row.pinned { border-left: 2px solid var(--accent); }
|
||||
|
||||
@keyframes card-flash { 0%,100%{} 50%{ background: var(--a-str); } }
|
||||
.card-row.flashing { animation: card-flash .3s ease; }
|
||||
|
||||
.card-row-title {
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
max-width: 145px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-row-dash {
|
||||
color: var(--dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-row-preview {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-row-preview .slot { color: var(--lineage); }
|
||||
.card-row-preview .link { color: var(--event); }
|
||||
.card-row-preview .choice { color: var(--text); }
|
||||
|
||||
.card-row-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.card-row-pin {
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.card-row-use {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
/* progress bar in card row */
|
||||
.card-row-prog {
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 3px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.card-row-prog span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--ok);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ── SECTION LABELS ─────────────────────────────────── */
|
||||
.list-sec-lbl {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .16em;
|
||||
color: var(--dim);
|
||||
padding: 8px 14px 4px;
|
||||
}
|
||||
|
||||
/* ── AFFORDANCE BADGES ──────────────────────────────── */
|
||||
.aff {
|
||||
font-family: var(--mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: .04em;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--r1);
|
||||
border: 1px solid;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.aff.clickable { cursor: pointer; transition: opacity var(--t-fast); }
|
||||
.aff.clickable:hover { opacity: .7; }
|
||||
|
||||
.aff-code { color: var(--accent); border-color: rgba(200,148,42,.38); background: var(--a-bg); }
|
||||
.aff-fill { color: var(--lineage); border-color: rgba(152,120,188,.38); background: rgba(152,120,188,.06); }
|
||||
.aff-steps { color: var(--ok); border-color: rgba(122,171,114,.38); background: rgba(122,171,114,.06); }
|
||||
.aff-decide { color: var(--note); border-color: rgba(106,184,176,.38); background: rgba(106,184,176,.06); }
|
||||
.aff-link { color: var(--event); border-color: rgba(104,152,200,.38); background: rgba(104,152,200,.06); }
|
||||
|
||||
/* ── TAG PILLS ──────────────────────────────────────── */
|
||||
.tag-pill {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(200,148,42,.35);
|
||||
background: var(--a-bg);
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--r1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── CAPTURE BAR ────────────────────────────────────── */
|
||||
#capture-bar {
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
Reference in New Issue
Block a user