granite-mem 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/dist/index.js +1160 -0
- package/dist/public/app.js +588 -0
- package/dist/public/canvas-renderer.js +524 -0
- package/dist/public/graph.js +380 -0
- package/dist/public/index.html +157 -0
- package/dist/public/markdown-renderer.js +189 -0
- package/dist/public/style.css +923 -0
- package/package.json +68 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Granite — App Controller
|
|
3
|
+
*
|
|
4
|
+
* Sidebar, HTML note rendering, wikilink previews,
|
|
5
|
+
* note creation, graph toggle, keyboard navigation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
(function () {
|
|
9
|
+
// ── State ──
|
|
10
|
+
let allNotes = [];
|
|
11
|
+
let currentNote = null;
|
|
12
|
+
let currentType = '';
|
|
13
|
+
let searchTimeout = null;
|
|
14
|
+
let isSearchMode = false;
|
|
15
|
+
let currentView = 'note'; // 'note' | 'graph'
|
|
16
|
+
let isCreateOpen = false;
|
|
17
|
+
let graphData = null;
|
|
18
|
+
let previewCache = {};
|
|
19
|
+
let previewTimeout = null;
|
|
20
|
+
let keyboardIndex = -1;
|
|
21
|
+
|
|
22
|
+
// ── DOM ──
|
|
23
|
+
const sidebar = document.getElementById('sidebar');
|
|
24
|
+
const searchBox = document.getElementById('search-box');
|
|
25
|
+
const typeFilters = document.getElementById('type-filters');
|
|
26
|
+
const noteList = document.getElementById('note-list');
|
|
27
|
+
const noteHeader = document.getElementById('note-header');
|
|
28
|
+
const noteTitle = document.getElementById('note-title');
|
|
29
|
+
const noteBreadcrumb = document.getElementById('note-breadcrumb');
|
|
30
|
+
const noteTypeBadge = document.getElementById('note-type-badge');
|
|
31
|
+
const noteDate = document.getElementById('note-date');
|
|
32
|
+
const noteTags = document.getElementById('note-tags');
|
|
33
|
+
const noteBody = document.getElementById('note-body');
|
|
34
|
+
const backlinksPanel = document.getElementById('backlinks-panel');
|
|
35
|
+
const backlinksList = document.getElementById('backlinks-list');
|
|
36
|
+
const backlinksCount = document.getElementById('backlinks-count');
|
|
37
|
+
const emptyState = document.getElementById('empty-state');
|
|
38
|
+
const contentWrapper = document.getElementById('content-wrapper');
|
|
39
|
+
const graphView = document.getElementById('graph-view');
|
|
40
|
+
const graphCanvas = document.getElementById('graph-canvas');
|
|
41
|
+
const createPanel = document.getElementById('create-panel');
|
|
42
|
+
const previewEl = document.getElementById('wikilink-preview');
|
|
43
|
+
|
|
44
|
+
// ── API ──
|
|
45
|
+
async function api(url, opts) {
|
|
46
|
+
const res = await fetch(url, opts);
|
|
47
|
+
return res.json();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Init ──
|
|
51
|
+
async function init() {
|
|
52
|
+
const { types } = await api('/api/types');
|
|
53
|
+
for (const name of Object.keys(types)) {
|
|
54
|
+
const btn = document.createElement('button');
|
|
55
|
+
btn.className = 'type-chip';
|
|
56
|
+
btn.dataset.type = name;
|
|
57
|
+
btn.textContent = name;
|
|
58
|
+
btn.addEventListener('click', () => filterByType(name));
|
|
59
|
+
typeFilters.appendChild(btn);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Populate create panel type selector
|
|
63
|
+
const createTypeSelect = document.getElementById('create-type');
|
|
64
|
+
for (const name of Object.keys(types)) {
|
|
65
|
+
const opt = document.createElement('option');
|
|
66
|
+
opt.value = name;
|
|
67
|
+
opt.textContent = name;
|
|
68
|
+
createTypeSelect.appendChild(opt);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
typeFilters.querySelector('[data-type=""]').addEventListener('click', () => filterByType(''));
|
|
72
|
+
|
|
73
|
+
await loadNotes();
|
|
74
|
+
setupSearch();
|
|
75
|
+
setupKeyboard();
|
|
76
|
+
setupSidebar();
|
|
77
|
+
setupCreate();
|
|
78
|
+
setupWikilinkPreviews();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ═══════════════════════════════════
|
|
82
|
+
// SEARCH
|
|
83
|
+
// ═══════════════════════════════════
|
|
84
|
+
|
|
85
|
+
function setupSearch() {
|
|
86
|
+
searchBox.addEventListener('input', () => {
|
|
87
|
+
clearTimeout(searchTimeout);
|
|
88
|
+
searchTimeout = setTimeout(() => {
|
|
89
|
+
const q = searchBox.value.trim();
|
|
90
|
+
if (q.length > 0) {
|
|
91
|
+
searchNotes(q);
|
|
92
|
+
isSearchMode = true;
|
|
93
|
+
} else {
|
|
94
|
+
isSearchMode = false;
|
|
95
|
+
renderNoteList(allNotes);
|
|
96
|
+
}
|
|
97
|
+
keyboardIndex = -1;
|
|
98
|
+
}, 180);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
searchBox.addEventListener('keydown', (e) => {
|
|
102
|
+
if (e.key === 'Escape') {
|
|
103
|
+
searchBox.value = '';
|
|
104
|
+
searchBox.blur();
|
|
105
|
+
isSearchMode = false;
|
|
106
|
+
renderNoteList(allNotes);
|
|
107
|
+
keyboardIndex = -1;
|
|
108
|
+
}
|
|
109
|
+
if (e.key === 'Enter') {
|
|
110
|
+
const items = noteList.querySelectorAll('.note-item');
|
|
111
|
+
const target = keyboardIndex >= 0 ? items[keyboardIndex] : items[0];
|
|
112
|
+
if (target) target.click();
|
|
113
|
+
}
|
|
114
|
+
if (e.key === 'ArrowDown') {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
navigateList(1);
|
|
117
|
+
}
|
|
118
|
+
if (e.key === 'ArrowUp') {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
navigateList(-1);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ═══════════════════════════════════
|
|
126
|
+
// KEYBOARD NAVIGATION
|
|
127
|
+
// ═══════════════════════════════════
|
|
128
|
+
|
|
129
|
+
function navigateList(delta) {
|
|
130
|
+
const items = noteList.querySelectorAll('.note-item');
|
|
131
|
+
if (items.length === 0) return;
|
|
132
|
+
|
|
133
|
+
// Clear old focus
|
|
134
|
+
items.forEach(el => el.classList.remove('keyboard-focus'));
|
|
135
|
+
|
|
136
|
+
keyboardIndex += delta;
|
|
137
|
+
if (keyboardIndex < 0) keyboardIndex = items.length - 1;
|
|
138
|
+
if (keyboardIndex >= items.length) keyboardIndex = 0;
|
|
139
|
+
|
|
140
|
+
items[keyboardIndex].classList.add('keyboard-focus');
|
|
141
|
+
items[keyboardIndex].scrollIntoView({ block: 'nearest' });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function setupKeyboard() {
|
|
145
|
+
document.addEventListener('keydown', (e) => {
|
|
146
|
+
// ⌘K — focus search
|
|
147
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
if (sidebar.classList.contains('collapsed')) toggleSidebar();
|
|
150
|
+
closeCreate();
|
|
151
|
+
searchBox.focus();
|
|
152
|
+
searchBox.select();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ⌘N — new note
|
|
156
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
toggleCreate();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ⌘G — toggle graph
|
|
162
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'g') {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
toggleGraph();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Escape — close panels / deselect
|
|
168
|
+
if (e.key === 'Escape' && document.activeElement !== searchBox) {
|
|
169
|
+
if (isCreateOpen) {
|
|
170
|
+
closeCreate();
|
|
171
|
+
} else if (currentView === 'graph') {
|
|
172
|
+
setView('note');
|
|
173
|
+
} else if (currentNote) {
|
|
174
|
+
currentNote = null;
|
|
175
|
+
showEmptyState();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ↑/↓ in note list (when not focused on inputs)
|
|
180
|
+
if (!['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) {
|
|
181
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); navigateList(1); }
|
|
182
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); navigateList(-1); }
|
|
183
|
+
if (e.key === 'Enter' && keyboardIndex >= 0) {
|
|
184
|
+
const items = noteList.querySelectorAll('.note-item');
|
|
185
|
+
if (items[keyboardIndex]) items[keyboardIndex].click();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ═══════════════════════════════════
|
|
192
|
+
// SIDEBAR
|
|
193
|
+
// ═══════════════════════════════════
|
|
194
|
+
|
|
195
|
+
function setupSidebar() {
|
|
196
|
+
document.getElementById('rail-toggle').addEventListener('click', toggleSidebar);
|
|
197
|
+
document.getElementById('rail-search').addEventListener('click', () => {
|
|
198
|
+
if (sidebar.classList.contains('collapsed')) toggleSidebar();
|
|
199
|
+
closeCreate();
|
|
200
|
+
setTimeout(() => { searchBox.focus(); searchBox.select(); }, 200);
|
|
201
|
+
});
|
|
202
|
+
document.getElementById('rail-graph').addEventListener('click', toggleGraph);
|
|
203
|
+
document.getElementById('rail-new').addEventListener('click', toggleCreate);
|
|
204
|
+
document.getElementById('brand-mark').addEventListener('click', () => {
|
|
205
|
+
currentNote = null;
|
|
206
|
+
setView('note');
|
|
207
|
+
showEmptyState();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function toggleSidebar() {
|
|
212
|
+
sidebar.classList.toggle('expanded');
|
|
213
|
+
sidebar.classList.toggle('collapsed');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ═══════════════════════════════════
|
|
217
|
+
// NOTE CREATION
|
|
218
|
+
// ═══════════════════════════════════
|
|
219
|
+
|
|
220
|
+
function setupCreate() {
|
|
221
|
+
document.getElementById('create-panel-close').addEventListener('click', closeCreate);
|
|
222
|
+
document.getElementById('create-submit').addEventListener('click', submitCreate);
|
|
223
|
+
|
|
224
|
+
// Enter in title field submits if body is empty
|
|
225
|
+
document.getElementById('create-title').addEventListener('keydown', (e) => {
|
|
226
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
document.getElementById('create-body').focus();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function toggleCreate() {
|
|
234
|
+
if (isCreateOpen) {
|
|
235
|
+
closeCreate();
|
|
236
|
+
} else {
|
|
237
|
+
openCreate();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function openCreate() {
|
|
242
|
+
if (sidebar.classList.contains('collapsed')) toggleSidebar();
|
|
243
|
+
isCreateOpen = true;
|
|
244
|
+
noteList.style.display = 'none';
|
|
245
|
+
document.getElementById('type-filters').style.display = 'none';
|
|
246
|
+
createPanel.style.display = 'flex';
|
|
247
|
+
document.getElementById('rail-new').classList.add('active');
|
|
248
|
+
setTimeout(() => document.getElementById('create-title').focus(), 100);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function closeCreate() {
|
|
252
|
+
isCreateOpen = false;
|
|
253
|
+
createPanel.style.display = 'none';
|
|
254
|
+
noteList.style.display = '';
|
|
255
|
+
document.getElementById('type-filters').style.display = '';
|
|
256
|
+
document.getElementById('rail-new').classList.remove('active');
|
|
257
|
+
// Reset form
|
|
258
|
+
document.getElementById('create-title').value = '';
|
|
259
|
+
document.getElementById('create-body').value = '';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function submitCreate() {
|
|
263
|
+
const type = document.getElementById('create-type').value;
|
|
264
|
+
const title = document.getElementById('create-title').value.trim();
|
|
265
|
+
const body = document.getElementById('create-body').value;
|
|
266
|
+
|
|
267
|
+
if (!title) {
|
|
268
|
+
document.getElementById('create-title').focus();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const result = await api('/api/notes', {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
headers: { 'Content-Type': 'application/json' },
|
|
275
|
+
body: JSON.stringify({ type, title, body }),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (result.slug) {
|
|
279
|
+
closeCreate();
|
|
280
|
+
await loadNotes();
|
|
281
|
+
loadNote(result.slug);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ═══════════════════════════════════
|
|
286
|
+
// GRAPH
|
|
287
|
+
// ═══════════════════════════════════
|
|
288
|
+
|
|
289
|
+
function toggleGraph() {
|
|
290
|
+
if (currentView === 'graph') {
|
|
291
|
+
setView('note');
|
|
292
|
+
} else {
|
|
293
|
+
setView('graph');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function setView(view) {
|
|
298
|
+
currentView = view;
|
|
299
|
+
const graphBtn = document.getElementById('rail-graph');
|
|
300
|
+
|
|
301
|
+
if (view === 'graph') {
|
|
302
|
+
graphBtn.classList.add('active');
|
|
303
|
+
contentWrapper.style.display = 'none';
|
|
304
|
+
emptyState.style.display = 'none';
|
|
305
|
+
graphView.style.display = 'flex';
|
|
306
|
+
loadGraph();
|
|
307
|
+
} else {
|
|
308
|
+
graphBtn.classList.remove('active');
|
|
309
|
+
graphView.style.display = 'none';
|
|
310
|
+
GraphEngine.stop();
|
|
311
|
+
if (currentNote) {
|
|
312
|
+
contentWrapper.style.display = 'flex';
|
|
313
|
+
emptyState.style.display = 'none';
|
|
314
|
+
} else {
|
|
315
|
+
contentWrapper.style.display = 'none';
|
|
316
|
+
emptyState.style.display = 'flex';
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function loadGraph() {
|
|
322
|
+
if (!graphData) {
|
|
323
|
+
graphData = await api('/api/graph');
|
|
324
|
+
}
|
|
325
|
+
GraphEngine.init(graphCanvas, graphData, {
|
|
326
|
+
activeSlug: currentNote?.slug || null,
|
|
327
|
+
onNavigate: (slug) => {
|
|
328
|
+
loadNote(slug);
|
|
329
|
+
setView('note');
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ═══════════════════════════════════
|
|
335
|
+
// WIKILINK PREVIEWS
|
|
336
|
+
// ═══════════════════════════════════
|
|
337
|
+
|
|
338
|
+
function setupWikilinkPreviews() {
|
|
339
|
+
document.addEventListener('mouseover', (e) => {
|
|
340
|
+
const link = e.target.closest('.wikilink');
|
|
341
|
+
if (!link) return;
|
|
342
|
+
|
|
343
|
+
clearTimeout(previewTimeout);
|
|
344
|
+
previewTimeout = setTimeout(() => showPreview(link), 300);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
document.addEventListener('mouseout', (e) => {
|
|
348
|
+
const link = e.target.closest('.wikilink');
|
|
349
|
+
if (link || e.relatedTarget?.closest?.('.wikilink-preview')) return;
|
|
350
|
+
|
|
351
|
+
clearTimeout(previewTimeout);
|
|
352
|
+
hidePreview();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
previewEl.addEventListener('mouseleave', hidePreview);
|
|
356
|
+
|
|
357
|
+
// Click navigation
|
|
358
|
+
document.addEventListener('click', (e) => {
|
|
359
|
+
const link = e.target.closest('.wikilink');
|
|
360
|
+
if (!link) return;
|
|
361
|
+
e.preventDefault();
|
|
362
|
+
hidePreview();
|
|
363
|
+
const slug = link.dataset.slug;
|
|
364
|
+
if (slug) loadNote(slug);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function showPreview(linkEl) {
|
|
369
|
+
const slug = linkEl.dataset.slug;
|
|
370
|
+
if (!slug) return;
|
|
371
|
+
|
|
372
|
+
let data = previewCache[slug];
|
|
373
|
+
if (!data) {
|
|
374
|
+
data = await api(`/api/notes/${slug}`);
|
|
375
|
+
if (data.error) return;
|
|
376
|
+
previewCache[slug] = data;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Position
|
|
380
|
+
const rect = linkEl.getBoundingClientRect();
|
|
381
|
+
const previewWidth = 280;
|
|
382
|
+
let left = rect.left;
|
|
383
|
+
let top = rect.bottom + 8;
|
|
384
|
+
|
|
385
|
+
// Keep on screen
|
|
386
|
+
if (left + previewWidth > window.innerWidth - 16) {
|
|
387
|
+
left = window.innerWidth - previewWidth - 16;
|
|
388
|
+
}
|
|
389
|
+
if (top + 160 > window.innerHeight) {
|
|
390
|
+
top = rect.top - 8;
|
|
391
|
+
previewEl.style.transform = 'translateY(-100%)';
|
|
392
|
+
} else {
|
|
393
|
+
previewEl.style.transform = '';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
previewEl.style.left = left + 'px';
|
|
397
|
+
previewEl.style.top = top + 'px';
|
|
398
|
+
|
|
399
|
+
// Content
|
|
400
|
+
previewEl.querySelector('.preview-type').textContent = data.type || '';
|
|
401
|
+
previewEl.querySelector('.preview-title').textContent = data.title || '';
|
|
402
|
+
previewEl.querySelector('.preview-snippet').textContent = (data.body || '').slice(0, 150).replace(/[#*_`>\[\]]/g, '');
|
|
403
|
+
const tagsEl = previewEl.querySelector('.preview-tags');
|
|
404
|
+
tagsEl.innerHTML = (data.tags || []).map(t => `<span class="preview-tag">${esc(t)}</span>`).join('');
|
|
405
|
+
|
|
406
|
+
previewEl.classList.add('visible');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function hidePreview() {
|
|
410
|
+
clearTimeout(previewTimeout);
|
|
411
|
+
previewEl.classList.remove('visible');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ═══════════════════════════════════
|
|
415
|
+
// DATA LOADING
|
|
416
|
+
// ═══════════════════════════════════
|
|
417
|
+
|
|
418
|
+
async function loadNotes() {
|
|
419
|
+
const url = currentType ? `/api/notes?type=${currentType}` : '/api/notes';
|
|
420
|
+
const data = await api(url);
|
|
421
|
+
allNotes = data.notes;
|
|
422
|
+
graphData = null; // Invalidate graph cache
|
|
423
|
+
renderNoteList(allNotes);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function filterByType(type) {
|
|
427
|
+
currentType = type;
|
|
428
|
+
typeFilters.querySelectorAll('.type-chip').forEach(btn => {
|
|
429
|
+
btn.classList.toggle('active', btn.dataset.type === type);
|
|
430
|
+
});
|
|
431
|
+
keyboardIndex = -1;
|
|
432
|
+
loadNotes();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function searchNotes(query) {
|
|
436
|
+
const data = await api(`/api/search?q=${encodeURIComponent(query)}`);
|
|
437
|
+
renderNoteList(data.results.map(r => ({
|
|
438
|
+
slug: r.slug,
|
|
439
|
+
title: r.title,
|
|
440
|
+
type: '',
|
|
441
|
+
snippet: r.snippet,
|
|
442
|
+
})));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ═══════════════════════════════════
|
|
446
|
+
// RENDER NOTE LIST
|
|
447
|
+
// ═══════════════════════════════════
|
|
448
|
+
|
|
449
|
+
function renderNoteList(notes) {
|
|
450
|
+
noteList.innerHTML = '';
|
|
451
|
+
keyboardIndex = -1;
|
|
452
|
+
|
|
453
|
+
if (notes.length === 0) {
|
|
454
|
+
noteList.innerHTML = '<div style="padding: 16px 12px; color: var(--text-3); font-size: 12px;">No notes found</div>';
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
for (const note of notes) {
|
|
459
|
+
const item = document.createElement('div');
|
|
460
|
+
item.className = 'note-item' + (currentNote?.slug === note.slug ? ' active' : '');
|
|
461
|
+
|
|
462
|
+
let meta = '';
|
|
463
|
+
if (note.type) {
|
|
464
|
+
meta += `<span class="note-item-type ${note.type}">${note.type}</span>`;
|
|
465
|
+
}
|
|
466
|
+
if (note.modified) {
|
|
467
|
+
if (note.type) meta += '<span class="note-item-dot"></span>';
|
|
468
|
+
meta += `<span>${formatDate(note.modified)}</span>`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
let snippet = '';
|
|
472
|
+
if (note.snippet) {
|
|
473
|
+
snippet = `<div class="note-item-snippet">${note.snippet.replace(/>>>/g, '<mark>').replace(/<<</g, '</mark>')}</div>`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
item.innerHTML = `
|
|
477
|
+
<div class="note-item-title">${esc(note.title)}</div>
|
|
478
|
+
<div class="note-item-meta">${meta}</div>
|
|
479
|
+
${snippet}
|
|
480
|
+
`;
|
|
481
|
+
item.addEventListener('click', () => loadNote(note.slug));
|
|
482
|
+
noteList.appendChild(item);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ═══════════════════════════════════
|
|
487
|
+
// LOAD + RENDER NOTE
|
|
488
|
+
// ═══════════════════════════════════
|
|
489
|
+
|
|
490
|
+
async function loadNote(slug) {
|
|
491
|
+
const note = await api(`/api/notes/${slug}`);
|
|
492
|
+
if (note.error) return;
|
|
493
|
+
|
|
494
|
+
currentNote = note;
|
|
495
|
+
|
|
496
|
+
// Switch to note view if in graph
|
|
497
|
+
if (currentView === 'graph') {
|
|
498
|
+
setView('note');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Show content, hide empty state
|
|
502
|
+
emptyState.style.display = 'none';
|
|
503
|
+
contentWrapper.style.display = 'flex';
|
|
504
|
+
noteHeader.style.display = 'block';
|
|
505
|
+
noteBody.style.display = 'block';
|
|
506
|
+
|
|
507
|
+
// Animate header
|
|
508
|
+
noteHeader.style.animation = 'none';
|
|
509
|
+
noteHeader.offsetHeight;
|
|
510
|
+
noteHeader.style.animation = 'fadeSlideIn 0.35s var(--ease-out)';
|
|
511
|
+
|
|
512
|
+
// Header content
|
|
513
|
+
noteBreadcrumb.textContent = note.type;
|
|
514
|
+
noteTitle.textContent = note.title;
|
|
515
|
+
noteTypeBadge.textContent = note.type;
|
|
516
|
+
noteTypeBadge.className = 'meta-badge';
|
|
517
|
+
noteDate.textContent = formatDate(note.created);
|
|
518
|
+
noteTags.innerHTML = (note.tags || []).map(t => `<span class="tag">${esc(t)}</span>`).join('');
|
|
519
|
+
|
|
520
|
+
// Render markdown body as HTML
|
|
521
|
+
noteBody.style.animation = 'none';
|
|
522
|
+
noteBody.offsetHeight;
|
|
523
|
+
noteBody.style.animation = 'fadeSlideIn 0.45s var(--ease-out) 0.05s both';
|
|
524
|
+
noteBody.innerHTML = MarkdownRenderer.render(note.body, note.outgoing_links);
|
|
525
|
+
|
|
526
|
+
// Backlinks
|
|
527
|
+
if (note.backlinks && note.backlinks.length > 0) {
|
|
528
|
+
backlinksPanel.style.display = 'block';
|
|
529
|
+
backlinksCount.textContent = note.backlinks.length;
|
|
530
|
+
backlinksList.innerHTML = '';
|
|
531
|
+
for (const bl of note.backlinks) {
|
|
532
|
+
const item = document.createElement('div');
|
|
533
|
+
item.className = 'backlink-item';
|
|
534
|
+
item.innerHTML = `
|
|
535
|
+
<div class="backlink-title">
|
|
536
|
+
<span class="backlink-arrow">←</span>
|
|
537
|
+
${esc(bl.source_title)}
|
|
538
|
+
</div>
|
|
539
|
+
${bl.context ? `<div class="backlink-context">${esc(bl.context)}</div>` : ''}
|
|
540
|
+
`;
|
|
541
|
+
item.addEventListener('click', () => loadNote(bl.source_slug));
|
|
542
|
+
backlinksList.appendChild(item);
|
|
543
|
+
}
|
|
544
|
+
} else {
|
|
545
|
+
backlinksPanel.style.display = 'none';
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Update active state in list
|
|
549
|
+
noteList.querySelectorAll('.note-item').forEach((el, idx) => {
|
|
550
|
+
const noteData = isSearchMode ? null : allNotes[idx];
|
|
551
|
+
el.classList.toggle('active', noteData?.slug === slug);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// Update graph if it was loaded
|
|
555
|
+
if (graphData) {
|
|
556
|
+
GraphEngine.setActiveSlug(slug);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
contentWrapper.scrollTop = 0;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function showEmptyState() {
|
|
563
|
+
noteHeader.style.display = 'none';
|
|
564
|
+
noteBody.style.display = 'none';
|
|
565
|
+
backlinksPanel.style.display = 'none';
|
|
566
|
+
contentWrapper.style.display = 'none';
|
|
567
|
+
emptyState.style.display = 'flex';
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ═══════════════════════════════════
|
|
571
|
+
// HELPERS
|
|
572
|
+
// ═══════════════════════════════════
|
|
573
|
+
|
|
574
|
+
function esc(str) {
|
|
575
|
+
const d = document.createElement('div');
|
|
576
|
+
d.textContent = str;
|
|
577
|
+
return d.innerHTML;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function formatDate(iso) {
|
|
581
|
+
if (!iso) return '';
|
|
582
|
+
const d = new Date(iso);
|
|
583
|
+
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// ── Boot ──
|
|
587
|
+
init();
|
|
588
|
+
})();
|