| 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>↑</kbd> <kbd>↓</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">▶</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−</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)">▶ 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">−</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">↺</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) ── |
| 1381 | let DIAGRAM_SECTIONS = []; |
| 1382 | let DIAGRAMS = []; |
| 1383 | |
| 1384 | async 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 ── |
| 1410 | function getCookie(name) { |
| 1411 | const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); |
| 1412 | return match ? decodeURIComponent(match[1]) : null; |
| 1413 | } |
| 1414 | |
| 1415 | // ── State ── |
| 1416 | let socket = null; |
| 1417 | let currentTab = 0; |
| 1418 | let myName = ""; |
| 1419 | let roomId = ""; |
| 1420 | let currentOwner = ""; |
| 1421 | let currentRepo = ""; |
| 1422 | let allNotes = {}; // { diagramId: [note, ...] } |
| 1423 | let allUsers = {}; |
| 1424 | let remoteCursors = {}; // { socketId: DOM element } |
| 1425 | |
| 1426 | // Pan/zoom state |
| 1427 | let panZoom = { x: 0, y: 0, scale: 1 }; |
| 1428 | let isPanning = false; |
| 1429 | let panStart = { x: 0, y: 0 }; |
| 1430 | let panStartOffset = { x: 0, y: 0 }; |
| 1431 | let didDrag = false; // true if mouse moved during a pan gesture (suppress click) |
| 1432 | let selectedNodeId = null; // currently focused node for dim effect |
| 1433 | |
| 1434 | // ── Init ── |
| 1435 | mermaid.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 | |
| 1471 | function 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 | |
| 1512 | async 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 | */ |
| 1559 | function 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 | |
| 1629 | function 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 | |
| 1639 | function 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 | */ |
| 1675 | function 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 | |
| 1697 | function 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 = "✎"; |
| 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 = "✕"; |
| 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 | |
| 1755 | function 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 | |
| 1763 | async 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 ── |
| 1790 | async 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 ── |
| 1924 | document.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 ── |
| 1952 | document.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 | |
| 1964 | function 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 | |
| 1994 | function 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 | |
| 2009 | function repositionRemoteCursors() { |
| 2010 | for (const el of Object.values(remoteCursors)) { |
| 2011 | if (el._contentX != null) positionCursorElement(el); |
| 2012 | } |
| 2013 | } |
| 2014 | |
| 2015 | function removeRemoteCursor(userId) { |
| 2016 | if (remoteCursors[userId]) { |
| 2017 | remoteCursors[userId].remove(); |
| 2018 | delete remoteCursors[userId]; |
| 2019 | } |
| 2020 | } |
| 2021 | |
| 2022 | // ── Presence bar ── |
| 2023 | function 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) ── |
| 2038 | let codeRenderTimeout = null; |
| 2039 | let cmEditor = null; |
| 2040 | let cmReady = false; |
| 2041 | let editorFontSize = 12; |
| 2042 | |
| 2043 | function 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 | |
| 2051 | function 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 ── |
| 2062 | const ydocs = {}; // { diagramId: Y.Doc } |
| 2063 | let currentYtext = null; |
| 2064 | let currentUndoManager = null; |
| 2065 | |
| 2066 | function 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 | |
| 2072 | function 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 | |
| 2089 | function 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 | |
| 2096 | let _suppressEditorDirty = false; |
| 2097 | |
| 2098 | function 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 | |
| 2111 | function 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 ── |
| 2153 | function 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 | |
| 2164 | function 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 | |
| 2233 | let editorDirty = false; |
| 2234 | |
| 2235 | function setEditorDirty(dirty) { |
| 2236 | editorDirty = dirty; |
| 2237 | const btn = document.getElementById("codePanePlay"); |
| 2238 | if (btn) btn.classList.toggle("dirty", dirty); |
| 2239 | } |
| 2240 | |
| 2241 | async 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 | |
| 2315 | async 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 | |
| 2330 | function 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 |
| 2336 | if (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 | |
| 2408 | function 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 | |
| 2424 | const noteInputEl = document.getElementById("noteInput"); |
| 2425 | noteInputEl.addEventListener("keydown", (e) => { |
| 2426 | if (e.key === "Enter" && !e.shiftKey) { |
| 2427 | e.preventDefault(); |
| 2428 | submitNote(); |
| 2429 | } |
| 2430 | }); |
| 2431 | noteInputEl.addEventListener("input", () => { |
| 2432 | noteInputEl.style.height = "auto"; |
| 2433 | noteInputEl.style.height = Math.min(noteInputEl.scrollHeight, 80) + "px"; |
| 2434 | }); |
| 2435 | |
| 2436 | function 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 | |
| 2474 | function deleteNote(noteId, diagramId) { |
| 2475 | if (!confirm("Delete this note?")) return; |
| 2476 | socket.emit("delete-note", { noteId, diagramId }); |
| 2477 | } |
| 2478 | |
| 2479 | // ── Actions ── |
| 2480 | function copyRoomLink() { |
| 2481 | const url = `${window.location.origin}/${encodeURIComponent(currentOwner)}/${encodeURIComponent(currentRepo)}`; |
| 2482 | navigator.clipboard.writeText(url).then(() => showToast("Link copied!")); |
| 2483 | } |
| 2484 | |
| 2485 | async 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 | |
| 2510 | function 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 ── |
| 2518 | function 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 | |
| 2528 | function 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 | |
| 2557 | function escapeHtml(s) { |
| 2558 | const d = document.createElement("div"); |
| 2559 | d.textContent = s; |
| 2560 | return d.innerHTML; |
| 2561 | } |
| 2562 | |
| 2563 | function 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 ── |
| 2571 | function 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 | |
| 2578 | function zoomIn() { |
| 2579 | panZoom.scale = Math.min(panZoom.scale * 1.25, 10); |
| 2580 | applyTransform(); |
| 2581 | } |
| 2582 | |
| 2583 | function zoomOut() { |
| 2584 | panZoom.scale = Math.max(panZoom.scale / 1.25, 0.1); |
| 2585 | applyTransform(); |
| 2586 | } |
| 2587 | |
| 2588 | function 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) |
| 2605 | document.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 |
| 2630 | document.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 | |
| 2643 | window.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 | |
| 2653 | window.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) |
| 2662 | document.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 ── |
| 2669 | function 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 | |