Add FastAPI dashboard MVP
This commit is contained in:
246
templates/dashboard.html
Normal file
246
templates/dashboard.html
Normal file
@@ -0,0 +1,246 @@
|
||||
<!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>
|
||||
|
||||
<!-- 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="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>
|
||||
|
||||
<!-- 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,
|
||||
};
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
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 === '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;
|
||||
}
|
||||
|
||||
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>
|
||||
<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 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();
|
||||
});
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user