+410
drag-fix.js
+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
+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
+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()">×</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
+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()">×</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
+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()">×</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
+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()">×</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
+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()">×</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>
lexicons.zip
lexicons.zip
This is a binary file and will not be displayed.