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.
- package/dist/jat-feedback.js +2 -2
- package/dist/jat-feedback.mjs +3 -3
- package/package.json +3 -2
- package/routes/feedback/replay/+page.js +1 -0
- package/routes/feedback/replay/+page.svelte +651 -0
- package/static/feedback/replay.html +328 -0
- package/static/feedback/rrweb-replay.min.css +2 -0
- package/static/feedback/rrweb-replay.min.js +18 -0
|
@@ -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>
|