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.
@@ -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
+ })();