The Go90 Scale of Doomed Streaming Services
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>Go90 Social</title>
7 <link rel="icon" type="image/svg+xml" href="favicon.svg" />
8 <style>
9 @font-face {
10 font-family: "Innovator Grotesk";
11 src: url("InnovatorGroteskVF-VF.ttf") format("truetype");
12 font-weight: 100 900;
13 font-stretch: 75% 125%;
14 font-style: normal;
15 }
16
17 *,
18 *::before,
19 *::after {
20 box-sizing: border-box;
21 }
22 * {
23 margin: 0;
24 }
25 body {
26 line-height: 1.5;
27 -webkit-font-smoothing: antialiased;
28 }
29 input,
30 button {
31 font: inherit;
32 }
33
34 :root {
35 --primary-500: #0078ff;
36 --primary-600: #0060cc;
37 --gray-100: #f5f5f5;
38 --gray-200: #e5e5e5;
39 --gray-500: #737373;
40 --gray-700: #404040;
41 --gray-900: #171717;
42 --border-color: #e5e5e5;
43 --error-bg: #fef2f2;
44 --error-border: #fecaca;
45 --error-text: #dc2626;
46 --go90-blue: #2020ff;
47 --go90-yellow: #ffff00;
48 }
49
50 body {
51 font-family:
52 "Innovator Grotesk",
53 -apple-system,
54 BlinkMacSystemFont,
55 "Segoe UI",
56 Roboto,
57 sans-serif;
58 background: var(--go90-blue);
59 color: white;
60 min-height: 100vh;
61 padding: 2rem 1rem;
62 }
63
64 #app {
65 max-width: 1100px;
66 margin: 0 auto;
67 }
68
69 header {
70 text-align: center;
71 margin-bottom: 2rem;
72 }
73
74 .logo {
75 width: 64px;
76 height: 64px;
77 margin-bottom: 0.5rem;
78 }
79
80 header h1 {
81 font-size: 2.5rem;
82 color: var(--go90-yellow);
83 margin-bottom: 0.25rem;
84 font-weight: 900;
85 text-transform: uppercase;
86 letter-spacing: 2px;
87 }
88
89 .tagline {
90 color: white;
91 font-size: 1.125rem;
92 }
93
94 .card {
95 background: white;
96 border-radius: 0.5rem;
97 padding: 1.5rem;
98 margin-bottom: 1rem;
99 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
100 }
101
102 .login-form {
103 display: flex;
104 flex-direction: column;
105 gap: 1rem;
106 }
107
108 .form-group {
109 display: flex;
110 flex-direction: column;
111 gap: 0.25rem;
112 }
113
114 .form-group label {
115 font-size: 0.875rem;
116 font-weight: 500;
117 color: var(--gray-700);
118 }
119
120 .form-group input {
121 padding: 0.75rem;
122 border: 1px solid var(--border-color);
123 border-radius: 0.375rem;
124 font-size: 1rem;
125 }
126
127 .form-group input:focus {
128 outline: none;
129 border-color: var(--primary-500);
130 box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1);
131 }
132
133 qs-actor-autocomplete {
134 --qs-input-border: var(--border-color);
135 --qs-input-border-focus: var(--primary-500);
136 --qs-input-padding: 0.75rem;
137 --qs-radius: 0.375rem;
138 }
139
140 .btn {
141 padding: 0.75rem 1.5rem;
142 border: none;
143 border-radius: 0.375rem;
144 font-size: 1rem;
145 font-weight: 500;
146 cursor: pointer;
147 transition: background-color 0.15s;
148 }
149
150 .btn-primary {
151 background: var(--primary-500);
152 color: white;
153 }
154
155 .btn-primary:hover {
156 background: var(--primary-600);
157 }
158
159 .btn-secondary {
160 background: var(--gray-200);
161 color: var(--gray-700);
162 }
163
164 .btn-secondary:hover {
165 background: var(--border-color);
166 }
167
168 .user-card {
169 display: flex;
170 align-items: center;
171 justify-content: space-between;
172 }
173
174 .user-info {
175 display: flex;
176 align-items: center;
177 gap: 0.75rem;
178 }
179
180 .user-avatar {
181 width: 48px;
182 height: 48px;
183 border-radius: 50%;
184 background: var(--gray-200);
185 display: flex;
186 align-items: center;
187 justify-content: center;
188 font-size: 1.5rem;
189 }
190
191 .user-avatar img {
192 width: 100%;
193 height: 100%;
194 border-radius: 50%;
195 object-fit: cover;
196 }
197
198 .user-name {
199 font-weight: 600;
200 }
201
202 .user-handle {
203 font-size: 0.875rem;
204 color: var(--gray-500);
205 }
206
207 #error-banner {
208 position: fixed;
209 top: 1rem;
210 left: 50%;
211 transform: translateX(-50%);
212 background: var(--error-bg);
213 border: 1px solid var(--error-border);
214 color: var(--error-text);
215 padding: 0.75rem 1rem;
216 border-radius: 0.375rem;
217 display: flex;
218 align-items: center;
219 gap: 0.75rem;
220 max-width: 90%;
221 z-index: 100;
222 }
223
224 #error-banner.hidden {
225 display: none;
226 }
227
228 #error-banner button {
229 background: none;
230 border: none;
231 color: var(--error-text);
232 cursor: pointer;
233 font-size: 1.25rem;
234 line-height: 1;
235 }
236
237 .hidden {
238 display: none !important;
239 }
240
241 .rating-form {
242 display: flex;
243 flex-direction: column;
244 gap: 1rem;
245 }
246
247 .rating-slider-container {
248 display: flex;
249 flex-direction: column;
250 gap: 0.5rem;
251 }
252
253 .rating-display {
254 text-align: center;
255 font-size: 3rem;
256 font-weight: 700;
257 color: var(--primary-500);
258 margin: 0.5rem 0;
259 }
260
261 .rating-display.defunct {
262 color: var(--error-text);
263 }
264
265 .rating-slider {
266 width: 100%;
267 height: 8px;
268 -webkit-appearance: none;
269 appearance: none;
270 background: linear-gradient(to right, #32cd32 0%, #ffd700 50%, #ff5722 100%);
271 border-radius: 4px;
272 outline: none;
273 }
274
275 .rating-slider::-webkit-slider-thumb {
276 -webkit-appearance: none;
277 appearance: none;
278 width: 24px;
279 height: 24px;
280 background: white;
281 border: 2px solid var(--primary-500);
282 border-radius: 50%;
283 cursor: pointer;
284 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
285 }
286
287 .rating-slider::-moz-range-thumb {
288 width: 24px;
289 height: 24px;
290 background: white;
291 border: 2px solid var(--primary-500);
292 border-radius: 50%;
293 cursor: pointer;
294 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
295 }
296
297 .rating-labels {
298 display: flex;
299 justify-content: space-between;
300 font-size: 0.875rem;
301 color: var(--gray-500);
302 }
303
304 textarea {
305 padding: 0.75rem;
306 border: 1px solid var(--border-color);
307 border-radius: 0.375rem;
308 font-size: 1rem;
309 resize: vertical;
310 min-height: 80px;
311 font-family: inherit;
312 }
313
314 textarea:focus {
315 outline: none;
316 border-color: var(--primary-500);
317 box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1);
318 }
319
320 .char-count {
321 text-align: right;
322 font-size: 0.75rem;
323 color: var(--gray-500);
324 }
325
326 .ratings-list {
327 display: flex;
328 flex-direction: column;
329 gap: 1rem;
330 }
331
332 .rating-item {
333 border-bottom: 1px solid var(--border-color);
334 padding-bottom: 1rem;
335 }
336
337 .rating-item:last-child {
338 border-bottom: none;
339 padding-bottom: 0;
340 }
341
342 .rating-header {
343 display: flex;
344 align-items: center;
345 gap: 0.75rem;
346 margin-bottom: 0.5rem;
347 }
348
349 .service-favicon {
350 width: 24px;
351 height: 24px;
352 border-radius: 4px;
353 }
354
355 .service-name {
356 font-weight: 600;
357 color: var(--gray-900);
358 flex: 1;
359 }
360
361 .rating-value {
362 font-size: 1.25rem;
363 font-weight: 700;
364 color: var(--primary-500);
365 }
366
367 .rating-value.defunct {
368 color: var(--error-text);
369 }
370
371 .rating-meta {
372 font-size: 0.875rem;
373 color: var(--gray-500);
374 margin-bottom: 0.5rem;
375 }
376
377 .rating-comment {
378 color: var(--gray-700);
379 font-size: 0.875rem;
380 }
381
382 .empty-state {
383 text-align: center;
384 padding: 2rem;
385 color: var(--gray-500);
386 }
387
388 .section-title {
389 font-size: 1.25rem;
390 font-weight: 600;
391 margin-bottom: 0.5rem;
392 color: var(--gray-900);
393 }
394
395 .btn-block {
396 width: 100%;
397 }
398
399 /* Go90 Scale Interface */
400 .scale-container {
401 background: var(--go90-blue);
402 padding: 2rem;
403 border-radius: 8px;
404 margin-bottom: 2rem;
405 position: relative;
406 display: flex;
407 flex-direction: column;
408 align-items: center;
409 }
410
411 .drag-canvas {
412 position: relative;
413 width: 100%;
414 height: 700px;
415 margin-bottom: 2rem;
416 }
417
418 #arcCanvas {
419 position: absolute;
420 top: 0;
421 left: 0;
422 pointer-events: none;
423 }
424
425 .service-on-canvas {
426 position: absolute;
427 cursor: grab;
428 transition: transform 0.1s;
429 }
430
431 .service-on-canvas:active {
432 cursor: grabbing;
433 }
434
435 .service-on-canvas:hover {
436 transform: scale(1.05);
437 }
438
439 .service-icon {
440 width: 80px;
441 height: 80px;
442 border-radius: 12px;
443 background: white;
444 padding: 8px;
445 box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
446 border: 3px solid var(--go90-yellow);
447 display: block;
448 }
449
450 .service-badge {
451 position: absolute;
452 bottom: -12px;
453 right: -12px;
454 background: black;
455 color: var(--go90-yellow);
456 font-size: 1.25rem;
457 font-weight: 900;
458 padding: 4px 12px;
459 border-radius: 50%;
460 border: 3px solid var(--go90-yellow);
461 min-width: 48px;
462 text-align: center;
463 }
464
465 .service-badge.defunct {
466 color: #ff5555;
467 border-color: #ff5555;
468 }
469
470 .services-bar {
471 background: transparent;
472 padding: 1.5rem;
473 border-radius: 8px;
474 display: flex;
475 gap: 1rem;
476 align-items: center;
477 justify-content: center;
478 flex-wrap: wrap;
479 border: 3px solid rgba(255, 255, 255, 0.3);
480 }
481
482 .service-item {
483 width: 80px;
484 height: 80px;
485 border-radius: 8px;
486 background: black;
487 padding: 8px;
488 cursor: grab;
489 transition:
490 transform 0.2s,
491 opacity 0.2s;
492 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
493 position: relative;
494 }
495
496 .service-item:hover {
497 transform: scale(1.05);
498 }
499
500 .service-item:active {
501 cursor: grabbing;
502 }
503
504 .service-item.dragging {
505 opacity: 0.3;
506 }
507
508 .service-item.on-canvas {
509 position: absolute;
510 top: 0;
511 left: 0;
512 }
513
514 .service-logo {
515 width: 100%;
516 height: 100%;
517 object-fit: contain;
518 }
519
520 .add-service-form {
521 display: flex;
522 gap: 0.5rem;
523 align-items: center;
524 justify-content: center;
525 width: 100%;
526 max-width: 600px;
527 }
528
529 .add-service-input {
530 flex: 1;
531 padding: 1rem 1.5rem;
532 border: 3px solid rgba(255, 255, 255, 0.3);
533 border-radius: 8px;
534 background: transparent;
535 color: white;
536 font-size: 1.25rem;
537 font-weight: 500;
538 }
539
540 .add-service-input::placeholder {
541 color: rgba(255, 255, 255, 0.5);
542 }
543
544 .add-service-input:focus {
545 outline: none;
546 border-color: white;
547 }
548
549 .add-service-btn {
550 padding: 1rem 1.5rem;
551 background: transparent;
552 color: white;
553 border: 3px solid rgba(255, 255, 255, 0.3);
554 border-radius: 8px;
555 font-weight: 700;
556 cursor: pointer;
557 font-size: 1.25rem;
558 transition: all 0.2s;
559 }
560
561 .add-service-btn:hover {
562 border-color: white;
563 background: rgba(255, 255, 255, 0.1);
564 }
565
566 .instructions {
567 text-align: center;
568 color: white;
569 font-size: 1.125rem;
570 margin-bottom: 2rem;
571 opacity: 0.9;
572 }
573
574 .save-button {
575 position: fixed;
576 bottom: 2rem;
577 right: 2rem;
578 padding: 1rem 2rem;
579 background: var(--go90-yellow);
580 color: black;
581 border: none;
582 border-radius: 8px;
583 font-weight: 900;
584 font-size: 1.25rem;
585 cursor: pointer;
586 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
587 text-transform: uppercase;
588 letter-spacing: 1px;
589 display: none;
590 }
591
592 .save-button.visible {
593 display: block;
594 animation: pulse 2s infinite;
595 }
596
597 .save-button:hover {
598 background: #ffff44;
599 transform: scale(1.05);
600 }
601
602 @keyframes pulse {
603 0%,
604 100% {
605 transform: scale(1);
606 }
607 50% {
608 transform: scale(1.05);
609 }
610 }
611 </style>
612 </head>
613 <body>
614 <div id="app">
615 <header>
616 <svg class="logo" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
617 <g transform="translate(64, 64)">
618 <ellipse cx="0" cy="-28" rx="50" ry="20" fill="#FF5722" />
619 <ellipse cx="0" cy="0" rx="60" ry="20" fill="#00ACC1" />
620 <ellipse cx="0" cy="28" rx="40" ry="20" fill="#32CD32" />
621 </g>
622 </svg>
623 <h1>Go90 Scale</h1>
624 <p class="tagline">Rate streaming services on the Go90 scale</p>
625 </header>
626 <main>
627 <div id="auth-section"></div>
628 <div id="content"></div>
629 </main>
630 <div id="error-banner" class="hidden"></div>
631 <button id="saveButton" class="save-button" onclick="saveAllRatings()">Save Ratings</button>
632 </div>
633
634 <!-- Quickslice Client SDK -->
635 <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script>
636 <!-- Web Components -->
637 <script src="https://cdn.jsdelivr.net/gh/bigmoves/elements@v0.1.0/dist/elements.min.js"></script>
638 <script src="drag-fix.js"></script>
639
640 <script>
641 // =============================================================================
642 // CONFIGURATION
643 // =============================================================================
644
645 const SERVER_URL = "http://127.0.0.1:8080";
646 const CLIENT_ID = "client_Wpu1V7VdSi1eKI01JTsKOg"; // Set your OAuth client ID here after registering
647
648 let client;
649 let serviceMetadataCache = {};
650 let pendingRatings = {}; // Store ratings before saving
651
652 const defaultServices = [
653 { domain: "netflix.com", name: "Netflix" },
654 { domain: "youtube.com", name: "YouTube" },
655 { domain: "max.com", name: "HBO Max" },
656 { domain: "disneyplus.com", name: "Disney+" },
657 { domain: "hulu.com", name: "Hulu" },
658 { domain: "tv.apple.com", name: "Apple TV" },
659 { domain: "primevideo.com", name: "Prime Video" },
660 { domain: "peacocktv.com", name: "Peacock" },
661 { domain: "paramountplus.com", name: "Paramount+" },
662 ];
663
664 // =============================================================================
665 // INITIALIZATION
666 // =============================================================================
667
668 async function main() {
669 // Check for OAuth errors in URL
670 const params = new URLSearchParams(window.location.search);
671 if (params.has("error")) {
672 const error = params.get("error");
673 const description = params.get("error_description") || error;
674 showError(description);
675 // Clean up URL
676 window.history.replaceState({}, "", window.location.pathname);
677 }
678
679 if (window.location.search.includes("code=")) {
680 if (!CLIENT_ID) {
681 showError("OAuth callback received but CLIENT_ID is not configured.");
682 renderLoginForm();
683 return;
684 }
685
686 try {
687 client = await QuicksliceClient.createQuicksliceClient({
688 server: SERVER_URL,
689 clientId: CLIENT_ID,
690 });
691 await client.handleRedirectCallback();
692 } catch (error) {
693 console.error("OAuth callback error:", error);
694 showError(`Authentication failed: ${error.message}`);
695 renderLoginForm();
696 return;
697 }
698 } else if (CLIENT_ID) {
699 try {
700 client = await QuicksliceClient.createQuicksliceClient({
701 server: SERVER_URL,
702 clientId: CLIENT_ID,
703 });
704 } catch (error) {
705 console.error("Failed to initialize client:", error);
706 }
707 }
708
709 await renderApp();
710 }
711
712 async function renderApp() {
713 const isLoggedIn = client && (await client.isAuthenticated());
714
715 if (isLoggedIn) {
716 try {
717 const viewer = await fetchViewer();
718 renderUserCard(viewer);
719 renderContent(viewer);
720 } catch (error) {
721 console.error("Failed to fetch viewer:", error);
722 renderUserCard(null);
723 }
724 } else {
725 renderLoginForm();
726 }
727 }
728
729 // =============================================================================
730 // DATA FETCHING
731 // =============================================================================
732
733 async function fetchViewer() {
734 const query = `
735 query {
736 viewer {
737 did
738 handle
739 appBskyActorProfileByDid {
740 displayName
741 avatar { url }
742 }
743 }
744 }
745 `;
746
747 const data = await client.query(query);
748 return data?.viewer;
749 }
750
751 async function fetchRatings() {
752 const query = `
753 query {
754 socialGo90Rating(last: 50) {
755 edges {
756 node {
757 serviceDomain
758 rating
759 comment
760 createdAt
761 actorHandle
762 }
763 }
764 }
765 }
766 `;
767
768 const data = await client.query(query);
769 const edges = data?.socialGo90Rating?.edges || [];
770 return edges.map((edge) => ({
771 ...edge.node,
772 author: { handle: edge.node.actorHandle },
773 }));
774 }
775
776 async function fetchServiceMetadata(domain) {
777 if (serviceMetadataCache[domain]) {
778 return serviceMetadataCache[domain];
779 }
780
781 const metadata = {
782 name: domain,
783 favicon: `https://www.google.com/s2/favicons?domain=${domain}&sz=128`,
784 };
785
786 serviceMetadataCache[domain] = metadata;
787 return metadata;
788 }
789
790 // =============================================================================
791 // EVENT HANDLERS
792 // =============================================================================
793
794 async function handleLogin(event) {
795 event.preventDefault();
796
797 const handle = document.getElementById("handle").value.trim();
798
799 if (!handle) {
800 showError("Please enter your handle");
801 return;
802 }
803
804 try {
805 client = await QuicksliceClient.createQuicksliceClient({
806 server: SERVER_URL,
807 clientId: CLIENT_ID,
808 });
809
810 await client.loginWithRedirect({ handle });
811 } catch (error) {
812 showError(`Login failed: ${error.message}`);
813 }
814 }
815
816 function logout() {
817 if (client) {
818 client.logout();
819 } else {
820 window.location.reload();
821 }
822 }
823
824 async function handleRatingSubmit(event) {
825 event.preventDefault();
826
827 const serviceDomain = document.getElementById("serviceDomain").value.trim();
828 const rating = parseInt(document.getElementById("rating").value);
829 const comment = document.getElementById("comment").value.trim();
830
831 if (!serviceDomain) {
832 showError("Please enter a service domain");
833 return;
834 }
835
836 // Basic domain validation
837 if (!serviceDomain.includes(".") || serviceDomain.includes("/")) {
838 showError("Please enter a valid domain (e.g., netflix.com)");
839 return;
840 }
841
842 try {
843 const mutation = `
844 mutation CreateRating($input: CreateSocialGo90RatingInput!) {
845 createSocialGo90Rating(input: $input)
846 }
847 `;
848
849 const variables = {
850 input: {
851 serviceDomain,
852 rating,
853 comment: comment || undefined,
854 createdAt: new Date().toISOString(),
855 },
856 };
857
858 await client.query(mutation, variables);
859
860 // Clear form
861 document.getElementById("serviceDomain").value = "";
862 document.getElementById("rating").value = "45";
863 document.getElementById("comment").value = "";
864 updateRatingDisplay(45);
865
866 // Refresh ratings list
867 const viewer = await fetchViewer();
868 await renderContent(viewer);
869 } catch (error) {
870 console.error("Failed to submit rating:", error);
871 showError(`Failed to submit rating: ${error.message}`);
872 }
873 }
874
875 function updateRatingDisplay(value) {
876 const display = document.getElementById("ratingDisplay");
877 const isDefunct = value === 90;
878 display.textContent = value;
879 display.className = isDefunct ? "rating-display defunct" : "rating-display";
880 }
881
882 function updateCharCount() {
883 const comment = document.getElementById("comment").value;
884 const count = document.getElementById("charCount");
885 count.textContent = `${comment.length}/300`;
886 }
887
888 // OLD: function initDragAndDrop() {
889 // OLD: const serviceItems = document.querySelectorAll(".service-item");
890 // OLD: const dropZone = document.getElementById("dropZone");
891 // OLD: const scaleBar = document.getElementById("scaleBar");
892 // OLD:
893 // OLD: serviceItems.forEach((item) => {
894 // OLD: item.addEventListener("dragstart", handleDragStart);
895 // OLD: item.addEventListener("dragend", handleDragEnd);
896 // OLD: });
897 // OLD:
898 // OLD: dropZone.addEventListener("dragover", handleDragOver);
899 // OLD: dropZone.addEventListener("drop", handleDrop);
900 // OLD: dropZone.addEventListener("dragleave", handleDragLeave);
901 // OLD: }
902 // OLD:
903 // OLD: let draggedItem = null;
904 // OLD:
905 // OLD: function handleDragStart(e) {
906 // OLD: draggedItem = e.target;
907 // OLD: e.target.classList.add("dragging");
908 // OLD: }
909 // OLD:
910 // OLD: function handleDragEnd(e) {
911 // OLD: e.target.classList.remove("dragging");
912 // OLD: // Don't clear draggedItem here - it's needed in handleDrop
913 // OLD: setTimeout(() => {
914 // OLD: draggedItem = null;
915 // OLD: }, 100);
916 // OLD: }
917 // OLD:
918 // OLD: function handleDragOver(e) {
919 // OLD: e.preventDefault();
920 // OLD: document.getElementById("scaleBar").classList.add("drag-over");
921 // OLD: }
922 // OLD:
923 // OLD: function handleDragLeave(e) {
924 // OLD: if (e.target === document.getElementById("dropZone")) {
925 // OLD: document.getElementById("scaleBar").classList.remove("drag-over");
926 // OLD: }
927 // OLD: }
928 // OLD:
929 // OLD: async function handleDrop(e) {
930 // OLD: e.preventDefault();
931 // OLD: document.getElementById("scaleBar").classList.remove("drag-over");
932 // OLD:
933 // OLD: if (!draggedItem) return;
934 // OLD:
935 // OLD: const domain = draggedItem.dataset.domain;
936 // OLD: const name = draggedItem.dataset.name;
937 // OLD:
938 // OLD: // Calculate rating and position based on drop location
939 // OLD: const scaleBar = document.getElementById("scaleBar");
940 // OLD: const rect = scaleBar.getBoundingClientRect();
941 // OLD: const x = e.clientX - rect.left;
942 // OLD: const y = e.clientY - rect.top;
943 // OLD:
944 // OLD: const percentageX = Math.max(0, Math.min(100, (x / rect.width) * 100));
945 // OLD: const rating = Math.round((percentageX / 100) * 90);
946 // OLD:
947 // OLD: // Allow vertical offset from center
948 // OLD: const offsetY = y - rect.height / 2;
949 // OLD:
950 // OLD: // Store in pending ratings (don't submit yet)
951 // OLD: pendingRatings[domain] = {
952 // OLD: domain,
953 // OLD: name,
954 // OLD: rating,
955 // OLD: percentageX,
956 // OLD: offsetY,
957 // OLD: };
958 // OLD:
959 // OLD: // Don't mark as rated - allow re-dragging
960 // OLD: // draggedItem.classList.add("rated");
961 // OLD:
962 // OLD: // Add service to scale
963 // OLD: addServiceToScale(domain, name, rating, percentageX, offsetY);
964 // OLD:
965 // OLD: // Show save button
966 // OLD: updateSaveButton();
967 // OLD: }
968 // OLD:
969 async function submitRating(serviceDomain, rating, comment = "") {
970 try {
971 const mutation = `
972 mutation CreateRating($input: CreateSocialGo90RatingInput!) {
973 createSocialGo90Rating(input: $input)
974 }
975 `;
976
977 const input = {
978 serviceDomain: serviceDomain,
979 rating: rating,
980 createdAt: new Date().toISOString(),
981 };
982
983 // Only add comment if it exists
984 if (comment && comment.trim()) {
985 input.comment = comment.trim();
986 }
987
988 const variables = { input };
989
990 await client.query(mutation, variables);
991 } catch (error) {
992 console.error("Failed to submit rating:", error);
993 showError(`Failed to submit rating: ${error.message}`);
994 throw error;
995 }
996 }
997 // OLD:
998 // OLD: function addServiceToScale(domain, name, rating, percentageX, offsetY = 0) {
999 // OLD: const scaleBar = document.getElementById("scaleBar");
1000 // OLD:
1001 // OLD: // Remove existing rating for this service
1002 // OLD: const existing = scaleBar.querySelector(`[data-scale-domain="${domain}"]`);
1003 // OLD: if (existing) existing.remove();
1004 // OLD:
1005 // OLD: const serviceEl = document.createElement("div");
1006 // OLD: serviceEl.className = "service-on-scale";
1007 // OLD: serviceEl.dataset.scaleDomain = domain;
1008 // OLD: serviceEl.style.left = `${percentageX}%`;
1009 // OLD: serviceEl.style.top = `calc(50% + ${offsetY}px)`;
1010 // OLD: serviceEl.draggable = true;
1011 // OLD: serviceEl.innerHTML = `
1012 // OLD: <img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128"
1013 // OLD: alt="${name}"
1014 // OLD: class="service-logo-large">
1015 // OLD: <div class="service-badge ${rating === 90 ? "defunct" : ""}">${rating}</div>
1016 // OLD: `;
1017 // OLD:
1018 // OLD: // Make it re-draggable to update rating
1019 // OLD: serviceEl.addEventListener("dragstart", (e) => {
1020 // OLD: draggedItem = { dataset: { domain, name } };
1021 // OLD: e.target.classList.add("dragging");
1022 // OLD: });
1023 // OLD:
1024 // OLD: serviceEl.addEventListener("dragend", (e) => {
1025 // OLD: e.target.classList.remove("dragging");
1026 // OLD: e.target.remove(); // Remove from scale when re-dragging
1027 // OLD: });
1028 // OLD:
1029 // OLD: scaleBar.appendChild(serviceEl);
1030 // OLD: }
1031 // OLD:
1032 async function addCustomService(e) {
1033 if (e) e.preventDefault();
1034
1035 const input = document.getElementById("customServiceDomain");
1036 const domain = input.value.trim();
1037
1038 if (!domain) {
1039 showError("Please enter a domain");
1040 return;
1041 }
1042
1043 if (!domain.includes(".") || domain.includes("/")) {
1044 showError("Please enter a valid domain (e.g., dropout.tv)");
1045 return;
1046 }
1047
1048 // Add to services bar
1049 const servicesBar = document.getElementById("servicesBar");
1050 const serviceEl = document.createElement("div");
1051 serviceEl.className = "service-item";
1052 serviceEl.dataset.domain = domain;
1053 serviceEl.dataset.name = domain;
1054 serviceEl.innerHTML = `
1055 <img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128"
1056 alt="${domain}"
1057 class="service-logo"
1058 draggable="false">
1059 `;
1060
1061 setupServiceDrag(serviceEl);
1062 servicesBar.appendChild(serviceEl);
1063 input.value = "";
1064 }
1065
1066 function updateSaveButton() {
1067 const saveButton = document.getElementById("saveButton");
1068 const hasRatings = Object.keys(pendingRatings).length > 0;
1069
1070 if (hasRatings) {
1071 saveButton.classList.add("visible");
1072 } else {
1073 saveButton.classList.remove("visible");
1074 }
1075 }
1076
1077 async function saveAllRatings() {
1078 const saveButton = document.getElementById("saveButton");
1079 saveButton.disabled = true;
1080 saveButton.textContent = "Saving...";
1081
1082 try {
1083 for (const [domain, data] of Object.entries(pendingRatings)) {
1084 await submitRating(data.domain, data.rating);
1085 }
1086
1087 // Clear pending ratings (but keep services on canvas)
1088 pendingRatings = {};
1089 updateSaveButton();
1090
1091 saveButton.textContent = "Saved!";
1092 setTimeout(() => {
1093 saveButton.textContent = "Save Ratings";
1094 saveButton.disabled = false;
1095 }, 2000);
1096 } catch (error) {
1097 saveButton.textContent = "Save Ratings";
1098 saveButton.disabled = false;
1099 showError("Failed to save some ratings. Please try again.");
1100 }
1101 }
1102
1103 // =============================================================================
1104 // UI RENDERING
1105 // =============================================================================
1106
1107 function showError(message) {
1108 const banner = document.getElementById("error-banner");
1109 banner.innerHTML = `
1110 <span>${escapeHtml(message)}</span>
1111 <button onclick="hideError()">×</button>
1112 `;
1113 banner.classList.remove("hidden");
1114 }
1115
1116 function hideError() {
1117 document.getElementById("error-banner").classList.add("hidden");
1118 }
1119
1120 function escapeHtml(text) {
1121 const div = document.createElement("div");
1122 div.textContent = text;
1123 return div.innerHTML;
1124 }
1125
1126 function renderLoginForm() {
1127 const container = document.getElementById("auth-section");
1128
1129 if (!CLIENT_ID) {
1130 container.innerHTML = `
1131 <div class="card">
1132 <p style="color: var(--error-text); text-align: center; margin-bottom: 1rem;">
1133 <strong>Configuration Required</strong>
1134 </p>
1135 <p style="color: var(--gray-700); text-align: center;">
1136 Set the <code style="background: var(--gray-100); padding: 0.125rem 0.375rem; border-radius: 0.25rem;">CLIENT_ID</code> constant after registering an OAuth client.
1137 </p>
1138 </div>
1139 `;
1140 return;
1141 }
1142
1143 container.innerHTML = `
1144 <div class="card">
1145 <form class="login-form" onsubmit="handleLogin(event)">
1146 <div class="form-group">
1147 <label for="handle">AT Protocol Handle</label>
1148 <qs-actor-autocomplete
1149 id="handle"
1150 name="handle"
1151 placeholder="you.bsky.social"
1152 required
1153 ></qs-actor-autocomplete>
1154 </div>
1155 <button type="submit" class="btn btn-primary">Login</button>
1156 </form>
1157 </div>
1158 `;
1159 }
1160
1161 function renderUserCard(viewer) {
1162 const container = document.getElementById("auth-section");
1163 const displayName = viewer?.appBskyActorProfileByDid?.displayName || "User";
1164 const handle = viewer?.handle || "unknown";
1165 const avatarUrl = viewer?.appBskyActorProfileByDid?.avatar?.url;
1166
1167 container.innerHTML = `
1168 <div class="card user-card">
1169 <div class="user-info">
1170 <div class="user-avatar">
1171 ${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` : "👤"}
1172 </div>
1173 <div>
1174 <div class="user-name">Hi, ${escapeHtml(displayName)}!</div>
1175 <div class="user-handle">@${escapeHtml(handle)}</div>
1176 </div>
1177 </div>
1178 <button class="btn btn-secondary" onclick="logout()">Logout</button>
1179 </div>
1180 `;
1181 }
1182
1183 async function renderContent(viewer) {
1184 const container = document.getElementById("content");
1185
1186 container.innerHTML = `
1187 <div class="scale-container">
1188 <div class="drag-canvas" id="dragCanvas">
1189 <canvas id="arcCanvas"></canvas>
1190 </div>
1191
1192 <div class="services-bar" id="servicesBar">
1193 ${defaultServices
1194 .map(
1195 (service) => `
1196 <div class="service-item"
1197 data-domain="${service.domain}"
1198 data-name="${service.name}">
1199 <img src="https://www.google.com/s2/favicons?domain=${service.domain}&sz=128"
1200 alt="${service.name}"
1201 class="service-logo"
1202 draggable="false">
1203 </div>
1204 `,
1205 )
1206 .join("")}
1207 </div>
1208
1209 <form class="add-service-form" onsubmit="addCustomService(event)">
1210 <input type="text"
1211 id="customServiceDomain"
1212 class="add-service-input"
1213 placeholder="add service">
1214 <button type="submit" class="add-service-btn">add service</button>
1215 </form>
1216 </div>
1217 `;
1218
1219 initDragAndDrop();
1220 await loadExistingRatings();
1221 }
1222
1223 window.existingRatings = {}; // Store existing ratings globally
1224
1225 async function loadExistingRatings() {
1226 try {
1227 // Fetch viewer's own ratings using edges/node structure
1228 const query = `
1229 query {
1230 viewer {
1231 did
1232 }
1233 socialGo90Rating(last: 100, filter: { did: { equalTo: $viewerDid } }) {
1234 edges {
1235 node {
1236 serviceDomain
1237 rating
1238 did
1239 }
1240 }
1241 }
1242 }
1243 `;
1244
1245 // First get viewer DID
1246 const viewerQuery = `query { viewer { did } }`;
1247 const viewerData = await client.query(viewerQuery);
1248 const viewerDid = viewerData?.viewer?.did;
1249
1250 if (!viewerDid) return;
1251
1252 // Now fetch ratings for this viewer
1253 const ratingsQuery = `
1254 query {
1255 socialGo90Rating(last: 100) {
1256 edges {
1257 node {
1258 serviceDomain
1259 rating
1260 did
1261 }
1262 }
1263 }
1264 }
1265 `;
1266
1267 const data = await client.query(ratingsQuery);
1268 const allEdges = data?.socialGo90Rating?.edges || [];
1269
1270 // Filter to only viewer's ratings and deduplicate by domain (keep latest)
1271 const ratingsByDomain = {};
1272 allEdges
1273 .filter((edge) => edge.node.did === viewerDid)
1274 .forEach((edge) => {
1275 const node = edge.node;
1276 ratingsByDomain[node.serviceDomain] = node;
1277 });
1278 const myRatings = Object.values(ratingsByDomain);
1279
1280 if (myRatings && myRatings.length > 0) {
1281 // Store ratings in global object
1282 myRatings.forEach((rating) => {
1283 window.existingRatings[rating.serviceDomain] = rating.rating;
1284 });
1285
1286 // Update badges on services in the bar
1287 updateServiceBadges();
1288
1289 // Load viewer's ratings onto arc canvas
1290 for (const rating of myRatings) {
1291 console.log(`Loading ${rating.serviceDomain} at rating ${rating.rating}`);
1292
1293 // Use arc-based placement from drag-fix.js
1294 if (typeof placeServiceOnArc !== "undefined") {
1295 placeServiceOnArc(rating.serviceDomain, rating.serviceDomain, rating.rating);
1296 }
1297 }
1298 }
1299 } catch (error) {
1300 // No ratings exist yet, that's OK
1301 console.log("No existing ratings found (this is normal for first use)");
1302 }
1303 }
1304
1305 function updateServiceBadges() {
1306 const servicesBar = document.getElementById("servicesBar");
1307 if (!servicesBar) return;
1308
1309 const serviceItems = servicesBar.querySelectorAll(".service-item");
1310
1311 serviceItems.forEach((item) => {
1312 const domain = item.dataset.domain;
1313 const rating = window.existingRatings[domain];
1314
1315 // Remove existing badge if any
1316 const existingBadge = item.querySelector(".service-badge");
1317 if (existingBadge) existingBadge.remove();
1318
1319 // Add badge if there's a rating
1320 if (rating !== undefined) {
1321 const badge = document.createElement("div");
1322 badge.className = `service-badge ${rating === 90 ? "defunct" : ""}`;
1323 badge.textContent = rating;
1324 item.appendChild(badge);
1325 }
1326 });
1327 }
1328
1329 async function renderRatingsList(ratings) {
1330 const container = document.getElementById("ratingsList");
1331
1332 if (!ratings || ratings.length === 0) {
1333 container.innerHTML = `
1334 <div class="empty-state">No ratings yet. Be the first to rate a service!</div>
1335 `;
1336 return;
1337 }
1338
1339 let html = '<div class="ratings-list">';
1340
1341 for (const rating of ratings) {
1342 const metadata = await fetchServiceMetadata(rating.serviceDomain);
1343 const displayName =
1344 rating.author?.appBskyActorProfileByDid?.displayName ||
1345 rating.author?.handle ||
1346 "Anonymous";
1347 const isDefunct = rating.rating === 90;
1348 const ratingClass = isDefunct ? "rating-value defunct" : "rating-value";
1349 const date = new Date(rating.createdAt).toLocaleDateString();
1350
1351 html += `
1352 <div class="rating-item">
1353 <div class="rating-header">
1354 <img src="${escapeHtml(metadata.favicon)}" alt="" class="service-favicon" onerror="this.style.display='none'" />
1355 <span class="service-name">${escapeHtml(metadata.name)}</span>
1356 <span class="${ratingClass}">${rating.rating}</span>
1357 </div>
1358 <div class="rating-meta">
1359 Rated by ${escapeHtml(displayName)} (@${escapeHtml(rating.author?.handle)}) on ${date}
1360 </div>
1361 ${rating.comment ? `<div class="rating-comment">${escapeHtml(rating.comment)}</div>` : ""}
1362 </div>
1363 `;
1364 }
1365
1366 html += "</div>";
1367 container.innerHTML = html;
1368 }
1369
1370 main();
1371 </script>
1372 </body>
1373</html>