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