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