feat(ui): phase 1 — layout, tokens, header, rail redesign
- Switch mono font from Monaspace Neon to JetBrains Mono - Grid layout 192px | 1fr | 400px (was 180/320) - Move capture bar from header to bottom of center panel - Add search input to header center - Redesign tag rail: grid items with arrow/dot/name/count - Add intent section (grab/read/fill) in cards view rail - Add --a-str token, toast component - Logo 16px 700 weight
This commit is contained in:
+159
-62
@@ -16,6 +16,8 @@
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const INTENT_HINTS = { grab: 'scan + copy', read: 'expand + study', fill: 'templates only' };
|
||||
|
||||
const state = {
|
||||
view: 'stream',
|
||||
entities: [],
|
||||
@@ -24,6 +26,7 @@
|
||||
activeTag: null,
|
||||
hasMore: false,
|
||||
activeMonth: null,
|
||||
intent: 'grab',
|
||||
};
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
@@ -223,32 +226,128 @@
|
||||
|
||||
function formatDate(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||||
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 allItem = `<div class="tag-item ${!state.activeTag ? 'active' : ''}" data-tag="">
|
||||
<span class="tag-name" style="font-style: italic">all</span>
|
||||
</div>`;
|
||||
const total = state.tags.reduce((s, t) => s + t.count, 0);
|
||||
|
||||
rail.innerHTML = allItem + state.tags.map(t =>
|
||||
`<div class="tag-item ${state.activeTag === t.tag ? 'active' : ''}" data-tag="${t.tag}">
|
||||
<span class="tag-name">${t.tag}</span>
|
||||
<span class="tag-count">${t.count}</span>
|
||||
</div>`
|
||||
).join('');
|
||||
let html = `<div class="rail-head"><span class="rail-brand">nib</span></div>`;
|
||||
html += '<div class="rail-scroll">';
|
||||
|
||||
rail.querySelectorAll('.tag-item').forEach(el => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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-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');
|
||||
input.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
handleCapture();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCapture() {
|
||||
const input = $('#capture-input');
|
||||
const val = input.value.trim();
|
||||
if (!val) return;
|
||||
|
||||
const parsed = parseInput(val);
|
||||
if (!parsed) 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;
|
||||
|
||||
await api.createEntity(data);
|
||||
input.value = '';
|
||||
await loadEntities();
|
||||
await loadTags();
|
||||
showToast('captured');
|
||||
}
|
||||
|
||||
// ── Entity List ──
|
||||
|
||||
function groupByDate(entities) {
|
||||
const groups = [];
|
||||
let current = null;
|
||||
@@ -308,10 +407,12 @@
|
||||
function renderEntityItem(e, idx) {
|
||||
const glyph = displayGlyph(e);
|
||||
const gc = glyphClass(e);
|
||||
const selected = idx === state.selectedIndex ? 'selected' : '';
|
||||
const tags = (e.tags || []).map(t => `<span class="entity-tag">${t}</span>`).join('');
|
||||
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;
|
||||
if (e.title) {
|
||||
@@ -321,11 +422,11 @@
|
||||
label = `<span class="entity-body">${escHtml(e.body)}</span>`;
|
||||
}
|
||||
|
||||
return `<div class="entity-item ${selected}" data-index="${idx}" data-id="${e.id}">
|
||||
return `<div class="entity-item${selected}${isCard}" data-index="${idx}" data-id="${e.id}">
|
||||
<span class="entity-glyph ${gc}">${glyph}</span>
|
||||
${label}
|
||||
${time}
|
||||
<span class="entity-tags">${tags}</span>
|
||||
<span class="entity-tags">${tags}${cardBadge}</span>
|
||||
<span class="entity-meta">${useBadge}</span>
|
||||
</div>`;
|
||||
}
|
||||
@@ -351,10 +452,10 @@
|
||||
|
||||
if (e.card_type) {
|
||||
cardContent = renderCardContent(e);
|
||||
actions += `<button class="action-btn" onclick="nibApp.copyEntity('${e.id}')">copy</button>`;
|
||||
actions += `<button class="action-btn primary" onclick="nibApp.copyEntity('${e.id}')">copy</button>`;
|
||||
actions += `<button class="action-btn" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||
} else {
|
||||
actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote</button>`;
|
||||
actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote →</button>`;
|
||||
actions += `<button class="action-btn" onclick="nibApp.showAbsorb('${e.id}')">absorb</button>`;
|
||||
}
|
||||
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
|
||||
@@ -363,17 +464,19 @@
|
||||
const titleHtml = e.title ? `<h2 class="detail-title" data-id="${e.id}">${escHtml(e.title)}</h2>` : '';
|
||||
|
||||
pane.innerHTML = `
|
||||
<div class="detail-header">
|
||||
<span class="detail-glyph ${gc}">${glyph}</span>
|
||||
<span class="detail-id">${shortId}</span>
|
||||
${e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : ''}
|
||||
<div class="detail-scroll">
|
||||
<div class="detail-header">
|
||||
<span class="detail-glyph ${gc}">${glyph}</span>
|
||||
<span class="detail-id">${shortId}</span>
|
||||
${e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : ''}
|
||||
</div>
|
||||
${descHtml}
|
||||
${titleHtml}
|
||||
<div class="detail-body" data-id="${e.id}">${escHtml(e.body)}</div>
|
||||
${tags ? `<div class="detail-tags">${tags}</div>` : ''}
|
||||
${cardContent}
|
||||
<div class="detail-actions">${actions}</div>
|
||||
</div>
|
||||
${descHtml}
|
||||
${titleHtml}
|
||||
<div class="detail-body" data-id="${e.id}">${escHtml(e.body)}</div>
|
||||
${tags ? `<div class="detail-tags">${tags}</div>` : ''}
|
||||
${cardContent}
|
||||
<div class="detail-actions">${actions}</div>
|
||||
`;
|
||||
|
||||
const titleEl = pane.querySelector('.detail-title');
|
||||
@@ -560,13 +663,10 @@
|
||||
<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>
|
||||
${state.activeMonth ? '<button class="month-nav-clear">clear</button>' : ''}
|
||||
`;
|
||||
|
||||
$('#month-prev').addEventListener('click', () => shiftMonth(-1));
|
||||
$('#month-next').addEventListener('click', () => shiftMonth(1));
|
||||
const clearBtn = nav.querySelector('.month-nav-clear');
|
||||
if (clearBtn) clearBtn.addEventListener('click', () => { state.activeMonth = null; loadEntities(); renderMonthNav(); });
|
||||
}
|
||||
|
||||
function shiftMonth(dir) {
|
||||
@@ -590,10 +690,25 @@
|
||||
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' : '/';
|
||||
loadEntities();
|
||||
renderMonthNav();
|
||||
renderTagRail();
|
||||
renderCaptureBar();
|
||||
}
|
||||
|
||||
// ========== 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) ==========
|
||||
@@ -606,6 +721,7 @@
|
||||
await navigator.clipboard.writeText(e.body);
|
||||
await api.useEntity(id);
|
||||
await loadEntities();
|
||||
showToast('copied');
|
||||
} catch (err) {
|
||||
console.error('clipboard:', err);
|
||||
}
|
||||
@@ -630,12 +746,14 @@
|
||||
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) {
|
||||
@@ -651,6 +769,7 @@
|
||||
await navigator.clipboard.writeText(resolved);
|
||||
await api.useEntity(id);
|
||||
await loadEntities();
|
||||
showToast('copied');
|
||||
} catch (err) {
|
||||
console.error('clipboard:', err);
|
||||
}
|
||||
@@ -688,6 +807,7 @@
|
||||
await loadTags();
|
||||
const idx = state.entities.findIndex(x => x.id === targetId);
|
||||
if (idx >= 0) selectEntity(idx);
|
||||
showToast('absorbed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -706,33 +826,6 @@
|
||||
},
|
||||
};
|
||||
|
||||
// ========== Capture bar ==========
|
||||
|
||||
$('#capture-bar').addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
const input = $('#capture-input');
|
||||
const val = input.value.trim();
|
||||
if (!val) return;
|
||||
|
||||
const parsed = parseInput(val);
|
||||
if (!parsed) 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;
|
||||
|
||||
await api.createEntity(data);
|
||||
input.value = '';
|
||||
await loadEntities();
|
||||
await loadTags();
|
||||
});
|
||||
|
||||
// ========== Promote modal ==========
|
||||
|
||||
$$('.type-btn').forEach(btn => {
|
||||
@@ -745,6 +838,7 @@
|
||||
await api.promoteEntity(id, btn.dataset.type);
|
||||
await loadEntities();
|
||||
await loadTags();
|
||||
showToast('promoted → ' + btn.dataset.type);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -761,12 +855,11 @@
|
||||
// ========== Keyboard shortcuts ==========
|
||||
|
||||
let lastDTime = 0;
|
||||
const captureInput = $('#capture-input');
|
||||
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (document.activeElement === captureInput ||
|
||||
document.activeElement.classList.contains('detail-body-edit')) {
|
||||
if (ev.key === 'Escape') document.activeElement.blur();
|
||||
const tag = (ev.target.tagName || '').toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea') {
|
||||
if (ev.key === 'Escape') ev.target.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -789,7 +882,7 @@
|
||||
break;
|
||||
case 'n':
|
||||
ev.preventDefault();
|
||||
captureInput.focus();
|
||||
$('#capture-input').focus();
|
||||
break;
|
||||
case 'p': {
|
||||
const e = state.entities[state.selectedIndex];
|
||||
@@ -848,6 +941,9 @@
|
||||
}
|
||||
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === state.view));
|
||||
loadEntities();
|
||||
renderMonthNav();
|
||||
renderTagRail();
|
||||
renderCaptureBar();
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', handleHash);
|
||||
@@ -884,6 +980,7 @@
|
||||
// ========== Init ==========
|
||||
|
||||
async function init() {
|
||||
renderCaptureBar();
|
||||
await Promise.all([loadEntities(), loadTags()]);
|
||||
handleHash();
|
||||
renderMonthNav();
|
||||
|
||||
Reference in New Issue
Block a user