The Go90 Scale of Doomed Streaming Services

its working and somewhat usable

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