dashboard.html
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>Mnemonic Dashboard</title> 7 <style> 8 /* ================================================================ 9 Design Tokens (from tailwind.config.js / global.css) 10 ================================================================ */ 11 :root { 12 --bg: #0d3d3d; 13 --bg-light: #145454; 14 --bg-dark: #072929; 15 --phosphor: #d9892e; 16 --phosphor-light: #e9a854; 17 --phosphor-dark: #b66f1a; 18 --phosphor-glow: #f5c06d; 19 --text-primary: #d9892e; 20 --text-secondary: #a3bfbf; 21 --text-muted: #6b8f8f; 22 --surface-raised: #145454; 23 --status-success: #4ade80; 24 --status-error: #f87171; 25 --font-mono: 'Berkeley Mono', 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; 26 } 27 28 /* ================================================================ 29 Reset & Base 30 ================================================================ */ 31 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 32 33 html { 34 font-family: var(--font-mono); 35 background: var(--bg); 36 color: var(--text-primary); 37 -webkit-text-size-adjust: 100%; 38 } 39 40 body { 41 min-height: 100vh; 42 line-height: 1.5; 43 } 44 45 a { color: inherit; text-decoration: none; } 46 47 /* ================================================================ 48 Scrollbar (retro theme) 49 ================================================================ */ 50 ::-webkit-scrollbar { width: 8px; height: 8px; } 51 ::-webkit-scrollbar-track { background: var(--bg-dark); } 52 ::-webkit-scrollbar-thumb { background: var(--phosphor-dark); border-radius: 4px; } 53 ::-webkit-scrollbar-thumb:hover { background: var(--phosphor); } 54 55 * { 56 scrollbar-width: thin; 57 scrollbar-color: var(--phosphor-dark) var(--bg-dark); 58 } 59 60 /* ================================================================ 61 Header (sticky) 62 ================================================================ */ 63 .header { 64 position: sticky; 65 top: 0; 66 z-index: 100; 67 background: var(--bg); 68 border-bottom: 1px solid rgba(182, 111, 26, 0.3); 69 padding: 12px; 70 } 71 72 .header-top { 73 display: flex; 74 align-items: center; 75 justify-content: space-between; 76 gap: 12px; 77 margin-bottom: 10px; 78 } 79 80 .logo { 81 display: flex; 82 align-items: center; 83 gap: 8px; 84 font-size: 1.125rem; 85 font-weight: 700; 86 color: var(--phosphor); 87 text-shadow: 0 0 10px rgba(217, 137, 46, 0.5); 88 white-space: nowrap; 89 } 90 91 .logo svg { flex-shrink: 0; } 92 93 .stats { 94 display: flex; 95 gap: 12px; 96 font-size: 0.6875rem; 97 color: var(--text-muted); 98 white-space: nowrap; 99 } 100 101 .stats span { display: flex; align-items: center; gap: 4px; } 102 .stats .num { color: var(--phosphor-light); font-weight: 600; } 103 104 /* ================================================================ 105 Search 106 ================================================================ */ 107 .search-wrap { 108 position: relative; 109 display: flex; 110 align-items: center; 111 } 112 113 .search-icon { 114 position: absolute; 115 left: 12px; 116 width: 20px; 117 height: 20px; 118 color: var(--text-muted); 119 pointer-events: none; 120 } 121 122 .search-input { 123 width: 100%; 124 height: 48px; 125 padding: 0 44px 0 40px; 126 font-family: var(--font-mono); 127 font-size: 16px; 128 background: var(--bg-dark); 129 border: 1px solid rgba(182, 111, 26, 0.3); 130 border-radius: 6px; 131 color: var(--text-primary); 132 outline: none; 133 transition: border-color 0.15s, box-shadow 0.15s; 134 } 135 136 .search-input::placeholder { color: var(--text-muted); } 137 .search-input:focus { 138 border-color: var(--phosphor); 139 box-shadow: 0 0 0 2px rgba(217, 137, 46, 0.15); 140 } 141 142 .search-clear { 143 position: absolute; 144 right: 8px; 145 width: 32px; 146 height: 32px; 147 display: none; 148 align-items: center; 149 justify-content: center; 150 background: transparent; 151 border: none; 152 color: var(--text-muted); 153 cursor: pointer; 154 border-radius: 4px; 155 } 156 157 .search-clear:hover { color: var(--text-primary); } 158 .search-clear.visible { display: flex; } 159 160 .search-hint { 161 position: absolute; 162 right: 44px; 163 font-size: 0.625rem; 164 color: var(--text-muted); 165 opacity: 0.5; 166 pointer-events: none; 167 } 168 169 /* ================================================================ 170 Main Content 171 ================================================================ */ 172 .main { 173 padding: 12px; 174 max-width: 100%; 175 } 176 177 /* ================================================================ 178 Sync Info 179 ================================================================ */ 180 .sync-info { 181 display: flex; 182 align-items: center; 183 justify-content: space-between; 184 padding: 8px 12px; 185 margin-bottom: 16px; 186 font-size: 0.75rem; 187 color: var(--text-muted); 188 background: var(--bg-dark); 189 border-radius: 6px; 190 border: 1px solid rgba(182, 111, 26, 0.15); 191 } 192 193 .sync-info button { 194 display: flex; 195 align-items: center; 196 gap: 4px; 197 min-height: 44px; 198 padding: 4px 8px; 199 background: transparent; 200 border: 1px solid rgba(182, 111, 26, 0.3); 201 border-radius: 4px; 202 color: var(--text-muted); 203 font-family: var(--font-mono); 204 font-size: 0.6875rem; 205 cursor: pointer; 206 transition: color 0.15s, border-color 0.15s; 207 } 208 209 .sync-info button:hover { 210 color: var(--phosphor); 211 border-color: var(--phosphor); 212 } 213 214 .sync-info button.refreshing svg { 215 animation: spin 0.8s linear infinite; 216 } 217 218 @keyframes spin { to { transform: rotate(360deg); } } 219 220 /* ================================================================ 221 Browser Section 222 ================================================================ */ 223 .browser-section { 224 margin-bottom: 20px; 225 } 226 227 .browser-header { 228 display: flex; 229 align-items: center; 230 gap: 8px; 231 padding: 8px 0; 232 margin-bottom: 8px; 233 font-size: 0.875rem; 234 font-weight: 600; 235 color: var(--phosphor-light); 236 text-transform: capitalize; 237 border-bottom: 1px solid rgba(182, 111, 26, 0.2); 238 } 239 240 .browser-header .count { 241 font-size: 0.6875rem; 242 font-weight: 400; 243 color: var(--text-muted); 244 } 245 246 /* ================================================================ 247 Transcendent Workspace Accent 248 ================================================================ */ 249 .ws-transcendent .ws-header { 250 border-color: rgba(217, 137, 46, 0.4); 251 background: rgba(217, 137, 46, 0.08); 252 } 253 254 .ws-transcendent .ws-header:hover { 255 background: rgba(217, 137, 46, 0.18); 256 } 257 258 .ws-transcendent .ws-header .ws-icon { 259 color: var(--phosphor-light); 260 } 261 262 .badge-transcendent-label { 263 font-size: 0.5625rem; 264 text-transform: uppercase; 265 letter-spacing: 0.05em; 266 color: var(--phosphor-light); 267 background: rgba(217, 137, 46, 0.25); 268 padding: 1px 6px; 269 border-radius: 3px; 270 flex-shrink: 0; 271 } 272 273 /* ================================================================ 274 Parent Group 275 ================================================================ */ 276 .parent-group { 277 margin-bottom: 12px; 278 } 279 280 .parent-header { 281 display: flex; 282 align-items: center; 283 gap: 8px; 284 padding: 8px 12px; 285 min-height: 44px; 286 background: var(--surface-raised); 287 border: 1px solid rgba(182, 111, 26, 0.3); 288 border-radius: 6px 6px 0 0; 289 cursor: pointer; 290 user-select: none; 291 transition: background 0.15s; 292 } 293 294 .parent-header:hover { background: rgba(20, 84, 84, 0.8); } 295 296 .parent-header .chevron { 297 width: 16px; 298 height: 16px; 299 flex-shrink: 0; 300 color: var(--text-muted); 301 transition: transform 0.2s; 302 } 303 304 .parent-header.expanded .chevron { transform: rotate(90deg); } 305 306 .parent-header .ws-name { 307 flex: 1; 308 font-size: 0.8125rem; 309 font-weight: 600; 310 color: var(--phosphor-light); 311 overflow: hidden; 312 text-overflow: ellipsis; 313 white-space: nowrap; 314 } 315 316 .parent-header .ws-count { 317 font-size: 0.6875rem; 318 color: var(--text-muted); 319 white-space: nowrap; 320 } 321 322 .parent-children { 323 display: none; 324 border: 1px solid rgba(182, 111, 26, 0.2); 325 border-top: none; 326 border-radius: 0 0 6px 6px; 327 overflow: hidden; 328 } 329 330 .parent-children.open { display: block; } 331 332 .parent-group.collapsed .parent-header { 333 border-radius: 6px; 334 } 335 336 .child-workspace { 337 border-bottom: 1px solid rgba(182, 111, 26, 0.1); 338 } 339 340 .child-workspace:last-child { border-bottom: none; } 341 342 /* ================================================================ 343 Workspace Card (standalone & child) 344 ================================================================ */ 345 .ws-card { margin-bottom: 8px; } 346 .child-workspace .ws-card { margin-bottom: 0; } 347 348 .ws-header { 349 display: flex; 350 align-items: center; 351 gap: 8px; 352 padding: 8px 12px; 353 min-height: 44px; 354 background: var(--surface-raised); 355 border: 1px solid rgba(182, 111, 26, 0.2); 356 border-radius: 6px; 357 cursor: pointer; 358 user-select: none; 359 transition: background 0.15s; 360 } 361 362 .child-workspace .ws-header { 363 border: none; 364 border-radius: 0; 365 padding-left: 28px; 366 background: rgba(20, 84, 84, 0.3); 367 } 368 369 .ws-header:hover { background: rgba(20, 84, 84, 0.5); } 370 371 .ws-header .chevron { 372 width: 14px; 373 height: 14px; 374 flex-shrink: 0; 375 color: var(--text-muted); 376 transition: transform 0.2s; 377 } 378 379 .ws-header.expanded .chevron { transform: rotate(90deg); } 380 381 .ws-header .ws-icon { 382 width: 16px; 383 height: 16px; 384 flex-shrink: 0; 385 color: var(--text-muted); 386 } 387 388 .ws-header .ws-name { 389 flex: 1; 390 font-size: 0.8125rem; 391 color: var(--text-secondary); 392 overflow: hidden; 393 text-overflow: ellipsis; 394 white-space: nowrap; 395 } 396 397 .ws-header .ws-count { 398 font-size: 0.6875rem; 399 color: var(--text-muted); 400 white-space: nowrap; 401 } 402 403 .ws-tabs { 404 display: none; 405 border: 1px solid rgba(182, 111, 26, 0.15); 406 border-top: none; 407 border-radius: 0 0 6px 6px; 408 overflow: hidden; 409 } 410 411 .ws-tabs.open { display: block; } 412 413 .child-workspace .ws-tabs { 414 border: none; 415 border-radius: 0; 416 } 417 418 /* ================================================================ 419 Tab Row 420 ================================================================ */ 421 .tab-row { 422 display: flex; 423 align-items: center; 424 gap: 8px; 425 padding: 6px 12px 6px 20px; 426 min-height: 40px; 427 color: var(--text-secondary); 428 text-decoration: none; 429 transition: background 0.15s; 430 border-bottom: 1px solid rgba(182, 111, 26, 0.08); 431 } 432 433 .child-workspace .tab-row { padding-left: 40px; } 434 435 .tab-row:last-child { border-bottom: none; } 436 .tab-row:hover { background: rgba(217, 137, 46, 0.08); } 437 438 .tab-favicon { 439 width: 16px; 440 height: 16px; 441 flex-shrink: 0; 442 border-radius: 2px; 443 } 444 445 .tab-favicon-placeholder { 446 width: 16px; 447 height: 16px; 448 flex-shrink: 0; 449 display: flex; 450 align-items: center; 451 justify-content: center; 452 color: var(--text-muted); 453 } 454 455 .tab-favicon-placeholder svg { width: 14px; height: 14px; } 456 457 .tab-info { 458 flex: 1; 459 min-width: 0; 460 display: flex; 461 flex-direction: column; 462 gap: 1px; 463 } 464 465 .tab-title { 466 font-size: 0.8125rem; 467 color: var(--text-secondary); 468 overflow: hidden; 469 text-overflow: ellipsis; 470 white-space: nowrap; 471 } 472 473 .tab-domain { 474 font-size: 0.6875rem; 475 color: var(--text-muted); 476 overflow: hidden; 477 text-overflow: ellipsis; 478 white-space: nowrap; 479 } 480 481 .tab-pin { 482 width: 12px; 483 height: 12px; 484 flex-shrink: 0; 485 color: var(--phosphor-dark); 486 } 487 488 /* ================================================================ 489 No Results 490 ================================================================ */ 491 .no-results { 492 display: none; 493 text-align: center; 494 padding: 48px 16px; 495 color: var(--text-muted); 496 } 497 498 .no-results.visible { display: block; } 499 .no-results p { margin-bottom: 4px; } 500 .no-results .hint { font-size: 0.75rem; opacity: 0.7; } 501 502 /* ================================================================ 503 Keyboard shortcut hint 504 ================================================================ */ 505 kbd { 506 display: inline-block; 507 padding: 1px 5px; 508 font-family: var(--font-mono); 509 font-size: 0.625rem; 510 background: var(--bg-dark); 511 border: 1px solid rgba(182, 111, 26, 0.3); 512 border-radius: 3px; 513 color: var(--text-muted); 514 } 515 516 /* ================================================================ 517 Responsive 518 ================================================================ */ 519 @media (min-width: 640px) { 520 .header, .main { padding: 12px 20px; } 521 .main { max-width: 640px; margin: 0 auto; } 522 .header { max-width: 100%; } 523 } 524 525 @media (min-width: 1024px) { 526 .main { max-width: 800px; } 527 } 528 529 /* ================================================================ 530 Match highlight 531 ================================================================ */ 532 .search-match { background: rgba(217, 137, 46, 0.25); border-radius: 2px; } 533 534 /* ================================================================ 535 Todoist Integration 536 ================================================================ */ 537 .todoist-status { 538 display: inline-flex; 539 align-items: center; 540 gap: 4px; 541 font-size: 0.6875rem; 542 color: var(--text-muted); 543 white-space: nowrap; 544 } 545 546 .todoist-status .dot { 547 width: 7px; 548 height: 7px; 549 border-radius: 50%; 550 flex-shrink: 0; 551 } 552 553 .todoist-status .dot.connected { background: var(--status-success); box-shadow: 0 0 4px var(--status-success); } 554 .todoist-status .dot.disconnected { background: var(--status-error); box-shadow: 0 0 4px var(--status-error); } 555 556 .todoist-btn { 557 display: inline-flex; 558 align-items: center; 559 gap: 3px; 560 padding: 2px 7px; 561 font-family: var(--font-mono); 562 font-size: 0.5625rem; 563 background: rgba(229, 78, 64, 0.12); 564 color: #e54e40; 565 border: 1px solid rgba(229, 78, 64, 0.3); 566 border-radius: 3px; 567 cursor: pointer; 568 white-space: nowrap; 569 transition: background 0.15s, border-color 0.15s; 570 flex-shrink: 0; 571 } 572 573 .todoist-btn:hover { 574 background: rgba(229, 78, 64, 0.25); 575 border-color: rgba(229, 78, 64, 0.5); 576 } 577 578 .todoist-btn:disabled { 579 opacity: 0.5; 580 cursor: wait; 581 } 582 583 .todoist-link { 584 display: inline-flex; 585 align-items: center; 586 gap: 3px; 587 padding: 2px 7px; 588 font-family: var(--font-mono); 589 font-size: 0.5625rem; 590 color: #e54e40; 591 text-decoration: none; 592 border: 1px solid rgba(229, 78, 64, 0.2); 593 border-radius: 3px; 594 white-space: nowrap; 595 transition: background 0.15s; 596 flex-shrink: 0; 597 } 598 599 .todoist-link:hover { 600 background: rgba(229, 78, 64, 0.12); 601 } 602 603 .todoist-add-btn { 604 display: inline-flex; 605 align-items: center; 606 justify-content: center; 607 width: 20px; 608 height: 20px; 609 font-size: 0.75rem; 610 font-weight: 700; 611 background: rgba(229, 78, 64, 0.12); 612 color: #e54e40; 613 border: 1px solid rgba(229, 78, 64, 0.3); 614 border-radius: 3px; 615 cursor: pointer; 616 flex-shrink: 0; 617 transition: background 0.15s; 618 } 619 620 .todoist-add-btn:hover { 621 background: rgba(229, 78, 64, 0.25); 622 } 623 624 .todoist-quick-add { 625 display: none; 626 padding: 8px 12px; 627 background: var(--bg-dark); 628 border: 1px solid rgba(229, 78, 64, 0.3); 629 border-radius: 0 0 6px 6px; 630 margin-top: -1px; 631 } 632 633 .todoist-quick-add.open { display: flex; gap: 6px; align-items: center; } 634 635 .todoist-quick-add input[type="text"] { 636 flex: 1; 637 min-width: 0; 638 height: 30px; 639 padding: 0 8px; 640 font-family: var(--font-mono); 641 font-size: 0.75rem; 642 background: var(--bg); 643 border: 1px solid rgba(182, 111, 26, 0.3); 644 border-radius: 4px; 645 color: var(--text-primary); 646 outline: none; 647 } 648 649 .todoist-quick-add input[type="text"]:focus { 650 border-color: #e54e40; 651 } 652 653 .todoist-quick-add select { 654 height: 30px; 655 padding: 0 4px; 656 font-family: var(--font-mono); 657 font-size: 0.6875rem; 658 background: var(--bg); 659 border: 1px solid rgba(182, 111, 26, 0.3); 660 border-radius: 4px; 661 color: var(--text-secondary); 662 outline: none; 663 } 664 665 .todoist-quick-add button[type="submit"] { 666 height: 30px; 667 padding: 0 10px; 668 font-family: var(--font-mono); 669 font-size: 0.6875rem; 670 font-weight: 600; 671 background: rgba(229, 78, 64, 0.2); 672 color: #e54e40; 673 border: 1px solid rgba(229, 78, 64, 0.4); 674 border-radius: 4px; 675 cursor: pointer; 676 white-space: nowrap; 677 transition: background 0.15s; 678 } 679 680 .todoist-quick-add button[type="submit"]:hover { 681 background: rgba(229, 78, 64, 0.35); 682 } 683 684 .todoist-error { 685 font-size: 0.625rem; 686 color: var(--status-error); 687 margin-left: 4px; 688 } 689 690 .todoist-success { 691 font-size: 0.625rem; 692 color: var(--status-success); 693 margin-left: 4px; 694 } 695 696 /* ---- Cross-browser tab transfer ---- */ 697 .tab-send-btn { 698 position: relative; 699 z-index: 2; 700 display: flex; 701 align-items: center; 702 justify-content: center; 703 width: 22px; 704 height: 22px; 705 padding: 0; 706 margin-left: auto; 707 flex-shrink: 0; 708 background: transparent; 709 border: 1px solid rgba(255,255,255,0.12); 710 border-radius: 4px; 711 color: var(--phosphor-dim); 712 cursor: pointer; 713 opacity: 0; 714 transition: opacity 0.15s, background 0.15s, color 0.15s; 715 } 716 .tab-row:hover .tab-send-btn { opacity: 1; } 717 .tab-send-btn:hover { 718 background: rgba(var(--accent-rgb, 100,180,255), 0.2); 719 color: var(--phosphor-light); 720 border-color: rgba(var(--accent-rgb, 100,180,255), 0.5); 721 } 722 .tab-send-btn .send-status { 723 position: absolute; 724 top: -6px; 725 right: -8px; 726 font-size: 0.55rem; 727 padding: 1px 4px; 728 border-radius: 3px; 729 white-space: nowrap; 730 pointer-events: none; 731 } 732 .tab-send-btn .send-status.ok { background: var(--status-success); color: #000; } 733 .tab-send-btn .send-status.err { background: var(--status-error); color: #fff; } 734 735 .transfer-picker { 736 position: fixed; 737 inset: 0; 738 z-index: 1000; 739 background: rgba(0,0,0,0.4); 740 } 741 .transfer-picker-content { 742 position: absolute; 743 width: 280px; 744 max-height: 360px; 745 overflow-y: auto; 746 background: var(--bg-card); 747 border: 1px solid var(--border); 748 border-radius: 8px; 749 box-shadow: 0 8px 24px rgba(0,0,0,0.5); 750 padding: 6px 0; 751 } 752 .transfer-picker-title { 753 padding: 6px 12px; 754 font-size: 0.7rem; 755 font-weight: 700; 756 color: var(--phosphor-dim); 757 text-transform: uppercase; 758 letter-spacing: 0.05em; 759 } 760 .transfer-browser-label { 761 padding: 4px 12px 2px; 762 font-size: 0.65rem; 763 font-weight: 600; 764 color: var(--phosphor-dim); 765 opacity: 0.7; 766 } 767 .transfer-ws-item { 768 display: flex; 769 align-items: center; 770 gap: 8px; 771 padding: 6px 12px; 772 font-size: 0.75rem; 773 color: var(--phosphor-light); 774 cursor: pointer; 775 transition: background 0.1s; 776 } 777 .transfer-ws-item:hover { background: rgba(255,255,255,0.06); } 778 .transfer-ws-item .ws-tab-count { 779 margin-left: auto; 780 font-size: 0.625rem; 781 color: var(--phosphor-dim); 782 } 783 </style> 784 </head> 785 <body> 786 787 <!-- ============================================================ 788 Header 789 ============================================================ --> 790 <header class="header"> 791 <div class="header-top"> 792 <div class="logo"> 793 <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> 794 <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/> 795 </svg> 796 <span>Mnemonic</span> 797 </div> 798 <div class="stats"> 799 <span><span class="num" id="stat-workspaces">{{ total_workspaces }}</span> workspaces</span> 800 <span><span class="num" id="stat-tabs">{{ total_tabs }}</span> tabs</span> 801 {% if todoist_configured %} 802 <span class="todoist-status"> 803 {% if todoist_status and todoist_status.connected %} 804 <span class="dot connected"></span>Todoist 805 {% else %} 806 <span class="dot disconnected"></span>Todoist 807 {% endif %} 808 </span> 809 {% endif %} 810 </div> 811 </div> 812 <div class="search-wrap"> 813 <svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 814 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/> 815 </svg> 816 <input 817 type="text" 818 class="search-input" 819 id="search" 820 placeholder="Search workspaces and tabs..." 821 autocomplete="off" 822 spellcheck="false" 823 /> 824 <span class="search-hint" id="search-hint"><kbd>/</kbd></span> 825 <button class="search-clear" id="search-clear" type="button" title="Clear search (Esc)"> 826 <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 827 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> 828 </svg> 829 </button> 830 </div> 831 </header> 832 833 <!-- ============================================================ 834 Main 835 ============================================================ --> 836 <main class="main" id="content"> 837 838 <div class="sync-info"> 839 <span id="sync-time" {% if last_modified %}data-utc="{{ last_modified }}"{% endif %}> 840 {% if last_modified %} 841 Last synced: {{ last_modified }} 842 {% else %} 843 No sync data available 844 {% endif %} 845 </span> 846 <button type="button" id="refresh-btn" title="Refresh workspace data"> 847 <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 848 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/> 849 </svg> 850 Refresh 851 </button> 852 </div> 853 854 <div id="workspace-content"> 855 {% for browser_key, section in browser_sections.items() %} 856 <div class="browser-section" data-browser="{{ browser_key }}"> 857 <div class="browser-header"> 858 <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="currentColor" opacity="0.6"/></svg> 859 <span>{{ browser_key }}</span> 860 <span class="count">({{ section.workspaces|length }} workspaces)</span> 861 </div> 862 863 {# ---- Transcendent workspaces ---- #} 864 {% for ws in section.transcendent %} 865 {% set ws_tabs = ws.get('tabs', []) %} 866 {% set ws_tab_count = ws_tabs|length if ws_tabs is iterable and ws_tabs is not string else 0 %} 867 <div class="ws-card ws-transcendent" data-ws-id="{{ ws.get('id', '') }}" data-ws-name="{{ ws.get('name', '') }}" data-search-text="{{ ws.get('name', '')|lower }}"> 868 <div class="ws-header" onclick="toggleWs(this)"> 869 <svg class="chevron" fill="currentColor" viewBox="0 0 20 20"> 870 <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/> 871 </svg> 872 <svg class="ws-icon" fill="currentColor" viewBox="0 0 20 20"> 873 <path d="M14.5 10c0 1.38-1.12 2.5-2.5 2.5-.83 0-1.57-.41-2.02-1.03L8.5 10l1.48-1.47A2.49 2.49 0 0112 7.5c1.38 0 2.5 1.12 2.5 2.5zm-9 0c0-1.38 1.12-2.5 2.5-2.5.83 0 1.57.41 2.02 1.03L11.5 10l-1.48 1.47A2.49 2.49 0 018 12.5c-1.38 0-2.5-1.12-2.5-2.5zm4.5 0l2-2c.78-.78 1.81-1.17 2.83-1.17 2.21 0 4 1.79 4 4s-1.79 4-4 4c-1.02 0-2.05-.39-2.83-1.17l-2-2-2 2c-.78.78-1.81 1.17-2.83 1.17-2.21 0-4-1.79-4-4s1.79-4 4-4c1.02 0 2.05.39 2.83 1.17l2 2z"/> 874 </svg> 875 <span class="ws-name">{{ ws.get('name', 'Unnamed') }}</span> 876 <span class="badge-transcendent-label">transcendent</span> 877 <span class="ws-count">{{ ws_tab_count }} tabs</span> 878 {% if todoist_configured and todoist_status and todoist_status.connected %} 879 {% if ws.get('todoistProjectId') %} 880 <a class="todoist-link" href="https://todoist.com/app/project/{{ ws.get('todoistProjectId') }}" target="_blank" rel="noopener" onclick="event.stopPropagation()">Todoist</a> 881 <button class="todoist-add-btn" onclick="event.stopPropagation(); toggleQuickAdd(this)" data-project-id="{{ ws.get('todoistProjectId') }}" title="Quick-add task">+</button> 882 {% else %} 883 <button class="todoist-btn" onclick="event.stopPropagation(); createTodoistProject(this)" data-ws-id="{{ ws.get('id', '') }}" data-ws-name="{{ ws.get('name', '') }}" data-browser-key="{{ browser_key }}">+ Todoist</button> 884 {% endif %} 885 {% endif %} 886 </div> 887 {% if todoist_configured and todoist_status and todoist_status.connected and ws.get('todoistProjectId') %} 888 <div class="todoist-quick-add" data-project-id="{{ ws.get('todoistProjectId') }}"> 889 <input type="text" placeholder="Add task..." onkeydown="if(event.key==='Enter'){event.preventDefault();submitQuickTask(this.parentElement)}" /> 890 <select title="Priority"><option value="1">P4</option><option value="2">P3</option><option value="3">P2</option><option value="4">P1</option></select> 891 <button type="submit" onclick="submitQuickTask(this.parentElement)">Add</button> 892 </div> 893 {% endif %} 894 <div class="ws-tabs"> 895 {% if ws_tabs is iterable and ws_tabs is not string %} 896 {% for tab in ws_tabs %} 897 {% set tab_url = tab.get('url', '') %} 898 <a class="tab-row" href="{{ tab_url }}" target="_blank" rel="noopener" 899 data-tab-title="{{ tab.get('title', '')|lower }}" data-tab-url="{{ tab_url|lower }}"> 900 {% if tab_url and tab_url.startswith('http') %} 901 <img class="tab-favicon" src="https://www.google.com/s2/favicons?domain={{ tab_url|domain }}&sz=16" 902 alt="" loading="lazy" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';"> 903 <span class="tab-favicon-placeholder" style="display:none;"> 904 <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg> 905 </span> 906 {% else %} 907 <span class="tab-favicon-placeholder"> 908 <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg> 909 </span> 910 {% endif %} 911 <div class="tab-info"> 912 <span class="tab-title">{{ tab.get('title', tab_url or 'Untitled') }}</span> 913 <span class="tab-domain">{{ tab_url|domain }}</span> 914 </div> 915 {% if tab.get('pinned') %} 916 <svg class="tab-pin" fill="currentColor" viewBox="0 0 20 20"><path d="M5 5a2 2 0 012-2h6a2 2 0 012 2v1h-2V5H7v1H5V5zm2 4h6v6l-3 2-3-2V9z"/></svg> 917 {% endif %} 918 <button class="tab-send-btn" onclick="event.preventDefault();event.stopPropagation();openTransferPicker(this)" data-tab-url="{{ tab_url }}" data-tab-title="{{ tab.get('title', '') }}" data-tab-pinned="{{ 'true' if tab.get('pinned') else 'false' }}" data-source-browser="{{ browser_key }}" title="Send to another browser"><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/></svg></button> 919 </a> 920 {% endfor %} 921 {% endif %} 922 </div> 923 </div> 924 {% endfor %} 925 926 {# ---- Parent workspaces with children ---- #} 927 {% for parent in section.parents %} 928 {% set ws = parent.ws %} 929 {% set children = parent.children %} 930 {% set ws_tabs = ws.get('tabs', []) %} 931 {% set ws_tab_count = ws_tabs|length if ws_tabs is iterable and ws_tabs is not string else 0 %} 932 <div class="parent-group collapsed" data-ws-id="{{ ws.get('id', '') }}" data-ws-name="{{ ws.get('name', '') }}" data-search-text="{{ ws.get('name', '')|lower }}"> 933 <div class="parent-header" onclick="toggleParent(this)"> 934 <svg class="chevron" fill="currentColor" viewBox="0 0 20 20"> 935 <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/> 936 </svg> 937 <svg width="16" height="16" fill="currentColor" viewBox="0 0 20 20" style="color: var(--phosphor-light); flex-shrink: 0;"> 938 <path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/> 939 </svg> 940 <span class="ws-name">{{ ws.get('name', 'Unnamed') }}</span> 941 <span class="ws-count">{{ ws_tab_count + parent.child_tab_total }} tabs · {{ children|length }} children</span> 942 {% if todoist_configured and todoist_status and todoist_status.connected %} 943 {% if ws.get('todoistProjectId') %} 944 <a class="todoist-link" href="https://todoist.com/app/project/{{ ws.get('todoistProjectId') }}" target="_blank" rel="noopener" onclick="event.stopPropagation()">Todoist</a> 945 <button class="todoist-add-btn" onclick="event.stopPropagation(); toggleQuickAdd(this)" data-project-id="{{ ws.get('todoistProjectId') }}" title="Quick-add task">+</button> 946 {% else %} 947 <button class="todoist-btn" onclick="event.stopPropagation(); createTodoistProject(this)" data-ws-id="{{ ws.get('id', '') }}" data-ws-name="{{ ws.get('name', '') }}" data-browser-key="{{ browser_key }}">+ Todoist</button> 948 {% endif %} 949 {% endif %} 950 </div> 951 {% if todoist_configured and todoist_status and todoist_status.connected and ws.get('todoistProjectId') %} 952 <div class="todoist-quick-add" data-project-id="{{ ws.get('todoistProjectId') }}"> 953 <input type="text" placeholder="Add task..." onkeydown="if(event.key==='Enter'){event.preventDefault();submitQuickTask(this.parentElement)}" /> 954 <select title="Priority"><option value="1">P4</option><option value="2">P3</option><option value="3">P2</option><option value="4">P1</option></select> 955 <button type="submit" onclick="submitQuickTask(this.parentElement)">Add</button> 956 </div> 957 {% endif %} 958 <div class="parent-children"> 959 {# Parent's own tabs #} 960 {% if ws_tabs is iterable and ws_tabs is not string and ws_tabs|length > 0 %} 961 <div class="ws-card" data-ws-id="{{ ws.get('id', '') }}" data-ws-name="{{ ws.get('name', '') }}"> 962 <div class="ws-tabs open"> 963 {% for tab in ws_tabs %} 964 {% set tab_url = tab.get('url', '') %} 965 <a class="tab-row" href="{{ tab_url }}" target="_blank" rel="noopener" 966 data-tab-title="{{ tab.get('title', '')|lower }}" data-tab-url="{{ tab_url|lower }}"> 967 {% if tab_url and tab_url.startswith('http') %} 968 <img class="tab-favicon" src="https://www.google.com/s2/favicons?domain={{ tab_url|domain }}&sz=16" 969 alt="" loading="lazy" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';"> 970 <span class="tab-favicon-placeholder" style="display:none;"> 971 <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg> 972 </span> 973 {% else %} 974 <span class="tab-favicon-placeholder"> 975 <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg> 976 </span> 977 {% endif %} 978 <div class="tab-info"> 979 <span class="tab-title">{{ tab.get('title', tab_url or 'Untitled') }}</span> 980 <span class="tab-domain">{{ tab_url|domain }}</span> 981 </div> 982 {% if tab.get('pinned') %} 983 <svg class="tab-pin" fill="currentColor" viewBox="0 0 20 20"><path d="M5 5a2 2 0 012-2h6a2 2 0 012 2v1h-2V5H7v1H5V5zm2 4h6v6l-3 2-3-2V9z"/></svg> 984 {% endif %} 985 <button class="tab-send-btn" onclick="event.preventDefault();event.stopPropagation();openTransferPicker(this)" data-tab-url="{{ tab_url }}" data-tab-title="{{ tab.get('title', '') }}" data-tab-pinned="{{ 'true' if tab.get('pinned') else 'false' }}" data-source-browser="{{ browser_key }}" title="Send to another browser"><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/></svg></button> 986 </a> 987 {% endfor %} 988 </div> 989 </div> 990 {% endif %} 991 992 {# Children #} 993 {% for child in children %} 994 {% set child_tabs = child.get('tabs', []) %} 995 {% set child_tab_count = child_tabs|length if child_tabs is iterable and child_tabs is not string else 0 %} 996 <div class="child-workspace" data-ws-id="{{ child.get('id', '') }}" data-ws-name="{{ child.get('name', '') }}" data-search-text="{{ child.get('name', '')|lower }}"> 997 <div class="ws-card"> 998 <div class="ws-header" onclick="toggleWs(this)"> 999 <svg class="chevron" fill="currentColor" viewBox="0 0 20 20"> 1000 <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/> 1001 </svg> 1002 <svg class="ws-icon" fill="currentColor" viewBox="0 0 20 20"> 1003 <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM4.332 8.027a6.012 6.012 0 011.912-2.706C6.512 5.73 6.974 6 7.5 6A1.5 1.5 0 019 7.5V8a2 2 0 004 0 2 2 0 011.523-1.943A5.977 5.977 0 0116 10c0 .34-.028.675-.083 1H15a2 2 0 00-2 2v2.197A5.973 5.973 0 0110 16v-2a2 2 0 00-2-2 2 2 0 01-2-2 2 2 0 00-1.668-1.973z" clip-rule="evenodd"/> 1004 </svg> 1005 <span class="ws-name">{{ child.get('name', 'Unnamed') }}</span> 1006 <span class="ws-count">{{ child_tab_count }} tabs</span> 1007 {% if todoist_configured and todoist_status and todoist_status.connected %} 1008 {% if child.get('todoistProjectId') %} 1009 <a class="todoist-link" href="https://todoist.com/app/project/{{ child.get('todoistProjectId') }}" target="_blank" rel="noopener" onclick="event.stopPropagation()">Todoist</a> 1010 <button class="todoist-add-btn" onclick="event.stopPropagation(); toggleQuickAdd(this)" data-project-id="{{ child.get('todoistProjectId') }}" title="Quick-add task">+</button> 1011 {% else %} 1012 <button class="todoist-btn" onclick="event.stopPropagation(); createTodoistProject(this)" data-ws-id="{{ child.get('id', '') }}" data-ws-name="{{ child.get('name', '') }}" data-browser-key="{{ browser_key }}" data-parent-todoist-id="{{ ws.get('todoistProjectId', '') }}">+ Todoist</button> 1013 {% endif %} 1014 {% endif %} 1015 </div> 1016 {% if todoist_configured and todoist_status and todoist_status.connected and child.get('todoistProjectId') %} 1017 <div class="todoist-quick-add" data-project-id="{{ child.get('todoistProjectId') }}"> 1018 <input type="text" placeholder="Add task..." onkeydown="if(event.key==='Enter'){event.preventDefault();submitQuickTask(this.parentElement)}" /> 1019 <select title="Priority"><option value="1">P4</option><option value="2">P3</option><option value="3">P2</option><option value="4">P1</option></select> 1020 <button type="submit" onclick="submitQuickTask(this.parentElement)">Add</button> 1021 </div> 1022 {% endif %} 1023 <div class="ws-tabs"> 1024 {% if child_tabs is iterable and child_tabs is not string %} 1025 {% for tab in child_tabs %} 1026 {% set tab_url = tab.get('url', '') %} 1027 <a class="tab-row" href="{{ tab_url }}" target="_blank" rel="noopener" 1028 data-tab-title="{{ tab.get('title', '')|lower }}" data-tab-url="{{ tab_url|lower }}"> 1029 {% if tab_url and tab_url.startswith('http') %} 1030 <img class="tab-favicon" src="https://www.google.com/s2/favicons?domain={{ tab_url|domain }}&sz=16" 1031 alt="" loading="lazy" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';"> 1032 <span class="tab-favicon-placeholder" style="display:none;"> 1033 <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg> 1034 </span> 1035 {% else %} 1036 <span class="tab-favicon-placeholder"> 1037 <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg> 1038 </span> 1039 {% endif %} 1040 <div class="tab-info"> 1041 <span class="tab-title">{{ tab.get('title', tab_url or 'Untitled') }}</span> 1042 <span class="tab-domain">{{ tab_url|domain }}</span> 1043 </div> 1044 {% if tab.get('pinned') %} 1045 <svg class="tab-pin" fill="currentColor" viewBox="0 0 20 20"><path d="M5 5a2 2 0 012-2h6a2 2 0 012 2v1h-2V5H7v1H5V5zm2 4h6v6l-3 2-3-2V9z"/></svg> 1046 {% endif %} 1047 <button class="tab-send-btn" onclick="event.preventDefault();event.stopPropagation();openTransferPicker(this)" data-tab-url="{{ tab_url }}" data-tab-title="{{ tab.get('title', '') }}" data-tab-pinned="{{ 'true' if tab.get('pinned') else 'false' }}" data-source-browser="{{ browser_key }}" title="Send to another browser"><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/></svg></button> 1048 </a> 1049 {% endfor %} 1050 {% endif %} 1051 </div> 1052 </div> 1053 </div> 1054 {% endfor %} 1055 </div> 1056 </div> 1057 {% endfor %} 1058 1059 {# ---- Standalone workspaces ---- #} 1060 {% for ws in section.standalone %} 1061 {% set ws_tabs = ws.get('tabs', []) %} 1062 {% set ws_tab_count = ws_tabs|length if ws_tabs is iterable and ws_tabs is not string else 0 %} 1063 <div class="ws-card" data-ws-id="{{ ws.get('id', '') }}" data-ws-name="{{ ws.get('name', '') }}" data-search-text="{{ ws.get('name', '')|lower }}"> 1064 <div class="ws-header" onclick="toggleWs(this)"> 1065 <svg class="chevron" fill="currentColor" viewBox="0 0 20 20"> 1066 <path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/> 1067 </svg> 1068 <svg class="ws-icon" fill="currentColor" viewBox="0 0 20 20"> 1069 <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM4.332 8.027a6.012 6.012 0 011.912-2.706C6.512 5.73 6.974 6 7.5 6A1.5 1.5 0 019 7.5V8a2 2 0 004 0 2 2 0 011.523-1.943A5.977 5.977 0 0116 10c0 .34-.028.675-.083 1H15a2 2 0 00-2 2v2.197A5.973 5.973 0 0110 16v-2a2 2 0 00-2-2 2 2 0 01-2-2 2 2 0 00-1.668-1.973z" clip-rule="evenodd"/> 1070 </svg> 1071 <span class="ws-name">{{ ws.get('name', 'Unnamed') }}</span> 1072 <span class="ws-count">{{ ws_tab_count }} tabs</span> 1073 {% if todoist_configured and todoist_status and todoist_status.connected %} 1074 {% if ws.get('todoistProjectId') %} 1075 <a class="todoist-link" href="https://todoist.com/app/project/{{ ws.get('todoistProjectId') }}" target="_blank" rel="noopener" onclick="event.stopPropagation()">Todoist</a> 1076 <button class="todoist-add-btn" onclick="event.stopPropagation(); toggleQuickAdd(this)" data-project-id="{{ ws.get('todoistProjectId') }}" title="Quick-add task">+</button> 1077 {% else %} 1078 <button class="todoist-btn" onclick="event.stopPropagation(); createTodoistProject(this)" data-ws-id="{{ ws.get('id', '') }}" data-ws-name="{{ ws.get('name', '') }}" data-browser-key="{{ browser_key }}">+ Todoist</button> 1079 {% endif %} 1080 {% endif %} 1081 </div> 1082 {% if todoist_configured and todoist_status and todoist_status.connected and ws.get('todoistProjectId') %} 1083 <div class="todoist-quick-add" data-project-id="{{ ws.get('todoistProjectId') }}"> 1084 <input type="text" placeholder="Add task..." onkeydown="if(event.key==='Enter'){event.preventDefault();submitQuickTask(this.parentElement)}" /> 1085 <select title="Priority"><option value="1">P4</option><option value="2">P3</option><option value="3">P2</option><option value="4">P1</option></select> 1086 <button type="submit" onclick="submitQuickTask(this.parentElement)">Add</button> 1087 </div> 1088 {% endif %} 1089 <div class="ws-tabs"> 1090 {% if ws_tabs is iterable and ws_tabs is not string %} 1091 {% for tab in ws_tabs %} 1092 {% set tab_url = tab.get('url', '') %} 1093 <a class="tab-row" href="{{ tab_url }}" target="_blank" rel="noopener" 1094 data-tab-title="{{ tab.get('title', '')|lower }}" data-tab-url="{{ tab_url|lower }}"> 1095 {% if tab_url and tab_url.startswith('http') %} 1096 <img class="tab-favicon" src="https://www.google.com/s2/favicons?domain={{ tab_url|domain }}&sz=16" 1097 alt="" loading="lazy" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';"> 1098 <span class="tab-favicon-placeholder" style="display:none;"> 1099 <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg> 1100 </span> 1101 {% else %} 1102 <span class="tab-favicon-placeholder"> 1103 <svg fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clip-rule="evenodd"/></svg> 1104 </span> 1105 {% endif %} 1106 <div class="tab-info"> 1107 <span class="tab-title">{{ tab.get('title', tab_url or 'Untitled') }}</span> 1108 <span class="tab-domain">{{ tab_url|domain }}</span> 1109 </div> 1110 {% if tab.get('pinned') %} 1111 <svg class="tab-pin" fill="currentColor" viewBox="0 0 20 20"><path d="M5 5a2 2 0 012-2h6a2 2 0 012 2v1h-2V5H7v1H5V5zm2 4h6v6l-3 2-3-2V9z"/></svg> 1112 {% endif %} 1113 <button class="tab-send-btn" onclick="event.preventDefault();event.stopPropagation();openTransferPicker(this)" data-tab-url="{{ tab_url }}" data-tab-title="{{ tab.get('title', '') }}" data-tab-pinned="{{ 'true' if tab.get('pinned') else 'false' }}" data-source-browser="{{ browser_key }}" title="Send to another browser"><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/></svg></button> 1114 </a> 1115 {% endfor %} 1116 {% endif %} 1117 </div> 1118 </div> 1119 {% endfor %} 1120 1121 </div> 1122 {% endfor %} 1123 </div> 1124 1125 <div class="no-results" id="no-results"> 1126 <p>No matching workspaces or tabs</p> 1127 <p class="hint">Try different keywords</p> 1128 </div> 1129 1130 </main> 1131 1132 <!-- Browser workspace data for tab transfer picker --> 1133 <script>const _browserWorkspaces = {{ browser_ws_json|safe }};</script> 1134 1135 <!-- ============================================================ 1136 JavaScript 1137 ============================================================ --> 1138 <script> 1139 (function() { 1140 'use strict'; 1141 1142 const searchInput = document.getElementById('search'); 1143 const searchClear = document.getElementById('search-clear'); 1144 const searchHint = document.getElementById('search-hint'); 1145 const noResults = document.getElementById('no-results'); 1146 const content = document.getElementById('workspace-content'); 1147 const refreshBtn = document.getElementById('refresh-btn'); 1148 const syncTime = document.getElementById('sync-time'); 1149 1150 // ---- Friendly time display (UTC → local timezone) ---- 1151 function formatSyncTime(utcIso) { 1152 const date = new Date(utcIso); 1153 if (isNaN(date.getTime())) return utcIso; 1154 1155 // Local time: "Feb 15, 2:13 PM" 1156 const local = date.toLocaleString(undefined, { 1157 month: 'short', day: 'numeric', 1158 hour: 'numeric', minute: '2-digit', 1159 hour12: true 1160 }); 1161 1162 // Relative: "2 min ago", "3 hours ago", etc. 1163 const now = Date.now(); 1164 const diffSec = Math.round((now - date.getTime()) / 1000); 1165 let relative; 1166 if (diffSec < 5) relative = 'just now'; 1167 else if (diffSec < 60) relative = diffSec + 's ago'; 1168 else if (diffSec < 3600) relative = Math.floor(diffSec / 60) + ' min ago'; 1169 else if (diffSec < 86400) relative = Math.floor(diffSec / 3600) + 'h ago'; 1170 else relative = Math.floor(diffSec / 86400) + 'd ago'; 1171 1172 return 'Last synced: ' + local + ' (' + relative + ')'; 1173 } 1174 1175 // Format on load if UTC data is available 1176 if (syncTime && syncTime.dataset.utc) { 1177 syncTime.textContent = formatSyncTime(syncTime.dataset.utc); 1178 // Keep it fresh — update relative portion every 30s 1179 setInterval(() => { 1180 syncTime.textContent = formatSyncTime(syncTime.dataset.utc); 1181 }, 30000); 1182 } 1183 1184 // ---- Build search index ---- 1185 const wsIndex = []; 1186 1187 function buildIndex() { 1188 wsIndex.length = 0; 1189 1190 // Index parent groups 1191 document.querySelectorAll('.parent-group').forEach(el => { 1192 const wsName = (el.dataset.wsName || '').toLowerCase(); 1193 const childEntries = []; 1194 1195 el.querySelectorAll('.child-workspace').forEach(cEl => { 1196 const cName = (cEl.dataset.wsName || '').toLowerCase(); 1197 const tabEls = []; 1198 cEl.querySelectorAll('.tab-row').forEach(tEl => { 1199 tabEls.push({ 1200 el: tEl, 1201 title: tEl.dataset.tabTitle || '', 1202 url: tEl.dataset.tabUrl || '' 1203 }); 1204 }); 1205 childEntries.push({ el: cEl, wsName: cName, tabEls, type: 'child' }); 1206 }); 1207 1208 // Parent's own tabs (direct child .ws-card, not inside .child-workspace) 1209 const parentTabs = []; 1210 const ownTabContainer = el.querySelector('.parent-children > .ws-card:not(.child-workspace .ws-card)'); 1211 if (ownTabContainer) { 1212 ownTabContainer.querySelectorAll('.tab-row').forEach(tEl => { 1213 parentTabs.push({ 1214 el: tEl, 1215 title: tEl.dataset.tabTitle || '', 1216 url: tEl.dataset.tabUrl || '' 1217 }); 1218 }); 1219 } 1220 1221 wsIndex.push({ 1222 el, 1223 wsName, 1224 tabEls: parentTabs, 1225 children: childEntries, 1226 type: 'parent' 1227 }); 1228 }); 1229 1230 // Index standalone cards (direct children of browser-section, not inside parent-group) 1231 document.querySelectorAll('.browser-section > .ws-card').forEach(el => { 1232 const wsName = (el.dataset.wsName || '').toLowerCase(); 1233 const tabEls = []; 1234 el.querySelectorAll('.tab-row').forEach(tEl => { 1235 tabEls.push({ 1236 el: tEl, 1237 title: tEl.dataset.tabTitle || '', 1238 url: tEl.dataset.tabUrl || '' 1239 }); 1240 }); 1241 wsIndex.push({ el, wsName, tabEls, children: [], type: 'standalone' }); 1242 }); 1243 } 1244 1245 buildIndex(); 1246 1247 // ---- Search ---- 1248 let debounceTimer = null; 1249 1250 function doSearch(query) { 1251 query = query.toLowerCase().trim(); 1252 if (!query) { clearSearch(); return; } 1253 1254 const url = new URL(window.location); 1255 url.searchParams.set('q', query); 1256 history.replaceState(null, '', url); 1257 1258 let anyVisible = false; 1259 1260 document.querySelectorAll('.browser-section').forEach(s => s.style.display = 'none'); 1261 1262 wsIndex.forEach(entry => { 1263 if (entry.type === 'parent') { 1264 const parentNameMatch = entry.wsName.includes(query); 1265 let anyChildVisible = false; 1266 1267 entry.tabEls.forEach(t => { 1268 const match = t.title.includes(query) || t.url.includes(query); 1269 t.el.style.display = match || parentNameMatch ? '' : 'none'; 1270 }); 1271 1272 let parentTabMatch = entry.tabEls.some(t => t.title.includes(query) || t.url.includes(query)); 1273 1274 entry.children.forEach(child => { 1275 const childNameMatch = child.wsName.includes(query) || parentNameMatch; 1276 let childTabMatch = false; 1277 child.tabEls.forEach(t => { 1278 const match = t.title.includes(query) || t.url.includes(query); 1279 t.el.style.display = match || childNameMatch ? '' : 'none'; 1280 if (match) childTabMatch = true; 1281 }); 1282 1283 const childVisible = childNameMatch || childTabMatch; 1284 child.el.style.display = childVisible ? '' : 'none'; 1285 if (childVisible) anyChildVisible = true; 1286 1287 if (childTabMatch && !childNameMatch) { 1288 const wsHeader = child.el.querySelector('.ws-header'); 1289 const wsTabs = child.el.querySelector('.ws-tabs'); 1290 if (wsHeader) wsHeader.classList.add('expanded'); 1291 if (wsTabs) wsTabs.classList.add('open'); 1292 } 1293 }); 1294 1295 const parentVisible = parentNameMatch || parentTabMatch || anyChildVisible; 1296 entry.el.style.display = parentVisible ? '' : 'none'; 1297 if (parentVisible) { 1298 anyVisible = true; 1299 entry.el.closest('.browser-section').style.display = ''; 1300 const ph = entry.el.querySelector('.parent-header'); 1301 const pc = entry.el.querySelector('.parent-children'); 1302 if (ph && pc && (anyChildVisible || parentTabMatch)) { 1303 ph.classList.add('expanded'); 1304 pc.classList.add('open'); 1305 entry.el.classList.remove('collapsed'); 1306 } 1307 } 1308 } else { 1309 const nameMatch = entry.wsName.includes(query); 1310 let tabMatch = false; 1311 entry.tabEls.forEach(t => { 1312 const match = t.title.includes(query) || t.url.includes(query); 1313 t.el.style.display = match || nameMatch ? '' : 'none'; 1314 if (match) tabMatch = true; 1315 }); 1316 1317 const visible = nameMatch || tabMatch; 1318 entry.el.style.display = visible ? '' : 'none'; 1319 if (visible) { 1320 anyVisible = true; 1321 entry.el.closest('.browser-section').style.display = ''; 1322 if (tabMatch) { 1323 const wh = entry.el.querySelector('.ws-header'); 1324 const wt = entry.el.querySelector('.ws-tabs'); 1325 if (wh) wh.classList.add('expanded'); 1326 if (wt) wt.classList.add('open'); 1327 } 1328 } 1329 } 1330 }); 1331 1332 noResults.classList.toggle('visible', !anyVisible); 1333 searchClear.classList.add('visible'); 1334 searchHint.style.display = 'none'; 1335 } 1336 1337 function clearSearch() { 1338 searchInput.value = ''; 1339 searchClear.classList.remove('visible'); 1340 searchHint.style.display = ''; 1341 noResults.classList.remove('visible'); 1342 1343 const url = new URL(window.location); 1344 url.searchParams.delete('q'); 1345 history.replaceState(null, '', url); 1346 1347 document.querySelectorAll('.browser-section').forEach(s => s.style.display = ''); 1348 1349 wsIndex.forEach(entry => { 1350 entry.el.style.display = ''; 1351 entry.tabEls.forEach(t => t.el.style.display = ''); 1352 if (entry.children) { 1353 entry.children.forEach(child => { 1354 child.el.style.display = ''; 1355 child.tabEls.forEach(t => t.el.style.display = ''); 1356 const wh = child.el.querySelector('.ws-header'); 1357 const wt = child.el.querySelector('.ws-tabs'); 1358 if (wh) wh.classList.remove('expanded'); 1359 if (wt) wt.classList.remove('open'); 1360 }); 1361 } 1362 if (entry.type === 'parent') { 1363 const ph = entry.el.querySelector('.parent-header'); 1364 const pc = entry.el.querySelector('.parent-children'); 1365 if (ph) ph.classList.remove('expanded'); 1366 if (pc) pc.classList.remove('open'); 1367 entry.el.classList.add('collapsed'); 1368 } else { 1369 const wh = entry.el.querySelector('.ws-header'); 1370 const wt = entry.el.querySelector('.ws-tabs'); 1371 if (wh) wh.classList.remove('expanded'); 1372 if (wt) wt.classList.remove('open'); 1373 } 1374 }); 1375 } 1376 1377 searchInput.addEventListener('input', function() { 1378 clearTimeout(debounceTimer); 1379 const val = this.value; 1380 if (!val) { clearSearch(); return; } 1381 searchClear.classList.add('visible'); 1382 searchHint.style.display = 'none'; 1383 debounceTimer = setTimeout(() => doSearch(val), 150); 1384 }); 1385 1386 searchClear.addEventListener('click', function() { 1387 clearSearch(); 1388 searchInput.focus(); 1389 }); 1390 1391 // ---- Keyboard shortcuts ---- 1392 document.addEventListener('keydown', function(e) { 1393 if (e.key === '/' && document.activeElement !== searchInput) { 1394 e.preventDefault(); 1395 searchInput.focus(); 1396 } 1397 if (e.key === 'Escape' && document.activeElement === searchInput) { 1398 clearSearch(); 1399 searchInput.blur(); 1400 } 1401 }); 1402 1403 searchInput.addEventListener('focus', function() { searchHint.style.display = 'none'; }); 1404 searchInput.addEventListener('blur', function() { if (!this.value) searchHint.style.display = ''; }); 1405 1406 // ---- Collapse/expand ---- 1407 window.toggleParent = function(header) { 1408 header.classList.toggle('expanded'); 1409 const children = header.parentElement.querySelector('.parent-children'); 1410 if (children) children.classList.toggle('open'); 1411 header.closest('.parent-group').classList.toggle('collapsed'); 1412 }; 1413 1414 window.toggleWs = function(header) { 1415 header.classList.toggle('expanded'); 1416 const tabs = header.parentElement.querySelector('.ws-tabs'); 1417 if (tabs) tabs.classList.toggle('open'); 1418 }; 1419 1420 // ---- Todoist integration ---- 1421 window.createTodoistProject = async function(btn) { 1422 btn.disabled = true; 1423 btn.textContent = '...'; 1424 // Remove any previous error 1425 const prevErr = btn.parentElement.querySelector('.todoist-error'); 1426 if (prevErr) prevErr.remove(); 1427 1428 const wsId = btn.dataset.wsId; 1429 const wsName = btn.dataset.wsName; 1430 const browserKey = btn.dataset.browserKey; 1431 const parentTodoistId = btn.dataset.parentTodoistId || null; 1432 1433 try { 1434 const res = await fetch('/todoist/create-project', { 1435 method: 'POST', 1436 headers: { 'Content-Type': 'application/json' }, 1437 body: JSON.stringify({ 1438 workspace_id: wsId, 1439 workspace_name: wsName, 1440 browser_key: browserKey, 1441 parent_todoist_project_id: parentTodoistId || undefined, 1442 }), 1443 }); 1444 if (!res.ok) { 1445 const err = await res.json().catch(() => ({ detail: res.statusText })); 1446 throw new Error(err.detail || 'Failed'); 1447 } 1448 const data = await res.json(); 1449 // Replace button with link + add button 1450 const link = document.createElement('a'); 1451 link.className = 'todoist-link'; 1452 link.href = data.project_url; 1453 link.target = '_blank'; 1454 link.rel = 'noopener'; 1455 link.textContent = 'Todoist'; 1456 link.onclick = function(e) { e.stopPropagation(); }; 1457 1458 const addBtn = document.createElement('button'); 1459 addBtn.className = 'todoist-add-btn'; 1460 addBtn.title = 'Quick-add task'; 1461 addBtn.textContent = '+'; 1462 addBtn.dataset.projectId = data.todoist_project_id; 1463 addBtn.onclick = function(e) { e.stopPropagation(); toggleQuickAdd(addBtn); }; 1464 1465 btn.parentElement.insertBefore(link, btn); 1466 btn.parentElement.insertBefore(addBtn, btn); 1467 btn.remove(); 1468 1469 // Add quick-add form after the header's parent element 1470 const header = link.closest('.ws-header, .parent-header'); 1471 if (header) { 1472 const form = document.createElement('div'); 1473 form.className = 'todoist-quick-add'; 1474 form.dataset.projectId = data.todoist_project_id; 1475 form.innerHTML = '<input type="text" placeholder="Add task..." onkeydown="if(event.key===\'Enter\'){event.preventDefault();submitQuickTask(this.parentElement)}" />' 1476 + '<select title="Priority"><option value="1">P4</option><option value="2">P3</option><option value="3">P2</option><option value="4">P1</option></select>' 1477 + '<button type="submit" onclick="submitQuickTask(this.parentElement)">Add</button>'; 1478 header.parentElement.insertBefore(form, header.nextElementSibling); 1479 } 1480 } catch (e) { 1481 btn.disabled = false; 1482 btn.textContent = '+ Todoist'; 1483 const errSpan = document.createElement('span'); 1484 errSpan.className = 'todoist-error'; 1485 errSpan.textContent = e.message; 1486 btn.parentElement.insertBefore(errSpan, btn.nextSibling); 1487 setTimeout(() => errSpan.remove(), 5000); 1488 } 1489 }; 1490 1491 window.toggleQuickAdd = function(btn) { 1492 const projectId = btn.dataset.projectId; 1493 // Find the quick-add form — it's a sibling of the header container 1494 const header = btn.closest('.ws-header, .parent-header'); 1495 if (!header) return; 1496 let form = header.nextElementSibling; 1497 while (form && !form.classList.contains('todoist-quick-add')) { 1498 form = form.nextElementSibling; 1499 } 1500 if (!form) return; 1501 form.classList.toggle('open'); 1502 if (form.classList.contains('open')) { 1503 const input = form.querySelector('input[type="text"]'); 1504 if (input) input.focus(); 1505 } 1506 }; 1507 1508 window.submitQuickTask = async function(form) { 1509 const input = form.querySelector('input[type="text"]'); 1510 const select = form.querySelector('select'); 1511 const submitBtn = form.querySelector('button[type="submit"]'); 1512 const content = input.value.trim(); 1513 if (!content) { input.focus(); return; } 1514 1515 // Remove any previous feedback 1516 const prevFeedback = form.querySelector('.todoist-error, .todoist-success'); 1517 if (prevFeedback) prevFeedback.remove(); 1518 1519 submitBtn.disabled = true; 1520 submitBtn.textContent = '...'; 1521 1522 try { 1523 const res = await fetch('/todoist/add-task', { 1524 method: 'POST', 1525 headers: { 'Content-Type': 'application/json' }, 1526 body: JSON.stringify({ 1527 project_id: form.dataset.projectId, 1528 content: content, 1529 priority: parseInt(select.value, 10), 1530 }), 1531 }); 1532 if (!res.ok) { 1533 const err = await res.json().catch(() => ({ detail: res.statusText })); 1534 throw new Error(err.detail || 'Failed'); 1535 } 1536 input.value = ''; 1537 const ok = document.createElement('span'); 1538 ok.className = 'todoist-success'; 1539 ok.textContent = 'Added!'; 1540 form.appendChild(ok); 1541 setTimeout(() => ok.remove(), 2000); 1542 } catch (e) { 1543 const errSpan = document.createElement('span'); 1544 errSpan.className = 'todoist-error'; 1545 errSpan.textContent = e.message; 1546 form.appendChild(errSpan); 1547 setTimeout(() => errSpan.remove(), 5000); 1548 } finally { 1549 submitBtn.disabled = false; 1550 submitBtn.textContent = 'Add'; 1551 } 1552 }; 1553 1554 // ---- Refresh ---- 1555 refreshBtn.addEventListener('click', function() { 1556 this.classList.add('refreshing'); 1557 window.location.reload(); 1558 }); 1559 1560 // ---- ?q= URL parameter ---- 1561 const params = new URLSearchParams(window.location.search); 1562 const initialQuery = params.get('q'); 1563 if (initialQuery) { 1564 searchInput.value = initialQuery; 1565 searchClear.classList.add('visible'); 1566 searchHint.style.display = 'none'; 1567 doSearch(initialQuery); 1568 } 1569 1570 // ================================================================== 1571 // Cross-browser tab transfer 1572 // ================================================================== 1573 1574 window.openTransferPicker = function(btn) { 1575 closeTransferPicker(); 1576 const sourceBrowser = btn.dataset.sourceBrowser; 1577 const tabUrl = btn.dataset.tabUrl; 1578 const tabTitle = btn.dataset.tabTitle; 1579 const tabPinned = btn.dataset.tabPinned === 'true'; 1580 1581 const overlay = document.createElement('div'); 1582 overlay.className = 'transfer-picker'; 1583 overlay.addEventListener('click', function(e) { 1584 if (e.target === overlay) closeTransferPicker(); 1585 }); 1586 1587 const panel = document.createElement('div'); 1588 panel.className = 'transfer-picker-content'; 1589 1590 const title = document.createElement('div'); 1591 title.className = 'transfer-picker-title'; 1592 title.textContent = 'Send tab to\u2026'; 1593 panel.appendChild(title); 1594 1595 let hasTargets = false; 1596 for (const [bk, workspaces] of Object.entries(_browserWorkspaces)) { 1597 if (bk === sourceBrowser) continue; 1598 if (!workspaces.length) continue; 1599 hasTargets = true; 1600 1601 const label = document.createElement('div'); 1602 label.className = 'transfer-browser-label'; 1603 label.textContent = bk; 1604 panel.appendChild(label); 1605 1606 for (const ws of workspaces) { 1607 const item = document.createElement('div'); 1608 item.className = 'transfer-ws-item'; 1609 item.innerHTML = '<span>' + (ws.name || 'Unnamed') + '</span>' + 1610 '<span class="ws-tab-count">' + ws.tab_count + ' tabs</span>'; 1611 item.addEventListener('click', function() { 1612 sendTabTransfer(sourceBrowser, bk, ws.id, ws.name, { 1613 url: tabUrl, title: tabTitle, pinned: tabPinned 1614 }, btn); 1615 }); 1616 panel.appendChild(item); 1617 } 1618 } 1619 1620 if (!hasTargets) { 1621 const empty = document.createElement('div'); 1622 empty.className = 'transfer-ws-item'; 1623 empty.style.color = 'var(--phosphor-dim)'; 1624 empty.textContent = 'No other browsers connected'; 1625 panel.appendChild(empty); 1626 } 1627 1628 // Position near button 1629 const rect = btn.getBoundingClientRect(); 1630 panel.style.top = Math.min(rect.bottom + 4, window.innerHeight - 380) + 'px'; 1631 panel.style.left = Math.min(rect.left, window.innerWidth - 290) + 'px'; 1632 1633 overlay.appendChild(panel); 1634 document.body.appendChild(overlay); 1635 1636 // Escape to close 1637 overlay._onKey = function(e) { if (e.key === 'Escape') closeTransferPicker(); }; 1638 document.addEventListener('keydown', overlay._onKey); 1639 }; 1640 1641 window.closeTransferPicker = function() { 1642 const existing = document.querySelector('.transfer-picker'); 1643 if (existing) { 1644 if (existing._onKey) document.removeEventListener('keydown', existing._onKey); 1645 existing.remove(); 1646 } 1647 }; 1648 1649 function sendTabTransfer(sourceBrowser, targetBrowser, targetWsId, targetWsName, tabData, btn) { 1650 closeTransferPicker(); 1651 fetch('/transfers', { 1652 method: 'POST', 1653 headers: { 'Content-Type': 'application/json' }, 1654 body: JSON.stringify({ 1655 source_browser: sourceBrowser, 1656 target_browser: targetBrowser, 1657 target_workspace_id: targetWsId, 1658 target_workspace_name: targetWsName, 1659 tab: tabData, 1660 }), 1661 }) 1662 .then(function(res) { 1663 if (!res.ok) throw new Error('HTTP ' + res.status); 1664 return res.json(); 1665 }) 1666 .then(function() { 1667 showSendStatus(btn, 'Sent!', 'ok'); 1668 }) 1669 .catch(function(err) { 1670 showSendStatus(btn, err.message || 'Error', 'err'); 1671 }); 1672 } 1673 1674 function showSendStatus(btn, text, cls) { 1675 const existing = btn.querySelector('.send-status'); 1676 if (existing) existing.remove(); 1677 const badge = document.createElement('span'); 1678 badge.className = 'send-status ' + cls; 1679 badge.textContent = text; 1680 btn.appendChild(badge); 1681 setTimeout(function() { badge.remove(); }, 3000); 1682 } 1683 1684 })(); 1685 </script> 1686 </body> 1687 </html>