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