The Go90 Scale of Doomed Streaming Services

previous ratings are loaded on start

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