82.2 KB2867 lines
Blame
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>Grove Collab</title>
7<link rel="icon" type="image/svg+xml" href="/favicon.svg">
8<link href="https://fonts.googleapis.com/css2?family=Libre+Caslon+Text:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
9<script src="/mermaid.min.js"></script>
10<script src="/socket.io/socket.io.js"></script>
11<script type="module" id="cm-loader">
12 // CodeMirror 6 — single local bundle (no duplicate @codemirror/state)
13 import {
14 EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection,
15 EditorState,
16 defaultKeymap, indentWithTab, history, historyKeymap,
17 HighlightStyle, syntaxHighlighting, StreamLanguage, bracketMatching,
18 tags,
19 closeBrackets,
20 Y, yCollab
21 } from "/cm-bundle.js";
22
23 // ── Custom Mermaid stream parser ──
24 const mermaidParser = {
25 startState() { return { inBlock: false, blockType: null }; },
26 token(stream, state) {
27 // Comments
28 if (stream.match(/^%%/)) { stream.skipToEnd(); return "lineComment"; }
29
30 // Strings
31 if (stream.match(/^"[^"]*"/)) return "string";
32
33 // Diagram-type keywords (at start of doc or after whitespace)
34 if (stream.sol() && stream.match(/^(graph|flowchart|erDiagram|classDiagram|stateDiagram-v2|stateDiagram|sequenceDiagram|gantt|pie|gitGraph|mindmap|timeline|journey|quadrantChart|sankey|xychart)\b/)) {
35 return "keyword";
36 }
37
38 // Block keywords
39 if (stream.match(/^(subgraph|end|direction|class|state|section|participant|actor|loop|alt|else|opt|par|critical|break|note|over|of|left|right|title)\b/)) {
40 return "keyword";
41 }
42
43 // ER cardinality symbols
44 if (stream.match(/^\|\|--|[{o|]/) || stream.match(/^[|o][|{]--[|o][|{]/)) return "operator";
45
46 // Arrows
47 if (stream.match(/^==>/) || stream.match(/^-\.->/) || stream.match(/^\.->/) || stream.match(/^-->/) || stream.match(/^---/) || stream.match(/^-\.-/) || stream.match(/^===/) || stream.match(/^-->/) || stream.match(/^-->/)) return "operator";
48 if (stream.match(/^--|>/)) return "operator";
49 if (stream.match(/^\.\.>/)) return "operator";
50
51 // Pipe labels |"text"|
52 if (stream.match(/^\|/)) return "bracket";
53
54 // Brackets for node shapes
55 if (stream.match(/^[\[\](){}]/)) return "bracket";
56
57 // Data types in ER diagrams
58 if (stream.match(/^(int|text|real|bool|datetime|float|string|void)\b/)) return "typeName";
59
60 // PK, FK, UK markers
61 if (stream.match(/^(PK|FK|UK)\b/)) return "annotation";
62
63 // Visibility markers in class diagrams
64 if (stream.match(/^[+#~-](?=[A-Za-z])/)) return "operator";
65
66 // Identifiers / words
67 if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) return "variableName";
68
69 // Numbers
70 if (stream.match(/^[0-9]+(\.[0-9]+)?/)) return "number";
71
72 // Colon
73 if (stream.match(/^:/)) return "punctuation";
74
75 // Skip anything else
76 stream.next();
77 return null;
78 }
79 };
80
81 const mermaidLang = StreamLanguage.define(mermaidParser);
82
83 // ── Grove theme (matches our CSS vars) ──
84 const groveTheme = EditorView.theme({
85 "&": {
86 fontSize: "12px",
87 fontFamily: "'JetBrains Mono', Menlo, monospace",
88 backgroundColor: "#eae6df",
89 color: "#2c2824",
90 flex: "1",
91 overflow: "hidden",
92 },
93 "&.cm-focused": { outline: "none" },
94 ".cm-scroller": { overflow: "auto", fontFamily: "inherit" },
95 ".cm-content": { padding: "12px 8px", caretColor: "#4d8a78" },
96 ".cm-gutters": {
97 backgroundColor: "#e2ddd5",
98 color: "#a09888",
99 border: "none",
100 borderRight: "1px solid #d9d3ca",
101 minWidth: "36px",
102 },
103 ".cm-activeLineGutter": { backgroundColor: "#d9d3ca", color: "#4a4540" },
104 ".cm-activeLine": { backgroundColor: "#e2ddd520" },
105 ".cm-cursor": { borderLeftColor: "#4d8a78", borderLeftWidth: "2px" },
106 ".cm-selectionBackground": { backgroundColor: "#4d8a7830 !important" },
107 "&.cm-focused .cm-selectionBackground": { backgroundColor: "#4d8a7840 !important" },
108 ".cm-matchingBracket": { backgroundColor: "#4d8a7830", outline: "1px solid #4d8a7860" },
109 }, { dark: false });
110
111 const groveHighlight = HighlightStyle.define([
112 { tag: tags.keyword, color: "#7c4dff", fontWeight: "500" },
113 { tag: tags.string, color: "#2d6b56" },
114 { tag: tags.lineComment, color: "#a09888", fontStyle: "italic" },
115 { tag: tags.operator, color: "#c56200" },
116 { tag: tags.bracket, color: "#7a746c" },
117 { tag: tags.typeName, color: "#0277bd" },
118 { tag: tags.annotation, color: "#c62828", fontWeight: "600" },
119 { tag: tags.variableName, color: "#2c2824" },
120 { tag: tags.number, color: "#0277bd" },
121 { tag: tags.punctuation, color: "#7a746c" },
122 ]);
123
124 // Expose Yjs so the main script can create docs/providers
125 window._Y = Y;
126 window._yCollab = yCollab;
127
128 // Expose a factory so the main script can create the editor
129 // If ytext is provided, uses collaborative Yjs mode; otherwise standalone
130 window._cmCreateEditor = function(parentEl, initialCode, onChange, ytext, undoManager) {
131 const collabMode = ytext && undoManager;
132 const extensions = [
133 lineNumbers(),
134 highlightActiveLine(),
135 highlightActiveLineGutter(),
136 drawSelection(),
137 bracketMatching(),
138 closeBrackets(),
139 collabMode ? [] : history(),
140 keymap.of([...defaultKeymap, ...(collabMode ? [] : historyKeymap), indentWithTab]),
141 mermaidLang,
142 groveTheme,
143 syntaxHighlighting(groveHighlight),
144 EditorView.updateListener.of((update) => {
145 if (update.docChanged && onChange) {
146 onChange(update.state.doc.toString());
147 }
148 }),
149 EditorView.lineWrapping,
150 ];
151 if (collabMode) {
152 extensions.push(yCollab(ytext, null, { undoManager }));
153 }
154 const view = new EditorView({
155 state: EditorState.create({
156 doc: collabMode ? ytext.toString() : initialCode,
157 extensions: extensions.flat(),
158 }),
159 parent: parentEl,
160 });
161 return view;
162 };
163
164 // Signal that CM is ready
165 window.dispatchEvent(new Event("cm-ready"));
166</script>
167<style>
168 :root {
169 /* Grove light theme */
170 --bg-page: #faf8f5;
171 --bg-card: #f2efe9;
172 --bg-inset: #eae6df;
173 --bg-hover: #e2ddd5;
174 --bg-input: #ffffff;
175
176 --text-primary: #2c2824;
177 --text-secondary: #4a4540;
178 --text-muted: #7a746c;
179 --text-faint: #a09888;
180
181 --accent: #4d8a78;
182 --accent-hover: #3d7a68;
183 --accent-subtle: #e8f2ee;
184 --accent-text: #ffffff;
185
186 --border: #d9d3ca;
187 --border-subtle: #e8e3db;
188 --divide: #e2ddd580;
189
190 --note-bg: #e8f2ee;
191 --note-text: #2d6b56;
192 --note-border: #b0d4c5;
193
194 --drawer-width: 260px;
195 }
196
197 * { margin: 0; padding: 0; box-sizing: border-box; }
198
199 body {
200 font-family: 'Libre Caslon Text', Georgia, serif;
201 background: var(--bg-page);
202 color: var(--text-primary);
203 overflow: hidden;
204 height: 100vh;
205 display: flex;
206 flex-direction: column;
207 }
208
209 code, pre, .mono {
210 font-family: 'JetBrains Mono', Menlo, monospace;
211 }
212
213 ::-webkit-scrollbar { width: 6px; height: 6px; }
214 ::-webkit-scrollbar-track { background: transparent; }
215 ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
216 ::-webkit-scrollbar-thumb:hover { background: var(--text-faint); }
217
218 /* Top bar */
219 .topbar {
220 display: flex;
221 align-items: center;
222 justify-content: space-between;
223 height: 3.5rem;
224 padding: 0 0.75rem;
225 border-bottom: 1px solid var(--border);
226 flex-shrink: 0;
227 z-index: 100;
228 }
229
230 @media (min-width: 640px) {
231 .topbar { padding: 0 1.5rem; }
232 }
233
234 .topbar-left {
235 display: flex;
236 align-items: center;
237 gap: 0.375rem;
238 font-size: 0.875rem;
239 min-width: 0;
240 }
241
242 @media (min-width: 640px) {
243 .topbar-left { gap: 0.75rem; }
244 }
245
246 .topbar .logo {
247 display: flex;
248 align-items: center;
249 gap: 0.5rem;
250 text-decoration: none;
251 flex-shrink: 0;
252 }
253
254 .topbar .logo-text {
255 font-size: 1.125rem;
256 font-weight: 500;
257 color: var(--text-primary);
258 margin-left: 0.25rem;
259 }
260
261 .topbar .breadcrumb-sep {
262 color: var(--text-faint);
263 }
264
265 .topbar .breadcrumb-item {
266 color: var(--text-muted);
267 text-decoration: none;
268 overflow: hidden;
269 text-overflow: ellipsis;
270 white-space: nowrap;
271 }
272
273 .topbar .breadcrumb-item:hover {
274 text-decoration: underline;
275 }
276
277 .topbar .breadcrumb-current {
278 color: var(--text-primary);
279 overflow: hidden;
280 text-overflow: ellipsis;
281 white-space: nowrap;
282 }
283
284 .topbar-right {
285 display: flex;
286 align-items: center;
287 gap: 0.5rem;
288 flex-shrink: 0;
289 }
290
291 .topbar .presence {
292 display: flex;
293 align-items: center;
294 gap: 4px;
295 }
296
297 .presence-dot {
298 width: 1.75rem;
299 height: 1.75rem;
300 border-radius: 50%;
301 display: flex;
302 align-items: center;
303 justify-content: center;
304 font-size: 0.6875rem;
305 font-weight: 600;
306 color: #fff;
307 cursor: default;
308 position: relative;
309 }
310
311 .presence-dot .tooltip {
312 display: none;
313 position: absolute;
314 bottom: -28px;
315 left: 50%;
316 transform: translateX(-50%);
317 background: var(--bg-card);
318 color: var(--text-primary);
319 padding: 2px 8px;
320 font-size: 11px;
321 white-space: nowrap;
322 border: 1px solid var(--border);
323 z-index: 200;
324 }
325
326 .presence-dot:hover .tooltip { display: block; }
327
328 .topbar .actions {
329 display: flex;
330 gap: 0.5rem;
331 }
332
333 .btn {
334 padding: 0.375rem 0.75rem;
335 border: 1px solid var(--border);
336 background: none;
337 color: var(--text-muted);
338 font-size: 0.875rem;
339 font-family: inherit;
340 cursor: pointer;
341 transition: background-color 0.15s;
342 }
343
344 .btn:hover { background: var(--bg-hover); }
345 .btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
346 .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
347
348 .topbar .profile-pill {
349 display: flex;
350 align-items: center;
351 gap: 6px;
352 padding: 4px 10px;
353 border: 1px solid var(--border);
354 border-radius: 9999px;
355 background: none;
356 color: var(--text-muted);
357 font-size: 0.8125rem;
358 font-family: inherit;
359 cursor: pointer;
360 }
361
362 .topbar .profile-pill:hover {
363 background: var(--bg-hover);
364 }
365
366 /* Left drawer */
367 .drawer {
368 width: var(--drawer-width);
369 background: var(--bg-card);
370 border-right: 1px solid var(--border);
371 display: flex;
372 flex-direction: column;
373 flex-shrink: 0;
374 overflow: hidden;
375 }
376
377
378 .drawer-header {
379 padding: 12px 16px;
380 font-size: 0.75rem;
381 font-weight: 500;
382 color: var(--text-muted);
383 text-transform: uppercase;
384 letter-spacing: 0.5px;
385 border-bottom: 1px solid var(--border-subtle);
386 flex-shrink: 0;
387 }
388
389 .drawer-list {
390 flex: 1;
391 overflow-y: auto;
392 padding: 4px 0;
393 }
394
395 .drawer-section {
396 padding: 8px 12px 4px 12px;
397 font-size: 10px;
398 font-weight: 500;
399 color: var(--text-faint);
400 text-transform: uppercase;
401 letter-spacing: 0.5px;
402 font-family: 'JetBrains Mono', Menlo, monospace;
403 }
404
405 .drawer-item {
406 display: flex;
407 align-items: center;
408 gap: 8px;
409 padding: 7px 12px 7px 16px;
410 font-size: 12px;
411 color: var(--text-secondary);
412 cursor: pointer;
413 transition: background-color 0.12s, color 0.12s;
414 white-space: nowrap;
415 overflow: hidden;
416 text-overflow: ellipsis;
417 font-family: 'JetBrains Mono', Menlo, monospace;
418 position: relative;
419 border-left: 3px solid transparent;
420 }
421
422 .drawer-item:hover {
423 background: var(--bg-hover);
424 color: var(--text-primary);
425 }
426
427 .drawer-item.active {
428 background: var(--accent-subtle);
429 color: var(--accent);
430 border-left-color: var(--accent);
431 }
432
433 .drawer-item .item-number {
434 font-size: 10px;
435 color: var(--text-faint);
436 min-width: 18px;
437 flex-shrink: 0;
438 }
439
440 .drawer-item.active .item-number {
441 color: var(--accent);
442 }
443
444 .drawer-item .item-label {
445 overflow: hidden;
446 text-overflow: ellipsis;
447 flex: 1;
448 }
449
450 .drawer-item .note-count {
451 background: var(--accent-subtle);
452 color: var(--accent);
453 font-size: 10px;
454 font-weight: 500;
455 padding: 1px 5px;
456 border-radius: 2px;
457 border: 1px solid var(--note-border);
458 flex-shrink: 0;
459 }
460
461 .drawer-item .viewer-dots {
462 display: flex;
463 gap: 3px;
464 flex-shrink: 0;
465 margin-left: auto;
466 padding-left: 6px;
467 }
468
469 .drawer-item .viewer-dot {
470 width: 8px;
471 height: 8px;
472 border-radius: 50%;
473 border: 1px solid rgba(255,255,255,0.5);
474 box-shadow: 0 0 0 0.5px rgba(0,0,0,0.1);
475 }
476
477 /* Main area */
478 .main {
479 display: flex;
480 flex: 1;
481 overflow: hidden;
482 }
483
484 /* Diagram pane */
485 .diagram-pane {
486 flex: 1;
487 overflow: hidden;
488 position: relative;
489 background: var(--bg-page);
490 cursor: grab;
491 }
492
493 .diagram-pane.panning { cursor: grabbing; }
494
495 .diagram-pane .mermaid-container {
496 transform-origin: 0 0;
497 display: inline-block;
498 padding: 40px;
499 position: relative;
500 }
501
502 .diagram-pane .mermaid-container svg {
503 display: block;
504 }
505
506 /* Node focus: dim all diagram elements, then un-dim highlighted ones */
507 .diagram-pane.node-focus .mermaid-container svg .node-dimable {
508 opacity: 0.1;
509 transition: opacity 0.2s ease;
510 }
511
512 .diagram-pane.node-focus .mermaid-container svg .node-dimable.node-highlight {
513 opacity: 1 !important;
514 transition: opacity 0.2s ease;
515 }
516
517 .diagram-pane .mermaid-container svg .node-clickable {
518 cursor: pointer;
519 }
520
521 .bottom-toolbar {
522 position: absolute;
523 bottom: 12px;
524 right: 12px;
525 display: flex;
526 align-items: center;
527 gap: 4px;
528 z-index: 20;
529 background: var(--bg-card);
530 border: 1px solid var(--border);
531 border-radius: 6px;
532 padding: 3px;
533 box-shadow: 0 2px 8px rgba(0,0,0,0.06);
534 opacity: 0.7;
535 transition: opacity 0.15s;
536 }
537
538 .bottom-toolbar:hover {
539 opacity: 1;
540 }
541
542 .toolbar-divider {
543 width: 1px;
544 height: 20px;
545 background: var(--border);
546 margin: 0 2px;
547 }
548
549 .mode-btn {
550 width: 32px;
551 height: 32px;
552 border: 1px solid transparent;
553 background: transparent;
554 color: var(--text-muted);
555 border-radius: 4px;
556 cursor: pointer;
557 display: flex;
558 align-items: center;
559 justify-content: center;
560 transition: background-color 0.12s, color 0.12s, border-color 0.12s;
561 }
562
563 .mode-btn:hover {
564 background: var(--bg-hover);
565 color: var(--text-primary);
566 }
567
568 .mode-btn.active {
569 background: var(--accent-subtle);
570 color: var(--accent);
571 border-color: var(--accent);
572 }
573
574 .zoom-btn {
575 width: 32px;
576 height: 32px;
577 border: 1px solid transparent;
578 background: transparent;
579 color: var(--text-secondary);
580 border-radius: 4px;
581 cursor: pointer;
582 font-size: 16px;
583 font-family: 'JetBrains Mono', Menlo, monospace;
584 display: flex;
585 align-items: center;
586 justify-content: center;
587 transition: background-color 0.15s;
588 }
589
590 .zoom-btn:hover { background: var(--bg-hover); }
591
592 .zoom-level {
593 height: 32px;
594 padding: 0 8px;
595 color: var(--text-muted);
596 font-size: 11px;
597 font-family: 'JetBrains Mono', Menlo, monospace;
598 display: flex;
599 align-items: center;
600 }
601
602
603 /* Code pane (vertical left sidebar) */
604 .code-pane {
605 width: 32px;
606 min-width: 32px;
607 background: var(--bg-card);
608 border-right: 1px solid var(--border);
609 flex-shrink: 0;
610 overflow: hidden;
611 display: flex;
612 flex-direction: column;
613 position: relative;
614 }
615
616 .code-pane.open {
617 width: 520px;
618 min-width: 200px;
619 }
620
621 .code-pane-header {
622 display: flex;
623 align-items: center;
624 gap: 6px;
625 padding: 8px 8px;
626 font-size: 11px;
627 font-weight: 500;
628 color: var(--text-muted);
629 font-family: 'JetBrains Mono', Menlo, monospace;
630 cursor: pointer;
631 user-select: none;
632 border-bottom: 1px solid var(--border-subtle);
633 text-transform: uppercase;
634 letter-spacing: 0.5px;
635 white-space: nowrap;
636 flex-shrink: 0;
637 }
638
639 .code-pane:not(.open) .code-pane-header {
640 writing-mode: vertical-lr;
641 text-orientation: mixed;
642 border-bottom: none;
643 padding: 12px 6px;
644 flex: 1;
645 justify-content: start;
646 align-items: center;
647 }
648
649 .code-pane:not(.open) .code-pane-toggle {
650 transform: rotate(90deg);
651 margin-bottom: 2px;
652 }
653
654 .code-pane-header:hover { background: var(--bg-hover); }
655
656 .code-pane-fmt-btn {
657 display: none;
658 padding: 2px 8px;
659 font-size: 10px;
660 font-family: 'JetBrains Mono', Menlo, monospace;
661 background: var(--bg-inset);
662 border: 1px solid var(--border);
663 border-radius: 3px;
664 color: var(--text-muted);
665 cursor: pointer;
666 text-transform: none;
667 letter-spacing: 0;
668 transition: background-color 0.12s, color 0.12s;
669 }
670
671 .code-pane.open .code-pane-fmt-btn { display: block; }
672 .code-pane.open .code-pane-text-size { display: flex !important; gap: 2px; align-items: center; }
673 .code-pane-fmt-btn:hover { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
674
675 .code-pane-play-btn {
676 display: none;
677 padding: 2px 10px;
678 font-size: 10px;
679 font-family: 'JetBrains Mono', Menlo, monospace;
680 background: var(--accent);
681 border: 1px solid var(--accent);
682 border-radius: 3px;
683 color: var(--accent-text);
684 cursor: pointer;
685 text-transform: none;
686 letter-spacing: 0;
687 transition: opacity 0.12s, background-color 0.12s;
688 opacity: 0.5;
689 }
690 .code-pane.open .code-pane-play-btn { display: block; }
691 .code-pane-play-btn.dirty { opacity: 1; }
692 .code-pane-play-btn:hover { opacity: 1; }
693
694 .code-pane-status {
695 display: none;
696 font-size: 10px;
697 font-family: 'JetBrains Mono', Menlo, monospace;
698 color: var(--text-muted);
699 white-space: nowrap;
700 cursor: default;
701 }
702 .code-pane.open .code-pane-status { display: inline-block; }
703 .code-pane-status.error { color: #a05050; }
704 .code-pane-status.ok { color: var(--accent); }
705
706 .code-pane-diagnostics {
707 display: none;
708 padding: 6px 10px;
709 font-size: 11px;
710 font-family: 'JetBrains Mono', Menlo, monospace;
711 color: #a05050;
712 background: #a050500a;
713 border-top: 1px solid #a0505030;
714 flex-shrink: 0;
715 overflow-x: auto;
716 white-space: pre-wrap;
717 word-break: break-word;
718 max-height: 120px;
719 overflow-y: auto;
720 line-height: 1.5;
721 }
722 .code-pane.open .code-pane-diagnostics.visible { display: block; }
723
724 .code-pane-toggle {
725 font-size: 10px;
726 transition: transform 0.15s;
727 display: inline-block;
728 }
729
730 .code-pane.open .code-pane-toggle {
731 transform: rotate(90deg);
732 }
733
734 .code-pane-editor {
735 display: none;
736 flex: 1;
737 overflow: hidden;
738 background: var(--bg-inset);
739 }
740
741 .code-pane.open .code-pane-editor {
742 display: flex;
743 }
744
745 /* Resize handle */
746 .code-pane-resize {
747 position: absolute;
748 top: 0;
749 right: -3px;
750 width: 6px;
751 height: 100%;
752 cursor: col-resize;
753 z-index: 10;
754 display: none;
755 }
756
757 .code-pane.open .code-pane-resize {
758 display: block;
759 }
760
761 .code-pane-resize:hover,
762 .code-pane-resize.dragging {
763 background: var(--accent);
764 opacity: 0.4;
765 }
766
767 /* Notes sidebar */
768 .notes-sidebar {
769 width: 300px;
770 min-width: 180px;
771 background: var(--bg-card);
772 border-left: 1px solid var(--border);
773 display: flex;
774 flex-direction: column;
775 flex-shrink: 0;
776 position: relative;
777 }
778
779 .notes-resize {
780 position: absolute;
781 top: 0;
782 left: -3px;
783 width: 6px;
784 height: 100%;
785 cursor: col-resize;
786 z-index: 10;
787 }
788
789 .notes-resize:hover,
790 .notes-resize.dragging {
791 background: var(--accent);
792 opacity: 0.4;
793 }
794
795 .notes-header {
796 padding: 8px 12px;
797 font-size: 11px;
798 font-weight: 500;
799 color: var(--text-muted);
800 border-bottom: 1px solid var(--border-subtle);
801 display: flex;
802 align-items: center;
803 justify-content: space-between;
804 font-family: 'JetBrains Mono', Menlo, monospace;
805 text-transform: uppercase;
806 letter-spacing: 0.5px;
807 }
808
809 .notes-list {
810 flex: 1;
811 overflow-y: auto;
812 padding: 6px;
813 }
814
815 .notes-list:empty::after {
816 content: "No notes yet";
817 display: block;
818 text-align: center;
819 padding: 32px 16px;
820 color: var(--text-faint);
821 font-size: 12px;
822 font-style: italic;
823 }
824
825 .note-card {
826 background: transparent;
827 border: none;
828 border-bottom: 1px solid var(--border-subtle);
829 border-radius: 0;
830 padding: 8px 10px;
831 font-size: 13px;
832 position: relative;
833 transition: background-color 0.12s;
834 }
835
836 .note-card:last-child { border-bottom: none; }
837
838 .note-card:hover {
839 background: var(--bg-hover);
840 }
841
842 .note-card .note-meta {
843 display: flex;
844 align-items: center;
845 gap: 6px;
846 margin-bottom: 3px;
847 font-size: 10px;
848 color: var(--text-muted);
849 font-family: 'JetBrains Mono', Menlo, monospace;
850 }
851
852 .note-card .note-author {
853 font-weight: 500;
854 color: var(--accent);
855 }
856
857 .note-card .note-text {
858 color: var(--text-primary);
859 line-height: 1.5;
860 font-size: 12px;
861 }
862
863 .note-card .note-actions {
864 position: absolute;
865 top: 6px;
866 right: 6px;
867 display: none;
868 gap: 3px;
869 }
870
871 .note-card:hover .note-actions { display: flex; }
872
873 .note-action-btn {
874 width: 20px;
875 height: 20px;
876 border: none;
877 background: var(--bg-inset);
878 color: var(--text-muted);
879 border-radius: 3px;
880 cursor: pointer;
881 font-size: 10px;
882 display: flex;
883 align-items: center;
884 justify-content: center;
885 transition: background-color 0.12s;
886 }
887
888 .note-action-btn:hover { background: var(--accent); color: var(--accent-text); }
889
890 .notes-input-area {
891 padding: 8px;
892 border-top: 1px solid var(--border-subtle);
893 display: flex;
894 gap: 6px;
895 align-items: flex-end;
896 }
897
898 .notes-input-area textarea {
899 flex: 1;
900 background: var(--bg-inset);
901 border: 1px solid var(--border-subtle);
902 border-radius: 4px;
903 color: var(--text-primary);
904 padding: 6px 10px;
905 font-size: 12px;
906 font-family: 'Libre Caslon Text', Georgia, serif;
907 resize: none;
908 outline: none;
909 min-height: 32px;
910 max-height: 80px;
911 line-height: 1.4;
912 transition: border-color 0.12s;
913 }
914
915 .notes-input-area textarea::placeholder { color: var(--text-faint); font-size: 11px; }
916 .notes-input-area textarea:focus { border-color: var(--accent); }
917
918 .notes-input-area .note-send-btn {
919 width: 32px;
920 height: 32px;
921 border: none;
922 background: var(--accent);
923 color: var(--accent-text);
924 border-radius: 4px;
925 cursor: pointer;
926 display: flex;
927 align-items: center;
928 justify-content: center;
929 flex-shrink: 0;
930 transition: opacity 0.12s;
931 opacity: 0.6;
932 }
933
934 .notes-input-area .note-send-btn:hover { opacity: 1; }
935
936 .notes-input-area .input-hint {
937 font-size: 11px;
938 color: var(--text-muted);
939 margin-top: 4px;
940 }
941
942 .notes-input-area .input-row {
943 display: flex;
944 gap: 8px;
945 margin-top: 8px;
946 align-items: center;
947 }
948
949 .notes-input-area .click-hint {
950 font-size: 11px;
951 color: var(--text-faint);
952 font-style: italic;
953 }
954
955 /* Remote cursors */
956 .remote-cursor {
957 position: fixed;
958 pointer-events: none;
959 z-index: 1000;
960 transition: left 0.12s linear, top 0.12s linear;
961 display: flex;
962 align-items: center;
963 gap: 4px;
964 }
965
966 .remote-cursor .cursor-dot {
967 width: 10px;
968 height: 10px;
969 border-radius: 50%;
970 border: 1.5px solid rgba(255,255,255,0.8);
971 box-shadow: 0 1px 3px rgba(0,0,0,0.3);
972 flex-shrink: 0;
973 }
974
975 .remote-cursor .cursor-label {
976 font-size: 9px;
977 font-weight: 500;
978 color: #fff;
979 padding: 1px 5px;
980 border-radius: 3px;
981 white-space: nowrap;
982 font-family: 'JetBrains Mono', Menlo, monospace;
983 opacity: 0;
984 transition: opacity 0.2s;
985 }
986
987 .remote-cursor:hover .cursor-label,
988 .remote-cursor.recent .cursor-label {
989 opacity: 1;
990 }
991
992
993 /* Empty state */
994 .empty-notes {
995 display: flex;
996 flex-direction: column;
997 align-items: center;
998 justify-content: center;
999 height: 100%;
1000 color: var(--text-faint);
1001 font-size: 13px;
1002 text-align: center;
1003 padding: 24px;
1004 }
1005
1006 .empty-notes .icon { font-size: 32px; margin-bottom: 8px; opacity: 0.4; }
1007
1008 /* Toast */
1009 @keyframes toast-enter {
1010 from { opacity: 0; transform: translateX(-50%) translateY(8px); }
1011 to { opacity: 1; transform: translateX(-50%) translateY(0); }
1012 }
1013
1014 .toast {
1015 position: fixed;
1016 bottom: 20px;
1017 left: 50%;
1018 transform: translateX(-50%);
1019 background: var(--bg-card);
1020 color: var(--text-primary);
1021 border: 1px solid var(--border);
1022 padding: 8px 20px;
1023 border-radius: 4px;
1024 font-size: 13px;
1025 z-index: 10000;
1026 opacity: 0;
1027 transition: opacity 0.3s;
1028 pointer-events: none;
1029 }
1030
1031 .toast.show { opacity: 1; animation: toast-enter 0.15s ease-out; }
1032
1033 /* Keyboard hint in drawer footer */
1034 .drawer-footer {
1035 padding: 8px 12px;
1036 border-top: 1px solid var(--border-subtle);
1037 font-size: 10px;
1038 color: var(--text-faint);
1039 font-family: 'JetBrains Mono', Menlo, monospace;
1040 text-align: center;
1041 flex-shrink: 0;
1042 }
1043
1044 .drawer-footer kbd {
1045 display: inline-block;
1046 background: var(--bg-hover);
1047 border: 1px solid var(--border);
1048 border-radius: 3px;
1049 padding: 0 4px;
1050 font-size: 10px;
1051 font-family: 'JetBrains Mono', Menlo, monospace;
1052 }
1053
1054
1055 /* Homepage */
1056 .homepage-nav {
1057 display: flex;
1058 align-items: center;
1059 justify-content: space-between;
1060 height: 3.5rem;
1061 padding: 0 1.5rem;
1062 border-bottom: 1px solid var(--border);
1063 background: var(--bg-page);
1064 }
1065
1066 .homepage-nav .nav-left {
1067 display: flex;
1068 align-items: center;
1069 gap: 0.5rem;
1070 }
1071
1072 .homepage-nav .nav-left span {
1073 font-size: 1.125rem;
1074 font-weight: 500;
1075 color: var(--text-primary);
1076 margin-left: 0.25rem;
1077 }
1078
1079 .homepage-nav .nav-right {
1080 display: flex;
1081 align-items: center;
1082 gap: 1rem;
1083 font-size: 0.875rem;
1084 }
1085
1086 .homepage-nav .nav-right a {
1087 color: var(--text-muted);
1088 text-decoration: none;
1089 }
1090
1091 .homepage-nav .nav-right a:hover {
1092 text-decoration: underline;
1093 }
1094
1095 .profile-pill {
1096 display: flex;
1097 align-items: center;
1098 gap: 6px;
1099 padding: 4px 10px;
1100 border: 1px solid var(--border);
1101 border-radius: 9999px;
1102 background: none;
1103 color: var(--text-muted);
1104 font-size: 0.8125rem;
1105 font-family: inherit;
1106 cursor: pointer;
1107 }
1108
1109 .profile-pill:hover {
1110 background: var(--bg-hover);
1111 }
1112
1113 .profile-pill svg {
1114 flex-shrink: 0;
1115 }
1116
1117 .profile-menu {
1118 position: relative;
1119 }
1120
1121 .profile-dropdown {
1122 display: none;
1123 position: absolute;
1124 right: 0;
1125 top: calc(100% + 4px);
1126 min-width: 160px;
1127 background: var(--bg-card);
1128 border: 1px solid var(--border);
1129 box-shadow: 0 4px 12px rgba(0,0,0,0.1);
1130 z-index: 200;
1131 }
1132
1133 .profile-dropdown.open {
1134 display: block;
1135 }
1136
1137 .profile-dropdown a {
1138 display: block;
1139 padding: 6px 12px;
1140 font-size: 0.875rem;
1141 color: var(--text-primary);
1142 text-decoration: none;
1143 cursor: pointer;
1144 }
1145
1146 .profile-dropdown a:hover {
1147 background: var(--bg-hover);
1148 text-decoration: none;
1149 }
1150
1151 .homepage {
1152 max-width: 48rem;
1153 width: 100%;
1154 margin: 0 auto;
1155 padding: 2rem 1rem;
1156 display: flex;
1157 flex-direction: column;
1158 }
1159
1160 .homepage-search {
1161 margin-bottom: 1rem;
1162 padding: 10px 12px;
1163 background: var(--bg-card);
1164 border: 1px solid var(--border-subtle);
1165 }
1166
1167 .homepage-search input {
1168 width: 100%;
1169 font-size: 14px;
1170 background: transparent;
1171 color: var(--text-secondary);
1172 border: none;
1173 outline: none;
1174 font-family: 'Libre Caslon Text', Georgia, serif;
1175 }
1176
1177 .homepage-search input::placeholder {
1178 color: var(--text-faint);
1179 }
1180
1181 .homepage-group {
1182 background: var(--bg-card);
1183 border: 1px solid var(--border-subtle);
1184 margin-bottom: 1rem;
1185 }
1186
1187 .homepage-owner {
1188 display: block;
1189 padding: 8px 12px;
1190 font-size: 14px;
1191 font-weight: 500;
1192 color: var(--text-muted);
1193 border-bottom: 1px solid var(--border-subtle);
1194 text-decoration: none;
1195 transition: background-color 0.1s;
1196 }
1197
1198 .homepage-owner:hover {
1199 background: var(--bg-hover);
1200 }
1201
1202 .homepage-repo {
1203 display: flex;
1204 align-items: center;
1205 justify-content: space-between;
1206 gap: 1rem;
1207 padding: 10px 12px;
1208 text-decoration: none;
1209 transition: background-color 0.1s;
1210 }
1211
1212 .homepage-repo:hover {
1213 background: var(--bg-hover);
1214 }
1215
1216 .homepage-repo + .homepage-repo {
1217 border-top: 1px solid var(--border-subtle);
1218 }
1219
1220 .homepage-repo-name {
1221 color: var(--accent);
1222 font-size: 14px;
1223 }
1224
1225 .homepage-repo-desc {
1226 color: var(--text-faint);
1227 font-size: 12px;
1228 margin-top: 2px;
1229 overflow: hidden;
1230 text-overflow: ellipsis;
1231 white-space: nowrap;
1232 }
1233
1234 .homepage-repo-time {
1235 color: var(--text-faint);
1236 font-size: 12px;
1237 flex-shrink: 0;
1238 }
1239
1240 .homepage-empty {
1241 padding: 3rem 1rem;
1242 text-align: center;
1243 background: var(--bg-card);
1244 border: 1px solid var(--border-subtle);
1245 }
1246
1247 .homepage-empty .logo-wrap {
1248 opacity: 0.5;
1249 margin-bottom: 1rem;
1250 }
1251
1252 .homepage-empty p {
1253 font-size: 14px;
1254 color: var(--text-faint);
1255 }
1256
1257 .homepage-loading {
1258 background: var(--bg-card);
1259 border: 1px solid var(--border-subtle);
1260 }
1261
1262 .homepage-loading .skel-row {
1263 padding: 10px 12px;
1264 border-top: 1px solid var(--divide);
1265 }
1266
1267 .homepage-loading .skel-row:first-child {
1268 border-top: none;
1269 }
1270
1271 .skel {
1272 height: 0.875rem;
1273 border-radius: 3px;
1274 background: linear-gradient(90deg, var(--bg-hover) 25%, var(--bg-inset) 50%, var(--bg-hover) 75%);
1275 background-size: 200% 100%;
1276 animation: shimmer 1.5s ease-in-out infinite;
1277 }
1278
1279 @keyframes shimmer {
1280 0% { background-position: 200% 0; }
1281 100% { background-position: -200% 0; }
1282 }
1283
1284</style>
1285</head>
1286<body>
1287
1288<!-- Top bar -->
1289<div class="topbar">
1290 <div class="topbar-left">
1291 <a href="/" class="logo">
1292 <svg width="28" height="28" viewBox="0 0 64 64" fill="none"><circle cx="32" cy="32" r="32" fill="var(--accent)"/><path d="M9 16 C9 11, 14 8, 21 8 L35 8 C42 8, 47 11, 47 16 L47 24 C47 29, 42 32, 35 32 L19 32 L13 37 L13 32 C10 31, 9 29, 9 24 Z" fill="white"/><path d="M55 35 C55 30, 50 27, 43 27 L29 27 C22 27, 17 30, 17 35 L17 43 C17 48, 22 51, 29 51 L45 51 L51 56 L51 51 C54 50, 55 48, 55 43 Z" fill="white" opacity="0.65"/></svg>
1293 </a>
1294 <span class="breadcrumb-sep">/</span>
1295 <a id="breadcrumbOwner" class="breadcrumb-item" href="#">—</a>
1296 <span class="breadcrumb-sep">/</span>
1297 <span id="breadcrumbRepo" class="breadcrumb-current">—</span>
1298 </div>
1299 <div class="topbar-right">
1300 <div class="presence" id="presenceBar"></div>
1301 <div class="actions">
1302 <button class="btn" onclick="copyRoomLink()" title="Copy shareable link">Copy Link</button>
1303 <button class="btn" onclick="exportNotesLLM()" title="Export notes in LLM-readable format">Export</button>
1304 </div>
1305 <a href="/login" id="topbarSignIn" class="sign-in-link" style="color:var(--text-muted);font-size:0.875rem;text-decoration:none">Sign in</a>
1306 <div class="profile-menu" id="topbarProfileMenu" style="display:none">
1307 <button class="profile-pill" id="topbarProfileToggle">
1308 <span id="topbarProfileName"></span>
1309 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4L5 6.5L7.5 4" /></svg>
1310 </button>
1311 <div class="profile-dropdown" id="topbarProfileDropdown">
1312 <a href="/dashboard">Dashboard</a>
1313 <a href="#" id="topbarProfileSignOut">Sign out</a>
1314 </div>
1315 </div>
1316 </div>
1317</div>
1318
1319<!-- Main content -->
1320<div class="main">
1321 <!-- Left drawer -->
1322 <div class="drawer" id="drawer">
1323 <div class="drawer-header">Diagrams</div>
1324 <div class="drawer-list" id="drawerList"></div>
1325 <div class="drawer-footer"><kbd>&#8593;</kbd> <kbd>&#8595;</kbd> to navigate</div>
1326 </div>
1327
1328 <!-- Code pane (left) -->
1329 <div class="code-pane" id="codePane">
1330 <div class="code-pane-header">
1331 <span onclick="toggleCodePane()" style="display:flex;align-items:center;gap:6px;flex:1;cursor:pointer">
1332 <span class="code-pane-toggle" id="codePaneToggle">&#9654;</span>
1333 <span>Definition</span>
1334 </span>
1335 <span class="code-pane-text-size" style="display:none">
1336 <button class="code-pane-fmt-btn" style="display:inline-block;padding:2px 5px" onclick="changeEditorFontSize(-1)" title="Decrease font size">A&minus;</button>
1337 <button class="code-pane-fmt-btn" style="display:inline-block;padding:2px 5px" onclick="changeEditorFontSize(1)" title="Increase font size">A+</button>
1338 </span>
1339 <span class="code-pane-status" id="codePaneStatus"></span>
1340 <button class="code-pane-fmt-btn" onclick="formatMermaid()" title="Format (Shift+Alt+F)">Format</button>
1341 <button class="code-pane-play-btn" id="codePanePlay" onclick="commitDiagramCode()" title="Render diagram (Ctrl+Enter)">&#9654; Run</button>
1342 </div>
1343 <div class="code-pane-editor" id="codePaneEditor"></div>
1344 <div class="code-pane-diagnostics" id="codePaneDiagnostics"></div>
1345 <div class="code-pane-resize" id="codePaneResize"></div>
1346 </div>
1347
1348 <!-- Diagram viewer -->
1349 <div class="diagram-pane" id="diagramPane">
1350 <div class="mermaid-container" id="mermaidContainer"></div>
1351 <div class="bottom-toolbar">
1352 <!-- Zoom controls -->
1353 <button class="zoom-btn" onclick="zoomOut()" title="Zoom out">&minus;</button>
1354 <span class="zoom-level" id="zoomLevel">100%</span>
1355 <button class="zoom-btn" onclick="zoomIn()" title="Zoom in">+</button>
1356 <button class="zoom-btn" onclick="zoomReset()" title="Fit to view">&#8634;</button>
1357 </div>
1358 </div>
1359
1360 <!-- Notes sidebar -->
1361 <div class="notes-sidebar" id="notesSidebar">
1362 <div class="notes-resize" id="notesResize"></div>
1363 <div class="notes-header">
1364 <span id="notesTitle">Notes</span>
1365 <span id="notesCount"></span>
1366 </div>
1367 <div class="notes-list" id="notesList"></div>
1368 <div class="notes-input-area">
1369 <textarea id="noteInput" placeholder="Add a note..." rows="1"></textarea>
1370 <button class="note-send-btn" onclick="submitNote()" title="Send note (Enter)">
1371 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
1372 </button>
1373 </div>
1374 </div>
1375</div>
1376
1377<div class="toast" id="toast"></div>
1378
1379<script>
1380// ── Diagram definitions (loaded from server) ──
1381let DIAGRAM_SECTIONS = [];
1382let DIAGRAMS = [];
1383
1384async function loadDiagramsFromServer(owner, repo) {
1385 try {
1386 const headers = {};
1387 const token = getCookie("grove_hub_token");
1388 if (token) headers["Authorization"] = `Bearer ${token}`;
1389 const res = await fetch(`/api/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/diagrams`, { headers });
1390 if (!res.ok) throw new Error(`HTTP ${res.status}`);
1391 const data = await res.json();
1392 DIAGRAM_SECTIONS = data.sections || [];
1393 DIAGRAMS = data.diagrams || [];
1394 } catch (e) {
1395 console.error("[diagrams] Failed to load from server:", e);
1396 DIAGRAM_SECTIONS = [{ label: "Error" }];
1397 DIAGRAMS = [{
1398 id: "error",
1399 title: "Failed to load diagrams",
1400 section: 0,
1401 code: "graph TD\n A[Error loading diagrams from server]"
1402 }];
1403 }
1404}
1405
1406// Diagram seed data lives in diagrams-default.json on the server.
1407// DIAGRAMS and DIAGRAM_SECTIONS are populated by loadDiagramsFromServer() above.
1408
1409// ── Auth helpers ──
1410function getCookie(name) {
1411 const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
1412 return match ? decodeURIComponent(match[1]) : null;
1413}
1414
1415// ── State ──
1416let socket = null;
1417let currentTab = 0;
1418let myName = "";
1419let roomId = "";
1420let currentOwner = "";
1421let currentRepo = "";
1422let allNotes = {}; // { diagramId: [note, ...] }
1423let allUsers = {};
1424let remoteCursors = {}; // { socketId: DOM element }
1425
1426// Pan/zoom state
1427let panZoom = { x: 0, y: 0, scale: 1 };
1428let isPanning = false;
1429let panStart = { x: 0, y: 0 };
1430let panStartOffset = { x: 0, y: 0 };
1431let didDrag = false; // true if mouse moved during a pan gesture (suppress click)
1432let selectedNodeId = null; // currently focused node for dim effect
1433
1434// ── Init ──
1435mermaid.initialize({
1436 startOnLoad: false,
1437 theme: "base",
1438 themeVariables: {
1439 primaryColor: "#e8f2ee",
1440 primaryTextColor: "#2c2824",
1441 primaryBorderColor: "#4d8a78",
1442 lineColor: "#4d8a78",
1443 secondaryColor: "#f2efe9",
1444 tertiaryColor: "#eae6df",
1445 noteBkgColor: "#e8f2ee",
1446 noteTextColor: "#2d6b56",
1447 noteBorderColor: "#b0d4c5",
1448 textColor: "#2c2824",
1449 mainBkg: "#f2efe9",
1450 nodeBorder: "#d9d3ca",
1451 clusterBkg: "#faf8f5",
1452 clusterBorder: "#d9d3ca",
1453 titleColor: "#2c2824",
1454 edgeLabelBackground: "#faf8f5",
1455 actorBkg: "#f2efe9",
1456 actorBorder: "#4d8a78",
1457 actorTextColor: "#2c2824",
1458 labelColor: "#2c2824",
1459 loopTextColor: "#4a4540",
1460 activationBorderColor: "#4d8a78",
1461 activationBkgColor: "#e8f2ee",
1462 sequenceNumberColor: "#ffffff",
1463 background: "#faf8f5",
1464 fontFamily: "'Libre Caslon Text', Georgia, serif",
1465 }
1466});
1467
1468
1469// ── Drawer ──
1470
1471function buildDrawer() {
1472 const list = document.getElementById("drawerList");
1473 list.innerHTML = "";
1474
1475 let lastSection = -1;
1476
1477 DIAGRAMS.forEach((d, i) => {
1478 // Section header
1479 if (d.section !== lastSection) {
1480 lastSection = d.section;
1481 const sec = document.createElement("div");
1482 sec.className = "drawer-section";
1483 sec.textContent = DIAGRAM_SECTIONS[d.section].label;
1484 list.appendChild(sec);
1485 }
1486
1487 const item = document.createElement("div");
1488 item.className = "drawer-item" + (i === currentTab ? " active" : "");
1489 item.dataset.index = i;
1490 item.onclick = () => switchTab(i);
1491
1492 let html = `<span class="item-label">${escapeHtml(d.title)}</span>`;
1493
1494 const count = (allNotes[d.id] || []).length;
1495 if (count > 0) html += `<span class="note-count">${count}</span>`;
1496
1497 // Show dots for users viewing this tab
1498 const viewers = Object.values(allUsers).filter(u => u.activeTab === d.id && u.id !== socket?.id);
1499 if (viewers.length > 0) {
1500 html += '<span class="viewer-dots">';
1501 viewers.forEach(v => {
1502 html += `<span class="viewer-dot" style="background:${v.color}" title="${escapeHtml(v.name)}"></span>`;
1503 });
1504 html += '</span>';
1505 }
1506
1507 item.innerHTML = html;
1508 list.appendChild(item);
1509 });
1510}
1511
1512async function renderDiagram(index) {
1513 const container = document.getElementById("mermaidContainer");
1514 container.innerHTML = "";
1515 const diagram = DIAGRAMS[index];
1516
1517 const div = document.createElement("div");
1518 div.id = `mermaid-${index}`;
1519 container.appendChild(div);
1520
1521 try {
1522 const { svg } = await mermaid.render(`mermaid-svg-${index}-${Date.now()}`, diagram.code);
1523 div.innerHTML = svg;
1524
1525 // Resize SVG so its natural size fills the pane at 100% zoom
1526 const pane = document.getElementById("diagramPane");
1527 const svgEl = div.querySelector("svg");
1528 if (svgEl) {
1529 const pw = pane.clientWidth - 80; // account for container padding
1530 const ph = pane.clientHeight - 80;
1531 const vb = svgEl.viewBox.baseVal;
1532 const svgW = vb.width || svgEl.width.baseVal.value || parseFloat(svgEl.getAttribute("width"));
1533 const svgH = vb.height || svgEl.height.baseVal.value || parseFloat(svgEl.getAttribute("height"));
1534 if (svgW && svgH) {
1535 const fitScale = Math.min(pw / svgW, ph / svgH);
1536 const newW = svgW * fitScale;
1537 const newH = svgH * fitScale;
1538 svgEl.setAttribute("width", newW);
1539 svgEl.setAttribute("height", newH);
1540 svgEl.style.width = newW + "px";
1541 svgEl.style.height = newH + "px";
1542 }
1543 }
1544 // Wire up node-focus click handlers
1545 wireNodeFocus(div);
1546 } catch (e) {
1547 div.innerHTML = `<pre style="color:#a05050;padding:20px;font-family:'JetBrains Mono',Menlo,monospace;font-size:13px;">Error rendering diagram:\n${e.message}</pre>`;
1548 }
1549
1550}
1551
1552// ── Node Focus (dim unconnected elements on click) ──
1553
1554/**
1555 * Build adjacency and node map directly from the rendered SVG.
1556 * Uses data-start/data-end on edges (from our patched Mermaid) and
1557 * the g.id on node groups — everything uses the same Mermaid internal IDs.
1558 */
1559function buildGraphFromSvg(svgEl) {
1560 const adjacency = {};
1561 const nodeMap = new Map();
1562
1563 const ensure = (id) => { if (!adjacency[id]) adjacency[id] = new Set(); };
1564 const addNode = (id, el) => {
1565 if (!nodeMap.has(id)) nodeMap.set(id, []);
1566 nodeMap.get(id).push(el);
1567 el.classList.add("node-dimable", "node-clickable");
1568 };
1569
1570 // Collect nodes — extract the short ID that edges use in data-start/data-end.
1571 // Flowchart: g.id "flowchart-Customer-0" → edge uses "Customer"
1572 // ER: g.id "entity-policy-3" → edge uses "entity-policy-3"
1573 svgEl.querySelectorAll("g.node").forEach(g => {
1574 if (!g.id) return;
1575 const parts = g.id.split("-");
1576 const short = parts.length >= 3 ? parts.slice(1, -1).join("-") : g.id;
1577 addNode(short, g);
1578 });
1579 svgEl.querySelectorAll("g[id^='entity-'], g.classGroup, g.statediagram-state").forEach(g => {
1580 if (g.id) addNode(g.id, g);
1581 });
1582
1583 // Build adjacency from data-start / data-end on edges
1584 svgEl.querySelectorAll("[data-start][data-end]").forEach(el => {
1585 const s = el.getAttribute("data-start");
1586 const e = el.getAttribute("data-end");
1587 if (s && e) {
1588 ensure(s); ensure(e);
1589 adjacency[s].add(e);
1590 adjacency[e].add(s);
1591 }
1592 el.classList.add("node-dimable");
1593 });
1594
1595 // Tag edge containers and clusters as dimable
1596 svgEl.querySelectorAll(".edgePath, .edgeLabel, .cluster").forEach(el => {
1597 el.classList.add("node-dimable");
1598 });
1599
1600 // Tag loose path/line elements NOT inside a node, existing dimable, or marker defs
1601 svgEl.querySelectorAll("path, line").forEach(el => {
1602 if (el.closest("marker, defs")) return; // skip arrowhead marker definitions
1603 if (!el.closest(".node-dimable") && !el.closest(".node-clickable")) {
1604 el.classList.add("node-dimable");
1605 }
1606 });
1607
1608 // Match edge labels to edge paths using data-id → path ID.
1609 // Edge paths have id="L_Start_End_0" and data-start/data-end.
1610 // Edge labels contain g.label[data-id] where data-id matches path id.
1611 // Copy data-start/data-end onto the edgeLabel group so highlighting works.
1612 const pathDataById = new Map();
1613 svgEl.querySelectorAll("[data-start][data-end]").forEach(el => {
1614 if (el.id) pathDataById.set(el.id, { start: el.getAttribute("data-start"), end: el.getAttribute("data-end") });
1615 });
1616 svgEl.querySelectorAll("g.edgeLabel").forEach(label => {
1617 const inner = label.querySelector("[data-id]");
1618 const dataId = inner?.getAttribute("data-id");
1619 if (dataId && pathDataById.has(dataId)) {
1620 const { start, end } = pathDataById.get(dataId);
1621 label.setAttribute("data-start", start);
1622 label.setAttribute("data-end", end);
1623 }
1624 });
1625
1626 return { adjacency, nodeMap };
1627}
1628
1629function clearNodeFocus() {
1630 selectedNodeId = null;
1631 const pane = document.getElementById("diagramPane");
1632 pane.classList.remove("node-focus");
1633 const svg = pane.querySelector("svg");
1634 if (svg) {
1635 svg.querySelectorAll(".node-highlight").forEach(el => el.classList.remove("node-highlight"));
1636 }
1637}
1638
1639function applyNodeFocus(svgEl, clickedNodeId, adjacency, nodeMap) {
1640 const pane = document.getElementById("diagramPane");
1641
1642 // Connected set = clicked + immediate neighbours
1643 const connected = new Set([clickedNodeId]);
1644 if (adjacency[clickedNodeId]) adjacency[clickedNodeId].forEach(id => connected.add(id));
1645
1646 // Clear old highlights
1647 svgEl.querySelectorAll(".node-highlight").forEach(el => el.classList.remove("node-highlight"));
1648
1649 // Helper: highlight an element and all its node-dimable descendants
1650 // (prevents CSS opacity compounding from dimming children)
1651 const highlight = (el) => {
1652 el.classList.add("node-highlight");
1653 el.querySelectorAll(".node-dimable").forEach(child => child.classList.add("node-highlight"));
1654 };
1655
1656 // Highlight connected nodes
1657 for (const [id, elements] of nodeMap) {
1658 if (connected.has(id)) elements.forEach(highlight);
1659 }
1660
1661 // Highlight all elements with data-start/data-end where both endpoints are connected
1662 svgEl.querySelectorAll("[data-start][data-end]").forEach(el => {
1663 const s = el.getAttribute("data-start");
1664 const e = el.getAttribute("data-end");
1665 if (connected.has(s) && connected.has(e)) highlight(el);
1666 });
1667
1668 pane.classList.add("node-focus");
1669 selectedNodeId = clickedNodeId;
1670}
1671
1672/**
1673 * Attach click handlers to all nodes in the rendered SVG.
1674 */
1675function wireNodeFocus(container) {
1676 const svgEl = container.querySelector("svg");
1677 if (!svgEl) return;
1678
1679 const { adjacency, nodeMap } = buildGraphFromSvg(svgEl);
1680 if (Object.keys(adjacency).length === 0) return;
1681
1682 for (const [nodeId, elements] of nodeMap) {
1683 elements.forEach(el => {
1684 el.addEventListener("click", (e) => {
1685 e.stopPropagation();
1686 if (didDrag) return;
1687 if (selectedNodeId === nodeId) {
1688 clearNodeFocus();
1689 } else {
1690 applyNodeFocus(svgEl, nodeId, adjacency, nodeMap);
1691 }
1692 });
1693 });
1694 }
1695}
1696
1697function renderNotesList(diagramId) {
1698 const list = document.getElementById("notesList");
1699 const notes = allNotes[diagramId] || [];
1700 const countEl = document.getElementById("notesCount");
1701 countEl.textContent = notes.length > 0 ? `(${notes.length})` : "";
1702
1703 if (notes.length === 0) {
1704 list.innerHTML = `<div class="empty-notes">
1705 No notes yet for this diagram.<br>Click on the diagram or type below to add one.
1706 </div>`;
1707 return;
1708 }
1709
1710 list.innerHTML = "";
1711 notes.forEach((note) => {
1712 const card = document.createElement("div");
1713 card.className = "note-card";
1714 card.id = `note-${note.id}`;
1715
1716 const meta = document.createElement("div");
1717 meta.className = "note-meta";
1718 const authorSpan = document.createElement("span");
1719 authorSpan.className = "note-author";
1720 authorSpan.textContent = note.author;
1721 const timeSpan = document.createElement("span");
1722 timeSpan.textContent = formatTime(note.timestamp);
1723 meta.appendChild(authorSpan);
1724 meta.appendChild(timeSpan);
1725
1726 const textEl = document.createElement("div");
1727 textEl.className = "note-text";
1728 textEl.textContent = note.text;
1729
1730 const actions = document.createElement("div");
1731 actions.className = "note-actions";
1732
1733 const editBtn = document.createElement("button");
1734 editBtn.className = "note-action-btn";
1735 editBtn.title = "Edit";
1736 editBtn.innerHTML = "&#9998;";
1737 editBtn.addEventListener("click", () => editNote(note.id, note.diagramId, card));
1738
1739 const deleteBtn = document.createElement("button");
1740 deleteBtn.className = "note-action-btn";
1741 deleteBtn.title = "Delete";
1742 deleteBtn.innerHTML = "&#10005;";
1743 deleteBtn.addEventListener("click", () => deleteNote(note.id, note.diagramId));
1744
1745 actions.appendChild(editBtn);
1746 actions.appendChild(deleteBtn);
1747
1748 card.appendChild(meta);
1749 card.appendChild(textEl);
1750 card.appendChild(actions);
1751 list.appendChild(card);
1752 });
1753}
1754
1755function highlightNoteCard(noteId) {
1756 const card = document.getElementById(`note-${noteId}`);
1757 if (!card) return;
1758 card.scrollIntoView({ behavior: "smooth", block: "center" });
1759 card.style.outline = "2px solid var(--note-border)";
1760 setTimeout(() => card.style.outline = "", 2000);
1761}
1762
1763async function switchTab(index) {
1764 clearNodeFocus();
1765 currentTab = index;
1766 buildDrawer();
1767 await renderDiagram(index);
1768 renderNotesList(DIAGRAMS[index].id);
1769
1770 // Rebuild editor with this diagram's Yjs doc
1771 rebuildEditorForDiagram(DIAGRAMS[index].id, DIAGRAMS[index].code);
1772
1773 setEditorDirty(false);
1774 const status = document.getElementById("codePaneStatus");
1775 if (status) { status.textContent = ""; status.className = "code-pane-status"; }
1776 // Fit diagram to view on tab switch
1777 requestAnimationFrame(() => zoomReset());
1778
1779 // Scroll active drawer item into view
1780 const activeItem = document.querySelector(".drawer-item.active");
1781 if (activeItem) activeItem.scrollIntoView({ behavior: "smooth", block: "nearest" });
1782
1783 // Notify server
1784 if (socket) {
1785 socket.emit("cursor-move", { x: 0, y: 0, activeTab: DIAGRAMS[index].id });
1786 }
1787}
1788
1789// ── Room / Socket ──
1790async function joinRoom(owner, repo, token, user) {
1791 currentOwner = owner;
1792 currentRepo = repo;
1793 myName = user.display_name || user.username || "Anonymous";
1794 roomId = `${owner}/${repo}`;
1795
1796 if (DIAGRAMS.length === 0) {
1797 await loadDiagramsFromServer(owner, repo);
1798 buildDrawer();
1799 }
1800
1801 setBreadcrumbs(owner, repo);
1802
1803 // Connect to the /collab namespace with auth token
1804 socket = io("/collab", { auth: { token } });
1805
1806 socket.on("connect", () => {
1807 socket.emit("join-room", { owner, repo });
1808 });
1809
1810 socket.on("connect_error", (err) => {
1811 if (err.message === "unauthorized") {
1812 console.warn("[collab] Socket auth failed — running in read-only mode");
1813 socket.disconnect();
1814 }
1815 });
1816
1817 socket.on("room-state", (state) => {
1818 allNotes = state.notes || {};
1819 allUsers = state.users || {};
1820 buildDrawer();
1821 switchTab(0);
1822 updatePresence();
1823 });
1824
1825 socket.on("users-updated", (users) => {
1826 allUsers = users;
1827 updatePresence();
1828 buildDrawer();
1829 });
1830
1831 socket.on("cursor-updated", ({ userId, x, y, activeTab, name, color }) => {
1832 // Update user's active tab in allUsers so drawer dots reflect it
1833 if (allUsers[userId]) {
1834 const prev = allUsers[userId].activeTab;
1835 allUsers[userId].activeTab = activeTab;
1836 if (prev !== activeTab) buildDrawer();
1837 }
1838 updateRemoteCursor(userId, x, y, activeTab, name, color);
1839 });
1840
1841 socket.on("cursor-removed", ({ userId }) => {
1842 removeRemoteCursor(userId);
1843 });
1844
1845 // ── Yjs sync over socket.io ──
1846 socket.on("yjs-sync", async ({ diagramId, update }) => {
1847 const Y = window._Y;
1848 if (!Y || !ydocs[diagramId]) return;
1849 const ydoc = ydocs[diagramId];
1850 const buf = Uint8Array.from(atob(update), c => c.charCodeAt(0));
1851 Y.applyUpdate(ydoc, buf, "remote");
1852 // If server had no content, initialize with default code
1853 const ytext = ydoc.getText("code");
1854 if (ytext.length === 0 && ydoc._defaultCode) {
1855 ytext.insert(0, ydoc._defaultCode);
1856 delete ydoc._defaultCode;
1857 }
1858 // Re-render diagram from synced content
1859 const idx = DIAGRAMS.findIndex(d => d.id === diagramId);
1860 if (idx !== -1 && currentTab === idx) {
1861 const code = ytext.toString();
1862 DIAGRAMS[idx].code = code;
1863 await renderDiagram(idx);
1864 requestAnimationFrame(() => zoomReset());
1865 }
1866 });
1867
1868 socket.on("yjs-update", ({ diagramId, update }) => {
1869 const Y = window._Y;
1870 if (!Y || !ydocs[diagramId]) return;
1871 const ydoc = ydocs[diagramId];
1872 const buf = Uint8Array.from(atob(update), c => c.charCodeAt(0));
1873 Y.applyUpdate(ydoc, buf, "remote");
1874 });
1875
1876 // Remote user hit Run — re-render the diagram on our side
1877 socket.on("diagram-code", async ({ diagramId, code }) => {
1878 const idx = DIAGRAMS.findIndex(d => d.id === diagramId);
1879 if (idx === -1) return;
1880 DIAGRAMS[idx].code = code;
1881 if (currentTab === idx) {
1882 await renderDiagram(idx);
1883 requestAnimationFrame(() => zoomReset());
1884 setEditorDirty(false);
1885 const status = document.getElementById("codePaneStatus");
1886 status.textContent = "\u2713 synced";
1887 status.className = "code-pane-status ok";
1888 }
1889 });
1890
1891 socket.on("note-added", (note) => {
1892 if (!allNotes[note.diagramId]) allNotes[note.diagramId] = [];
1893 allNotes[note.diagramId].push(note);
1894 if (DIAGRAMS[currentTab].id === note.diagramId) {
1895 renderNotesList(note.diagramId);
1896 }
1897 buildDrawer();
1898 });
1899
1900 socket.on("note-edited", ({ noteId, diagramId, text, editedAt }) => {
1901 const notes = allNotes[diagramId] || [];
1902 const note = notes.find(n => n.id === noteId);
1903 if (note) {
1904 note.text = text;
1905 note.editedAt = editedAt;
1906 }
1907 if (DIAGRAMS[currentTab].id === diagramId) {
1908 renderNotesList(diagramId);
1909 }
1910 });
1911
1912 socket.on("note-deleted", ({ noteId, diagramId }) => {
1913 if (allNotes[diagramId]) {
1914 allNotes[diagramId] = allNotes[diagramId].filter(n => n.id !== noteId);
1915 }
1916 if (DIAGRAMS[currentTab].id === diagramId) {
1917 renderNotesList(diagramId);
1918 }
1919 buildDrawer();
1920 });
1921}
1922
1923// ── Keyboard navigation ──
1924document.addEventListener("keydown", (e) => {
1925 // Ctrl+Enter or Cmd+Enter = commit and render diagram
1926 if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
1927 e.preventDefault();
1928 commitDiagramCode();
1929 return;
1930 }
1931
1932 // Shift+Alt+F = format (works even inside the editor)
1933 if (e.key === "F" && e.shiftKey && e.altKey) {
1934 e.preventDefault();
1935 formatMermaid();
1936 return;
1937 }
1938
1939 // Don't intercept when typing in inputs
1940 if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.closest(".cm-editor")) return;
1941
1942 if (e.key === "ArrowDown" || e.key === "j") {
1943 e.preventDefault();
1944 if (currentTab < DIAGRAMS.length - 1) switchTab(currentTab + 1);
1945 } else if (e.key === "ArrowUp" || e.key === "k") {
1946 e.preventDefault();
1947 if (currentTab > 0) switchTab(currentTab - 1);
1948 }
1949});
1950
1951// ── Cursor tracking ──
1952document.getElementById("diagramPane").addEventListener("mousemove", (e) => {
1953 if (!socket) return;
1954 const pane = document.getElementById("diagramPane");
1955 const rect = pane.getBoundingClientRect();
1956 // Send content-space coords so they're zoom-independent
1957 socket.emit("cursor-move", {
1958 x: (e.clientX - rect.left - panZoom.x) / panZoom.scale,
1959 y: (e.clientY - rect.top - panZoom.y) / panZoom.scale,
1960 activeTab: DIAGRAMS[currentTab].id,
1961 });
1962});
1963
1964function updateRemoteCursor(userId, x, y, activeTab, name, color) {
1965 if (activeTab !== DIAGRAMS[currentTab].id) {
1966 removeRemoteCursor(userId);
1967 return;
1968 }
1969
1970 let el = remoteCursors[userId];
1971 if (!el) {
1972 el = document.createElement("div");
1973 el.className = "remote-cursor";
1974 el.innerHTML = `
1975 <span class="cursor-dot" style="background:${color}"></span>
1976 <span class="cursor-label" style="background:${color}">${escapeHtml(name)}</span>
1977 `;
1978 document.body.appendChild(el);
1979 remoteCursors[userId] = el;
1980 }
1981
1982 // Briefly show the name label on movement
1983 el.classList.add("recent");
1984 clearTimeout(el._labelTimer);
1985 el._labelTimer = setTimeout(() => el.classList.remove("recent"), 2000);
1986
1987 // Store content-space coords so we can reposition on zoom/pan
1988 el._contentX = x;
1989 el._contentY = y;
1990
1991 positionCursorElement(el);
1992}
1993
1994function positionCursorElement(el) {
1995 const pane = document.getElementById("diagramPane");
1996 const rect = pane.getBoundingClientRect();
1997 const screenX = rect.left + panZoom.x + el._contentX * panZoom.scale;
1998 const screenY = rect.top + panZoom.y + el._contentY * panZoom.scale;
1999
2000 if (screenX < rect.left || screenX > rect.right || screenY < rect.top || screenY > rect.bottom) {
2001 el.style.display = "none";
2002 } else {
2003 el.style.display = "flex";
2004 el.style.left = screenX - 5 + "px";
2005 el.style.top = screenY - 5 + "px";
2006 }
2007}
2008
2009function repositionRemoteCursors() {
2010 for (const el of Object.values(remoteCursors)) {
2011 if (el._contentX != null) positionCursorElement(el);
2012 }
2013}
2014
2015function removeRemoteCursor(userId) {
2016 if (remoteCursors[userId]) {
2017 remoteCursors[userId].remove();
2018 delete remoteCursors[userId];
2019 }
2020}
2021
2022// ── Presence bar ──
2023function updatePresence() {
2024 const bar = document.getElementById("presenceBar");
2025 bar.innerHTML = "";
2026 Object.values(allUsers).forEach(u => {
2027 const dot = document.createElement("div");
2028 dot.className = "presence-dot";
2029 dot.style.background = u.color;
2030 dot.textContent = (u.name || "?")[0].toUpperCase();
2031 dot.innerHTML += `<span class="tooltip">${escapeHtml(u.name)}</span>`;
2032 bar.appendChild(dot);
2033 });
2034}
2035
2036// ── Notes ──
2037// ── Code pane (CodeMirror 6) ──
2038let codeRenderTimeout = null;
2039let cmEditor = null;
2040let cmReady = false;
2041let editorFontSize = 12;
2042
2043function changeEditorFontSize(delta) {
2044 editorFontSize = Math.max(8, Math.min(24, editorFontSize + delta));
2045 if (cmEditor) {
2046 cmEditor.dom.style.fontSize = editorFontSize + "px";
2047 cmEditor.requestMeasure();
2048 }
2049}
2050
2051function toggleCodePane() {
2052 const pane = document.getElementById("codePane");
2053 pane.classList.toggle("open");
2054 // Clear any inline width from manual resize so CSS takes over
2055 pane.style.width = "";
2056 if (pane.classList.contains("open")) {
2057 setTimeout(() => zoomReset(), 220);
2058 }
2059}
2060
2061// ── Yjs document management ──
2062const ydocs = {}; // { diagramId: Y.Doc }
2063let currentYtext = null;
2064let currentUndoManager = null;
2065
2066function uint8ToBase64(uint8) {
2067 let binary = "";
2068 for (let i = 0; i < uint8.length; i++) binary += String.fromCharCode(uint8[i]);
2069 return btoa(binary);
2070}
2071
2072function getOrCreateYDoc(diagramId) {
2073 const Y = window._Y;
2074 if (!Y) return null;
2075 if (!ydocs[diagramId]) {
2076 const ydoc = new Y.Doc();
2077 ydocs[diagramId] = ydoc;
2078 // When Yjs text changes locally, send update to server
2079 ydoc.on("update", (update, origin) => {
2080 if (origin === "remote") return;
2081 if (socket) {
2082 socket.emit("yjs-update", { diagramId, update: uint8ToBase64(update) });
2083 }
2084 });
2085 }
2086 return ydocs[diagramId];
2087}
2088
2089function syncYDoc(diagramId, defaultCode) {
2090 if (!socket) return;
2091 // Store default code so we can initialize if server has no state
2092 if (defaultCode) ydocs[diagramId]._defaultCode = defaultCode;
2093 socket.emit("yjs-sync", { diagramId });
2094}
2095
2096let _suppressEditorDirty = false;
2097
2098function updateCodePane(code) {
2099 if (cmEditor) {
2100 const cur = cmEditor.state.doc.toString();
2101 if (cur !== code) {
2102 _suppressEditorDirty = true;
2103 cmEditor.dispatch({
2104 changes: { from: 0, to: cur.length, insert: code }
2105 });
2106 _suppressEditorDirty = false;
2107 }
2108 }
2109}
2110
2111function rebuildEditorForDiagram(diagramId, defaultCode) {
2112 if (!window._cmCreateEditor) return;
2113 const container = document.getElementById("codePaneEditor");
2114
2115 // Destroy previous editor
2116 if (cmEditor) {
2117 cmEditor.destroy();
2118 cmEditor = null;
2119 }
2120
2121 const Y = window._Y;
2122 if (Y && socket) {
2123 // Collaborative mode: use Yjs
2124 const ydoc = getOrCreateYDoc(diagramId);
2125 const ytext = ydoc.getText("code");
2126 currentYtext = ytext;
2127 currentUndoManager = new Y.UndoManager(ytext);
2128
2129 cmEditor = window._cmCreateEditor(container, "", (code) => {
2130 if (_suppressEditorDirty) return;
2131 setEditorDirty(true);
2132 clearTimeout(codeRenderTimeout);
2133 codeRenderTimeout = setTimeout(() => validateMermaidCode(code), 500);
2134 }, ytext, currentUndoManager);
2135
2136 // Request latest state from server; pass default code for first-time init
2137 syncYDoc(diagramId, defaultCode);
2138 } else {
2139 // Standalone mode: no Yjs
2140 currentYtext = null;
2141 currentUndoManager = null;
2142 cmEditor = window._cmCreateEditor(container, defaultCode || "", (code) => {
2143 if (_suppressEditorDirty) return;
2144 setEditorDirty(true);
2145 clearTimeout(codeRenderTimeout);
2146 codeRenderTimeout = setTimeout(() => validateMermaidCode(code), 500);
2147 });
2148 }
2149 cmReady = true;
2150}
2151
2152// ── Mermaid auto-formatter ──
2153function formatMermaid() {
2154 if (!cmEditor) return;
2155 const src = cmEditor.state.doc.toString();
2156 const formatted = formatMermaidCode(src);
2157 if (formatted !== src) {
2158 cmEditor.dispatch({
2159 changes: { from: 0, to: src.length, insert: formatted }
2160 });
2161 }
2162}
2163
2164function formatMermaidCode(src) {
2165 const lines = src.split("\n");
2166 const out = [];
2167 const INDENT = " "; // 4 spaces
2168 let depth = 0;
2169
2170 // Keywords that open a block (increase indent after this line)
2171 const openers = /^(subgraph|state|loop|alt|par|critical|opt)\b/;
2172 // Keywords that close a block (decrease indent before this line)
2173 const closers = /^end\b/;
2174 // "else" is at same level as the opener, then content indented again
2175 const midBlock = /^(else|break)\b/;
2176 // Brace openers for ER/class: "entity_name {" or "class Foo {"
2177 const braceOpen = /\{\s*$/;
2178 const braceClose = /^\s*\}/;
2179 // Diagram type declarations stay at column 0
2180 const diagramType = /^(graph|flowchart|erDiagram|classDiagram|stateDiagram-v2|stateDiagram|sequenceDiagram|gantt|pie|gitGraph|mindmap|timeline|journey|quadrantChart|sankey|xychart)\b/;
2181
2182 for (let i = 0; i < lines.length; i++) {
2183 const trimmed = lines[i].trim();
2184
2185 // Blank lines pass through
2186 if (trimmed === "") { out.push(""); continue; }
2187
2188 // Comments stay at current indent level
2189 if (trimmed.startsWith("%%")) {
2190 out.push(INDENT.repeat(depth) + trimmed);
2191 continue;
2192 }
2193
2194 // Diagram type at column 0, reset depth
2195 if (diagramType.test(trimmed)) {
2196 depth = 0;
2197 out.push(trimmed);
2198 continue;
2199 }
2200
2201 // Brace close: dedent before
2202 if (braceClose.test(trimmed)) {
2203 depth = Math.max(0, depth - 1);
2204 out.push(INDENT.repeat(depth) + trimmed);
2205 continue;
2206 }
2207
2208 // "end" keyword: dedent before
2209 if (closers.test(trimmed)) {
2210 depth = Math.max(0, depth - 1);
2211 out.push(INDENT.repeat(depth) + trimmed);
2212 continue;
2213 }
2214
2215 // Mid-block keywords (else): dedent for this line, re-indent after
2216 if (midBlock.test(trimmed)) {
2217 out.push(INDENT.repeat(Math.max(0, depth - 1)) + trimmed);
2218 continue;
2219 }
2220
2221 // Normal line at current depth
2222 out.push(INDENT.repeat(depth) + trimmed);
2223
2224 // After outputting, check if this line opens a block
2225 if (openers.test(trimmed) || braceOpen.test(trimmed)) {
2226 depth++;
2227 }
2228 }
2229
2230 return out.join("\n");
2231}
2232
2233let editorDirty = false;
2234
2235function setEditorDirty(dirty) {
2236 editorDirty = dirty;
2237 const btn = document.getElementById("codePanePlay");
2238 if (btn) btn.classList.toggle("dirty", dirty);
2239}
2240
2241async function validateMermaidCode(code) {
2242 const status = document.getElementById("codePaneStatus");
2243 const diag = document.getElementById("codePaneDiagnostics");
2244 try {
2245 await mermaid.parse(code);
2246 status.textContent = "\u2713 valid";
2247 status.className = "code-pane-status ok";
2248 diag.className = "code-pane-diagnostics";
2249 diag.textContent = "";
2250 return true;
2251 } catch (e) {
2252 const raw = e.message || String(e);
2253 status.textContent = "\u2717 error";
2254 status.className = "code-pane-status error";
2255
2256 // Extract line/col from mermaid error (formats vary)
2257 let line = null, col = null;
2258 const lineMatch = raw.match(/line\s*(\d+)/i) || raw.match(/at line (\d+)/i);
2259 const colMatch = raw.match(/col(?:umn)?\s*(\d+)/i);
2260 if (lineMatch) line = parseInt(lineMatch[1]);
2261 if (colMatch) col = parseInt(colMatch[1]);
2262
2263 // Also check e.hash (jison parser data)
2264 if (!line && e.hash) {
2265 if (e.hash.line != null) line = e.hash.line + 1;
2266 if (e.hash.loc) {
2267 line = line || (e.hash.loc.first_line);
2268 col = col || (e.hash.loc.first_column + 1);
2269 }
2270 }
2271
2272 // Build diagnostics message
2273 let msg = "";
2274 if (line) {
2275 msg += "Line " + line;
2276 if (col) msg += ":" + col;
2277 msg += " \u2014 ";
2278 }
2279 // Clean up the error message
2280 let cleaned = raw.replace(/^Error:\s*/i, "").replace(/Syntax error in.*?:\s*/i, "");
2281 // Take the first meaningful line
2282 const lines = cleaned.split("\n").filter(l => l.trim());
2283 msg += lines[0] || cleaned;
2284
2285 // If we can, show the offending source line
2286 if (line && code) {
2287 const srcLines = code.split("\n");
2288 const srcLine = srcLines[line - 1];
2289 if (srcLine != null) {
2290 msg += "\n\n " + line + " \u2502 " + srcLine;
2291 if (col) {
2292 msg += "\n " + " ".repeat(String(line).length) + " ".repeat(col) + "\u2191";
2293 }
2294 }
2295 }
2296
2297 diag.textContent = msg;
2298 diag.className = "code-pane-diagnostics visible";
2299
2300 // Jump editor cursor to error line
2301 if (line && cmEditor) {
2302 try {
2303 const pos = cmEditor.state.doc.line(line);
2304 cmEditor.dispatch({
2305 selection: { anchor: pos.from + (col ? col - 1 : 0) },
2306 scrollIntoView: true
2307 });
2308 } catch (_) {}
2309 }
2310
2311 return false;
2312 }
2313}
2314
2315async function commitDiagramCode() {
2316 if (!cmEditor) return;
2317 const code = cmEditor.state.doc.toString();
2318 const valid = await validateMermaidCode(code);
2319 if (!valid) return;
2320 DIAGRAMS[currentTab].code = code;
2321 await renderDiagram(currentTab);
2322 requestAnimationFrame(() => zoomReset());
2323 setEditorDirty(false);
2324 // Code is already synced via Yjs — also broadcast render trigger to others
2325 if (socket) {
2326 socket.emit("diagram-code", { diagramId: DIAGRAMS[currentTab].id, code });
2327 }
2328}
2329
2330function initCodeMirror() {
2331 if (cmEditor || !window._cmCreateEditor) return;
2332 rebuildEditorForDiagram(DIAGRAMS[currentTab]?.id, DIAGRAMS[currentTab]?.code || "");
2333}
2334
2335// Init CM when the ESM module finishes loading
2336if (window._cmCreateEditor) {
2337 initCodeMirror();
2338} else {
2339 window.addEventListener("cm-ready", () => initCodeMirror());
2340}
2341
2342// Resize handle — code pane
2343(function() {
2344 const handle = document.getElementById("codePaneResize");
2345 const pane = document.getElementById("codePane");
2346 let startX, startWidth;
2347
2348 handle.addEventListener("mousedown", (e) => {
2349 e.preventDefault();
2350 startX = e.clientX;
2351 startWidth = pane.offsetWidth;
2352 handle.classList.add("dragging");
2353 document.addEventListener("mousemove", onDrag);
2354 document.addEventListener("mouseup", onStop);
2355 });
2356
2357 handle.addEventListener("dblclick", () => {
2358 pane.style.width = "";
2359 setTimeout(() => zoomReset(), 50);
2360 });
2361
2362 function onDrag(e) {
2363 const newWidth = Math.max(200, startWidth + (e.clientX - startX));
2364 pane.style.width = newWidth + "px";
2365 }
2366
2367 function onStop() {
2368 handle.classList.remove("dragging");
2369 document.removeEventListener("mousemove", onDrag);
2370 document.removeEventListener("mouseup", onStop);
2371 setTimeout(() => zoomReset(), 50);
2372 }
2373})();
2374
2375// Resize handle — notes sidebar
2376(function() {
2377 const handle = document.getElementById("notesResize");
2378 const pane = document.getElementById("notesSidebar");
2379 let startX, startWidth;
2380
2381 handle.addEventListener("mousedown", (e) => {
2382 e.preventDefault();
2383 startX = e.clientX;
2384 startWidth = pane.offsetWidth;
2385 handle.classList.add("dragging");
2386 document.addEventListener("mousemove", onDrag);
2387 document.addEventListener("mouseup", onStop);
2388 });
2389
2390 handle.addEventListener("dblclick", () => {
2391 pane.style.width = "";
2392 setTimeout(() => zoomReset(), 50);
2393 });
2394
2395 function onDrag(e) {
2396 const newWidth = Math.max(180, startWidth - (e.clientX - startX));
2397 pane.style.width = newWidth + "px";
2398 }
2399
2400 function onStop() {
2401 handle.classList.remove("dragging");
2402 document.removeEventListener("mousemove", onDrag);
2403 document.removeEventListener("mouseup", onStop);
2404 setTimeout(() => zoomReset(), 50);
2405 }
2406})();
2407
2408function submitNote() {
2409 const input = document.getElementById("noteInput");
2410 const text = input.value.trim();
2411 if (!text || !socket) return;
2412
2413 const diagram = DIAGRAMS[currentTab];
2414
2415 socket.emit("add-note", {
2416 text,
2417 diagramId: diagram.id,
2418 diagramTitle: diagram.title,
2419 });
2420
2421 input.value = "";
2422}
2423
2424const noteInputEl = document.getElementById("noteInput");
2425noteInputEl.addEventListener("keydown", (e) => {
2426 if (e.key === "Enter" && !e.shiftKey) {
2427 e.preventDefault();
2428 submitNote();
2429 }
2430});
2431noteInputEl.addEventListener("input", () => {
2432 noteInputEl.style.height = "auto";
2433 noteInputEl.style.height = Math.min(noteInputEl.scrollHeight, 80) + "px";
2434});
2435
2436function editNote(noteId, diagramId, card) {
2437 const notes = allNotes[diagramId] || [];
2438 const note = notes.find(n => n.id === noteId);
2439 if (!note || !card) return;
2440
2441 const textEl = card.querySelector(".note-text");
2442 if (!textEl || textEl.dataset.editing) return;
2443 textEl.dataset.editing = "true";
2444
2445 const original = note.text;
2446 const input = document.createElement("textarea");
2447 input.value = original;
2448 input.rows = 2;
2449 input.style.cssText = "width:100%;background:var(--bg-inset);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);padding:4px 8px;font-size:12px;font-family:'Libre Caslon Text',Georgia,serif;resize:none;outline:none;line-height:1.4;";
2450 input.style.height = "auto";
2451
2452 textEl.textContent = "";
2453 textEl.appendChild(input);
2454 input.focus();
2455 input.style.height = Math.min(input.scrollHeight, 120) + "px";
2456
2457 function commit() {
2458 const newText = input.value.trim();
2459 delete textEl.dataset.editing;
2460 if (newText && newText !== original) {
2461 socket.emit("edit-note", { noteId, diagramId, text: newText });
2462 } else {
2463 textEl.textContent = original;
2464 }
2465 }
2466
2467 input.addEventListener("keydown", (e) => {
2468 if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commit(); }
2469 if (e.key === "Escape") { delete textEl.dataset.editing; textEl.textContent = original; }
2470 });
2471 input.addEventListener("blur", commit);
2472}
2473
2474function deleteNote(noteId, diagramId) {
2475 if (!confirm("Delete this note?")) return;
2476 socket.emit("delete-note", { noteId, diagramId });
2477}
2478
2479// ── Actions ──
2480function copyRoomLink() {
2481 const url = `${window.location.origin}/${encodeURIComponent(currentOwner)}/${encodeURIComponent(currentRepo)}`;
2482 navigator.clipboard.writeText(url).then(() => showToast("Link copied!"));
2483}
2484
2485async function exportNotesLLM() {
2486 try {
2487 const headers = {};
2488 const token = getCookie("grove_hub_token");
2489 if (token) headers["Authorization"] = `Bearer ${token}`;
2490 const resp = await fetch(`/api/repos/${encodeURIComponent(currentOwner)}/${encodeURIComponent(currentRepo)}/notes/llm`, { headers });
2491 const blob = await resp.blob();
2492 const cd = resp.headers.get("Content-Disposition") || "";
2493 const match = cd.match(/filename="(.+?)"/);
2494 const filename = match ? match[1] : `collab-notes-${currentOwner}-${currentRepo}.md`;
2495
2496 const url = URL.createObjectURL(blob);
2497 const a = document.createElement("a");
2498 a.href = url;
2499 a.download = filename;
2500 document.body.appendChild(a);
2501 a.click();
2502 a.remove();
2503 URL.revokeObjectURL(url);
2504 showToast("Notes exported!");
2505 } catch (e) {
2506 showToast("Export failed");
2507 }
2508}
2509
2510function showToast(msg) {
2511 const t = document.getElementById("toast");
2512 t.textContent = msg;
2513 t.classList.add("show");
2514 setTimeout(() => t.classList.remove("show"), 2500);
2515}
2516
2517// ── Helpers ──
2518function setBreadcrumbs(owner, repo) {
2519 const ownerEl = document.getElementById("breadcrumbOwner");
2520 const repoEl = document.getElementById("breadcrumbRepo");
2521 if (ownerEl) {
2522 ownerEl.textContent = owner;
2523 ownerEl.href = `/${encodeURIComponent(owner)}`;
2524 }
2525 if (repoEl) repoEl.textContent = repo;
2526}
2527
2528function setupTopbarProfile(user) {
2529 const menu = document.getElementById("topbarProfileMenu");
2530 const toggle = document.getElementById("topbarProfileToggle");
2531 const dropdown = document.getElementById("topbarProfileDropdown");
2532 const nameEl = document.getElementById("topbarProfileName");
2533 const signInLink = document.getElementById("topbarSignIn");
2534 if (!menu || !toggle || !user) return;
2535 if (nameEl) nameEl.textContent = user.username;
2536 if (signInLink) signInLink.style.display = "none";
2537 menu.style.display = "";
2538 toggle.addEventListener("click", () => dropdown.classList.toggle("open"));
2539 document.addEventListener("mousedown", (e) => {
2540 if (!menu.contains(e.target)) dropdown.classList.remove("open");
2541 });
2542 document.getElementById("topbarProfileSignOut").addEventListener("click", (e) => {
2543 e.preventDefault();
2544 document.cookie = "grove_hub_token=; path=/; max-age=0";
2545 document.cookie = "grove_hub_user=; path=/; max-age=0";
2546 const hostname = window.location.hostname;
2547 const parts = hostname.split(".");
2548 const domain = parts.length > 2 ? "." + parts.slice(-2).join(".") : "." + hostname;
2549 document.cookie = "grove_hub_token=; path=/; domain=" + domain + "; max-age=0";
2550 document.cookie = "grove_hub_user=; path=/; domain=" + domain + "; max-age=0";
2551 localStorage.removeItem("grove_hub_token");
2552 localStorage.removeItem("grove_hub_user");
2553 window.location.href = "/";
2554 });
2555}
2556
2557function escapeHtml(s) {
2558 const d = document.createElement("div");
2559 d.textContent = s;
2560 return d.innerHTML;
2561}
2562
2563function formatTime(iso) {
2564 try {
2565 const d = new Date(iso);
2566 return d.toLocaleString("en-CA", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
2567 } catch { return iso; }
2568}
2569
2570// ── Pan / Zoom ──
2571function applyTransform() {
2572 const container = document.getElementById("mermaidContainer");
2573 container.style.transform = `translate(${panZoom.x}px, ${panZoom.y}px) scale(${panZoom.scale})`;
2574 document.getElementById("zoomLevel").textContent = Math.round(panZoom.scale * 100) + "%";
2575 repositionRemoteCursors();
2576}
2577
2578function zoomIn() {
2579 panZoom.scale = Math.min(panZoom.scale * 1.25, 10);
2580 applyTransform();
2581}
2582
2583function zoomOut() {
2584 panZoom.scale = Math.max(panZoom.scale / 1.25, 0.1);
2585 applyTransform();
2586}
2587
2588function zoomReset() {
2589 const pane = document.getElementById("diagramPane");
2590 const container = document.getElementById("mermaidContainer");
2591 // Reset to measure natural size
2592 container.style.transform = "none";
2593 const cw = container.scrollWidth;
2594 const ch = container.scrollHeight;
2595 const pw = pane.clientWidth;
2596 const ph = pane.clientHeight;
2597 // Start at 100% (natural size), centered in the pane
2598 panZoom.scale = 1;
2599 panZoom.x = (pw - cw) / 2;
2600 panZoom.y = (ph - ch) / 2;
2601 applyTransform();
2602}
2603
2604// Wheel zoom (pinch-to-zoom on trackpad)
2605document.getElementById("diagramPane").addEventListener("wheel", (e) => {
2606 e.preventDefault();
2607 const pane = document.getElementById("diagramPane");
2608 const rect = pane.getBoundingClientRect();
2609
2610 // Mouse position relative to pane
2611 const mx = e.clientX - rect.left;
2612 const my = e.clientY - rect.top;
2613
2614 // Point in content space before zoom
2615 const contentX = (mx - panZoom.x) / panZoom.scale;
2616 const contentY = (my - panZoom.y) / panZoom.scale;
2617
2618 // Determine zoom delta
2619 const delta = e.deltaY > 0 ? 0.9 : 1.1;
2620 panZoom.scale = Math.min(Math.max(panZoom.scale * delta, 0.1), 10);
2621
2622 // Adjust pan so the point under the cursor stays fixed
2623 panZoom.x = mx - contentX * panZoom.scale;
2624 panZoom.y = my - contentY * panZoom.scale;
2625
2626 applyTransform();
2627}, { passive: false });
2628
2629// Mouse drag to pan
2630document.getElementById("diagramPane").addEventListener("mousedown", (e) => {
2631 const onInteractive = e.target.closest(".bottom-toolbar, .node-clickable");
2632 // Middle-click always pans; left-click pans only outside interactive elements
2633 if (e.button === 1 || (e.button === 0 && !onInteractive)) {
2634 isPanning = true;
2635 didDrag = false;
2636 panStart = { x: e.clientX, y: e.clientY };
2637 panStartOffset = { x: panZoom.x, y: panZoom.y };
2638 document.getElementById("diagramPane").classList.add("panning");
2639 e.preventDefault();
2640 }
2641});
2642
2643window.addEventListener("mousemove", (e) => {
2644 if (!isPanning) return;
2645 const dx = e.clientX - panStart.x;
2646 const dy = e.clientY - panStart.y;
2647 if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didDrag = true;
2648 panZoom.x = panStartOffset.x + dx;
2649 panZoom.y = panStartOffset.y + dy;
2650 applyTransform();
2651});
2652
2653window.addEventListener("mouseup", (e) => {
2654 if (isPanning) {
2655 isPanning = false;
2656 document.getElementById("diagramPane").classList.remove("panning");
2657 }
2658});
2659
2660// Click on diagram background clears node focus
2661// (node clicks call stopPropagation, so only background clicks reach here)
2662document.getElementById("diagramPane").addEventListener("click", (e) => {
2663 if (didDrag || !selectedNodeId) return;
2664 const onToolbar = e.target.closest(".bottom-toolbar");
2665 if (!onToolbar) clearNodeFocus();
2666});
2667
2668// ── Homepage ──
2669function showHomepage(token, user) {
2670 // Hide the collab UI, show the homepage
2671 document.body.innerHTML = "";
2672 document.body.style.overflow = "auto";
2673 document.body.style.height = "auto";
2674
2675 // Nav bar (matches Grove)
2676 const nav = document.createElement("nav");
2677 nav.className = "homepage-nav";
2678 nav.innerHTML = `
2679 <a href="/" class="nav-left" style="text-decoration:none">
2680 <svg width="28" height="28" viewBox="0 0 64 64" fill="none"><circle cx="32" cy="32" r="32" fill="var(--accent)"/><path d="M9 16 C9 11, 14 8, 21 8 L35 8 C42 8, 47 11, 47 16 L47 24 C47 29, 42 32, 35 32 L19 32 L13 37 L13 32 C10 31, 9 29, 9 24 Z" fill="white"/><path d="M55 35 C55 30, 50 27, 43 27 L29 27 C22 27, 17 30, 17 35 L17 43 C17 48, 22 51, 29 51 L45 51 L51 56 L51 51 C54 50, 55 48, 55 43 Z" fill="white" opacity="0.65"/></svg>
2681 </a>
2682 <div class="nav-right">
2683 ${user ? `
2684 <div class="profile-menu">
2685 <button class="profile-pill" id="profileToggle">
2686 ${escapeHtml(user.username)}
2687 <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4L5 6.5L7.5 4" /></svg>
2688 </button>
2689 <div class="profile-dropdown" id="profileDropdown">
2690 <a href="/dashboard">Dashboard</a>
2691 <a href="#" id="profileSignOut">Sign out</a>
2692 </div>
2693 </div>
2694 ` : `<a href="/login">Sign in</a>`}
2695 </div>
2696 `;
2697 document.body.appendChild(nav);
2698
2699 // Profile dropdown toggle
2700 const profileToggle = document.getElementById("profileToggle");
2701 const profileDropdown = document.getElementById("profileDropdown");
2702 if (profileToggle && profileDropdown) {
2703 profileToggle.addEventListener("click", () => {
2704 profileDropdown.classList.toggle("open");
2705 });
2706 document.addEventListener("mousedown", (e) => {
2707 if (!profileToggle.contains(e.target) && !profileDropdown.contains(e.target)) {
2708 profileDropdown.classList.remove("open");
2709 }
2710 });
2711 document.getElementById("profileSignOut").addEventListener("click", (e) => {
2712 e.preventDefault();
2713 document.cookie = "grove_hub_token=; path=/; max-age=0";
2714 document.cookie = "grove_hub_user=; path=/; max-age=0";
2715 const hostname = window.location.hostname;
2716 const parts = hostname.split(".");
2717 const domain = parts.length > 2 ? "." + parts.slice(-2).join(".") : "." + hostname;
2718 document.cookie = "grove_hub_token=; path=/; domain=" + domain + "; max-age=0";
2719 document.cookie = "grove_hub_user=; path=/; domain=" + domain + "; max-age=0";
2720 localStorage.removeItem("grove_hub_token");
2721 localStorage.removeItem("grove_hub_user");
2722 window.location.href = "/";
2723 });
2724 }
2725
2726 const root = document.createElement("div");
2727 root.className = "homepage";
2728
2729 root.innerHTML = `
2730 <div class="homepage-search">
2731 <input id="homepageSearch" type="text" placeholder="Search repositories" />
2732 </div>
2733 <div id="homepageContent">
2734 <div class="homepage-loading">
2735 ${[0,1,2,3].map(i => `<div class="skel-row"><div class="skel" style="width:${[240,200,260,220][i]}px;margin-bottom:4px"></div><div class="skel" style="width:${[180,160,200,140][i]}px;height:0.7rem"></div></div>`).join("")}
2736 </div>
2737 </div>
2738 `;
2739 document.body.appendChild(root);
2740
2741 let allRepos = [];
2742
2743 function timeAgo(ts) {
2744 const secs = Math.floor(Date.now() / 1000) - ts;
2745 if (secs < 60) return "just now";
2746 if (secs < 3600) return Math.floor(secs / 60) + "m ago";
2747 if (secs < 86400) return Math.floor(secs / 3600) + "h ago";
2748 if (secs < 2592000) return Math.floor(secs / 86400) + "d ago";
2749 return Math.floor(secs / 2592000) + "mo ago";
2750 }
2751
2752 function renderRepos(repos) {
2753 const container = document.getElementById("homepageContent");
2754 if (repos.length === 0) {
2755 container.innerHTML = `
2756 <div class="homepage-empty">
2757 <div class="logo-wrap">
2758 <svg width="48" height="48" viewBox="0 0 64 64" fill="none"><circle cx="32" cy="32" r="32" fill="var(--accent)"/><path d="M9 16 C9 11, 14 8, 21 8 L35 8 C42 8, 47 11, 47 16 L47 24 C47 29, 42 32, 35 32 L19 32 L13 37 L13 32 C10 31, 9 29, 9 24 Z" fill="white"/><path d="M55 35 C55 30, 50 27, 43 27 L29 27 C22 27, 17 30, 17 35 L17 43 C17 48, 22 51, 29 51 L45 51 L51 56 L51 51 C54 50, 55 48, 55 43 Z" fill="white" opacity="0.65"/></svg>
2759 </div>
2760 <p>No repositories found.</p>
2761 </div>
2762 `;
2763 return;
2764 }
2765
2766 // Group by owner
2767 const groups = [];
2768 const map = new Map();
2769 for (const r of repos) {
2770 const owner = r.owner_name;
2771 if (!map.has(owner)) { map.set(owner, []); groups.push({ owner, repos: [] }); }
2772 groups.find(g => g.owner === owner).repos.push(r);
2773 }
2774
2775 container.innerHTML = groups.map(g => `
2776 <div class="homepage-group">
2777 <span class="homepage-owner">${escapeHtml(g.owner)}</span>
2778 ${g.repos.map(r => {
2779 const commitTs = r.last_commit_ts ?? null;
2780 const updatedTs = r.updated_at ? Math.floor(new Date(r.updated_at).getTime() / 1000) : null;
2781 const ts = commitTs ?? updatedTs;
2782 const label = commitTs ? "Pushed" : updatedTs ? "Updated" : null;
2783 return `
2784 <a href="/${encodeURIComponent(r.owner_name)}/${encodeURIComponent(r.name)}" class="homepage-repo">
2785 <div style="min-width:0">
2786 <div class="homepage-repo-name">${escapeHtml(r.name)}</div>
2787 <div class="homepage-repo-desc">${escapeHtml(r.description || "No description")}</div>
2788 </div>
2789 <span class="homepage-repo-time">${ts ? `${label} ${timeAgo(ts)}` : ""}</span>
2790 </a>
2791 `;
2792 }).join("")}
2793 </div>
2794 `).join("");
2795 }
2796
2797 // Fetch repos
2798 const repoHeaders = token ? { Authorization: `Bearer ${token}` } : {};
2799 fetch("/api/repos", { headers: repoHeaders })
2800 .then(res => res.json())
2801 .then(data => {
2802 allRepos = (data.repos || []).sort((a, b) => {
2803 const aTs = a.last_commit_ts ?? (a.updated_at ? Math.floor(new Date(a.updated_at).getTime() / 1000) : 0);
2804 const bTs = b.last_commit_ts ?? (b.updated_at ? Math.floor(new Date(b.updated_at).getTime() / 1000) : 0);
2805 return bTs - aTs;
2806 });
2807 renderRepos(allRepos);
2808 })
2809 .catch(() => renderRepos([]));
2810
2811 // Search
2812 document.getElementById("homepageSearch").addEventListener("input", (e) => {
2813 const q = e.target.value.trim().toLowerCase();
2814 if (!q) { renderRepos(allRepos); return; }
2815 renderRepos(allRepos.filter(r => {
2816 return `${r.owner_name} ${r.name} ${r.description || ""}`.toLowerCase().includes(q);
2817 }));
2818 });
2819}
2820
2821// ── Init: load page, connect socket only if authenticated ──
2822(async function() {
2823 const token = getCookie("grove_hub_token");
2824 const userJson = getCookie("grove_hub_user");
2825 let user = null;
2826 if (token && userJson) {
2827 try { user = JSON.parse(userJson); } catch { /* treat as unauthenticated */ }
2828 }
2829
2830 // Extract owner/repo from URL path
2831 const pathParts = window.location.pathname.split("/").filter(Boolean);
2832 if (pathParts.length < 2) {
2833 showHomepage(token, user);
2834 return;
2835 }
2836 const [owner, repo] = pathParts;
2837
2838 // Set up topbar profile icon
2839 setupTopbarProfile(user);
2840
2841 if (token && user) {
2842 await joinRoom(owner, repo, token, user);
2843 } else {
2844 // Unauthenticated: load diagrams read-only (no real-time collab)
2845 currentOwner = owner;
2846 currentRepo = repo;
2847 myName = "Anonymous";
2848 roomId = `${owner}/${repo}`;
2849 setBreadcrumbs(owner, repo);
2850 await loadDiagramsFromServer(owner, repo);
2851 buildDrawer();
2852 switchTab(0);
2853 }
2854})();
2855
2856// Live reload: reconnect a lightweight socket just for hot-reload signals
2857(function() {
2858 const reloadSocket = io();
2859 reloadSocket.on("hot-reload", ({ file }) => {
2860 console.log(`[hot-reload] ${file} changed, reloading...`);
2861 window.location.reload();
2862 });
2863})();
2864</script>
2865</body>
2866</html>
2867