The Go90 Scale of Doomed Streaming Services

Compare changes

Choose any two refs to compare.

+1
.gitignore
··· 1 1 data/ 2 + *.ttf
+410
drag-fix.js
··· 1 + // Arc-based drag system for Go90 Scale 2 + console.log("Arc drag system loaded"); 3 + 4 + // Arc configuration 5 + const ARC_CONFIG = { 6 + centerX: 0, // Will be set based on canvas width 7 + centerY: 0, // Will be set based on canvas height 8 + radius: 300, 9 + startAngle: -Math.PI / 2, // -90° (top, 0 rating) 10 + endAngle: 0, // 0° (right, 90 rating) 11 + baseDistance: 50, // Base distance from arc for service icons 12 + crowdingOffset: 120, // Distance to push out for each overlapping service 13 + iconSize: 80, 14 + }; 15 + 16 + let canvas, ctx, canvasContainer; 17 + let servicePositions = {}; // Store { domain: { rating, angle, distance } } 18 + let currentDrag = null; 19 + 20 + function initDragAndDrop() { 21 + console.log("initDragAndDrop called - arc mode"); 22 + 23 + canvasContainer = document.getElementById("dragCanvas"); 24 + canvas = document.getElementById("arcCanvas"); 25 + ctx = canvas.getContext("2d"); 26 + 27 + // Set canvas size 28 + resizeCanvas(); 29 + window.addEventListener("resize", resizeCanvas); 30 + 31 + // Draw the arc scale 32 + drawArcScale(); 33 + 34 + // Setup service items for dragging 35 + const serviceItems = document.querySelectorAll(".service-item"); 36 + serviceItems.forEach(setupServiceDrag); 37 + } 38 + 39 + function resizeCanvas() { 40 + const rect = canvasContainer.getBoundingClientRect(); 41 + canvas.width = rect.width; 42 + canvas.height = rect.height; 43 + 44 + // Set arc center 45 + ARC_CONFIG.centerX = canvas.width / 2; 46 + ARC_CONFIG.centerY = canvas.height - 100; 47 + 48 + drawArcScale(); 49 + redrawServices(); 50 + } 51 + 52 + function drawArcScale() { 53 + if (!ctx) return; 54 + 55 + ctx.clearRect(0, 0, canvas.width, canvas.height); 56 + 57 + const { centerX, centerY, radius, startAngle, endAngle } = ARC_CONFIG; 58 + 59 + // Draw gradient arc 60 + const gradient = ctx.createLinearGradient( 61 + centerX, 62 + centerY + radius * Math.sin(startAngle), 63 + centerX + radius * Math.cos(endAngle), 64 + centerY + radius * Math.sin(endAngle), 65 + ); 66 + 67 + gradient.addColorStop(0, "#32cd32"); 68 + gradient.addColorStop(0.2, "#adff2f"); 69 + gradient.addColorStop(0.4, "#ffff00"); 70 + gradient.addColorStop(0.6, "#ffa500"); 71 + gradient.addColorStop(0.8, "#ff4500"); 72 + gradient.addColorStop(1, "#8b0000"); 73 + 74 + ctx.strokeStyle = gradient; 75 + ctx.lineWidth = 6; 76 + ctx.beginPath(); 77 + ctx.arc(centerX, centerY, radius, startAngle, endAngle); 78 + ctx.stroke(); 79 + 80 + // Draw tick marks and labels 81 + const totalRatings = 91; // 0 to 90 82 + for (let rating = 0; rating <= 90; rating += 10) { 83 + const angle = ratingToAngle(rating); 84 + const tickLength = rating % 10 === 0 ? 20 : 10; 85 + 86 + // Outer point on arc 87 + const x1 = centerX + radius * Math.cos(angle); 88 + const y1 = centerY + radius * Math.sin(angle); 89 + 90 + // Inner point (tick mark) 91 + const x2 = centerX + (radius - tickLength) * Math.cos(angle); 92 + const y2 = centerY + (radius - tickLength) * Math.sin(angle); 93 + 94 + ctx.strokeStyle = "white"; 95 + ctx.lineWidth = 3; 96 + ctx.beginPath(); 97 + ctx.moveTo(x1, y1); 98 + ctx.lineTo(x2, y2); 99 + ctx.stroke(); 100 + 101 + // Draw rating label every 10 102 + if (rating % 10 === 0) { 103 + const labelDistance = radius - 50; 104 + const labelX = centerX + labelDistance * Math.cos(angle); 105 + const labelY = centerY + labelDistance * Math.sin(angle); 106 + 107 + ctx.fillStyle = rating === 90 ? "#ff5555" : "white"; 108 + ctx.font = "bold 32px 'Innovator Grotesk', sans-serif"; 109 + ctx.textAlign = "center"; 110 + ctx.textBaseline = "middle"; 111 + ctx.fillText(rating.toString(), labelX, labelY); 112 + } 113 + } 114 + 115 + // Draw indicator lines for placed services 116 + Object.entries(servicePositions).forEach(([domain, data]) => { 117 + drawIndicatorLine(data.angle, data.distance); 118 + }); 119 + } 120 + 121 + function drawIndicatorLine(angle, distance) { 122 + const { centerX, centerY, radius } = ARC_CONFIG; 123 + 124 + // Point on arc 125 + const arcX = centerX + radius * Math.cos(angle); 126 + const arcY = centerY + radius * Math.sin(angle); 127 + 128 + // Point at service icon 129 + const iconX = centerX + distance * Math.cos(angle); 130 + const iconY = centerY + distance * Math.sin(angle); 131 + 132 + ctx.strokeStyle = "white"; 133 + ctx.lineWidth = 2; 134 + ctx.setLineDash([5, 5]); 135 + ctx.beginPath(); 136 + ctx.moveTo(arcX, arcY); 137 + ctx.lineTo(iconX, iconY); 138 + ctx.stroke(); 139 + ctx.setLineDash([]); 140 + } 141 + 142 + function ratingToAngle(rating) { 143 + const { startAngle, endAngle } = ARC_CONFIG; 144 + const t = rating / 90; 145 + return startAngle + t * (endAngle - startAngle); 146 + } 147 + 148 + function angleToRating(angle) { 149 + const { startAngle, endAngle } = ARC_CONFIG; 150 + const t = (angle - startAngle) / (endAngle - startAngle); 151 + return Math.max(0, Math.min(90, Math.round(t * 90))); 152 + } 153 + 154 + function setupServiceDrag(element) { 155 + element.style.cursor = "grab"; 156 + element.addEventListener("mousedown", startDrag); 157 + } 158 + 159 + function startDrag(e) { 160 + const serviceItem = e.target.closest(".service-item, .service-on-canvas"); 161 + if (!serviceItem) return; 162 + 163 + e.preventDefault(); 164 + 165 + const domain = serviceItem.dataset.domain || serviceItem.dataset.canvasDomain; 166 + const name = serviceItem.dataset.name; 167 + 168 + currentDrag = { 169 + domain, 170 + name, 171 + element: serviceItem, 172 + isFromCanvas: serviceItem.classList.contains("service-on-canvas"), 173 + }; 174 + 175 + serviceItem.style.opacity = "0.5"; 176 + document.addEventListener("mousemove", onDragMove); 177 + document.addEventListener("mouseup", onDragEnd); 178 + } 179 + 180 + function onDragMove(e) { 181 + if (!currentDrag) return; 182 + 183 + // Update visual feedback based on mouse position relative to arc 184 + const canvasRect = canvas.getBoundingClientRect(); 185 + const mouseX = e.clientX - canvasRect.left; 186 + const mouseY = e.clientY - canvasRect.top; 187 + 188 + // Calculate angle from center to mouse 189 + const dx = mouseX - ARC_CONFIG.centerX; 190 + const dy = mouseY - ARC_CONFIG.centerY; 191 + const angle = Math.atan2(dy, dx); 192 + 193 + // Constrain to arc range 194 + const { startAngle, endAngle } = ARC_CONFIG; 195 + const constrainedAngle = Math.max(startAngle, Math.min(endAngle, angle)); 196 + 197 + // Show preview by updating element position if it's on canvas 198 + if (currentDrag.isFromCanvas) { 199 + const distance = calculateDistance(currentDrag.domain, constrainedAngle); 200 + const x = ARC_CONFIG.centerX + distance * Math.cos(constrainedAngle); 201 + const y = ARC_CONFIG.centerY + distance * Math.sin(constrainedAngle); 202 + 203 + currentDrag.element.style.left = x - ARC_CONFIG.iconSize / 2 + "px"; 204 + currentDrag.element.style.top = y - ARC_CONFIG.iconSize / 2 + "px"; 205 + } 206 + } 207 + 208 + function onDragEnd(e) { 209 + if (!currentDrag) return; 210 + 211 + document.removeEventListener("mousemove", onDragMove); 212 + document.removeEventListener("mouseup", onDragEnd); 213 + 214 + const { domain, name, element, isFromCanvas } = currentDrag; 215 + 216 + // Check if dropped on services bar (to remove rating) 217 + const servicesBar = document.getElementById("servicesBar"); 218 + const servicesBarRect = servicesBar.getBoundingClientRect(); 219 + 220 + if ( 221 + e.clientY >= servicesBarRect.top && 222 + e.clientY <= servicesBarRect.bottom && 223 + e.clientX >= servicesBarRect.left && 224 + e.clientX <= servicesBarRect.right 225 + ) { 226 + // Remove from canvas and add back to bar 227 + removeServiceFromCanvas(domain, name); 228 + element.style.opacity = "1"; 229 + currentDrag = null; 230 + return; 231 + } 232 + 233 + // Calculate position on arc 234 + const canvasRect = canvas.getBoundingClientRect(); 235 + const mouseX = e.clientX - canvasRect.left; 236 + const mouseY = e.clientY - canvasRect.top; 237 + 238 + const dx = mouseX - ARC_CONFIG.centerX; 239 + const dy = mouseY - ARC_CONFIG.centerY; 240 + const angle = Math.atan2(dy, dx); 241 + 242 + // Constrain to arc range 243 + const { startAngle, endAngle } = ARC_CONFIG; 244 + const constrainedAngle = Math.max(startAngle, Math.min(endAngle, angle)); 245 + 246 + const rating = angleToRating(constrainedAngle); 247 + 248 + // Place on canvas 249 + placeServiceOnArc(domain, name, rating); 250 + 251 + element.style.opacity = "1"; 252 + currentDrag = null; 253 + } 254 + 255 + function calculateDistance(domain, angle) { 256 + // Find the furthest "valence ring" occupied within 5 degrees BEFORE this angle 257 + // This prevents recursive offsetting - only look backwards along the arc 258 + // Only services at the current ring level count as crowding 259 + const angularTolerance = 10 * (Math.PI / 180); // 5 degrees in radians 260 + const baseDistance = ARC_CONFIG.radius + ARC_CONFIG.baseDistance; 261 + 262 + // Find all occupied rings in the angular range 263 + let occupiedRings = new Set(); 264 + Object.entries(servicePositions).forEach(([d, data]) => { 265 + if (d !== domain) { 266 + const angleDiff = angle - data.angle; // Positive if data.angle is before this angle 267 + if (angleDiff >= 0 && angleDiff <= angularTolerance) { 268 + // Found a service in the 5 degrees before 269 + occupiedRings.add(data.distance); 270 + } 271 + } 272 + }); 273 + 274 + // Find the first unoccupied ring 275 + let testDistance = baseDistance; 276 + while (occupiedRings.has(testDistance)) { 277 + testDistance += ARC_CONFIG.crowdingOffset; 278 + } 279 + 280 + return testDistance; 281 + } 282 + 283 + function placeServiceOnArc(domain, name, rating) { 284 + const angle = ratingToAngle(rating); 285 + const distance = calculateDistance(domain, angle); 286 + 287 + // Store position 288 + servicePositions[domain] = { rating, angle, distance }; 289 + 290 + // Store in pending ratings 291 + if (typeof pendingRatings !== "undefined") { 292 + pendingRatings[domain] = { domain, name, rating }; 293 + } 294 + 295 + // Remove from services bar 296 + const servicesBar = document.getElementById("servicesBar"); 297 + const inBar = servicesBar.querySelector(`[data-domain="${domain}"]`); 298 + if (inBar) inBar.remove(); 299 + 300 + // Remove existing on canvas 301 + const existing = canvasContainer.querySelector(`[data-canvas-domain="${domain}"]`); 302 + if (existing) existing.remove(); 303 + 304 + // Calculate pixel position 305 + const x = ARC_CONFIG.centerX + distance * Math.cos(angle); 306 + const y = ARC_CONFIG.centerY + distance * Math.sin(angle); 307 + 308 + // Create element 309 + const el = document.createElement("div"); 310 + el.className = "service-on-canvas"; 311 + el.dataset.canvasDomain = domain; 312 + el.dataset.domain = domain; 313 + el.dataset.name = name; 314 + el.style.left = x - ARC_CONFIG.iconSize / 2 + "px"; 315 + el.style.top = y - ARC_CONFIG.iconSize / 2 + "px"; 316 + el.innerHTML = ` 317 + <img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128" 318 + alt="${name}" 319 + class="service-icon" 320 + draggable="false"> 321 + <div class="service-badge ${rating === 90 ? "defunct" : ""}">${rating}</div> 322 + `; 323 + 324 + setupServiceDrag(el); 325 + canvasContainer.appendChild(el); 326 + 327 + // Redraw canvas to show indicator line 328 + drawArcScale(); 329 + 330 + // Reposition any crowded services 331 + repositionCrowdedServices(); 332 + 333 + if (typeof updateSaveButton !== "undefined") { 334 + updateSaveButton(); 335 + } 336 + } 337 + 338 + function repositionCrowdedServices() { 339 + // Recalculate distances for all services to handle crowding 340 + Object.entries(servicePositions).forEach(([domain, data]) => { 341 + const newDistance = calculateDistance(domain, data.angle); 342 + if (newDistance !== data.distance) { 343 + data.distance = newDistance; 344 + 345 + // Update element position 346 + const el = canvasContainer.querySelector(`[data-canvas-domain="${domain}"]`); 347 + if (el) { 348 + const x = ARC_CONFIG.centerX + newDistance * Math.cos(data.angle); 349 + const y = ARC_CONFIG.centerY + newDistance * Math.sin(data.angle); 350 + el.style.left = x - ARC_CONFIG.iconSize / 2 + "px"; 351 + el.style.top = y - ARC_CONFIG.iconSize / 2 + "px"; 352 + } 353 + } 354 + }); 355 + 356 + drawArcScale(); 357 + } 358 + 359 + function removeServiceFromCanvas(domain, name) { 360 + // Remove from positions 361 + delete servicePositions[domain]; 362 + 363 + // Remove from canvas 364 + const existing = canvasContainer.querySelector(`[data-canvas-domain="${domain}"]`); 365 + if (existing) existing.remove(); 366 + 367 + // Add back to services bar 368 + const servicesBar = document.getElementById("servicesBar"); 369 + const rating = window.existingRatings ? window.existingRatings[domain] : undefined; 370 + 371 + const serviceEl = document.createElement("div"); 372 + serviceEl.className = "service-item"; 373 + serviceEl.dataset.domain = domain; 374 + serviceEl.dataset.name = name; 375 + serviceEl.innerHTML = ` 376 + <img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128" 377 + alt="${name}" 378 + class="service-logo" 379 + draggable="false"> 380 + ${rating !== undefined ? `<div class="service-badge ${rating === 90 ? "defunct" : ""}">${rating}</div>` : ""} 381 + `; 382 + setupServiceDrag(serviceEl); 383 + servicesBar.appendChild(serviceEl); 384 + 385 + // Remove from pending 386 + if (typeof pendingRatings !== "undefined") { 387 + delete pendingRatings[domain]; 388 + } 389 + 390 + // Redraw and reposition 391 + drawArcScale(); 392 + repositionCrowdedServices(); 393 + 394 + if (typeof updateSaveButton !== "undefined") { 395 + updateSaveButton(); 396 + } 397 + } 398 + 399 + function redrawServices() { 400 + // Redraw all services at their current positions 401 + Object.entries(servicePositions).forEach(([domain, data]) => { 402 + const el = canvasContainer.querySelector(`[data-canvas-domain="${domain}"]`); 403 + if (el) { 404 + const x = ARC_CONFIG.centerX + data.distance * Math.cos(data.angle); 405 + const y = ARC_CONFIG.centerY + data.distance * Math.sin(data.angle); 406 + el.style.left = x - ARC_CONFIG.iconSize / 2 + "px"; 407 + el.style.top = y - ARC_CONFIG.iconSize / 2 + "px"; 408 + } 409 + }); 410 + }
+919 -17
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 + @font-face { 10 + font-family: "Innovator Grotesk"; 11 + src: url("InnovatorGroteskVF-VF.ttf") format("truetype"); 12 + font-weight: 100 900; 13 + font-stretch: 75% 125%; 14 + font-style: normal; 15 + } 16 + 9 17 *, 10 18 *::before, 11 19 *::after { ··· 35 43 --error-bg: #fef2f2; 36 44 --error-border: #fecaca; 37 45 --error-text: #dc2626; 46 + --go90-blue: #2020ff; 47 + --go90-yellow: #ffff00; 38 48 } 39 49 40 50 body { 41 - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 42 - background: var(--gray-100); 43 - color: var(--gray-900); 51 + font-family: 52 + "Innovator Grotesk", 53 + -apple-system, 54 + BlinkMacSystemFont, 55 + "Segoe UI", 56 + Roboto, 57 + sans-serif; 58 + background: var(--go90-blue); 59 + color: white; 44 60 min-height: 100vh; 45 61 padding: 2rem 1rem; 46 62 } 47 63 48 64 #app { 49 - max-width: 500px; 65 + max-width: 1100px; 50 66 margin: 0 auto; 51 67 } 52 68 ··· 62 78 } 63 79 64 80 header h1 { 65 - font-size: 2rem; 66 - color: var(--primary-500); 81 + font-size: 2.5rem; 82 + color: var(--go90-yellow); 67 83 margin-bottom: 0.25rem; 84 + font-weight: 900; 85 + text-transform: uppercase; 86 + letter-spacing: 2px; 68 87 } 69 88 70 89 .tagline { 71 - color: var(--gray-500); 72 - font-size: 1rem; 90 + color: white; 91 + font-size: 1.125rem; 73 92 } 74 93 75 94 .card { ··· 218 237 .hidden { 219 238 display: none !important; 220 239 } 240 + 241 + .rating-form { 242 + display: flex; 243 + flex-direction: column; 244 + gap: 1rem; 245 + } 246 + 247 + .rating-slider-container { 248 + display: flex; 249 + flex-direction: column; 250 + gap: 0.5rem; 251 + } 252 + 253 + .rating-display { 254 + text-align: center; 255 + font-size: 3rem; 256 + font-weight: 700; 257 + color: var(--primary-500); 258 + margin: 0.5rem 0; 259 + } 260 + 261 + .rating-display.defunct { 262 + color: var(--error-text); 263 + } 264 + 265 + .rating-slider { 266 + width: 100%; 267 + height: 8px; 268 + -webkit-appearance: none; 269 + appearance: none; 270 + background: linear-gradient(to right, #32cd32 0%, #ffd700 50%, #ff5722 100%); 271 + border-radius: 4px; 272 + outline: none; 273 + } 274 + 275 + .rating-slider::-webkit-slider-thumb { 276 + -webkit-appearance: none; 277 + appearance: none; 278 + width: 24px; 279 + height: 24px; 280 + background: white; 281 + border: 2px solid var(--primary-500); 282 + border-radius: 50%; 283 + cursor: pointer; 284 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 285 + } 286 + 287 + .rating-slider::-moz-range-thumb { 288 + width: 24px; 289 + height: 24px; 290 + background: white; 291 + border: 2px solid var(--primary-500); 292 + border-radius: 50%; 293 + cursor: pointer; 294 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 295 + } 296 + 297 + .rating-labels { 298 + display: flex; 299 + justify-content: space-between; 300 + font-size: 0.875rem; 301 + color: var(--gray-500); 302 + } 303 + 304 + textarea { 305 + padding: 0.75rem; 306 + border: 1px solid var(--border-color); 307 + border-radius: 0.375rem; 308 + font-size: 1rem; 309 + resize: vertical; 310 + min-height: 80px; 311 + font-family: inherit; 312 + } 313 + 314 + textarea:focus { 315 + outline: none; 316 + border-color: var(--primary-500); 317 + box-shadow: 0 0 0 3px rgba(0, 120, 255, 0.1); 318 + } 319 + 320 + .char-count { 321 + text-align: right; 322 + font-size: 0.75rem; 323 + color: var(--gray-500); 324 + } 325 + 326 + .ratings-list { 327 + display: flex; 328 + flex-direction: column; 329 + gap: 1rem; 330 + } 331 + 332 + .rating-item { 333 + border-bottom: 1px solid var(--border-color); 334 + padding-bottom: 1rem; 335 + } 336 + 337 + .rating-item:last-child { 338 + border-bottom: none; 339 + padding-bottom: 0; 340 + } 341 + 342 + .rating-header { 343 + display: flex; 344 + align-items: center; 345 + gap: 0.75rem; 346 + margin-bottom: 0.5rem; 347 + } 348 + 349 + .service-favicon { 350 + width: 24px; 351 + height: 24px; 352 + border-radius: 4px; 353 + } 354 + 355 + .service-name { 356 + font-weight: 600; 357 + color: var(--gray-900); 358 + flex: 1; 359 + } 360 + 361 + .rating-value { 362 + font-size: 1.25rem; 363 + font-weight: 700; 364 + color: var(--primary-500); 365 + } 366 + 367 + .rating-value.defunct { 368 + color: var(--error-text); 369 + } 370 + 371 + .rating-meta { 372 + font-size: 0.875rem; 373 + color: var(--gray-500); 374 + margin-bottom: 0.5rem; 375 + } 376 + 377 + .rating-comment { 378 + color: var(--gray-700); 379 + font-size: 0.875rem; 380 + } 381 + 382 + .empty-state { 383 + text-align: center; 384 + padding: 2rem; 385 + color: var(--gray-500); 386 + } 387 + 388 + .section-title { 389 + font-size: 1.25rem; 390 + font-weight: 600; 391 + margin-bottom: 0.5rem; 392 + color: var(--gray-900); 393 + } 394 + 395 + .btn-block { 396 + width: 100%; 397 + } 398 + 399 + /* Go90 Scale Interface */ 400 + .scale-container { 401 + background: var(--go90-blue); 402 + padding: 2rem; 403 + border-radius: 8px; 404 + margin-bottom: 2rem; 405 + position: relative; 406 + display: flex; 407 + flex-direction: column; 408 + align-items: center; 409 + } 410 + 411 + .drag-canvas { 412 + position: relative; 413 + width: 100%; 414 + height: 700px; 415 + margin-bottom: 2rem; 416 + } 417 + 418 + #arcCanvas { 419 + position: absolute; 420 + top: 0; 421 + left: 0; 422 + pointer-events: none; 423 + } 424 + 425 + .service-on-canvas { 426 + position: absolute; 427 + cursor: grab; 428 + transition: transform 0.1s; 429 + } 430 + 431 + .service-on-canvas:active { 432 + cursor: grabbing; 433 + } 434 + 435 + .service-on-canvas:hover { 436 + transform: scale(1.05); 437 + } 438 + 439 + .service-icon { 440 + width: 80px; 441 + height: 80px; 442 + border-radius: 12px; 443 + background: white; 444 + padding: 8px; 445 + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); 446 + border: 3px solid var(--go90-yellow); 447 + display: block; 448 + } 449 + 450 + .service-badge { 451 + position: absolute; 452 + bottom: -12px; 453 + right: -12px; 454 + background: black; 455 + color: var(--go90-yellow); 456 + font-size: 1.25rem; 457 + font-weight: 900; 458 + padding: 4px 12px; 459 + border-radius: 50%; 460 + border: 3px solid var(--go90-yellow); 461 + min-width: 48px; 462 + text-align: center; 463 + } 464 + 465 + .service-badge.defunct { 466 + color: #ff5555; 467 + border-color: #ff5555; 468 + } 469 + 470 + .services-bar { 471 + background: transparent; 472 + padding: 1.5rem; 473 + border-radius: 8px; 474 + display: flex; 475 + gap: 1rem; 476 + align-items: center; 477 + justify-content: center; 478 + flex-wrap: wrap; 479 + border: 3px solid rgba(255, 255, 255, 0.3); 480 + } 481 + 482 + .service-item { 483 + width: 80px; 484 + height: 80px; 485 + border-radius: 8px; 486 + background: black; 487 + padding: 8px; 488 + cursor: grab; 489 + transition: 490 + transform 0.2s, 491 + opacity 0.2s; 492 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); 493 + position: relative; 494 + } 495 + 496 + .service-item:hover { 497 + transform: scale(1.05); 498 + } 499 + 500 + .service-item:active { 501 + cursor: grabbing; 502 + } 503 + 504 + .service-item.dragging { 505 + opacity: 0.3; 506 + } 507 + 508 + .service-item.on-canvas { 509 + position: absolute; 510 + top: 0; 511 + left: 0; 512 + } 513 + 514 + .service-logo { 515 + width: 100%; 516 + height: 100%; 517 + object-fit: contain; 518 + } 519 + 520 + .add-service-form { 521 + display: flex; 522 + gap: 0.5rem; 523 + align-items: center; 524 + justify-content: center; 525 + width: 100%; 526 + max-width: 600px; 527 + } 528 + 529 + .add-service-input { 530 + flex: 1; 531 + padding: 1rem 1.5rem; 532 + border: 3px solid rgba(255, 255, 255, 0.3); 533 + border-radius: 8px; 534 + background: transparent; 535 + color: white; 536 + font-size: 1.25rem; 537 + font-weight: 500; 538 + } 539 + 540 + .add-service-input::placeholder { 541 + color: rgba(255, 255, 255, 0.5); 542 + } 543 + 544 + .add-service-input:focus { 545 + outline: none; 546 + border-color: white; 547 + } 548 + 549 + .add-service-btn { 550 + padding: 1rem 1.5rem; 551 + background: transparent; 552 + color: white; 553 + border: 3px solid rgba(255, 255, 255, 0.3); 554 + border-radius: 8px; 555 + font-weight: 700; 556 + cursor: pointer; 557 + font-size: 1.25rem; 558 + transition: all 0.2s; 559 + } 560 + 561 + .add-service-btn:hover { 562 + border-color: white; 563 + background: rgba(255, 255, 255, 0.1); 564 + } 565 + 566 + .instructions { 567 + text-align: center; 568 + color: white; 569 + font-size: 1.125rem; 570 + margin-bottom: 2rem; 571 + opacity: 0.9; 572 + } 573 + 574 + .save-button { 575 + position: fixed; 576 + bottom: 2rem; 577 + right: 2rem; 578 + padding: 1rem 2rem; 579 + background: var(--go90-yellow); 580 + color: black; 581 + border: none; 582 + border-radius: 8px; 583 + font-weight: 900; 584 + font-size: 1.25rem; 585 + cursor: pointer; 586 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 587 + text-transform: uppercase; 588 + letter-spacing: 1px; 589 + display: none; 590 + } 591 + 592 + .save-button.visible { 593 + display: block; 594 + animation: pulse 2s infinite; 595 + } 596 + 597 + .save-button:hover { 598 + background: #ffff44; 599 + transform: scale(1.05); 600 + } 601 + 602 + @keyframes pulse { 603 + 0%, 604 + 100% { 605 + transform: scale(1); 606 + } 607 + 50% { 608 + transform: scale(1.05); 609 + } 610 + } 221 611 </style> 222 612 </head> 223 613 <body> ··· 230 620 <ellipse cx="0" cy="28" rx="40" ry="20" fill="#32CD32" /> 231 621 </g> 232 622 </svg> 233 - <h1>Slice Kit</h1> 234 - <p class="tagline">Build your slice of Atmosphere</p> 623 + <h1>Go90 Scale</h1> 624 + <p class="tagline">Rate streaming services on the Go90 scale</p> 235 625 </header> 236 626 <main> 237 627 <div id="auth-section"></div> 238 628 <div id="content"></div> 239 629 </main> 240 630 <div id="error-banner" class="hidden"></div> 631 + <button id="saveButton" class="save-button" onclick="saveAllRatings()">Save Ratings</button> 241 632 </div> 242 633 243 634 <!-- Quickslice Client SDK --> 244 635 <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script> 245 636 <!-- Web Components --> 246 637 <script src="https://cdn.jsdelivr.net/gh/bigmoves/elements@v0.1.0/dist/elements.min.js"></script> 638 + <script src="drag-fix.js"></script> 247 639 248 640 <script> 249 641 // ============================================================================= ··· 251 643 // ============================================================================= 252 644 253 645 const SERVER_URL = "http://127.0.0.1:8080"; 254 - const CLIENT_ID = ""; // Set your OAuth client ID here after registering 646 + const CLIENT_ID = "client_Wpu1V7VdSi1eKI01JTsKOg"; // Set your OAuth client ID here after registering 255 647 256 648 let client; 649 + let serviceMetadataCache = {}; 650 + let pendingRatings = {}; // Store ratings before saving 651 + 652 + const defaultServices = [ 653 + { domain: "netflix.com", name: "Netflix" }, 654 + { domain: "youtube.com", name: "YouTube" }, 655 + { domain: "max.com", name: "HBO Max" }, 656 + { domain: "disneyplus.com", name: "Disney+" }, 657 + { domain: "hulu.com", name: "Hulu" }, 658 + { domain: "tv.apple.com", name: "Apple TV" }, 659 + { domain: "primevideo.com", name: "Prime Video" }, 660 + { domain: "peacocktv.com", name: "Peacock" }, 661 + { domain: "paramountplus.com", name: "Paramount+" }, 662 + ]; 257 663 258 664 // ============================================================================= 259 665 // INITIALIZATION ··· 342 748 return data?.viewer; 343 749 } 344 750 751 + async function fetchRatings() { 752 + const query = ` 753 + query { 754 + socialGo90Rating(last: 50) { 755 + edges { 756 + node { 757 + serviceDomain 758 + rating 759 + comment 760 + createdAt 761 + actorHandle 762 + } 763 + } 764 + } 765 + } 766 + `; 767 + 768 + const data = await client.query(query); 769 + const edges = data?.socialGo90Rating?.edges || []; 770 + return edges.map((edge) => ({ 771 + ...edge.node, 772 + author: { handle: edge.node.actorHandle }, 773 + })); 774 + } 775 + 776 + async function fetchServiceMetadata(domain) { 777 + if (serviceMetadataCache[domain]) { 778 + return serviceMetadataCache[domain]; 779 + } 780 + 781 + const metadata = { 782 + name: domain, 783 + favicon: `https://www.google.com/s2/favicons?domain=${domain}&sz=128`, 784 + }; 785 + 786 + serviceMetadataCache[domain] = metadata; 787 + return metadata; 788 + } 789 + 345 790 // ============================================================================= 346 791 // EVENT HANDLERS 347 792 // ============================================================================= ··· 352 797 const handle = document.getElementById("handle").value.trim(); 353 798 354 799 if (!handle) { 355 - showError("Please enter your { }"); 800 + showError("Please enter your handle"); 356 801 return; 357 802 } 358 803 ··· 376 821 } 377 822 } 378 823 824 + async function handleRatingSubmit(event) { 825 + event.preventDefault(); 826 + 827 + const serviceDomain = document.getElementById("serviceDomain").value.trim(); 828 + const rating = parseInt(document.getElementById("rating").value); 829 + const comment = document.getElementById("comment").value.trim(); 830 + 831 + if (!serviceDomain) { 832 + showError("Please enter a service domain"); 833 + return; 834 + } 835 + 836 + // Basic domain validation 837 + if (!serviceDomain.includes(".") || serviceDomain.includes("/")) { 838 + showError("Please enter a valid domain (e.g., netflix.com)"); 839 + return; 840 + } 841 + 842 + try { 843 + const mutation = ` 844 + mutation CreateRating($input: CreateSocialGo90RatingInput!) { 845 + createSocialGo90Rating(input: $input) 846 + } 847 + `; 848 + 849 + const variables = { 850 + input: { 851 + serviceDomain, 852 + rating, 853 + comment: comment || undefined, 854 + createdAt: new Date().toISOString(), 855 + }, 856 + }; 857 + 858 + await client.query(mutation, variables); 859 + 860 + // Clear form 861 + document.getElementById("serviceDomain").value = ""; 862 + document.getElementById("rating").value = "45"; 863 + document.getElementById("comment").value = ""; 864 + updateRatingDisplay(45); 865 + 866 + // Refresh ratings list 867 + const viewer = await fetchViewer(); 868 + await renderContent(viewer); 869 + } catch (error) { 870 + console.error("Failed to submit rating:", error); 871 + showError(`Failed to submit rating: ${error.message}`); 872 + } 873 + } 874 + 875 + function updateRatingDisplay(value) { 876 + const display = document.getElementById("ratingDisplay"); 877 + const isDefunct = value === 90; 878 + display.textContent = value; 879 + display.className = isDefunct ? "rating-display defunct" : "rating-display"; 880 + } 881 + 882 + function updateCharCount() { 883 + const comment = document.getElementById("comment").value; 884 + const count = document.getElementById("charCount"); 885 + count.textContent = `${comment.length}/300`; 886 + } 887 + 888 + // OLD: function initDragAndDrop() { 889 + // OLD: const serviceItems = document.querySelectorAll(".service-item"); 890 + // OLD: const dropZone = document.getElementById("dropZone"); 891 + // OLD: const scaleBar = document.getElementById("scaleBar"); 892 + // OLD: 893 + // OLD: serviceItems.forEach((item) => { 894 + // OLD: item.addEventListener("dragstart", handleDragStart); 895 + // OLD: item.addEventListener("dragend", handleDragEnd); 896 + // OLD: }); 897 + // OLD: 898 + // OLD: dropZone.addEventListener("dragover", handleDragOver); 899 + // OLD: dropZone.addEventListener("drop", handleDrop); 900 + // OLD: dropZone.addEventListener("dragleave", handleDragLeave); 901 + // OLD: } 902 + // OLD: 903 + // OLD: let draggedItem = null; 904 + // OLD: 905 + // OLD: function handleDragStart(e) { 906 + // OLD: draggedItem = e.target; 907 + // OLD: e.target.classList.add("dragging"); 908 + // OLD: } 909 + // OLD: 910 + // OLD: function handleDragEnd(e) { 911 + // OLD: e.target.classList.remove("dragging"); 912 + // OLD: // Don't clear draggedItem here - it's needed in handleDrop 913 + // OLD: setTimeout(() => { 914 + // OLD: draggedItem = null; 915 + // OLD: }, 100); 916 + // OLD: } 917 + // OLD: 918 + // OLD: function handleDragOver(e) { 919 + // OLD: e.preventDefault(); 920 + // OLD: document.getElementById("scaleBar").classList.add("drag-over"); 921 + // OLD: } 922 + // OLD: 923 + // OLD: function handleDragLeave(e) { 924 + // OLD: if (e.target === document.getElementById("dropZone")) { 925 + // OLD: document.getElementById("scaleBar").classList.remove("drag-over"); 926 + // OLD: } 927 + // OLD: } 928 + // OLD: 929 + // OLD: async function handleDrop(e) { 930 + // OLD: e.preventDefault(); 931 + // OLD: document.getElementById("scaleBar").classList.remove("drag-over"); 932 + // OLD: 933 + // OLD: if (!draggedItem) return; 934 + // OLD: 935 + // OLD: const domain = draggedItem.dataset.domain; 936 + // OLD: const name = draggedItem.dataset.name; 937 + // OLD: 938 + // OLD: // Calculate rating and position based on drop location 939 + // OLD: const scaleBar = document.getElementById("scaleBar"); 940 + // OLD: const rect = scaleBar.getBoundingClientRect(); 941 + // OLD: const x = e.clientX - rect.left; 942 + // OLD: const y = e.clientY - rect.top; 943 + // OLD: 944 + // OLD: const percentageX = Math.max(0, Math.min(100, (x / rect.width) * 100)); 945 + // OLD: const rating = Math.round((percentageX / 100) * 90); 946 + // OLD: 947 + // OLD: // Allow vertical offset from center 948 + // OLD: const offsetY = y - rect.height / 2; 949 + // OLD: 950 + // OLD: // Store in pending ratings (don't submit yet) 951 + // OLD: pendingRatings[domain] = { 952 + // OLD: domain, 953 + // OLD: name, 954 + // OLD: rating, 955 + // OLD: percentageX, 956 + // OLD: offsetY, 957 + // OLD: }; 958 + // OLD: 959 + // OLD: // Don't mark as rated - allow re-dragging 960 + // OLD: // draggedItem.classList.add("rated"); 961 + // OLD: 962 + // OLD: // Add service to scale 963 + // OLD: addServiceToScale(domain, name, rating, percentageX, offsetY); 964 + // OLD: 965 + // OLD: // Show save button 966 + // OLD: updateSaveButton(); 967 + // OLD: } 968 + // OLD: 969 + async function submitRating(serviceDomain, rating, comment = "") { 970 + try { 971 + const mutation = ` 972 + mutation CreateRating($input: CreateSocialGo90RatingInput!) { 973 + createSocialGo90Rating(input: $input) 974 + } 975 + `; 976 + 977 + const input = { 978 + serviceDomain: serviceDomain, 979 + rating: rating, 980 + createdAt: new Date().toISOString(), 981 + }; 982 + 983 + // Only add comment if it exists 984 + if (comment && comment.trim()) { 985 + input.comment = comment.trim(); 986 + } 987 + 988 + const variables = { input }; 989 + 990 + await client.query(mutation, variables); 991 + } catch (error) { 992 + console.error("Failed to submit rating:", error); 993 + showError(`Failed to submit rating: ${error.message}`); 994 + throw error; 995 + } 996 + } 997 + // OLD: 998 + // OLD: function addServiceToScale(domain, name, rating, percentageX, offsetY = 0) { 999 + // OLD: const scaleBar = document.getElementById("scaleBar"); 1000 + // OLD: 1001 + // OLD: // Remove existing rating for this service 1002 + // OLD: const existing = scaleBar.querySelector(`[data-scale-domain="${domain}"]`); 1003 + // OLD: if (existing) existing.remove(); 1004 + // OLD: 1005 + // OLD: const serviceEl = document.createElement("div"); 1006 + // OLD: serviceEl.className = "service-on-scale"; 1007 + // OLD: serviceEl.dataset.scaleDomain = domain; 1008 + // OLD: serviceEl.style.left = `${percentageX}%`; 1009 + // OLD: serviceEl.style.top = `calc(50% + ${offsetY}px)`; 1010 + // OLD: serviceEl.draggable = true; 1011 + // OLD: serviceEl.innerHTML = ` 1012 + // OLD: <img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128" 1013 + // OLD: alt="${name}" 1014 + // OLD: class="service-logo-large"> 1015 + // OLD: <div class="service-badge ${rating === 90 ? "defunct" : ""}">${rating}</div> 1016 + // OLD: `; 1017 + // OLD: 1018 + // OLD: // Make it re-draggable to update rating 1019 + // OLD: serviceEl.addEventListener("dragstart", (e) => { 1020 + // OLD: draggedItem = { dataset: { domain, name } }; 1021 + // OLD: e.target.classList.add("dragging"); 1022 + // OLD: }); 1023 + // OLD: 1024 + // OLD: serviceEl.addEventListener("dragend", (e) => { 1025 + // OLD: e.target.classList.remove("dragging"); 1026 + // OLD: e.target.remove(); // Remove from scale when re-dragging 1027 + // OLD: }); 1028 + // OLD: 1029 + // OLD: scaleBar.appendChild(serviceEl); 1030 + // OLD: } 1031 + // OLD: 1032 + async function addCustomService(e) { 1033 + if (e) e.preventDefault(); 1034 + 1035 + const input = document.getElementById("customServiceDomain"); 1036 + const domain = input.value.trim(); 1037 + 1038 + if (!domain) { 1039 + showError("Please enter a domain"); 1040 + return; 1041 + } 1042 + 1043 + if (!domain.includes(".") || domain.includes("/")) { 1044 + showError("Please enter a valid domain (e.g., dropout.tv)"); 1045 + return; 1046 + } 1047 + 1048 + // Add to services bar 1049 + const servicesBar = document.getElementById("servicesBar"); 1050 + const serviceEl = document.createElement("div"); 1051 + serviceEl.className = "service-item"; 1052 + serviceEl.dataset.domain = domain; 1053 + serviceEl.dataset.name = domain; 1054 + serviceEl.innerHTML = ` 1055 + <img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128" 1056 + alt="${domain}" 1057 + class="service-logo" 1058 + draggable="false"> 1059 + `; 1060 + 1061 + setupServiceDrag(serviceEl); 1062 + servicesBar.appendChild(serviceEl); 1063 + input.value = ""; 1064 + } 1065 + 1066 + function updateSaveButton() { 1067 + const saveButton = document.getElementById("saveButton"); 1068 + const hasRatings = Object.keys(pendingRatings).length > 0; 1069 + 1070 + if (hasRatings) { 1071 + saveButton.classList.add("visible"); 1072 + } else { 1073 + saveButton.classList.remove("visible"); 1074 + } 1075 + } 1076 + 1077 + async function saveAllRatings() { 1078 + const saveButton = document.getElementById("saveButton"); 1079 + saveButton.disabled = true; 1080 + saveButton.textContent = "Saving..."; 1081 + 1082 + try { 1083 + for (const [domain, data] of Object.entries(pendingRatings)) { 1084 + await submitRating(data.domain, data.rating); 1085 + } 1086 + 1087 + // Clear pending ratings (but keep services on canvas) 1088 + pendingRatings = {}; 1089 + updateSaveButton(); 1090 + 1091 + saveButton.textContent = "Saved!"; 1092 + setTimeout(() => { 1093 + saveButton.textContent = "Save Ratings"; 1094 + saveButton.disabled = false; 1095 + }, 2000); 1096 + } catch (error) { 1097 + saveButton.textContent = "Save Ratings"; 1098 + saveButton.disabled = false; 1099 + showError("Failed to save some ratings. Please try again."); 1100 + } 1101 + } 1102 + 379 1103 // ============================================================================= 380 1104 // UI RENDERING 381 1105 // ============================================================================= ··· 420 1144 <div class="card"> 421 1145 <form class="login-form" onsubmit="handleLogin(event)"> 422 1146 <div class="form-group"> 423 - <label for="handle">Handle</label> 1147 + <label for="handle">AT Protocol Handle</label> 424 1148 <qs-actor-autocomplete 425 1149 id="handle" 426 1150 name="handle" ··· 456 1180 `; 457 1181 } 458 1182 459 - function renderContent(viewer) { 1183 + async function renderContent(viewer) { 460 1184 const container = document.getElementById("content"); 1185 + 461 1186 container.innerHTML = ` 462 - <div class="card"> 463 - <p style="color: var(--gray-700);">You're logged in! #getsliced</p> 1187 + <div class="scale-container"> 1188 + <div class="drag-canvas" id="dragCanvas"> 1189 + <canvas id="arcCanvas"></canvas> 1190 + </div> 1191 + 1192 + <div class="services-bar" id="servicesBar"> 1193 + ${defaultServices 1194 + .map( 1195 + (service) => ` 1196 + <div class="service-item" 1197 + data-domain="${service.domain}" 1198 + data-name="${service.name}"> 1199 + <img src="https://www.google.com/s2/favicons?domain=${service.domain}&sz=128" 1200 + alt="${service.name}" 1201 + class="service-logo" 1202 + draggable="false"> 1203 + </div> 1204 + `, 1205 + ) 1206 + .join("")} 1207 + </div> 1208 + 1209 + <form class="add-service-form" onsubmit="addCustomService(event)"> 1210 + <input type="text" 1211 + id="customServiceDomain" 1212 + class="add-service-input" 1213 + placeholder="add service"> 1214 + <button type="submit" class="add-service-btn">add service</button> 1215 + </form> 464 1216 </div> 465 1217 `; 1218 + 1219 + initDragAndDrop(); 1220 + await loadExistingRatings(); 1221 + } 1222 + 1223 + window.existingRatings = {}; // Store existing ratings globally 1224 + 1225 + async function loadExistingRatings() { 1226 + try { 1227 + // Fetch viewer's own ratings using edges/node structure 1228 + const query = ` 1229 + query { 1230 + viewer { 1231 + did 1232 + } 1233 + socialGo90Rating(last: 100, filter: { did: { equalTo: $viewerDid } }) { 1234 + edges { 1235 + node { 1236 + serviceDomain 1237 + rating 1238 + did 1239 + } 1240 + } 1241 + } 1242 + } 1243 + `; 1244 + 1245 + // First get viewer DID 1246 + const viewerQuery = `query { viewer { did } }`; 1247 + const viewerData = await client.query(viewerQuery); 1248 + const viewerDid = viewerData?.viewer?.did; 1249 + 1250 + if (!viewerDid) return; 1251 + 1252 + // Now fetch ratings for this viewer 1253 + const ratingsQuery = ` 1254 + query { 1255 + socialGo90Rating(last: 100) { 1256 + edges { 1257 + node { 1258 + serviceDomain 1259 + rating 1260 + did 1261 + } 1262 + } 1263 + } 1264 + } 1265 + `; 1266 + 1267 + const data = await client.query(ratingsQuery); 1268 + const allEdges = data?.socialGo90Rating?.edges || []; 1269 + 1270 + // Filter to only viewer's ratings and deduplicate by domain (keep latest) 1271 + const ratingsByDomain = {}; 1272 + allEdges 1273 + .filter((edge) => edge.node.did === viewerDid) 1274 + .forEach((edge) => { 1275 + const node = edge.node; 1276 + ratingsByDomain[node.serviceDomain] = node; 1277 + }); 1278 + const myRatings = Object.values(ratingsByDomain); 1279 + 1280 + if (myRatings && myRatings.length > 0) { 1281 + // Store ratings in global object 1282 + myRatings.forEach((rating) => { 1283 + window.existingRatings[rating.serviceDomain] = rating.rating; 1284 + }); 1285 + 1286 + // Update badges on services in the bar 1287 + updateServiceBadges(); 1288 + 1289 + // Load viewer's ratings onto arc canvas 1290 + for (const rating of myRatings) { 1291 + console.log(`Loading ${rating.serviceDomain} at rating ${rating.rating}`); 1292 + 1293 + // Use arc-based placement from drag-fix.js 1294 + if (typeof placeServiceOnArc !== "undefined") { 1295 + placeServiceOnArc(rating.serviceDomain, rating.serviceDomain, rating.rating); 1296 + } 1297 + } 1298 + } 1299 + } catch (error) { 1300 + // No ratings exist yet, that's OK 1301 + console.log("No existing ratings found (this is normal for first use)"); 1302 + } 1303 + } 1304 + 1305 + function updateServiceBadges() { 1306 + const servicesBar = document.getElementById("servicesBar"); 1307 + if (!servicesBar) return; 1308 + 1309 + const serviceItems = servicesBar.querySelectorAll(".service-item"); 1310 + 1311 + serviceItems.forEach((item) => { 1312 + const domain = item.dataset.domain; 1313 + const rating = window.existingRatings[domain]; 1314 + 1315 + // Remove existing badge if any 1316 + const existingBadge = item.querySelector(".service-badge"); 1317 + if (existingBadge) existingBadge.remove(); 1318 + 1319 + // Add badge if there's a rating 1320 + if (rating !== undefined) { 1321 + const badge = document.createElement("div"); 1322 + badge.className = `service-badge ${rating === 90 ? "defunct" : ""}`; 1323 + badge.textContent = rating; 1324 + item.appendChild(badge); 1325 + } 1326 + }); 1327 + } 1328 + 1329 + async function renderRatingsList(ratings) { 1330 + const container = document.getElementById("ratingsList"); 1331 + 1332 + if (!ratings || ratings.length === 0) { 1333 + container.innerHTML = ` 1334 + <div class="empty-state">No ratings yet. Be the first to rate a service!</div> 1335 + `; 1336 + return; 1337 + } 1338 + 1339 + let html = '<div class="ratings-list">'; 1340 + 1341 + for (const rating of ratings) { 1342 + const metadata = await fetchServiceMetadata(rating.serviceDomain); 1343 + const displayName = 1344 + rating.author?.appBskyActorProfileByDid?.displayName || 1345 + rating.author?.handle || 1346 + "Anonymous"; 1347 + const isDefunct = rating.rating === 90; 1348 + const ratingClass = isDefunct ? "rating-value defunct" : "rating-value"; 1349 + const date = new Date(rating.createdAt).toLocaleDateString(); 1350 + 1351 + html += ` 1352 + <div class="rating-item"> 1353 + <div class="rating-header"> 1354 + <img src="${escapeHtml(metadata.favicon)}" alt="" class="service-favicon" onerror="this.style.display='none'" /> 1355 + <span class="service-name">${escapeHtml(metadata.name)}</span> 1356 + <span class="${ratingClass}">${rating.rating}</span> 1357 + </div> 1358 + <div class="rating-meta"> 1359 + Rated by ${escapeHtml(displayName)} (@${escapeHtml(rating.author?.handle)}) on ${date} 1360 + </div> 1361 + ${rating.comment ? `<div class="rating-comment">${escapeHtml(rating.comment)}</div>` : ""} 1362 + </div> 1363 + `; 1364 + } 1365 + 1366 + html += "</div>"; 1367 + container.innerHTML = html; 466 1368 } 467 1369 468 1370 main();
+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>
+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>
+37
lexicons/social/go90/rating.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.go90.rating", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A rating of a streaming service on the Go90 scale", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["serviceDomain", "rating", "createdAt"], 12 + "properties": { 13 + "serviceDomain": { 14 + "type": "string", 15 + "description": "Domain of the service being rated (e.g., netflix.com, dropout.tv)" 16 + }, 17 + "rating": { 18 + "type": "integer", 19 + "minimum": 0, 20 + "maximum": 90, 21 + "description": "Rating on the Go90 scale: 0 = thriving, 1-89 = at risk, 90 = defunct" 22 + }, 23 + "comment": { 24 + "type": "string", 25 + "maxLength": 300, 26 + "maxGraphemes": 300, 27 + "description": "Optional commentary on the rating" 28 + }, 29 + "createdAt": { 30 + "type": "string", 31 + "format": "datetime" 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
lexicons.zip

This is a binary file and will not be displayed.