jat-feedback 3.3.1 → 3.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,651 @@
1
+ <script>
2
+ import { onMount, onDestroy } from 'svelte'
3
+
4
+ let statusEl
5
+ let bodyEl
6
+ let playerEl
7
+ let scrubberEl
8
+ let eventBarEl
9
+ let detailPanelEl
10
+ let playBtn
11
+ let playIcon
12
+ let pauseIcon
13
+ let currentTimeEl
14
+ let totalTimeEl
15
+ let scrubberFill
16
+ let scrubberThumb
17
+ let eventPlayhead
18
+ let eventBarWrap
19
+ let legend
20
+ let detailBadge
21
+ let detailTime
22
+ let detailBody
23
+
24
+ let replayer = null
25
+ let totalDuration = 0
26
+ let currentTime = 0
27
+ let playing = false
28
+ let speed = 1
29
+ let recordingStartTime = 0
30
+ let consoleLogs = []
31
+ let networkRequests = []
32
+ let animFrame = null
33
+
34
+ function fmt(ms) {
35
+ const s = Math.floor(ms / 1000)
36
+ return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`
37
+ }
38
+
39
+ function setStatus(msg, isError) {
40
+ if (!statusEl) return
41
+ statusEl.style.display = 'flex'
42
+ statusEl.className = isError ? 'error' : ''
43
+ statusEl.innerHTML = isError
44
+ ? `<span>${msg}</span>`
45
+ : `<div class="spinner"></div><span>${msg}</span>`
46
+ }
47
+
48
+ function showPlayer() {
49
+ if (statusEl) statusEl.style.display = 'none'
50
+ if (bodyEl) bodyEl.style.display = 'flex'
51
+ }
52
+
53
+ function updateScrubber() {
54
+ const pct = totalDuration ? (currentTime / totalDuration) * 100 : 0
55
+ if (scrubberFill) scrubberFill.style.width = pct + '%'
56
+ if (scrubberThumb) scrubberThumb.style.left = pct + '%'
57
+ if (eventPlayhead) eventPlayhead.style.left = pct + '%'
58
+ if (currentTimeEl) currentTimeEl.textContent = fmt(currentTime)
59
+ }
60
+
61
+ function tick() {
62
+ if (replayer && playing) {
63
+ currentTime = replayer.getCurrentTime()
64
+ updateScrubber()
65
+ }
66
+ animFrame = requestAnimationFrame(tick)
67
+ }
68
+
69
+ function scaleToFit() {
70
+ if (!playerEl) return
71
+ const wrapper = playerEl.querySelector('.replayer-wrapper')
72
+ const iframe = playerEl.querySelector('iframe')
73
+ if (!wrapper || !iframe) return
74
+ const cw = playerEl.clientWidth, ch = playerEl.clientHeight
75
+ const iw = parseInt(iframe.width) || iframe.clientWidth || 1280
76
+ const ih = parseInt(iframe.height) || iframe.clientHeight || 800
77
+ const scale = Math.min(cw / iw, ch / ih, 1)
78
+ const ox = (cw - iw * scale) / 2, oy = (ch - ih * scale) / 2
79
+ wrapper.style.transform = `translate(${ox}px,${oy}px) scale(${scale})`
80
+ wrapper.style.transformOrigin = 'top left'
81
+ }
82
+
83
+ function buildTimeline() {
84
+ if (!consoleLogs.length && !networkRequests.length) return
85
+ if (!eventBarEl) return
86
+ if (eventBarWrap) eventBarWrap.style.display = 'block'
87
+ const t0 = recordingStartTime
88
+
89
+ const items = []
90
+ for (const log of consoleLogs) {
91
+ const offset = (log.timestampMs || 0) - t0
92
+ if (offset < 0 || offset > totalDuration) continue
93
+ const color = log.type === 'error' ? '#ef4444' : log.type === 'warn' ? '#f59e0b' : '#3b82f6'
94
+ items.push({ offset, pct: (offset / totalDuration) * 100, color, label: `console.${log.type}`, detail: String(log.message || '').slice(0, 500) })
95
+ }
96
+ for (const req of networkRequests) {
97
+ const offset = (req.timestampMs || 0) - t0
98
+ if (offset < 0 || offset > totalDuration) continue
99
+ const s = req.status || 0
100
+ const color = req.error ? '#ef4444' : s >= 400 ? '#ef4444' : s >= 200 && s < 300 ? '#10b981' : '#9ca3af'
101
+ const urlShort = (req.url || '').replace(/^https?:\/\/[^/]+/, '')
102
+ items.push({ offset, pct: (offset / totalDuration) * 100, color, label: `${req.method || 'GET'} ${s || 'ERR'}`, detail: `${req.method || 'GET'} ${urlShort}${req.duration ? ` (${req.duration}ms)` : ''}${req.error ? ` — ${req.error}` : ''}` })
103
+ }
104
+
105
+ for (const item of items) {
106
+ const btn = document.createElement('button')
107
+ btn.className = 'evt-dot'
108
+ btn.style.cssText = `left:${item.pct}%;background:${item.color}`
109
+ btn.title = `${item.label}: ${item.detail}`
110
+ btn.onclick = (e) => {
111
+ e.stopPropagation()
112
+ showDetail(item)
113
+ replayer?.pause(item.offset)
114
+ currentTime = item.offset
115
+ updateScrubber()
116
+ }
117
+ eventBarEl.appendChild(btn)
118
+ }
119
+ }
120
+
121
+ function showDetail(item) {
122
+ if (!detailPanelEl) return
123
+ detailPanelEl.style.display = 'block'
124
+ if (detailBadge) {
125
+ detailBadge.textContent = item.label
126
+ detailBadge.style.cssText = `background:${item.color}20;color:${item.color};border:1px solid ${item.color}40`
127
+ }
128
+ if (detailTime) detailTime.textContent = fmt(item.offset)
129
+ if (detailBody) detailBody.textContent = item.detail
130
+ }
131
+
132
+ function handleDetailClose() {
133
+ if (detailPanelEl) detailPanelEl.style.display = 'none'
134
+ }
135
+
136
+ function handlePlayPause() {
137
+ if (!replayer) return
138
+ if (playing) {
139
+ replayer.pause()
140
+ } else {
141
+ replayer.play(currentTime)
142
+ }
143
+ }
144
+
145
+ function handleScrubberClick(e) {
146
+ if (!replayer || !scrubberEl) return
147
+ const rect = scrubberEl.getBoundingClientRect()
148
+ const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
149
+ replayer.pause(pct * totalDuration)
150
+ currentTime = pct * totalDuration
151
+ updateScrubber()
152
+ }
153
+
154
+ function handleEventBarClick(e) {
155
+ if (e.target.classList.contains('evt-dot')) return
156
+ if (!replayer || !eventBarEl) return
157
+ const rect = eventBarEl.getBoundingClientRect()
158
+ const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
159
+ replayer.pause(pct * totalDuration)
160
+ currentTime = pct * totalDuration
161
+ updateScrubber()
162
+ }
163
+
164
+ function handleSpeedClick(e) {
165
+ const btn = e.currentTarget
166
+ speed = parseFloat(btn.dataset.speed)
167
+ document.querySelectorAll('.speed-btn').forEach(b => b.classList.remove('active'))
168
+ btn.classList.add('active')
169
+ replayer?.setConfig({ speed })
170
+ }
171
+
172
+ async function init() {
173
+ const taskId = new URLSearchParams(location.search).get('id') || location.pathname.split('/').pop()
174
+ const apiBase = location.origin
175
+
176
+ const res = await fetch(`${apiBase}/api/feedback/reports`)
177
+ if (!res.ok) throw new Error(`Reports API: HTTP ${res.status}`)
178
+ const data = await res.json()
179
+ const report = (data.reports || []).find(r => r.id === taskId)
180
+ if (!report) throw new Error(`Report ${taskId} not found`)
181
+
182
+ document.title = `Replay: ${report.title || taskId}`
183
+ const reportTitleEl = document.getElementById('report-title')
184
+ if (reportTitleEl) reportTitleEl.textContent = report.title || taskId
185
+ if (report.page_url) {
186
+ const u = document.getElementById('page-url')
187
+ if (u) {
188
+ u.href = report.page_url
189
+ u.textContent = report.page_url
190
+ u.style.display = ''
191
+ }
192
+ }
193
+ consoleLogs = report.console_logs || []
194
+ networkRequests = report.network_requests || []
195
+
196
+ const recordingUrl = report.recording_url
197
+ if (!recordingUrl) throw new Error('No recording available for this report')
198
+
199
+ setStatus('Loading recording…')
200
+ const recRes = await fetch(recordingUrl.startsWith('http') ? recordingUrl : `${apiBase}${recordingUrl}`)
201
+ if (!recRes.ok) throw new Error(`Recording fetch: HTTP ${recRes.status}`)
202
+ const recData = await recRes.json()
203
+
204
+ const events = Array.isArray(recData) ? recData : recData.events || []
205
+ if (!events.length) throw new Error('Recording has no events')
206
+ if (recData.recordingStartTime) recordingStartTime = recData.recordingStartTime
207
+
208
+ showPlayer()
209
+
210
+ const R = window.rrwebReplay
211
+ if (!R?.Replayer) throw new Error('rrweb Replayer not available')
212
+
213
+ replayer = new R.Replayer(events, {
214
+ root: playerEl,
215
+ skipInactive: true,
216
+ showWarning: false,
217
+ showDebug: false,
218
+ speed,
219
+ })
220
+
221
+ const meta = replayer.getMetaData()
222
+ totalDuration = meta.totalTime
223
+ if (!recordingStartTime && meta.startTime) recordingStartTime = meta.startTime
224
+ if (totalTimeEl) totalTimeEl.textContent = fmt(totalDuration)
225
+
226
+ function setPlaying(v) {
227
+ playing = v
228
+ if (playIcon) playIcon.style.display = v ? 'none' : ''
229
+ if (pauseIcon) pauseIcon.style.display = v ? '' : 'none'
230
+ }
231
+ replayer.on('start', () => setPlaying(true))
232
+ replayer.on('play', () => setPlaying(true))
233
+ replayer.on('pause', () => setPlaying(false))
234
+ replayer.on('finish', () => { setPlaying(false); currentTime = totalDuration; updateScrubber() })
235
+
236
+ requestAnimationFrame(() => requestAnimationFrame(scaleToFit))
237
+ setTimeout(scaleToFit, 50)
238
+ setTimeout(scaleToFit, 200)
239
+
240
+ window.addEventListener('resize', scaleToFit)
241
+
242
+ if (typeof ResizeObserver !== 'undefined') {
243
+ new ResizeObserver(scaleToFit).observe(playerEl)
244
+ }
245
+
246
+ buildTimeline()
247
+ animFrame = requestAnimationFrame(tick)
248
+ }
249
+
250
+ onMount(() => {
251
+ // Dynamically load rrweb CSS and JS, then init
252
+ const link = document.createElement('link')
253
+ link.rel = 'stylesheet'
254
+ link.href = '/feedback/rrweb-replay.min.css'
255
+ document.head.appendChild(link)
256
+
257
+ const script = document.createElement('script')
258
+ script.src = '/feedback/rrweb-replay.min.js'
259
+ script.onload = () => {
260
+ init().catch(err => {
261
+ console.error('[replay]', err)
262
+ setStatus(err?.message || String(err) || 'Failed to load replay', true)
263
+ })
264
+ }
265
+ script.onerror = () => setStatus('Failed to load rrweb player', true)
266
+ document.head.appendChild(script)
267
+ })
268
+
269
+ onDestroy(() => {
270
+ if (animFrame) cancelAnimationFrame(animFrame)
271
+ })
272
+ </script>
273
+
274
+ <div id="header">
275
+ <!-- svelte-ignore a11y_invalid_attribute -->
276
+ <a id="back-link" href="#" onclick="history.back(); return false;">← Back</a>
277
+ <div class="meta">
278
+ <span id="report-title">Loading…</span>
279
+ <a id="page-url" href="#" target="_blank" rel="noreferrer" style="display:none"></a>
280
+ </div>
281
+ </div>
282
+
283
+ <div id="status" bind:this={statusEl}>
284
+ <div class="spinner"></div>
285
+ <span>Loading recording…</span>
286
+ </div>
287
+
288
+ <div id="body" bind:this={bodyEl} style="display:none">
289
+ <div id="player" bind:this={playerEl}></div>
290
+ <div id="controls">
291
+ <button id="play-btn" bind:this={playBtn} title="Play/Pause" onclick={handlePlayPause}>
292
+ <svg bind:this={playIcon} id="play-icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21"/></svg>
293
+ <svg bind:this={pauseIcon} id="pause-icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="display:none"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
294
+ </button>
295
+ <span bind:this={currentTimeEl} id="current-time" class="time">0:00</span>
296
+ <div id="scrubber" bind:this={scrubberEl} onclick={handleScrubberClick}>
297
+ <div id="scrubber-fill" bind:this={scrubberFill}></div>
298
+ <div id="scrubber-thumb" bind:this={scrubberThumb}></div>
299
+ </div>
300
+ <span bind:this={totalTimeEl} id="total-time" class="time">0:00</span>
301
+ <div class="speed-btns">
302
+ <button class="speed-btn active" data-speed="1" onclick={handleSpeedClick}>1x</button>
303
+ <button class="speed-btn" data-speed="2" onclick={handleSpeedClick}>2x</button>
304
+ <button class="speed-btn" data-speed="4" onclick={handleSpeedClick}>4x</button>
305
+ </div>
306
+ </div>
307
+ <div id="event-bar-wrap" bind:this={eventBarWrap}>
308
+ <div id="event-bar" bind:this={eventBarEl} onclick={handleEventBarClick}>
309
+ <div id="event-playhead" bind:this={eventPlayhead}></div>
310
+ </div>
311
+ <div id="legend" bind:this={legend}>
312
+ <span class="legend-item"><span class="legend-dot" style="background:#ef4444"></span>Error</span>
313
+ <span class="legend-item"><span class="legend-dot" style="background:#f59e0b"></span>Warn</span>
314
+ <span class="legend-item"><span class="legend-dot" style="background:#3b82f6"></span>Log</span>
315
+ <span class="legend-item"><span class="legend-dot" style="background:#10b981"></span>2xx</span>
316
+ <span class="legend-item"><span class="legend-dot" style="background:#9ca3af"></span>Other</span>
317
+ </div>
318
+ </div>
319
+ <div id="detail-panel" bind:this={detailPanelEl}>
320
+ <div id="detail-header">
321
+ <span bind:this={detailBadge} id="detail-badge" class="detail-badge"></span>
322
+ <span bind:this={detailTime} id="detail-time" class="detail-time"></span>
323
+ <button id="detail-close" onclick={handleDetailClose}>×</button>
324
+ </div>
325
+ <pre bind:this={detailBody} id="detail-body"></pre>
326
+ </div>
327
+ </div>
328
+
329
+ <style>
330
+ :global(body) {
331
+ background: #0d1117;
332
+ color: #e5e7eb;
333
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
334
+ height: 100vh;
335
+ display: flex;
336
+ flex-direction: column;
337
+ overflow: hidden;
338
+ margin: 0;
339
+ padding: 0;
340
+ box-sizing: border-box;
341
+ }
342
+
343
+ :global(*, *::before, *::after) {
344
+ box-sizing: border-box;
345
+ margin: 0;
346
+ padding: 0;
347
+ }
348
+
349
+ #header {
350
+ display: flex;
351
+ align-items: center;
352
+ gap: 12px;
353
+ padding: 8px 16px;
354
+ background: #161b22;
355
+ border-bottom: 1px solid #30363d;
356
+ flex-shrink: 0;
357
+ }
358
+
359
+ :global(#back-link) {
360
+ color: #58a6ff;
361
+ text-decoration: none;
362
+ font-size: 13px;
363
+ }
364
+
365
+ :global(#back-link:hover) {
366
+ text-decoration: underline;
367
+ }
368
+
369
+ :global(#report-title) {
370
+ font-size: 14px;
371
+ font-weight: 600;
372
+ color: #e6edf3;
373
+ }
374
+
375
+ :global(#page-url) {
376
+ font-size: 11px;
377
+ color: #8b949e;
378
+ text-decoration: none;
379
+ }
380
+
381
+ :global(#page-url:hover) {
382
+ color: #58a6ff;
383
+ }
384
+
385
+ .meta {
386
+ display: flex;
387
+ flex-direction: column;
388
+ gap: 2px;
389
+ }
390
+
391
+ #status {
392
+ display: flex;
393
+ align-items: center;
394
+ justify-content: center;
395
+ gap: 10px;
396
+ flex: 1;
397
+ color: #8b949e;
398
+ font-size: 14px;
399
+ }
400
+
401
+ :global(#status.error) {
402
+ color: #f85149;
403
+ }
404
+
405
+ :global(.spinner) {
406
+ width: 18px;
407
+ height: 18px;
408
+ border: 2px solid #30363d;
409
+ border-top-color: #58a6ff;
410
+ border-radius: 50%;
411
+ animation: spin 0.6s linear infinite;
412
+ }
413
+
414
+ @keyframes spin {
415
+ to { transform: rotate(360deg); }
416
+ }
417
+
418
+ #body {
419
+ display: flex;
420
+ flex-direction: column;
421
+ flex: 1;
422
+ overflow: hidden;
423
+ }
424
+
425
+ #player {
426
+ flex: 1;
427
+ background: #0a0a0a;
428
+ overflow: hidden;
429
+ position: relative;
430
+ }
431
+
432
+ :global(#player .replayer-wrapper) {
433
+ transform-origin: top left;
434
+ }
435
+
436
+ :global(#player iframe) {
437
+ border: none;
438
+ display: block;
439
+ }
440
+
441
+ #controls {
442
+ display: flex;
443
+ align-items: center;
444
+ gap: 8px;
445
+ padding: 7px 14px;
446
+ background: #161b22;
447
+ border-top: 1px solid #30363d;
448
+ flex-shrink: 0;
449
+ }
450
+
451
+ #play-btn {
452
+ display: flex;
453
+ align-items: center;
454
+ justify-content: center;
455
+ width: 30px;
456
+ height: 30px;
457
+ background: none;
458
+ border: 1px solid #30363d;
459
+ border-radius: 6px;
460
+ color: #e6edf3;
461
+ cursor: pointer;
462
+ flex-shrink: 0;
463
+ }
464
+
465
+ #play-btn:hover {
466
+ background: #21262d;
467
+ }
468
+
469
+ .time {
470
+ font-size: 11px;
471
+ color: #8b949e;
472
+ font-family: monospace;
473
+ min-width: 36px;
474
+ flex-shrink: 0;
475
+ }
476
+
477
+ #scrubber {
478
+ flex: 1;
479
+ height: 6px;
480
+ background: #21262d;
481
+ border-radius: 3px;
482
+ position: relative;
483
+ cursor: pointer;
484
+ }
485
+
486
+ #scrubber-fill {
487
+ position: absolute;
488
+ top: 0;
489
+ left: 0;
490
+ height: 100%;
491
+ background: #58a6ff;
492
+ border-radius: 3px;
493
+ pointer-events: none;
494
+ }
495
+
496
+ #scrubber-thumb {
497
+ position: absolute;
498
+ top: 50%;
499
+ width: 12px;
500
+ height: 12px;
501
+ background: #e6edf3;
502
+ border-radius: 50%;
503
+ transform: translate(-50%, -50%);
504
+ pointer-events: none;
505
+ }
506
+
507
+ .speed-btns {
508
+ display: flex;
509
+ gap: 2px;
510
+ flex-shrink: 0;
511
+ }
512
+
513
+ .speed-btn {
514
+ padding: 2px 7px;
515
+ font-size: 10px;
516
+ background: none;
517
+ border: 1px solid #30363d;
518
+ border-radius: 4px;
519
+ color: #6e7681;
520
+ cursor: pointer;
521
+ }
522
+
523
+ .speed-btn:hover {
524
+ color: #e6edf3;
525
+ }
526
+
527
+ :global(.speed-btn.active) {
528
+ background: #58a6ff;
529
+ border-color: #58a6ff;
530
+ color: #fff;
531
+ }
532
+
533
+ #event-bar-wrap {
534
+ padding: 5px 14px 7px;
535
+ background: #161b22;
536
+ border-top: 1px solid #30363d;
537
+ flex-shrink: 0;
538
+ display: none;
539
+ }
540
+
541
+ #event-bar {
542
+ position: relative;
543
+ height: 22px;
544
+ background: #0d1117;
545
+ border-radius: 4px;
546
+ cursor: pointer;
547
+ }
548
+
549
+ #event-playhead {
550
+ position: absolute;
551
+ top: 0;
552
+ bottom: 0;
553
+ width: 1px;
554
+ background: #e6edf3;
555
+ opacity: 0.4;
556
+ pointer-events: none;
557
+ }
558
+
559
+ :global(.evt-dot) {
560
+ position: absolute;
561
+ top: 50%;
562
+ width: 9px;
563
+ height: 9px;
564
+ border-radius: 50%;
565
+ transform: translate(-50%, -50%);
566
+ cursor: pointer;
567
+ border: none;
568
+ padding: 0;
569
+ transition: transform 0.1s;
570
+ }
571
+
572
+ :global(.evt-dot:hover) {
573
+ transform: translate(-50%, -50%) scale(1.7);
574
+ z-index: 1;
575
+ }
576
+
577
+ #legend {
578
+ display: flex;
579
+ gap: 10px;
580
+ margin-top: 4px;
581
+ }
582
+
583
+ .legend-item {
584
+ display: flex;
585
+ align-items: center;
586
+ gap: 3px;
587
+ font-size: 10px;
588
+ color: #6e7681;
589
+ }
590
+
591
+ .legend-dot {
592
+ width: 6px;
593
+ height: 6px;
594
+ border-radius: 50%;
595
+ display: inline-block;
596
+ }
597
+
598
+ #detail-panel {
599
+ padding: 8px 14px;
600
+ background: #161b22;
601
+ border-top: 1px solid #30363d;
602
+ display: none;
603
+ }
604
+
605
+ #detail-header {
606
+ display: flex;
607
+ align-items: center;
608
+ gap: 8px;
609
+ margin-bottom: 4px;
610
+ }
611
+
612
+ .detail-badge {
613
+ font-size: 10px;
614
+ padding: 1px 7px;
615
+ border-radius: 4px;
616
+ font-family: monospace;
617
+ }
618
+
619
+ .detail-time {
620
+ font-size: 10px;
621
+ color: #6e7681;
622
+ font-family: monospace;
623
+ }
624
+
625
+ #detail-close {
626
+ margin-left: auto;
627
+ background: none;
628
+ border: none;
629
+ color: #6e7681;
630
+ font-size: 18px;
631
+ cursor: pointer;
632
+ }
633
+
634
+ #detail-close:hover {
635
+ color: #e6edf3;
636
+ }
637
+
638
+ #detail-body {
639
+ margin: 0;
640
+ padding: 6px 8px;
641
+ background: #0d1117;
642
+ border-radius: 4px;
643
+ font-size: 11px;
644
+ color: #d2a8ff;
645
+ font-family: monospace;
646
+ white-space: pre-wrap;
647
+ word-break: break-all;
648
+ max-height: 100px;
649
+ overflow-y: auto;
650
+ }
651
+ </style>