cordenar-mcp 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/LICENSE +21 -0
- package/README.md +330 -0
- package/auth.js +48 -0
- package/config.js +87 -0
- package/dashboard/api-handler.js +173 -0
- package/dashboard/public/app.js +447 -0
- package/dashboard/public/index.html +96 -0
- package/dashboard/public/logo.svg +1 -0
- package/dashboard/public/style.css +273 -0
- package/dashboard.js +230 -0
- package/db.js +95 -0
- package/embedding.js +84 -0
- package/index.js +198 -0
- package/package.json +26 -0
- package/supabase.js +32 -0
- package/sync.js +117 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Cordenar MCP Dashboard — API handler
|
|
2
|
+
// Pattern mirrors Hemisphere's dashboard/api-handler.js
|
|
3
|
+
import { listSynapses } from '../sync.js';
|
|
4
|
+
import {
|
|
5
|
+
getPushEntries,
|
|
6
|
+
getPullEntries,
|
|
7
|
+
getDirtyEntries,
|
|
8
|
+
lookupLocal,
|
|
9
|
+
removeMapping,
|
|
10
|
+
} from '../db.js';
|
|
11
|
+
import { isAuthenticated, getNodeId } from '../auth.js';
|
|
12
|
+
|
|
13
|
+
export function json(data, status = 200) {
|
|
14
|
+
return {
|
|
15
|
+
status,
|
|
16
|
+
headers: { 'Content-Type': 'application/json' },
|
|
17
|
+
body: JSON.stringify(data),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function err(msg, status = 500) {
|
|
22
|
+
return json({ error: msg }, status);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createApiHandler(db) {
|
|
26
|
+
return function handleApi(path, method, params) {
|
|
27
|
+
// ── List synapses ────────────────────────────────────────
|
|
28
|
+
if (path === '/api/synapses' && method === 'GET') {
|
|
29
|
+
const source = params.get('source') || '';
|
|
30
|
+
const status = params.get('status') || '';
|
|
31
|
+
const search = params.get('search') || '';
|
|
32
|
+
const limit = Math.min(
|
|
33
|
+
parseInt(params.get('limit') || '20', 10),
|
|
34
|
+
200
|
|
35
|
+
);
|
|
36
|
+
const offset = Math.max(parseInt(params.get('offset') || '0', 10), 0);
|
|
37
|
+
|
|
38
|
+
const allSynapses = listSynapses({ source: source || undefined, limit: 200 });
|
|
39
|
+
|
|
40
|
+
// Enrich with sync map info
|
|
41
|
+
const rows = allSynapses.map((s) => {
|
|
42
|
+
const mapped = lookupLocal(s.source, s.id);
|
|
43
|
+
let synapseStatus = 'unshared';
|
|
44
|
+
let cloudId = null;
|
|
45
|
+
let direction = null;
|
|
46
|
+
let isDirty = false;
|
|
47
|
+
|
|
48
|
+
if (mapped) {
|
|
49
|
+
cloudId = mapped.cloud_id;
|
|
50
|
+
direction = mapped.direction;
|
|
51
|
+
if (mapped.direction === 'push' && mapped.local_hash !== mapped.cloud_hash) {
|
|
52
|
+
isDirty = true;
|
|
53
|
+
}
|
|
54
|
+
if (mapped.direction === 'push') {
|
|
55
|
+
synapseStatus = 'shared';
|
|
56
|
+
} else if (mapped.direction === 'pull') {
|
|
57
|
+
synapseStatus = 'pulled';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { ...s, synapseStatus, cloudId, direction, isDirty };
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Apply status filter
|
|
65
|
+
let filtered = rows;
|
|
66
|
+
if (status) {
|
|
67
|
+
filtered = rows.filter((r) => r.synapseStatus === status);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Apply search filter
|
|
71
|
+
if (search) {
|
|
72
|
+
const term = search.toLowerCase();
|
|
73
|
+
filtered = filtered.filter(
|
|
74
|
+
(r) =>
|
|
75
|
+
(r.preview || '').toLowerCase().includes(term) ||
|
|
76
|
+
(r.title || '').toLowerCase().includes(term) ||
|
|
77
|
+
(r.type || '').toLowerCase().includes(term) ||
|
|
78
|
+
(r.description || '').toLowerCase().includes(term) ||
|
|
79
|
+
(r.body || '').toLowerCase().includes(term)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const total = filtered.length;
|
|
84
|
+
const paged = filtered.slice(offset, offset + limit);
|
|
85
|
+
|
|
86
|
+
return json({ total, limit, offset, rows: paged });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Status overview ───────────────────────────────────────
|
|
90
|
+
if (path === '/api/status' && method === 'GET') {
|
|
91
|
+
const authenticated = isAuthenticated();
|
|
92
|
+
const nodeId = getNodeId();
|
|
93
|
+
const pushes = getPushEntries();
|
|
94
|
+
const pulls = getPullEntries();
|
|
95
|
+
const dirty = getDirtyEntries();
|
|
96
|
+
|
|
97
|
+
return json({
|
|
98
|
+
authenticated,
|
|
99
|
+
nodeId,
|
|
100
|
+
pushed: pushes.length,
|
|
101
|
+
pulled: pulls.length,
|
|
102
|
+
dirty: dirty.length,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Trigger sync ──────────────────────────────────────────
|
|
107
|
+
if (path === '/api/sync' && method === 'POST') {
|
|
108
|
+
return json({ message: 'Sync not yet implemented' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Share synapse ─────────────────────────────────────────
|
|
112
|
+
const shareMatch = path.match(/^\/api\/synapses\/(\d+)\/share$/);
|
|
113
|
+
if (shareMatch && method === 'POST') {
|
|
114
|
+
const id = parseInt(shareMatch[1], 10);
|
|
115
|
+
const source = params.get('source') || '';
|
|
116
|
+
if (!id || !source) return err('Missing id or source', 400);
|
|
117
|
+
return json({ message: 'Share not yet implemented', id, source });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Unshare (cancel pending) ──────────────────────────────
|
|
121
|
+
const unshareMatch = path.match(/^\/api\/synapses\/(\d+)\/unshare$/);
|
|
122
|
+
if (unshareMatch && method === 'POST') {
|
|
123
|
+
const id = parseInt(unshareMatch[1], 10);
|
|
124
|
+
const source = params.get('source') || '';
|
|
125
|
+
if (!id || !source) return err('Missing id or source', 400);
|
|
126
|
+
const mapped = lookupLocal(source, id);
|
|
127
|
+
if (mapped) {
|
|
128
|
+
removeMapping(mapped.cloud_id);
|
|
129
|
+
return json({ unshared: true });
|
|
130
|
+
}
|
|
131
|
+
return err('Not found in sync map', 404);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Unlink (keep local, remove mapping) ───────────────────
|
|
135
|
+
const unlinkMatch = path.match(/^\/api\/synapses\/(\d+)\/unlink$/);
|
|
136
|
+
if (unlinkMatch && method === 'POST') {
|
|
137
|
+
const id = parseInt(unlinkMatch[1], 10);
|
|
138
|
+
const source = params.get('source') || '';
|
|
139
|
+
if (!id || !source) return err('Missing id or source', 400);
|
|
140
|
+
const mapped = lookupLocal(source, id);
|
|
141
|
+
if (mapped) {
|
|
142
|
+
removeMapping(mapped.cloud_id);
|
|
143
|
+
return json({ unlinked: true });
|
|
144
|
+
}
|
|
145
|
+
return err('Not found in sync map', 404);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Remove pulled local copy ──────────────────────────────
|
|
149
|
+
const pullDeleteMatch = path.match(/^\/api\/synapses\/(\d+)\/pull-delete$/);
|
|
150
|
+
if (pullDeleteMatch && method === 'POST') {
|
|
151
|
+
const id = parseInt(pullDeleteMatch[1], 10);
|
|
152
|
+
const source = params.get('source') || '';
|
|
153
|
+
if (!id || !source) return err('Missing id or source', 400);
|
|
154
|
+
return json({ message: 'Pull delete not yet implemented', id, source });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Dismiss rejected ──────────────────────────────────────
|
|
158
|
+
const dismissMatch = path.match(/^\/api\/synapses\/(\d+)\/dismiss$/);
|
|
159
|
+
if (dismissMatch && method === 'POST') {
|
|
160
|
+
const id = parseInt(dismissMatch[1], 10);
|
|
161
|
+
const source = params.get('source') || '';
|
|
162
|
+
if (!id || !source) return err('Missing id or source', 400);
|
|
163
|
+
const mapped = lookupLocal(source, id);
|
|
164
|
+
if (mapped) {
|
|
165
|
+
removeMapping(mapped.cloud_id);
|
|
166
|
+
return json({ dismissed: true });
|
|
167
|
+
}
|
|
168
|
+
return err('Not found in sync map', 404);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return null;
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// Cordenar MCP Dashboard — Vanilla JS SPA
|
|
2
|
+
// Pattern mirrors Hemisphere's dashboard/public/app.js
|
|
3
|
+
let state = { source: '', status: '', search: '', limit: 20, offset: 0, total: 0 };
|
|
4
|
+
let loadVersion = 0;
|
|
5
|
+
let expandedId = null;
|
|
6
|
+
|
|
7
|
+
function esc(s) {
|
|
8
|
+
const d = document.createElement('div');
|
|
9
|
+
d.textContent = String(s);
|
|
10
|
+
return d.innerHTML;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function timeAgo(ts) {
|
|
14
|
+
const sec = Math.floor(Date.now() / 1000 - ts);
|
|
15
|
+
if (sec < 60) return 'just now';
|
|
16
|
+
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
|
|
17
|
+
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
|
|
18
|
+
return Math.floor(sec / 86400) + 'd ago';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sourceClass(s) {
|
|
22
|
+
return 'source-' + (s === 'hemisphere' ? 'hemisphere' : 'compend');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function statusClass(s) {
|
|
26
|
+
return 'status-' + (s || 'unshared');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* ─────────── TOASTS ─────────── */
|
|
30
|
+
|
|
31
|
+
function toast(msg, type, duration) {
|
|
32
|
+
type = type || 'info';
|
|
33
|
+
duration = duration || 4000;
|
|
34
|
+
const container = document.getElementById('toast-container');
|
|
35
|
+
const el = document.createElement('div');
|
|
36
|
+
el.className = 'toast toast-' + type;
|
|
37
|
+
el.textContent = msg;
|
|
38
|
+
container.appendChild(el);
|
|
39
|
+
const timer = setTimeout(() => removeToast(el), duration);
|
|
40
|
+
el._timer = timer;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function removeToast(el) {
|
|
44
|
+
if (el._removing) return;
|
|
45
|
+
el._removing = true;
|
|
46
|
+
clearTimeout(el._timer);
|
|
47
|
+
el.classList.add('removing');
|
|
48
|
+
setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); }, 250);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* ─────────── SKELETON ─────────── */
|
|
52
|
+
|
|
53
|
+
function showSkeleton() {
|
|
54
|
+
const tbody = document.getElementById('tbody');
|
|
55
|
+
const skeleton = document.getElementById('loading-skeleton');
|
|
56
|
+
if (skeleton) skeleton.style.display = '';
|
|
57
|
+
if (tbody) tbody.innerHTML = '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hideSkeleton() {
|
|
61
|
+
const skeleton = document.getElementById('loading-skeleton');
|
|
62
|
+
if (skeleton) skeleton.style.display = 'none';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function initSkeleton() {
|
|
66
|
+
const tbody = document.getElementById('skeleton-body');
|
|
67
|
+
if (!tbody) return;
|
|
68
|
+
const rows = [];
|
|
69
|
+
for (let i = 0; i < 8; i++) {
|
|
70
|
+
rows.push('<tr class="skeleton-row"><td class="skeleton-cell tiny"></td><td class="skeleton-cell narrow"></td><td class="skeleton-cell narrow"></td><td class="skeleton-cell wide"></td><td class="skeleton-cell narrow"></td><td class="skeleton-cell tiny"></td><td></td></tr>');
|
|
71
|
+
}
|
|
72
|
+
tbody.innerHTML = rows.join('');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ─────────── DETAIL TOGGLE ─────────── */
|
|
76
|
+
|
|
77
|
+
function toggleDetail(id) {
|
|
78
|
+
const detail = document.querySelector('.detail-row[data-parent="' + id + '"]');
|
|
79
|
+
if (!detail) return;
|
|
80
|
+
const open = detail.style.display !== 'none';
|
|
81
|
+
if (open) {
|
|
82
|
+
detail.style.display = 'none';
|
|
83
|
+
detail.querySelector('.detail').classList.remove('open');
|
|
84
|
+
expandedId = null;
|
|
85
|
+
} else {
|
|
86
|
+
if (expandedId) {
|
|
87
|
+
const prev = document.querySelector('.detail-row[data-parent="' + expandedId + '"]');
|
|
88
|
+
if (prev) {
|
|
89
|
+
prev.style.display = 'none';
|
|
90
|
+
prev.querySelector('.detail').classList.remove('open');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
detail.style.display = '';
|
|
94
|
+
detail.querySelector('.detail').classList.add('open');
|
|
95
|
+
expandedId = id;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* ─────────── LOAD SYNAPSES ─────────── */
|
|
100
|
+
|
|
101
|
+
function buildRow(m) {
|
|
102
|
+
const sourceLabel = m.source === 'hemisphere' ? 'H' : 'C';
|
|
103
|
+
const statusLabel = { unshared: 'Unshared', shared: 'Shared', pulled: 'Pulled' }[m.synapseStatus] || m.synapseStatus;
|
|
104
|
+
const dirtyMark = m.isDirty ? ' *' : '';
|
|
105
|
+
|
|
106
|
+
return '<tr class="memory-row" data-id="' + m.id + '" data-source="' + m.source + '" tabindex="0" role="button">'
|
|
107
|
+
+ '<td class="id-cell">' + m.id + '</td>'
|
|
108
|
+
+ '<td><span class="source-badge ' + sourceClass(m.source) + '">' + sourceLabel + '</span></td>'
|
|
109
|
+
+ '<td><span class="type-badge">' + esc(m.type || '') + '</span></td>'
|
|
110
|
+
+ '<td class="content-cell">' + esc(m.preview || m.title || m.description || m.slug || '') + '</td>'
|
|
111
|
+
+ '<td><span class="status-badge ' + statusClass(m.synapseStatus) + '">' + statusLabel + dirtyMark + '</span></td>'
|
|
112
|
+
+ '<td class="time-cell" title="' + new Date((m.created_at || 0) * 1000).toISOString().slice(0, 19).replace('T', ' ') + '">' + timeAgo(m.created_at) + '</td>'
|
|
113
|
+
+ '<td><div class="kebab-menu">'
|
|
114
|
+
+ '<button class="kebab-btn" aria-label="Actions"><svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor" aria-hidden="true"><path d="M480-160q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Zm0-240q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm0-240q-33 0-56.5-23.5T400-720q0-33 23.5-56.5T480-800q33 0 56.5 23.5T560-720q0 33-23.5 56.5T480-640Z"/></svg></button>'
|
|
115
|
+
+ '<div class="kebab-dropdown">'
|
|
116
|
+
+ buildMenu(m)
|
|
117
|
+
+ '</div></div></td></tr>'
|
|
118
|
+
+ '<tr class="detail-row" data-parent="' + m.id + '" style="display:none"><td colspan="7"><div class="detail"><pre>' + esc(m.body || m.preview || m.description || '') + '</pre></div></td></tr>';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildMenu(m) {
|
|
122
|
+
const s = m.source;
|
|
123
|
+
const id = m.id;
|
|
124
|
+
const items = [];
|
|
125
|
+
switch (m.synapseStatus) {
|
|
126
|
+
case 'unshared':
|
|
127
|
+
items.push('<button class="kebab-item share-action" data-id="' + id + '" data-source="' + s + '">Share</button>');
|
|
128
|
+
break;
|
|
129
|
+
case 'shared':
|
|
130
|
+
if (m.direction === 'push') {
|
|
131
|
+
items.push('<button class="kebab-item unlink-action" data-id="' + id + '" data-source="' + s + '">Unlink</button>');
|
|
132
|
+
} else {
|
|
133
|
+
items.push('<button class="kebab-item danger pull-delete-action" data-id="' + id + '" data-source="' + s + '">Remove Local</button>');
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
case 'pulled':
|
|
137
|
+
items.push('<button class="kebab-item danger pull-delete-action" data-id="' + id + '" data-source="' + s + '">Remove Local</button>');
|
|
138
|
+
break;
|
|
139
|
+
default:
|
|
140
|
+
items.push('<button class="kebab-item dismiss-action" data-id="' + id + '" data-source="' + s + '">Dismiss</button>');
|
|
141
|
+
}
|
|
142
|
+
return items.join('');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function loadSynapses() {
|
|
146
|
+
const version = ++loadVersion;
|
|
147
|
+
showSkeleton();
|
|
148
|
+
|
|
149
|
+
const params = new URLSearchParams();
|
|
150
|
+
if (state.source) params.set('source', state.source);
|
|
151
|
+
params.set('status', state.status);
|
|
152
|
+
params.set('limit', state.limit);
|
|
153
|
+
params.set('offset', state.offset);
|
|
154
|
+
if (state.search) params.set('search', state.search);
|
|
155
|
+
|
|
156
|
+
const r = await fetch('/api/synapses?' + params.toString());
|
|
157
|
+
if (!r.ok) throw new Error('Failed to load synapses');
|
|
158
|
+
const data = await r.json();
|
|
159
|
+
if (version !== loadVersion) return;
|
|
160
|
+
|
|
161
|
+
hideSkeleton();
|
|
162
|
+
document.getElementById('error').style.display = 'none';
|
|
163
|
+
|
|
164
|
+
state.total = data.total;
|
|
165
|
+
const tbody = document.getElementById('tbody');
|
|
166
|
+
const stats = document.getElementById('stats');
|
|
167
|
+
const pagination = document.getElementById('pagination');
|
|
168
|
+
|
|
169
|
+
if (data.rows.length === 0) {
|
|
170
|
+
const msg = state.search ? 'No results for "' + esc(state.search) + '"' : 'No synapses yet';
|
|
171
|
+
tbody.innerHTML = '<tr><td colspan="7"><div class="empty">' + msg + '</div></td></tr>';
|
|
172
|
+
stats.textContent = msg;
|
|
173
|
+
pagination.innerHTML = '';
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
stats.textContent = 'Showing ' + (data.offset + 1) + '\u2013' + Math.min(data.offset + data.rows.length, data.total) + ' of ' + data.total + ' synapses';
|
|
178
|
+
|
|
179
|
+
let savedScroll = 0;
|
|
180
|
+
if (expandedId) {
|
|
181
|
+
const el = document.querySelector('.detail-row[data-parent="' + expandedId + '"] .detail');
|
|
182
|
+
if (el) savedScroll = el.scrollTop;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
tbody.innerHTML = data.rows.map(buildRow).join('');
|
|
186
|
+
|
|
187
|
+
if (expandedId) {
|
|
188
|
+
const detail = tbody.querySelector('.detail-row[data-parent="' + expandedId + '"]');
|
|
189
|
+
if (detail) {
|
|
190
|
+
detail.style.display = '';
|
|
191
|
+
detail.querySelector('.detail').classList.add('open');
|
|
192
|
+
if (savedScroll > 0) {
|
|
193
|
+
const el = detail.querySelector('.detail');
|
|
194
|
+
if (el) el.scrollTop = savedScroll;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const tp = Math.ceil(data.total / state.limit);
|
|
200
|
+
const cp = Math.floor(state.offset / state.limit) + 1;
|
|
201
|
+
pagination.innerHTML = '<button id="page-prev"' + (cp <= 1 ? ' disabled' : '') + ' aria-label="Previous page">\u2190 Prev</button>'
|
|
202
|
+
+ '<span>Page ' + cp + ' of ' + tp + '</span>'
|
|
203
|
+
+ '<button id="page-next"' + (cp >= tp ? ' disabled' : '') + ' aria-label="Next page">Next \u2192</button>';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ─────────── STATUS ─────────── */
|
|
207
|
+
|
|
208
|
+
async function loadStatus() {
|
|
209
|
+
try {
|
|
210
|
+
const r = await fetch('/api/status');
|
|
211
|
+
if (!r.ok) return;
|
|
212
|
+
const data = await r.json();
|
|
213
|
+
const dot = document.getElementById('status-dot');
|
|
214
|
+
if (data.authenticated) {
|
|
215
|
+
dot.className = 'status-dot connected';
|
|
216
|
+
dot.title = 'Authenticated — Node: ' + (data.nodeId || '?');
|
|
217
|
+
} else {
|
|
218
|
+
dot.className = 'status-dot disconnected';
|
|
219
|
+
dot.title = 'Not authenticated';
|
|
220
|
+
}
|
|
221
|
+
} catch {}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* ─────────── EVENT WIRING ─────────── */
|
|
225
|
+
|
|
226
|
+
document.getElementById('source').addEventListener('change', function () {
|
|
227
|
+
state.source = this.value;
|
|
228
|
+
state.offset = 0;
|
|
229
|
+
loadSynapses().catch(showError);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
document.getElementById('status').addEventListener('change', function () {
|
|
233
|
+
state.status = this.value;
|
|
234
|
+
state.offset = 0;
|
|
235
|
+
loadSynapses().catch(showError);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
let searchTimer = null;
|
|
239
|
+
document.getElementById('search').addEventListener('input', function () {
|
|
240
|
+
clearTimeout(searchTimer);
|
|
241
|
+
searchTimer = setTimeout(() => {
|
|
242
|
+
state.search = this.value.trim();
|
|
243
|
+
state.offset = 0;
|
|
244
|
+
loadSynapses().catch(showError);
|
|
245
|
+
}, 200);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
document.getElementById('sync-btn').addEventListener('click', function () {
|
|
249
|
+
fetch('/api/sync', { method: 'POST' })
|
|
250
|
+
.then(r => r.json())
|
|
251
|
+
.then(() => { toast('Sync complete', 'success'); loadSynapses().catch(showError); })
|
|
252
|
+
.catch(() => toast('Sync failed', 'error'));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
document.getElementById('tbody').addEventListener('click', function (e) {
|
|
256
|
+
const kebabBtn = e.target.closest('.kebab-btn');
|
|
257
|
+
if (kebabBtn) {
|
|
258
|
+
e.stopPropagation();
|
|
259
|
+
const menu = kebabBtn.closest('.kebab-menu');
|
|
260
|
+
const dd = menu.querySelector('.kebab-dropdown');
|
|
261
|
+
if (dd.classList.contains('open')) {
|
|
262
|
+
dd.classList.remove('open');
|
|
263
|
+
} else {
|
|
264
|
+
document.querySelectorAll('.kebab-dropdown.open').forEach(el => el.classList.remove('open'));
|
|
265
|
+
dd.classList.add('open');
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const shareItem = e.target.closest('.share-action');
|
|
271
|
+
if (shareItem) {
|
|
272
|
+
e.stopPropagation();
|
|
273
|
+
shareItem.closest('.kebab-dropdown').classList.remove('open');
|
|
274
|
+
fetch('/api/synapses/' + shareItem.dataset.id + '/share?source=' + encodeURIComponent(shareItem.dataset.source), { method: 'POST' })
|
|
275
|
+
.then(r => { if (!r.ok) throw new Error('Share failed'); return r.json(); })
|
|
276
|
+
.then(() => { toast('Synapse shared', 'success'); loadSynapses().catch(showError); })
|
|
277
|
+
.catch(err => toast('Share failed: ' + err.message, 'error'));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const unlinkItem = e.target.closest('.unlink-action');
|
|
282
|
+
if (unlinkItem) {
|
|
283
|
+
e.stopPropagation();
|
|
284
|
+
unlinkItem.closest('.kebab-dropdown').classList.remove('open');
|
|
285
|
+
fetch('/api/synapses/' + unlinkItem.dataset.id + '/unlink?source=' + encodeURIComponent(unlinkItem.dataset.source), { method: 'POST' })
|
|
286
|
+
.then(r => { if (!r.ok) throw new Error('Unlink failed'); return r.json(); })
|
|
287
|
+
.then(() => { toast('Synapse unlinked', 'success'); loadSynapses().catch(showError); })
|
|
288
|
+
.catch(err => toast('Unlink failed: ' + err.message, 'error'));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const pullDeleteItem = e.target.closest('.pull-delete-action');
|
|
293
|
+
if (pullDeleteItem) {
|
|
294
|
+
e.stopPropagation();
|
|
295
|
+
pullDeleteItem.closest('.kebab-dropdown').classList.remove('open');
|
|
296
|
+
const id = pullDeleteItem.dataset.id;
|
|
297
|
+
const source = pullDeleteItem.dataset.source;
|
|
298
|
+
const dialog = document.getElementById('confirm-dialog');
|
|
299
|
+
document.getElementById('confirm-message').textContent = 'Remove pulled synapse from local storage? This only deletes your local copy.';
|
|
300
|
+
dialog.showModal();
|
|
301
|
+
document.getElementById('confirm-ok').onclick = function () {
|
|
302
|
+
dialog.close();
|
|
303
|
+
fetch('/api/synapses/' + id + '/pull-delete?source=' + encodeURIComponent(source), { method: 'POST' })
|
|
304
|
+
.then(r => { if (!r.ok) throw new Error('Remove failed'); return r.json(); })
|
|
305
|
+
.then(() => { toast('Local copy removed', 'success'); loadSynapses().catch(showError); })
|
|
306
|
+
.catch(err => toast('Remove failed: ' + err.message, 'error'));
|
|
307
|
+
};
|
|
308
|
+
document.getElementById('confirm-cancel').onclick = function () { dialog.close(); };
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const dismissItem = e.target.closest('.dismiss-action');
|
|
313
|
+
if (dismissItem) {
|
|
314
|
+
e.stopPropagation();
|
|
315
|
+
dismissItem.closest('.kebab-dropdown').classList.remove('open');
|
|
316
|
+
fetch('/api/synapses/' + dismissItem.dataset.id + '/dismiss?source=' + encodeURIComponent(dismissItem.dataset.source), { method: 'POST' })
|
|
317
|
+
.then(r => { if (!r.ok) throw new Error('Dismiss failed'); return r.json(); })
|
|
318
|
+
.then(() => { toast('Dismissed', 'success'); loadSynapses().catch(showError); })
|
|
319
|
+
.catch(err => toast('Dismiss failed: ' + err.message, 'error'));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const row = e.target.closest('.memory-row');
|
|
324
|
+
if (row) {
|
|
325
|
+
toggleDetail(row.dataset.id);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
document.addEventListener('click', function (e) {
|
|
330
|
+
if (!e.target.closest('.kebab-menu')) {
|
|
331
|
+
document.querySelectorAll('.kebab-dropdown.open').forEach(el => el.classList.remove('open'));
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
document.getElementById('tbody').addEventListener('keydown', function (e) {
|
|
336
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
337
|
+
const row = e.target.closest('.memory-row');
|
|
338
|
+
if (row) {
|
|
339
|
+
e.preventDefault();
|
|
340
|
+
toggleDetail(row.dataset.id);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
document.getElementById('pagination').addEventListener('click', function (e) {
|
|
346
|
+
const btn = e.target.closest('button');
|
|
347
|
+
if (!btn) return;
|
|
348
|
+
if (btn.id === 'page-prev' && state.offset > 0) {
|
|
349
|
+
state.offset = Math.max(0, state.offset - state.limit);
|
|
350
|
+
loadSynapses().catch(showError);
|
|
351
|
+
} else if (btn.id === 'page-next') {
|
|
352
|
+
state.offset += state.limit;
|
|
353
|
+
loadSynapses().catch(showError);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
document.getElementById('theme-btn').addEventListener('click', function () {
|
|
358
|
+
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
|
359
|
+
document.documentElement.setAttribute('data-theme', isLight ? '' : 'light');
|
|
360
|
+
localStorage.setItem('cordenar-theme', isLight ? 'dark' : 'light');
|
|
361
|
+
this.setAttribute('aria-label', isLight ? 'Switch to dark theme' : 'Switch to light theme');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
function showError(msg) {
|
|
365
|
+
const el = document.getElementById('error');
|
|
366
|
+
if (el) {
|
|
367
|
+
el.textContent = msg;
|
|
368
|
+
el.style.display = 'block';
|
|
369
|
+
}
|
|
370
|
+
console.error(msg);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/* ===== SSE: Real-Time Updates ===== */
|
|
374
|
+
const es = new EventSource('/api/events');
|
|
375
|
+
|
|
376
|
+
es.onerror = function () {};
|
|
377
|
+
|
|
378
|
+
es.addEventListener('synapse_shared', function (e) {
|
|
379
|
+
try {
|
|
380
|
+
const data = JSON.parse(e.data);
|
|
381
|
+
toast('Synapse shared', 'success');
|
|
382
|
+
loadSynapses().catch(showError);
|
|
383
|
+
} catch {}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
es.addEventListener('synapse_unlinked', function (e) {
|
|
387
|
+
try {
|
|
388
|
+
const data = JSON.parse(e.data);
|
|
389
|
+
removeRow(data.id);
|
|
390
|
+
} catch {}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
es.addEventListener('synapse_pulled', function (e) {
|
|
394
|
+
try {
|
|
395
|
+
toast('New synapses pulled', 'info');
|
|
396
|
+
loadSynapses().catch(showError);
|
|
397
|
+
} catch {}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
es.addEventListener('synapse_revoked', function (e) {
|
|
401
|
+
try {
|
|
402
|
+
const data = JSON.parse(e.data);
|
|
403
|
+
removeRow(data.id);
|
|
404
|
+
} catch {}
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
es.addEventListener('sync_complete', function () {
|
|
408
|
+
toast('Sync complete', 'success');
|
|
409
|
+
loadSynapses().catch(showError);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
es.addEventListener('auth_changed', function () {
|
|
413
|
+
loadStatus().catch(() => {});
|
|
414
|
+
loadSynapses().catch(showError);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
function removeRow(id) {
|
|
418
|
+
const row = document.querySelector('.memory-row[data-id="' + id + '"]');
|
|
419
|
+
if (!row) return;
|
|
420
|
+
const detailRow = document.querySelector('.detail-row[data-parent="' + id + '"]');
|
|
421
|
+
if (detailRow) {
|
|
422
|
+
detailRow.style.transition = 'opacity .2s, max-height .2s';
|
|
423
|
+
detailRow.style.opacity = '0';
|
|
424
|
+
detailRow.style.maxHeight = '0';
|
|
425
|
+
setTimeout(function () { detailRow.remove(); }, 250);
|
|
426
|
+
}
|
|
427
|
+
row.style.transition = 'opacity .2s';
|
|
428
|
+
row.style.opacity = '0';
|
|
429
|
+
setTimeout(function () { row.remove(); }, 250);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
es.addEventListener('synapse_updated', function () {
|
|
433
|
+
loadSynapses().catch(showError);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
/* Init */
|
|
437
|
+
(function () {
|
|
438
|
+
initSkeleton();
|
|
439
|
+
document.getElementById('confirm-dialog').addEventListener('click', function (e) {
|
|
440
|
+
if (e.target === this) this.close();
|
|
441
|
+
});
|
|
442
|
+
if (document.documentElement.getAttribute('data-theme') === 'light') {
|
|
443
|
+
document.getElementById('theme-btn').setAttribute('aria-label', 'Switch to dark theme');
|
|
444
|
+
}
|
|
445
|
+
loadStatus().catch(function () {});
|
|
446
|
+
loadSynapses().catch(showError);
|
|
447
|
+
})();
|