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/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>