11 Commits

Author SHA1 Message Date
680b3869bb fix(dashboard): explizite zwei-zeilen-layout für suchbox 2026-05-31 17:09:24 +02:00
dab1b84a68 fix(dashboard): site-only layout — suchfeld oben, filter/export unten auf mobil 2026-05-31 17:00:42 +02:00
c22c7be444 fix(dashboard): mobile viewport and search bar overflow 2026-05-31 16:45:18 +02:00
9ec1e0d28f style(dashboard): improve small-screen layout 2026-05-31 16:29:46 +02:00
f8de7e626b feat(dashboard): keyboard navigation 2026-05-31 16:29:15 +02:00
432d758b90 feat(dashboard): export current view 2026-05-31 16:27:52 +02:00
788ee1539d feat(dashboard): richer engram detail overlay 2026-05-31 16:26:37 +02:00
1db483c053 feat(dashboard): extend stats bar 2026-05-31 16:24:40 +02:00
672077a14c fix(dashboard): render cards again 2026-05-31 16:05:53 +02:00
0ff6db73ea feat(dashboard): integrate link suggestions and predictive links into UI
- FastAPI: parse_engram now includes link_suggestions and predictive_links from metadata
- FastAPI: add POST /api/links/accept to create links from suggestions
- Dashboard: new renderCardsWithSuggestions() displays suggestions in each card
- Dashboard: acceptLink() function handles click-to-link
- Dashboard: loadCards() calls renderCardsWithSuggestions()
- Systemd: remove DirectoryNotEmpty from ingest path unit (already present)

Refs: #25 #27
2026-05-31 15:42:46 +02:00
2024e2850d Merge PR #28: Event-driven Tuning (30min/15min + inotify) 2026-05-31 14:11:23 +02:00
5 changed files with 356 additions and 54 deletions

View File

@@ -15,34 +15,35 @@ from pathlib import Path
BRAIN_DIR = Path("/root/.openclaw/workspace/second-brain")
WORKSPACE = Path("/root/.openclaw/workspace")
HANDLER = WORKSPACE / "context-buffer" / "handler.py"
CURRENT_DIR = WORKSPACE / "context-buffer" / "current"
def run():
# Hole alle Topics mit status done/completed via handler
# Lese context-buffer index.json direkt
index_path = WORKSPACE / "context-buffer" / "index.json"
try:
result = subprocess.run(
["python3", str(HANDLER), "search", "--status", "done"],
capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
raise Exception(f"Handler error: {result.stderr}")
topics = json.loads(result.stdout)
with open(index_path) as f:
idx = json.load(f)
topics = []
for tid, t in idx.get("topics", {}).items():
status = t.get("status", "active")
if status in ("done", "completed"):
# Lade den vollen Inhalt aus der topic-Datei
topic_file = CURRENT_DIR / f"topic-{tid}.md"
if topic_file.exists():
content = topic_file.read_text(encoding="utf-8")
# Entferne Frontmatter für reinen Content
if content.startswith("---"):
parts = content.split("---", 2)
if len(parts) >= 3:
content = parts[2].strip()
t["content"] = content
else:
t["content"] = ""
topics.append(t)
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False))
return
# Alternative: auch 'completed' suchen
try:
result2 = subprocess.run(
["python3", str(HANDLER), "search", "--status", "completed"],
capture_output=True, text=True, timeout=30
)
if result2.returncode == 0:
topics_completed = json.loads(result2.stdout)
topics.extend(topics_completed)
except Exception:
pass
if not topics:
print(json.dumps({"success": True, "imported": 0, "message": "No completed topics found"}, indent=2, ensure_ascii=False))
return

View File

@@ -63,7 +63,7 @@ def parse_engram(row: sqlite3.Row) -> dict:
verdict = "confirmed_false"
else:
verdict = "unknown"
return {
result = {
"id": row["id"],
"content": row["content"],
"confidence": meta.get("confidence", 0.0),
@@ -81,6 +81,12 @@ def parse_engram(row: sqlite3.Row) -> dict:
"access_count": meta.get("access_count", 0),
"grounding": meta.get("grounding", 0),
}
# Vorschläge aus metadata
if "link_suggestions" in meta:
result["link_suggestions"] = meta["link_suggestions"]
if "predictive_links" in meta:
result["predictive_links"] = meta["predictive_links"]
return result
def _now_iso() -> str:
@@ -902,6 +908,31 @@ def api_refresh(engram_id: str):
return {"success": True, "new_confidence": round(conf, 2)}
@app.post("/api/links/accept")
def api_accept_link(from_id: str = Form(...), to_id: str = Form(...)):
"""Erstelle einen Link zwischen zwei Engrammen (aus Vorschlag)."""
conn = get_db()
c = conn.cursor()
# Prüfe Existenz beider Engramme
for eid in (from_id, to_id):
if not c.execute("SELECT 1 FROM engrams WHERE id = ?", (eid,)).fetchone():
conn.close()
return JSONResponse({"error": f"Engram {eid} not found"}, status_code=404)
# Vermeide Duplikate
c.execute("SELECT 1 FROM engrams_links WHERE from_id = ? AND to_id = ?", (from_id, to_id))
if c.fetchone():
conn.close()
return {"ok": True, "message": "link already exists"}
# Link erstellen
c.execute(
"INSERT INTO engrams_links (from_id, to_id) VALUES (?, ?)",
(from_id, to_id)
)
conn.commit()
conn.close()
return {"ok": True}
@app.post("/api/engrams")
def api_create_engram(content: str = Form(...), tags: str = Form(""), source: str = Form("web")):
engram_id = f"web-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f')[:20]}"

View File

@@ -14,6 +14,7 @@ body {
margin: 0 auto;
min-height: 100vh;
background: #141419;
width: 100%;
}
/* ─── Stats Bar ───────────────────────────────────────────────────────────── */
@@ -75,10 +76,22 @@ body {
/* ─── Search ──────────────────────────────────────────────────────────────── */
.search-box {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
background: #141419;
}
.search-row {
display: flex;
gap: 8px;
flex-direction: row;
}
.search-row:first-child {
width: 100%;
}
.search-row:last-child {
width: 100%;
}
/* tab buttons styled via .tabs-bar */
@@ -196,7 +209,9 @@ body {
.legend-dot.match{ background:#f7d154; }
.graph-hint{ padding: 4px 12px 10px; }
#searchInput {
width: 100%;
flex: 1;
min-width: 0;
background: #1e1e28;
border: 1px solid #2a2a3a;
border-radius: 10px;
@@ -207,6 +222,8 @@ body {
}
#searchInput:focus { border-color: #6c8af5; }
#filterSelect {
flex: 1;
min-width: 0;
background: #1e1e28;
border: 1px solid #2a2a3a;
border-radius: 10px;
@@ -216,6 +233,32 @@ body {
outline: none;
}
#exportFormat {
flex: 1;
min-width: 0;
background: #1e1e28;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 10px;
color: #e8e8ee;
font-size: 0.85rem;
outline: none;
}
.btn-export {
flex: 1;
min-width: 0;
background: #1e1e28;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 10px 12px;
color: #cfd3ff;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
}
.btn-export:active { transform: scale(0.98); }
/* ─── New Engram ──────────────────────────────────────────────────────────── */
.new-engram {
padding: 0 12px 8px;
@@ -266,6 +309,10 @@ body {
transition: transform 0.15s ease, border-color 0.2s ease;
touch-action: manipulation;
}
.card.selected {
border-color: #6c8af5;
box-shadow: 0 0 0 1px rgba(108,138,245,0.25) inset;
}
.card:active { transform: scale(0.985); }
.card.confirmed { border-left: 4px solid #3a7d3a; }
.card.rejected { border-left: 4px solid #8a3a3a; }
@@ -455,3 +502,13 @@ body {
@media (pointer: coarse) {
button, .card { -webkit-tap-highlight-color: transparent; }
}
/* ─── Small Screens ────────────────────────────────────────────────────────── */
@media (max-width: 420px) {
html { font-size: 13px; }
.stat { min-width: 48px; }
.stat-num { font-size: 1.15rem; }
.tabs-bar { top: 48px; }
.modal { padding: 14px 10px; }
.modal-content { padding: 16px 12px; }
}

View File

@@ -4,7 +4,6 @@ PartOf=openclaw-secondbrain.target
[Path]
PathModified=/root/.openclaw/workspace/memory
DirectoryNotEmpty=true
[Install]
WantedBy=multi-user.target

View File

@@ -2,7 +2,7 @@
<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">
<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>
@@ -12,8 +12,10 @@
<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="statRejected">-</span><span class="stat-label">Rej</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>
<div class="stat"><span class="stat-num" id="statAvgConf">-</span><span class="stat-label">Avg</span></div>
</header>
<div class="tabs-bar">
@@ -24,14 +26,23 @@
<!-- 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 class="search-row">
<input type="text" id="searchInput" placeholder="🔍 Suche..." />
</div>
<div class="search-row">
<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>
<select id="exportFormat" title="Export format">
<option value="jsonl">JSONL</option>
<option value="csv">CSV</option>
</select>
<button class="btn-export" onclick="exportCurrent()">Export</button>
</div>
</div>
<!-- New Engram -->
@@ -108,6 +119,7 @@ let state = {
autoRefresh: true,
view: 'cards',
lastEvent: null,
selectedId: null,
};
// ─── Fetch ──────────────────────────────────────────────────────────────────
@@ -132,8 +144,10 @@ async function loadStats() {
const s = await api('/api/stats');
document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed;
document.getElementById('statRejected').textContent = (s.rejected ?? '-');
document.getElementById('statPending').textContent = s.pending;
document.getElementById('statErrors').textContent = s.errors;
document.getElementById('statAvgConf').textContent = (typeof s.avg_confidence === 'number') ? `${Math.round(s.avg_confidence * 100)}%` : '-';
}
function updateStatsFromEvent(ev) {
@@ -141,8 +155,10 @@ function updateStatsFromEvent(ev) {
const s = ev.stats;
document.getElementById('statTotal').textContent = s.total;
document.getElementById('statConfirmed').textContent = s.confirmed;
if (document.getElementById('statRejected')) document.getElementById('statRejected').textContent = (s.rejected ?? '-');
document.getElementById('statPending').textContent = s.pending;
document.getElementById('statErrors').textContent = s.errors;
if (document.getElementById('statAvgConf')) document.getElementById('statAvgConf').textContent = (typeof s.avg_confidence === 'number') ? `${Math.round(s.avg_confidence * 100)}%` : '-';
}
function setView(view) {
@@ -161,19 +177,74 @@ function setView(view) {
}
async function loadCards() {
let url = `/api/engrams?limit=${state.limit}&offset=${state.offset}`;
const data = await api(buildEngramsUrl(state.limit, state.offset));
state.items = data.items;
renderCardsWithSuggestions();
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 buildEngramsUrl(limit, offset) {
let url = `/api/engrams?limit=${limit}&offset=${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';
return url;
}
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 exportCurrent() {
const fmt = (document.getElementById('exportFormat')?.value || 'jsonl').toLowerCase();
const limit = 100;
const max = 5000;
let offset = 0;
let all = [];
while (all.length < max) {
const data = await api(buildEngramsUrl(limit, offset));
const items = data.items || [];
all = all.concat(items);
if (items.length < limit) break;
offset += limit;
}
const safe = (s) => String(s ?? '').replace(/[\\r\\n]+/g, ' ').trim();
let payload = '';
let mime = 'text/plain';
if (fmt === 'csv') {
mime = 'text/csv';
const esc = (v) => '\"' + String(v ?? '').replace(/\"/g, '\"\"') + '\"';
payload += ['id','created','source','confidence','verdict','tags','content'].join(',') + '\\n';
for (const it of all) {
payload += [
esc(it.id),
esc(it.created),
esc(it.source),
esc(it.confidence),
esc(it.verdict),
esc((it.tags || []).join('|')),
esc((it.content || '').replace(/\\r?\\n/g, '\\\\n')),
].join(',') + '\\n';
}
} else {
mime = 'application/x-ndjson';
payload = all.map(x => JSON.stringify(x)).join('\\n') + (all.length ? '\\n' : '');
}
const now = new Date();
const ymd = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
const filename = `second-brain_${ymd}_${safe(state.filter || 'all')}_${fmt}.${fmt}`;
const blob = new Blob([payload], {type: mime});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function loadStatus() {
@@ -1001,9 +1072,11 @@ function escapeHtml(t) {
}
// ─── Actions ────────────────────────────────────────────────────────────────
async function confirm(id, ev) {
async function confirm(id, ev, ctx = 'card') {
ev.stopPropagation();
const reason = document.getElementById('reason-'+id).value;
const reasonElId = (ctx === 'modal') ? ('reason-modal-' + id) : ('reason-' + id);
const reasonEl = document.getElementById(reasonElId);
const reason = reasonEl ? reasonEl.value : '';
await api(`/api/engrams/${id}/confirm`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
@@ -1014,9 +1087,11 @@ async function confirm(id, ev) {
if (state.view === 'status') loadStatus();
}
async function reject(id, ev) {
async function reject(id, ev, ctx = 'card') {
ev.stopPropagation();
const reason = document.getElementById('reason-'+id).value;
const reasonElId = (ctx === 'modal') ? ('reason-modal-' + id) : ('reason-' + id);
const reasonEl = document.getElementById(reasonElId);
const reason = reasonEl ? reasonEl.value : '';
await api(`/api/engrams/${id}/reject`, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
@@ -1052,18 +1127,62 @@ async function createEngram() {
async function showDetail(id) {
const item = await api(`/api/engrams/${id}`);
const body = document.getElementById('modalBody');
const links = (item.links || []);
const suggestions = (item.link_suggestions || []).concat(item.predictive_links || []);
const suggHtml = suggestions.length ? suggestions.map(s => `
<div class="suggestion">
<span class="sugg-id">${s.engram_id.substring(0,8)}</span>
<span class="sugg-preview">${escapeHtml(s.preview || s.content_preview || '')}</span>
<button class="btn-link" onclick="acceptLink('${item.id}', '${s.engram_id}', event)">🔗</button>
</div>
`).join('') : '<span class="muted">Keine Vorschläge</span>';
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>
<h3>Engramm <span class="pill">${item.id.substring(0,8)}</span></h3>
<div class="kv-row"><div class="kv-key">verdict</div><div class="kv-val">${renderVerdictPill(item)} <span class="muted small">${Math.round(item.confidence*100)}%</span></div></div>
<div class="kv-row"><div class="kv-key">source</div><div class="kv-val">${escapeHtml(item.source || '-')}</div></div>
<div class="kv-row"><div class="kv-key">created</div><div class="kv-val">${fmtDate(item.created)}</div></div>
<div class="kv-row"><div class="kv-key">modified</div><div class="kv-val">${fmtDate(item.modified)}</div></div>
<div class="kv-row"><div class="kv-key">access</div><div class="kv-val">${item.access_count ?? 0} • grounding ${item.grounding ?? 0}</div></div>
<div class="kv-row"><div class="kv-key">tags</div><div class="kv-val">${(item.tags || []).map(t => '<span class="tag">'+escapeHtml(t)+'</span>').join(' ') || '-'}</div></div>
<div style="margin-top:10px"><b>Content</b></div>
<div class="detail-content">${escapeHtml(item.content || '')}</div>
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">Vorschläge</div>
<div class="suggestions">${suggHtml}</div>
</div>
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">Links</div>
<div>
${links.length ? links.map(l => `<span class="pill" style="cursor:pointer" onclick="showDetail('${l}')">${l.substring(0,8)}</span>`).join(' ') : '<span class="muted">none</span>'}
</div>
</div>
${Array.isArray(item.evidence) && item.evidence.length ? `
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">Evidence</div>
<ul class="history">
${item.evidence.map(e => `<li>${escapeHtml(typeof e === 'string' ? e : JSON.stringify(e))}</li>`).join('')}
</ul>
</div>` : ''}
<div class="panel" style="margin:10px 0 0; padding:10px 12px;">
<div class="panel-title">History</div>
<ul class="history">
${(item.review_history || []).map(h => `<li>${fmtDate(h.at)}${escapeHtml(h.action)} ${h.note ? ('(' + escapeHtml(h.note) + ')') : ''}</li>`).join('') || '<li class=\"muted\">-</li>'}
</ul>
</div>
<div class="card-footer" style="margin-top:10px">
<input type="text" class="reason-input" placeholder="Grund (optional)" id="reason-modal-${item.id}"/>
<div class="actions">
<button class="btn-ok" onclick="confirm('${item.id}', event, 'modal')">✅</button>
<button class="btn-no" onclick="reject('${item.id}', event, 'modal')">❌</button>
<button class="btn-archive" onclick="refresh('${item.id}', event)">🔄</button>
</div>
</div>
`;
document.getElementById('detailModal').classList.add('open');
}
@@ -1072,6 +1191,15 @@ function closeModal() {
document.getElementById('detailModal').classList.remove('open');
}
function selectCard(id) {
state.selectedId = id;
renderCardsWithSuggestions();
setTimeout(() => {
const el = document.querySelector(`.card[data-id=\"${id}\"]`);
if (el) el.scrollIntoView({block: 'nearest', behavior: 'smooth'});
}, 0);
}
// ─── Pagination ─────────────────────────────────────────────────────────────
function nextPage() {
state.offset += state.limit;
@@ -1114,6 +1242,92 @@ setInterval(() => {
// ─── Init ───────────────────────────────────────────────────────────────────
loadStats();
loadCards();
document.getElementById('detailModal').addEventListener('click', (e) => {
if (e.target && e.target.id === 'detailModal') closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
if (e.key === '/' && !(e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT'))) {
e.preventDefault();
document.getElementById('searchInput')?.focus();
}
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT')) return;
if (document.getElementById('detailModal')?.classList.contains('open')) return;
if (e.key === 'g') setView('graph');
if (e.key === 's') setView('status');
if (e.key === '1') setView('cards');
if (state.view !== 'cards') return;
if (!state.items || !state.items.length) return;
const idxOf = (id) => state.items.findIndex(x => x.id === id);
let idx = state.selectedId ? idxOf(state.selectedId) : -1;
if (idx < 0) idx = 0;
if (e.key === 'j') {
idx = Math.min(state.items.length - 1, idx + 1);
selectCard(state.items[idx].id);
} else if (e.key === 'k') {
idx = Math.max(0, idx - 1);
selectCard(state.items[idx].id);
} else if (e.key === 'Enter') {
showDetail(state.items[idx].id);
} else if (e.key === 'c') {
confirm(state.items[idx].id, e);
} else if (e.key === 'r') {
reject(state.items[idx].id, e);
}
});
function renderCardsWithSuggestions() {
const el = document.getElementById('cards');
el.innerHTML = state.items.map(item => {
const suggestions = (item.link_suggestions || []).concat(item.predictive_links || []);
const suggHtml = suggestions.length ? suggestions.map(s => `
<div class="suggestion">
<span class="sugg-id">${s.engram_id.substring(0,8)}</span>
<span class="sugg-preview">${escapeHtml(s.preview || s.content_preview || '')}</span>
<button class="btn-link" onclick="acceptLink('${item.id}', '${s.engram_id}', event)">🔗</button>
</div>
`).join('') : '<span class="muted">Keine Vorschläge</span>';
return `
<div class="card ${item.id === state.selectedId ? 'selected' : ''} ${item.confirmed ? 'confirmed' : ''} ${item.rejections > 0 ? 'rejected' : ''}" data-id="${item.id}" onclick="selectCard('${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="suggestions">
<strong>Vorschläge:</strong> ${suggHtml}
</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('');
}
async function acceptLink(fromId, toId, ev) {
ev.stopPropagation();
await api('/api/links/accept', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({from_id: fromId, to_id: toId})
});
alert('Link erstellt');
await loadCards();
}
</script>
</body>
</html>