tangled
alpha
login
or
join now
slices.network
/
tools
7
fork
atom
Tools for the Atmosphere
tools.slices.network
quickslice
atproto
html
7
fork
atom
overview
issues
1
pulls
pipelines
docs: add teal-scrobble implementation plan
chadtmiller.com
2 months ago
c2fcae01
d4d2c8fd
+1498
1 changed file
expand all
collapse all
unified
split
docs
plans
2025-12-19-teal-scrobble.md
+1498
docs/plans/2025-12-19-teal-scrobble.md
···
1
1
+
# Teal Manual Scrobble Tool Implementation Plan
2
2
+
3
3
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
4
+
5
5
+
**Goal:** Build a single-page HTML tool for manually scrobbling tracks to Teal with MusicBrainz search-as-you-type.
6
6
+
7
7
+
**Architecture:** Single HTML file following existing patterns (statusphere.html, teal-plays.html). Uses quickslice-client-js for OAuth/mutations, MusicBrainz public API for track search. Artist-first search flow with 300ms debounce.
8
8
+
9
9
+
**Tech Stack:** HTML, vanilla JS, quickslice-client-js SDK, MusicBrainz API, dark music theme CSS
10
10
+
11
11
+
---
12
12
+
13
13
+
### Task 1: Create HTML skeleton with dark theme CSS
14
14
+
15
15
+
**Files:**
16
16
+
- Create: `teal-scrobble.html`
17
17
+
18
18
+
**Step 1: Create the base HTML file**
19
19
+
20
20
+
Create `teal-scrobble.html` with the dark music theme CSS (copied from teal-plays.html), basic structure, and quickslice SDK script tag.
21
21
+
22
22
+
```html
23
23
+
<!doctype html>
24
24
+
<html lang="en">
25
25
+
<head>
26
26
+
<meta charset="UTF-8" />
27
27
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
28
28
+
<meta
29
29
+
http-equiv="Content-Security-Policy"
30
30
+
content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; connect-src 'self' https://fmteal.slices.network https://musicbrainz.org; img-src 'self' https: data:;"
31
31
+
/>
32
32
+
<title>Teal Scrobble</title>
33
33
+
<style>
34
34
+
/* CSS Reset */
35
35
+
*,
36
36
+
*::before,
37
37
+
*::after {
38
38
+
box-sizing: border-box;
39
39
+
}
40
40
+
* {
41
41
+
margin: 0;
42
42
+
}
43
43
+
body {
44
44
+
line-height: 1.5;
45
45
+
-webkit-font-smoothing: antialiased;
46
46
+
}
47
47
+
input,
48
48
+
button {
49
49
+
font: inherit;
50
50
+
}
51
51
+
52
52
+
/* Dark Music Theme */
53
53
+
:root {
54
54
+
--bg-primary: #0a0a0a;
55
55
+
--bg-card: #161616;
56
56
+
--bg-hover: #1f1f1f;
57
57
+
--bg-input: #1a1a1a;
58
58
+
--text-primary: #ffffff;
59
59
+
--text-secondary: #a0a0a0;
60
60
+
--accent: #1db954;
61
61
+
--accent-hover: #1ed760;
62
62
+
--border: #2a2a2a;
63
63
+
--error-bg: #2d1f1f;
64
64
+
--error-border: #5c2828;
65
65
+
--error-text: #ff6b6b;
66
66
+
--success-bg: #1f2d1f;
67
67
+
--success-border: #285c28;
68
68
+
--success-text: #6bff6b;
69
69
+
}
70
70
+
71
71
+
body {
72
72
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
73
73
+
background: var(--bg-primary);
74
74
+
color: var(--text-primary);
75
75
+
min-height: 100vh;
76
76
+
padding: 2rem 1rem;
77
77
+
}
78
78
+
79
79
+
#app {
80
80
+
max-width: 500px;
81
81
+
margin: 0 auto;
82
82
+
}
83
83
+
84
84
+
header {
85
85
+
text-align: center;
86
86
+
margin-bottom: 1.5rem;
87
87
+
}
88
88
+
89
89
+
header h1 {
90
90
+
font-size: 2rem;
91
91
+
color: var(--accent);
92
92
+
margin-bottom: 0.25rem;
93
93
+
}
94
94
+
95
95
+
.tagline {
96
96
+
color: var(--text-secondary);
97
97
+
font-size: 0.875rem;
98
98
+
}
99
99
+
100
100
+
.card {
101
101
+
background: var(--bg-card);
102
102
+
border-radius: 0.5rem;
103
103
+
padding: 1.25rem;
104
104
+
margin-bottom: 1rem;
105
105
+
border: 1px solid var(--border);
106
106
+
}
107
107
+
108
108
+
.card-title {
109
109
+
font-size: 0.875rem;
110
110
+
font-weight: 600;
111
111
+
color: var(--text-secondary);
112
112
+
margin-bottom: 1rem;
113
113
+
text-transform: uppercase;
114
114
+
letter-spacing: 0.05em;
115
115
+
}
116
116
+
117
117
+
/* Form Styles */
118
118
+
.form-group {
119
119
+
margin-bottom: 1rem;
120
120
+
}
121
121
+
122
122
+
.form-group:last-child {
123
123
+
margin-bottom: 0;
124
124
+
}
125
125
+
126
126
+
.form-group label {
127
127
+
display: block;
128
128
+
font-size: 0.875rem;
129
129
+
font-weight: 500;
130
130
+
color: var(--text-secondary);
131
131
+
margin-bottom: 0.375rem;
132
132
+
}
133
133
+
134
134
+
.form-group input {
135
135
+
width: 100%;
136
136
+
padding: 0.75rem;
137
137
+
background: var(--bg-input);
138
138
+
border: 1px solid var(--border);
139
139
+
border-radius: 0.375rem;
140
140
+
color: var(--text-primary);
141
141
+
font-size: 1rem;
142
142
+
}
143
143
+
144
144
+
.form-group input:focus {
145
145
+
outline: none;
146
146
+
border-color: var(--accent);
147
147
+
}
148
148
+
149
149
+
.form-group input:disabled {
150
150
+
opacity: 0.5;
151
151
+
cursor: not-allowed;
152
152
+
}
153
153
+
154
154
+
.form-group input::placeholder {
155
155
+
color: var(--text-secondary);
156
156
+
opacity: 0.6;
157
157
+
}
158
158
+
159
159
+
.form-row {
160
160
+
display: flex;
161
161
+
gap: 1rem;
162
162
+
}
163
163
+
164
164
+
.form-row .form-group {
165
165
+
flex: 1;
166
166
+
}
167
167
+
168
168
+
.read-only {
169
169
+
background: var(--bg-hover);
170
170
+
cursor: default;
171
171
+
}
172
172
+
173
173
+
/* Autocomplete Dropdown */
174
174
+
.autocomplete-wrapper {
175
175
+
position: relative;
176
176
+
}
177
177
+
178
178
+
.autocomplete-dropdown {
179
179
+
position: absolute;
180
180
+
top: 100%;
181
181
+
left: 0;
182
182
+
right: 0;
183
183
+
background: var(--bg-card);
184
184
+
border: 1px solid var(--border);
185
185
+
border-top: none;
186
186
+
border-radius: 0 0 0.375rem 0.375rem;
187
187
+
max-height: 240px;
188
188
+
overflow-y: auto;
189
189
+
z-index: 10;
190
190
+
}
191
191
+
192
192
+
.autocomplete-dropdown.hidden {
193
193
+
display: none;
194
194
+
}
195
195
+
196
196
+
.autocomplete-item {
197
197
+
padding: 0.75rem;
198
198
+
cursor: pointer;
199
199
+
border-bottom: 1px solid var(--border);
200
200
+
}
201
201
+
202
202
+
.autocomplete-item:last-child {
203
203
+
border-bottom: none;
204
204
+
}
205
205
+
206
206
+
.autocomplete-item:hover,
207
207
+
.autocomplete-item.selected {
208
208
+
background: var(--bg-hover);
209
209
+
}
210
210
+
211
211
+
.autocomplete-item-title {
212
212
+
font-weight: 500;
213
213
+
color: var(--text-primary);
214
214
+
}
215
215
+
216
216
+
.autocomplete-item-subtitle {
217
217
+
font-size: 0.75rem;
218
218
+
color: var(--text-secondary);
219
219
+
}
220
220
+
221
221
+
.autocomplete-status {
222
222
+
padding: 0.75rem;
223
223
+
color: var(--text-secondary);
224
224
+
font-size: 0.875rem;
225
225
+
text-align: center;
226
226
+
}
227
227
+
228
228
+
/* Selected Tag */
229
229
+
.selected-tag {
230
230
+
display: inline-flex;
231
231
+
align-items: center;
232
232
+
gap: 0.5rem;
233
233
+
background: var(--bg-hover);
234
234
+
border: 1px solid var(--accent);
235
235
+
border-radius: 0.375rem;
236
236
+
padding: 0.5rem 0.75rem;
237
237
+
color: var(--text-primary);
238
238
+
}
239
239
+
240
240
+
.selected-tag button {
241
241
+
background: none;
242
242
+
border: none;
243
243
+
color: var(--text-secondary);
244
244
+
cursor: pointer;
245
245
+
font-size: 1.25rem;
246
246
+
line-height: 1;
247
247
+
padding: 0;
248
248
+
}
249
249
+
250
250
+
.selected-tag button:hover {
251
251
+
color: var(--error-text);
252
252
+
}
253
253
+
254
254
+
/* Buttons */
255
255
+
.btn {
256
256
+
padding: 0.75rem 1.5rem;
257
257
+
border: none;
258
258
+
border-radius: 0.375rem;
259
259
+
font-size: 1rem;
260
260
+
font-weight: 500;
261
261
+
cursor: pointer;
262
262
+
transition: background-color 0.15s, opacity 0.15s;
263
263
+
width: 100%;
264
264
+
}
265
265
+
266
266
+
.btn-primary {
267
267
+
background: var(--accent);
268
268
+
color: var(--bg-primary);
269
269
+
}
270
270
+
271
271
+
.btn-primary:hover {
272
272
+
background: var(--accent-hover);
273
273
+
}
274
274
+
275
275
+
.btn-primary:disabled {
276
276
+
opacity: 0.5;
277
277
+
cursor: not-allowed;
278
278
+
}
279
279
+
280
280
+
.btn-secondary {
281
281
+
background: var(--bg-hover);
282
282
+
color: var(--text-primary);
283
283
+
border: 1px solid var(--border);
284
284
+
}
285
285
+
286
286
+
.btn-secondary:hover {
287
287
+
background: var(--border);
288
288
+
}
289
289
+
290
290
+
/* User Card */
291
291
+
.user-card {
292
292
+
display: flex;
293
293
+
align-items: center;
294
294
+
justify-content: space-between;
295
295
+
}
296
296
+
297
297
+
.user-info {
298
298
+
display: flex;
299
299
+
align-items: center;
300
300
+
gap: 0.75rem;
301
301
+
}
302
302
+
303
303
+
.user-avatar {
304
304
+
width: 40px;
305
305
+
height: 40px;
306
306
+
border-radius: 50%;
307
307
+
background: var(--bg-hover);
308
308
+
overflow: hidden;
309
309
+
}
310
310
+
311
311
+
.user-avatar img {
312
312
+
width: 100%;
313
313
+
height: 100%;
314
314
+
object-fit: cover;
315
315
+
}
316
316
+
317
317
+
.user-name {
318
318
+
font-weight: 600;
319
319
+
}
320
320
+
321
321
+
.user-handle {
322
322
+
font-size: 0.875rem;
323
323
+
color: var(--text-secondary);
324
324
+
}
325
325
+
326
326
+
/* Time Toggle */
327
327
+
.time-toggle {
328
328
+
display: flex;
329
329
+
align-items: center;
330
330
+
gap: 0.75rem;
331
331
+
margin-bottom: 0.5rem;
332
332
+
}
333
333
+
334
334
+
.time-toggle label {
335
335
+
margin-bottom: 0;
336
336
+
}
337
337
+
338
338
+
.toggle-switch {
339
339
+
position: relative;
340
340
+
width: 44px;
341
341
+
height: 24px;
342
342
+
background: var(--bg-hover);
343
343
+
border-radius: 12px;
344
344
+
cursor: pointer;
345
345
+
transition: background-color 0.2s;
346
346
+
}
347
347
+
348
348
+
.toggle-switch.active {
349
349
+
background: var(--accent);
350
350
+
}
351
351
+
352
352
+
.toggle-switch::after {
353
353
+
content: "";
354
354
+
position: absolute;
355
355
+
top: 2px;
356
356
+
left: 2px;
357
357
+
width: 20px;
358
358
+
height: 20px;
359
359
+
background: var(--text-primary);
360
360
+
border-radius: 50%;
361
361
+
transition: transform 0.2s;
362
362
+
}
363
363
+
364
364
+
.toggle-switch.active::after {
365
365
+
transform: translateX(20px);
366
366
+
}
367
367
+
368
368
+
/* Recent Scrobbles */
369
369
+
.recent-item {
370
370
+
display: flex;
371
371
+
align-items: center;
372
372
+
gap: 0.75rem;
373
373
+
padding: 0.75rem 0;
374
374
+
border-bottom: 1px solid var(--border);
375
375
+
}
376
376
+
377
377
+
.recent-item:last-child {
378
378
+
border-bottom: none;
379
379
+
padding-bottom: 0;
380
380
+
}
381
381
+
382
382
+
.recent-item:first-child {
383
383
+
padding-top: 0;
384
384
+
}
385
385
+
386
386
+
.recent-info {
387
387
+
flex: 1;
388
388
+
min-width: 0;
389
389
+
}
390
390
+
391
391
+
.recent-track {
392
392
+
font-weight: 500;
393
393
+
white-space: nowrap;
394
394
+
overflow: hidden;
395
395
+
text-overflow: ellipsis;
396
396
+
}
397
397
+
398
398
+
.recent-artist {
399
399
+
font-size: 0.875rem;
400
400
+
color: var(--text-secondary);
401
401
+
white-space: nowrap;
402
402
+
overflow: hidden;
403
403
+
text-overflow: ellipsis;
404
404
+
}
405
405
+
406
406
+
.recent-time {
407
407
+
font-size: 0.75rem;
408
408
+
color: var(--text-secondary);
409
409
+
flex-shrink: 0;
410
410
+
}
411
411
+
412
412
+
/* Toast */
413
413
+
#toast {
414
414
+
position: fixed;
415
415
+
bottom: 2rem;
416
416
+
left: 50%;
417
417
+
transform: translateX(-50%);
418
418
+
padding: 0.75rem 1.5rem;
419
419
+
border-radius: 0.5rem;
420
420
+
font-weight: 500;
421
421
+
z-index: 100;
422
422
+
transition: opacity 0.3s;
423
423
+
}
424
424
+
425
425
+
#toast.hidden {
426
426
+
opacity: 0;
427
427
+
pointer-events: none;
428
428
+
}
429
429
+
430
430
+
#toast.success {
431
431
+
background: var(--success-bg);
432
432
+
border: 1px solid var(--success-border);
433
433
+
color: var(--success-text);
434
434
+
}
435
435
+
436
436
+
#toast.error {
437
437
+
background: var(--error-bg);
438
438
+
border: 1px solid var(--error-border);
439
439
+
color: var(--error-text);
440
440
+
}
441
441
+
442
442
+
/* Spinner */
443
443
+
.spinner {
444
444
+
width: 20px;
445
445
+
height: 20px;
446
446
+
border: 2px solid var(--border);
447
447
+
border-top-color: var(--bg-primary);
448
448
+
border-radius: 50%;
449
449
+
animation: spin 0.8s linear infinite;
450
450
+
display: inline-block;
451
451
+
vertical-align: middle;
452
452
+
}
453
453
+
454
454
+
@keyframes spin {
455
455
+
to {
456
456
+
transform: rotate(360deg);
457
457
+
}
458
458
+
}
459
459
+
460
460
+
.hidden {
461
461
+
display: none !important;
462
462
+
}
463
463
+
</style>
464
464
+
</head>
465
465
+
<body>
466
466
+
<div id="app">
467
467
+
<header>
468
468
+
<h1>Teal Scrobble</h1>
469
469
+
<p class="tagline">Manually log what you're listening to</p>
470
470
+
</header>
471
471
+
<main>
472
472
+
<div id="auth-section"></div>
473
473
+
<div id="scrobble-form"></div>
474
474
+
<div id="recent-scrobbles"></div>
475
475
+
</main>
476
476
+
<div id="toast" class="hidden"></div>
477
477
+
</div>
478
478
+
479
479
+
<script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script>
480
480
+
<script>
481
481
+
// Implementation will go here
482
482
+
console.log("Teal Scrobble loaded");
483
483
+
</script>
484
484
+
</body>
485
485
+
</html>
486
486
+
```
487
487
+
488
488
+
**Step 2: Verify the file renders**
489
489
+
490
490
+
Open in browser and verify dark theme displays correctly.
491
491
+
492
492
+
**Step 3: Commit**
493
493
+
494
494
+
```bash
495
495
+
git add teal-scrobble.html
496
496
+
git commit -m "feat: add teal-scrobble.html skeleton with dark theme"
497
497
+
```
498
498
+
499
499
+
---
500
500
+
501
501
+
### Task 2: Implement OAuth login flow
502
502
+
503
503
+
**Files:**
504
504
+
- Modify: `teal-scrobble.html`
505
505
+
506
506
+
**Step 1: Add configuration and state**
507
507
+
508
508
+
Replace the script section with configuration constants and state object:
509
509
+
510
510
+
```javascript
511
511
+
// =============================================================================
512
512
+
// CONFIGURATION
513
513
+
// =============================================================================
514
514
+
515
515
+
const SERVER_URL = "https://fmteal.slices.network";
516
516
+
const CLIENT_ID = null; // Set your OAuth client ID here
517
517
+
518
518
+
// =============================================================================
519
519
+
// STATE
520
520
+
// =============================================================================
521
521
+
522
522
+
const state = {
523
523
+
client: null,
524
524
+
user: null,
525
525
+
selectedArtist: null,
526
526
+
selectedRecording: null,
527
527
+
recentScrobbles: [],
528
528
+
isSubmitting: false,
529
529
+
useCustomTime: false,
530
530
+
};
531
531
+
532
532
+
// =============================================================================
533
533
+
// HELPERS
534
534
+
// =============================================================================
535
535
+
536
536
+
function esc(str) {
537
537
+
const d = document.createElement("div");
538
538
+
d.textContent = str;
539
539
+
return d.innerHTML;
540
540
+
}
541
541
+
542
542
+
function showToast(message, type = "success") {
543
543
+
const toast = document.getElementById("toast");
544
544
+
toast.textContent = message;
545
545
+
toast.className = type;
546
546
+
setTimeout(() => toast.classList.add("hidden"), 3000);
547
547
+
}
548
548
+
549
549
+
// =============================================================================
550
550
+
// INITIALIZATION
551
551
+
// =============================================================================
552
552
+
553
553
+
async function main() {
554
554
+
// Handle OAuth callback
555
555
+
if (window.location.search.includes("code=")) {
556
556
+
if (!CLIENT_ID) {
557
557
+
showToast("CLIENT_ID not configured", "error");
558
558
+
renderLoginForm();
559
559
+
return;
560
560
+
}
561
561
+
562
562
+
try {
563
563
+
state.client = await QuicksliceClient.createQuicksliceClient({
564
564
+
server: SERVER_URL,
565
565
+
clientId: CLIENT_ID,
566
566
+
});
567
567
+
await state.client.handleRedirectCallback();
568
568
+
window.history.replaceState({}, "", window.location.pathname);
569
569
+
} catch (error) {
570
570
+
console.error("OAuth callback error:", error);
571
571
+
showToast("Authentication failed", "error");
572
572
+
renderLoginForm();
573
573
+
return;
574
574
+
}
575
575
+
} else if (CLIENT_ID) {
576
576
+
try {
577
577
+
state.client = await QuicksliceClient.createQuicksliceClient({
578
578
+
server: SERVER_URL,
579
579
+
clientId: CLIENT_ID,
580
580
+
});
581
581
+
} catch (error) {
582
582
+
console.error("Failed to initialize client:", error);
583
583
+
}
584
584
+
}
585
585
+
586
586
+
await renderApp();
587
587
+
}
588
588
+
589
589
+
async function renderApp() {
590
590
+
const isLoggedIn = state.client && (await state.client.isAuthenticated());
591
591
+
592
592
+
if (isLoggedIn) {
593
593
+
try {
594
594
+
state.user = await fetchViewer();
595
595
+
renderUserCard();
596
596
+
renderScrobbleForm();
597
597
+
await loadRecentScrobbles();
598
598
+
} catch (error) {
599
599
+
console.error("Failed to load user data:", error);
600
600
+
renderLoginForm();
601
601
+
}
602
602
+
} else {
603
603
+
renderLoginForm();
604
604
+
document.getElementById("scrobble-form").innerHTML = "";
605
605
+
document.getElementById("recent-scrobbles").innerHTML = "";
606
606
+
}
607
607
+
}
608
608
+
609
609
+
// =============================================================================
610
610
+
// DATA FETCHING
611
611
+
// =============================================================================
612
612
+
613
613
+
async function fetchViewer() {
614
614
+
const query = `
615
615
+
query {
616
616
+
viewer {
617
617
+
did
618
618
+
handle
619
619
+
appBskyActorProfileByDid {
620
620
+
displayName
621
621
+
avatar { url(preset: "avatar") }
622
622
+
}
623
623
+
}
624
624
+
}
625
625
+
`;
626
626
+
const data = await state.client.query(query);
627
627
+
return data?.viewer;
628
628
+
}
629
629
+
630
630
+
// =============================================================================
631
631
+
// EVENT HANDLERS
632
632
+
// =============================================================================
633
633
+
634
634
+
async function handleLogin(event) {
635
635
+
event.preventDefault();
636
636
+
const handle = document.getElementById("handle").value.trim();
637
637
+
638
638
+
if (!handle) {
639
639
+
showToast("Please enter your handle", "error");
640
640
+
return;
641
641
+
}
642
642
+
643
643
+
try {
644
644
+
state.client = await QuicksliceClient.createQuicksliceClient({
645
645
+
server: SERVER_URL,
646
646
+
clientId: CLIENT_ID,
647
647
+
});
648
648
+
await state.client.loginWithRedirect({ handle });
649
649
+
} catch (error) {
650
650
+
showToast("Login failed: " + error.message, "error");
651
651
+
}
652
652
+
}
653
653
+
654
654
+
function handleLogout() {
655
655
+
if (state.client) {
656
656
+
state.client.logout();
657
657
+
}
658
658
+
window.location.reload();
659
659
+
}
660
660
+
661
661
+
// =============================================================================
662
662
+
// RENDERING
663
663
+
// =============================================================================
664
664
+
665
665
+
function renderLoginForm() {
666
666
+
const container = document.getElementById("auth-section");
667
667
+
668
668
+
if (!CLIENT_ID) {
669
669
+
container.innerHTML = `
670
670
+
<div class="card">
671
671
+
<p style="color: var(--error-text); text-align: center; margin-bottom: 0.5rem;">
672
672
+
<strong>Configuration Required</strong>
673
673
+
</p>
674
674
+
<p style="color: var(--text-secondary); text-align: center; font-size: 0.875rem;">
675
675
+
Set the <code style="background: var(--bg-hover); padding: 0.125rem 0.375rem; border-radius: 0.25rem;">CLIENT_ID</code> constant in this file.
676
676
+
</p>
677
677
+
</div>
678
678
+
`;
679
679
+
return;
680
680
+
}
681
681
+
682
682
+
container.innerHTML = `
683
683
+
<div class="card">
684
684
+
<form onsubmit="handleLogin(event)">
685
685
+
<div class="form-group">
686
686
+
<label for="handle">Bluesky Handle</label>
687
687
+
<input type="text" id="handle" placeholder="you.bsky.social" required />
688
688
+
</div>
689
689
+
<button type="submit" class="btn btn-primary">Login with Teal</button>
690
690
+
</form>
691
691
+
</div>
692
692
+
`;
693
693
+
}
694
694
+
695
695
+
function renderUserCard() {
696
696
+
const container = document.getElementById("auth-section");
697
697
+
const profile = state.user?.appBskyActorProfileByDid;
698
698
+
const displayName = profile?.displayName || state.user?.handle || "User";
699
699
+
const handle = state.user?.handle || "unknown";
700
700
+
const avatar = profile?.avatar?.url || "";
701
701
+
702
702
+
container.innerHTML = `
703
703
+
<div class="card user-card">
704
704
+
<div class="user-info">
705
705
+
<div class="user-avatar">
706
706
+
${avatar ? `<img src="${esc(avatar)}" alt="">` : ""}
707
707
+
</div>
708
708
+
<div>
709
709
+
<div class="user-name">${esc(displayName)}</div>
710
710
+
<div class="user-handle">@${esc(handle)}</div>
711
711
+
</div>
712
712
+
</div>
713
713
+
<button class="btn btn-secondary" onclick="handleLogout()" style="width: auto; padding: 0.5rem 1rem; font-size: 0.875rem;">Logout</button>
714
714
+
</div>
715
715
+
`;
716
716
+
}
717
717
+
718
718
+
function renderScrobbleForm() {
719
719
+
document.getElementById("scrobble-form").innerHTML = `
720
720
+
<div class="card">
721
721
+
<div class="card-title">Scrobble a Track</div>
722
722
+
<p style="color: var(--text-secondary); font-size: 0.875rem;">Form coming in next task...</p>
723
723
+
</div>
724
724
+
`;
725
725
+
}
726
726
+
727
727
+
async function loadRecentScrobbles() {
728
728
+
document.getElementById("recent-scrobbles").innerHTML = `
729
729
+
<div class="card">
730
730
+
<div class="card-title">Your Recent Scrobbles</div>
731
731
+
<p style="color: var(--text-secondary); font-size: 0.875rem;">Loading...</p>
732
732
+
</div>
733
733
+
`;
734
734
+
}
735
735
+
736
736
+
// Start the app
737
737
+
main();
738
738
+
```
739
739
+
740
740
+
**Step 2: Test login flow**
741
741
+
742
742
+
1. Set a valid CLIENT_ID
743
743
+
2. Open in browser
744
744
+
3. Enter handle and click Login
745
745
+
4. Verify OAuth redirect works
746
746
+
5. Verify user card displays after callback
747
747
+
748
748
+
**Step 3: Commit**
749
749
+
750
750
+
```bash
751
751
+
git add teal-scrobble.html
752
752
+
git commit -m "feat: implement OAuth login flow for teal-scrobble"
753
753
+
```
754
754
+
755
755
+
---
756
756
+
757
757
+
### Task 3: Implement MusicBrainz artist search
758
758
+
759
759
+
**Files:**
760
760
+
- Modify: `teal-scrobble.html`
761
761
+
762
762
+
**Step 1: Add MusicBrainz search functions**
763
763
+
764
764
+
Add after the helpers section:
765
765
+
766
766
+
```javascript
767
767
+
// =============================================================================
768
768
+
// MUSICBRAINZ API
769
769
+
// =============================================================================
770
770
+
771
771
+
let searchTimeout = null;
772
772
+
const MB_API = "https://musicbrainz.org/ws/2";
773
773
+
const MB_HEADERS = { Accept: "application/json" };
774
774
+
775
775
+
async function searchArtists(query) {
776
776
+
if (query.length < 2) return [];
777
777
+
778
778
+
const url = `${MB_API}/artist?query=${encodeURIComponent(query)}&fmt=json&limit=5`;
779
779
+
const res = await fetch(url, { headers: MB_HEADERS });
780
780
+
781
781
+
if (!res.ok) throw new Error("MusicBrainz search failed");
782
782
+
783
783
+
const data = await res.json();
784
784
+
return data.artists || [];
785
785
+
}
786
786
+
787
787
+
async function searchRecordings(query, artistMbid) {
788
788
+
if (query.length < 2) return [];
789
789
+
790
790
+
const fullQuery = `${query} AND arid:${artistMbid}`;
791
791
+
const url = `${MB_API}/recording?query=${encodeURIComponent(fullQuery)}&fmt=json&limit=8`;
792
792
+
const res = await fetch(url, { headers: MB_HEADERS });
793
793
+
794
794
+
if (!res.ok) throw new Error("MusicBrainz search failed");
795
795
+
796
796
+
const data = await res.json();
797
797
+
return data.recordings || [];
798
798
+
}
799
799
+
800
800
+
function debounce(fn, ms) {
801
801
+
return (...args) => {
802
802
+
clearTimeout(searchTimeout);
803
803
+
searchTimeout = setTimeout(() => fn(...args), ms);
804
804
+
};
805
805
+
}
806
806
+
```
807
807
+
808
808
+
**Step 2: Add artist search input with autocomplete**
809
809
+
810
810
+
Update `renderScrobbleForm`:
811
811
+
812
812
+
```javascript
813
813
+
function renderScrobbleForm() {
814
814
+
const container = document.getElementById("scrobble-form");
815
815
+
816
816
+
container.innerHTML = `
817
817
+
<div class="card">
818
818
+
<div class="card-title">Scrobble a Track</div>
819
819
+
820
820
+
<div class="form-group">
821
821
+
<label>Artist</label>
822
822
+
<div id="artist-field"></div>
823
823
+
</div>
824
824
+
825
825
+
<div class="form-group">
826
826
+
<label>Track</label>
827
827
+
<div id="track-field"></div>
828
828
+
</div>
829
829
+
830
830
+
<div class="form-row">
831
831
+
<div class="form-group">
832
832
+
<label>Album</label>
833
833
+
<input type="text" id="album-display" class="read-only" readonly placeholder="Auto-filled" />
834
834
+
</div>
835
835
+
<div class="form-group" style="flex: 0 0 80px;">
836
836
+
<label>Duration</label>
837
837
+
<input type="text" id="duration-display" class="read-only" readonly placeholder="--:--" />
838
838
+
</div>
839
839
+
</div>
840
840
+
841
841
+
<div class="form-group">
842
842
+
<label>Music Service</label>
843
843
+
<input type="text" id="service-domain" placeholder="e.g., spotify.com, music.apple.com (optional)" />
844
844
+
</div>
845
845
+
846
846
+
<div class="form-group">
847
847
+
<div class="time-toggle">
848
848
+
<label>Played time</label>
849
849
+
<div id="time-toggle" class="toggle-switch" onclick="toggleCustomTime()"></div>
850
850
+
<span style="font-size: 0.875rem; color: var(--text-secondary);" id="time-label">Now</span>
851
851
+
</div>
852
852
+
<input type="datetime-local" id="custom-time" class="hidden" />
853
853
+
</div>
854
854
+
855
855
+
<button id="submit-btn" class="btn btn-primary" onclick="handleSubmit()" disabled>
856
856
+
Scrobble
857
857
+
</button>
858
858
+
</div>
859
859
+
`;
860
860
+
861
861
+
renderArtistField();
862
862
+
renderTrackField();
863
863
+
}
864
864
+
865
865
+
function renderArtistField() {
866
866
+
const container = document.getElementById("artist-field");
867
867
+
868
868
+
if (state.selectedArtist) {
869
869
+
container.innerHTML = `
870
870
+
<div class="selected-tag">
871
871
+
<span>${esc(state.selectedArtist.name)}</span>
872
872
+
<button onclick="clearArtist()">×</button>
873
873
+
</div>
874
874
+
`;
875
875
+
} else {
876
876
+
container.innerHTML = `
877
877
+
<div class="autocomplete-wrapper">
878
878
+
<input
879
879
+
type="text"
880
880
+
id="artist-input"
881
881
+
placeholder="Search for an artist..."
882
882
+
oninput="handleArtistInput(this.value)"
883
883
+
onfocus="handleArtistInput(this.value)"
884
884
+
/>
885
885
+
<div id="artist-dropdown" class="autocomplete-dropdown hidden"></div>
886
886
+
</div>
887
887
+
`;
888
888
+
}
889
889
+
}
890
890
+
891
891
+
function renderTrackField() {
892
892
+
const container = document.getElementById("track-field");
893
893
+
894
894
+
if (state.selectedRecording) {
895
895
+
container.innerHTML = `
896
896
+
<div class="selected-tag">
897
897
+
<span>${esc(state.selectedRecording.title)}</span>
898
898
+
<button onclick="clearRecording()">×</button>
899
899
+
</div>
900
900
+
`;
901
901
+
} else {
902
902
+
const disabled = !state.selectedArtist;
903
903
+
container.innerHTML = `
904
904
+
<div class="autocomplete-wrapper">
905
905
+
<input
906
906
+
type="text"
907
907
+
id="track-input"
908
908
+
placeholder="${disabled ? "Select an artist first" : "Search for a track..."}"
909
909
+
${disabled ? "disabled" : ""}
910
910
+
oninput="handleTrackInput(this.value)"
911
911
+
onfocus="handleTrackInput(this.value)"
912
912
+
/>
913
913
+
<div id="track-dropdown" class="autocomplete-dropdown hidden"></div>
914
914
+
</div>
915
915
+
`;
916
916
+
}
917
917
+
}
918
918
+
919
919
+
// =============================================================================
920
920
+
// ARTIST SEARCH HANDLERS
921
921
+
// =============================================================================
922
922
+
923
923
+
const handleArtistInput = debounce(async (query) => {
924
924
+
const dropdown = document.getElementById("artist-dropdown");
925
925
+
926
926
+
if (query.length < 2) {
927
927
+
dropdown.classList.add("hidden");
928
928
+
return;
929
929
+
}
930
930
+
931
931
+
dropdown.innerHTML = `<div class="autocomplete-status">Searching...</div>`;
932
932
+
dropdown.classList.remove("hidden");
933
933
+
934
934
+
try {
935
935
+
const artists = await searchArtists(query);
936
936
+
937
937
+
if (artists.length === 0) {
938
938
+
dropdown.innerHTML = `<div class="autocomplete-status">No artists found</div>`;
939
939
+
return;
940
940
+
}
941
941
+
942
942
+
dropdown.innerHTML = artists
943
943
+
.map(
944
944
+
(a, i) => `
945
945
+
<div class="autocomplete-item" onclick="selectArtist(${i})" data-index="${i}">
946
946
+
<div class="autocomplete-item-title">${esc(a.name)}</div>
947
947
+
${a.disambiguation ? `<div class="autocomplete-item-subtitle">${esc(a.disambiguation)}</div>` : ""}
948
948
+
</div>
949
949
+
`
950
950
+
)
951
951
+
.join("");
952
952
+
953
953
+
// Store artists for selection
954
954
+
dropdown.dataset.artists = JSON.stringify(artists);
955
955
+
} catch (error) {
956
956
+
dropdown.innerHTML = `<div class="autocomplete-status" style="color: var(--error-text)">Search failed</div>`;
957
957
+
}
958
958
+
}, 300);
959
959
+
960
960
+
function selectArtist(index) {
961
961
+
const dropdown = document.getElementById("artist-dropdown");
962
962
+
const artists = JSON.parse(dropdown.dataset.artists || "[]");
963
963
+
const artist = artists[index];
964
964
+
965
965
+
if (!artist) return;
966
966
+
967
967
+
state.selectedArtist = {
968
968
+
name: artist.name,
969
969
+
mbid: artist.id,
970
970
+
};
971
971
+
972
972
+
state.selectedRecording = null;
973
973
+
document.getElementById("album-display").value = "";
974
974
+
document.getElementById("duration-display").value = "";
975
975
+
976
976
+
renderArtistField();
977
977
+
renderTrackField();
978
978
+
updateSubmitButton();
979
979
+
}
980
980
+
981
981
+
function clearArtist() {
982
982
+
state.selectedArtist = null;
983
983
+
state.selectedRecording = null;
984
984
+
document.getElementById("album-display").value = "";
985
985
+
document.getElementById("duration-display").value = "";
986
986
+
987
987
+
renderArtistField();
988
988
+
renderTrackField();
989
989
+
updateSubmitButton();
990
990
+
}
991
991
+
992
992
+
function updateSubmitButton() {
993
993
+
const btn = document.getElementById("submit-btn");
994
994
+
btn.disabled = !state.selectedArtist || !state.selectedRecording || state.isSubmitting;
995
995
+
}
996
996
+
```
997
997
+
998
998
+
**Step 3: Test artist search**
999
999
+
1000
1000
+
1. Login to the app
1001
1001
+
2. Type in artist field
1002
1002
+
3. Verify dropdown appears after 300ms
1003
1003
+
4. Verify selecting artist shows tag
1004
1004
+
5. Verify clearing artist works
1005
1005
+
1006
1006
+
**Step 4: Commit**
1007
1007
+
1008
1008
+
```bash
1009
1009
+
git add teal-scrobble.html
1010
1010
+
git commit -m "feat: add MusicBrainz artist search with autocomplete"
1011
1011
+
```
1012
1012
+
1013
1013
+
---
1014
1014
+
1015
1015
+
### Task 4: Implement track search
1016
1016
+
1017
1017
+
**Files:**
1018
1018
+
- Modify: `teal-scrobble.html`
1019
1019
+
1020
1020
+
**Step 1: Add track search handlers**
1021
1021
+
1022
1022
+
Add after artist handlers:
1023
1023
+
1024
1024
+
```javascript
1025
1025
+
// =============================================================================
1026
1026
+
// TRACK SEARCH HANDLERS
1027
1027
+
// =============================================================================
1028
1028
+
1029
1029
+
const handleTrackInput = debounce(async (query) => {
1030
1030
+
const dropdown = document.getElementById("track-dropdown");
1031
1031
+
1032
1032
+
if (!state.selectedArtist || query.length < 2) {
1033
1033
+
dropdown.classList.add("hidden");
1034
1034
+
return;
1035
1035
+
}
1036
1036
+
1037
1037
+
dropdown.innerHTML = `<div class="autocomplete-status">Searching...</div>`;
1038
1038
+
dropdown.classList.remove("hidden");
1039
1039
+
1040
1040
+
try {
1041
1041
+
const recordings = await searchRecordings(query, state.selectedArtist.mbid);
1042
1042
+
1043
1043
+
if (recordings.length === 0) {
1044
1044
+
dropdown.innerHTML = `<div class="autocomplete-status">No tracks found</div>`;
1045
1045
+
return;
1046
1046
+
}
1047
1047
+
1048
1048
+
dropdown.innerHTML = recordings
1049
1049
+
.map((r, i) => {
1050
1050
+
const release = r.releases?.[0];
1051
1051
+
const duration = r.length ? formatDuration(Math.floor(r.length / 1000)) : "";
1052
1052
+
const album = release?.title || "";
1053
1053
+
1054
1054
+
return `
1055
1055
+
<div class="autocomplete-item" onclick="selectRecording(${i})" data-index="${i}">
1056
1056
+
<div class="autocomplete-item-title">${esc(r.title)} ${duration ? `<span style="color: var(--text-secondary); font-weight: normal;">${duration}</span>` : ""}</div>
1057
1057
+
${album ? `<div class="autocomplete-item-subtitle">${esc(album)}</div>` : ""}
1058
1058
+
</div>
1059
1059
+
`;
1060
1060
+
})
1061
1061
+
.join("");
1062
1062
+
1063
1063
+
dropdown.dataset.recordings = JSON.stringify(recordings);
1064
1064
+
} catch (error) {
1065
1065
+
dropdown.innerHTML = `<div class="autocomplete-status" style="color: var(--error-text)">Search failed</div>`;
1066
1066
+
}
1067
1067
+
}, 300);
1068
1068
+
1069
1069
+
function selectRecording(index) {
1070
1070
+
const dropdown = document.getElementById("track-dropdown");
1071
1071
+
const recordings = JSON.parse(dropdown.dataset.recordings || "[]");
1072
1072
+
const recording = recordings[index];
1073
1073
+
1074
1074
+
if (!recording) return;
1075
1075
+
1076
1076
+
const release = recording.releases?.[0];
1077
1077
+
const durationSecs = recording.length ? Math.floor(recording.length / 1000) : null;
1078
1078
+
1079
1079
+
state.selectedRecording = {
1080
1080
+
title: recording.title,
1081
1081
+
mbid: recording.id,
1082
1082
+
releaseName: release?.title || null,
1083
1083
+
releaseMbid: release?.id || null,
1084
1084
+
duration: durationSecs,
1085
1085
+
artists: recording["artist-credit"]?.map((ac) => ({
1086
1086
+
artistName: ac.artist.name,
1087
1087
+
artistMbId: ac.artist.id,
1088
1088
+
})) || [{ artistName: state.selectedArtist.name, artistMbId: state.selectedArtist.mbid }],
1089
1089
+
};
1090
1090
+
1091
1091
+
document.getElementById("album-display").value = state.selectedRecording.releaseName || "";
1092
1092
+
document.getElementById("duration-display").value = durationSecs ? formatDuration(durationSecs) : "";
1093
1093
+
1094
1094
+
renderTrackField();
1095
1095
+
updateSubmitButton();
1096
1096
+
}
1097
1097
+
1098
1098
+
function clearRecording() {
1099
1099
+
state.selectedRecording = null;
1100
1100
+
document.getElementById("album-display").value = "";
1101
1101
+
document.getElementById("duration-display").value = "";
1102
1102
+
1103
1103
+
renderTrackField();
1104
1104
+
updateSubmitButton();
1105
1105
+
}
1106
1106
+
1107
1107
+
function formatDuration(secs) {
1108
1108
+
if (!secs) return "";
1109
1109
+
const m = Math.floor(secs / 60);
1110
1110
+
const s = secs % 60;
1111
1111
+
return `${m}:${s.toString().padStart(2, "0")}`;
1112
1112
+
}
1113
1113
+
```
1114
1114
+
1115
1115
+
**Step 2: Test track search**
1116
1116
+
1117
1117
+
1. Select an artist first
1118
1118
+
2. Type in track field
1119
1119
+
3. Verify dropdown shows tracks with album/duration
1120
1120
+
4. Verify selecting track auto-fills album and duration
1121
1121
+
5. Verify submit button enables when both selected
1122
1122
+
1123
1123
+
**Step 3: Commit**
1124
1124
+
1125
1125
+
```bash
1126
1126
+
git add teal-scrobble.html
1127
1127
+
git commit -m "feat: add MusicBrainz track search with auto-fill"
1128
1128
+
```
1129
1129
+
1130
1130
+
---
1131
1131
+
1132
1132
+
### Task 5: Implement time toggle and submit
1133
1133
+
1134
1134
+
**Files:**
1135
1135
+
- Modify: `teal-scrobble.html`
1136
1136
+
1137
1137
+
**Step 1: Add time toggle handler**
1138
1138
+
1139
1139
+
```javascript
1140
1140
+
function toggleCustomTime() {
1141
1141
+
state.useCustomTime = !state.useCustomTime;
1142
1142
+
1143
1143
+
const toggle = document.getElementById("time-toggle");
1144
1144
+
const label = document.getElementById("time-label");
1145
1145
+
const input = document.getElementById("custom-time");
1146
1146
+
1147
1147
+
toggle.classList.toggle("active", state.useCustomTime);
1148
1148
+
label.textContent = state.useCustomTime ? "Custom" : "Now";
1149
1149
+
input.classList.toggle("hidden", !state.useCustomTime);
1150
1150
+
1151
1151
+
if (state.useCustomTime && !input.value) {
1152
1152
+
const now = new Date();
1153
1153
+
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
1154
1154
+
input.value = now.toISOString().slice(0, 16);
1155
1155
+
}
1156
1156
+
}
1157
1157
+
```
1158
1158
+
1159
1159
+
**Step 2: Add submit handler and mutation**
1160
1160
+
1161
1161
+
```javascript
1162
1162
+
async function handleSubmit() {
1163
1163
+
if (!state.selectedArtist || !state.selectedRecording || state.isSubmitting) return;
1164
1164
+
1165
1165
+
state.isSubmitting = true;
1166
1166
+
updateSubmitButton();
1167
1167
+
1168
1168
+
const btn = document.getElementById("submit-btn");
1169
1169
+
btn.innerHTML = `<span class="spinner"></span> Scrobbling...`;
1170
1170
+
1171
1171
+
try {
1172
1172
+
const playedTime = state.useCustomTime
1173
1173
+
? new Date(document.getElementById("custom-time").value).toISOString()
1174
1174
+
: new Date().toISOString();
1175
1175
+
1176
1176
+
const serviceDomain = document.getElementById("service-domain").value.trim() || null;
1177
1177
+
1178
1178
+
const input = {
1179
1179
+
trackName: state.selectedRecording.title,
1180
1180
+
recordingMbId: state.selectedRecording.mbid,
1181
1181
+
artists: state.selectedRecording.artists,
1182
1182
+
playedTime,
1183
1183
+
submissionClientAgent: "slices-tools-scrobbler/0.1.0",
1184
1184
+
};
1185
1185
+
1186
1186
+
if (state.selectedRecording.duration) {
1187
1187
+
input.duration = state.selectedRecording.duration;
1188
1188
+
}
1189
1189
+
if (state.selectedRecording.releaseName) {
1190
1190
+
input.releaseName = state.selectedRecording.releaseName;
1191
1191
+
}
1192
1192
+
if (state.selectedRecording.releaseMbid) {
1193
1193
+
input.releaseMbId = state.selectedRecording.releaseMbid;
1194
1194
+
}
1195
1195
+
if (serviceDomain) {
1196
1196
+
input.musicServiceBaseDomain = serviceDomain;
1197
1197
+
}
1198
1198
+
1199
1199
+
const mutation = `
1200
1200
+
mutation CreatePlay($input: FmTealAlphaFeedPlayInput!) {
1201
1201
+
createFmTealAlphaFeedPlay(input: $input) {
1202
1202
+
uri
1203
1203
+
trackName
1204
1204
+
}
1205
1205
+
}
1206
1206
+
`;
1207
1207
+
1208
1208
+
await state.client.mutate(mutation, { input });
1209
1209
+
1210
1210
+
showToast("Scrobbled!", "success");
1211
1211
+
1212
1212
+
// Reset form
1213
1213
+
state.selectedArtist = null;
1214
1214
+
state.selectedRecording = null;
1215
1215
+
state.useCustomTime = false;
1216
1216
+
document.getElementById("service-domain").value = "";
1217
1217
+
1218
1218
+
renderScrobbleForm();
1219
1219
+
await loadRecentScrobbles();
1220
1220
+
} catch (error) {
1221
1221
+
console.error("Submit failed:", error);
1222
1222
+
showToast("Failed to scrobble: " + error.message, "error");
1223
1223
+
} finally {
1224
1224
+
state.isSubmitting = false;
1225
1225
+
updateSubmitButton();
1226
1226
+
const btn = document.getElementById("submit-btn");
1227
1227
+
if (btn) btn.textContent = "Scrobble";
1228
1228
+
}
1229
1229
+
}
1230
1230
+
```
1231
1231
+
1232
1232
+
**Step 3: Test submission**
1233
1233
+
1234
1234
+
1. Select artist and track
1235
1235
+
2. Click Scrobble with "Now" selected
1236
1236
+
3. Verify success toast appears
1237
1237
+
4. Verify form clears
1238
1238
+
5. Test with custom time selected
1239
1239
+
1240
1240
+
**Step 4: Commit**
1241
1241
+
1242
1242
+
```bash
1243
1243
+
git add teal-scrobble.html
1244
1244
+
git commit -m "feat: add time toggle and scrobble submission"
1245
1245
+
```
1246
1246
+
1247
1247
+
---
1248
1248
+
1249
1249
+
### Task 6: Implement recent scrobbles section
1250
1250
+
1251
1251
+
**Files:**
1252
1252
+
- Modify: `teal-scrobble.html`
1253
1253
+
1254
1254
+
**Step 1: Update loadRecentScrobbles function**
1255
1255
+
1256
1256
+
```javascript
1257
1257
+
async function loadRecentScrobbles() {
1258
1258
+
const container = document.getElementById("recent-scrobbles");
1259
1259
+
1260
1260
+
container.innerHTML = `
1261
1261
+
<div class="card">
1262
1262
+
<div class="card-title">Your Recent Scrobbles</div>
1263
1263
+
<p style="color: var(--text-secondary); font-size: 0.875rem;">Loading...</p>
1264
1264
+
</div>
1265
1265
+
`;
1266
1266
+
1267
1267
+
try {
1268
1268
+
const query = `
1269
1269
+
query {
1270
1270
+
viewer {
1271
1271
+
fmTealAlphaFeedPlayByDid(
1272
1272
+
first: 3
1273
1273
+
sortBy: [{ field: playedTime, direction: DESC }]
1274
1274
+
) {
1275
1275
+
edges {
1276
1276
+
node {
1277
1277
+
trackName
1278
1278
+
artistNames
1279
1279
+
artists { artistName }
1280
1280
+
releaseName
1281
1281
+
playedTime
1282
1282
+
}
1283
1283
+
}
1284
1284
+
}
1285
1285
+
}
1286
1286
+
}
1287
1287
+
`;
1288
1288
+
1289
1289
+
const data = await state.client.query(query);
1290
1290
+
const plays = data?.viewer?.fmTealAlphaFeedPlayByDid?.edges?.map((e) => e.node) || [];
1291
1291
+
state.recentScrobbles = plays;
1292
1292
+
1293
1293
+
renderRecentScrobbles();
1294
1294
+
} catch (error) {
1295
1295
+
console.error("Failed to load recent scrobbles:", error);
1296
1296
+
container.innerHTML = `
1297
1297
+
<div class="card">
1298
1298
+
<div class="card-title">Your Recent Scrobbles</div>
1299
1299
+
<p style="color: var(--error-text); font-size: 0.875rem;">Failed to load</p>
1300
1300
+
</div>
1301
1301
+
`;
1302
1302
+
}
1303
1303
+
}
1304
1304
+
1305
1305
+
function renderRecentScrobbles() {
1306
1306
+
const container = document.getElementById("recent-scrobbles");
1307
1307
+
1308
1308
+
if (state.recentScrobbles.length === 0) {
1309
1309
+
container.innerHTML = `
1310
1310
+
<div class="card">
1311
1311
+
<div class="card-title">Your Recent Scrobbles</div>
1312
1312
+
<p style="color: var(--text-secondary); font-size: 0.875rem;">No scrobbles yet. Start logging!</p>
1313
1313
+
</div>
1314
1314
+
`;
1315
1315
+
return;
1316
1316
+
}
1317
1317
+
1318
1318
+
const items = state.recentScrobbles
1319
1319
+
.map((play) => {
1320
1320
+
const artists = play.artists?.map((a) => a.artistName).join(", ") ||
1321
1321
+
play.artistNames?.join(", ") ||
1322
1322
+
"Unknown Artist";
1323
1323
+
1324
1324
+
return `
1325
1325
+
<div class="recent-item">
1326
1326
+
<div class="recent-info">
1327
1327
+
<div class="recent-track">${esc(play.trackName)}</div>
1328
1328
+
<div class="recent-artist">${esc(artists)}</div>
1329
1329
+
</div>
1330
1330
+
<div class="recent-time">${formatTimeAgo(play.playedTime)}</div>
1331
1331
+
</div>
1332
1332
+
`;
1333
1333
+
})
1334
1334
+
.join("");
1335
1335
+
1336
1336
+
container.innerHTML = `
1337
1337
+
<div class="card">
1338
1338
+
<div class="card-title">Your Recent Scrobbles</div>
1339
1339
+
${items}
1340
1340
+
</div>
1341
1341
+
`;
1342
1342
+
}
1343
1343
+
1344
1344
+
function formatTimeAgo(iso) {
1345
1345
+
const d = new Date(iso);
1346
1346
+
const now = new Date();
1347
1347
+
const diff = Math.floor((now - d) / 1000);
1348
1348
+
1349
1349
+
if (diff < 60) return "just now";
1350
1350
+
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
1351
1351
+
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
1352
1352
+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
1353
1353
+
}
1354
1354
+
```
1355
1355
+
1356
1356
+
**Step 2: Test recent scrobbles**
1357
1357
+
1358
1358
+
1. Login to app
1359
1359
+
2. Verify recent scrobbles load
1360
1360
+
3. Scrobble a new track
1361
1361
+
4. Verify list refreshes with new entry at top
1362
1362
+
1363
1363
+
**Step 3: Commit**
1364
1364
+
1365
1365
+
```bash
1366
1366
+
git add teal-scrobble.html
1367
1367
+
git commit -m "feat: add recent scrobbles section"
1368
1368
+
```
1369
1369
+
1370
1370
+
---
1371
1371
+
1372
1372
+
### Task 7: Add dropdown click-outside handling and keyboard navigation
1373
1373
+
1374
1374
+
**Files:**
1375
1375
+
- Modify: `teal-scrobble.html`
1376
1376
+
1377
1377
+
**Step 1: Add global click handler to close dropdowns**
1378
1378
+
1379
1379
+
Add at end of script, before `main()`:
1380
1380
+
1381
1381
+
```javascript
1382
1382
+
// =============================================================================
1383
1383
+
// GLOBAL EVENT HANDLERS
1384
1384
+
// =============================================================================
1385
1385
+
1386
1386
+
document.addEventListener("click", (e) => {
1387
1387
+
// Close dropdowns when clicking outside
1388
1388
+
if (!e.target.closest(".autocomplete-wrapper")) {
1389
1389
+
document.querySelectorAll(".autocomplete-dropdown").forEach((d) => {
1390
1390
+
d.classList.add("hidden");
1391
1391
+
});
1392
1392
+
}
1393
1393
+
});
1394
1394
+
1395
1395
+
document.addEventListener("keydown", (e) => {
1396
1396
+
const activeDropdown = document.querySelector(".autocomplete-dropdown:not(.hidden)");
1397
1397
+
if (!activeDropdown) return;
1398
1398
+
1399
1399
+
const items = activeDropdown.querySelectorAll(".autocomplete-item");
1400
1400
+
if (items.length === 0) return;
1401
1401
+
1402
1402
+
const selected = activeDropdown.querySelector(".autocomplete-item.selected");
1403
1403
+
let currentIndex = selected ? Array.from(items).indexOf(selected) : -1;
1404
1404
+
1405
1405
+
if (e.key === "ArrowDown") {
1406
1406
+
e.preventDefault();
1407
1407
+
currentIndex = Math.min(currentIndex + 1, items.length - 1);
1408
1408
+
} else if (e.key === "ArrowUp") {
1409
1409
+
e.preventDefault();
1410
1410
+
currentIndex = Math.max(currentIndex - 1, 0);
1411
1411
+
} else if (e.key === "Enter" && selected) {
1412
1412
+
e.preventDefault();
1413
1413
+
selected.click();
1414
1414
+
return;
1415
1415
+
} else if (e.key === "Escape") {
1416
1416
+
activeDropdown.classList.add("hidden");
1417
1417
+
return;
1418
1418
+
} else {
1419
1419
+
return;
1420
1420
+
}
1421
1421
+
1422
1422
+
items.forEach((item, i) => {
1423
1423
+
item.classList.toggle("selected", i === currentIndex);
1424
1424
+
});
1425
1425
+
1426
1426
+
if (items[currentIndex]) {
1427
1427
+
items[currentIndex].scrollIntoView({ block: "nearest" });
1428
1428
+
}
1429
1429
+
});
1430
1430
+
```
1431
1431
+
1432
1432
+
**Step 2: Test keyboard navigation**
1433
1433
+
1434
1434
+
1. Type in artist field
1435
1435
+
2. Use arrow keys to navigate dropdown
1436
1436
+
3. Press Enter to select
1437
1437
+
4. Press Escape to close
1438
1438
+
5. Click outside to close dropdown
1439
1439
+
1440
1440
+
**Step 3: Commit**
1441
1441
+
1442
1442
+
```bash
1443
1443
+
git add teal-scrobble.html
1444
1444
+
git commit -m "feat: add keyboard navigation and click-outside for dropdowns"
1445
1445
+
```
1446
1446
+
1447
1447
+
---
1448
1448
+
1449
1449
+
### Task 8: Add to index.html
1450
1450
+
1451
1451
+
**Files:**
1452
1452
+
- Modify: `index.html`
1453
1453
+
1454
1454
+
**Step 1: Read current index.html**
1455
1455
+
1456
1456
+
Read the file to see existing structure.
1457
1457
+
1458
1458
+
**Step 2: Add teal-scrobble link**
1459
1459
+
1460
1460
+
Add a link to teal-scrobble.html in the Teal section.
1461
1461
+
1462
1462
+
**Step 3: Commit**
1463
1463
+
1464
1464
+
```bash
1465
1465
+
git add index.html teal-scrobble.html
1466
1466
+
git commit -m "feat: add teal-scrobble to index page"
1467
1467
+
```
1468
1468
+
1469
1469
+
---
1470
1470
+
1471
1471
+
### Task 9: Final testing and cleanup
1472
1472
+
1473
1473
+
**Step 1: End-to-end test**
1474
1474
+
1475
1475
+
1. Open teal-scrobble.html in browser
1476
1476
+
2. Login with Bluesky handle
1477
1477
+
3. Search for artist
1478
1478
+
4. Search for track
1479
1479
+
5. Verify album/duration auto-fill
1480
1480
+
6. Add optional service domain
1481
1481
+
7. Submit scrobble with "Now"
1482
1482
+
8. Verify success toast and form clear
1483
1483
+
9. Verify recent scrobbles updates
1484
1484
+
10. Submit scrobble with custom time
1485
1485
+
11. Logout and verify form hidden
1486
1486
+
1487
1487
+
**Step 2: Run format**
1488
1488
+
1489
1489
+
```bash
1490
1490
+
./format.sh
1491
1491
+
```
1492
1492
+
1493
1493
+
**Step 3: Final commit if needed**
1494
1494
+
1495
1495
+
```bash
1496
1496
+
git add -A
1497
1497
+
git commit -m "chore: format teal-scrobble"
1498
1498
+
```