+198
drag-fix.js
+198
drag-fix.js
···
1
+
// New unified drag system
2
+
console.log("Drag fix script loaded");
3
+
4
+
function initDragAndDrop() {
5
+
console.log("initDragAndDrop called");
6
+
const canvas = document.getElementById("dragCanvas");
7
+
const servicesBar = document.getElementById("servicesBar");
8
+
const serviceItems = document.querySelectorAll(".service-item");
9
+
10
+
console.log("Found service items:", serviceItems.length);
11
+
12
+
// Setup all service items for dragging
13
+
serviceItems.forEach(setupServiceDrag);
14
+
15
+
// Make canvas accept drops
16
+
canvas.addEventListener("dragover", (e) => e.preventDefault());
17
+
canvas.addEventListener("drop", handleCanvasDrop);
18
+
}
19
+
20
+
let currentDrag = null;
21
+
22
+
function setupServiceDrag(element) {
23
+
element.style.cursor = "grab";
24
+
element.addEventListener("mousedown", startDrag);
25
+
}
26
+
27
+
function startDrag(e) {
28
+
const serviceItem = e.target.closest(".service-item");
29
+
if (!serviceItem) return;
30
+
31
+
e.preventDefault();
32
+
33
+
const clone = serviceItem.cloneNode(true);
34
+
clone.style.position = "fixed";
35
+
clone.style.pointerEvents = "none";
36
+
clone.style.zIndex = "10000";
37
+
clone.style.width = "80px";
38
+
clone.style.height = "80px";
39
+
40
+
currentDrag = {
41
+
element: serviceItem,
42
+
clone: clone,
43
+
domain: serviceItem.dataset.domain,
44
+
name: serviceItem.dataset.name,
45
+
startX: e.clientX,
46
+
startY: e.clientY,
47
+
};
48
+
49
+
document.body.appendChild(clone);
50
+
updateClonePosition(e);
51
+
52
+
serviceItem.style.opacity = "0.3";
53
+
54
+
document.addEventListener("mousemove", onDragMove);
55
+
document.addEventListener("mouseup", onDragEnd);
56
+
}
57
+
58
+
function onDragMove(e) {
59
+
if (!currentDrag) return;
60
+
updateClonePosition(e);
61
+
}
62
+
63
+
function updateClonePosition(e) {
64
+
if (!currentDrag) return;
65
+
currentDrag.clone.style.left = e.clientX - 40 + "px";
66
+
currentDrag.clone.style.top = e.clientY - 40 + "px";
67
+
}
68
+
69
+
function onDragEnd(e) {
70
+
if (!currentDrag) return;
71
+
72
+
document.removeEventListener("mousemove", onDragMove);
73
+
document.removeEventListener("mouseup", onDragEnd);
74
+
75
+
const domain = currentDrag.domain;
76
+
const name = currentDrag.name;
77
+
78
+
// Check where it was dropped
79
+
const canvas = document.getElementById("dragCanvas");
80
+
const servicesBar = document.getElementById("servicesBar");
81
+
const scaleBar = document.getElementById("scaleBar");
82
+
83
+
const servicesBarRect = servicesBar.getBoundingClientRect();
84
+
const canvasRect = canvas.getBoundingClientRect();
85
+
const scaleBarRect = scaleBar.getBoundingClientRect();
86
+
87
+
const dropX = e.clientX;
88
+
const dropY = e.clientY;
89
+
90
+
// Clean up
91
+
currentDrag.clone.remove();
92
+
currentDrag.element.style.opacity = "1";
93
+
94
+
// Check if dropped on services bar
95
+
if (
96
+
dropY >= servicesBarRect.top &&
97
+
dropY <= servicesBarRect.bottom &&
98
+
dropX >= servicesBarRect.left &&
99
+
dropX <= servicesBarRect.right
100
+
) {
101
+
// Return to bar - remove rating
102
+
removeServiceFromCanvas(domain);
103
+
delete pendingRatings[domain];
104
+
updateSaveButton();
105
+
currentDrag = null;
106
+
return;
107
+
}
108
+
109
+
// Calculate rating from horizontal position relative to scale bar
110
+
const scaleX = dropX - scaleBarRect.left;
111
+
const percentage = Math.max(0, Math.min(100, (scaleX / scaleBarRect.width) * 100));
112
+
const rating = Math.round((percentage / 100) * 90);
113
+
114
+
// Position on canvas
115
+
const canvasX = dropX - canvasRect.left;
116
+
const canvasY = dropY - canvasRect.top;
117
+
118
+
// Store pending rating
119
+
pendingRatings[domain] = {
120
+
domain,
121
+
name,
122
+
rating,
123
+
x: canvasX,
124
+
y: canvasY,
125
+
};
126
+
127
+
// Place on canvas
128
+
placeServiceOnCanvas(domain, name, rating, canvasX, canvasY);
129
+
updateSaveButton();
130
+
131
+
currentDrag = null;
132
+
}
133
+
134
+
function placeServiceOnCanvas(domain, name, rating, x, y) {
135
+
const canvas = document.getElementById("dragCanvas");
136
+
const servicesBar = document.getElementById("servicesBar");
137
+
138
+
// Remove from services bar if it's there
139
+
const inBar = servicesBar.querySelector(`[data-domain="${domain}"]`);
140
+
if (inBar) {
141
+
inBar.remove();
142
+
}
143
+
144
+
// Remove existing placement on canvas
145
+
const existing = canvas.querySelector(`[data-canvas-domain="${domain}"]`);
146
+
if (existing) existing.remove();
147
+
148
+
// Create element
149
+
const el = document.createElement("div");
150
+
el.className = "service-item on-canvas";
151
+
el.dataset.domain = domain;
152
+
el.dataset.name = name;
153
+
el.dataset.canvasDomain = domain;
154
+
el.style.position = "absolute";
155
+
el.style.left = x - 40 + "px";
156
+
el.style.top = y - 40 + "px";
157
+
el.innerHTML = `
158
+
<img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128"
159
+
alt="${name}"
160
+
class="service-logo"
161
+
draggable="false">
162
+
<div class="service-badge ${rating === 90 ? "defunct" : ""}">${rating}</div>
163
+
`;
164
+
165
+
setupServiceDrag(el);
166
+
canvas.appendChild(el);
167
+
}
168
+
169
+
function removeServiceFromCanvas(domain) {
170
+
const canvas = document.getElementById("dragCanvas");
171
+
const servicesBar = document.getElementById("servicesBar");
172
+
173
+
// Remove from canvas
174
+
const existing = canvas.querySelector(`[data-canvas-domain="${domain}"]`);
175
+
if (existing) {
176
+
const name = existing.dataset.name;
177
+
existing.remove();
178
+
179
+
// Add back to services bar
180
+
const serviceEl = document.createElement("div");
181
+
serviceEl.className = "service-item";
182
+
serviceEl.dataset.domain = domain;
183
+
serviceEl.dataset.name = name;
184
+
serviceEl.innerHTML = `
185
+
<img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128"
186
+
alt="${name}"
187
+
class="service-logo"
188
+
draggable="false">
189
+
`;
190
+
setupServiceDrag(serviceEl);
191
+
servicesBar.appendChild(serviceEl);
192
+
}
193
+
}
194
+
195
+
function handleCanvasDrop(e) {
196
+
// This is here for compatibility but we use mouse events instead
197
+
e.preventDefault();
198
+
}
+524
-65
index.html
+524
-65
index.html
···
3
3
<head>
4
4
<meta charset="UTF-8" />
5
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
-
<title>Slice Kit</title>
6
+
<title>Go90 Social</title>
7
7
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
8
8
<style>
9
9
*,
···
35
35
--error-bg: #fef2f2;
36
36
--error-border: #fecaca;
37
37
--error-text: #dc2626;
38
+
--go90-blue: #2020ff;
39
+
--go90-yellow: #ffff00;
38
40
}
39
41
40
42
body {
41
43
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
42
-
background: var(--gray-100);
43
-
color: var(--gray-900);
44
+
background: var(--go90-blue);
45
+
color: white;
44
46
min-height: 100vh;
45
47
padding: 2rem 1rem;
46
48
}
47
49
48
50
#app {
49
-
max-width: 500px;
51
+
max-width: 1100px;
50
52
margin: 0 auto;
51
53
}
52
54
···
62
64
}
63
65
64
66
header h1 {
65
-
font-size: 2rem;
66
-
color: var(--primary-500);
67
+
font-size: 2.5rem;
68
+
color: var(--go90-yellow);
67
69
margin-bottom: 0.25rem;
70
+
font-weight: 900;
71
+
text-transform: uppercase;
72
+
letter-spacing: 2px;
68
73
}
69
74
70
75
.tagline {
71
-
color: var(--gray-500);
72
-
font-size: 1rem;
76
+
color: white;
77
+
font-size: 1.125rem;
73
78
}
74
79
75
80
.card {
···
376
381
.btn-block {
377
382
width: 100%;
378
383
}
384
+
385
+
/* Go90 Scale Interface */
386
+
.scale-container {
387
+
background: var(--go90-blue);
388
+
border: 4px solid var(--go90-yellow);
389
+
padding: 3rem 2rem;
390
+
border-radius: 8px;
391
+
margin-bottom: 2rem;
392
+
min-height: 600px;
393
+
position: relative;
394
+
}
395
+
396
+
.drag-canvas {
397
+
position: relative;
398
+
min-height: 500px;
399
+
}
400
+
401
+
.scale-bar-wrapper {
402
+
margin-bottom: 3rem;
403
+
}
404
+
405
+
.scale-labels {
406
+
display: flex;
407
+
justify-content: space-between;
408
+
margin-bottom: 1rem;
409
+
}
410
+
411
+
.scale-label {
412
+
font-size: 3rem;
413
+
font-weight: 900;
414
+
color: var(--go90-yellow);
415
+
}
416
+
417
+
.scale-label.red {
418
+
color: #ff5555;
419
+
}
420
+
421
+
.scale-bar {
422
+
position: relative;
423
+
height: 80px;
424
+
background: linear-gradient(
425
+
to right,
426
+
#32cd32 0%,
427
+
#7fff00 10%,
428
+
#adff2f 20%,
429
+
#ffff00 30%,
430
+
#ffd700 40%,
431
+
#ffa500 50%,
432
+
#ff8c00 60%,
433
+
#ff6347 70%,
434
+
#ff4500 80%,
435
+
#dc143c 90%,
436
+
#8b0000 100%
437
+
);
438
+
border-radius: 12px;
439
+
border: 3px solid white;
440
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
441
+
margin-bottom: 120px;
442
+
margin-top: 120px;
443
+
}
444
+
445
+
.scale-bar.drag-over {
446
+
box-shadow: 0 0 20px rgba(255, 255, 0, 0.8);
447
+
border-color: var(--go90-yellow);
448
+
}
449
+
450
+
.drop-zone {
451
+
position: absolute;
452
+
top: 0;
453
+
left: 0;
454
+
right: 0;
455
+
bottom: 0;
456
+
border-radius: 12px;
457
+
}
458
+
459
+
.service-on-scale {
460
+
position: absolute;
461
+
top: 50%;
462
+
transform: translate(-50%, -50%);
463
+
cursor: move;
464
+
transition: transform 0.1s;
465
+
}
466
+
467
+
.service-on-scale:hover {
468
+
transform: translate(-50%, -50%) scale(1.1);
469
+
}
470
+
471
+
.service-logo-large {
472
+
width: 80px;
473
+
height: 80px;
474
+
border-radius: 12px;
475
+
background: white;
476
+
padding: 8px;
477
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
478
+
border: 3px solid white;
479
+
}
480
+
481
+
.service-badge {
482
+
position: absolute;
483
+
bottom: -12px;
484
+
right: -12px;
485
+
background: black;
486
+
color: var(--go90-yellow);
487
+
font-size: 1.25rem;
488
+
font-weight: 900;
489
+
padding: 4px 12px;
490
+
border-radius: 50%;
491
+
border: 3px solid var(--go90-yellow);
492
+
min-width: 48px;
493
+
text-align: center;
494
+
}
495
+
496
+
.service-badge.defunct {
497
+
color: #ff5555;
498
+
border-color: #ff5555;
499
+
}
500
+
501
+
.services-bar {
502
+
background: black;
503
+
padding: 1.5rem;
504
+
border-radius: 8px;
505
+
display: flex;
506
+
gap: 1rem;
507
+
align-items: center;
508
+
flex-wrap: wrap;
509
+
min-height: 100px;
510
+
}
511
+
512
+
.service-item {
513
+
width: 80px;
514
+
height: 80px;
515
+
border-radius: 8px;
516
+
background: white;
517
+
padding: 8px;
518
+
cursor: grab;
519
+
transition:
520
+
transform 0.2s,
521
+
opacity 0.2s;
522
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
523
+
position: relative;
524
+
}
525
+
526
+
.service-item:hover {
527
+
transform: scale(1.05);
528
+
}
529
+
530
+
.service-item:active {
531
+
cursor: grabbing;
532
+
}
533
+
534
+
.service-item.dragging {
535
+
opacity: 0.3;
536
+
}
537
+
538
+
.service-item.on-canvas {
539
+
position: absolute;
540
+
top: 0;
541
+
left: 0;
542
+
}
543
+
544
+
.service-logo {
545
+
width: 100%;
546
+
height: 100%;
547
+
object-fit: contain;
548
+
}
549
+
550
+
.add-service-form {
551
+
display: flex;
552
+
gap: 0.5rem;
553
+
align-items: center;
554
+
margin-top: 1rem;
555
+
}
556
+
557
+
.add-service-input {
558
+
flex: 1;
559
+
padding: 0.75rem;
560
+
border: 2px solid var(--gray-500);
561
+
border-radius: 4px;
562
+
background: var(--gray-900);
563
+
color: white;
564
+
font-size: 1rem;
565
+
}
566
+
567
+
.add-service-input:focus {
568
+
outline: none;
569
+
border-color: var(--go90-yellow);
570
+
}
571
+
572
+
.add-service-btn {
573
+
padding: 0.75rem 1.5rem;
574
+
background: var(--go90-yellow);
575
+
color: black;
576
+
border: none;
577
+
border-radius: 4px;
578
+
font-weight: 700;
579
+
cursor: pointer;
580
+
font-size: 1rem;
581
+
}
582
+
583
+
.add-service-btn:hover {
584
+
background: #ffff44;
585
+
}
586
+
587
+
.instructions {
588
+
text-align: center;
589
+
color: white;
590
+
font-size: 1.125rem;
591
+
margin-bottom: 2rem;
592
+
opacity: 0.9;
593
+
}
594
+
595
+
.save-button {
596
+
position: fixed;
597
+
bottom: 2rem;
598
+
right: 2rem;
599
+
padding: 1rem 2rem;
600
+
background: var(--go90-yellow);
601
+
color: black;
602
+
border: none;
603
+
border-radius: 8px;
604
+
font-weight: 900;
605
+
font-size: 1.25rem;
606
+
cursor: pointer;
607
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
608
+
text-transform: uppercase;
609
+
letter-spacing: 1px;
610
+
display: none;
611
+
}
612
+
613
+
.save-button.visible {
614
+
display: block;
615
+
animation: pulse 2s infinite;
616
+
}
617
+
618
+
.save-button:hover {
619
+
background: #ffff44;
620
+
transform: scale(1.05);
621
+
}
622
+
623
+
@keyframes pulse {
624
+
0%,
625
+
100% {
626
+
transform: scale(1);
627
+
}
628
+
50% {
629
+
transform: scale(1.05);
630
+
}
631
+
}
379
632
</style>
380
633
</head>
381
634
<body>
···
396
649
<div id="content"></div>
397
650
</main>
398
651
<div id="error-banner" class="hidden"></div>
652
+
<button id="saveButton" class="save-button" onclick="saveAllRatings()">Save Ratings</button>
399
653
</div>
400
654
401
655
<!-- Quickslice Client SDK -->
402
656
<script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script>
403
657
<!-- Web Components -->
404
658
<script src="https://cdn.jsdelivr.net/gh/bigmoves/elements@v0.1.0/dist/elements.min.js"></script>
659
+
<script src="drag-fix.js"></script>
405
660
406
661
<script>
407
662
// =============================================================================
···
413
668
414
669
let client;
415
670
let serviceMetadataCache = {};
671
+
let pendingRatings = {}; // Store ratings before saving
672
+
673
+
const defaultServices = [
674
+
{ domain: "netflix.com", name: "Netflix" },
675
+
{ domain: "youtube.com", name: "YouTube" },
676
+
{ domain: "max.com", name: "HBO Max" },
677
+
{ domain: "disneyplus.com", name: "Disney+" },
678
+
{ domain: "hulu.com", name: "Hulu" },
679
+
{ domain: "tv.apple.com", name: "Apple TV" },
680
+
{ domain: "primevideo.com", name: "Prime Video" },
681
+
{ domain: "peacocktv.com", name: "Peacock" },
682
+
{ domain: "paramountplus.com", name: "Paramount+" },
683
+
];
416
684
417
685
// =============================================================================
418
686
// INITIALIZATION
···
638
906
count.textContent = `${comment.length}/300`;
639
907
}
640
908
909
+
// OLD: function initDragAndDrop() {
910
+
// OLD: const serviceItems = document.querySelectorAll(".service-item");
911
+
// OLD: const dropZone = document.getElementById("dropZone");
912
+
// OLD: const scaleBar = document.getElementById("scaleBar");
913
+
// OLD:
914
+
// OLD: serviceItems.forEach((item) => {
915
+
// OLD: item.addEventListener("dragstart", handleDragStart);
916
+
// OLD: item.addEventListener("dragend", handleDragEnd);
917
+
// OLD: });
918
+
// OLD:
919
+
// OLD: dropZone.addEventListener("dragover", handleDragOver);
920
+
// OLD: dropZone.addEventListener("drop", handleDrop);
921
+
// OLD: dropZone.addEventListener("dragleave", handleDragLeave);
922
+
// OLD: }
923
+
// OLD:
924
+
// OLD: let draggedItem = null;
925
+
// OLD:
926
+
// OLD: function handleDragStart(e) {
927
+
// OLD: draggedItem = e.target;
928
+
// OLD: e.target.classList.add("dragging");
929
+
// OLD: }
930
+
// OLD:
931
+
// OLD: function handleDragEnd(e) {
932
+
// OLD: e.target.classList.remove("dragging");
933
+
// OLD: // Don't clear draggedItem here - it's needed in handleDrop
934
+
// OLD: setTimeout(() => {
935
+
// OLD: draggedItem = null;
936
+
// OLD: }, 100);
937
+
// OLD: }
938
+
// OLD:
939
+
// OLD: function handleDragOver(e) {
940
+
// OLD: e.preventDefault();
941
+
// OLD: document.getElementById("scaleBar").classList.add("drag-over");
942
+
// OLD: }
943
+
// OLD:
944
+
// OLD: function handleDragLeave(e) {
945
+
// OLD: if (e.target === document.getElementById("dropZone")) {
946
+
// OLD: document.getElementById("scaleBar").classList.remove("drag-over");
947
+
// OLD: }
948
+
// OLD: }
949
+
// OLD:
950
+
// OLD: async function handleDrop(e) {
951
+
// OLD: e.preventDefault();
952
+
// OLD: document.getElementById("scaleBar").classList.remove("drag-over");
953
+
// OLD:
954
+
// OLD: if (!draggedItem) return;
955
+
// OLD:
956
+
// OLD: const domain = draggedItem.dataset.domain;
957
+
// OLD: const name = draggedItem.dataset.name;
958
+
// OLD:
959
+
// OLD: // Calculate rating and position based on drop location
960
+
// OLD: const scaleBar = document.getElementById("scaleBar");
961
+
// OLD: const rect = scaleBar.getBoundingClientRect();
962
+
// OLD: const x = e.clientX - rect.left;
963
+
// OLD: const y = e.clientY - rect.top;
964
+
// OLD:
965
+
// OLD: const percentageX = Math.max(0, Math.min(100, (x / rect.width) * 100));
966
+
// OLD: const rating = Math.round((percentageX / 100) * 90);
967
+
// OLD:
968
+
// OLD: // Allow vertical offset from center
969
+
// OLD: const offsetY = y - rect.height / 2;
970
+
// OLD:
971
+
// OLD: // Store in pending ratings (don't submit yet)
972
+
// OLD: pendingRatings[domain] = {
973
+
// OLD: domain,
974
+
// OLD: name,
975
+
// OLD: rating,
976
+
// OLD: percentageX,
977
+
// OLD: offsetY,
978
+
// OLD: };
979
+
// OLD:
980
+
// OLD: // Don't mark as rated - allow re-dragging
981
+
// OLD: // draggedItem.classList.add("rated");
982
+
// OLD:
983
+
// OLD: // Add service to scale
984
+
// OLD: addServiceToScale(domain, name, rating, percentageX, offsetY);
985
+
// OLD:
986
+
// OLD: // Show save button
987
+
// OLD: updateSaveButton();
988
+
// OLD: }
989
+
// OLD:
990
+
async function submitRating(serviceDomain, rating, comment = "") {
991
+
try {
992
+
const mutation = `
993
+
mutation CreateRating($input: CreateSocialGo90RatingInput!) {
994
+
createSocialGo90Rating(input: $input)
995
+
}
996
+
`;
997
+
998
+
const input = {
999
+
serviceDomain: serviceDomain,
1000
+
rating: rating,
1001
+
createdAt: new Date().toISOString(),
1002
+
};
1003
+
1004
+
// Only add comment if it exists
1005
+
if (comment && comment.trim()) {
1006
+
input.comment = comment.trim();
1007
+
}
1008
+
1009
+
const variables = { input };
1010
+
1011
+
await client.query(mutation, variables);
1012
+
} catch (error) {
1013
+
console.error("Failed to submit rating:", error);
1014
+
showError(`Failed to submit rating: ${error.message}`);
1015
+
throw error;
1016
+
}
1017
+
}
1018
+
// OLD:
1019
+
// OLD: function addServiceToScale(domain, name, rating, percentageX, offsetY = 0) {
1020
+
// OLD: const scaleBar = document.getElementById("scaleBar");
1021
+
// OLD:
1022
+
// OLD: // Remove existing rating for this service
1023
+
// OLD: const existing = scaleBar.querySelector(`[data-scale-domain="${domain}"]`);
1024
+
// OLD: if (existing) existing.remove();
1025
+
// OLD:
1026
+
// OLD: const serviceEl = document.createElement("div");
1027
+
// OLD: serviceEl.className = "service-on-scale";
1028
+
// OLD: serviceEl.dataset.scaleDomain = domain;
1029
+
// OLD: serviceEl.style.left = `${percentageX}%`;
1030
+
// OLD: serviceEl.style.top = `calc(50% + ${offsetY}px)`;
1031
+
// OLD: serviceEl.draggable = true;
1032
+
// OLD: serviceEl.innerHTML = `
1033
+
// OLD: <img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128"
1034
+
// OLD: alt="${name}"
1035
+
// OLD: class="service-logo-large">
1036
+
// OLD: <div class="service-badge ${rating === 90 ? "defunct" : ""}">${rating}</div>
1037
+
// OLD: `;
1038
+
// OLD:
1039
+
// OLD: // Make it re-draggable to update rating
1040
+
// OLD: serviceEl.addEventListener("dragstart", (e) => {
1041
+
// OLD: draggedItem = { dataset: { domain, name } };
1042
+
// OLD: e.target.classList.add("dragging");
1043
+
// OLD: });
1044
+
// OLD:
1045
+
// OLD: serviceEl.addEventListener("dragend", (e) => {
1046
+
// OLD: e.target.classList.remove("dragging");
1047
+
// OLD: e.target.remove(); // Remove from scale when re-dragging
1048
+
// OLD: });
1049
+
// OLD:
1050
+
// OLD: scaleBar.appendChild(serviceEl);
1051
+
// OLD: }
1052
+
// OLD:
1053
+
async function addCustomService(e) {
1054
+
if (e) e.preventDefault();
1055
+
1056
+
const input = document.getElementById("customServiceDomain");
1057
+
const domain = input.value.trim();
1058
+
1059
+
if (!domain) {
1060
+
showError("Please enter a domain");
1061
+
return;
1062
+
}
1063
+
1064
+
if (!domain.includes(".") || domain.includes("/")) {
1065
+
showError("Please enter a valid domain (e.g., dropout.tv)");
1066
+
return;
1067
+
}
1068
+
1069
+
// Add to services bar
1070
+
const servicesBar = document.getElementById("servicesBar");
1071
+
const serviceEl = document.createElement("div");
1072
+
serviceEl.className = "service-item";
1073
+
serviceEl.dataset.domain = domain;
1074
+
serviceEl.dataset.name = domain;
1075
+
serviceEl.innerHTML = `
1076
+
<img src="https://www.google.com/s2/favicons?domain=${domain}&sz=128"
1077
+
alt="${domain}"
1078
+
class="service-logo"
1079
+
draggable="false">
1080
+
`;
1081
+
1082
+
setupServiceDrag(serviceEl);
1083
+
servicesBar.appendChild(serviceEl);
1084
+
input.value = "";
1085
+
}
1086
+
1087
+
function updateSaveButton() {
1088
+
const saveButton = document.getElementById("saveButton");
1089
+
const hasRatings = Object.keys(pendingRatings).length > 0;
1090
+
1091
+
if (hasRatings) {
1092
+
saveButton.classList.add("visible");
1093
+
} else {
1094
+
saveButton.classList.remove("visible");
1095
+
}
1096
+
}
1097
+
1098
+
async function saveAllRatings() {
1099
+
const saveButton = document.getElementById("saveButton");
1100
+
saveButton.disabled = true;
1101
+
saveButton.textContent = "Saving...";
1102
+
1103
+
try {
1104
+
for (const [domain, data] of Object.entries(pendingRatings)) {
1105
+
await submitRating(data.domain, data.rating);
1106
+
}
1107
+
1108
+
// Clear pending ratings
1109
+
pendingRatings = {};
1110
+
updateSaveButton();
1111
+
1112
+
saveButton.textContent = "Saved!";
1113
+
setTimeout(() => {
1114
+
saveButton.textContent = "Save Ratings";
1115
+
saveButton.disabled = false;
1116
+
}, 2000);
1117
+
} catch (error) {
1118
+
saveButton.textContent = "Save Ratings";
1119
+
saveButton.disabled = false;
1120
+
showError("Failed to save some ratings. Please try again.");
1121
+
}
1122
+
}
1123
+
641
1124
// =============================================================================
642
1125
// UI RENDERING
643
1126
// =============================================================================
···
721
1204
async function renderContent(viewer) {
722
1205
const container = document.getElementById("content");
723
1206
724
-
// Render rating form
725
1207
container.innerHTML = `
726
-
<div class="card">
727
-
<h2 class="section-title">Rate a Streaming Service</h2>
728
-
<form class="rating-form" onsubmit="handleRatingSubmit(event)">
729
-
<div class="form-group">
730
-
<label for="serviceDomain">Service Domain</label>
731
-
<input
732
-
type="text"
733
-
id="serviceDomain"
734
-
placeholder="netflix.com"
735
-
required
736
-
/>
1208
+
<div class="instructions">
1209
+
Drag services anywhere to rate them! Drop on the bar to remove rating.
1210
+
</div>
1211
+
1212
+
<div class="scale-container drag-canvas" id="dragCanvas">
1213
+
<div class="scale-bar-wrapper">
1214
+
<div class="scale-labels">
1215
+
<span class="scale-label">0</span>
1216
+
<span class="scale-label red">90</span>
737
1217
</div>
1218
+
<div class="scale-bar" id="scaleBar"></div>
1219
+
</div>
738
1220
739
-
<div class="form-group">
740
-
<label for="rating">Rating</label>
741
-
<div class="rating-slider-container">
742
-
<div id="ratingDisplay" class="rating-display">45</div>
743
-
<input
744
-
type="range"
745
-
id="rating"
746
-
class="rating-slider"
747
-
min="0"
748
-
max="90"
749
-
value="45"
750
-
oninput="updateRatingDisplay(this.value)"
751
-
/>
752
-
<div class="rating-labels">
753
-
<span>0 (Thriving)</span>
754
-
<span>90 (Defunct)</span>
755
-
</div>
1221
+
<div class="services-bar" id="servicesBar">
1222
+
${defaultServices
1223
+
.map(
1224
+
(service) => `
1225
+
<div class="service-item"
1226
+
data-domain="${service.domain}"
1227
+
data-name="${service.name}">
1228
+
<img src="https://www.google.com/s2/favicons?domain=${service.domain}&sz=128"
1229
+
alt="${service.name}"
1230
+
class="service-logo"
1231
+
draggable="false">
756
1232
</div>
757
-
</div>
758
-
759
-
<div class="form-group">
760
-
<label for="comment">Comment (optional)</label>
761
-
<textarea
762
-
id="comment"
763
-
placeholder="Share your thoughts..."
764
-
maxlength="300"
765
-
oninput="updateCharCount()"
766
-
></textarea>
767
-
<div id="charCount" class="char-count">0/300</div>
768
-
</div>
1233
+
`,
1234
+
)
1235
+
.join("")}
1236
+
</div>
769
1237
770
-
<button type="submit" class="btn btn-primary btn-block">Submit Rating</button>
1238
+
<form class="add-service-form" onsubmit="addCustomService(event)">
1239
+
<input type="text"
1240
+
id="customServiceDomain"
1241
+
class="add-service-input"
1242
+
placeholder="Enter a domain (e.g., dropout.tv)">
1243
+
<button type="submit" class="add-service-btn">Add Service</button>
771
1244
</form>
772
1245
</div>
773
-
774
-
<div class="card">
775
-
<h2 class="section-title">Recent Ratings</h2>
776
-
<div id="ratingsList"></div>
777
-
</div>
778
1246
`;
779
1247
780
-
// Fetch and render ratings
781
-
try {
782
-
const ratings = await fetchRatings();
783
-
await renderRatingsList(ratings);
784
-
} catch (error) {
785
-
console.error("Failed to fetch ratings:", error);
786
-
document.getElementById("ratingsList").innerHTML = `
787
-
<div class="empty-state">Failed to load ratings</div>
788
-
`;
789
-
}
1248
+
initDragAndDrop();
790
1249
}
791
1250
792
1251
async function renderRatingsList(ratings) {
+1296
index.html.backup
+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>