Tools for the Atmosphere tools.slices.network
quickslice atproto html
at main 4409 lines 143 kB view raw
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 <meta 7 http-equiv="Content-Security-Policy" 8 content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com; style-src 'self' 'unsafe-inline'; connect-src 'self' https://quickslice-production-cc52.up.railway.app wss://quickslice-production-cc52.up.railway.app https://public.api.bsky.app https://unpkg.com; img-src 'self' https: data: blob:;" 9 /> 10 <title>🐛 Bug Tracker</title> 11 <style> 12 /* CSS Reset */ 13 *, 14 *::before, 15 *::after { 16 box-sizing: border-box; 17 } 18 * { 19 margin: 0; 20 } 21 body { 22 line-height: 1.5; 23 -webkit-font-smoothing: antialiased; 24 } 25 input, 26 button, 27 textarea, 28 select { 29 font: inherit; 30 } 31 32 /* Theme */ 33 :root { 34 --bg-primary: #fafaf9; 35 --bg-card: #ffffff; 36 --bg-hover: #f5f5f4; 37 --text-primary: #1c1917; 38 --text-secondary: #78716c; 39 --accent: #7c3aed; 40 --accent-hover: #6d28d9; 41 --border: #e7e5e4; 42 --error-bg: #fef2f2; 43 --error-border: #fecaca; 44 --error-text: #dc2626; 45 --severity-unusable: #dc2626; 46 --severity-broken: #ea580c; 47 --severity-annoying: #ca8a04; 48 --severity-cosmetic: #78716c; 49 } 50 51 body { 52 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 53 background: var(--bg-primary); 54 color: var(--text-primary); 55 min-height: 100vh; 56 } 57 58 #app { 59 max-width: 800px; 60 margin: 0 auto; 61 padding: 1rem; 62 } 63 64 /* Header */ 65 header { 66 display: flex; 67 justify-content: space-between; 68 align-items: center; 69 padding: 1rem 0; 70 margin-bottom: 1rem; 71 border-bottom: 1px solid var(--border); 72 } 73 74 header h1 { 75 font-size: 1.5rem; 76 font-weight: 700; 77 } 78 79 .breadcrumb { 80 display: flex; 81 align-items: center; 82 gap: 0.5rem; 83 } 84 85 .breadcrumb a { 86 color: var(--accent); 87 text-decoration: none; 88 } 89 90 .breadcrumb a:hover { 91 text-decoration: underline; 92 } 93 94 .user-status { 95 display: flex; 96 align-items: center; 97 gap: 0.75rem; 98 } 99 100 /* Buttons */ 101 .btn { 102 padding: 0.5rem 1rem; 103 border: none; 104 border-radius: 0.375rem; 105 font-size: 0.875rem; 106 font-weight: 500; 107 cursor: pointer; 108 transition: background-color 0.15s; 109 } 110 111 .btn-primary { 112 background: var(--accent); 113 color: white; 114 } 115 116 .btn-primary:hover { 117 background: var(--accent-hover); 118 } 119 120 .btn-primary:disabled { 121 opacity: 0.5; 122 cursor: not-allowed; 123 } 124 125 .btn-secondary { 126 background: var(--bg-card); 127 color: var(--text-primary); 128 border: 1px solid var(--border); 129 } 130 131 .btn-secondary:hover { 132 background: var(--bg-hover); 133 } 134 135 .btn-icon-text { 136 display: inline-flex; 137 align-items: center; 138 gap: 0.375rem; 139 } 140 141 .btn-danger { 142 background: #dc2626; 143 color: white; 144 border: none; 145 } 146 147 .btn-danger:hover { 148 background: #b91c1c; 149 } 150 151 .btn-icon { 152 background: none; 153 border: none; 154 cursor: pointer; 155 padding: 0.25rem 0.5rem; 156 font-size: 1rem; 157 line-height: 1; 158 border-radius: 0.25rem; 159 } 160 161 .btn-danger-text { 162 color: #dc2626; 163 } 164 165 .btn-danger-text:hover { 166 background: rgba(220, 38, 38, 0.1); 167 } 168 169 .btn-info { 170 width: 24px; 171 height: 24px; 172 padding: 0; 173 border-radius: 50%; 174 font-size: 0.875rem; 175 font-weight: 600; 176 background: var(--bg-hover); 177 color: var(--text-secondary); 178 border: 1px solid var(--border); 179 cursor: pointer; 180 display: inline-flex; 181 align-items: center; 182 justify-content: center; 183 } 184 185 .btn-info:hover { 186 background: var(--border); 187 color: var(--text-primary); 188 } 189 190 /* Cards */ 191 .card { 192 background: var(--bg-card); 193 border: 1px solid var(--border); 194 border-radius: 0.5rem; 195 padding: 1rem; 196 margin-bottom: 0.75rem; 197 cursor: pointer; 198 transition: 199 box-shadow 0.15s, 200 transform 0.15s; 201 } 202 203 .card:hover { 204 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); 205 transform: translateY(-1px); 206 } 207 208 /* Severity badges */ 209 .badge { 210 display: inline-block; 211 padding: 0.125rem 0.5rem; 212 border-radius: 9999px; 213 font-size: 0.75rem; 214 font-weight: 500; 215 text-transform: uppercase; 216 } 217 218 .badge-unusable { 219 background: var(--severity-unusable); 220 color: white; 221 } 222 .badge-broken { 223 background: var(--severity-broken); 224 color: white; 225 } 226 .badge-annoying { 227 background: var(--severity-annoying); 228 color: white; 229 } 230 .badge-cosmetic { 231 background: var(--severity-cosmetic); 232 color: white; 233 } 234 235 /* Status badges */ 236 .status-badge { 237 display: inline-block; 238 padding: 0.125rem 0.5rem; 239 border-radius: 0.25rem; 240 font-size: 0.75rem; 241 font-weight: 500; 242 } 243 244 .status-open { 245 background: #dbeafe; 246 color: #1d4ed8; 247 } 248 .status-inprogress { 249 background: #fef3c7; 250 color: #d97706; 251 } 252 .status-closed { 253 background: #dcfce7; 254 color: #16a34a; 255 } 256 .status-acknowledged { 257 background: #dbeafe; 258 color: #1d4ed8; 259 } 260 .status-fixed { 261 background: #dcfce7; 262 color: #16a34a; 263 } 264 .status-wontfix { 265 background: #f3f4f6; 266 color: #4b5563; 267 } 268 .status-duplicate { 269 background: #fef3c7; 270 color: #d97706; 271 } 272 .status-invalid { 273 background: #fee2e2; 274 color: #dc2626; 275 } 276 277 /* Hidden utility */ 278 .hidden { 279 display: none !important; 280 } 281 282 /* Facet links */ 283 .facet-link { 284 color: var(--accent); 285 text-decoration: underline; 286 } 287 288 .facet-link:hover { 289 color: var(--accent-hover); 290 } 291 292 .facet-bold { 293 font-weight: 600; 294 } 295 296 .facet-italic { 297 font-style: italic; 298 } 299 300 .facet-code { 301 font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 302 background: var(--bg-hover); 303 padding: 0.125rem 0.25rem; 304 border-radius: 0.25rem; 305 font-size: 0.875em; 306 } 307 308 .facet-codeblock { 309 font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; 310 background: var(--bg-hover); 311 padding: 1rem; 312 border-radius: 0.5rem; 313 font-size: 0.875em; 314 overflow-x: auto; 315 margin: 0.5rem 0; 316 white-space: pre; 317 } 318 319 .facet-codeblock code { 320 background: none; 321 padding: 0; 322 } 323 324 .form-hint { 325 font-size: 0.75rem; 326 color: var(--text-secondary); 327 margin-top: 0.25rem; 328 } 329 330 /* Loading spinner */ 331 .spinner { 332 width: 24px; 333 height: 24px; 334 border: 3px solid var(--border); 335 border-top-color: var(--accent); 336 border-radius: 50%; 337 animation: spin 0.8s linear infinite; 338 } 339 340 @keyframes spin { 341 to { 342 transform: rotate(360deg); 343 } 344 } 345 346 .loading-container { 347 display: flex; 348 flex-direction: column; 349 align-items: center; 350 gap: 0.75rem; 351 padding: 3rem; 352 color: var(--text-secondary); 353 } 354 355 /* Error banner */ 356 #error-banner { 357 position: fixed; 358 top: 1rem; 359 left: 50%; 360 transform: translateX(-50%); 361 background: var(--error-bg); 362 border: 1px solid var(--error-border); 363 color: var(--error-text); 364 padding: 0.75rem 1rem; 365 border-radius: 0.5rem; 366 display: flex; 367 align-items: center; 368 gap: 0.75rem; 369 max-width: 90%; 370 z-index: 100; 371 } 372 373 #error-banner button { 374 background: none; 375 border: none; 376 color: var(--error-text); 377 cursor: pointer; 378 font-size: 1.25rem; 379 line-height: 1; 380 } 381 382 /* Overlay (bug detail) */ 383 #overlay { 384 position: fixed; 385 top: 0; 386 right: 0; 387 bottom: 0; 388 width: 100%; 389 max-width: 600px; 390 background: var(--bg-card); 391 box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15); 392 z-index: 50; 393 overflow-y: auto; 394 transform: translateX(100%); 395 transition: transform 0.2s ease-out; 396 } 397 398 #overlay.open { 399 transform: translateX(0); 400 } 401 402 #overlay.hidden { 403 display: block !important; 404 transform: translateX(100%); 405 } 406 407 .overlay-backdrop { 408 position: fixed; 409 inset: 0; 410 background: rgba(0, 0, 0, 0.3); 411 z-index: 40; 412 } 413 414 .overlay-header { 415 display: flex; 416 justify-content: space-between; 417 align-items: flex-start; 418 padding: 1.5rem; 419 border-bottom: 1px solid var(--border); 420 position: sticky; 421 top: 0; 422 background: var(--bg-card); 423 } 424 425 .overlay-title { 426 font-size: 1.25rem; 427 font-weight: 600; 428 flex: 1; 429 margin-right: 1rem; 430 } 431 432 .overlay-close { 433 background: none; 434 border: none; 435 font-size: 1.5rem; 436 cursor: pointer; 437 color: var(--text-secondary); 438 padding: 0; 439 line-height: 1; 440 } 441 442 .overlay-close:hover { 443 color: var(--text-primary); 444 } 445 446 .overlay-actions { 447 display: flex; 448 align-items: center; 449 gap: 0.5rem; 450 } 451 452 .overlay-body { 453 padding: 1.5rem; 454 } 455 456 .overlay-section { 457 margin-bottom: 1.5rem; 458 } 459 460 .overlay-section h3 { 461 font-size: 0.75rem; 462 font-weight: 600; 463 text-transform: uppercase; 464 color: var(--text-secondary); 465 margin-bottom: 0.5rem; 466 } 467 468 .overlay-section p { 469 white-space: pre-wrap; 470 overflow-wrap: break-word; 471 word-break: break-word; 472 } 473 474 .attachment-gallery { 475 display: grid; 476 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 477 gap: 0.75rem; 478 margin-top: 0.5rem; 479 } 480 481 .attachment-image { 482 max-width: 100%; 483 height: auto; 484 border-radius: 0.5rem; 485 cursor: pointer; 486 transition: transform 0.15s; 487 } 488 489 .attachment-image:hover { 490 transform: scale(1.02); 491 } 492 493 .image-lightbox { 494 position: fixed; 495 inset: 0; 496 background: rgba(0, 0, 0, 0.9); 497 display: flex; 498 align-items: center; 499 justify-content: center; 500 z-index: 100; 501 cursor: pointer; 502 } 503 504 .image-lightbox img { 505 max-width: 90vw; 506 max-height: 90vh; 507 object-fit: contain; 508 } 509 510 /* Modal (submit bug) */ 511 #modal { 512 position: fixed; 513 inset: 0; 514 display: flex; 515 align-items: center; 516 justify-content: center; 517 z-index: 60; 518 padding: 1rem; 519 } 520 521 .modal-backdrop { 522 position: absolute; 523 inset: 0; 524 background: rgba(0, 0, 0, 0.5); 525 } 526 527 .modal-content { 528 position: relative; 529 background: var(--bg-card); 530 border-radius: 0.75rem; 531 width: 100%; 532 max-width: 500px; 533 max-height: 90vh; 534 overflow: visible; 535 box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); 536 } 537 538 .modal-content.scrollable { 539 overflow-y: auto; 540 } 541 542 .modal-header { 543 display: flex; 544 justify-content: space-between; 545 align-items: center; 546 padding: 1rem 1.5rem; 547 border-bottom: 1px solid var(--border); 548 } 549 550 .modal-header h2 { 551 font-size: 1.125rem; 552 font-weight: 600; 553 } 554 555 .modal-close { 556 background: none; 557 border: none; 558 font-size: 1.5rem; 559 cursor: pointer; 560 color: var(--text-secondary); 561 padding: 0; 562 line-height: 1; 563 } 564 565 .modal-body { 566 padding: 1.5rem; 567 overflow: visible; 568 } 569 570 /* Info modal */ 571 .info-section { 572 margin-bottom: 1.5rem; 573 } 574 575 .info-section:last-child { 576 margin-bottom: 0; 577 } 578 579 .info-section h3 { 580 font-size: 1rem; 581 font-weight: 600; 582 margin-bottom: 0.5rem; 583 } 584 585 .info-section p { 586 color: var(--text-secondary); 587 line-height: 1.6; 588 } 589 590 .info-section a { 591 color: var(--accent); 592 } 593 594 .info-section code { 595 background: var(--bg-hover); 596 padding: 0.125rem 0.375rem; 597 border-radius: 0.25rem; 598 font-size: 0.875rem; 599 } 600 601 .status-list { 602 list-style: none; 603 padding: 0; 604 margin: 0; 605 } 606 607 .status-list li { 608 display: flex; 609 align-items: center; 610 gap: 0.75rem; 611 padding: 0.375rem 0; 612 color: var(--text-secondary); 613 } 614 615 /* Forms */ 616 .form-group { 617 margin-bottom: 1rem; 618 } 619 620 .form-group label { 621 display: block; 622 font-size: 0.875rem; 623 font-weight: 500; 624 margin-bottom: 0.25rem; 625 } 626 627 .form-group .hint { 628 font-size: 0.75rem; 629 color: var(--text-secondary); 630 margin-bottom: 0.25rem; 631 } 632 633 .form-group input, 634 .form-group textarea, 635 .form-group select { 636 width: 100%; 637 padding: 0.5rem 0.75rem; 638 border: 1px solid var(--border); 639 border-radius: 0.375rem; 640 background: var(--bg-card); 641 } 642 643 .form-group input:focus, 644 .form-group textarea:focus, 645 .form-group select:focus { 646 outline: none; 647 border-color: var(--accent); 648 box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1); 649 } 650 651 .form-group textarea { 652 min-height: 100px; 653 resize: vertical; 654 } 655 656 .form-group .error { 657 color: var(--error-text); 658 font-size: 0.75rem; 659 margin-top: 0.25rem; 660 } 661 662 .form-group.has-error input, 663 .form-group.has-error textarea, 664 .form-group.has-error select { 665 border-color: var(--error-text); 666 } 667 668 .form-actions { 669 display: flex; 670 gap: 0.75rem; 671 justify-content: flex-end; 672 margin-top: 1.5rem; 673 } 674 675 /* Handle autocomplete */ 676 .handle-autocomplete { 677 position: relative; 678 } 679 680 .autocomplete-menu { 681 position: absolute; 682 top: 100%; 683 left: 0; 684 right: 0; 685 margin-top: 4px; 686 background: var(--bg-card); 687 border: 1px solid var(--border); 688 border-radius: 0.5rem; 689 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 690 max-height: 240px; 691 overflow-y: auto; 692 z-index: 100; 693 list-style: none; 694 padding: 4px; 695 } 696 697 .autocomplete-menu:empty { 698 display: none; 699 } 700 701 .autocomplete-item { 702 display: flex; 703 align-items: center; 704 gap: 8px; 705 padding: 8px; 706 border-radius: 0.375rem; 707 cursor: pointer; 708 } 709 710 .autocomplete-item:hover, 711 .autocomplete-item.active { 712 background: var(--bg-hover); 713 } 714 715 .autocomplete-avatar { 716 width: 32px; 717 height: 32px; 718 border-radius: 50%; 719 background: var(--border); 720 flex-shrink: 0; 721 overflow: hidden; 722 } 723 724 .autocomplete-avatar img { 725 width: 100%; 726 height: 100%; 727 object-fit: cover; 728 } 729 730 /* User avatars */ 731 .user-avatar { 732 width: 20px; 733 height: 20px; 734 border-radius: 50%; 735 background: var(--border); 736 flex-shrink: 0; 737 overflow: hidden; 738 display: inline-flex; 739 align-items: center; 740 justify-content: center; 741 } 742 743 .user-avatar img { 744 width: 100%; 745 height: 100%; 746 object-fit: cover; 747 } 748 749 .user-avatar:not(:has(img)) { 750 background: var(--accent); 751 color: white; 752 font-weight: 600; 753 font-size: 0.625em; 754 } 755 756 .user-avatar-sm { 757 width: 16px; 758 height: 16px; 759 } 760 761 .user-avatar-lg { 762 width: 24px; 763 height: 24px; 764 } 765 766 .user-avatar-xl { 767 width: 32px; 768 height: 32px; 769 } 770 771 .user-avatar-ring { 772 outline: 2px solid var(--border); 773 outline-offset: 1px; 774 } 775 776 .user-info { 777 display: inline-flex; 778 align-items: center; 779 gap: 0.375rem; 780 } 781 782 .autocomplete-handle { 783 overflow: hidden; 784 text-overflow: ellipsis; 785 white-space: nowrap; 786 } 787 788 /* Image upload */ 789 .image-upload { 790 border: 2px dashed var(--border); 791 border-radius: 0.5rem; 792 padding: 1.5rem; 793 text-align: center; 794 cursor: pointer; 795 transition: border-color 0.15s; 796 } 797 798 .image-upload:hover { 799 border-color: var(--accent); 800 } 801 802 .image-upload input { 803 display: none; 804 } 805 806 .image-previews { 807 display: flex; 808 gap: 0.5rem; 809 flex-wrap: wrap; 810 margin-top: 0.75rem; 811 padding: 0.5rem; 812 } 813 814 .image-preview { 815 position: relative; 816 } 817 818 .image-preview img { 819 max-width: 80px; 820 max-height: 80px; 821 object-fit: contain; 822 border-radius: 0.25rem; 823 } 824 825 .image-preview button { 826 position: absolute; 827 top: -0.5rem; 828 right: -0.5rem; 829 width: 1.5rem; 830 height: 1.5rem; 831 border-radius: 50%; 832 background: var(--error-text); 833 color: white; 834 border: none; 835 cursor: pointer; 836 font-size: 0.75rem; 837 line-height: 1; 838 } 839 840 /* Namespace grid */ 841 .namespace-grid { 842 display: grid; 843 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 844 gap: 0.75rem; 845 } 846 847 .namespace-card { 848 text-align: center; 849 padding: 1.5rem; 850 } 851 852 .namespace-name { 853 font-weight: 600; 854 font-size: 1rem; 855 margin-bottom: 0.25rem; 856 font-family: monospace; 857 } 858 859 .namespace-count { 860 color: var(--text-secondary); 861 font-size: 0.875rem; 862 } 863 864 /* Filter bar */ 865 .filter-bar { 866 display: flex; 867 justify-content: space-between; 868 align-items: center; 869 margin-bottom: 1rem; 870 gap: 1rem; 871 } 872 873 .filter-group { 874 display: flex; 875 gap: 0.5rem; 876 } 877 878 .filter-group select { 879 padding: 0.5rem 0.75rem; 880 border: 1px solid var(--border); 881 border-radius: 0.375rem; 882 background: var(--bg-card); 883 } 884 885 /* Bug list */ 886 .bug-list { 887 display: flex; 888 flex-direction: column; 889 } 890 891 .bug-card { 892 display: flex; 893 flex-direction: column; 894 gap: 0.25rem; 895 } 896 897 .bug-card-header { 898 display: flex; 899 align-items: center; 900 gap: 0.75rem; 901 } 902 903 .bug-card-title { 904 font-weight: 500; 905 flex: 1; 906 overflow: hidden; 907 text-overflow: ellipsis; 908 white-space: nowrap; 909 } 910 911 .bug-card-meta { 912 display: flex; 913 align-items: center; 914 gap: 0.25rem; 915 color: var(--text-secondary); 916 font-size: 0.875rem; 917 } 918 919 .comment-count { 920 color: var(--text-secondary); 921 font-size: 0.75rem; 922 display: inline-flex; 923 align-items: center; 924 gap: 0.25rem; 925 margin-left: 0.5rem; 926 } 927 928 .comment-count svg { 929 width: 14px; 930 height: 14px; 931 } 932 933 .btn-icon svg { 934 width: 16px; 935 height: 16px; 936 } 937 938 .load-more { 939 text-align: center; 940 padding: 1rem; 941 } 942 943 /* Overlay meta */ 944 .overlay-meta { 945 display: flex; 946 align-items: center; 947 gap: 0.5rem; 948 color: var(--text-secondary); 949 font-size: 0.875rem; 950 margin-bottom: 1.5rem; 951 } 952 953 .text-secondary { 954 color: var(--text-secondary); 955 } 956 957 /* Response list */ 958 .response-list { 959 display: flex; 960 flex-direction: column; 961 gap: 0.75rem; 962 margin-top: 0.75rem; 963 } 964 965 .response-card { 966 background: var(--bg-hover); 967 padding: 0.75rem; 968 border-radius: 0.375rem; 969 } 970 971 .response-header { 972 display: flex; 973 align-items: center; 974 gap: 0.75rem; 975 margin-bottom: 0.25rem; 976 } 977 978 .response-meta { 979 color: var(--text-secondary); 980 font-size: 0.75rem; 981 } 982 983 .response-message { 984 font-size: 0.875rem; 985 white-space: pre-wrap; 986 overflow-wrap: break-word; 987 word-break: break-word; 988 } 989 990 /* Comments */ 991 .comment-card { 992 background: var(--bg-hover); 993 border-radius: 0.5rem; 994 padding: 0.75rem 1rem; 995 margin-bottom: 0.75rem; 996 } 997 998 .comment-header { 999 display: flex; 1000 justify-content: space-between; 1001 align-items: center; 1002 margin-bottom: 0.5rem; 1003 } 1004 1005 .comment-meta { 1006 display: flex; 1007 align-items: center; 1008 gap: 0.25rem; 1009 font-size: 0.75rem; 1010 color: var(--text-secondary); 1011 } 1012 1013 .comment-body { 1014 white-space: pre-wrap; 1015 word-break: break-word; 1016 } 1017 1018 .comment-actions { 1019 display: flex; 1020 gap: 0.5rem; 1021 margin-top: 0.5rem; 1022 } 1023 1024 .comment-replies { 1025 margin-left: 1.5rem; 1026 padding-left: 0.75rem; 1027 border-left: 2px solid var(--border); 1028 } 1029 1030 .comment-form-inline { 1031 margin-top: 0.75rem; 1032 padding-top: 0.75rem; 1033 border-top: 1px solid var(--border); 1034 } 1035 1036 .comment-form-inline textarea { 1037 width: 100%; 1038 min-height: 80px; 1039 padding: 0.5rem 0.75rem; 1040 border: 1px solid var(--border); 1041 border-radius: 0.375rem; 1042 background: var(--bg-card); 1043 resize: vertical; 1044 font-family: inherit; 1045 margin-bottom: 0.5rem; 1046 } 1047 1048 .comment-form-inline textarea:focus { 1049 outline: none; 1050 border-color: var(--accent); 1051 box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1); 1052 } 1053 1054 .comment-form-actions { 1055 display: flex; 1056 gap: 0.5rem; 1057 justify-content: flex-end; 1058 } 1059 1060 /* Linked issues */ 1061 .linked-issues-list { 1062 display: flex; 1063 flex-direction: column; 1064 gap: 0.5rem; 1065 margin-top: 0.5rem; 1066 } 1067 1068 .linked-issues-list .card { 1069 padding: 0.75rem; 1070 margin-bottom: 0; 1071 } 1072 1073 .linked-issues-list .card:hover { 1074 transform: none; 1075 box-shadow: none; 1076 } 1077 1078 .linked-issues-list a { 1079 color: var(--accent); 1080 text-decoration: none; 1081 } 1082 1083 .linked-issues-list a:hover { 1084 text-decoration: underline; 1085 } 1086 1087 /* Repo and issue lists in modal */ 1088 .repo-list, 1089 .issue-list { 1090 display: flex; 1091 flex-direction: column; 1092 gap: 0.5rem; 1093 } 1094 1095 .repo-list .card, 1096 .issue-list .card { 1097 margin-bottom: 0; 1098 } 1099 1100 /* ============================================================================= 1101 MOBILE RESPONSIVE STYLES 1102 ============================================================================= */ 1103 @media (max-width: 640px) { 1104 /* Header - stack vertically */ 1105 header { 1106 flex-direction: column; 1107 gap: 0.75rem; 1108 text-align: center; 1109 } 1110 1111 .breadcrumb { 1112 justify-content: center; 1113 flex-wrap: wrap; 1114 } 1115 1116 .user-status { 1117 justify-content: center; 1118 } 1119 1120 /* Filter bar - stack vertically */ 1121 .filter-bar { 1122 flex-direction: column; 1123 align-items: stretch; 1124 } 1125 1126 .filter-group { 1127 width: 100%; 1128 } 1129 1130 .filter-group select { 1131 width: 100%; 1132 } 1133 1134 .filter-bar > .btn-primary { 1135 width: 100%; 1136 } 1137 1138 /* Touch targets - minimum 44px */ 1139 .btn { 1140 padding: 0.75rem 1rem; 1141 min-height: 44px; 1142 } 1143 1144 .btn-info { 1145 width: 44px; 1146 height: 44px; 1147 font-size: 1rem; 1148 } 1149 1150 .btn-icon { 1151 min-width: 44px; 1152 min-height: 44px; 1153 } 1154 1155 .overlay-close, 1156 .modal-close { 1157 width: 44px; 1158 height: 44px; 1159 display: flex; 1160 align-items: center; 1161 justify-content: center; 1162 } 1163 1164 /* Overlay - full screen takeover */ 1165 #overlay { 1166 max-width: 100%; 1167 width: 100%; 1168 box-shadow: none; 1169 } 1170 1171 .overlay-header { 1172 position: relative; 1173 padding-right: 3.5rem; 1174 } 1175 1176 .overlay-header > .overlay-close { 1177 position: absolute; 1178 top: 1rem; 1179 right: 1rem; 1180 } 1181 1182 .overlay-actions { 1183 flex-wrap: wrap; 1184 } 1185 1186 /* Modal - nearly full screen */ 1187 #modal { 1188 padding: 0.5rem; 1189 } 1190 1191 .modal-content { 1192 max-width: 100%; 1193 width: 100%; 1194 max-height: 100%; 1195 border-radius: 0.5rem; 1196 } 1197 1198 .modal-body { 1199 padding: 1rem; 1200 } 1201 1202 .form-actions { 1203 flex-direction: column-reverse; 1204 } 1205 1206 .form-actions .btn { 1207 width: 100%; 1208 } 1209 1210 .image-upload { 1211 padding: 2rem 1rem; 1212 } 1213 } 1214 </style> 1215 </head> 1216 <body> 1217 <div id="app"> 1218 <header id="header"></header> 1219 <main id="main"></main> 1220 </div> 1221 <div id="overlay" class="hidden"></div> 1222 <div id="modal" class="hidden"></div> 1223 <div id="error-banner" class="hidden"></div> 1224 1225 <!-- Quickslice Client SDK --> 1226 <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script> 1227 1228 <!-- Lucide Icons --> 1229 <script src="https://unpkg.com/lucide@latest"></script> 1230 1231 <script type="module"> 1232 import { parseFacets, renderFacetedText } from './richtext.js'; 1233 1234 // Configuration 1235 const SERVER_URL = "https://quickslice-production-cc52.up.railway.app"; 1236 const CLIENT_ID = "client_RYT1cM7os1OMBtLNFQVlbw"; 1237 const PAGE_SIZE = 20; 1238 1239 // State 1240 const state = { 1241 view: "landing", // "landing" | "list" | "detail" 1242 namespace: null, 1243 bugUri: null, 1244 bugs: [], 1245 namespaces: [], 1246 responses: [], 1247 comments: [], 1248 replyingTo: null, 1249 editingComment: null, 1250 editCommentAttachments: [], 1251 commentImages: [], 1252 cursor: null, 1253 hasMore: true, 1254 isLoading: false, 1255 viewer: null, 1256 filters: { severity: null, status: "open" }, 1257 editingBug: null, 1258 existingAttachments: [], // Track which existing attachments to keep when editing 1259 confirmedNamespace: false, // Track if user confirmed a domain-like namespace 1260 handleSuggestions: [], // Handle autocomplete suggestions 1261 handleSuggestionIndex: -1, // Currently selected suggestion 1262 linkIssueState: { 1263 bugUri: null, 1264 step: "repos", // "repos" | "issues" 1265 repos: [], 1266 selectedRepo: null, 1267 issues: [], 1268 isLoading: false, 1269 }, 1270 }; 1271 1272 // ============================================================================= 1273 // HELPERS 1274 // ============================================================================= 1275 1276 function tangledIcon(size = 16) { 1277 return `<svg width="${size}" height="${size}" viewBox="0 0 25 25" fill="currentColor"><path d="m 16.35,24.11 c -0.79,-0.01 -1.38,-0.23 -2.03,-0.63 -0.93,-0.49 -1.64,-1.31 -2.15,-2.22 -0.81,1 -1.89,1.61 -3.1,1.95 -0.51,0.15 -1.41,0.3 -2.91,-0.24 -2.15,-0.72 -3.72,-2.97 -3.54,-5.25 -0.03,-0.95 0.31,-1.88 0.8,-2.67 -1.31,-0.7 -2.37,-1.88 -2.78,-3.32 -0.25,-0.79 -0.24,-1.64 -0.15,-2.45 0.33,-1.92 1.77,-3.58 3.62,-4.18 0.74,-1.68 2.35,-2.94 4.18,-3.19 1.21,-0.17 2.47,0.08 3.53,0.7 1.54,-1.71 4.24,-2.22 6.29,-1.17 1.57,0.75 2.69,2.31 2.96,4.02 1.49,0.6 2.75,1.82 3.24,3.36 0.33,0.96 0.34,2.01 0.13,3 -0.38,1.54 -1.47,2.84 -2.87,3.56 0,0.27 0.9,2.24 0.75,3.73 -0.03,1.86 -1.21,3.62 -2.85,4.48 -0.95,0.56 -2.08,0.55 -3.12,0.54 z m -4.47,-5.35 c 1.32,-0.15 2.19,-1.3 2.86,-2.34 0.32,-0.47 0.56,-1 0.8,-1.51 0.31,0.29 0.58,0.83 1.07,0.96 0.52,0.16 1.13,0.03 1.45,-0.44 0.61,-1.14 0.31,-2.52 -0.05,-3.7 -0.22,-0.68 -0.5,-1.38 -1.05,-1.86 0.12,-0.82 -0.37,-1.66 -1.06,-2.09 -0.59,0.47 -1.49,0.47 -2.06,-0.03 -1.09,1.11 -2.09,1.08 -3.06,0.19 -0.22,-0.2 -0.63,1.21 -2.09,0.41 -0.84,0.7 -1.48,1.38 -2.06,2.35 -0.56,1.05 -1.14,1.98 -1.19,3.11 -0.02,0.66 0.49,1.36 1.2,1.31 0.7,0.06 1.18,-0.63 1.71,-0.92 0.08,0.93 0.17,1.92 0.48,2.83 0.36,1.17 1.63,1.92 2.83,1.75 0.08,-0.01 0.22,-0.02 0.22,-0.02 z m 0.69,-3.5 c -0.64,-0.39 -0.33,-1.25 -0.36,-1.87 0.06,-0.75 0.12,-1.54 0.45,-2.22 0.36,-0.49 1.23,-0.3 1.27,0.33 -0.03,0.63 -0.31,1.25 -0.28,1.91 -0.07,0.54 0.05,1.15 -0.19,1.65 -0.2,0.28 -0.6,0.36 -0.89,0.21 z m -2.81,-0.36 c -0.61,-0.33 -0.41,-1.16 -0.51,-1.73 0.08,-0.67 0.01,-1.51 0.57,-1.98 0.55,-0.38 1.29,0.27 1.03,0.87 -0.27,0.76 -0.09,1.58 -0.09,2.35 -0.1,0.45 -0.59,0.69 -1,0.49 z" transform="translate(-0.43,-0.88)"/></svg>`; 1278 } 1279 1280 function esc(str) { 1281 if (!str) return ""; 1282 const d = document.createElement("div"); 1283 d.textContent = str; 1284 return d.innerHTML; 1285 } 1286 1287 // Image resize utilities 1288 function readFileAsDataURL(file) { 1289 return new Promise((resolve, reject) => { 1290 const reader = new FileReader(); 1291 reader.onload = () => resolve(reader.result); 1292 reader.onerror = reject; 1293 reader.readAsDataURL(file); 1294 }); 1295 } 1296 1297 function getDataUrlSize(dataUrl) { 1298 const base64 = dataUrl.split(",")[1]; 1299 return Math.ceil((base64.length * 3) / 4); 1300 } 1301 1302 function createResizedImage(dataUrl, options) { 1303 return new Promise((resolve, reject) => { 1304 const img = new Image(); 1305 img.onload = () => { 1306 let scale; 1307 if (options.mode === "cover") { 1308 scale = Math.max(options.width / img.width, options.height / img.height); 1309 } else if (options.mode === "contain") { 1310 scale = Math.min(options.width / img.width, options.height / img.height); 1311 } else { 1312 scale = 1; 1313 } 1314 1315 // Don't upscale 1316 scale = Math.min(scale, 1); 1317 1318 const w = Math.round(img.width * scale); 1319 const h = Math.round(img.height * scale); 1320 1321 const canvas = document.createElement("canvas"); 1322 canvas.width = w; 1323 canvas.height = h; 1324 1325 const ctx = canvas.getContext("2d"); 1326 if (!ctx) return reject(new Error("Failed to get canvas context")); 1327 1328 ctx.fillStyle = "#fff"; 1329 ctx.fillRect(0, 0, w, h); 1330 ctx.imageSmoothingEnabled = true; 1331 ctx.imageSmoothingQuality = "high"; 1332 ctx.drawImage(img, 0, 0, w, h); 1333 1334 resolve({ 1335 dataUrl: canvas.toDataURL("image/jpeg", options.quality), 1336 width: w, 1337 height: h, 1338 }); 1339 }; 1340 img.onerror = (e) => reject(e); 1341 img.src = dataUrl; 1342 }); 1343 } 1344 1345 async function resizeImage(dataUrl, opts) { 1346 // Binary search for optimal quality 1347 let bestResult = null; 1348 let minQuality = 0; 1349 let maxQuality = 101; 1350 1351 while (maxQuality - minQuality > 1) { 1352 const quality = Math.round((minQuality + maxQuality) / 2); 1353 const result = await createResizedImage(dataUrl, { 1354 width: opts.width, 1355 height: opts.height, 1356 quality: quality / 100, 1357 mode: opts.mode, 1358 }); 1359 1360 const size = getDataUrlSize(result.dataUrl); 1361 1362 if (size < opts.maxSize) { 1363 minQuality = quality; 1364 bestResult = result; 1365 } else { 1366 maxQuality = quality; 1367 } 1368 } 1369 1370 if (!bestResult) { 1371 throw new Error("Failed to compress image within size limit"); 1372 } 1373 1374 return bestResult; 1375 } 1376 1377 function formatTime(iso) { 1378 const d = new Date(iso); 1379 const now = new Date(); 1380 const diff = Math.floor((now - d) / 1000); 1381 1382 if (diff < 60) return "just now"; 1383 if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; 1384 if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; 1385 if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; 1386 1387 return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); 1388 } 1389 1390 function renderAvatar(profile, handle, sizeClass = "") { 1391 const avatarUrl = profile?.avatar?.url; 1392 const className = `user-avatar${sizeClass ? ` ${sizeClass}` : ""}`; 1393 if (avatarUrl) { 1394 return `<span class="${className}"><img src="${esc(avatarUrl)}" alt=""></span>`; 1395 } 1396 const initial = handle ? handle.charAt(0).toUpperCase() : "?"; 1397 return `<span class="${className}">${esc(initial)}</span>`; 1398 } 1399 1400 function showError(msg) { 1401 const el = document.getElementById("error-banner"); 1402 el.innerHTML = `<span>${esc(msg)}</span><button onclick="BugsApp.hideError()">×</button>`; 1403 el.classList.remove("hidden"); 1404 } 1405 1406 function hideError() { 1407 document.getElementById("error-banner").classList.add("hidden"); 1408 } 1409 1410 function validateNamespace(ns) { 1411 return /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/.test(ns); 1412 } 1413 1414 // Common TLDs to detect if user entered a domain instead of reverse-domain 1415 const COMMON_TLDS = new Set([ 1416 // Generic TLDs 1417 "com", 1418 "org", 1419 "net", 1420 "info", 1421 "biz", 1422 "name", 1423 "pro", 1424 // Popular new gTLDs 1425 "app", 1426 "dev", 1427 "io", 1428 "co", 1429 "ai", 1430 "xyz", 1431 "online", 1432 "site", 1433 "tech", 1434 "cloud", 1435 "social", 1436 "club", 1437 "blog", 1438 "shop", 1439 "store", 1440 "news", 1441 "live", 1442 "media", 1443 "email", 1444 "digital", 1445 "network", 1446 "systems", 1447 "solutions", 1448 "services", 1449 "software", 1450 "games", 1451 // Country codes (most common) 1452 "uk", 1453 "de", 1454 "fr", 1455 "nl", 1456 "eu", 1457 "ru", 1458 "cn", 1459 "jp", 1460 "kr", 1461 "au", 1462 "ca", 1463 "br", 1464 "it", 1465 "es", 1466 "pl", 1467 "ch", 1468 "at", 1469 "be", 1470 "se", 1471 "no", 1472 "dk", 1473 "fi", 1474 "ie", 1475 "nz", 1476 "in", 1477 "mx", 1478 "ar", 1479 "za", 1480 "sg", 1481 "hk", 1482 "tw", 1483 "id", 1484 "th", 1485 "my", 1486 "ph", 1487 "vn", 1488 // AT Protocol ecosystem 1489 "blue", 1490 "fm", 1491 "tv", 1492 "gg", 1493 "me", 1494 "us", 1495 ]); 1496 1497 function looksLikeDomain(ns) { 1498 const parts = ns.split("."); 1499 if (parts.length < 2) return null; 1500 const lastPart = parts[parts.length - 1]; 1501 if (COMMON_TLDS.has(lastPart)) { 1502 // Suggest the reversed version 1503 return parts.reverse().join("."); 1504 } 1505 return null; 1506 } 1507 1508 function useSuggestedNamespace(suggested) { 1509 document.getElementById("bug-namespace").value = suggested; 1510 document.getElementById("namespace-error").innerHTML = ""; 1511 document.getElementById("bug-namespace").parentElement.classList.remove("has-error"); 1512 state.confirmedNamespace = false; 1513 } 1514 1515 function confirmNamespace() { 1516 state.confirmedNamespace = true; 1517 document.getElementById("namespace-error").innerHTML = ""; 1518 document.getElementById("bug-namespace").parentElement.classList.remove("has-error"); 1519 // Re-trigger form submission 1520 document.getElementById("bug-form").dispatchEvent(new Event("submit")); 1521 } 1522 1523 function validateNamespaceOnBlur() { 1524 const input = document.getElementById("bug-namespace"); 1525 const namespace = input.value.trim().toLowerCase(); 1526 const errorEl = document.getElementById("namespace-error"); 1527 1528 // Clear previous errors 1529 errorEl.innerHTML = ""; 1530 input.parentElement.classList.remove("has-error"); 1531 1532 if (!namespace) return; // Don't validate empty field on blur 1533 1534 // Check format 1535 if (!validateNamespace(namespace)) { 1536 errorEl.textContent = "Invalid format. Use: word.word (e.g., social.grain)"; 1537 input.parentElement.classList.add("has-error"); 1538 return; 1539 } 1540 1541 // Check if it looks like a domain 1542 const suggested = looksLikeDomain(namespace); 1543 if (suggested) { 1544 errorEl.innerHTML = ` 1545 This looks like a domain. Did you mean <strong>${esc(suggested)}</strong>? 1546 <div style="margin-top: 0.5rem;"> 1547 <button type="button" class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.875rem;" onclick="BugsApp.useSuggestedNamespace('${esc(suggested)}')">Use ${esc(suggested)}</button> 1548 <button type="button" class="btn btn-secondary" style="margin-left: 0.25rem; padding: 0.25rem 0.5rem; font-size: 0.875rem;" onclick="BugsApp.dismissNamespaceSuggestion()">Keep as-is</button> 1549 </div> 1550 `; 1551 input.parentElement.classList.add("has-error"); 1552 } 1553 } 1554 1555 function dismissNamespaceSuggestion() { 1556 state.confirmedNamespace = true; 1557 document.getElementById("namespace-error").innerHTML = ""; 1558 document.getElementById("bug-namespace").parentElement.classList.remove("has-error"); 1559 } 1560 1561 function getSeverityClass(severity) { 1562 return `badge-${severity}`; 1563 } 1564 1565 function getStatusClass(status) { 1566 return `status-${status}`; 1567 } 1568 1569 // Derive bug display status from responses 1570 // Priority: fixed > wontfix > duplicate > invalid > acknowledged > (none = open) 1571 const STATUS_DISPLAY = { 1572 open: { label: "Open", cssClass: "status-open" }, 1573 acknowledged: { label: "In Progress", cssClass: "status-inprogress" }, 1574 fixed: { label: "Closed - Fixed", cssClass: "status-closed" }, 1575 wontfix: { label: "Closed - Won't Fix", cssClass: "status-wontfix" }, 1576 duplicate: { label: "Closed - Duplicate", cssClass: "status-duplicate" }, 1577 invalid: { label: "Closed - Invalid", cssClass: "status-invalid" }, 1578 }; 1579 1580 const STATUS_PRIORITY = ["fixed", "wontfix", "duplicate", "invalid", "acknowledged"]; 1581 1582 function getBugDisplayStatus(bug) { 1583 const responses = bug.networkSlicesToolsBugResponseViaBug?.edges?.map((e) => e.node) || []; 1584 if (responses.length === 0) { 1585 return STATUS_DISPLAY.open; 1586 } 1587 // Find highest priority status from all responses 1588 for (const status of STATUS_PRIORITY) { 1589 if (responses.some((r) => r.status === status)) { 1590 return STATUS_DISPLAY[status]; 1591 } 1592 } 1593 return STATUS_DISPLAY.open; 1594 } 1595 1596 function updateUrl(params) { 1597 const url = new URL(window.location); 1598 Object.entries(params).forEach(([key, value]) => { 1599 if (value === null) { 1600 url.searchParams.delete(key); 1601 } else { 1602 url.searchParams.set(key, value); 1603 } 1604 }); 1605 window.history.pushState({}, "", url); 1606 } 1607 1608 function canRespond(bugAuthorDid, bugNamespace) { 1609 if (!state.viewer) return false; 1610 // Only the domain authority can respond 1611 // Namespace: "social.grain" -> required handle: "grain.social" (exact match) 1612 if (state.viewer.handle && bugNamespace) { 1613 const namespaceDomain = bugNamespace.split(".").reverse().join("."); 1614 if (state.viewer.handle === namespaceDomain) return true; 1615 } 1616 return false; 1617 } 1618 1619 function isAuthor(bugAuthorDid) { 1620 return state.viewer && state.viewer.did === bugAuthorDid; 1621 } 1622 1623 function getRkeyFromUri(uri) { 1624 // URI format: at://did:plc:xxx/collection/rkey 1625 return uri.split("/").pop(); 1626 } 1627 1628 // ============================================================================= 1629 // GRAPHQL 1630 // ============================================================================= 1631 1632 const NAMESPACES_QUERY = ` 1633 query { 1634 networkSlicesToolsBugAggregated( 1635 groupBy: [{ field: namespace }] 1636 orderBy: { direction: DESC } 1637 ) { 1638 namespace 1639 count 1640 } 1641 } 1642 `; 1643 1644 const BUGS_QUERY = ` 1645 query GetBugs($first: Int!, $after: String, $namespace: String) { 1646 networkSlicesToolsBug( 1647 where: { namespace: { eq: $namespace } } 1648 sortBy: [{ field: createdAt, direction: DESC }] 1649 first: $first 1650 after: $after 1651 ) { 1652 edges { 1653 node { 1654 uri 1655 did 1656 title 1657 description 1658 descriptionFacets { 1659 index { byteStart byteEnd } 1660 features { 1661 __typename 1662 ... on AppBskyRichtextFacetLink { uri } 1663 ... on NetworkSlicesToolsRichtextFacetLink { uri } 1664 } 1665 } 1666 stepsToReproduce 1667 stepsToReproduceFacets { 1668 index { byteStart byteEnd } 1669 features { 1670 __typename 1671 ... on AppBskyRichtextFacetLink { uri } 1672 ... on NetworkSlicesToolsRichtextFacetLink { uri } 1673 } 1674 } 1675 severity 1676 appUsed 1677 namespace 1678 createdAt 1679 actorHandle 1680 appBskyActorProfileByDid { 1681 avatar { 1682 url(preset: "avatar") 1683 } 1684 } 1685 attachments { 1686 ... on NetworkSlicesToolsDefsImages { 1687 images { 1688 alt 1689 image { 1690 ref 1691 mimeType 1692 size 1693 url(preset: "feed_fullsize") 1694 } 1695 } 1696 } 1697 } 1698 networkSlicesToolsBugResponseViaBug { 1699 edges { 1700 node { 1701 status 1702 createdAt 1703 } 1704 } 1705 } 1706 networkSlicesToolsBugIssueViaBug { 1707 edges { 1708 node { 1709 uri 1710 issue 1711 issueResolved { 1712 ... on ShTangledRepoIssue { 1713 uri 1714 title 1715 repo 1716 repoResolved { 1717 ... on ShTangledRepo { 1718 name 1719 actorHandle 1720 } 1721 } 1722 } 1723 } 1724 } 1725 } 1726 } 1727 networkSlicesToolsBugCommentViaBug(where: { parent: { isNull: true } }) { 1728 totalCount 1729 } 1730 } 1731 } 1732 pageInfo { 1733 hasNextPage 1734 endCursor 1735 } 1736 } 1737 } 1738 `; 1739 1740 const RESPONSES_QUERY = ` 1741 query GetResponses($bugUri: String!) { 1742 networkSlicesToolsBugResponse( 1743 where: { bug: { eq: $bugUri } } 1744 sortBy: [{ field: createdAt, direction: ASC }] 1745 ) { 1746 edges { 1747 node { 1748 uri 1749 did 1750 status 1751 message 1752 messageFacets { 1753 index { byteStart byteEnd } 1754 features { 1755 __typename 1756 ... on AppBskyRichtextFacetLink { uri } 1757 ... on NetworkSlicesToolsRichtextFacetLink { uri } 1758 } 1759 } 1760 actorHandle 1761 appBskyActorProfileByDid { 1762 avatar { 1763 url(preset: "avatar") 1764 } 1765 } 1766 createdAt 1767 } 1768 } 1769 } 1770 } 1771 `; 1772 1773 const CREATE_BUG_MUTATION = ` 1774 mutation CreateBug($input: NetworkSlicesToolsBugInput!) { 1775 createNetworkSlicesToolsBug(input: $input) { 1776 uri 1777 } 1778 } 1779 `; 1780 1781 const UPLOAD_BLOB_MUTATION = ` 1782 mutation UploadBlob($data: String!, $mimeType: String!) { 1783 uploadBlob(data: $data, mimeType: $mimeType) { 1784 ref 1785 mimeType 1786 size 1787 } 1788 } 1789 `; 1790 1791 const CREATE_RESPONSE_MUTATION = ` 1792 mutation CreateResponse($input: NetworkSlicesToolsBugResponseInput!) { 1793 createNetworkSlicesToolsBugResponse(input: $input) { 1794 uri 1795 } 1796 } 1797 `; 1798 1799 const COMMENTS_QUERY = ` 1800 query GetComments($bugUri: String!) { 1801 networkSlicesToolsBugComment( 1802 where: { bug: { eq: $bugUri } } 1803 sortBy: [{ field: createdAt, direction: ASC }] 1804 ) { 1805 edges { 1806 node { 1807 uri 1808 did 1809 actorHandle 1810 appBskyActorProfileByDid { 1811 avatar { 1812 url(preset: "avatar") 1813 } 1814 } 1815 body 1816 bodyFacets { 1817 index { byteStart byteEnd } 1818 features { 1819 __typename 1820 ... on AppBskyRichtextFacetLink { uri } 1821 ... on NetworkSlicesToolsRichtextFacetLink { uri } 1822 } 1823 } 1824 parent 1825 attachments { 1826 ... on NetworkSlicesToolsDefsImages { 1827 images { 1828 alt 1829 image { 1830 ref 1831 mimeType 1832 size 1833 url 1834 } 1835 } 1836 } 1837 } 1838 createdAt 1839 } 1840 } 1841 } 1842 } 1843 `; 1844 1845 const CREATE_COMMENT_MUTATION = ` 1846 mutation CreateComment($input: NetworkSlicesToolsBugCommentInput!) { 1847 createNetworkSlicesToolsBugComment(input: $input) { 1848 uri 1849 } 1850 } 1851 `; 1852 1853 const UPDATE_COMMENT_MUTATION = ` 1854 mutation UpdateComment($rkey: String!, $input: NetworkSlicesToolsBugCommentInput!) { 1855 updateNetworkSlicesToolsBugComment(rkey: $rkey, input: $input) { 1856 uri 1857 } 1858 } 1859 `; 1860 1861 const DELETE_COMMENT_MUTATION = ` 1862 mutation DeleteComment($rkey: String!) { 1863 deleteNetworkSlicesToolsBugComment(rkey: $rkey) { 1864 uri 1865 } 1866 } 1867 `; 1868 1869 const UPDATE_BUG_MUTATION = ` 1870 mutation UpdateBug($rkey: String!, $input: NetworkSlicesToolsBugInput!) { 1871 updateNetworkSlicesToolsBug(rkey: $rkey, input: $input) { 1872 uri 1873 } 1874 } 1875 `; 1876 1877 const DELETE_BUG_MUTATION = ` 1878 mutation DeleteBug($rkey: String!) { 1879 deleteNetworkSlicesToolsBug(rkey: $rkey) { 1880 uri 1881 } 1882 } 1883 `; 1884 1885 const DELETE_RESPONSE_MUTATION = ` 1886 mutation DeleteResponse($rkey: String!) { 1887 deleteNetworkSlicesToolsBugResponse(rkey: $rkey) { 1888 uri 1889 } 1890 } 1891 `; 1892 1893 const VIEWER_REPOS_QUERY = ` 1894 query { 1895 viewer { 1896 shTangledRepoByDid(sortBy: [{ field: createdAt, direction: DESC }]) { 1897 edges { 1898 node { 1899 uri 1900 name 1901 description 1902 actorHandle 1903 } 1904 } 1905 } 1906 } 1907 } 1908 `; 1909 1910 const REPO_ISSUES_QUERY = ` 1911 query GetRepoIssues($repoUri: String!) { 1912 shTangledRepoIssue( 1913 where: { repo: { eq: $repoUri } } 1914 sortBy: [{ field: createdAt, direction: DESC }] 1915 ) { 1916 edges { 1917 node { 1918 uri 1919 title 1920 createdAt 1921 } 1922 } 1923 } 1924 } 1925 `; 1926 1927 const CREATE_TANGLED_ISSUE_MUTATION = ` 1928 mutation CreateTangledIssue($input: ShTangledRepoIssueInput!) { 1929 createShTangledRepoIssue(input: $input) { 1930 uri 1931 } 1932 } 1933 `; 1934 1935 const CREATE_BUG_ISSUE_MUTATION = ` 1936 mutation CreateBugIssue($input: NetworkSlicesToolsBugIssueInput!) { 1937 createNetworkSlicesToolsBugIssue(input: $input) { 1938 uri 1939 } 1940 } 1941 `; 1942 1943 const DELETE_BUG_ISSUE_MUTATION = ` 1944 mutation DeleteBugIssue($rkey: String!) { 1945 deleteNetworkSlicesToolsBugIssue(rkey: $rkey) { 1946 uri 1947 } 1948 } 1949 `; 1950 1951 // ============================================================================= 1952 // DATA FETCHING 1953 // ============================================================================= 1954 1955 async function gqlQuery(query, variables = {}) { 1956 const res = await fetch(`${SERVER_URL}/graphql`, { 1957 method: "POST", 1958 headers: { "Content-Type": "application/json" }, 1959 body: JSON.stringify({ query, variables }), 1960 }); 1961 1962 if (!res.ok) throw new Error(`HTTP ${res.status}`); 1963 1964 const json = await res.json(); 1965 if (json.errors) throw new Error(json.errors[0].message); 1966 1967 return json.data; 1968 } 1969 1970 async function gqlMutation(query, variables = {}) { 1971 if (!client) { 1972 throw new Error("Not authenticated"); 1973 } 1974 return await client.mutate(query, variables); 1975 } 1976 1977 async function fetchNamespaces() { 1978 const data = await gqlQuery(NAMESPACES_QUERY); 1979 return data.networkSlicesToolsBugAggregated || []; 1980 } 1981 1982 async function fetchBugs(namespace, cursor = null) { 1983 const variables = { first: PAGE_SIZE, after: cursor, namespace }; 1984 const data = await gqlQuery(BUGS_QUERY, variables); 1985 return data.networkSlicesToolsBug; 1986 } 1987 1988 async function fetchResponses(bugUri) { 1989 const data = await gqlQuery(RESPONSES_QUERY, { bugUri }); 1990 return data.networkSlicesToolsBugResponse?.edges.map((e) => e.node) || []; 1991 } 1992 1993 async function fetchComments(bugUri) { 1994 const data = await gqlQuery(COMMENTS_QUERY, { bugUri }); 1995 return data.networkSlicesToolsBugComment?.edges.map((e) => e.node) || []; 1996 } 1997 1998 function groupComments(comments) { 1999 const topLevel = comments.filter((c) => !c.parent); 2000 const replies = comments.filter((c) => c.parent); 2001 2002 return topLevel.map((comment) => ({ 2003 ...comment, 2004 replies: replies.filter((r) => r.parent === comment.uri), 2005 })); 2006 } 2007 2008 function canComment() { 2009 return !!state.viewer; 2010 } 2011 2012 function canEditComment(commentDid) { 2013 return state.viewer && state.viewer.did === commentDid; 2014 } 2015 2016 // ============================================================================= 2017 // RENDERING - LANDING 2018 // ============================================================================= 2019 2020 function renderLanding() { 2021 if (state.isLoading) { 2022 return ` 2023 <div class="loading-container"> 2024 <div class="spinner"></div> 2025 <span>Loading namespaces...</span> 2026 </div> 2027 `; 2028 } 2029 2030 if (state.namespaces.length === 0) { 2031 return ` 2032 <div class="loading-container"> 2033 <p>No bug reports yet.</p> 2034 <button class="btn btn-primary" onclick="BugsApp.handleReportBug()">Report First Bug</button> 2035 </div> 2036 `; 2037 } 2038 2039 return ` 2040 <div class="filter-bar" style="justify-content: flex-end;"><button class="btn btn-primary" onclick="BugsApp.handleReportBug()">Report Bug</button></div> 2041 <div class="namespace-grid"> 2042 ${state.namespaces 2043 .map( 2044 (ns) => ` 2045 <div class="card namespace-card" onclick="BugsApp.navigateToNamespace('${esc(ns.namespace)}')"> 2046 <div class="namespace-name">${esc(ns.namespace)}</div> 2047 <div class="namespace-count">${ns.count} bug${ns.count !== 1 ? "s" : ""}</div> 2048 </div> 2049 `, 2050 ) 2051 .join("")} 2052 </div> 2053 `; 2054 } 2055 2056 function navigateToNamespace(ns) { 2057 state.namespace = ns; 2058 state.view = "list"; 2059 state.bugs = []; 2060 state.cursor = null; 2061 state.hasMore = true; 2062 updateUrl({ ns, bug: null }); 2063 render(); 2064 loadBugs(); 2065 } 2066 2067 // ============================================================================= 2068 // RENDERING - BUG LIST 2069 // ============================================================================= 2070 2071 function renderBugList() { 2072 let content = ""; 2073 2074 // Filter bar 2075 content += ` 2076 <div class="filter-bar"> 2077 <div class="filter-group"> 2078 <select onchange="BugsApp.handleSeverityFilter(this.value)"> 2079 <option value="">All Severities</option> 2080 <option value="unusable" ${state.filters.severity === "unusable" ? "selected" : ""}>Unusable</option> 2081 <option value="broken" ${state.filters.severity === "broken" ? "selected" : ""}>Broken</option> 2082 <option value="annoying" ${state.filters.severity === "annoying" ? "selected" : ""}>Annoying</option> 2083 <option value="cosmetic" ${state.filters.severity === "cosmetic" ? "selected" : ""}>Cosmetic</option> 2084 </select> 2085 </div> 2086 <button class="btn btn-primary" onclick="BugsApp.handleReportBug()">Report Bug</button> 2087 </div> 2088 `; 2089 2090 // Bug cards 2091 if (state.isLoading && state.bugs.length === 0) { 2092 content += ` 2093 <div class="loading-container"> 2094 <div class="spinner"></div> 2095 <span>Loading bugs...</span> 2096 </div> 2097 `; 2098 } else if (state.bugs.length === 0) { 2099 content += ` 2100 <div class="loading-container"> 2101 <p>No bugs reported for ${esc(state.namespace)}</p> 2102 </div> 2103 `; 2104 } else { 2105 const filteredBugs = state.bugs.filter((bug) => { 2106 if (state.filters.severity && bug.severity !== state.filters.severity) return false; 2107 return true; 2108 }); 2109 2110 content += `<div class="bug-list">`; 2111 content += filteredBugs.map((bug) => renderBugCard(bug)).join(""); 2112 content += `</div>`; 2113 2114 // Load more 2115 if (state.hasMore) { 2116 content += ` 2117 <div class="load-more"> 2118 <button class="btn btn-secondary" onclick="BugsApp.loadMoreBugs()" ${state.isLoading ? "disabled" : ""}> 2119 ${state.isLoading ? '<span class="spinner" style="width:16px;height:16px;border-width:2px;display:inline-block;vertical-align:middle;"></span>' : "Load More"} 2120 </button> 2121 </div> 2122 `; 2123 } 2124 } 2125 2126 return content; 2127 } 2128 2129 function renderBugCard(bug) { 2130 const displayStatus = getBugDisplayStatus(bug); 2131 const commentCount = bug.networkSlicesToolsBugCommentViaBug?.totalCount || 0; 2132 return ` 2133 <div class="card bug-card" onclick="BugsApp.openBugDetail('${esc(bug.uri)}')"> 2134 <div class="bug-card-header"> 2135 <span class="badge ${getSeverityClass(bug.severity)}">${esc(bug.severity)}</span> 2136 <span class="status-badge ${displayStatus.cssClass}">${esc(displayStatus.label)}</span> 2137 <span class="bug-card-title">${esc(bug.title)}</span> 2138 </div> 2139 <div class="bug-card-meta"> 2140 <span class="user-info">${renderAvatar(bug.appBskyActorProfileByDid, bug.actorHandle, "user-avatar-sm")}@${esc(bug.actorHandle)}</span> · ${formatTime(bug.createdAt)}${commentCount > 0 ? `<span class="comment-count"><i data-lucide="message-circle"></i> ${commentCount}</span>` : ""} 2141 </div> 2142 </div> 2143 `; 2144 } 2145 2146 function handleSeverityFilter(value) { 2147 state.filters.severity = value || null; 2148 render(); 2149 } 2150 2151 async function loadMoreBugs() { 2152 if (state.isLoading || !state.hasMore) return; 2153 state.isLoading = true; 2154 render(); 2155 2156 try { 2157 const data = await fetchBugs(state.namespace, state.cursor); 2158 state.bugs = [...state.bugs, ...data.edges.map((e) => e.node)]; 2159 state.cursor = data.pageInfo.endCursor; 2160 state.hasMore = data.pageInfo.hasNextPage; 2161 } catch (err) { 2162 console.error("Failed to load more:", err); 2163 showError(`Failed to load: ${err.message}`); 2164 } finally { 2165 state.isLoading = false; 2166 render(); 2167 } 2168 } 2169 2170 function openBugDetail(uri) { 2171 state.bugUri = uri; 2172 state.view = "detail"; 2173 updateUrl({ bug: uri }); 2174 renderOverlay(); 2175 } 2176 2177 // ============================================================================= 2178 // RENDERING - OVERLAY (BUG DETAIL) 2179 // ============================================================================= 2180 2181 async function renderOverlay() { 2182 const overlay = document.getElementById("overlay"); 2183 const bug = state.bugs.find((b) => b.uri === state.bugUri); 2184 2185 if (!bug) { 2186 overlay.classList.add("hidden"); 2187 return; 2188 } 2189 2190 // Show loading state 2191 overlay.innerHTML = ` 2192 <div class="overlay-header"> 2193 <span class="overlay-title">Loading...</span> 2194 <button class="overlay-close" onclick="BugsApp.closeOverlay()">×</button> 2195 </div> 2196 <div class="overlay-body"> 2197 <div class="loading-container"> 2198 <div class="spinner"></div> 2199 </div> 2200 </div> 2201 `; 2202 overlay.classList.remove("hidden"); 2203 setTimeout(() => overlay.classList.add("open"), 10); 2204 2205 // Add backdrop 2206 let backdrop = document.querySelector(".overlay-backdrop"); 2207 if (!backdrop) { 2208 backdrop = document.createElement("div"); 2209 backdrop.className = "overlay-backdrop"; 2210 backdrop.onclick = closeOverlay; 2211 document.body.appendChild(backdrop); 2212 } 2213 2214 // Fetch responses and comments 2215 try { 2216 [state.responses, state.comments] = await Promise.all([ 2217 fetchResponses(bug.uri), 2218 fetchComments(bug.uri), 2219 ]); 2220 } catch (err) { 2221 console.error("Failed to load data:", err); 2222 state.responses = []; 2223 state.comments = []; 2224 } 2225 2226 // Render full content 2227 const canAddResponse = canRespond(bug.did, bug.namespace); 2228 const canEdit = isAuthor(bug.did); 2229 const displayStatus = getBugDisplayStatus(bug); 2230 2231 overlay.innerHTML = ` 2232 <div class="overlay-header"> 2233 <div> 2234 <span class="badge ${getSeverityClass(bug.severity)}">${esc(bug.severity)}</span> 2235 <span class="status-badge ${displayStatus.cssClass}">${esc(displayStatus.label)}</span> 2236 <h2 class="overlay-title">${esc(bug.title)}</h2> 2237 </div> 2238 <button class="overlay-close" onclick="BugsApp.closeOverlay()">×</button> 2239 </div> 2240 <div class="overlay-body"> 2241 <div class="overlay-meta"> 2242 <span class="user-info">${renderAvatar(bug.appBskyActorProfileByDid, bug.actorHandle)}@${esc(bug.actorHandle)}</span> 2243 <span>·</span> 2244 <span>${formatTime(bug.createdAt)}</span> 2245 </div> 2246 2247 <div class="overlay-actions" style="margin-bottom: 1.5rem;"> 2248 <button class="btn btn-secondary" onclick="BugsApp.shareBug()">Share</button> 2249 ${canAddResponse ? `<button class="btn btn-secondary btn-icon-text" onclick="BugsApp.openLinkIssueModal('${esc(bug.uri)}')">${tangledIcon(16)} Link Issue</button>` : ""} 2250 ${ 2251 canEdit 2252 ? ` 2253 <button class="btn btn-secondary" onclick="BugsApp.openEditModal('${esc(bug.uri)}')">Edit</button> 2254 <button class="btn btn-danger" onclick="BugsApp.handleDeleteBug('${esc(bug.uri)}')">Delete</button> 2255 ` 2256 : "" 2257 } 2258 </div> 2259 2260 <div class="overlay-section"> 2261 <h3>Description</h3> 2262 <p>${renderFacetedText(bug.description, bug.descriptionFacets, { escapeHtml: esc })}</p> 2263 </div> 2264 2265 <div class="overlay-section"> 2266 <h3>Steps to Reproduce</h3> 2267 <p>${renderFacetedText(bug.stepsToReproduce, bug.stepsToReproduceFacets, { escapeHtml: esc })}</p> 2268 </div> 2269 2270 ${ 2271 bug.appUsed 2272 ? ` 2273 <div class="overlay-section"> 2274 <h3>App Used</h3> 2275 <p>${esc(bug.appUsed)}</p> 2276 </div> 2277 ` 2278 : "" 2279 } 2280 2281 ${renderAttachments(bug.attachments)} 2282 2283 <div class="overlay-section"> 2284 <h3>Comments (${state.comments.filter((c) => !c.parent).length})</h3> 2285 ${renderComments()} 2286 </div> 2287 2288 <div class="overlay-section"> 2289 <h3>Responses (${state.responses.length})</h3> 2290 ${state.responses.length === 0 ? "<p class='text-secondary'>No responses yet.</p>" : ""} 2291 <div class="response-list"> 2292 ${state.responses.map((r) => renderResponse(r)).join("")} 2293 </div> 2294 </div> 2295 2296 ${renderLinkedIssues(bug, canAddResponse)} 2297 2298 ${canAddResponse ? renderResponseForm() : ""} 2299 </div> 2300 `; 2301 lucide.createIcons(); 2302 } 2303 2304 function updateOverlayContent() { 2305 const overlay = document.getElementById("overlay"); 2306 const bug = state.bugs.find((b) => b.uri === state.bugUri); 2307 if (!bug) return; 2308 2309 const canAddResponse = canRespond(bug.did, bug.namespace); 2310 const canEdit = isAuthor(bug.did); 2311 const displayStatus = getBugDisplayStatus(bug); 2312 2313 overlay.innerHTML = ` 2314 <div class="overlay-header"> 2315 <div> 2316 <span class="badge ${getSeverityClass(bug.severity)}">${esc(bug.severity)}</span> 2317 <span class="status-badge ${displayStatus.cssClass}">${esc(displayStatus.label)}</span> 2318 <h2 class="overlay-title">${esc(bug.title)}</h2> 2319 </div> 2320 <button class="overlay-close" onclick="BugsApp.closeOverlay()">×</button> 2321 </div> 2322 <div class="overlay-body"> 2323 <div class="overlay-meta"> 2324 <span class="user-info">${renderAvatar(bug.appBskyActorProfileByDid, bug.actorHandle)}@${esc(bug.actorHandle)}</span> 2325 <span>·</span> 2326 <span>${formatTime(bug.createdAt)}</span> 2327 </div> 2328 2329 <div class="overlay-actions" style="margin-bottom: 1.5rem;"> 2330 <button class="btn btn-secondary" onclick="BugsApp.shareBug()">Share</button> 2331 ${canAddResponse ? `<button class="btn btn-secondary btn-icon-text" onclick="BugsApp.openLinkIssueModal('${esc(bug.uri)}')">${tangledIcon(16)} Link Issue</button>` : ""} 2332 ${ 2333 canEdit 2334 ? ` 2335 <button class="btn btn-secondary" onclick="BugsApp.openEditModal('${esc(bug.uri)}')">Edit</button> 2336 <button class="btn btn-danger" onclick="BugsApp.handleDeleteBug('${esc(bug.uri)}')">Delete</button> 2337 ` 2338 : "" 2339 } 2340 </div> 2341 2342 <div class="overlay-section"> 2343 <h3>Description</h3> 2344 <p>${renderFacetedText(bug.description, bug.descriptionFacets, { escapeHtml: esc })}</p> 2345 </div> 2346 2347 <div class="overlay-section"> 2348 <h3>Steps to Reproduce</h3> 2349 <p>${renderFacetedText(bug.stepsToReproduce, bug.stepsToReproduceFacets, { escapeHtml: esc })}</p> 2350 </div> 2351 2352 ${ 2353 bug.appUsed 2354 ? ` 2355 <div class="overlay-section"> 2356 <h3>App Used</h3> 2357 <p>${esc(bug.appUsed)}</p> 2358 </div> 2359 ` 2360 : "" 2361 } 2362 2363 ${renderAttachments(bug.attachments)} 2364 2365 <div class="overlay-section"> 2366 <h3>Comments (${state.comments.filter((c) => !c.parent).length})</h3> 2367 ${renderComments()} 2368 </div> 2369 2370 <div class="overlay-section"> 2371 <h3>Responses (${state.responses.length})</h3> 2372 ${state.responses.length === 0 ? "<p class='text-secondary'>No responses yet.</p>" : ""} 2373 <div class="response-list"> 2374 ${state.responses.map((r) => renderResponse(r)).join("")} 2375 </div> 2376 </div> 2377 2378 ${renderLinkedIssues(bug, canAddResponse)} 2379 2380 ${canAddResponse ? renderResponseForm() : ""} 2381 </div> 2382 `; 2383 lucide.createIcons(); 2384 } 2385 2386 async function openLinkIssueModal(bugUri) { 2387 state.linkIssueState = { 2388 bugUri, 2389 step: "repos", 2390 repos: [], 2391 selectedRepo: null, 2392 issues: [], 2393 isLoading: true, 2394 }; 2395 2396 renderLinkIssueModal(); 2397 2398 try { 2399 const data = await gqlMutation(VIEWER_REPOS_QUERY, {}); 2400 state.linkIssueState.repos = 2401 data.viewer?.shTangledRepoByDid?.edges?.map((e) => e.node) || []; 2402 } catch (err) { 2403 console.error("Failed to fetch repos:", err); 2404 showError(`Failed to load repos: ${err.message}`); 2405 } finally { 2406 state.linkIssueState.isLoading = false; 2407 renderLinkIssueModal(); 2408 } 2409 } 2410 2411 function renderLinkIssueModal() { 2412 const modal = document.getElementById("modal"); 2413 const { step, repos, selectedRepo, issues, isLoading } = state.linkIssueState; 2414 2415 let content = ""; 2416 2417 if (step === "repos") { 2418 content = ` 2419 <div class="modal-header"> 2420 <h2 style="display: flex; align-items: center; gap: 0.5rem;">${tangledIcon(20)} Link to Tangled Issue</h2> 2421 <button class="modal-close" onclick="BugsApp.closeLinkIssueModal()">×</button> 2422 </div> 2423 <div class="modal-body"> 2424 ${ 2425 isLoading 2426 ? ` 2427 <div class="loading-container"> 2428 <div class="spinner"></div> 2429 <span>Loading repos...</span> 2430 </div> 2431 ` 2432 : repos.length === 0 2433 ? ` 2434 <div class="loading-container"> 2435 <p>No Tangled repos found.</p> 2436 <p class="text-secondary">Create a repo on <a href="https://tangled.sh" target="_blank">tangled.sh</a> first.</p> 2437 </div> 2438 ` 2439 : ` 2440 <div class="form-group"> 2441 <label for="repo-select">Select a repository:</label> 2442 <select id="repo-select" onchange="if(this.value) BugsApp.selectRepoForLinking(this.value)"> 2443 <option value="">Choose a repo...</option> 2444 ${repos 2445 .map( 2446 (repo) => ` 2447 <option value="${esc(repo.uri)}">${esc(repo.name)}</option> 2448 `, 2449 ) 2450 .join("")} 2451 </select> 2452 </div> 2453 ` 2454 } 2455 </div> 2456 `; 2457 } else if (step === "issues") { 2458 const bug = state.bugs.find((b) => b.uri === state.linkIssueState.bugUri); 2459 content = ` 2460 <div class="modal-header"> 2461 <h2 style="display: flex; align-items: center; gap: 0.5rem;">${tangledIcon(20)} Link to Tangled Issue</h2> 2462 <button class="modal-close" onclick="BugsApp.closeLinkIssueModal()">×</button> 2463 </div> 2464 <div class="modal-body"> 2465 <button class="btn btn-secondary" onclick="BugsApp.goBackToRepos()" style="margin-bottom: 1rem;">← Back to repos</button> 2466 <p style="margin-bottom: 0.5rem;"><strong>${esc(selectedRepo.name)}</strong></p> 2467 2468 <button class="btn btn-primary" style="width: 100%; margin-bottom: 1rem;" onclick="BugsApp.createAndLinkIssue()" ${isLoading ? "disabled" : ""}> 2469 + Create New Issue from Bug 2470 </button> 2471 2472 ${ 2473 isLoading 2474 ? ` 2475 <div class="loading-container"> 2476 <div class="spinner"></div> 2477 <span>Loading issues...</span> 2478 </div> 2479 ` 2480 : issues.length === 0 2481 ? ` 2482 <p class="text-secondary">No existing issues in this repo.</p> 2483 ` 2484 : ` 2485 <p class="text-secondary" style="margin-bottom: 0.5rem;">Or link to existing issue:</p> 2486 <div class="issue-list"> 2487 ${issues 2488 .map( 2489 (issue) => ` 2490 <div class="card" style="cursor: pointer;" onclick="BugsApp.linkToExistingIssue('${esc(issue.uri)}')"> 2491 <div style="font-weight: 500;">${esc(issue.title)}</div> 2492 <div class="text-secondary" style="font-size: 0.75rem;">${formatTime(issue.createdAt)}</div> 2493 </div> 2494 `, 2495 ) 2496 .join("")} 2497 </div> 2498 ` 2499 } 2500 </div> 2501 `; 2502 } 2503 2504 modal.innerHTML = ` 2505 <div class="modal-backdrop" onclick="BugsApp.closeLinkIssueModal()"></div> 2506 <div class="modal-content scrollable"> 2507 ${content} 2508 </div> 2509 `; 2510 modal.classList.remove("hidden"); 2511 } 2512 2513 function closeLinkIssueModal() { 2514 state.linkIssueState = { 2515 bugUri: null, 2516 step: "repos", 2517 repos: [], 2518 selectedRepo: null, 2519 issues: [], 2520 isLoading: false, 2521 }; 2522 closeModal(); 2523 } 2524 2525 async function selectRepoForLinking(repoUri) { 2526 const repo = state.linkIssueState.repos.find((r) => r.uri === repoUri); 2527 state.linkIssueState.selectedRepo = repo; 2528 state.linkIssueState.step = "issues"; 2529 state.linkIssueState.isLoading = true; 2530 state.linkIssueState.issues = []; 2531 2532 renderLinkIssueModal(); 2533 2534 try { 2535 const data = await gqlQuery(REPO_ISSUES_QUERY, { repoUri }); 2536 state.linkIssueState.issues = data.shTangledRepoIssue?.edges?.map((e) => e.node) || []; 2537 } catch (err) { 2538 console.error("Failed to fetch issues:", err); 2539 showError(`Failed to load issues: ${err.message}`); 2540 } finally { 2541 state.linkIssueState.isLoading = false; 2542 renderLinkIssueModal(); 2543 } 2544 } 2545 2546 function goBackToRepos() { 2547 state.linkIssueState.step = "repos"; 2548 state.linkIssueState.selectedRepo = null; 2549 state.linkIssueState.issues = []; 2550 renderLinkIssueModal(); 2551 } 2552 2553 async function createAndLinkIssue() { 2554 const { bugUri, selectedRepo } = state.linkIssueState; 2555 const bug = state.bugs.find((b) => b.uri === bugUri); 2556 2557 if (!bug || !selectedRepo) return; 2558 2559 state.linkIssueState.isLoading = true; 2560 renderLinkIssueModal(); 2561 2562 try { 2563 // Build issue body from bug data 2564 const bugUrl = `https://tools.slices.network/bugs?ns=${encodeURIComponent(bug.namespace)}&bug=${encodeURIComponent(bug.uri)}`; 2565 const issueBody = `**Description:** 2566${bug.description} 2567 2568**Steps to Reproduce:** 2569${bug.stepsToReproduce} 2570 2571--- 2572Linked from bug: ${bugUrl}`; 2573 2574 // Create the Tangled issue 2575 const issueInput = { 2576 repo: selectedRepo.uri, 2577 title: bug.title, 2578 body: issueBody, 2579 createdAt: new Date().toISOString(), 2580 }; 2581 2582 const issueResult = await gqlMutation(CREATE_TANGLED_ISSUE_MUTATION, { 2583 input: issueInput, 2584 }); 2585 const newIssueUri = issueResult.createShTangledRepoIssue.uri; 2586 2587 // Create the bug.issue link 2588 const linkInput = { 2589 bug: bugUri, 2590 issue: newIssueUri, 2591 createdAt: new Date().toISOString(), 2592 }; 2593 2594 await gqlMutation(CREATE_BUG_ISSUE_MUTATION, { input: linkInput }); 2595 2596 // Close modal and refresh 2597 closeLinkIssueModal(); 2598 2599 // Refresh bugs to get updated linked issues 2600 await loadBugs(); 2601 2602 // Re-open the overlay to show the new linked issue 2603 if (state.bugUri) { 2604 renderOverlay(); 2605 } 2606 2607 showSuccess("Issue created and linked!"); 2608 } catch (err) { 2609 console.error("Failed to create and link issue:", err); 2610 showError(`Failed to create issue: ${err.message}`); 2611 state.linkIssueState.isLoading = false; 2612 renderLinkIssueModal(); 2613 } 2614 } 2615 2616 async function linkToExistingIssue(issueUri) { 2617 const { bugUri } = state.linkIssueState; 2618 2619 if (!bugUri) return; 2620 2621 state.linkIssueState.isLoading = true; 2622 renderLinkIssueModal(); 2623 2624 try { 2625 const linkInput = { 2626 bug: bugUri, 2627 issue: issueUri, 2628 createdAt: new Date().toISOString(), 2629 }; 2630 2631 await gqlMutation(CREATE_BUG_ISSUE_MUTATION, { input: linkInput }); 2632 2633 closeLinkIssueModal(); 2634 2635 // Refresh bugs to get updated linked issues 2636 await loadBugs(); 2637 2638 // Re-open the overlay to show the linked issue 2639 if (state.bugUri) { 2640 renderOverlay(); 2641 } 2642 2643 showSuccess("Issue linked!"); 2644 } catch (err) { 2645 console.error("Failed to link issue:", err); 2646 showError(`Failed to link issue: ${err.message}`); 2647 state.linkIssueState.isLoading = false; 2648 renderLinkIssueModal(); 2649 } 2650 } 2651 2652 function getTangledIssueUrl(issue) { 2653 if (!issue.issueResolved) return null; 2654 const resolved = issue.issueResolved; 2655 if (!resolved.repoResolved) return null; 2656 2657 const handle = resolved.repoResolved.actorHandle; 2658 const repoName = resolved.repoResolved.name; 2659 2660 // Tangled doesn't support linking to specific issues by rkey yet, 2661 // so link to the repo's issues page instead 2662 return `https://tangled.sh/${handle}/${repoName}/issues`; 2663 } 2664 2665 function renderLinkedIssues(bug, canUnlink) { 2666 const linkedIssues = bug.networkSlicesToolsBugIssueViaBug?.edges?.map((e) => e.node) || []; 2667 2668 if (linkedIssues.length === 0) { 2669 return ""; 2670 } 2671 2672 return ` 2673 <div class="overlay-section"> 2674 <h3>Linked Issues (${linkedIssues.length})</h3> 2675 <div class="linked-issues-list"> 2676 ${linkedIssues 2677 .map((link) => { 2678 const issue = link.issueResolved; 2679 const url = getTangledIssueUrl(link); 2680 const title = issue?.title || "Unknown Issue"; 2681 const repoName = issue?.repoResolved?.name || "Unknown Repo"; 2682 2683 return ` 2684 <div class="card" style="display: flex; justify-content: space-between; align-items: center; cursor: default;"> 2685 <div> 2686 <div style="font-weight: 500;">${esc(title)}</div> 2687 <div class="text-secondary" style="font-size: 0.75rem;"> 2688 ${esc(repoName)} 2689 ${url ? ` · <a href="${esc(url)}" target="_blank" onclick="event.stopPropagation()">View on Tangled</a>` : ""} 2690 </div> 2691 </div> 2692 ${canUnlink ? `<button class="btn-icon btn-danger-text" onclick="BugsApp.unlinkIssue('${esc(link.uri)}')" title="Unlink">×</button>` : ""} 2693 </div> 2694 `; 2695 }) 2696 .join("")} 2697 </div> 2698 </div> 2699 `; 2700 } 2701 2702 async function unlinkIssue(linkUri) { 2703 if (!confirm("Unlink this issue?")) { 2704 return; 2705 } 2706 2707 try { 2708 const rkey = getRkeyFromUri(linkUri); 2709 await gqlMutation(DELETE_BUG_ISSUE_MUTATION, { rkey }); 2710 2711 // Refresh bugs to update linked issues 2712 await loadBugs(); 2713 2714 // Re-render overlay 2715 if (state.bugUri) { 2716 renderOverlay(); 2717 } 2718 2719 showSuccess("Issue unlinked!"); 2720 } catch (err) { 2721 console.error("Failed to unlink issue:", err); 2722 showError(`Failed to unlink: ${err.message}`); 2723 } 2724 } 2725 2726 function renderAttachments(attachments) { 2727 if (!attachments || !attachments.images || attachments.images.length === 0) { 2728 return ""; 2729 } 2730 const images = attachments.images; 2731 return ` 2732 <div class="overlay-section"> 2733 <h3>Attachments (${images.length})</h3> 2734 <div class="attachment-gallery"> 2735 ${images 2736 .map( 2737 (img) => ` 2738 <img 2739 class="attachment-image" 2740 src="${esc(img.image.url)}" 2741 alt="${esc(img.alt || "Bug attachment")}" 2742 onclick="BugsApp.openLightbox('${esc(img.image.url)}')" 2743 /> 2744 `, 2745 ) 2746 .join("")} 2747 </div> 2748 </div> 2749 `; 2750 } 2751 2752 function openLightbox(imageUrl) { 2753 const lightbox = document.createElement("div"); 2754 lightbox.className = "image-lightbox"; 2755 lightbox.innerHTML = `<img src="${imageUrl}" alt="Full size image" />`; 2756 lightbox.onclick = () => lightbox.remove(); 2757 document.body.appendChild(lightbox); 2758 } 2759 2760 function renderResponse(response) { 2761 const isResponseAuthor = state.viewer && state.viewer.did === response.did; 2762 return ` 2763 <div class="response-card"> 2764 <div class="response-header"> 2765 <span class="status-badge ${getStatusClass(response.status)}">${esc(response.status)}</span> 2766 <span class="response-meta"><span class="user-info">${renderAvatar(response.appBskyActorProfileByDid, response.actorHandle, "user-avatar-sm")}@${esc(response.actorHandle)}</span> · ${formatTime(response.createdAt)}</span> 2767 ${isResponseAuthor ? `<button class="btn-icon btn-danger-text" onclick="BugsApp.handleDeleteResponse('${esc(response.uri)}')" title="Delete response">×</button>` : ""} 2768 </div> 2769 ${response.message ? `<p class="response-message">${renderFacetedText(response.message, response.messageFacets, { escapeHtml: esc })}</p>` : ""} 2770 </div> 2771 `; 2772 } 2773 2774 function renderResponseForm() { 2775 return ` 2776 <div class="overlay-section"> 2777 <h3>Add Response</h3> 2778 <form onsubmit="BugsApp.handleSubmitResponse(event)"> 2779 <div class="form-group"> 2780 <label for="response-status">Status</label> 2781 <select id="response-status" required> 2782 <option value="">Select status...</option> 2783 <option value="acknowledged">Acknowledged</option> 2784 <option value="fixed">Fixed</option> 2785 <option value="wontfix">Won't Fix</option> 2786 <option value="duplicate">Duplicate</option> 2787 <option value="invalid">Invalid</option> 2788 </select> 2789 </div> 2790 <div class="form-group"> 2791 <label for="response-message">Message (optional)</label> 2792 <textarea id="response-message" placeholder="Add context or a link to the fix..."></textarea> 2793 </div> 2794 <div class="form-actions"> 2795 <button type="submit" class="btn btn-primary">Submit Response</button> 2796 </div> 2797 </form> 2798 </div> 2799 `; 2800 } 2801 2802 function renderComment(comment, isReply = false) { 2803 const canEdit = canEditComment(comment.did); 2804 const isEditing = state.editingComment === comment.uri; 2805 2806 if (isEditing) { 2807 const attachmentPreviews = 2808 state.editCommentAttachments.length > 0 2809 ? ` 2810 <div class="image-previews" style="margin-bottom: 0.5rem;"> 2811 ${state.editCommentAttachments 2812 .map( 2813 (img, i) => ` 2814 <div class="image-preview"> 2815 <img src="${esc(img.image?.url)}" alt="${esc(img.alt || "")}" style="max-width: 80px; max-height: 80px; width: auto; height: auto;"> 2816 <button type="button" onclick="BugsApp.removeEditCommentAttachment(${i})">×</button> 2817 </div> 2818 `, 2819 ) 2820 .join("")} 2821 </div> 2822 ` 2823 : ""; 2824 2825 return ` 2826 <div class="comment-card" data-uri="${esc(comment.uri)}"> 2827 <div class="comment-form-inline" style="border-top: none; margin-top: 0; padding-top: 0;"> 2828 <form onsubmit="BugsApp.handleSaveEditComment(event, '${esc(comment.uri)}')"> 2829 <textarea id="edit-comment-${esc(comment.uri)}" required>${esc(comment.body)}</textarea> 2830 ${attachmentPreviews} 2831 <div class="comment-form-actions"> 2832 <button type="button" class="btn btn-secondary" onclick="BugsApp.cancelEditComment()">Cancel</button> 2833 <button type="submit" class="btn btn-primary">Save</button> 2834 </div> 2835 </form> 2836 </div> 2837 </div> 2838 `; 2839 } 2840 2841 return ` 2842 <div class="comment-card" data-uri="${esc(comment.uri)}"> 2843 <div class="comment-header"> 2844 <span class="comment-meta"><span class="user-info">${renderAvatar(comment.appBskyActorProfileByDid, comment.actorHandle, "user-avatar-sm")}@${esc(comment.actorHandle)}</span> · ${formatTime(comment.createdAt)}</span> 2845 ${ 2846 canEdit 2847 ? ` 2848 <div> 2849 <button class="btn-icon" onclick="BugsApp.startEditComment('${esc(comment.uri)}')" title="Edit"><i data-lucide="pencil"></i></button> 2850 <button class="btn-icon btn-danger-text" onclick="BugsApp.handleDeleteComment('${esc(comment.uri)}')" title="Delete"><i data-lucide="trash-2"></i></button> 2851 </div> 2852 ` 2853 : "" 2854 } 2855 </div> 2856 <p class="comment-body">${renderFacetedText(comment.body, comment.bodyFacets, { escapeHtml: esc })}</p> 2857 ${renderCommentAttachments(comment.attachments)} 2858 ${ 2859 !isReply && canComment() 2860 ? ` 2861 <div class="comment-actions"> 2862 <button class="btn btn-secondary" onclick="BugsApp.showReplyForm('${esc(comment.uri)}')">Reply</button> 2863 </div> 2864 ` 2865 : "" 2866 } 2867 ${state.replyingTo === comment.uri ? renderReplyForm(comment.uri) : ""} 2868 </div> 2869 `; 2870 } 2871 2872 function renderCommentAttachments(attachments) { 2873 if (!attachments || !attachments.images || attachments.images.length === 0) { 2874 return ""; 2875 } 2876 return ` 2877 <div class="attachment-gallery" style="margin-top: 0.5rem;"> 2878 ${attachments.images 2879 .map( 2880 (img) => ` 2881 <img 2882 class="attachment-image" 2883 src="${esc(img.image.url)}" 2884 alt="${esc(img.alt || "Comment attachment")}" 2885 onclick="BugsApp.openLightbox('${esc(img.image.url)}')" 2886 /> 2887 `, 2888 ) 2889 .join("")} 2890 </div> 2891 `; 2892 } 2893 2894 function renderReplyForm(parentUri) { 2895 return ` 2896 <div class="comment-form-inline"> 2897 <form onsubmit="BugsApp.handleSubmitComment(event, '${esc(parentUri)}')"> 2898 <textarea id="reply-body" placeholder="Write a reply..." required></textarea> 2899 ${renderCommentImagePreviews()} 2900 <div class="comment-form-actions"> 2901 <button type="button" class="btn btn-secondary" onclick="BugsApp.cancelReply()">Cancel</button> 2902 <input type="file" id="reply-images" accept="image/*" multiple onchange="BugsApp.handleCommentImageSelect(event)" style="display: none;"> 2903 <button type="button" class="btn btn-secondary btn-icon" onclick="document.getElementById('reply-images').click()" title="Add images"> 2904 <i data-lucide="image"></i> 2905 </button> 2906 <button type="submit" class="btn btn-primary">Reply</button> 2907 </div> 2908 </form> 2909 </div> 2910 `; 2911 } 2912 2913 function renderComments() { 2914 const grouped = groupComments(state.comments); 2915 2916 if (grouped.length === 0 && !canComment()) { 2917 return `<p class="text-secondary">No comments yet.</p>`; 2918 } 2919 2920 return ` 2921 <div class="comments-list"> 2922 ${grouped 2923 .map( 2924 (comment) => ` 2925 ${renderComment(comment)} 2926 ${ 2927 comment.replies.length > 0 2928 ? ` 2929 <div class="comment-replies"> 2930 ${comment.replies.map((reply) => renderComment(reply, true)).join("")} 2931 </div> 2932 ` 2933 : "" 2934 } 2935 `, 2936 ) 2937 .join("")} 2938 </div> 2939 ${canComment() ? renderCommentForm() : ""} 2940 `; 2941 } 2942 2943 function renderCommentForm() { 2944 return ` 2945 <div class="comment-form-inline" style="border-top: none; margin-top: 1rem;"> 2946 <form onsubmit="BugsApp.handleSubmitComment(event)"> 2947 <textarea id="comment-body" placeholder="Add a comment..." required></textarea> 2948 ${renderCommentImagePreviews()} 2949 <div class="comment-form-actions"> 2950 <input type="file" id="comment-images" accept="image/*" multiple onchange="BugsApp.handleCommentImageSelect(event)" style="display: none;"> 2951 <button type="button" class="btn btn-secondary btn-icon" onclick="document.getElementById('comment-images').click()" title="Add images"> 2952 <i data-lucide="image"></i> 2953 </button> 2954 <button type="submit" class="btn btn-primary">Comment</button> 2955 </div> 2956 </form> 2957 </div> 2958 `; 2959 } 2960 2961 function renderCommentImagePreviews() { 2962 if (state.commentImages.length === 0) return ""; 2963 return ` 2964 <div class="image-previews"> 2965 ${state.commentImages 2966 .map( 2967 (img, i) => ` 2968 <div class="image-preview"> 2969 <img src="${img.preview}" alt="Preview"> 2970 <button type="button" onclick="BugsApp.removeCommentImage(${i})">×</button> 2971 </div> 2972 `, 2973 ) 2974 .join("")} 2975 </div> 2976 `; 2977 } 2978 2979 async function handleCommentImageSelect(event) { 2980 const files = Array.from(event.target.files); 2981 event.target.value = ""; 2982 2983 for (const file of files) { 2984 try { 2985 const dataUrl = await readFileAsDataURL(file); 2986 const resized = await resizeImage(dataUrl, { 2987 width: 2000, 2988 height: 2000, 2989 maxSize: 900000, 2990 mode: "contain", 2991 }); 2992 state.commentImages.push({ 2993 file, 2994 preview: resized.dataUrl, 2995 }); 2996 updateOverlayContent(); 2997 } catch (err) { 2998 showError(`Failed to process ${file.name}: ${err.message}`); 2999 } 3000 } 3001 } 3002 3003 function removeCommentImage(index) { 3004 state.commentImages.splice(index, 1); 3005 updateOverlayContent(); 3006 } 3007 3008 function showReplyForm(commentUri) { 3009 state.replyingTo = commentUri; 3010 updateOverlayContent(); 3011 } 3012 3013 function cancelReply() { 3014 state.replyingTo = null; 3015 state.commentImages = []; 3016 updateOverlayContent(); 3017 } 3018 3019 function startEditComment(commentUri) { 3020 state.editingComment = commentUri; 3021 const comment = state.comments.find((c) => c.uri === commentUri); 3022 // Keep full image objects to preserve blob references 3023 state.editCommentAttachments = comment?.attachments?.images || []; 3024 updateOverlayContent(); 3025 } 3026 3027 function cancelEditComment() { 3028 state.editingComment = null; 3029 state.editCommentAttachments = []; 3030 updateOverlayContent(); 3031 } 3032 3033 function removeEditCommentAttachment(index) { 3034 state.editCommentAttachments.splice(index, 1); 3035 updateOverlayContent(); 3036 } 3037 3038 async function handleSaveEditComment(event, commentUri) { 3039 event.preventDefault(); 3040 3041 const textarea = document.getElementById(`edit-comment-${commentUri}`); 3042 const newBody = textarea.value.trim(); 3043 3044 if (!newBody) return; 3045 3046 const comment = state.comments.find((c) => c.uri === commentUri); 3047 if (!comment) return; 3048 3049 const submitBtn = event.target.querySelector('button[type="submit"]'); 3050 submitBtn.disabled = true; 3051 submitBtn.textContent = "Saving..."; 3052 3053 try { 3054 const rkey = getRkeyFromUri(commentUri); 3055 3056 // Parse facets for body 3057 const bodyParsed = parseFacets(newBody); 3058 3059 const input = { 3060 bug: state.bugUri, 3061 body: bodyParsed.text, 3062 ...(bodyParsed.facets && { bodyFacets: bodyParsed.facets }), 3063 createdAt: comment.createdAt, 3064 ...(comment.parent && { parent: comment.parent }), 3065 }; 3066 3067 // Include remaining attachments 3068 if (state.editCommentAttachments.length > 0) { 3069 input.attachments = { 3070 $type: "network.slices.tools.defs#images", 3071 images: state.editCommentAttachments.map((img) => ({ 3072 alt: img.alt || "", 3073 image: { 3074 $type: "blob", 3075 ref: { $link: img.image.ref }, 3076 mimeType: img.image.mimeType, 3077 size: img.image.size, 3078 }, 3079 })), 3080 }; 3081 } 3082 3083 await gqlMutation(UPDATE_COMMENT_MUTATION, { rkey, input }); 3084 3085 state.editingComment = null; 3086 state.editCommentAttachments = []; 3087 state.comments = await fetchComments(state.bugUri); 3088 updateOverlayContent(); 3089 showSuccess("Comment updated!"); 3090 } catch (err) { 3091 console.error("Failed to update comment:", err); 3092 showError(`Failed to update: ${err.message}`); 3093 submitBtn.disabled = false; 3094 submitBtn.textContent = "Save"; 3095 } 3096 } 3097 3098 async function handleSubmitComment(event, parentUri = null) { 3099 event.preventDefault(); 3100 3101 const textareaId = parentUri ? "reply-body" : "comment-body"; 3102 const textarea = document.getElementById(textareaId); 3103 const body = textarea.value.trim(); 3104 3105 if (!body) return; 3106 3107 const submitBtn = event.target.querySelector('button[type="submit"]'); 3108 submitBtn.disabled = true; 3109 submitBtn.textContent = parentUri ? "Replying..." : "Posting..."; 3110 3111 try { 3112 // Upload images if any 3113 let attachments = null; 3114 if (state.commentImages.length > 0) { 3115 const uploadedImages = []; 3116 for (const img of state.commentImages) { 3117 const base64Data = img.preview.split(",")[1]; 3118 const uploadResult = await gqlMutation(UPLOAD_BLOB_MUTATION, { 3119 data: base64Data, 3120 mimeType: "image/jpeg", 3121 }); 3122 if (uploadResult.uploadBlob) { 3123 uploadedImages.push({ 3124 alt: "", 3125 image: { 3126 $type: "blob", 3127 ref: { $link: uploadResult.uploadBlob.ref }, 3128 mimeType: uploadResult.uploadBlob.mimeType, 3129 size: uploadResult.uploadBlob.size, 3130 }, 3131 }); 3132 } 3133 } 3134 attachments = { 3135 $type: "network.slices.tools.defs#images", 3136 images: uploadedImages, 3137 }; 3138 } 3139 3140 // Parse facets for body 3141 const bodyParsed = parseFacets(body); 3142 3143 const input = { 3144 bug: state.bugUri, 3145 body: bodyParsed.text, 3146 ...(bodyParsed.facets && { bodyFacets: bodyParsed.facets }), 3147 createdAt: new Date().toISOString(), 3148 ...(parentUri && { parent: parentUri }), 3149 ...(attachments && { attachments }), 3150 }; 3151 3152 await gqlMutation(CREATE_COMMENT_MUTATION, { input }); 3153 3154 state.replyingTo = null; 3155 state.commentImages = []; 3156 state.comments = await fetchComments(state.bugUri); 3157 3158 // Update comment count in bugs list (top-level only) 3159 const bugIndex = state.bugs.findIndex((b) => b.uri === state.bugUri); 3160 if (bugIndex !== -1) { 3161 if (!state.bugs[bugIndex].networkSlicesToolsBugCommentViaBug) { 3162 state.bugs[bugIndex].networkSlicesToolsBugCommentViaBug = { totalCount: 0 }; 3163 } 3164 state.bugs[bugIndex].networkSlicesToolsBugCommentViaBug.totalCount = 3165 state.comments.filter((c) => !c.parent).length; 3166 } 3167 3168 updateOverlayContent(); 3169 render(); 3170 showSuccess(parentUri ? "Reply added!" : "Comment added!"); 3171 } catch (err) { 3172 console.error("Failed to add comment:", err); 3173 showError(`Failed to add comment: ${err.message}`); 3174 submitBtn.disabled = false; 3175 submitBtn.textContent = parentUri ? "Reply" : "Comment"; 3176 } 3177 } 3178 3179 async function handleDeleteComment(commentUri) { 3180 if (!confirm("Delete this comment?")) return; 3181 3182 try { 3183 const rkey = getRkeyFromUri(commentUri); 3184 await gqlMutation(DELETE_COMMENT_MUTATION, { rkey }); 3185 3186 state.comments = state.comments.filter((c) => c.uri !== commentUri); 3187 3188 // Update comment count in bugs list (top-level only) 3189 const bugIndex = state.bugs.findIndex((b) => b.uri === state.bugUri); 3190 if (bugIndex !== -1 && state.bugs[bugIndex].networkSlicesToolsBugCommentViaBug) { 3191 state.bugs[bugIndex].networkSlicesToolsBugCommentViaBug.totalCount = 3192 state.comments.filter((c) => !c.parent).length; 3193 } 3194 3195 updateOverlayContent(); 3196 render(); 3197 showSuccess("Comment deleted!"); 3198 } catch (err) { 3199 console.error("Failed to delete comment:", err); 3200 showError(`Failed to delete: ${err.message}`); 3201 } 3202 } 3203 3204 function closeOverlay() { 3205 const overlay = document.getElementById("overlay"); 3206 overlay.classList.remove("open"); 3207 setTimeout(() => overlay.classList.add("hidden"), 200); 3208 3209 const backdrop = document.querySelector(".overlay-backdrop"); 3210 if (backdrop) backdrop.remove(); 3211 3212 state.bugUri = null; 3213 state.view = "list"; 3214 state.responses = []; 3215 state.comments = []; 3216 state.replyingTo = null; 3217 state.editingComment = null; 3218 state.commentImages = []; 3219 updateUrl({ bug: null }); 3220 } 3221 3222 async function handleSubmitResponse(event) { 3223 event.preventDefault(); 3224 3225 const status = document.getElementById("response-status").value; 3226 const message = document.getElementById("response-message").value.trim(); 3227 const bug = state.bugs.find((b) => b.uri === state.bugUri); 3228 3229 if (!bug) return; 3230 3231 const submitBtn = event.target.querySelector('button[type="submit"]'); 3232 submitBtn.disabled = true; 3233 submitBtn.textContent = "Submitting..."; 3234 3235 try { 3236 // Parse facets for message if present 3237 const messageParsed = message ? parseFacets(message) : { text: null, facets: null }; 3238 3239 const input = { 3240 bug: bug.uri, 3241 status, 3242 createdAt: new Date().toISOString(), 3243 ...(messageParsed.text && { message: messageParsed.text }), 3244 ...(messageParsed.facets && { messageFacets: messageParsed.facets }), 3245 }; 3246 3247 await gqlMutation(CREATE_RESPONSE_MUTATION, { input }); 3248 3249 // Refresh responses 3250 state.responses = await fetchResponses(bug.uri); 3251 3252 // Update bug's embedded responses for status display 3253 const bugIndex = state.bugs.findIndex((b) => b.uri === bug.uri); 3254 if (bugIndex !== -1) { 3255 state.bugs[bugIndex].networkSlicesToolsBugResponseViaBug = { 3256 edges: state.responses.map((r) => ({ 3257 node: { status: r.status, createdAt: r.createdAt }, 3258 })), 3259 }; 3260 } 3261 3262 renderOverlay(); 3263 render(); // Update list to show new status 3264 3265 showSuccess("Response added!"); 3266 } catch (err) { 3267 console.error("Submit response failed:", err); 3268 showError(`Failed to submit: ${err.message}`); 3269 } finally { 3270 submitBtn.disabled = false; 3271 submitBtn.textContent = "Submit Response"; 3272 } 3273 } 3274 3275 // ============================================================================= 3276 // RENDERING - MODAL (SUBMIT BUG) 3277 // ============================================================================= 3278 3279 function openSubmitModal() { 3280 if (!state.viewer) { 3281 login(); 3282 return; 3283 } 3284 3285 const modal = document.getElementById("modal"); 3286 modal.innerHTML = ` 3287 <div class="modal-backdrop" onclick="BugsApp.closeModal()"></div> 3288 <div class="modal-content scrollable"> 3289 <div class="modal-header"> 3290 <h2>Report Bug</h2> 3291 <button class="modal-close" onclick="BugsApp.closeModal()">×</button> 3292 </div> 3293 <div class="modal-body"> 3294 <form id="bug-form" onsubmit="BugsApp.handleSubmitBug(event)"> 3295 <div class="form-group"> 3296 <label for="bug-title">Title *</label> 3297 <input type="text" id="bug-title" required maxlength="100" placeholder="Brief description of the issue"> 3298 </div> 3299 3300 <div class="form-group"> 3301 <label for="bug-namespace">Namespace *</label> 3302 <p class="hint">e.g., social.grain, app.bsky, fm.teal</p> 3303 <input type="text" id="bug-namespace" required placeholder="com.example" value="${esc(state.namespace || "")}" onblur="BugsApp.validateNamespaceOnBlur()"> 3304 <div class="error" id="namespace-error"></div> 3305 </div> 3306 3307 <div class="form-group"> 3308 <label for="bug-description">Description *</label> 3309 <textarea id="bug-description" required maxlength="3000" placeholder="What happened? What did you expect?"></textarea> 3310 <div class="form-hint">Supports **bold**, *italic*, \`code\`, and \`\`\`code blocks\`\`\`</div> 3311 </div> 3312 3313 <div class="form-group"> 3314 <label for="bug-steps">Steps to Reproduce *</label> 3315 <textarea id="bug-steps" required maxlength="1500" placeholder="1. Go to...\n2. Click on...\n3. See error"></textarea> 3316 <div class="form-hint">Supports **bold**, *italic*, \`code\`, and \`\`\`code blocks\`\`\`</div> 3317 </div> 3318 3319 <div class="form-group"> 3320 <label for="bug-severity">Severity *</label> 3321 <select id="bug-severity" required> 3322 <option value="">Select severity...</option> 3323 <option value="cosmetic">Cosmetic - Visual issue, doesn't affect function</option> 3324 <option value="annoying">Annoying - Works but frustrating</option> 3325 <option value="broken">Broken - Feature doesn't work correctly</option> 3326 <option value="unusable">Unusable - Can't use the app at all</option> 3327 </select> 3328 </div> 3329 3330 <div class="form-group"> 3331 <label for="bug-app">App Used (optional)</label> 3332 <input type="text" id="bug-app" maxlength="300" placeholder="e.g., Bluesky iOS, grain.social"> 3333 </div> 3334 3335 <div class="form-group"> 3336 <label>Screenshots (optional)</label> 3337 <div class="image-upload" onclick="document.getElementById('bug-images').click()"> 3338 <input type="file" id="bug-images" accept="image/*" multiple onchange="BugsApp.handleImageSelect(event)"> 3339 <p>Click to upload images</p> 3340 </div> 3341 <div class="image-previews" id="image-previews"></div> 3342 </div> 3343 3344 <div class="form-actions"> 3345 <button type="button" class="btn btn-secondary" onclick="BugsApp.closeModal()">Cancel</button> 3346 <button type="submit" class="btn btn-primary" id="submit-bug-btn">Submit Bug</button> 3347 </div> 3348 </form> 3349 </div> 3350 </div> 3351 `; 3352 modal.classList.remove("hidden"); 3353 } 3354 3355 function closeModal() { 3356 const modal = document.getElementById("modal"); 3357 modal.classList.add("hidden"); 3358 modal.innerHTML = ""; 3359 state.pendingImages = []; 3360 state.editingBug = null; 3361 state.existingAttachments = []; 3362 } 3363 3364 function openInfoModal() { 3365 const modal = document.getElementById("modal"); 3366 modal.innerHTML = ` 3367 <div class="modal-backdrop" onclick="BugsApp.closeModal()"></div> 3368 <div class="modal-content scrollable"> 3369 <div class="modal-header"> 3370 <h2>How Bug Tracker Works</h2> 3371 <button class="modal-close" onclick="BugsApp.closeModal()">×</button> 3372 </div> 3373 <div class="modal-body"> 3374 <div class="info-section"> 3375 <h3>Reporting Bugs</h3> 3376 <p>Anyone with an <a href="https://internethandle.org/" target="_blank">internet handle</a> can report bugs. Each bug belongs to a <strong>namespace</strong> (like <code>social.grain</code> or <code>fm.teal</code>) which identifies the project.</p> 3377 </div> 3378 3379 <div class="info-section"> 3380 <h3>Responding to Bugs</h3> 3381 <p>Only the domain authority can respond to bugs for their namespace. Your handle must exactly match the namespace domain (e.g., <code>@grain.social</code> can respond to <code>social.grain</code> bugs).</p> 3382 </div> 3383 3384 <div class="info-section"> 3385 <h3>Comments</h3> 3386 <p>Anyone logged in can add comments to discuss bugs, ask questions, or provide additional information. Comments support replies for threaded conversations and can include image attachments.</p> 3387 </div> 3388 3389 <div class="info-section"> 3390 <h3>Bug Statuses</h3> 3391 <ul class="status-list"> 3392 <li><span class="status-badge status-open">Open</span> No response yet</li> 3393 <li><span class="status-badge status-inprogress">In Progress</span> Acknowledged by maintainer</li> 3394 <li><span class="status-badge status-closed">Closed - Fixed</span> Bug has been resolved</li> 3395 <li><span class="status-badge status-wontfix">Closed - Won't Fix</span> Not planned to fix</li> 3396 <li><span class="status-badge status-duplicate">Closed - Duplicate</span> Already reported</li> 3397 <li><span class="status-badge status-invalid">Closed - Invalid</span> Not a valid bug</li> 3398 </ul> 3399 </div> 3400 3401 <div class="info-section"> 3402 <h3>Built on ATmosphere</h3> 3403 <p>This bug tracker is built on the <a href="https://atproto.com/" target="_blank">AT Protocol</a>. Your bugs and responses are stored in your personal data repository, giving you ownership of your data.</p> 3404 </div> 3405 3406 <div class="info-section"> 3407 <h3>Lexicons</h3> 3408 <p>The bug tracker uses the following <a href="https://tangled.sh/slices.network/tools/tree/main/lexicons" target="_blank">lexicon schemas</a>:</p> 3409 <div style="margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem;"> 3410 <div> 3411 <code style="font-weight: 600;">network.slices.tools.bug</code> 3412 <div style="color: var(--text-secondary); font-size: 0.875rem;">Bug reports with title, description, severity, and attachments</div> 3413 </div> 3414 <div> 3415 <code style="font-weight: 600;">network.slices.tools.bug.response</code> 3416 <div style="color: var(--text-secondary); font-size: 0.875rem;">Official responses from namespace maintainers</div> 3417 </div> 3418 <div> 3419 <code style="font-weight: 600;">network.slices.tools.bug.comment</code> 3420 <div style="color: var(--text-secondary); font-size: 0.875rem;">Discussion comments with optional replies and attachments</div> 3421 </div> 3422 <div> 3423 <code style="font-weight: 600;">network.slices.tools.bug.issue</code> 3424 <div style="color: var(--text-secondary); font-size: 0.875rem;">Links between bugs and Tangled repository issues</div> 3425 </div> 3426 </div> 3427 </div> 3428 </div> 3429 </div> 3430 `; 3431 modal.classList.remove("hidden"); 3432 } 3433 3434 function openEditModal(bugUri) { 3435 const bug = state.bugs.find((b) => b.uri === bugUri); 3436 if (!bug) return; 3437 3438 state.editingBug = bug; 3439 // Copy existing attachments so we can track removals 3440 state.existingAttachments = bug.attachments?.images ? [...bug.attachments.images] : []; 3441 3442 const modal = document.getElementById("modal"); 3443 modal.innerHTML = ` 3444 <div class="modal-backdrop" onclick="BugsApp.closeModal()"></div> 3445 <div class="modal-content scrollable"> 3446 <div class="modal-header"> 3447 <h2>Edit Bug</h2> 3448 <button class="modal-close" onclick="BugsApp.closeModal()">×</button> 3449 </div> 3450 <div class="modal-body"> 3451 <form id="bug-form" onsubmit="BugsApp.handleEditBug(event)"> 3452 <div class="form-group"> 3453 <label for="bug-title">Title *</label> 3454 <input type="text" id="bug-title" required maxlength="100" value="${esc(bug.title)}"> 3455 </div> 3456 3457 <div class="form-group"> 3458 <label for="bug-namespace">Namespace *</label> 3459 <p class="hint">e.g., social.grain, app.bsky, fm.teal</p> 3460 <input type="text" id="bug-namespace" required value="${esc(bug.namespace)}" onblur="BugsApp.validateNamespaceOnBlur()"> 3461 <div class="error" id="namespace-error"></div> 3462 </div> 3463 3464 <div class="form-group"> 3465 <label for="bug-description">Description *</label> 3466 <textarea id="bug-description" required maxlength="3000">${esc(bug.description)}</textarea> 3467 <div class="form-hint">Supports **bold**, *italic*, \`code\`, and \`\`\`code blocks\`\`\`</div> 3468 </div> 3469 3470 <div class="form-group"> 3471 <label for="bug-steps">Steps to Reproduce *</label> 3472 <textarea id="bug-steps" required maxlength="1500">${esc(bug.stepsToReproduce)}</textarea> 3473 <div class="form-hint">Supports **bold**, *italic*, \`code\`, and \`\`\`code blocks\`\`\`</div> 3474 </div> 3475 3476 <div class="form-group"> 3477 <label for="bug-severity">Severity *</label> 3478 <select id="bug-severity" required> 3479 <option value="cosmetic" ${bug.severity === "cosmetic" ? "selected" : ""}>Cosmetic - Visual issue, doesn't affect function</option> 3480 <option value="annoying" ${bug.severity === "annoying" ? "selected" : ""}>Annoying - Works but frustrating</option> 3481 <option value="broken" ${bug.severity === "broken" ? "selected" : ""}>Broken - Feature doesn't work correctly</option> 3482 <option value="unusable" ${bug.severity === "unusable" ? "selected" : ""}>Unusable - Can't use the app at all</option> 3483 </select> 3484 </div> 3485 3486 <div class="form-group"> 3487 <label for="bug-app">App Used (optional)</label> 3488 <input type="text" id="bug-app" maxlength="300" value="${esc(bug.appUsed || "")}"> 3489 </div> 3490 3491 <div class="form-group"> 3492 <label>Attachments</label> 3493 <div id="edit-attachments-list"></div> 3494 <div class="image-upload" onclick="document.getElementById('edit-images').click()"> 3495 <input type="file" id="edit-images" accept="image/*" multiple onchange="BugsApp.handleEditImageSelect(event)"> 3496 <p>Click to add images</p> 3497 </div> 3498 <div id="edit-new-images"></div> 3499 </div> 3500 3501 <div class="form-actions"> 3502 <button type="button" class="btn btn-secondary" onclick="BugsApp.closeModal()">Cancel</button> 3503 <button type="submit" class="btn btn-primary" id="submit-bug-btn">Save Changes</button> 3504 </div> 3505 </form> 3506 </div> 3507 </div> 3508 `; 3509 modal.classList.remove("hidden"); 3510 renderEditAttachments(); 3511 } 3512 3513 function renderEditAttachments() { 3514 const container = document.getElementById("edit-attachments-list"); 3515 if (!container) return; 3516 3517 if (state.existingAttachments.length === 0) { 3518 container.innerHTML = '<p class="text-secondary">No attachments</p>'; 3519 return; 3520 } 3521 3522 container.innerHTML = ` 3523 <div class="image-previews"> 3524 ${state.existingAttachments 3525 .map( 3526 (img, index) => ` 3527 <div class="image-preview"> 3528 <img src="${esc(img.image.url)}" alt="${esc(img.alt || "Attachment")}"> 3529 <button type="button" class="remove-image" onclick="BugsApp.removeExistingAttachment(${index})">×</button> 3530 </div> 3531 `, 3532 ) 3533 .join("")} 3534 </div> 3535 `; 3536 } 3537 3538 function removeExistingAttachment(index) { 3539 state.existingAttachments.splice(index, 1); 3540 renderEditAttachments(); 3541 } 3542 3543 async function handleEditImageSelect(event) { 3544 const files = Array.from(event.target.files); 3545 event.target.value = ""; 3546 3547 for (const file of files) { 3548 const totalImages = state.existingAttachments.length + state.pendingImages.length; 3549 if (totalImages >= 4) { 3550 showError("Maximum 4 images allowed"); 3551 return; 3552 } 3553 3554 try { 3555 const dataUrl = await readFileAsDataURL(file); 3556 const resized = await resizeImage(dataUrl, { 3557 width: 2000, 3558 height: 2000, 3559 maxSize: 900000, 3560 mode: "contain", 3561 }); 3562 state.pendingImages.push({ 3563 file, 3564 dataUrl: resized.dataUrl, 3565 }); 3566 renderEditNewImages(); 3567 } catch (err) { 3568 showError(`Failed to process ${file.name}: ${err.message}`); 3569 } 3570 } 3571 } 3572 3573 function renderEditNewImages() { 3574 const container = document.getElementById("edit-new-images"); 3575 if (!container) return; 3576 3577 if (state.pendingImages.length === 0) { 3578 container.innerHTML = ""; 3579 return; 3580 } 3581 3582 container.innerHTML = ` 3583 <div class="image-previews"> 3584 ${state.pendingImages 3585 .map( 3586 (img, index) => ` 3587 <div class="image-preview"> 3588 <img src="${img.dataUrl}" alt="New attachment"> 3589 <button type="button" class="remove-image" onclick="BugsApp.removeEditPendingImage(${index})">×</button> 3590 </div> 3591 `, 3592 ) 3593 .join("")} 3594 </div> 3595 `; 3596 } 3597 3598 function removeEditPendingImage(index) { 3599 state.pendingImages.splice(index, 1); 3600 renderEditNewImages(); 3601 } 3602 3603 async function handleEditBug(event) { 3604 event.preventDefault(); 3605 3606 const bug = state.editingBug; 3607 if (!bug) return; 3608 3609 const title = document.getElementById("bug-title").value.trim(); 3610 const namespace = document.getElementById("bug-namespace").value.trim().toLowerCase(); 3611 const description = document.getElementById("bug-description").value.trim(); 3612 const steps = document.getElementById("bug-steps").value.trim(); 3613 const severity = document.getElementById("bug-severity").value; 3614 const appUsed = document.getElementById("bug-app").value.trim(); 3615 3616 // Clear previous errors 3617 document.getElementById("namespace-error").innerHTML = ""; 3618 document.getElementById("bug-namespace").parentElement.classList.remove("has-error"); 3619 3620 // Validate namespace format 3621 if (!validateNamespace(namespace)) { 3622 document.getElementById("namespace-error").textContent = 3623 "Invalid format. Use: word.word (e.g., social.grain)"; 3624 document.getElementById("bug-namespace").parentElement.classList.add("has-error"); 3625 return; 3626 } 3627 3628 // Check if it looks like a domain (ends with TLD) 3629 const suggested = looksLikeDomain(namespace); 3630 if (suggested && !state.confirmedNamespace) { 3631 document.getElementById("namespace-error").innerHTML = ` 3632 This looks like a domain. Did you mean <strong>${esc(suggested)}</strong>? 3633 <div style="margin-top: 0.5rem;"> 3634 <button type="button" class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.875rem;" onclick="BugsApp.useSuggestedNamespace('${esc(suggested)}')">Use ${esc(suggested)}</button> 3635 <button type="button" class="btn btn-secondary" style="margin-left: 0.25rem; padding: 0.25rem 0.5rem; font-size: 0.875rem;" onclick="BugsApp.confirmNamespace()">Keep as-is</button> 3636 </div> 3637 `; 3638 document.getElementById("bug-namespace").parentElement.classList.add("has-error"); 3639 return; 3640 } 3641 3642 // Reset confirmed flag for next submission 3643 state.confirmedNamespace = false; 3644 3645 const submitBtn = document.getElementById("submit-bug-btn"); 3646 submitBtn.disabled = true; 3647 submitBtn.textContent = "Saving..."; 3648 3649 try { 3650 const rkey = getRkeyFromUri(bug.uri); 3651 3652 // Upload new images if any 3653 const uploadedImages = []; 3654 for (const pending of state.pendingImages) { 3655 const base64Data = pending.dataUrl.split(",")[1]; 3656 const uploadResult = await gqlMutation(UPLOAD_BLOB_MUTATION, { 3657 data: base64Data, 3658 mimeType: "image/jpeg", 3659 }); 3660 3661 if (uploadResult.uploadBlob) { 3662 uploadedImages.push({ 3663 alt: "", 3664 image: { 3665 $type: "blob", 3666 ref: { $link: uploadResult.uploadBlob.ref }, 3667 mimeType: uploadResult.uploadBlob.mimeType, 3668 size: uploadResult.uploadBlob.size, 3669 }, 3670 }); 3671 } 3672 } 3673 3674 // Build attachments from existing + new images 3675 const existingFormatted = state.existingAttachments.map((img) => ({ 3676 alt: img.alt || "", 3677 image: { 3678 $type: "blob", 3679 ref: { $link: img.image.ref }, 3680 mimeType: img.image.mimeType, 3681 size: img.image.size, 3682 }, 3683 })); 3684 3685 const allImages = [...existingFormatted, ...uploadedImages]; 3686 let attachments = null; 3687 if (allImages.length > 0) { 3688 attachments = { 3689 $type: "network.slices.tools.defs#images", 3690 images: allImages, 3691 }; 3692 } 3693 3694 // Parse facets for description and steps 3695 const descriptionParsed = parseFacets(description); 3696 const stepsParsed = parseFacets(steps); 3697 3698 const input = { 3699 title, 3700 namespace, 3701 description: descriptionParsed.text, 3702 ...(descriptionParsed.facets && { descriptionFacets: descriptionParsed.facets }), 3703 stepsToReproduce: stepsParsed.text, 3704 ...(stepsParsed.facets && { stepsToReproduceFacets: stepsParsed.facets }), 3705 severity, 3706 createdAt: bug.createdAt, // Keep original createdAt 3707 ...(appUsed && { appUsed }), 3708 ...(attachments && { attachments }), 3709 }; 3710 3711 await gqlMutation(UPDATE_BUG_MUTATION, { rkey, input }); 3712 3713 closeModal(); 3714 3715 // Re-fetch bugs to get fresh data with proper image URLs 3716 state.bugs = []; 3717 state.cursor = null; 3718 state.hasMore = true; 3719 await loadBugs(); 3720 3721 // Re-render overlay with updated data 3722 renderOverlay(); 3723 render(); 3724 3725 showSuccess("Bug updated successfully!"); 3726 } catch (err) { 3727 console.error("Update failed:", err); 3728 showError(`Failed to update: ${err.message}`); 3729 } finally { 3730 submitBtn.disabled = false; 3731 submitBtn.textContent = "Save Changes"; 3732 } 3733 } 3734 3735 async function handleDeleteBug(bugUri) { 3736 if (!confirm("Are you sure you want to delete this bug? This cannot be undone.")) { 3737 return; 3738 } 3739 3740 try { 3741 // Delete user's own comments on this bug first 3742 const comments = await fetchComments(bugUri); 3743 const myComments = comments.filter((c) => c.did === state.viewer.did); 3744 for (const comment of myComments) { 3745 const commentRkey = getRkeyFromUri(comment.uri); 3746 await gqlMutation(DELETE_COMMENT_MUTATION, { rkey: commentRkey }); 3747 } 3748 3749 const rkey = getRkeyFromUri(bugUri); 3750 await gqlMutation(DELETE_BUG_MUTATION, { rkey }); 3751 3752 // Close overlay and go back to list 3753 closeOverlay(); 3754 state.bugUri = null; 3755 state.view = "list"; 3756 3757 // Remove from local state 3758 state.bugs = state.bugs.filter((b) => b.uri !== bugUri); 3759 3760 // Update URL 3761 const url = new URL(window.location); 3762 url.searchParams.delete("bug"); 3763 history.pushState({}, "", url); 3764 3765 render(); 3766 showSuccess("Bug deleted successfully!"); 3767 } catch (err) { 3768 console.error("Delete failed:", err); 3769 showError(`Failed to delete: ${err.message}`); 3770 } 3771 } 3772 3773 async function shareBug() { 3774 const url = window.location.href; 3775 try { 3776 await navigator.clipboard.writeText(url); 3777 showSuccess("Link copied to clipboard!"); 3778 } catch (err) { 3779 // Fallback for older browsers 3780 prompt("Copy this link:", url); 3781 } 3782 } 3783 3784 async function handleDeleteResponse(responseUri) { 3785 if (!confirm("Delete this response?")) { 3786 return; 3787 } 3788 3789 try { 3790 const rkey = getRkeyFromUri(responseUri); 3791 await gqlMutation(DELETE_RESPONSE_MUTATION, { rkey }); 3792 3793 // Remove from local state 3794 state.responses = state.responses.filter((r) => r.uri !== responseUri); 3795 3796 // Update bug's embedded responses for status display 3797 const bug = state.bugs.find((b) => b.uri === state.bugUri); 3798 if (bug) { 3799 bug.networkSlicesToolsBugResponseViaBug = { 3800 edges: state.responses.map((r) => ({ 3801 node: { status: r.status, createdAt: r.createdAt }, 3802 })), 3803 }; 3804 } 3805 3806 // Re-render overlay and list 3807 renderOverlay(); 3808 render(); // Update list to show new status 3809 3810 showSuccess("Response deleted!"); 3811 } catch (err) { 3812 console.error("Delete response failed:", err); 3813 showError(`Failed to delete: ${err.message}`); 3814 } 3815 } 3816 3817 // Image handling 3818 state.pendingImages = []; 3819 3820 async function handleImageSelect(event) { 3821 const files = Array.from(event.target.files); 3822 event.target.value = ""; 3823 3824 for (const file of files) { 3825 try { 3826 const dataUrl = await readFileAsDataURL(file); 3827 const resized = await resizeImage(dataUrl, { 3828 width: 2000, 3829 height: 2000, 3830 maxSize: 900000, 3831 mode: "contain", 3832 }); 3833 state.pendingImages.push({ 3834 file, 3835 dataUrl: resized.dataUrl, 3836 }); 3837 renderImagePreviews(); 3838 } catch (err) { 3839 showError(`Failed to process ${file.name}: ${err.message}`); 3840 } 3841 } 3842 } 3843 3844 function renderImagePreviews() { 3845 const previews = document.getElementById("image-previews"); 3846 if (!previews) return; 3847 3848 previews.innerHTML = state.pendingImages 3849 .map( 3850 (img, i) => ` 3851 <div class="image-preview"> 3852 <img src="${img.dataUrl}" alt="Preview"> 3853 <button type="button" onclick="BugsApp.removeImage(${i})">×</button> 3854 </div> 3855 `, 3856 ) 3857 .join(""); 3858 } 3859 3860 function removeImage(index) { 3861 state.pendingImages.splice(index, 1); 3862 renderImagePreviews(); 3863 } 3864 3865 async function handleSubmitBug(event) { 3866 event.preventDefault(); 3867 3868 const title = document.getElementById("bug-title").value.trim(); 3869 const namespace = document.getElementById("bug-namespace").value.trim().toLowerCase(); 3870 const description = document.getElementById("bug-description").value.trim(); 3871 const steps = document.getElementById("bug-steps").value.trim(); 3872 const severity = document.getElementById("bug-severity").value; 3873 const appUsed = document.getElementById("bug-app").value.trim(); 3874 3875 // Clear previous errors 3876 document.getElementById("namespace-error").innerHTML = ""; 3877 document.getElementById("bug-namespace").parentElement.classList.remove("has-error"); 3878 3879 // Validate namespace format 3880 if (!validateNamespace(namespace)) { 3881 document.getElementById("namespace-error").textContent = 3882 "Invalid format. Use: word.word (e.g., social.grain)"; 3883 document.getElementById("bug-namespace").parentElement.classList.add("has-error"); 3884 return; 3885 } 3886 3887 // Check if it looks like a domain (ends with TLD) 3888 const suggested = looksLikeDomain(namespace); 3889 if (suggested && !state.confirmedNamespace) { 3890 document.getElementById("namespace-error").innerHTML = ` 3891 This looks like a domain. Did you mean <strong>${esc(suggested)}</strong>? 3892 <div style="margin-top: 0.5rem;"> 3893 <button type="button" class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.875rem;" onclick="BugsApp.useSuggestedNamespace('${esc(suggested)}')">Use ${esc(suggested)}</button> 3894 <button type="button" class="btn btn-secondary" style="margin-left: 0.25rem; padding: 0.25rem 0.5rem; font-size: 0.875rem;" onclick="BugsApp.confirmNamespace()">Keep as-is</button> 3895 </div> 3896 `; 3897 document.getElementById("bug-namespace").parentElement.classList.add("has-error"); 3898 return; 3899 } 3900 3901 // Reset confirmed flag for next submission 3902 state.confirmedNamespace = false; 3903 3904 const submitBtn = document.getElementById("submit-bug-btn"); 3905 submitBtn.disabled = true; 3906 submitBtn.textContent = "Submitting..."; 3907 3908 try { 3909 // Upload images if any 3910 let attachments = null; 3911 if (state.pendingImages.length > 0) { 3912 const uploadedImages = []; 3913 for (const img of state.pendingImages) { 3914 const base64Data = img.dataUrl.split(",")[1]; 3915 const uploadResult = await gqlMutation(UPLOAD_BLOB_MUTATION, { 3916 data: base64Data, 3917 mimeType: "image/jpeg", 3918 }); 3919 if (uploadResult.uploadBlob) { 3920 uploadedImages.push({ 3921 image: { 3922 $type: "blob", 3923 ref: { $link: uploadResult.uploadBlob.ref }, 3924 mimeType: uploadResult.uploadBlob.mimeType, 3925 size: uploadResult.uploadBlob.size, 3926 }, 3927 alt: "", 3928 }); 3929 } 3930 } 3931 attachments = { 3932 $type: "network.slices.tools.defs#images", 3933 images: uploadedImages, 3934 }; 3935 } 3936 3937 // Parse facets for description and steps 3938 const descriptionParsed = parseFacets(description); 3939 const stepsParsed = parseFacets(steps); 3940 3941 // Create bug 3942 const input = { 3943 title, 3944 namespace, 3945 description: descriptionParsed.text, 3946 ...(descriptionParsed.facets && { descriptionFacets: descriptionParsed.facets }), 3947 stepsToReproduce: stepsParsed.text, 3948 ...(stepsParsed.facets && { stepsToReproduceFacets: stepsParsed.facets }), 3949 severity, 3950 createdAt: new Date().toISOString(), 3951 ...(appUsed && { appUsed }), 3952 ...(attachments && { attachments }), 3953 }; 3954 3955 await gqlMutation(CREATE_BUG_MUTATION, { input }); 3956 3957 closeModal(); 3958 3959 // Navigate to the namespace and reload 3960 state.namespace = namespace; 3961 state.view = "list"; 3962 state.bugs = []; 3963 updateUrl({ ns: namespace, bug: null }); 3964 await loadBugs(); 3965 3966 showSuccess("Bug reported successfully!"); 3967 } catch (err) { 3968 console.error("Submit failed:", err); 3969 showError(`Failed to submit: ${err.message}`); 3970 } finally { 3971 submitBtn.disabled = false; 3972 submitBtn.textContent = "Submit Bug"; 3973 } 3974 } 3975 3976 function showSuccess(msg) { 3977 // Reuse error banner with different styling 3978 const el = document.getElementById("error-banner"); 3979 el.style.background = "#dcfce7"; 3980 el.style.borderColor = "#86efac"; 3981 el.style.color = "#16a34a"; 3982 el.innerHTML = `<span>✓ ${esc(msg)}</span><button style="color: #16a34a" onclick="BugsApp.hideError()">×</button>`; 3983 el.classList.remove("hidden"); 3984 setTimeout(hideError, 3000); 3985 } 3986 3987 // ============================================================================= 3988 // MAIN 3989 // ============================================================================= 3990 3991 async function main() { 3992 // Handle OAuth callback first 3993 await handleOAuthCallback(); 3994 3995 parseUrl(); 3996 window.addEventListener("popstate", () => { 3997 parseUrl(); 3998 render(); 3999 if (state.view === "landing") loadNamespaces(); 4000 if (state.view === "list") loadBugs(); 4001 }); 4002 4003 // Check auth 4004 await checkAuth(); 4005 render(); 4006 4007 if (state.view === "landing") { 4008 await loadNamespaces(); 4009 } else if (state.view === "list" || state.view === "detail") { 4010 await loadBugs(); 4011 } 4012 } 4013 4014 function parseUrl() { 4015 const params = new URLSearchParams(window.location.search); 4016 state.namespace = params.get("ns"); 4017 state.bugUri = params.get("bug"); 4018 state.view = state.namespace ? (state.bugUri ? "detail" : "list") : "landing"; 4019 } 4020 4021 async function loadNamespaces() { 4022 state.isLoading = true; 4023 render(); 4024 4025 try { 4026 state.namespaces = await fetchNamespaces(); 4027 } catch (err) { 4028 console.error("Failed to load namespaces:", err); 4029 showError(`Failed to load: ${err.message}`); 4030 } finally { 4031 state.isLoading = false; 4032 render(); 4033 } 4034 } 4035 4036 async function loadBugs() { 4037 state.isLoading = true; 4038 render(); 4039 4040 try { 4041 const data = await fetchBugs(state.namespace, null); 4042 state.bugs = data.edges.map((e) => e.node); 4043 state.cursor = data.pageInfo.endCursor; 4044 state.hasMore = data.pageInfo.hasNextPage; 4045 } catch (err) { 4046 console.error("Failed to load bugs:", err); 4047 showError(`Failed to load: ${err.message}`); 4048 } finally { 4049 state.isLoading = false; 4050 render(); 4051 } 4052 } 4053 4054 function render() { 4055 renderHeaderComponent(); 4056 4057 const main = document.getElementById("main"); 4058 switch (state.view) { 4059 case "landing": 4060 main.innerHTML = renderLanding(); 4061 break; 4062 case "list": 4063 case "detail": 4064 main.innerHTML = renderBugList(); 4065 if (state.view === "detail" && state.bugUri) { 4066 renderOverlay(); 4067 } 4068 break; 4069 } 4070 lucide.createIcons(); 4071 } 4072 4073 function renderHeaderComponent() { 4074 const header = document.getElementById("header"); 4075 4076 let left = `<h1>🐛 Bug Tracker</h1> <button class="btn-info" onclick="BugsApp.openInfoModal()" title="How it works">?</button>`; 4077 if (state.namespace) { 4078 left = ` 4079 <div class="breadcrumb"> 4080 <a href="?" onclick="BugsApp.navigateHome(event)">🐛 Bug Tracker</a> 4081 <button class="btn-info" onclick="BugsApp.openInfoModal()" title="How it works">?</button> 4082 <span>/</span> 4083 <span>${esc(state.namespace)}</span> 4084 </div> 4085 `; 4086 } 4087 4088 const right = state.viewer 4089 ? `<div class="user-status"> 4090 ${renderAvatar(state.viewer.appBskyActorProfileByDid, state.viewer.handle, "user-avatar-xl user-avatar-ring")} 4091 <button class="btn-icon" onclick="BugsApp.logout()" title="Logout"><i data-lucide="log-out"></i></button> 4092 </div>` 4093 : `<button class="btn btn-primary" onclick="BugsApp.login()">Login</button>`; 4094 4095 header.innerHTML = `${left}<div class="user-status">${right}</div>`; 4096 } 4097 4098 function navigateHome(event) { 4099 event.preventDefault(); 4100 state.namespace = null; 4101 state.bugUri = null; 4102 state.view = "landing"; 4103 state.bugs = []; 4104 updateUrl({ ns: null, bug: null }); 4105 render(); 4106 loadNamespaces(); 4107 } 4108 4109 // ============================================================================= 4110 // OAUTH 4111 // ============================================================================= 4112 4113 let client = null; 4114 4115 async function initClient() { 4116 if (!client) { 4117 client = await QuicksliceClient.createQuicksliceClient({ 4118 server: SERVER_URL, 4119 clientId: CLIENT_ID, 4120 scope: 4121 "atproto repo:network.slices.tools.bug repo:network.slices.tools.bug.response repo:network.slices.tools.bug.comment repo:network.slices.tools.bug.issue repo:sh.tangled.repo.issue blob:image/*", 4122 }); 4123 } 4124 return client; 4125 } 4126 4127 async function handleOAuthCallback() { 4128 const params = new URLSearchParams(window.location.search); 4129 if (params.has("code") || params.has("state")) { 4130 try { 4131 await initClient(); 4132 await client.handleRedirectCallback(); 4133 // Clean up URL 4134 window.history.replaceState({}, "", window.location.pathname); 4135 } catch (err) { 4136 console.error("OAuth callback error:", err); 4137 showError(`Authentication failed: ${err.message}`); 4138 } 4139 } 4140 } 4141 4142 // Handle autocomplete 4143 let handleInputTimeout = null; 4144 4145 async function handleHandleInput(event) { 4146 const query = event.target.value.trim(); 4147 4148 // Debounce 4149 clearTimeout(handleInputTimeout); 4150 if (!query || query.length < 2) { 4151 state.handleSuggestions = []; 4152 state.handleSuggestionIndex = -1; 4153 renderHandleSuggestions(); 4154 return; 4155 } 4156 4157 handleInputTimeout = setTimeout(async () => { 4158 try { 4159 const url = new URL( 4160 "https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead", 4161 ); 4162 url.searchParams.set("q", query); 4163 url.searchParams.set("limit", "5"); 4164 4165 const res = await fetch(url); 4166 if (!res.ok) return; 4167 4168 const json = await res.json(); 4169 state.handleSuggestions = json.actors || []; 4170 state.handleSuggestionIndex = -1; 4171 renderHandleSuggestions(); 4172 } catch (err) { 4173 console.error("Handle search failed:", err); 4174 } 4175 }, 200); 4176 } 4177 4178 function handleHandleKeydown(event) { 4179 if (state.handleSuggestions.length === 0) return; 4180 4181 switch (event.key) { 4182 case "ArrowDown": 4183 event.preventDefault(); 4184 state.handleSuggestionIndex = Math.min( 4185 state.handleSuggestionIndex + 1, 4186 state.handleSuggestions.length - 1, 4187 ); 4188 renderHandleSuggestions(); 4189 break; 4190 4191 case "ArrowUp": 4192 event.preventDefault(); 4193 state.handleSuggestionIndex = Math.max(state.handleSuggestionIndex - 1, 0); 4194 renderHandleSuggestions(); 4195 break; 4196 4197 case "Enter": 4198 if (state.handleSuggestionIndex >= 0) { 4199 event.preventDefault(); 4200 selectHandleSuggestion(state.handleSuggestionIndex); 4201 } 4202 break; 4203 4204 case "Escape": 4205 event.preventDefault(); 4206 clearHandleSuggestions(); 4207 break; 4208 } 4209 } 4210 4211 function renderHandleSuggestions() { 4212 const menu = document.getElementById("handle-suggestions"); 4213 if (!menu) return; 4214 4215 if (state.handleSuggestions.length === 0) { 4216 menu.innerHTML = ""; 4217 return; 4218 } 4219 4220 menu.innerHTML = state.handleSuggestions 4221 .map( 4222 (actor, i) => ` 4223 <li class="autocomplete-item ${i === state.handleSuggestionIndex ? "active" : ""}" 4224 onmousedown="BugsApp.selectHandleSuggestion(${i})"> 4225 <div class="autocomplete-avatar"> 4226 ${actor.avatar ? `<img src="${esc(actor.avatar)}" alt="">` : ""} 4227 </div> 4228 <span class="autocomplete-handle">${esc(actor.handle)}</span> 4229 </li> 4230 `, 4231 ) 4232 .join(""); 4233 } 4234 4235 function selectHandleSuggestion(index) { 4236 const actor = state.handleSuggestions[index]; 4237 if (!actor) return; 4238 4239 const input = document.getElementById("login-handle"); 4240 if (input) input.value = actor.handle; 4241 4242 clearHandleSuggestions(); 4243 } 4244 4245 function clearHandleSuggestions() { 4246 state.handleSuggestions = []; 4247 state.handleSuggestionIndex = -1; 4248 const menu = document.getElementById("handle-suggestions"); 4249 if (menu) menu.innerHTML = ""; 4250 } 4251 4252 function handleReportBug() { 4253 if (state.viewer) { 4254 openSubmitModal(); 4255 } else { 4256 login(); 4257 } 4258 } 4259 4260 function login() { 4261 // Show login modal to get handle 4262 const modal = document.getElementById("modal"); 4263 modal.innerHTML = ` 4264 <div class="modal-backdrop" onclick="BugsApp.closeModal()"></div> 4265 <div class="modal-content"> 4266 <div class="modal-header"> 4267 <h2>Login</h2> 4268 <button class="modal-close" onclick="BugsApp.closeModal()">×</button> 4269 </div> 4270 <div class="modal-body"> 4271 <form onsubmit="BugsApp.handleLogin(event)"> 4272 <div class="form-group handle-autocomplete"> 4273 <label for="login-handle">Sign in with your <a href="https://internethandle.org/" target="_blank">internet handle</a></label> 4274 <input type="text" id="login-handle" required placeholder="you.bsky.social" autocomplete="off" data-1p-ignore 4275 oninput="BugsApp.handleHandleInput(event)" 4276 onkeydown="BugsApp.handleHandleKeydown(event)" 4277 onfocusout="setTimeout(() => BugsApp.clearHandleSuggestions(), 150)"> 4278 <ul id="handle-suggestions" class="autocomplete-menu"></ul> 4279 </div> 4280 <div class="form-actions"> 4281 <button type="button" class="btn btn-secondary" onclick="BugsApp.closeModal()">Cancel</button> 4282 <button type="submit" class="btn btn-primary">Login</button> 4283 </div> 4284 </form> 4285 </div> 4286 </div> 4287 `; 4288 modal.classList.remove("hidden"); 4289 state.handleSuggestions = []; 4290 state.handleSuggestionIndex = -1; 4291 } 4292 4293 async function handleLogin(event) { 4294 event.preventDefault(); 4295 const handle = document.getElementById("login-handle").value.trim(); 4296 if (!handle) { 4297 showError("Please enter your handle"); 4298 return; 4299 } 4300 4301 try { 4302 await initClient(); 4303 await client.loginWithRedirect({ 4304 handle, 4305 scope: 4306 "atproto repo:network.slices.tools.bug repo:network.slices.tools.bug.response repo:network.slices.tools.bug.comment repo:network.slices.tools.bug.issue repo:sh.tangled.repo.issue blob:image/*", 4307 }); 4308 } catch (err) { 4309 console.error("Login failed:", err); 4310 showError(`Login failed: ${err.message}`); 4311 } 4312 } 4313 4314 function logout() { 4315 if (client) { 4316 client.logout(); 4317 } 4318 state.viewer = null; 4319 render(); 4320 } 4321 4322 async function checkAuth() { 4323 try { 4324 await initClient(); 4325 if (await client.isAuthenticated()) { 4326 const data = await client.query( 4327 `query { viewer { did handle appBskyActorProfileByDid { avatar { url(preset: "avatar") } } } }`, 4328 ); 4329 state.viewer = data?.viewer; 4330 } else { 4331 state.viewer = null; 4332 } 4333 } catch (err) { 4334 console.error("checkAuth error:", err); 4335 state.viewer = null; 4336 } 4337 } 4338 4339 // Expose functions via namespace for inline event handlers 4340 // (required because script type="module" scopes everything to the module) 4341 window.BugsApp = { 4342 // Error display 4343 hideError, 4344 // Namespace suggestions 4345 useSuggestedNamespace, 4346 dismissNamespaceSuggestion, 4347 confirmNamespace, 4348 validateNamespaceOnBlur, 4349 // Navigation 4350 navigateToNamespace, 4351 navigateHome, 4352 // Bug list 4353 handleReportBug, 4354 handleSeverityFilter, 4355 loadMoreBugs, 4356 openBugDetail, 4357 // Overlay/modal 4358 closeOverlay, 4359 closeModal, 4360 openInfoModal, 4361 // Bug actions 4362 shareBug, 4363 openEditModal, 4364 handleDeleteBug, 4365 handleSubmitBug, 4366 handleEditBug, 4367 // Images 4368 handleImageSelect, 4369 handleEditImageSelect, 4370 removeImage, 4371 removeExistingAttachment, 4372 removeEditPendingImage, 4373 openLightbox, 4374 // Link issue modal 4375 openLinkIssueModal, 4376 closeLinkIssueModal, 4377 selectRepoForLinking, 4378 goBackToRepos, 4379 createAndLinkIssue, 4380 linkToExistingIssue, 4381 unlinkIssue, 4382 // Comments 4383 handleSubmitComment, 4384 handleDeleteComment, 4385 startEditComment, 4386 cancelEditComment, 4387 handleSaveEditComment, 4388 removeEditCommentAttachment, 4389 showReplyForm, 4390 cancelReply, 4391 handleCommentImageSelect, 4392 removeCommentImage, 4393 // Responses 4394 handleSubmitResponse, 4395 handleDeleteResponse, 4396 // Auth 4397 login, 4398 logout, 4399 handleLogin, 4400 handleHandleInput, 4401 handleHandleKeydown, 4402 selectHandleSuggestion, 4403 clearHandleSuggestions, 4404 }; 4405 4406 main(); 4407 </script> 4408 </body> 4409</html>