diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e532696 --- /dev/null +++ b/TODO.md @@ -0,0 +1,37 @@ +# UI Redesign — Design Handoff Implementation + +## Phase 1: Layout + Tokens + Header + Rail ✓ +- [x] Update CSS tokens (add --a-str, switch mono font to JetBrains Mono) +- [x] Fix grid dimensions (192px rail, 400px peek) +- [x] Move capture bar from header to bottom of center panel +- [x] Add search bar to header (centered, max-width 400px) +- [x] Redesign tag rail: grid layout (arrow ▸ + dot + name + count) +- [x] Add intent section (grab/read/fill) for cards view in rail + +## Phase 2: Stream + Cards Views ✓ +- [x] Stream rows: promoted entries get card-style border/radius + card-type badge +- [x] Card rows: rich single-line with title — preview — affordance badges — tag pills — pin — use count +- [x] Affordance detection client-side (fill, steps, decide, link, code) +- [x] Affordance badge components +- [x] Cards sub-header (scope label + card count + sort dropdown) +- [x] Section labels (★ pinned, recent) +- [x] Flash animation on copy +- [x] Bottom capture bar styling per view (different placeholders) + +## Phase 3: Peek Pane + Modes ✓ +- [x] Idle state with keyboard shortcuts display +- [x] Stream entry peek: eyebrow, body, tags, context, actions +- [x] Card peek: card container with eyebrow, title, desc, meta, content sections +- [x] Code block with content display +- [x] Decision section display +- [x] Steps section display +- [x] Link section display +- [x] Run mode (interactive checklist with progress bar) +- [x] Fill mode (inline slot editor with tab navigation) +- [x] Edit mode (form fields) +- [x] Toast notifications + +## Phase 4: Polish ✓ +- [x] Promote modal enhancement (add hint text per type, show entry body preview) +- [x] Keyboard shortcuts (r=run, f=fill, p=pin in cards view) +- [x] Escape exits active modes diff --git a/web/app.js b/web/app.js index 10c1ec6..9ece617 100644 --- a/web/app.js +++ b/web/app.js @@ -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,13 @@ activeTag: null, hasMore: false, activeMonth: null, + intent: 'grab', + flashId: null, + peekMode: 'preview', + runChecked: new Set(), + fillValues: {}, + fillActive: 0, + searchQuery: '', }; const $ = (sel) => document.querySelector(sel); @@ -211,6 +220,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 `▸ ${escHtml(data.chose)}`; + 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 ` ${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 => `\${${escHtml(s)}}`).join(' '); + } + if (data.url) return `${escHtml(data.url.replace(/^https?:\/\//, ''))}`; + const first = (entity.body || '').split('\n')[0] || ''; + return escHtml(first.slice(0, 60)); + } + // ========== Rendering ========== function displayGlyph(entity) { @@ -223,32 +268,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 = `
- all -
`; + const total = state.tags.reduce((s, t) => s + t.count, 0); - rail.innerHTML = allItem + state.tags.map(t => - `
- ${t.tag} - ${t.count} -
` - ).join(''); + let html = `
nib
`; + html += '
'; - rail.querySelectorAll('.tag-item').forEach(el => { + if (state.view === 'cards') { + html += '
'; + html += '
intent
'; + 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 += `'; + } + html += '
'; + } + + html += '
'; + html += '
tags
'; + + const allOn = !state.activeTag ? ' on' : ''; + html += `'; + + for (const t of state.tags) { + const on = state.activeTag === t.tag ? ' on' : ''; + html += `'; + } + + html += '
'; + 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 = ` +
+ + + ⏎ save +
+ `; + + 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; @@ -266,27 +407,43 @@ function renderEntityList() { const list = $('#entity-list'); + const filtered = filterBySearch(state.entities); - if (state.entities.length === 0) { - list.innerHTML = '
no entities yet
'; + if (filtered.length === 0) { + list.innerHTML = `
${state.searchQuery ? 'no matches' : 'no entities yet'}
`; + renderCardsHeader(state.view === 'cards'); return; } let html = ''; if (state.view === 'stream') { - const groups = groupByDate(state.entities); + renderCardsHeader(false); + const groups = groupByDate(filtered); let idx = 0; for (const g of groups) { html += `
${g.label}
`; for (const e of g.entities) { - html += renderEntityItem(e, idx); + const realIdx = state.entities.indexOf(e); + html += renderEntityItem(e, realIdx); idx++; } } } else { - state.entities.forEach((e, idx) => { - html += renderEntityItem(e, idx); - }); + renderCardsHeader(true); + const pinned = filtered.filter(e => e.pinned); + const rest = filtered.filter(e => !e.pinned); + if (pinned.length) { + html += '
★ pinned
'; + for (const e of pinned) { + html += renderCardRow(e, state.entities.indexOf(e)); + } + } + if (rest.length) { + if (pinned.length) html += '
recent
'; + for (const e of rest) { + html += renderCardRow(e, state.entities.indexOf(e)); + } + } } if (state.hasMore) { @@ -301,17 +458,72 @@ }); }); + 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 = ` + ${scope} + ${state.entities.length} cards + + `; + } + + 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 => `#${t}`).join(''); + const affHtml = affs.map(a => `${AFF_LABELS[a]}`).join(''); + + return `
+ ${escHtml(title)} + + ${preview} +
+ ${affHtml} + ${tags} + ${e.pinned ? '' : ''} + ${e.use_count > 0 ? `${e.use_count}×` : ''} +
+
`; + } + function renderEntityItem(e, idx) { const glyph = displayGlyph(e); const gc = glyphClass(e); - const selected = idx === state.selectedIndex ? 'selected' : ''; - const tags = (e.tags || []).map(t => `${t}`).join(''); + const selected = idx === state.selectedIndex ? ' selected' : ''; + const isCard = e.card_type ? ' is-card' : ''; + const tags = (e.tags || []).slice(0, 2).map(t => `${t}`).join(''); const time = e.time_anchor ? `@${e.time_anchor}` : ''; const useBadge = e.use_count > 0 ? `${e.use_count}×` : ''; + const cardBadge = e.card_type ? `${e.card_type}` : ''; let label; if (e.title) { @@ -321,116 +533,357 @@ label = `${escHtml(e.body)}`; } - return `
+ return `
${glyph} ${label} ${time} - ${tags} + ${tags}${cardBadge} ${useBadge}
`; } + 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 = '
select an entity
'; + pane.innerHTML = renderPeekIdle(); pane.classList.remove('visible'); return; } pane.classList.add('visible'); + + 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 if (state.peekMode === 'edit') { + pane.innerHTML = renderEditMode(e); + } else { + pane.innerHTML = renderCardPeek(e); + } + + bindPeekEvents(e); + } + + function renderPeekIdle() { + const v = state.view; + return `
+
peek
+
Select ${v === 'cards' ? 'a card' : 'an entry'}.
+
${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.'}
+
+
+
navigate
+
jknext / prev
+
12stream / cards
+
+ ${v === 'stream' ? `
+
stream grammar
+
(bare text) = thought
+
- todo · @time event · !time reminder
+
#tag · |title · // desc · !pin
+
` : `
+
act
+
copy
+
rrun checklist
+
ffill template
+
eedit
+
ppin
+
`} +
+
`; + } + + 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 => `#${t}`).join(''); - const shortId = e.id.slice(0, 12); - let cardContent = ''; let actions = ''; - - if (e.card_type) { - cardContent = renderCardContent(e); - actions += ``; - actions += ``; - } else { - actions += ``; - actions += ``; + if (!e.card_type) { + actions += ``; } actions += ``; - const descHtml = e.description ? `
${escHtml(e.description)}
` : ''; - const titleHtml = e.title ? `

${escHtml(e.title)}

` : ''; - - pane.innerHTML = ` -
- ${glyph} - ${shortId} - ${e.time_anchor ? `@${e.time_anchor}` : ''} + return `
+
+ ${glyph} + ${kindLbl} + · + ${e.id.slice(-10)} + ${fmtDateLong(e.created_at)}
- ${descHtml} - ${titleHtml} -
${escHtml(e.body)}
- ${tags ? `
${tags}
` : ''} - ${cardContent} -
${actions}
- `; - - const titleEl = pane.querySelector('.detail-title'); - if (titleEl) titleEl.addEventListener('dblclick', () => startEditField('title')); - const descEl = pane.querySelector('.detail-desc'); - if (descEl) descEl.addEventListener('dblclick', () => startEditField('description')); - const bodyEl = pane.querySelector('.detail-body'); - if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody); + ${e.title ? `
${escHtml(e.title)}
` : ''} +
${escHtml(e.body)}
+ ${tags ? `
tags
${tags}
` : ''} +
+
context
+
+ created${fmtDateLong(e.created_at)} + ${e.time_anchor ? `time@${e.time_anchor}` : ''} + ${e.card_type ? `statuspromoted → ${e.card_type}` : ''} +
+
+
${actions}
+
`; } - function renderCardContent(e) { - if (!e.card_data) return ''; - let data; - try { data = JSON.parse(e.card_data); } catch { return ''; } + 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 => `#${t}`).join(''); + const affHtml = affs.map(a => `${AFF_LABELS[a]}`).join(''); + const hasSteps = data.steps && data.steps.length; + const hasDecision = data.chose != null; + const hasFill = /\$\{[^}]+\}/.test(e.body || ''); + const hasLink = !!data.url; - switch (e.card_type) { - case 'template': - if (!data.slots || !data.slots.length) return ''; - return `
- ${data.slots.map(s => ` -
- \${${s.name}} - -
- `).join('')} - -
`; + let sections = ''; - case 'checklist': - if (!data.steps || !data.steps.length) return ''; - return `
- ${data.steps.map((s, i) => ` -
- - ${escHtml(s.text)} -
- `).join('')} -
`; - - case 'decision': - return `
-
chose
${escHtml(data.chose || '—')}
-
why
${escHtml(data.why || '—')}
- ${data.rejected && data.rejected.length ? `
rejected
${data.rejected.map(escHtml).join(', ') || '—'}
` : ''} -
`; - - case 'link': - if (data.url && isSafeUrl(data.url)) { - return `
- -
`; - } - return ''; - - default: - return ''; + if (hasDecision) { + const rejected = (data.rejected || []).map(r => `${escHtml(r)}`).join(''); + sections += `
+
decision${data.status || 'decided'}
+
+
${escHtml(data.chose)}
+
why${escHtml(data.why || '')}
+ ${rejected ? `
considered
${rejected}
` : ''} +
+
`; } + + if (hasLink && !hasDecision) { + sections += `
+
link
+
+
`; + } + + if (hasSteps) { + const steps = data.steps.map((s, i) => `
${escHtml(s.text || s)}
`).join(''); + sections += `
+
steps · ${data.steps.length}
+
${steps}
+
`; + } + + if (!hasDecision && e.body) { + const lang = data.lang || ''; + sections += `
+
content${lang ? `${lang}` : ''}${hasFill ? `` : ''}
+
${escHtml(e.body)}
+
`; + } + + let actions = ``; + if (hasFill) actions += ``; + if (hasSteps) actions += ``; + actions += ``; + actions += ``; + actions += ``; + + return `
+
+
+
+ ${glyph} + ${e.card_type} + · + ${e.id.slice(-10)} + ${e.use_count > 0 ? `${e.use_count}× used` : ''} +
+
${escHtml(e.title || '')}
+ ${e.description ? `
${escHtml(e.description)}
` : ''} +
${affHtml}${tags}${e.pinned ? '' : ''}
+
+ ${sections} +
+
${actions}
+
`; + } + + 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 `
+ ${isDone ? '●' : '○'} + ${escHtml(text)} +
`; + }).join(''); + + return `
+
+ ▶ running + ${done}/${total} done +
+
${escHtml(e.title || '')}
+ ${e.description ? `
${escHtml(e.description)}
` : ''} +
+
+ ${pct}% +
+
${steps}
+
Space toggler resetEsc exit
+
+ + +
+
`; + } + + 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}}`, ``); + } + + const allFilled = slots.every(s => fill[s]); + + return `
+
+ ⤓ filling + slot ${active + 1} / ${slots.length} +
+
${escHtml(e.title || '')}
+ ${e.description ? `
${escHtml(e.description)}
` : ''} +
${content}
+
Tab next⇧Tab prev copyEsc cancel
+
+ + +
+
`; + } + + function renderEditMode(e) { + return `
+
✎ editing
+
${escHtml(e.title || 'untitled')}
+
+
+
+
+
+
+
+
+
+
+
⌘⏎ saveEsc cancel
+
+ + +
+
`; + } + + 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 ========== @@ -438,7 +891,7 @@ function startEditBody() { const e = state.entities[state.selectedIndex]; if (!e) return; - const el = $(`.detail-body[data-id="${e.id}"]`); + const el = $(`.peek-body[data-id="${e.id}"]`); if (!el || el.tagName === 'TEXTAREA') return; const ta = document.createElement('textarea'); @@ -470,8 +923,7 @@ function startEditField(field) { const e = state.entities[state.selectedIndex]; if (!e) return; - const cls = field === 'title' ? '.detail-title' : '.detail-desc'; - const el = $(`${cls}[data-id="${e.id}"]`); + const el = $(`.peek-title[data-id="${e.id}"]`); if (!el || el.tagName === 'INPUT') return; const input = document.createElement('input'); @@ -505,6 +957,10 @@ function selectEntity(idx) { state.selectedIndex = idx; + state.peekMode = 'preview'; + state.runChecked = new Set(); + state.fillValues = {}; + state.fillActive = 0; renderEntityList(); renderDetailPane(); } @@ -549,7 +1005,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 @@ -560,13 +1017,10 @@ ${label} - ${state.activeMonth ? '' : ''} `; $('#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 +1044,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) ========== @@ -605,7 +1074,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); } @@ -620,6 +1092,10 @@ 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); @@ -630,12 +1106,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 +1129,7 @@ await navigator.clipboard.writeText(resolved); await api.useEntity(id); await loadEntities(); + showToast('copied'); } catch (err) { console.error('clipboard:', err); } @@ -688,6 +1167,7 @@ await loadTags(); const idx = state.entities.findIndex(x => x.id === targetId); if (idx >= 0) selectEntity(idx); + showToast('absorbed'); }); }); @@ -704,35 +1184,69 @@ 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'); + }, }; - // ========== 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 +1259,7 @@ await api.promoteEntity(id, btn.dataset.type); await loadEntities(); await loadTags(); + showToast('promoted → ' + btn.dataset.type); }); }); @@ -761,12 +1276,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; } @@ -776,6 +1290,13 @@ return; } + if (state.peekMode !== 'preview' && ev.key === 'Escape') { + nibApp.exitMode(); + return; + } + + const sel = state.entities[state.selectedIndex]; + switch (ev.key) { case 'j': ev.preventDefault(); @@ -789,38 +1310,44 @@ break; case 'n': ev.preventDefault(); - captureInput.focus(); + $('#capture-input').focus(); break; - case 'p': { - const e = state.entities[state.selectedIndex]; - if (e && !e.card_type) nibApp.showPromote(e.id); + 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': { - const e = state.entities[state.selectedIndex]; - if (e) nibApp.copyEntity(e.id); + 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 && sel.card_type && state.view === 'cards') { + nibApp.enterMode('edit'); + } else { + startEditBody(); + } break; - } case 'd': { const now = Date.now(); if (now - lastDTime < 400) { - const e = state.entities[state.selectedIndex]; - if (e) nibApp.deleteEntity(e.id); + if (sel) nibApp.deleteEntity(sel.id); lastDTime = 0; } else { lastDTime = now; } break; } - case 'e': { - startEditBody(); + case 'a': + if (sel && !sel.card_type) nibApp.showAbsorb(sel.id); break; - } - case 'a': { - const e = state.entities[state.selectedIndex]; - if (e && !e.card_type) nibApp.showAbsorb(e.id); - break; - } case '1': switchView('stream'); break; case '2': switchView('cards'); break; } @@ -848,10 +1375,46 @@ } $$('.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 filterBySearch(entities) { + if (!state.searchQuery) return entities; + let query = state.searchQuery; + let filterTags = []; + query = query.replace(/#(\S+)/g, (_, tag) => { filterTags.push(tag); return ''; }).trim(); + return entities.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) { @@ -884,6 +1447,7 @@ // ========== Init ========== async function init() { + renderCaptureBar(); await Promise.all([loadEntities(), loadTags()]); handleHash(); renderMonthNav(); diff --git a/web/index.html b/web/index.html index 0c8e0fd..42aed17 100644 --- a/web/index.html +++ b/web/index.html @@ -6,28 +6,22 @@ nib - + -
-

nib

+
-
- -
+
@@ -35,6 +29,7 @@
+