mdbrowse-cli 0.1.0
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 +64 -0
- package/bin/mdnow.js +80 -0
- package/package.json +46 -0
- package/public/app.js +658 -0
- package/public/index.html +46 -0
- package/public/style.css +879 -0
- package/src/renderer.js +170 -0
- package/src/scanner.js +96 -0
- package/src/search.js +118 -0
- package/src/server.js +299 -0
- package/src/tunnel.js +61 -0
- package/src/watcher.js +40 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/* ── mdnow client ── */
|
|
2
|
+
|
|
3
|
+
const fileTreeEl = document.getElementById('file-tree');
|
|
4
|
+
const contentInner = document.getElementById('content-inner');
|
|
5
|
+
const themeToggle = document.getElementById('theme-toggle');
|
|
6
|
+
const sidebarToggle = document.getElementById('sidebar-toggle');
|
|
7
|
+
const sidebar = document.getElementById('sidebar');
|
|
8
|
+
|
|
9
|
+
const searchInput = document.getElementById('search-input');
|
|
10
|
+
const searchClear = document.getElementById('search-clear');
|
|
11
|
+
|
|
12
|
+
let currentPath = null;
|
|
13
|
+
let treeData = null;
|
|
14
|
+
let isReadOnly = true;
|
|
15
|
+
let editMode = false;
|
|
16
|
+
let dirty = false;
|
|
17
|
+
let searchDebounceTimer = null;
|
|
18
|
+
let searchResultsEl = null;
|
|
19
|
+
|
|
20
|
+
const editBtn = document.getElementById('edit-btn');
|
|
21
|
+
const saveBtn = document.getElementById('save-btn');
|
|
22
|
+
const cancelEditBtn = document.getElementById('cancel-edit-btn');
|
|
23
|
+
|
|
24
|
+
const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp']);
|
|
25
|
+
|
|
26
|
+
function isImageFile(name) {
|
|
27
|
+
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
|
|
28
|
+
return IMAGE_EXTENSIONS.has(ext);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Theme ──
|
|
32
|
+
|
|
33
|
+
function getTheme() {
|
|
34
|
+
return localStorage.getItem('mdnow-theme') || 'auto';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function applyTheme(theme) {
|
|
38
|
+
document.documentElement.classList.remove('dark', 'light');
|
|
39
|
+
if (theme !== 'auto') {
|
|
40
|
+
document.documentElement.classList.add(theme);
|
|
41
|
+
}
|
|
42
|
+
localStorage.setItem('mdnow-theme', theme);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
themeToggle.addEventListener('click', () => {
|
|
46
|
+
const current = getTheme();
|
|
47
|
+
const isDark =
|
|
48
|
+
current === 'dark' ||
|
|
49
|
+
(current === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
50
|
+
applyTheme(isDark ? 'light' : 'dark');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
applyTheme(getTheme());
|
|
54
|
+
|
|
55
|
+
// ── Sidebar Toggle (mobile) ──
|
|
56
|
+
|
|
57
|
+
const sidebarBackdrop = document.createElement('div');
|
|
58
|
+
sidebarBackdrop.className = 'sidebar-backdrop';
|
|
59
|
+
document.body.appendChild(sidebarBackdrop);
|
|
60
|
+
|
|
61
|
+
function toggleSidebar(open) {
|
|
62
|
+
const isOpen = open !== undefined ? open : !sidebar.classList.contains('open');
|
|
63
|
+
sidebar.classList.toggle('open', isOpen);
|
|
64
|
+
sidebarBackdrop.classList.toggle('visible', isOpen);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
sidebarToggle.addEventListener('click', () => toggleSidebar());
|
|
68
|
+
sidebarBackdrop.addEventListener('click', () => toggleSidebar(false));
|
|
69
|
+
|
|
70
|
+
// Close sidebar when clicking content on mobile
|
|
71
|
+
document.getElementById('content').addEventListener('click', () => toggleSidebar(false));
|
|
72
|
+
|
|
73
|
+
// ── File Tree ──
|
|
74
|
+
|
|
75
|
+
async function fetchTree() {
|
|
76
|
+
const res = await fetch('/api/tree');
|
|
77
|
+
treeData = await res.json();
|
|
78
|
+
renderTree(treeData);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function fileIcon(name) {
|
|
82
|
+
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
|
|
83
|
+
const icons = {
|
|
84
|
+
md: '📄', mdx: '📄',
|
|
85
|
+
js: '📜', ts: '📜', mjs: '📜', cjs: '📜', jsx: '📜', tsx: '📜',
|
|
86
|
+
py: '🐍', rb: '💎', go: '🔵', rs: '🦀',
|
|
87
|
+
json: '{}', yaml: '⚙️', yml: '⚙️', toml: '⚙️',
|
|
88
|
+
html: '🌐', css: '🎨', svg: '🖼️',
|
|
89
|
+
sh: '⌨️', bash: '⌨️', zsh: '⌨️',
|
|
90
|
+
png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', webp: '🖼️',
|
|
91
|
+
};
|
|
92
|
+
return icons[ext] || '📄';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderTree(nodes, container, depth = 0) {
|
|
96
|
+
if (!container) {
|
|
97
|
+
container = fileTreeEl;
|
|
98
|
+
container.innerHTML = '';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const node of nodes) {
|
|
102
|
+
if (node.type === 'directory') {
|
|
103
|
+
const dirEl = document.createElement('div');
|
|
104
|
+
dirEl.className = 'tree-dir-group';
|
|
105
|
+
|
|
106
|
+
const item = document.createElement('div');
|
|
107
|
+
item.className = 'tree-item tree-dir';
|
|
108
|
+
item.style.setProperty('--depth', depth);
|
|
109
|
+
item.innerHTML = `
|
|
110
|
+
<span class="tree-icon tree-chevron">▾</span>
|
|
111
|
+
<span class="tree-icon">📁</span>
|
|
112
|
+
<span class="tree-name">${escapeHtml(node.name)}</span>
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
const children = document.createElement('div');
|
|
116
|
+
children.className = 'tree-children';
|
|
117
|
+
|
|
118
|
+
item.addEventListener('click', (e) => {
|
|
119
|
+
e.stopPropagation();
|
|
120
|
+
item.classList.toggle('collapsed');
|
|
121
|
+
children.classList.toggle('hidden');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
dirEl.appendChild(item);
|
|
125
|
+
dirEl.appendChild(children);
|
|
126
|
+
container.appendChild(dirEl);
|
|
127
|
+
|
|
128
|
+
if (node.children && node.children.length > 0) {
|
|
129
|
+
renderTree(node.children, children, depth + 1);
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
const item = document.createElement('div');
|
|
133
|
+
item.className = 'tree-item tree-file';
|
|
134
|
+
item.style.setProperty('--depth', depth);
|
|
135
|
+
item.dataset.path = node.path;
|
|
136
|
+
item.innerHTML = `
|
|
137
|
+
<span class="tree-icon">${fileIcon(node.name)}</span>
|
|
138
|
+
<span class="tree-name">${escapeHtml(node.name)}</span>
|
|
139
|
+
`;
|
|
140
|
+
|
|
141
|
+
item.addEventListener('click', (e) => {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
navigateTo(node.path);
|
|
144
|
+
toggleSidebar(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
container.appendChild(item);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function highlightActive(filePath) {
|
|
153
|
+
document.querySelectorAll('.tree-item.active').forEach(el => el.classList.remove('active'));
|
|
154
|
+
if (!filePath) return;
|
|
155
|
+
const el = document.querySelector(`.tree-file[data-path="${CSS.escape(filePath)}"]`);
|
|
156
|
+
if (el) {
|
|
157
|
+
el.classList.add('active');
|
|
158
|
+
el.scrollIntoView({ block: 'nearest' });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Navigation ──
|
|
163
|
+
|
|
164
|
+
async function navigateTo(filePath, pushState = true) {
|
|
165
|
+
if (pushState) {
|
|
166
|
+
history.pushState({ path: filePath }, '', '/view/' + filePath);
|
|
167
|
+
}
|
|
168
|
+
editMode = false;
|
|
169
|
+
hideAllToolbarButtons();
|
|
170
|
+
currentPath = filePath;
|
|
171
|
+
highlightActive(filePath);
|
|
172
|
+
await loadFile(filePath);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function loadFile(filePath) {
|
|
176
|
+
contentInner.innerHTML = '<div class="loading-skeleton">' +
|
|
177
|
+
'<div class="skeleton-line"></div>'.repeat(6) + '</div>';
|
|
178
|
+
|
|
179
|
+
if (isImageFile(filePath)) {
|
|
180
|
+
const src = '/raw/' + filePath.split('/').map(encodeURIComponent).join('/');
|
|
181
|
+
renderFile(filePath, { type: 'image', src });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const res = await fetch('/api/file?path=' + encodeURIComponent(filePath));
|
|
187
|
+
if (!res.ok) {
|
|
188
|
+
contentInner.innerHTML = `<div class="welcome"><h2>Error</h2><p>Could not load file: ${escapeHtml(filePath)}</p></div>`;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const data = await res.json();
|
|
192
|
+
renderFile(filePath, data);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
contentInner.innerHTML = `<div class="welcome"><h2>Error</h2><p>${escapeHtml(err.message)}</p></div>`;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function renderFile(filePath, data) {
|
|
199
|
+
const pathHeader = `<div class="file-path-header">${escapeHtml(filePath)}</div>`;
|
|
200
|
+
|
|
201
|
+
if (data.type === 'image') {
|
|
202
|
+
document.title = `${filePath} — mdnow`;
|
|
203
|
+
const name = filePath.split('/').pop();
|
|
204
|
+
contentInner.innerHTML = pathHeader + `<div class="image-preview"><img src="${data.src}" alt="${escapeHtml(name)}"></div>`;
|
|
205
|
+
} else if (data.type === 'notice') {
|
|
206
|
+
document.title = `${filePath} — mdnow`;
|
|
207
|
+
contentInner.innerHTML = pathHeader + `<div class="file-notice">${escapeHtml(data.message)}</div>`;
|
|
208
|
+
} else if (data.type === 'markdown') {
|
|
209
|
+
document.title = `${data.title || filePath} — mdnow`;
|
|
210
|
+
contentInner.innerHTML = pathHeader + `<div class="markdown-body">${data.html}</div>`;
|
|
211
|
+
} else {
|
|
212
|
+
document.title = `${filePath} — mdnow`;
|
|
213
|
+
contentInner.innerHTML = pathHeader + `<div class="code-view">${data.html}</div>`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Initialize mermaid diagrams
|
|
217
|
+
initMermaid();
|
|
218
|
+
|
|
219
|
+
// Show edit button if applicable
|
|
220
|
+
showEditButton();
|
|
221
|
+
|
|
222
|
+
// Scroll to top
|
|
223
|
+
document.getElementById('content').scrollTop = 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function initMermaid() {
|
|
227
|
+
const blocks = contentInner.querySelectorAll(
|
|
228
|
+
'code.language-mermaid, pre > code.language-mermaid'
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
if (blocks.length === 0) return;
|
|
232
|
+
|
|
233
|
+
// Initialize mermaid with theme detection
|
|
234
|
+
const isDark =
|
|
235
|
+
document.documentElement.classList.contains('dark') ||
|
|
236
|
+
(!document.documentElement.classList.contains('light') &&
|
|
237
|
+
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
238
|
+
|
|
239
|
+
mermaid.initialize({
|
|
240
|
+
startOnLoad: false,
|
|
241
|
+
theme: isDark ? 'dark' : 'default',
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
blocks.forEach((block, i) => {
|
|
245
|
+
const pre = block.closest('pre') || block;
|
|
246
|
+
const container = document.createElement('div');
|
|
247
|
+
container.className = 'mermaid';
|
|
248
|
+
container.textContent = block.textContent;
|
|
249
|
+
pre.replaceWith(container);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
mermaid.run();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── History API ──
|
|
256
|
+
|
|
257
|
+
window.addEventListener('popstate', (e) => {
|
|
258
|
+
if (e.state && e.state.path) {
|
|
259
|
+
navigateTo(e.state.path, false);
|
|
260
|
+
} else {
|
|
261
|
+
currentPath = null;
|
|
262
|
+
highlightActive(null);
|
|
263
|
+
showWelcome();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
function showWelcome() {
|
|
268
|
+
document.title = 'mdnow';
|
|
269
|
+
hideAllToolbarButtons();
|
|
270
|
+
editMode = false;
|
|
271
|
+
contentInner.innerHTML = `
|
|
272
|
+
<div id="welcome">
|
|
273
|
+
<h1>mdnow</h1>
|
|
274
|
+
<p>Select a file from the sidebar to get started.</p>
|
|
275
|
+
</div>
|
|
276
|
+
`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── WebSocket Live Reload ──
|
|
280
|
+
|
|
281
|
+
function connectWebSocket() {
|
|
282
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
283
|
+
const ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
|
284
|
+
const statusEl = getOrCreateStatus();
|
|
285
|
+
|
|
286
|
+
ws.addEventListener('open', () => {
|
|
287
|
+
statusEl.textContent = 'Connected';
|
|
288
|
+
statusEl.className = 'ws-status connected visible';
|
|
289
|
+
setTimeout(() => statusEl.classList.remove('visible'), 1500);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
ws.addEventListener('message', (event) => {
|
|
293
|
+
const msg = JSON.parse(event.data);
|
|
294
|
+
|
|
295
|
+
if (msg.type === 'change' && currentPath === msg.path) {
|
|
296
|
+
loadFile(msg.path);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (msg.type === 'add' || msg.type === 'unlink') {
|
|
300
|
+
fetchTree().then(() => {
|
|
301
|
+
if (currentPath) highlightActive(currentPath);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
ws.addEventListener('close', () => {
|
|
307
|
+
statusEl.textContent = 'Reconnecting…';
|
|
308
|
+
statusEl.className = 'ws-status visible';
|
|
309
|
+
setTimeout(connectWebSocket, 2000);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
ws.addEventListener('error', () => {
|
|
313
|
+
ws.close();
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function getOrCreateStatus() {
|
|
318
|
+
let el = document.querySelector('.ws-status');
|
|
319
|
+
if (!el) {
|
|
320
|
+
el = document.createElement('div');
|
|
321
|
+
el.className = 'ws-status';
|
|
322
|
+
document.body.appendChild(el);
|
|
323
|
+
}
|
|
324
|
+
return el;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Helpers ──
|
|
328
|
+
|
|
329
|
+
function escapeHtml(str) {
|
|
330
|
+
const div = document.createElement('div');
|
|
331
|
+
div.textContent = str;
|
|
332
|
+
return div.innerHTML;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── Edit Mode ──
|
|
336
|
+
|
|
337
|
+
function isEditableFile(filePath) {
|
|
338
|
+
if (!filePath) return false;
|
|
339
|
+
if (isImageFile(filePath)) return false;
|
|
340
|
+
const ext = filePath.includes('.') ? filePath.split('.').pop().toLowerCase() : '';
|
|
341
|
+
const binaryExts = new Set([
|
|
342
|
+
'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'bmp', 'tiff', 'tif',
|
|
343
|
+
'mp3', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'wav', 'ogg',
|
|
344
|
+
'zip', 'tar', 'gz', 'bz2', '7z', 'rar', 'xz',
|
|
345
|
+
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
|
346
|
+
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
|
347
|
+
'exe', 'dll', 'so', 'dylib', 'o', 'a',
|
|
348
|
+
'class', 'pyc', 'pyo', 'sqlite', 'db',
|
|
349
|
+
]);
|
|
350
|
+
return !binaryExts.has(ext);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function showEditButton() {
|
|
354
|
+
if (!isReadOnly && currentPath && isEditableFile(currentPath) && !editMode) {
|
|
355
|
+
editBtn.style.display = '';
|
|
356
|
+
} else {
|
|
357
|
+
editBtn.style.display = 'none';
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function hideAllToolbarButtons() {
|
|
362
|
+
editBtn.style.display = 'none';
|
|
363
|
+
saveBtn.style.display = 'none';
|
|
364
|
+
cancelEditBtn.style.display = 'none';
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function enterEditMode() {
|
|
368
|
+
if (!currentPath) return;
|
|
369
|
+
editMode = true;
|
|
370
|
+
editBtn.style.display = 'none';
|
|
371
|
+
saveBtn.style.display = '';
|
|
372
|
+
cancelEditBtn.style.display = '';
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
const res = await fetch('/api/raw-content?path=' + encodeURIComponent(currentPath));
|
|
376
|
+
if (!res.ok) {
|
|
377
|
+
exitEditMode();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const rawText = await res.text();
|
|
381
|
+
const pathHeader = `<div class="file-path-header">${escapeHtml(currentPath)}</div>`;
|
|
382
|
+
contentInner.innerHTML = pathHeader + '<textarea id="editor"></textarea>';
|
|
383
|
+
const editor = document.getElementById('editor');
|
|
384
|
+
editor.value = rawText;
|
|
385
|
+
dirty = false;
|
|
386
|
+
saveBtn.classList.remove('unsaved');
|
|
387
|
+
autoResizeTextarea(editor);
|
|
388
|
+
editor.addEventListener('input', () => {
|
|
389
|
+
dirty = true;
|
|
390
|
+
saveBtn.classList.add('unsaved');
|
|
391
|
+
autoResizeTextarea(editor);
|
|
392
|
+
});
|
|
393
|
+
editor.addEventListener('keydown', handleTabKey);
|
|
394
|
+
} catch {
|
|
395
|
+
exitEditMode();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function autoResizeTextarea(textarea) {
|
|
400
|
+
textarea.style.height = 'auto';
|
|
401
|
+
textarea.style.height = textarea.scrollHeight + 'px';
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function handleTabKey(e) {
|
|
405
|
+
if (e.key === 'Tab') {
|
|
406
|
+
e.preventDefault();
|
|
407
|
+
const textarea = e.target;
|
|
408
|
+
const start = textarea.selectionStart;
|
|
409
|
+
const end = textarea.selectionEnd;
|
|
410
|
+
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end);
|
|
411
|
+
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
|
412
|
+
textarea.dispatchEvent(new Event('input'));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function saveFile() {
|
|
417
|
+
const editor = document.getElementById('editor');
|
|
418
|
+
if (!editor || !currentPath) return;
|
|
419
|
+
|
|
420
|
+
saveBtn.disabled = true;
|
|
421
|
+
saveBtn.textContent = '⏳ Saving…';
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
const res = await fetch('/api/file?path=' + encodeURIComponent(currentPath), {
|
|
425
|
+
method: 'POST',
|
|
426
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
427
|
+
body: editor.value,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
if (!res.ok) {
|
|
431
|
+
const data = await res.json().catch(() => ({}));
|
|
432
|
+
alert('Save failed: ' + (data.error || res.statusText));
|
|
433
|
+
saveBtn.disabled = false;
|
|
434
|
+
saveBtn.textContent = '💾 Save';
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
dirty = false;
|
|
439
|
+
saveBtn.classList.remove('unsaved');
|
|
440
|
+
editMode = false;
|
|
441
|
+
hideAllToolbarButtons();
|
|
442
|
+
await loadFile(currentPath);
|
|
443
|
+
showEditButton();
|
|
444
|
+
|
|
445
|
+
// Brief success indicator
|
|
446
|
+
const status = getOrCreateStatus();
|
|
447
|
+
status.textContent = 'Saved';
|
|
448
|
+
status.className = 'ws-status connected visible';
|
|
449
|
+
setTimeout(() => status.classList.remove('visible'), 1500);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
alert('Save failed: ' + err.message);
|
|
452
|
+
saveBtn.disabled = false;
|
|
453
|
+
saveBtn.textContent = '💾 Save';
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function exitEditMode() {
|
|
458
|
+
dirty = false;
|
|
459
|
+
saveBtn.classList.remove('unsaved');
|
|
460
|
+
editMode = false;
|
|
461
|
+
hideAllToolbarButtons();
|
|
462
|
+
if (currentPath) {
|
|
463
|
+
await loadFile(currentPath);
|
|
464
|
+
showEditButton();
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
editBtn.addEventListener('click', enterEditMode);
|
|
469
|
+
saveBtn.addEventListener('click', saveFile);
|
|
470
|
+
cancelEditBtn.addEventListener('click', exitEditMode);
|
|
471
|
+
|
|
472
|
+
// Ctrl+S / Cmd+S to save
|
|
473
|
+
document.addEventListener('keydown', (e) => {
|
|
474
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
475
|
+
e.preventDefault();
|
|
476
|
+
if (editMode) saveFile();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Unsaved changes warning
|
|
481
|
+
window.addEventListener('beforeunload', (e) => {
|
|
482
|
+
if (dirty) e.preventDefault();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// ── Search ──
|
|
486
|
+
|
|
487
|
+
function clearSearch() {
|
|
488
|
+
searchInput.value = '';
|
|
489
|
+
searchClear.style.display = 'none';
|
|
490
|
+
if (searchResultsEl) {
|
|
491
|
+
searchResultsEl.remove();
|
|
492
|
+
searchResultsEl = null;
|
|
493
|
+
}
|
|
494
|
+
fileTreeEl.style.display = '';
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function highlightMatch(text, query) {
|
|
498
|
+
const lowerText = text.toLowerCase();
|
|
499
|
+
const lowerQuery = query.toLowerCase();
|
|
500
|
+
const parts = [];
|
|
501
|
+
let lastIndex = 0;
|
|
502
|
+
let idx;
|
|
503
|
+
while ((idx = lowerText.indexOf(lowerQuery, lastIndex)) !== -1) {
|
|
504
|
+
if (idx > lastIndex) parts.push(escapeHtml(text.slice(lastIndex, idx)));
|
|
505
|
+
parts.push('<mark>' + escapeHtml(text.slice(idx, idx + query.length)) + '</mark>');
|
|
506
|
+
lastIndex = idx + query.length;
|
|
507
|
+
}
|
|
508
|
+
if (lastIndex < text.length) parts.push(escapeHtml(text.slice(lastIndex)));
|
|
509
|
+
return parts.join('');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function renderSearchResults(data) {
|
|
513
|
+
if (searchResultsEl) searchResultsEl.remove();
|
|
514
|
+
fileTreeEl.style.display = 'none';
|
|
515
|
+
|
|
516
|
+
searchResultsEl = document.createElement('div');
|
|
517
|
+
searchResultsEl.className = 'search-results';
|
|
518
|
+
|
|
519
|
+
if (data.results.length === 0) {
|
|
520
|
+
searchResultsEl.innerHTML = `<div class="search-empty">No results for '${escapeHtml(data.query)}'</div>`;
|
|
521
|
+
sidebar.appendChild(searchResultsEl);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
for (const file of data.results) {
|
|
526
|
+
const group = document.createElement('div');
|
|
527
|
+
group.className = 'search-file-group';
|
|
528
|
+
|
|
529
|
+
const header = document.createElement('div');
|
|
530
|
+
header.className = 'search-file-header';
|
|
531
|
+
header.innerHTML = `<span class="tree-icon">${fileIcon(file.name)}</span> ${escapeHtml(file.path)}`;
|
|
532
|
+
header.addEventListener('click', () => {
|
|
533
|
+
navigateTo(file.path);
|
|
534
|
+
clearSearch();
|
|
535
|
+
toggleSidebar(false);
|
|
536
|
+
});
|
|
537
|
+
group.appendChild(header);
|
|
538
|
+
|
|
539
|
+
for (const match of file.matches) {
|
|
540
|
+
const row = document.createElement('div');
|
|
541
|
+
row.className = 'search-match';
|
|
542
|
+
row.innerHTML = `<span class="search-line-num">${match.lineNumber}</span><span class="search-line-text">${highlightMatch(match.line, data.query)}</span>`;
|
|
543
|
+
row.addEventListener('click', () => {
|
|
544
|
+
navigateTo(file.path);
|
|
545
|
+
clearSearch();
|
|
546
|
+
toggleSidebar(false);
|
|
547
|
+
});
|
|
548
|
+
group.appendChild(row);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
searchResultsEl.appendChild(group);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
sidebar.appendChild(searchResultsEl);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
searchInput.addEventListener('input', () => {
|
|
558
|
+
const query = searchInput.value.trim();
|
|
559
|
+
searchClear.style.display = query ? '' : 'none';
|
|
560
|
+
clearTimeout(searchDebounceTimer);
|
|
561
|
+
|
|
562
|
+
if (!query) {
|
|
563
|
+
clearSearch();
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
searchDebounceTimer = setTimeout(async () => {
|
|
568
|
+
try {
|
|
569
|
+
const res = await fetch('/api/search?q=' + encodeURIComponent(query));
|
|
570
|
+
const data = await res.json();
|
|
571
|
+
// Only render if input hasn't changed
|
|
572
|
+
if (searchInput.value.trim() === query) {
|
|
573
|
+
renderSearchResults(data);
|
|
574
|
+
}
|
|
575
|
+
} catch { /* ignore */ }
|
|
576
|
+
}, 300);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
searchClear.addEventListener('click', () => {
|
|
580
|
+
clearSearch();
|
|
581
|
+
searchInput.focus();
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// Ctrl+K / Cmd+K to focus search
|
|
585
|
+
document.addEventListener('keydown', (e) => {
|
|
586
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
587
|
+
e.preventDefault();
|
|
588
|
+
searchInput.focus();
|
|
589
|
+
searchInput.select();
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// ── Init ──
|
|
594
|
+
|
|
595
|
+
async function init() {
|
|
596
|
+
// Fetch config to determine read-only status
|
|
597
|
+
try {
|
|
598
|
+
const res = await fetch('/api/config');
|
|
599
|
+
const config = await res.json();
|
|
600
|
+
isReadOnly = config.readOnly;
|
|
601
|
+
} catch {
|
|
602
|
+
isReadOnly = true;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
await fetchTree();
|
|
606
|
+
|
|
607
|
+
// Check if URL has a /view/ path
|
|
608
|
+
const viewMatch = location.pathname.match(/^\/view\/(.+)$/);
|
|
609
|
+
if (viewMatch) {
|
|
610
|
+
const filePath = decodeURIComponent(viewMatch[1]);
|
|
611
|
+
await navigateTo(filePath, false);
|
|
612
|
+
// Ensure parent directories are expanded
|
|
613
|
+
expandToPath(filePath);
|
|
614
|
+
} else {
|
|
615
|
+
// Try to show README if it exists
|
|
616
|
+
const readme = findReadme(treeData);
|
|
617
|
+
if (readme) {
|
|
618
|
+
await navigateTo(readme, true);
|
|
619
|
+
expandToPath(readme);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
connectWebSocket();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function findReadme(nodes) {
|
|
627
|
+
for (const node of nodes) {
|
|
628
|
+
if (node.type === 'file' && /^readme\.md$/i.test(node.name)) {
|
|
629
|
+
return node.path;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function expandToPath(filePath) {
|
|
636
|
+
// Expand each parent directory in the tree
|
|
637
|
+
const parts = filePath.split('/');
|
|
638
|
+
let pathSoFar = '';
|
|
639
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
640
|
+
pathSoFar += (i > 0 ? '/' : '') + parts[i];
|
|
641
|
+
// Find the directory item and make sure it's not collapsed
|
|
642
|
+
const items = document.querySelectorAll('.tree-dir');
|
|
643
|
+
for (const item of items) {
|
|
644
|
+
const nameEl = item.querySelector('.tree-name');
|
|
645
|
+
if (nameEl && item.classList.contains('collapsed')) {
|
|
646
|
+
// Check if this is in the path by walking up
|
|
647
|
+
const group = item.closest('.tree-dir-group');
|
|
648
|
+
const children = group?.querySelector('.tree-children');
|
|
649
|
+
if (children?.classList.contains('hidden')) {
|
|
650
|
+
item.classList.remove('collapsed');
|
|
651
|
+
children.classList.remove('hidden');
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
init();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>mdnow</title>
|
|
7
|
+
<link rel="stylesheet" href="/assets/style.css">
|
|
8
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
|
|
9
|
+
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="app">
|
|
13
|
+
<aside id="sidebar">
|
|
14
|
+
<div class="sidebar-header">
|
|
15
|
+
<span class="logo">mdnow</span>
|
|
16
|
+
<button id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
|
|
17
|
+
<svg class="icon-sun" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
|
18
|
+
<svg class="icon-moon" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
19
|
+
</button>
|
|
20
|
+
</div>
|
|
21
|
+
<div id="search-container">
|
|
22
|
+
<input type="text" id="search-input" placeholder="Search files..." autocomplete="off">
|
|
23
|
+
<button id="search-clear" title="Clear search" style="display:none">✕</button>
|
|
24
|
+
</div>
|
|
25
|
+
<nav id="file-tree"></nav>
|
|
26
|
+
</aside>
|
|
27
|
+
<main id="content">
|
|
28
|
+
<div id="content-toolbar">
|
|
29
|
+
<button id="edit-btn" title="Edit file" style="display:none">✏️ Edit</button>
|
|
30
|
+
<button id="save-btn" title="Save file" style="display:none">💾 Save</button>
|
|
31
|
+
<button id="cancel-edit-btn" title="Cancel editing" style="display:none">✖ Cancel</button>
|
|
32
|
+
</div>
|
|
33
|
+
<div id="content-inner">
|
|
34
|
+
<div id="welcome">
|
|
35
|
+
<h1>mdnow</h1>
|
|
36
|
+
<p>Select a file from the sidebar to get started.</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</main>
|
|
40
|
+
</div>
|
|
41
|
+
<button id="sidebar-toggle" title="Toggle sidebar" aria-label="Toggle sidebar">
|
|
42
|
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
|
43
|
+
</button>
|
|
44
|
+
<script src="/assets/app.js"></script>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|