feat(web): add vanilla JS/CSS SPA with embed.FS
Stream view with date grouping, card view sorted by usage, capture bar with client-side grammar parsing, tag rail filter, detail pane with card affordances (template slot fill, checklist toggle, link open), promote modal with auto-detect, keyboard shortcuts (j/k/n/p/ Enter/dd/1/2). Dark theme, responsive layout. Embedded in Go binary.
This commit is contained in:
+597
@@ -0,0 +1,597 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const GLYPHS = {
|
||||
note: '◦', todo: '▸', event: '◇',
|
||||
snippet: '◆', template: '◈', checklist: '☐',
|
||||
decision: '⚖', link: '🔗',
|
||||
};
|
||||
|
||||
const GLYPH_CLASSES = {
|
||||
note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event',
|
||||
snippet: 'glyph-snippet', template: 'glyph-template',
|
||||
checklist: 'glyph-checklist', decision: 'glyph-decision',
|
||||
link: 'glyph-link',
|
||||
};
|
||||
|
||||
const state = {
|
||||
view: 'stream',
|
||||
entities: [],
|
||||
tags: [],
|
||||
selectedIndex: -1,
|
||||
activeTag: null,
|
||||
};
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => document.querySelectorAll(sel);
|
||||
|
||||
// ========== API ==========
|
||||
|
||||
const api = {
|
||||
async listEntities(params = {}) {
|
||||
const q = new URLSearchParams();
|
||||
if (params.tag) q.set('tag', params.tag);
|
||||
if (params.date) q.set('date', params.date);
|
||||
if (params.cards_only) q.set('cards_only', 'true');
|
||||
if (params.sort) q.set('sort', params.sort);
|
||||
if (params.order) q.set('order', params.order);
|
||||
const resp = await fetch('/api/entities?' + q);
|
||||
return resp.json();
|
||||
},
|
||||
async createEntity(data) {
|
||||
const resp = await fetch('/api/entities', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return resp.json();
|
||||
},
|
||||
async getEntity(id) {
|
||||
const resp = await fetch('/api/entities/' + id);
|
||||
return resp.json();
|
||||
},
|
||||
async updateEntity(id, data) {
|
||||
const resp = await fetch('/api/entities/' + id, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return resp.json();
|
||||
},
|
||||
async deleteEntity(id) {
|
||||
return fetch('/api/entities/' + id, { method: 'DELETE' });
|
||||
},
|
||||
async promoteEntity(id, cardType, cardData) {
|
||||
const resp = await fetch('/api/entities/' + id + '/promote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ card_type: cardType, card_data: cardData }),
|
||||
});
|
||||
return resp.json();
|
||||
},
|
||||
async demoteEntity(id) {
|
||||
const resp = await fetch('/api/entities/' + id + '/demote', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
return resp.json();
|
||||
},
|
||||
async useEntity(id) {
|
||||
const resp = await fetch('/api/entities/' + id + '/use', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
return resp.json();
|
||||
},
|
||||
async listTags() {
|
||||
const resp = await fetch('/api/tags');
|
||||
return resp.json();
|
||||
},
|
||||
};
|
||||
|
||||
// ========== Grammar parser (mirrors Go parser) ==========
|
||||
|
||||
const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' };
|
||||
|
||||
function parseInput(input) {
|
||||
input = input.trim();
|
||||
if (!input) return null;
|
||||
|
||||
const tokens = input.split(/\s+/);
|
||||
let glyph = 'note';
|
||||
|
||||
const first = tokens[0];
|
||||
if (first === '-' || first === '▸') { glyph = 'todo'; tokens.shift(); }
|
||||
else if (first === '*' || first === '◇') { glyph = 'event'; tokens.shift(); }
|
||||
|
||||
const bodyParts = [];
|
||||
let timeAnchor = null;
|
||||
const tags = [];
|
||||
const seenTags = {};
|
||||
let cardSuffix = null;
|
||||
|
||||
for (const tok of tokens) {
|
||||
if (tok.startsWith('@') && tok.length > 1) {
|
||||
timeAnchor = tok.slice(1);
|
||||
} else if (tok.startsWith('#') && tok.length > 1) {
|
||||
const tag = tok.slice(1);
|
||||
if (!seenTags[tag]) { tags.push(tag); seenTags[tag] = true; }
|
||||
} else if (tok.startsWith('^') && tok.length > 1) {
|
||||
const suffix = tok.slice(1);
|
||||
if (VALID_CARDS[suffix]) cardSuffix = VALID_CARDS[suffix];
|
||||
} else {
|
||||
bodyParts.push(tok);
|
||||
}
|
||||
}
|
||||
|
||||
const body = bodyParts.join(' ');
|
||||
if (!body) return null;
|
||||
|
||||
return { body, glyph, timeAnchor, tags, cardSuffix };
|
||||
}
|
||||
|
||||
function detectCardType(body) {
|
||||
if (/\$\{.+\}/.test(body)) return 'template';
|
||||
if (/\bchose:|why:/.test(body)) return 'decision';
|
||||
if (/\[ \]|\d+\./.test(body)) return 'checklist';
|
||||
if (/https?:\/\//.test(body)) return 'link';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========== Rendering ==========
|
||||
|
||||
function displayGlyph(entity) {
|
||||
return entity.card_type ? GLYPHS[entity.card_type] : GLYPHS[entity.glyph];
|
||||
}
|
||||
|
||||
function glyphClass(entity) {
|
||||
return entity.card_type ? GLYPH_CLASSES[entity.card_type] : GLYPH_CLASSES[entity.glyph];
|
||||
}
|
||||
|
||||
function formatDate(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();
|
||||
}
|
||||
|
||||
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>`;
|
||||
|
||||
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('');
|
||||
|
||||
rail.querySelectorAll('.tag-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
state.activeTag = el.dataset.tag || null;
|
||||
loadEntities();
|
||||
renderTagRail();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function groupByDate(entities) {
|
||||
const groups = [];
|
||||
let current = null;
|
||||
for (const e of entities) {
|
||||
const label = formatDate(e.created_at);
|
||||
if (!current || current.label !== label) {
|
||||
if (current) groups.push(current);
|
||||
current = { label, entities: [] };
|
||||
}
|
||||
current.entities.push(e);
|
||||
}
|
||||
if (current) groups.push(current);
|
||||
return groups;
|
||||
}
|
||||
|
||||
function renderEntityList() {
|
||||
const list = $('#entity-list');
|
||||
|
||||
if (state.entities.length === 0) {
|
||||
list.innerHTML = '<div class="detail-empty" style="margin-top:40px">no entities yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
if (state.view === 'stream') {
|
||||
const groups = groupByDate(state.entities);
|
||||
let idx = 0;
|
||||
for (const g of groups) {
|
||||
html += `<div class="date-header">── ${g.label} ──</div>`;
|
||||
for (const e of g.entities) {
|
||||
html += renderEntityItem(e, idx);
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.entities.forEach((e, idx) => {
|
||||
html += renderEntityItem(e, idx);
|
||||
});
|
||||
}
|
||||
|
||||
list.innerHTML = html;
|
||||
|
||||
list.querySelectorAll('.entity-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
selectEntity(parseInt(el.dataset.index));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 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>` : '';
|
||||
|
||||
return `<div class="entity-item ${selected}" data-index="${idx}" data-id="${e.id}">
|
||||
<span class="entity-glyph ${gc}">${glyph}</span>
|
||||
<span class="entity-body">${escHtml(e.body)}</span>
|
||||
${time}
|
||||
<span class="entity-tags">${tags}</span>
|
||||
<span class="entity-meta">${useBadge}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
pane.classList.add('visible');
|
||||
const glyph = displayGlyph(e);
|
||||
const gc = glyphClass(e);
|
||||
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" 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 danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
|
||||
|
||||
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>
|
||||
<div class="detail-body">${escHtml(e.body)}</div>
|
||||
${tags ? `<div class="detail-tags">${tags}</div>` : ''}
|
||||
${cardContent}
|
||||
<div class="detail-actions">${actions}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCardContent(e) {
|
||||
if (!e.card_data) return '';
|
||||
let data;
|
||||
try { data = JSON.parse(e.card_data); } catch { return ''; }
|
||||
|
||||
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>`;
|
||||
|
||||
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) {
|
||||
return `<div style="margin-bottom:12px">
|
||||
<button class="action-btn" onclick="window.open('${escAttr(data.url)}', '_blank')">open link</button>
|
||||
</div>`;
|
||||
}
|
||||
return '';
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Actions ==========
|
||||
|
||||
function selectEntity(idx) {
|
||||
state.selectedIndex = idx;
|
||||
renderEntityList();
|
||||
renderDetailPane();
|
||||
}
|
||||
|
||||
async function loadEntities() {
|
||||
const params = {};
|
||||
if (state.activeTag) params.tag = state.activeTag;
|
||||
if (state.view === 'cards') {
|
||||
params.cards_only = true;
|
||||
params.sort = 'use_count';
|
||||
params.order = 'desc';
|
||||
} else {
|
||||
params.sort = 'created';
|
||||
params.order = 'desc';
|
||||
}
|
||||
|
||||
state.entities = await api.listEntities(params);
|
||||
state.selectedIndex = -1;
|
||||
renderEntityList();
|
||||
renderDetailPane();
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
state.tags = await api.listTags();
|
||||
renderTagRail();
|
||||
}
|
||||
|
||||
function switchView(view) {
|
||||
state.view = view;
|
||||
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
|
||||
window.location.hash = view === 'cards' ? '/cards' : '/';
|
||||
loadEntities();
|
||||
}
|
||||
|
||||
// ========== Public API (for inline handlers) ==========
|
||||
|
||||
window.nibApp = {
|
||||
async copyEntity(id) {
|
||||
const e = state.entities.find(x => x.id === id);
|
||||
if (!e) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(e.body);
|
||||
await api.useEntity(id);
|
||||
await loadEntities();
|
||||
} catch (err) {
|
||||
console.error('clipboard:', err);
|
||||
}
|
||||
},
|
||||
|
||||
showPromote(id) {
|
||||
const e = state.entities.find(x => x.id === id);
|
||||
if (!e || e.card_type) return;
|
||||
|
||||
const modal = $('#promote-modal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('visible');
|
||||
modal.dataset.entityId = id;
|
||||
|
||||
const suggested = detectCardType(e.body);
|
||||
$$('.type-btn').forEach(btn => {
|
||||
btn.classList.toggle('suggested', btn.dataset.type === suggested);
|
||||
});
|
||||
},
|
||||
|
||||
async demoteEntity(id) {
|
||||
await api.demoteEntity(id);
|
||||
await loadEntities();
|
||||
await loadTags();
|
||||
},
|
||||
|
||||
async deleteEntity(id) {
|
||||
await api.deleteEntity(id);
|
||||
await loadEntities();
|
||||
await loadTags();
|
||||
},
|
||||
|
||||
async resolveTemplate(id) {
|
||||
const e = state.entities.find(x => x.id === id);
|
||||
if (!e) return;
|
||||
let resolved = e.body;
|
||||
$$('.slot-input').forEach(input => {
|
||||
const name = input.dataset.slot;
|
||||
const val = input.value || input.placeholder;
|
||||
resolved = resolved.replace(new RegExp('\\$\\{' + name + '\\}', 'g'), val);
|
||||
});
|
||||
try {
|
||||
await navigator.clipboard.writeText(resolved);
|
||||
await api.useEntity(id);
|
||||
await loadEntities();
|
||||
} catch (err) {
|
||||
console.error('clipboard:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async toggleStep(id, stepIdx) {
|
||||
const e = state.entities.find(x => x.id === id);
|
||||
if (!e || !e.card_data) return;
|
||||
const data = JSON.parse(e.card_data);
|
||||
data.steps[stepIdx].done = !data.steps[stepIdx].done;
|
||||
await api.updateEntity(id, { card_data: JSON.stringify(data) });
|
||||
await loadEntities();
|
||||
selectEntity(state.entities.findIndex(x => x.id === id));
|
||||
},
|
||||
};
|
||||
|
||||
// ========== 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.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 => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const modal = $('#promote-modal');
|
||||
const id = modal.dataset.entityId;
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('visible');
|
||||
|
||||
await api.promoteEntity(id, btn.dataset.type);
|
||||
await loadEntities();
|
||||
await loadTags();
|
||||
});
|
||||
});
|
||||
|
||||
$('.modal-backdrop').addEventListener('click', closeModal);
|
||||
$('.modal-close').addEventListener('click', closeModal);
|
||||
|
||||
function closeModal() {
|
||||
const modal = $('#promote-modal');
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('visible');
|
||||
}
|
||||
|
||||
// ========== Keyboard shortcuts ==========
|
||||
|
||||
let lastDTime = 0;
|
||||
const captureInput = $('#capture-input');
|
||||
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (document.activeElement === captureInput) {
|
||||
if (ev.key === 'Escape') captureInput.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
if ($('#promote-modal').classList.contains('visible')) {
|
||||
if (ev.key === 'Escape') closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (ev.key) {
|
||||
case 'j':
|
||||
ev.preventDefault();
|
||||
selectEntity(Math.min(state.selectedIndex + 1, state.entities.length - 1));
|
||||
scrollSelectedIntoView();
|
||||
break;
|
||||
case 'k':
|
||||
ev.preventDefault();
|
||||
selectEntity(Math.max(state.selectedIndex - 1, 0));
|
||||
scrollSelectedIntoView();
|
||||
break;
|
||||
case 'n':
|
||||
ev.preventDefault();
|
||||
captureInput.focus();
|
||||
break;
|
||||
case 'p': {
|
||||
const e = state.entities[state.selectedIndex];
|
||||
if (e && !e.card_type) nibApp.showPromote(e.id);
|
||||
break;
|
||||
}
|
||||
case 'Enter': {
|
||||
const e = state.entities[state.selectedIndex];
|
||||
if (e) nibApp.copyEntity(e.id);
|
||||
break;
|
||||
}
|
||||
case 'd': {
|
||||
const now = Date.now();
|
||||
if (now - lastDTime < 400) {
|
||||
const e = state.entities[state.selectedIndex];
|
||||
if (e) nibApp.deleteEntity(e.id);
|
||||
lastDTime = 0;
|
||||
} else {
|
||||
lastDTime = now;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '1': switchView('stream'); break;
|
||||
case '2': switchView('cards'); break;
|
||||
}
|
||||
});
|
||||
|
||||
function scrollSelectedIntoView() {
|
||||
const el = $(`.entity-item[data-index="${state.selectedIndex}"]`);
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
// ========== View nav buttons ==========
|
||||
|
||||
$$('.nav-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => switchView(btn.dataset.view));
|
||||
});
|
||||
|
||||
// ========== Hash routing ==========
|
||||
|
||||
function handleHash() {
|
||||
const hash = window.location.hash;
|
||||
if (hash === '#/cards') {
|
||||
state.view = 'cards';
|
||||
} else {
|
||||
state.view = 'stream';
|
||||
}
|
||||
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === state.view));
|
||||
loadEntities();
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', handleHash);
|
||||
|
||||
// ========== Utils ==========
|
||||
|
||||
function escHtml(s) {
|
||||
if (!s) return '';
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
return escHtml(s).replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ========== Init ==========
|
||||
|
||||
async function init() {
|
||||
await Promise.all([loadEntities(), loadTags()]);
|
||||
handleHash();
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>nib</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<h1 class="logo">nib</h1>
|
||||
<nav>
|
||||
<button data-view="stream" class="nav-btn active">stream</button>
|
||||
<button data-view="cards" class="nav-btn">cards</button>
|
||||
</nav>
|
||||
</div>
|
||||
<form id="capture-bar" autocomplete="off">
|
||||
<input type="text" id="capture-input" placeholder="capture... (n to focus)" spellcheck="false">
|
||||
</form>
|
||||
</header>
|
||||
<main>
|
||||
<aside id="tag-rail"></aside>
|
||||
<section id="entity-list"></section>
|
||||
<aside id="detail-pane">
|
||||
<div class="detail-empty">select an entity</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="promote-modal" class="modal hidden">
|
||||
<div class="modal-backdrop"></div>
|
||||
<div class="modal-content">
|
||||
<h3>promote to card</h3>
|
||||
<div class="type-picker">
|
||||
<button data-type="snippet" class="type-btn">
|
||||
<span class="type-glyph">◆</span>
|
||||
<span>snippet</span>
|
||||
</button>
|
||||
<button data-type="template" class="type-btn">
|
||||
<span class="type-glyph">◈</span>
|
||||
<span>template</span>
|
||||
</button>
|
||||
<button data-type="checklist" class="type-btn">
|
||||
<span class="type-glyph">☐</span>
|
||||
<span>checklist</span>
|
||||
</button>
|
||||
<button data-type="decision" class="type-btn">
|
||||
<span class="type-glyph">⚖</span>
|
||||
<span>decision</span>
|
||||
</button>
|
||||
<button data-type="link" class="type-btn">
|
||||
<span class="type-glyph">🔗</span>
|
||||
<span>link</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="modal-close">esc to cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
:root {
|
||||
--bg: #1a1b26;
|
||||
--bg-surface: #24283b;
|
||||
--bg-hover: #292e42;
|
||||
--bg-selected: #33394d;
|
||||
--text: #c0caf5;
|
||||
--text-dim: #565f89;
|
||||
--text-muted: #3b4261;
|
||||
--accent: #7aa2f7;
|
||||
--accent-dim: #3d59a1;
|
||||
--green: #9ece6a;
|
||||
--red: #f7768e;
|
||||
--yellow: #e0af68;
|
||||
--orange: #ff9e64;
|
||||
--purple: #bb9af7;
|
||||
--cyan: #7dcfff;
|
||||
--border: #292e42;
|
||||
--radius: 6px;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nav-btn:hover { color: var(--text); background: var(--bg-hover); }
|
||||
.nav-btn.active { color: var(--accent); border-color: var(--accent-dim); background: var(--bg); }
|
||||
|
||||
#capture-bar {
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
#capture-input {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
#capture-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
#capture-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Main layout */
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr 320px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Tag rail */
|
||||
#tag-rail {
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 12px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.tag-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tag-item.active { color: var(--accent); background: var(--bg-selected); }
|
||||
|
||||
.tag-name { font-family: var(--font-mono); }
|
||||
.tag-name::before { content: '#'; color: var(--text-muted); }
|
||||
.tag-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
min-width: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Entity list */
|
||||
#entity-list {
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.date-header {
|
||||
padding: 8px 20px 4px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.entity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.entity-item:hover { background: var(--bg-hover); }
|
||||
.entity-item.selected {
|
||||
background: var(--bg-selected);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
.entity-glyph {
|
||||
font-size: 14px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.glyph-note { color: var(--text-dim); }
|
||||
.glyph-todo { color: var(--green); }
|
||||
.glyph-event { color: var(--yellow); }
|
||||
.glyph-snippet { color: var(--accent); }
|
||||
.glyph-template { color: var(--purple); }
|
||||
.glyph-checklist { color: var(--orange); }
|
||||
.glyph-decision { color: var(--cyan); }
|
||||
.glyph-link { color: var(--red); }
|
||||
|
||||
.entity-body {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entity-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entity-tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entity-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--accent-dim);
|
||||
background: rgba(122, 162, 247, 0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.entity-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.use-badge {
|
||||
color: var(--yellow);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Detail pane */
|
||||
#detail-pane {
|
||||
border-left: 1px solid var(--border);
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-glyph { font-size: 20px; }
|
||||
|
||||
.detail-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 16px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
background: rgba(122, 162, 247, 0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.action-btn.primary { background: var(--accent-dim); border-color: var(--accent); color: white; }
|
||||
.action-btn.danger { border-color: var(--red); color: var(--red); }
|
||||
.action-btn.danger:hover { background: rgba(247, 118, 142, 0.1); }
|
||||
|
||||
/* Template slot form */
|
||||
.slot-form { margin: 16px 0; }
|
||||
|
||||
.slot-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.slot-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--purple);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.slot-input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.slot-input:focus { border-color: var(--purple); }
|
||||
|
||||
/* Checklist */
|
||||
.checklist-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.checklist-step input[type="checkbox"] {
|
||||
accent-color: var(--green);
|
||||
}
|
||||
|
||||
.checklist-step.done span {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Decision card */
|
||||
.decision-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.decision-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--cyan);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.decision-value {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal { display: none; }
|
||||
.modal.visible { display: flex; }
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
z-index: 101;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
margin-bottom: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.type-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.type-btn:hover { border-color: var(--accent); background: var(--bg-hover); }
|
||||
.type-btn.suggested { border-color: var(--accent-dim); background: rgba(122, 162, 247, 0.05); }
|
||||
|
||||
.type-glyph { font-size: 16px; width: 24px; text-align: center; }
|
||||
|
||||
.modal-close {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
main { grid-template-columns: 1fr; }
|
||||
#tag-rail { display: none; }
|
||||
#detail-pane {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: auto;
|
||||
height: 50vh;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border);
|
||||
border-left: none;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.2s;
|
||||
z-index: 50;
|
||||
}
|
||||
#detail-pane.visible { transform: translateY(0); }
|
||||
}
|
||||
Reference in New Issue
Block a user