feat(ui): phase 3 — peek pane redesign with modes
- Full peek pane rewrite: idle state, stream peek, card peek - Idle state shows keyboard shortcuts per view - Stream peek: eyebrow (glyph + kind + id + timestamp), body, tags, context - Card peek: card container with eyebrow, title, desc, meta, content sections - Decision section with choice/rationale/rejected display - Steps section with run button - Code section with content display - Run mode: interactive checklist with progress bar + step toggling - Fill mode: inline slot editor with tab navigation + copy resolved - Edit mode: form fields for title/desc/body/tags - Mode pills (running/filling/editing) with colored badges - Pin/unpin action via keyboard (p) and button - Escape exits any active mode - Keyboard shortcuts: r=run, f=fill, e=edit, p=pin in cards view
This commit is contained in:
+417
-96
@@ -28,6 +28,10 @@
|
||||
activeMonth: null,
|
||||
intent: 'grab',
|
||||
flashId: null,
|
||||
peekMode: 'preview',
|
||||
runChecked: new Set(),
|
||||
fillValues: {},
|
||||
fillActive: 0,
|
||||
};
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
@@ -536,109 +540,348 @@
|
||||
</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 = '<div class="detail-empty">select an entity</div>';
|
||||
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 `<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('');
|
||||
const shortId = e.id.slice(0, 12);
|
||||
|
||||
let cardContent = '';
|
||||
let actions = '';
|
||||
|
||||
if (e.card_type) {
|
||||
cardContent = renderCardContent(e);
|
||||
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 {
|
||||
if (!e.card_type) {
|
||||
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>`;
|
||||
|
||||
const descHtml = e.description ? `<div class="detail-desc" data-id="${e.id}">${escHtml(e.description)}</div>` : '';
|
||||
const titleHtml = e.title ? `<h2 class="detail-title" data-id="${e.id}">${escHtml(e.title)}</h2>` : '';
|
||||
|
||||
pane.innerHTML = `
|
||||
<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>
|
||||
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>
|
||||
`;
|
||||
|
||||
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 ? `<div class="peek-title" data-id="${e.id}">${escHtml(e.title)}</div>` : ''}
|
||||
<div class="peek-body" data-id="${e.id}">${escHtml(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 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 => `<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;
|
||||
|
||||
switch (e.card_type) {
|
||||
case 'template':
|
||||
if (!data.slots || !data.slots.length) return '';
|
||||
return `<div class="slot-form">
|
||||
${data.slots.map(s => `
|
||||
<div class="slot-field">
|
||||
<span class="slot-label">\${${s.name}}</span>
|
||||
<input class="slot-input" data-slot="${s.name}" placeholder="${s.default || s.name}" value="${s.default || ''}">
|
||||
</div>
|
||||
`).join('')}
|
||||
<button class="action-btn primary" onclick="nibApp.resolveTemplate('${e.id}')">resolve & copy</button>
|
||||
</div>`;
|
||||
let sections = '';
|
||||
|
||||
case 'checklist':
|
||||
if (!data.steps || !data.steps.length) return '';
|
||||
return `<div class="checklist">
|
||||
${data.steps.map((s, i) => `
|
||||
<div class="checklist-step ${s.done ? 'done' : ''}">
|
||||
<input type="checkbox" ${s.done ? 'checked' : ''} onchange="nibApp.toggleStep('${e.id}', ${i})">
|
||||
<span>${escHtml(s.text)}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>`;
|
||||
|
||||
case 'decision':
|
||||
return `<div>
|
||||
<div class="decision-field"><div class="decision-label">chose</div><div class="decision-value">${escHtml(data.chose || '—')}</div></div>
|
||||
<div class="decision-field"><div class="decision-label">why</div><div class="decision-value">${escHtml(data.why || '—')}</div></div>
|
||||
${data.rejected && data.rejected.length ? `<div class="decision-field"><div class="decision-label">rejected</div><div class="decision-value">${data.rejected.map(escHtml).join(', ') || '—'}</div></div>` : ''}
|
||||
</div>`;
|
||||
|
||||
case 'link':
|
||||
if (data.url && isSafeUrl(data.url)) {
|
||||
return `<div style="margin-bottom:12px">
|
||||
<button class="action-btn" onclick="window.open('${escAttr(data.url)}', '_blank')">open link</button>
|
||||
</div>`;
|
||||
}
|
||||
return '';
|
||||
|
||||
default:
|
||||
return '';
|
||||
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 || '';
|
||||
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"><div class="peek-code"><pre><code>${escHtml(e.body)}</code></pre></div></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.deleteEntity('${e.id}')">delete</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 ==========
|
||||
@@ -713,6 +956,10 @@
|
||||
|
||||
function selectEntity(idx) {
|
||||
state.selectedIndex = idx;
|
||||
state.peekMode = 'preview';
|
||||
state.runChecked = new Set();
|
||||
state.fillValues = {};
|
||||
state.fillActive = 0;
|
||||
renderEntityList();
|
||||
renderDetailPane();
|
||||
}
|
||||
@@ -932,6 +1179,67 @@
|
||||
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');
|
||||
},
|
||||
};
|
||||
|
||||
// ========== Promote modal ==========
|
||||
@@ -977,6 +1285,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();
|
||||
@@ -992,36 +1307,42 @@
|
||||
ev.preventDefault();
|
||||
$('#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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user