Tools for the Atmosphere
tools.slices.network
quickslice
atproto
html
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <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>