Files
second-brain/templates/dashboard.html

784 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width; initial-scale=1.0; maximum-scale=1.0; user-scalable=no">
<title>🧠 Second Brain</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app">
<!-- Stats Header -->
<header class="stats-bar" id="statsBar">
<div class="stat"><span class="stat-num" id="statTotal">-</span><span class="stat-label">Total</span></div>
<div class="stat"><span class="stat-num" id="statConfirmed">-</span><span class="stat-label">OK</span></div>
<div class="stat"><span class="stat-num" id="statPending">-</span><span class="stat-label">Pending</span></div>
<div class="stat"><span class="stat-num" id="statErrors">-</span><span class="stat-label">Err</span></div>
</header>
<div class="tabs-bar">
<button class="tab-btn active" id="tabCards" onclick="setView('cards')">Cards</button>
<button class="tab-btn" id="tabGraph" onclick="setView('graph')">Graph</button>
<button class="tab-btn" id="tabStatus" onclick="setView('status')">Status</button>
</div>
<!-- Search -->
<div class="search-box">
<input type="text" id="searchInput" placeholder="🔍 Suche..." />
<select id="filterSelect">
<option value="all">Alle</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="rejected">Rejected</option>
<option value="errors">Errors</option>
</select>
</div>
<!-- New Engram -->
<div class="new-engram">
<textarea id="newContent" placeholder="Neues Engramm..."></textarea>
<input type="text" id="newTags" placeholder="Tags (comma sep)" />
<button onclick="createEngram()"> Speichern</button>
</div>
<!-- Cards -->
<div class="cards" id="cards"></div>
<!-- Graph -->
<div class="graph" id="graph" style="display:none;">
<div class="graph-controls">
<button class="btn primary" id="btnGraphPhysics" onclick="toggleGraphPhysics()">Physics: off</button>
<button class="btn" onclick="resetGraphView()">Reset view</button>
<button class="btn" onclick="fitGraphView()">Fit</button>
<button class="btn" onclick="reloadGraph()">Reload</button>
</div>
<canvas id="graphCanvas" width="440" height="520"></canvas>
<div class="graph-hint muted small" id="graphHint">Lade Graph…</div>
<div class="graph-legend">
<div><strong>Graph</strong>: Zoom per Pinch (2 Finger), Pan per Drag (1 Finger). Tap auf Engram öffnet Details, Tap auf Tag setzt Suche.</div>
<div class="legend-row"><span class="legend-dot engram"></span> Engram</div>
<div class="legend-row"><span class="legend-dot tag"></span> Tag</div>
<div class="legend-row"><span class="legend-dot match"></span> Match (Suche)</div>
</div>
</div>
<!-- Status -->
<div class="status" id="status" style="display:none;"></div>
<!-- Pagination -->
<div class="pagination" id="pagination">
<button id="btnPrev" onclick="prevPage()"></button>
<span id="pageNum">1</span>
<button id="btnNext" onclick="nextPage()"></button>
</div>
<div class="footer">
<span id="lastUpdate">--:--</span>
<button onclick="manualRefresh()" class="refresh-btn"></button>
</div>
</div>
<!-- Detail Modal -->
<div class="modal" id="detailModal">
<div class="modal-content">
<button class="close-btn" onclick="closeModal()">×</button>
<div id="modalBody"></div>
</div>
</div>
<script>
// ─── State ──────────────────────────────────────────────────────────────────
let state = {
items: [],
offset: 0,
limit: 10,
filter: 'all',
search: '',
autoRefresh: true,
view: 'cards',
lastEvent: null,
};
// ─── Fetch ──────────────────────────────────────────────────────────────────
async function api(path, opts = {}) {
const r = await fetch(path, opts);
if (!r.ok) throw new Error((await r.json()).error || r.statusText);
return r.json();
}
async function loadStats() {
const s = await api('/api/stats');
document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed;
document.getElementById('statPending').textContent = s.pending;
document.getElementById('statErrors').textContent = s.errors;
}
function updateStatsFromEvent(ev) {
if (!ev || !ev.stats) return;
const s = ev.stats;
document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed;
document.getElementById('statPending').textContent = s.pending;
document.getElementById('statErrors').textContent = s.errors;
}
function setView(view) {
state.view = view;
document.getElementById('tabCards').classList.toggle('active', view === 'cards');
document.getElementById('tabGraph').classList.toggle('active', view === 'graph');
document.getElementById('tabStatus').classList.toggle('active', view === 'status');
document.getElementById('cards').style.display = view === 'cards' ? '' : 'none';
document.getElementById('pagination').style.display = view === 'cards' ? '' : 'none';
document.getElementById('graph').style.display = view === 'graph' ? '' : 'none';
document.getElementById('status').style.display = view === 'status' ? '' : 'none';
if (view === 'graph') loadGraph();
if (view === 'status') loadStatus();
}
async function loadCards() {
let url = `/api/engrams?limit=${state.limit}&offset=${state.offset}`;
if (state.search) url += `&search=${encodeURIComponent(state.search)}`;
if (state.filter === 'confirmed') url += '&confirmed=1';
if (state.filter === 'pending') url += '&confirmed=0';
if (state.filter === 'rejected') url += '&verdict=confirmed_false';
if (state.filter === 'errors') url += '&tag=error';
const data = await api(url);
state.items = data.items;
renderCards();
document.getElementById('pageNum').textContent = Math.floor(state.offset / state.limit) + 1;
document.getElementById('btnPrev').disabled = state.offset === 0;
document.getElementById('btnNext').disabled = data.items.length < state.limit;
}
async function loadStatus() {
const [cfg, db, jobs, ins, stor] = await Promise.all([
api('/api/config'),
api('/api/db_info'),
api('/api/jobs'),
api('/api/insights?limit=8'),
api('/api/storage_stats'),
]);
const pend = await api('/api/pending?limit=20&offset=0');
const el = document.getElementById('status');
const jobsHtml = (jobs.items || []).map(j => `
<div class="kv-row">
<div class="kv-key">${j.unit}</div>
<div class="kv-val">${j.error ? ('ERR: ' + j.error) : (j.active + '/' + j.sub)}</div>
</div>
`).join('');
const topTags = (ins.top_tags || []).map(t => `<span class="pill">${t.key} (${t.count})</span>`).join(' ');
const topHosts = (ins.top_hosts || []).map(t => `<span class="pill">${t.key} (${t.count})</span>`).join(' ');
const bySource = Object.entries((stor.sql && stor.sql.by_source) ? stor.sql.by_source : {})
.slice(0, 8)
.map(([k,v]) => `<span class="pill">${k}: ${v}</span>`)
.join(' ');
const pendItems = (pend.items || []);
const pendHtml = pendItems.map(p => `
<div class="kv-row" onclick="showDetail('${p.id}')">
<div class="kv-key">${(p.source||'').slice(0,12)}</div>
<div class="kv-val">
<span class="pill">${p.id.substring(0,8)}</span>
${escapeHtml((p.content||'').substring(0,120))}${(p.content||'').length>120?'…':''}
<div class="actions" style="margin-top:6px" onclick="event.stopPropagation()">
<button class="btn-ok" onclick="confirm('${p.id}', event)">✅</button>
<button class="btn-no" onclick="reject('${p.id}', event)">❌</button>
</div>
</div>
</div>
`).join('');
el.innerHTML = `
<div class="panel">
<div class="panel-title">Config</div>
<div class="kv-row"><div class="kv-key">workspace</div><div class="kv-val">${cfg.workspace}</div></div>
<div class="kv-row"><div class="kv-key">db</div><div class="kv-val">${db.db_path}</div></div>
<div class="kv-row"><div class="kv-key">db mtime</div><div class="kv-val">${new Date(db.mtime).toLocaleString()}</div></div>
</div>
<div class="panel">
<div class="panel-title">Storage</div>
<div class="kv-row"><div class="kv-key">SQL</div><div class="kv-val">${stor.sql.total_engrams} engrams (ok ${stor.sql.confirmed}, pending ${stor.sql.pending})</div></div>
<div class="kv-row"><div class="kv-key">Vector</div><div class="kv-val">chroma ${(stor.vector.chroma_size_bytes/1024/1024).toFixed(1)} MB, cache ${stor.vector.embedding_cache_files} files</div></div>
<div class="kv-row"><div class="kv-key">Obsidian</div><div class="kv-val">${stor.obsidian.configured ? 'configured' : 'not configured'}</div></div>
<div class="kv-row"><div class="kv-key">By source</div><div class="kv-val">${bySource || '-'}</div></div>
</div>
<div class="panel">
<div class="panel-title">Jobs</div>
${jobsHtml || '<div class="muted">Keine Daten</div>'}
</div>
<div class="panel">
<div class="panel-title">Insights</div>
<div class="kv-row"><div class="kv-key">pending</div><div class="kv-val">${ins.pending}</div></div>
<div class="kv-row"><div class="kv-key">top tags</div><div class="kv-val">${topTags || '-'}</div></div>
<div class="kv-row"><div class="kv-key">top hosts</div><div class="kv-val">${topHosts || '-'}</div></div>
</div>
<div class="panel">
<div class="panel-title">Pending Queue (latest)</div>
${pendHtml || '<div class="muted">Keine Pendings</div>'}
</div>
`;
}
async function loadGraph() {
const g = await api('/api/graph?limit_nodes=200');
renderGraph(g.nodes || [], g.edges || []);
}
function reloadGraph() { loadGraph(); }
// ─── Graph Renderer (Canvas) ────────────────────────────────────────────────
let graphState = {
nodes: [],
edges: [],
sim: [],
links: [],
nodeById: new Map(),
simById: new Map(),
degree: new Map(),
physicsOn: false,
draggingId: null,
panning: false,
lastX: 0,
lastY: 0,
panX: 0,
panY: 0,
zoom: 1,
raf: null,
search: '',
pointers: new Map(),
pinchStartDist: null,
pinchStartZoom: null,
pinchStartPan: null,
};
function _graphCanvas() { return document.getElementById('graphCanvas'); }
function _graphCtx() { return _graphCanvas().getContext('2d'); }
function _graphNodeRadius(n) {
const d = graphState.degree.get(n.id) || 0;
const base = n.kind === 'tag' ? 4 : 6;
return Math.max(3, Math.min(14, base + Math.sqrt(d)));
}
function _graphNodeFill(n) {
if (n.kind === 'tag') return '#8a9aff';
return '#6c8af5';
}
function _graphMatches(n, term) {
const t = (term || '').trim().toLowerCase();
if (!t) return false;
const id = (n.id || '').toLowerCase();
const label = (n.label || '').toLowerCase();
return id.includes(t) || label.includes(t);
}
function _graphWorldFromScreen(cx, cy) {
return {
x: (cx - graphState.panX) / graphState.zoom,
y: (cy - graphState.panY) / graphState.zoom,
};
}
function _graphHitTest(wx, wy) {
for (let i = graphState.sim.length - 1; i >= 0; i--) {
const n = graphState.sim[i];
const r = _graphNodeRadius(n) + 2;
const dx = wx - n.x;
const dy = wy - n.y;
if ((dx*dx + dy*dy) <= r*r) return n;
}
return null;
}
function _graphInitInteractions() {
const canvas = _graphCanvas();
if (canvas._graphBound) return;
canvas._graphBound = true;
const toCanvasXY = (ev) => {
const rect = canvas.getBoundingClientRect();
return { cx: ev.clientX - rect.left, cy: ev.clientY - rect.top };
};
const pinchMidpoint = () => {
const pts = Array.from(graphState.pointers.values());
if (pts.length < 2) return null;
return { cx: (pts[0].cx + pts[1].cx) / 2, cy: (pts[0].cy + pts[1].cy) / 2 };
};
const pinchDistance = () => {
const pts = Array.from(graphState.pointers.values());
if (pts.length < 2) return null;
const dx = pts[0].cx - pts[1].cx;
const dy = pts[0].cy - pts[1].cy;
return Math.sqrt(dx*dx + dy*dy);
};
canvas.addEventListener('pointerdown', (ev) => {
canvas.setPointerCapture(ev.pointerId);
const pos = toCanvasXY(ev);
graphState.pointers.set(ev.pointerId, pos);
// When 2 pointers: start pinch
if (graphState.pointers.size === 2) {
graphState.panning = false;
graphState.draggingId = null;
graphState.pinchStartDist = pinchDistance();
graphState.pinchStartZoom = graphState.zoom;
graphState.pinchStartPan = { panX: graphState.panX, panY: graphState.panY };
_graphDraw();
return;
}
// Single pointer: pan or drag node
const { cx, cy } = pos;
const w = _graphWorldFromScreen(cx, cy);
const hit = _graphHitTest(w.x, w.y);
graphState.lastX = cx; graphState.lastY = cy;
if (hit) graphState.draggingId = hit.id;
else graphState.panning = true;
});
canvas.addEventListener('pointermove', (ev) => {
if (!graphState.pointers.has(ev.pointerId)) return;
const pos = toCanvasXY(ev);
graphState.pointers.set(ev.pointerId, pos);
// Pinch zoom (2 pointers)
if (graphState.pointers.size >= 2 && graphState.pinchStartDist) {
const dist = pinchDistance();
const mid = pinchMidpoint();
if (!dist || !mid) return;
const zoomOld = graphState.zoom;
const wx = (mid.cx - graphState.panX) / zoomOld;
const wy = (mid.cy - graphState.panY) / zoomOld;
const factor = dist / graphState.pinchStartDist;
const next = Math.max(0.35, Math.min(3.0, graphState.pinchStartZoom * factor));
graphState.zoom = next;
graphState.panX = mid.cx - wx * graphState.zoom;
graphState.panY = mid.cy - wy * graphState.zoom;
_graphDraw();
return;
}
// Single pointer
const { cx, cy } = pos;
const dx = cx - graphState.lastX;
const dy = cy - graphState.lastY;
graphState.lastX = cx; graphState.lastY = cy;
if (graphState.draggingId) {
const w = _graphWorldFromScreen(cx, cy);
const n = graphState.simById.get(graphState.draggingId);
if (n) { n.x = w.x; n.y = w.y; n.vx = 0; n.vy = 0; }
_graphDraw();
return;
}
if (graphState.panning) {
graphState.panX += dx;
graphState.panY += dy;
_graphDraw();
}
});
const endPointer = (ev) => {
if (graphState.pointers.has(ev.pointerId)) graphState.pointers.delete(ev.pointerId);
try { canvas.releasePointerCapture(ev.pointerId); } catch {}
if (graphState.pointers.size < 2) {
graphState.pinchStartDist = null;
graphState.pinchStartZoom = null;
graphState.pinchStartPan = null;
}
graphState.draggingId = null;
graphState.panning = false;
};
canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);
}
function renderGraph(nodes, edges) {
const canvas = _graphCanvas();
const hint = document.getElementById('graphHint');
const ctx = _graphCtx();
const w = canvas.parentElement.clientWidth - 24;
canvas.width = Math.max(320, Math.min(520, w));
canvas.height = 520;
graphState.nodes = nodes || [];
graphState.edges = edges || [];
graphState.nodeById = new Map(graphState.nodes.map(n => [n.id, n]));
graphState.degree = new Map();
for (const e of graphState.edges) {
graphState.degree.set(e.from, (graphState.degree.get(e.from) || 0) + 1);
graphState.degree.set(e.to, (graphState.degree.get(e.to) || 0) + 1);
}
if (!graphState.nodes.length || !graphState.edges.length) {
hint.textContent = 'Graph: keine Kanten (noch keine Links/Tags im Sample).';
ctx.clearRect(0,0,canvas.width,canvas.height);
return;
}
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}`;
graphState.sim = graphState.nodes.map(n => ({
id: n.id,
kind: n.kind,
label: n.label || n.id,
x: (Math.random() - 0.5) * canvas.width,
y: (Math.random() - 0.5) * canvas.height,
vx: 0, vy: 0,
}));
graphState.simById = new Map(graphState.sim.map(n => [n.id, n]));
graphState.links = graphState.edges
.map(e => ({a: graphState.simById.get(e.from), b: graphState.simById.get(e.to), kind: e.kind}))
.filter(l => l.a && l.b);
graphState.panX = canvas.width / 2;
graphState.panY = canvas.height / 2;
graphState.zoom = 1;
graphState.search = state.search || '';
_graphInitInteractions();
for (let i = 0; i < 120; i++) _graphStepPhysics(0.9);
_graphDraw();
}
function _graphStepPhysics(alpha = 1.0) {
const canvas = _graphCanvas();
const repulsion = 180;
const damping = 0.86;
const target = 80;
const springK = 0.018;
const sim = graphState.sim;
for (let i = 0; i < sim.length; i++) {
for (let j = i + 1; j < sim.length; j++) {
const a = sim[i], b = sim[j];
const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx*dx + dy*dy + 0.02;
const f = (repulsion / d2) * alpha;
a.vx += dx*f; a.vy += dy*f;
b.vx -= dx*f; b.vy -= dy*f;
}
}
for (const l of graphState.links) {
const a = l.a, b = l.b;
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
const f = (dist - target) * springK * alpha;
const fx = (dx/dist) * f, fy = (dy/dist) * f;
a.vx += fx; a.vy += fy;
b.vx -= fx; b.vy -= fy;
}
for (const n of sim) {
n.vx *= damping; n.vy *= damping;
n.x += n.vx; n.y += n.vy;
const pad = 10;
const bx = canvas.width / graphState.zoom;
const by = canvas.height / graphState.zoom;
n.x = Math.max(-bx/2 + pad, Math.min(bx/2 - pad, n.x));
n.y = Math.max(-by/2 + pad, Math.min(by/2 - pad, n.y));
}
}
function _graphDraw() {
const canvas = _graphCanvas();
const ctx = _graphCtx();
const hint = document.getElementById('graphHint');
ctx.clearRect(0,0,canvas.width,canvas.height);
ctx.save();
ctx.translate(graphState.panX, graphState.panY);
ctx.scale(graphState.zoom, graphState.zoom);
ctx.globalAlpha = 0.45;
ctx.strokeStyle = '#3a3a55';
ctx.lineWidth = 1 / graphState.zoom;
for (const l of graphState.links) {
ctx.beginPath();
ctx.moveTo(l.a.x, l.a.y);
ctx.lineTo(l.b.x, l.b.y);
ctx.stroke();
}
ctx.globalAlpha = 1.0;
const term = (graphState.search || '').trim();
let matches = 0;
for (const n of graphState.sim) {
const r = _graphNodeRadius(n);
const isMatch = _graphMatches(n, term);
if (isMatch) matches++;
if (isMatch) {
ctx.beginPath();
ctx.strokeStyle = '#f7d154';
ctx.lineWidth = 3 / graphState.zoom;
ctx.arc(n.x, n.y, r + 3, 0, Math.PI*2);
ctx.stroke();
}
ctx.beginPath();
ctx.fillStyle = _graphNodeFill(n);
ctx.arc(n.x, n.y, r, 0, Math.PI*2);
ctx.fill();
}
ctx.restore();
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}` + (term ? ` | match=${matches}` : '');
}
function _graphLoop() {
if (!graphState.physicsOn) return;
_graphStepPhysics(1.0);
_graphDraw();
graphState.raf = requestAnimationFrame(_graphLoop);
}
function toggleGraphPhysics() {
graphState.physicsOn = !graphState.physicsOn;
const b = document.getElementById('btnGraphPhysics');
b.textContent = `Physics: ${graphState.physicsOn ? 'on' : 'off'}`;
b.classList.toggle('primary', graphState.physicsOn);
if (graphState.physicsOn) {
if (graphState.raf) cancelAnimationFrame(graphState.raf);
graphState.raf = requestAnimationFrame(_graphLoop);
} else {
if (graphState.raf) cancelAnimationFrame(graphState.raf);
graphState.raf = null;
_graphDraw();
}
}
function resetGraphView() {
const canvas = _graphCanvas();
graphState.panX = canvas.width / 2;
graphState.panY = canvas.height / 2;
graphState.zoom = 1;
_graphDraw();
}
function fitGraphView() {
const canvas = _graphCanvas();
if (!graphState.sim.length) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of graphState.sim) {
minX = Math.min(minX, n.x); minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x); maxY = Math.max(maxY, n.y);
}
const w = Math.max(1, maxX - minX);
const h = Math.max(1, maxY - minY);
const zx = (canvas.width - 40) / w;
const zy = (canvas.height - 40) / h;
graphState.zoom = Math.max(0.35, Math.min(2.5, Math.min(zx, zy)));
graphState.panX = canvas.width / 2;
graphState.panY = canvas.height / 2;
_graphDraw();
}
function graphApplySearch(term) {
graphState.search = term || '';
_graphDraw();
}
// Real-time updates via SSE
function startEvents() {
try {
const es = new EventSource('/api/events');
es.onmessage = (msg) => {
try {
state.lastEvent = JSON.parse(msg.data);
updateStatsFromEvent(state.lastEvent);
if (state.view === 'status') {
// refresh status panels without heavy re-render: just rerun loadStatus occasionally
loadStatus();
}
if (state.view === 'graph') {
// fetch graph less often (every ~15s)
const t = Date.now();
if (!state._lastGraphFetch || (t - state._lastGraphFetch) > 15000) {
state._lastGraphFetch = t;
loadGraph();
}
}
} catch (e) {}
};
es.onerror = () => {
// keep UI usable even if SSE drops
};
} catch (e) {}
}
startEvents();
function renderCards() {
const el = document.getElementById('cards');
el.innerHTML = state.items.map(item => `
<div class="card ${item.confirmed ? 'confirmed' : ''} ${item.rejections > 0 ? 'rejected' : ''}" data-id="${item.id}">
<div class="card-header">
<span class="conf-badge" style="background:hsl(${item.confidence*120},70%,40%)">${Math.round(item.confidence*100)}%</span>
${renderVerdictPill(item)}
<span class="tags">${item.tags.map(t => '<span class="tag">'+t+'</span>').join('')}</span>
<span class="date">${fmtDate(item.created)}</span>
</div>
<div class="card-body" onclick="showDetail('${item.id}')">
${escapeHtml(item.content.substring(0, 200))}${item.content.length>200?'...':''}
</div>
<div class="card-footer">
<input type="text" class="reason-input" placeholder="Grund (optional)" id="reason-${item.id}"/>
<div class="actions">
<button class="btn-ok" onclick="confirm('${item.id}', event)">✅</button>
<button class="btn-no" onclick="reject('${item.id}', event)">❌</button>
<button class="btn-archive" onclick="refresh('${item.id}', event)">🔄</button>
</div>
</div>
</div>
`).join('');
}
function renderVerdictPill(item) {
const v = (item.verdict || '').toString();
if (!v) return '';
let cls = 'v-unknown';
let label = v;
if (v === 'confirmed_true') { cls = 'v-true'; label = 'TRUE'; }
else if (v === 'confirmed_false') { cls = 'v-false'; label = 'FALSE'; }
else if (v === 'probable_true') { cls = 'v-prob-true'; label = 'LIKELY'; }
else if (v === 'probable_false') { cls = 'v-prob-false'; label = 'UNLIKELY'; }
else if (v === 'unknown') { cls = 'v-unknown'; label = 'UNKNOWN'; }
return `<span class="verdict-pill ${cls}">${label}</span>`;
}
function fmtDate(iso) {
const d = new Date(iso);
return `${d.getDate().toString().padStart(2,'0')}.${(d.getMonth()+1).toString().padStart(2,'0')} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
}
function escapeHtml(t) {
const d = document.createElement('div');
d.textContent = t;
return d.innerHTML;
}
// ─── Actions ────────────────────────────────────────────────────────────────
async function confirm(id, ev) {
ev.stopPropagation();
const reason = document.getElementById('reason-'+id).value;
await api(`/api/engrams/${id}/confirm`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({reason})
});
await loadCards(); await loadStats();
}
async function reject(id, ev) {
ev.stopPropagation();
const reason = document.getElementById('reason-'+id).value;
await api(`/api/engrams/${id}/reject`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({reason})
});
await loadCards(); await loadStats();
}
async function refresh(id, ev) {
ev.stopPropagation();
await api(`/api/engrams/${id}/refresh`, {method: 'POST'});
await loadCards();
}
async function createEngram() {
const content = document.getElementById('newContent').value;
const tags = document.getElementById('newTags').value;
if (!content.trim()) return;
await api('/api/engrams', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({content, tags})
});
document.getElementById('newContent').value = '';
document.getElementById('newTags').value = '';
await loadCards(); await loadStats();
}
async function showDetail(id) {
const item = await api(`/api/engrams/${id}`);
const body = document.getElementById('modalBody');
body.innerHTML = `
<h3>Engramm ${item.id.substring(0,8)}</h3>
<p><b>Confidence:</b> ${Math.round(item.confidence*100)}%</p>
<p><b>Confirmed:</b> ${item.confirmed ? '✅' : '❌'}</p>
<p><b>Tags:</b> ${item.tags.map(t => '<span class="tag">'+t+'</span>').join(' ')}</p>
<p><b>Content:</b></p>
<div class="detail-content">${escapeHtml(item.content)}</div>
<p><b>History:</b></p>
<ul class="history">
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)}${h.action} (${h.note})</li>`).join('')}
</ul>
<p><b>Links:</b> ${item.links?.join(', ') || 'none'}</p>
`;
document.getElementById('detailModal').classList.add('open');
}
function closeModal() {
document.getElementById('detailModal').classList.remove('open');
}
// ─── Pagination ─────────────────────────────────────────────────────────────
function nextPage() {
state.offset += state.limit;
loadCards();
}
function prevPage() {
state.offset = Math.max(0, state.offset - state.limit);
loadCards();
}
function manualRefresh() {
loadCards(); loadStats();
}
// ─── Search ─────────────────────────────────────────────────────────────────
document.getElementById('searchInput').addEventListener('input', (e) => {
state.search = e.target.value;
state.offset = 0;
loadCards();
if (state.view === 'graph') graphApplySearch(state.search);
});
document.getElementById('filterSelect').addEventListener('change', (e) => {
state.filter = e.target.value;
state.offset = 0;
loadCards();
});
// ─── Auto Refresh ───────────────────────────────────────────────────────────
setInterval(() => {
if (!state.autoRefresh) return;
loadStats();
loadCards();
const now = new Date();
document.getElementById('lastUpdate').textContent =
`${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}`;
}, 5000);
// ─── Init ───────────────────────────────────────────────────────────────────
loadStats();
loadCards();
</script>
</body>
</html>