The Go90 Scale of Doomed Streaming Services
at main 41 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 <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 async function submitRating(serviceDomain, rating, comment = "") { 991 try { 992 const mutation = ` 993 mutation CreateRating($input: CreateSocialGo90RatingInput!) { 994 createSocialGo90Rating(input: $input) 995 } 996 `; 997 998 const input = { 999 serviceDomain: serviceDomain, 1000 rating: rating, 1001 createdAt: new Date().toISOString(), 1002 }; 1003 1004 // Only add comment if it exists 1005 if (comment && comment.trim()) { 1006 input.comment = comment.trim(); 1007 } 1008 1009 const variables = { input }; 1010 1011 await client.query(mutation, variables); 1012 } catch (error) { 1013 console.error("Failed to submit rating:", error); 1014 showError(`Failed to submit rating: ${error.message}`); 1015 throw error; 1016 } 1017 } 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(e) { 1054 if (e) e.preventDefault(); 1055 1056 const input = document.getElementById("customServiceDomain"); 1057 const domain = input.value.trim(); 1058 1059 if (!domain) { 1060 showError("Please enter a domain"); 1061 return; 1062 } 1063 1064 if (!domain.includes(".") || domain.includes("/")) { 1065 showError("Please enter a valid domain (e.g., dropout.tv)"); 1066 return; 1067 } 1068 1069 // Add to services bar 1070 const servicesBar = document.getElementById("servicesBar"); 1071 const serviceEl = document.createElement("div"); 1072 serviceEl.className = "service-item"; 1073 serviceEl.dataset.domain = domain; 1074 serviceEl.dataset.name = domain; 1075 serviceEl.innerHTML = ` 1076 <img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128" 1077 alt="${domain}" 1078 class="service-logo" 1079 draggable="false"> 1080 `; 1081 1082 setupServiceDrag(serviceEl); 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 (but keep services on canvas) 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()">&times;</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 <form class="add-service-form" onsubmit="addCustomService(event)"> 1239 <input type="text" 1240 id="customServiceDomain" 1241 class="add-service-input" 1242 placeholder="Enter a domain (e.g., dropout.tv)"> 1243 <button type="submit" class="add-service-btn">Add Service</button> 1244 </form> 1245 </div> 1246 `; 1247 1248 initDragAndDrop(); 1249 await loadExistingRatings(); 1250 } 1251 1252 window.existingRatings = {}; // Store existing ratings globally 1253 1254 async function loadExistingRatings() { 1255 try { 1256 // Fetch viewer's own ratings 1257 const query = ` 1258 query { 1259 viewer { 1260 did 1261 } 1262 socialGo90Ratings(orderBy: CREATED_AT_DESC, first: 100) { 1263 nodes { 1264 id 1265 serviceDomain 1266 rating 1267 author { 1268 did 1269 } 1270 } 1271 } 1272 } 1273 `; 1274 1275 const data = await client.query(query); 1276 const viewerDid = data?.viewer?.did; 1277 const allRatings = data?.socialGo90Ratings?.nodes || []; 1278 1279 // Filter to only viewer's ratings 1280 const myRatings = allRatings.filter((r) => r.author?.did === viewerDid); 1281 1282 // Store ratings in global object 1283 myRatings.forEach((rating) => { 1284 window.existingRatings[rating.serviceDomain] = rating.rating; 1285 }); 1286 1287 // Update badges on services in the bar 1288 updateServiceBadges(); 1289 1290 const scaleBar = document.getElementById("scaleBar"); 1291 const scaleBarRect = scaleBar.getBoundingClientRect(); 1292 const canvas = document.getElementById("dragCanvas"); 1293 const canvasRect = canvas.getBoundingClientRect(); 1294 1295 // Load viewer's ratings onto canvas 1296 for (const rating of myRatings) { 1297 // Calculate position based on rating value 1298 const percentageX = (rating.rating / 90) * 100; 1299 const scaleX = scaleBarRect.left + (percentageX / 100) * scaleBarRect.width; 1300 const scaleY = scaleBarRect.top + scaleBarRect.height / 2; 1301 1302 // Convert to canvas coordinates 1303 const canvasX = scaleX - canvasRect.left; 1304 const canvasY = scaleY - canvasRect.top; 1305 1306 // Place on canvas 1307 placeServiceOnCanvas( 1308 rating.serviceDomain, 1309 rating.serviceDomain, 1310 rating.rating, 1311 canvasX, 1312 canvasY, 1313 ); 1314 } 1315 } catch (error) { 1316 console.error("Failed to load existing ratings:", error); 1317 } 1318 } 1319 1320 function updateServiceBadges() { 1321 const servicesBar = document.getElementById("servicesBar"); 1322 if (!servicesBar) return; 1323 1324 const serviceItems = servicesBar.querySelectorAll(".service-item"); 1325 1326 serviceItems.forEach((item) => { 1327 const domain = item.dataset.domain; 1328 const rating = window.existingRatings[domain]; 1329 1330 // Remove existing badge if any 1331 const existingBadge = item.querySelector(".service-badge"); 1332 if (existingBadge) existingBadge.remove(); 1333 1334 // Add badge if there's a rating 1335 if (rating !== undefined) { 1336 const badge = document.createElement("div"); 1337 badge.className = `service-badge ${rating === 90 ? "defunct" : ""}`; 1338 badge.textContent = rating; 1339 item.appendChild(badge); 1340 } 1341 }); 1342 } 1343 1344 async function renderRatingsList(ratings) { 1345 const container = document.getElementById("ratingsList"); 1346 1347 if (!ratings || ratings.length === 0) { 1348 container.innerHTML = ` 1349 <div class="empty-state">No ratings yet. Be the first to rate a service!</div> 1350 `; 1351 return; 1352 } 1353 1354 let html = '<div class="ratings-list">'; 1355 1356 for (const rating of ratings) { 1357 const metadata = await fetchServiceMetadata(rating.serviceDomain); 1358 const displayName = 1359 rating.author?.appBskyActorProfileByDid?.displayName || 1360 rating.author?.handle || 1361 "Anonymous"; 1362 const isDefunct = rating.rating === 90; 1363 const ratingClass = isDefunct ? "rating-value defunct" : "rating-value"; 1364 const date = new Date(rating.createdAt).toLocaleDateString(); 1365 1366 html += ` 1367 <div class="rating-item"> 1368 <div class="rating-header"> 1369 <img src="${escapeHtml(metadata.favicon)}" alt="" class="service-favicon" onerror="this.style.display='none'" /> 1370 <span class="service-name">${escapeHtml(metadata.name)}</span> 1371 <span class="${ratingClass}">${rating.rating}</span> 1372 </div> 1373 <div class="rating-meta"> 1374 Rated by ${escapeHtml(displayName)} (@${escapeHtml(rating.author?.handle)}) on ${date} 1375 </div> 1376 ${rating.comment ? `<div class="rating-comment">${escapeHtml(rating.comment)}</div>` : ""} 1377 </div> 1378 `; 1379 } 1380 1381 html += "</div>"; 1382 container.innerHTML = html; 1383 } 1384 1385 main(); 1386 </script> 1387 </body> 1388</html>