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 <script src="drag-fix.js"></script>
660
661 <script>
662 // =============================================================================
663 // CONFIGURATION
664 // =============================================================================
665
666 const SERVER_URL = "http://127.0.0.1:8080";
667 const CLIENT_ID = "client_Wpu1V7VdSi1eKI01JTsKOg"; // Set your OAuth client ID here after registering
668
669 let client;
670 let serviceMetadataCache = {};
671 let pendingRatings = {}; // Store ratings before saving
672
673 const defaultServices = [
674 { domain: "netflix.com", name: "Netflix" },
675 { domain: "youtube.com", name: "YouTube" },
676 { domain: "max.com", name: "HBO Max" },
677 { domain: "disneyplus.com", name: "Disney+" },
678 { domain: "hulu.com", name: "Hulu" },
679 { domain: "tv.apple.com", name: "Apple TV" },
680 { domain: "primevideo.com", name: "Prime Video" },
681 { domain: "peacocktv.com", name: "Peacock" },
682 { domain: "paramountplus.com", name: "Paramount+" },
683 ];
684
685 // =============================================================================
686 // INITIALIZATION
687 // =============================================================================
688
689 async function main() {
690 // Check for OAuth errors in URL
691 const params = new URLSearchParams(window.location.search);
692 if (params.has("error")) {
693 const error = params.get("error");
694 const description = params.get("error_description") || error;
695 showError(description);
696 // Clean up URL
697 window.history.replaceState({}, "", window.location.pathname);
698 }
699
700 if (window.location.search.includes("code=")) {
701 if (!CLIENT_ID) {
702 showError("OAuth callback received but CLIENT_ID is not configured.");
703 renderLoginForm();
704 return;
705 }
706
707 try {
708 client = await QuicksliceClient.createQuicksliceClient({
709 server: SERVER_URL,
710 clientId: CLIENT_ID,
711 });
712 await client.handleRedirectCallback();
713 } catch (error) {
714 console.error("OAuth callback error:", error);
715 showError(`Authentication failed: ${error.message}`);
716 renderLoginForm();
717 return;
718 }
719 } else if (CLIENT_ID) {
720 try {
721 client = await QuicksliceClient.createQuicksliceClient({
722 server: SERVER_URL,
723 clientId: CLIENT_ID,
724 });
725 } catch (error) {
726 console.error("Failed to initialize client:", error);
727 }
728 }
729
730 await renderApp();
731 }
732
733 async function renderApp() {
734 const isLoggedIn = client && (await client.isAuthenticated());
735
736 if (isLoggedIn) {
737 try {
738 const viewer = await fetchViewer();
739 renderUserCard(viewer);
740 renderContent(viewer);
741 } catch (error) {
742 console.error("Failed to fetch viewer:", error);
743 renderUserCard(null);
744 }
745 } else {
746 renderLoginForm();
747 }
748 }
749
750 // =============================================================================
751 // DATA FETCHING
752 // =============================================================================
753
754 async function fetchViewer() {
755 const query = `
756 query {
757 viewer {
758 did
759 handle
760 appBskyActorProfileByDid {
761 displayName
762 avatar { url }
763 }
764 }
765 }
766 `;
767
768 const data = await client.query(query);
769 return data?.viewer;
770 }
771
772 async function fetchRatings() {
773 const query = `
774 query {
775 socialGo90Ratings(orderBy: CREATED_AT_DESC, first: 50) {
776 nodes {
777 id
778 serviceDomain
779 rating
780 comment
781 createdAt
782 author {
783 handle
784 appBskyActorProfileByDid {
785 displayName
786 }
787 }
788 }
789 }
790 }
791 `;
792
793 const data = await client.query(query);
794 return data?.socialGo90Ratings?.nodes || [];
795 }
796
797 async function fetchServiceMetadata(domain) {
798 if (serviceMetadataCache[domain]) {
799 return serviceMetadataCache[domain];
800 }
801
802 const metadata = {
803 name: domain,
804 favicon: `https://www.google.com/s2/favicons?domain=${domain}&sz=128`,
805 };
806
807 serviceMetadataCache[domain] = metadata;
808 return metadata;
809 }
810
811 // =============================================================================
812 // EVENT HANDLERS
813 // =============================================================================
814
815 async function handleLogin(event) {
816 event.preventDefault();
817
818 const handle = document.getElementById("handle").value.trim();
819
820 if (!handle) {
821 showError("Please enter your handle");
822 return;
823 }
824
825 try {
826 client = await QuicksliceClient.createQuicksliceClient({
827 server: SERVER_URL,
828 clientId: CLIENT_ID,
829 });
830
831 await client.loginWithRedirect({ handle });
832 } catch (error) {
833 showError(`Login failed: ${error.message}`);
834 }
835 }
836
837 function logout() {
838 if (client) {
839 client.logout();
840 } else {
841 window.location.reload();
842 }
843 }
844
845 async function handleRatingSubmit(event) {
846 event.preventDefault();
847
848 const serviceDomain = document.getElementById("serviceDomain").value.trim();
849 const rating = parseInt(document.getElementById("rating").value);
850 const comment = document.getElementById("comment").value.trim();
851
852 if (!serviceDomain) {
853 showError("Please enter a service domain");
854 return;
855 }
856
857 // Basic domain validation
858 if (!serviceDomain.includes(".") || serviceDomain.includes("/")) {
859 showError("Please enter a valid domain (e.g., netflix.com)");
860 return;
861 }
862
863 try {
864 const mutation = `
865 mutation CreateRating($input: CreateSocialGo90RatingInput!) {
866 createSocialGo90Rating(input: $input)
867 }
868 `;
869
870 const variables = {
871 input: {
872 serviceDomain,
873 rating,
874 comment: comment || undefined,
875 createdAt: new Date().toISOString(),
876 },
877 };
878
879 await client.query(mutation, variables);
880
881 // Clear form
882 document.getElementById("serviceDomain").value = "";
883 document.getElementById("rating").value = "45";
884 document.getElementById("comment").value = "";
885 updateRatingDisplay(45);
886
887 // Refresh ratings list
888 const viewer = await fetchViewer();
889 await renderContent(viewer);
890 } catch (error) {
891 console.error("Failed to submit rating:", error);
892 showError(`Failed to submit rating: ${error.message}`);
893 }
894 }
895
896 function updateRatingDisplay(value) {
897 const display = document.getElementById("ratingDisplay");
898 const isDefunct = value === 90;
899 display.textContent = value;
900 display.className = isDefunct ? "rating-display defunct" : "rating-display";
901 }
902
903 function updateCharCount() {
904 const comment = document.getElementById("comment").value;
905 const count = document.getElementById("charCount");
906 count.textContent = `${comment.length}/300`;
907 }
908
909// OLD: function initDragAndDrop() {
910// OLD: const serviceItems = document.querySelectorAll(".service-item");
911// OLD: const dropZone = document.getElementById("dropZone");
912// OLD: const scaleBar = document.getElementById("scaleBar");
913// OLD:
914// OLD: serviceItems.forEach((item) => {
915// OLD: item.addEventListener("dragstart", handleDragStart);
916// OLD: item.addEventListener("dragend", handleDragEnd);
917// OLD: });
918// OLD:
919// OLD: dropZone.addEventListener("dragover", handleDragOver);
920// OLD: dropZone.addEventListener("drop", handleDrop);
921// OLD: dropZone.addEventListener("dragleave", handleDragLeave);
922// OLD: }
923// OLD:
924// OLD: let draggedItem = null;
925// OLD:
926// OLD: function handleDragStart(e) {
927// OLD: draggedItem = e.target;
928// OLD: e.target.classList.add("dragging");
929// OLD: }
930// OLD:
931// OLD: function handleDragEnd(e) {
932// OLD: e.target.classList.remove("dragging");
933// OLD: // Don't clear draggedItem here - it's needed in handleDrop
934// OLD: setTimeout(() => {
935// OLD: draggedItem = null;
936// OLD: }, 100);
937// OLD: }
938// OLD:
939// OLD: function handleDragOver(e) {
940// OLD: e.preventDefault();
941// OLD: document.getElementById("scaleBar").classList.add("drag-over");
942// OLD: }
943// OLD:
944// OLD: function handleDragLeave(e) {
945// OLD: if (e.target === document.getElementById("dropZone")) {
946// OLD: document.getElementById("scaleBar").classList.remove("drag-over");
947// OLD: }
948// OLD: }
949// OLD:
950// OLD: async function handleDrop(e) {
951// OLD: e.preventDefault();
952// OLD: document.getElementById("scaleBar").classList.remove("drag-over");
953// OLD:
954// OLD: if (!draggedItem) return;
955// OLD:
956// OLD: const domain = draggedItem.dataset.domain;
957// OLD: const name = draggedItem.dataset.name;
958// OLD:
959// OLD: // Calculate rating and position based on drop location
960// OLD: const scaleBar = document.getElementById("scaleBar");
961// OLD: const rect = scaleBar.getBoundingClientRect();
962// OLD: const x = e.clientX - rect.left;
963// OLD: const y = e.clientY - rect.top;
964// OLD:
965// OLD: const percentageX = Math.max(0, Math.min(100, (x / rect.width) * 100));
966// OLD: const rating = Math.round((percentageX / 100) * 90);
967// OLD:
968// OLD: // Allow vertical offset from center
969// OLD: const offsetY = y - rect.height / 2;
970// OLD:
971// OLD: // Store in pending ratings (don't submit yet)
972// OLD: pendingRatings[domain] = {
973// OLD: domain,
974// OLD: name,
975// OLD: rating,
976// OLD: percentageX,
977// OLD: offsetY,
978// OLD: };
979// OLD:
980// OLD: // Don't mark as rated - allow re-dragging
981// OLD: // draggedItem.classList.add("rated");
982// OLD:
983// OLD: // Add service to scale
984// OLD: addServiceToScale(domain, name, rating, percentageX, offsetY);
985// OLD:
986// OLD: // Show save button
987// OLD: updateSaveButton();
988// OLD: }
989// OLD:
990// OLD: async function submitRating(serviceDomain, rating, comment = "") {
991// OLD: try {
992// OLD: const mutation = `
993// OLD: mutation CreateRating($input: CreateSocialGo90RatingInput!) {
994// OLD: createSocialGo90Rating(input: $input)
995// OLD: }
996// OLD: `;
997// OLD:
998// OLD: const input = {
999// OLD: serviceDomain: serviceDomain,
1000// OLD: rating: rating,
1001// OLD: createdAt: new Date().toISOString(),
1002// OLD: };
1003// OLD:
1004// OLD: // Only add comment if it exists
1005// OLD: if (comment && comment.trim()) {
1006// OLD: input.comment = comment.trim();
1007// OLD: }
1008// OLD:
1009// OLD: const variables = { input };
1010// OLD:
1011// OLD: await client.query(mutation, variables);
1012// OLD: } catch (error) {
1013// OLD: console.error("Failed to submit rating:", error);
1014// OLD: showError(`Failed to submit rating: ${error.message}`);
1015// OLD: throw error;
1016// OLD: }
1017// OLD: }
1018// OLD:
1019// OLD: function addServiceToScale(domain, name, rating, percentageX, offsetY = 0) {
1020// OLD: const scaleBar = document.getElementById("scaleBar");
1021// OLD:
1022// OLD: // Remove existing rating for this service
1023// OLD: const existing = scaleBar.querySelector(`[data-scale-domain="${domain}"]`);
1024// OLD: if (existing) existing.remove();
1025// OLD:
1026// OLD: const serviceEl = document.createElement("div");
1027// OLD: serviceEl.className = "service-on-scale";
1028// OLD: serviceEl.dataset.scaleDomain = domain;
1029// OLD: serviceEl.style.left = `${percentageX}%`;
1030// OLD: serviceEl.style.top = `calc(50% + ${offsetY}px)`;
1031// OLD: serviceEl.draggable = true;
1032// OLD: serviceEl.innerHTML = `
1033// OLD: <img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128"
1034// OLD: alt="${name}"
1035// OLD: class="service-logo-large">
1036// OLD: <div class="service-badge ${rating === 90 ? "defunct" : ""}">${rating}</div>
1037// OLD: `;
1038// OLD:
1039// OLD: // Make it re-draggable to update rating
1040// OLD: serviceEl.addEventListener("dragstart", (e) => {
1041// OLD: draggedItem = { dataset: { domain, name } };
1042// OLD: e.target.classList.add("dragging");
1043// OLD: });
1044// OLD:
1045// OLD: serviceEl.addEventListener("dragend", (e) => {
1046// OLD: e.target.classList.remove("dragging");
1047// OLD: e.target.remove(); // Remove from scale when re-dragging
1048// OLD: });
1049// OLD:
1050// OLD: scaleBar.appendChild(serviceEl);
1051// OLD: }
1052// OLD:
1053 async function addCustomService() {
1054 const input = document.getElementById("customServiceDomain");
1055 const domain = input.value.trim();
1056
1057 if (!domain) {
1058 showError("Please enter a domain");
1059 return;
1060 }
1061
1062 if (!domain.includes(".") || domain.includes("/")) {
1063 showError("Please enter a valid domain (e.g., dropout.tv)");
1064 return;
1065 }
1066
1067 // Add to services bar
1068 const servicesBar = document.getElementById("servicesBar");
1069 const serviceEl = document.createElement("div");
1070 serviceEl.className = "service-item";
1071 serviceEl.draggable = true;
1072 serviceEl.dataset.domain = domain;
1073 serviceEl.dataset.name = domain;
1074 serviceEl.innerHTML = `
1075 <img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128"
1076 alt="${domain}"
1077 class="service-logo">
1078 `;
1079
1080 serviceEl.addEventListener("dragstart", handleDragStart);
1081 serviceEl.addEventListener("dragend", handleDragEnd);
1082
1083 servicesBar.appendChild(serviceEl);
1084 input.value = "";
1085 }
1086
1087 function updateSaveButton() {
1088 const saveButton = document.getElementById("saveButton");
1089 const hasRatings = Object.keys(pendingRatings).length > 0;
1090
1091 if (hasRatings) {
1092 saveButton.classList.add("visible");
1093 } else {
1094 saveButton.classList.remove("visible");
1095 }
1096 }
1097
1098 async function saveAllRatings() {
1099 const saveButton = document.getElementById("saveButton");
1100 saveButton.disabled = true;
1101 saveButton.textContent = "Saving...";
1102
1103 try {
1104 for (const [domain, data] of Object.entries(pendingRatings)) {
1105 await submitRating(data.domain, data.rating);
1106 }
1107
1108 // Clear pending ratings
1109 pendingRatings = {};
1110 updateSaveButton();
1111
1112 saveButton.textContent = "Saved!";
1113 setTimeout(() => {
1114 saveButton.textContent = "Save Ratings";
1115 saveButton.disabled = false;
1116 }, 2000);
1117 } catch (error) {
1118 saveButton.textContent = "Save Ratings";
1119 saveButton.disabled = false;
1120 showError("Failed to save some ratings. Please try again.");
1121 }
1122 }
1123
1124 // =============================================================================
1125 // UI RENDERING
1126 // =============================================================================
1127
1128 function showError(message) {
1129 const banner = document.getElementById("error-banner");
1130 banner.innerHTML = `
1131 <span>${escapeHtml(message)}</span>
1132 <button onclick="hideError()">×</button>
1133 `;
1134 banner.classList.remove("hidden");
1135 }
1136
1137 function hideError() {
1138 document.getElementById("error-banner").classList.add("hidden");
1139 }
1140
1141 function escapeHtml(text) {
1142 const div = document.createElement("div");
1143 div.textContent = text;
1144 return div.innerHTML;
1145 }
1146
1147 function renderLoginForm() {
1148 const container = document.getElementById("auth-section");
1149
1150 if (!CLIENT_ID) {
1151 container.innerHTML = `
1152 <div class="card">
1153 <p style="color: var(--error-text); text-align: center; margin-bottom: 1rem;">
1154 <strong>Configuration Required</strong>
1155 </p>
1156 <p style="color: var(--gray-700); text-align: center;">
1157 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.
1158 </p>
1159 </div>
1160 `;
1161 return;
1162 }
1163
1164 container.innerHTML = `
1165 <div class="card">
1166 <form class="login-form" onsubmit="handleLogin(event)">
1167 <div class="form-group">
1168 <label for="handle">AT Protocol Handle</label>
1169 <qs-actor-autocomplete
1170 id="handle"
1171 name="handle"
1172 placeholder="you.bsky.social"
1173 required
1174 ></qs-actor-autocomplete>
1175 </div>
1176 <button type="submit" class="btn btn-primary">Login</button>
1177 </form>
1178 </div>
1179 `;
1180 }
1181
1182 function renderUserCard(viewer) {
1183 const container = document.getElementById("auth-section");
1184 const displayName = viewer?.appBskyActorProfileByDid?.displayName || "User";
1185 const handle = viewer?.handle || "unknown";
1186 const avatarUrl = viewer?.appBskyActorProfileByDid?.avatar?.url;
1187
1188 container.innerHTML = `
1189 <div class="card user-card">
1190 <div class="user-info">
1191 <div class="user-avatar">
1192 ${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` : "👤"}
1193 </div>
1194 <div>
1195 <div class="user-name">Hi, ${escapeHtml(displayName)}!</div>
1196 <div class="user-handle">@${escapeHtml(handle)}</div>
1197 </div>
1198 </div>
1199 <button class="btn btn-secondary" onclick="logout()">Logout</button>
1200 </div>
1201 `;
1202 }
1203
1204 async function renderContent(viewer) {
1205 const container = document.getElementById("content");
1206
1207 container.innerHTML = `
1208 <div class="instructions">
1209 Drag services anywhere to rate them! Drop on the bar to remove rating.
1210 </div>
1211
1212 <div class="scale-container drag-canvas" id="dragCanvas">
1213 <div class="scale-bar-wrapper">
1214 <div class="scale-labels">
1215 <span class="scale-label">0</span>
1216 <span class="scale-label red">90</span>
1217 </div>
1218 <div class="scale-bar" id="scaleBar"></div>
1219 </div>
1220
1221 <div class="services-bar" id="servicesBar">
1222 ${defaultServices
1223 .map(
1224 (service) => `
1225 <div class="service-item"
1226 data-domain="${service.domain}"
1227 data-name="${service.name}">
1228 <img src="https://www.google.com/s2/favicons?domain=${service.domain}&sz=128"
1229 alt="${service.name}"
1230 class="service-logo"
1231 draggable="false">
1232 </div>
1233 `,
1234 )
1235 .join("")}
1236 </div>
1237
1238 <div class="add-service-form">
1239 <input type="text"
1240 id="customServiceDomain"
1241 class="add-service-input"
1242 placeholder="Enter a domain (e.g., dropout.tv)">
1243 <button class="add-service-btn" onclick="addCustomService()">Add Service</button>
1244 </div>
1245 </div>
1246 `;
1247
1248 initDragAndDrop();
1249 }
1250
1251 async function renderRatingsList(ratings) {
1252 const container = document.getElementById("ratingsList");
1253
1254 if (!ratings || ratings.length === 0) {
1255 container.innerHTML = `
1256 <div class="empty-state">No ratings yet. Be the first to rate a service!</div>
1257 `;
1258 return;
1259 }
1260
1261 let html = '<div class="ratings-list">';
1262
1263 for (const rating of ratings) {
1264 const metadata = await fetchServiceMetadata(rating.serviceDomain);
1265 const displayName =
1266 rating.author?.appBskyActorProfileByDid?.displayName ||
1267 rating.author?.handle ||
1268 "Anonymous";
1269 const isDefunct = rating.rating === 90;
1270 const ratingClass = isDefunct ? "rating-value defunct" : "rating-value";
1271 const date = new Date(rating.createdAt).toLocaleDateString();
1272
1273 html += `
1274 <div class="rating-item">
1275 <div class="rating-header">
1276 <img src="${escapeHtml(metadata.favicon)}" alt="" class="service-favicon" onerror="this.style.display='none'" />
1277 <span class="service-name">${escapeHtml(metadata.name)}</span>
1278 <span class="${ratingClass}">${rating.rating}</span>
1279 </div>
1280 <div class="rating-meta">
1281 Rated by ${escapeHtml(displayName)} (@${escapeHtml(rating.author?.handle)}) on ${date}
1282 </div>
1283 ${rating.comment ? `<div class="rating-comment">${escapeHtml(rating.comment)}</div>` : ""}
1284 </div>
1285 `;
1286 }
1287
1288 html += "</div>";
1289 container.innerHTML = html;
1290 }
1291
1292 main();
1293 </script>
1294 </body>
1295</html>