The Go90 Scale of Doomed Streaming Services
at main 36 kB view raw
1<!doctype html> 2<html lang="en"> 3 <head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <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()">&times;</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>