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