@cmer/localhook 1.1.0

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,1044 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <script>(function(){var t=localStorage.getItem('localhook-theme');if(!t)t=matchMedia('(prefers-color-scheme:light)').matches?'light':'dark';document.documentElement.setAttribute('data-theme',t)})()</script>
7
+ <title>LocalHook</title>
8
+ <style>
9
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
10
+
11
+ :root {
12
+ --bg: #09090b;
13
+ --sidebar-bg: #0f0f12;
14
+ --surface: #18181b;
15
+ --surface-hover: #1e1e22;
16
+ --border: #27272a;
17
+ --border-light: #3f3f46;
18
+ --text: #fafafa;
19
+ --text-secondary: #a1a1aa;
20
+ --text-muted: #71717a;
21
+ --accent: #6366f1;
22
+ --accent-soft: rgba(99, 102, 241, 0.12);
23
+ --green: #4ade80;
24
+ --blue: #60a5fa;
25
+ --orange: #fb923c;
26
+ --red: #f87171;
27
+ --purple: #c084fc;
28
+ --yellow: #fbbf24;
29
+ --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif;
30
+ --mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
31
+ --label-bg: rgba(0,0,0,0.15);
32
+ --hover-overlay: rgba(255,255,255,0.08);
33
+ --json-key: #93c5fd;
34
+ --json-string: #86efac;
35
+ --json-number: #fdba74;
36
+ --json-boolean: #c4b5fd;
37
+ --json-null: #fca5a5;
38
+ }
39
+
40
+ [data-theme="light"] {
41
+ --bg: #fafafa;
42
+ --sidebar-bg: #f4f4f5;
43
+ --surface: #ffffff;
44
+ --surface-hover: #f0f0f2;
45
+ --border: #e4e4e7;
46
+ --border-light: #d4d4d8;
47
+ --text: #18181b;
48
+ --text-secondary: #52525b;
49
+ --text-muted: #a1a1aa;
50
+ --accent: #4f46e5;
51
+ --accent-soft: rgba(79, 70, 229, 0.08);
52
+ --green: #16a34a;
53
+ --blue: #2563eb;
54
+ --orange: #ea580c;
55
+ --red: #dc2626;
56
+ --purple: #9333ea;
57
+ --yellow: #ca8a04;
58
+ --label-bg: rgba(0,0,0,0.04);
59
+ --hover-overlay: rgba(0,0,0,0.05);
60
+ --json-key: #1d4ed8;
61
+ --json-string: #16a34a;
62
+ --json-number: #ea580c;
63
+ --json-boolean: #7c3aed;
64
+ --json-null: #dc2626;
65
+ }
66
+
67
+ body {
68
+ font-family: var(--font);
69
+ background: var(--bg);
70
+ color: var(--text);
71
+ height: 100vh;
72
+ overflow: hidden;
73
+ font-size: 13px;
74
+ -webkit-font-smoothing: antialiased;
75
+ }
76
+
77
+ #app {
78
+ display: flex;
79
+ height: 100vh;
80
+ }
81
+
82
+ /* ---- Sidebar ---- */
83
+ #sidebar {
84
+ width: 300px;
85
+ min-width: 300px;
86
+ background: var(--sidebar-bg);
87
+ border-right: 1px solid var(--border);
88
+ display: flex;
89
+ flex-direction: column;
90
+ }
91
+
92
+ .sidebar-header {
93
+ padding: 16px;
94
+ border-bottom: 1px solid var(--border);
95
+ }
96
+
97
+ .sidebar-header h1 {
98
+ font-size: 14px;
99
+ font-weight: 600;
100
+ letter-spacing: -0.02em;
101
+ color: var(--text);
102
+ display: flex;
103
+ align-items: center;
104
+ }
105
+
106
+ .url-box {
107
+ margin-top: 10px;
108
+ padding: 7px 10px;
109
+ background: var(--surface);
110
+ border: 1px solid var(--border);
111
+ border-radius: 6px;
112
+ font-family: var(--mono);
113
+ font-size: 11.5px;
114
+ color: var(--accent);
115
+ cursor: pointer;
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: space-between;
119
+ gap: 8px;
120
+ transition: border-color 0.15s;
121
+ user-select: none;
122
+ }
123
+
124
+ .url-box:hover { border-color: var(--accent); }
125
+
126
+ .url-box .copy-hint {
127
+ font-family: var(--font);
128
+ font-size: 10px;
129
+ color: var(--text-muted);
130
+ flex-shrink: 0;
131
+ }
132
+
133
+ .sidebar-actions {
134
+ display: flex;
135
+ align-items: center;
136
+ justify-content: space-between;
137
+ padding: 8px 16px;
138
+ border-bottom: 1px solid var(--border);
139
+ }
140
+
141
+ .sidebar-actions .count {
142
+ font-size: 12px;
143
+ color: var(--text-secondary);
144
+ }
145
+
146
+ .btn {
147
+ background: var(--surface);
148
+ border: 1px solid var(--border);
149
+ color: var(--text-secondary);
150
+ padding: 4px 10px;
151
+ border-radius: 5px;
152
+ font-size: 11px;
153
+ cursor: pointer;
154
+ transition: all 0.15s;
155
+ font-family: var(--font);
156
+ }
157
+
158
+ .btn:hover {
159
+ background: var(--surface-hover);
160
+ color: var(--text);
161
+ border-color: var(--border-light);
162
+ }
163
+
164
+ .btn-danger:hover {
165
+ background: rgba(248, 113, 113, 0.1);
166
+ color: var(--red);
167
+ border-color: rgba(248, 113, 113, 0.3);
168
+ }
169
+
170
+ #webhook-list {
171
+ flex: 1;
172
+ overflow-y: auto;
173
+ }
174
+
175
+ .webhook-item {
176
+ padding: 10px 12px 10px 16px;
177
+ border-bottom: 1px solid var(--border);
178
+ cursor: pointer;
179
+ display: flex;
180
+ align-items: center;
181
+ gap: 10px;
182
+ transition: background 0.1s;
183
+ position: relative;
184
+ border-left: 2px solid transparent;
185
+ }
186
+
187
+ .webhook-item:hover {
188
+ background: var(--surface-hover);
189
+ }
190
+
191
+ .webhook-item.selected {
192
+ background: var(--accent-soft);
193
+ border-left-color: var(--accent);
194
+ }
195
+
196
+ .webhook-item-info {
197
+ flex: 1;
198
+ min-width: 0;
199
+ }
200
+
201
+ .webhook-item-path {
202
+ font-size: 12px;
203
+ font-weight: 500;
204
+ color: var(--text);
205
+ white-space: nowrap;
206
+ overflow: hidden;
207
+ text-overflow: ellipsis;
208
+ }
209
+
210
+ .webhook-item-time {
211
+ font-size: 11px;
212
+ color: var(--text-muted);
213
+ margin-top: 2px;
214
+ }
215
+
216
+ .webhook-item .delete-btn {
217
+ opacity: 0;
218
+ background: none;
219
+ border: none;
220
+ color: var(--text-muted);
221
+ font-size: 16px;
222
+ cursor: pointer;
223
+ padding: 2px 4px;
224
+ border-radius: 3px;
225
+ line-height: 1;
226
+ transition: all 0.1s;
227
+ flex-shrink: 0;
228
+ }
229
+
230
+ .webhook-item:hover .delete-btn { opacity: 1; }
231
+ .webhook-item .delete-btn:hover { color: var(--red); background: rgba(248,113,113,0.1); }
232
+
233
+ /* Method badges */
234
+ .method-badge {
235
+ display: inline-flex;
236
+ align-items: center;
237
+ justify-content: center;
238
+ padding: 2px 0;
239
+ border-radius: 3px;
240
+ font-size: 9px;
241
+ font-weight: 700;
242
+ letter-spacing: 0.03em;
243
+ width: 42px;
244
+ text-align: center;
245
+ flex-shrink: 0;
246
+ }
247
+
248
+ .method-GET { background: rgba(74,222,128,0.12); color: var(--green); }
249
+ .method-POST { background: rgba(96,165,250,0.12); color: var(--blue); }
250
+ .method-PUT { background: rgba(251,146,60,0.12); color: var(--orange); }
251
+ .method-DELETE { background: rgba(248,113,113,0.12); color: var(--red); }
252
+ .method-PATCH { background: rgba(192,132,252,0.12); color: var(--purple); }
253
+ .method-HEAD { background: rgba(251,191,36,0.12); color: var(--yellow); }
254
+ .method-OPTIONS { background: rgba(161,161,170,0.12); color: var(--text-secondary); }
255
+
256
+ /* ---- Detail panel ---- */
257
+ #detail {
258
+ flex: 1;
259
+ overflow-y: auto;
260
+ padding: 0;
261
+ }
262
+
263
+ .empty-state {
264
+ display: flex;
265
+ flex-direction: column;
266
+ align-items: center;
267
+ justify-content: center;
268
+ height: 100%;
269
+ color: var(--text-muted);
270
+ text-align: center;
271
+ padding: 40px;
272
+ }
273
+
274
+ .empty-state h2 {
275
+ font-size: 16px;
276
+ font-weight: 600;
277
+ color: var(--text-secondary);
278
+ margin-bottom: 12px;
279
+ }
280
+
281
+ .empty-state .empty-url {
282
+ font-family: var(--mono);
283
+ font-size: 13px;
284
+ color: var(--accent);
285
+ background: var(--surface);
286
+ border: 1px solid var(--border);
287
+ border-radius: 6px;
288
+ padding: 10px 16px;
289
+ margin: 8px 0 16px;
290
+ }
291
+
292
+ .empty-state p {
293
+ font-size: 13px;
294
+ line-height: 1.6;
295
+ max-width: 360px;
296
+ }
297
+
298
+ .empty-code-wrap {
299
+ position: relative;
300
+ margin-top: 16px;
301
+ }
302
+
303
+ .empty-code-wrap code {
304
+ display: block;
305
+ text-align: left;
306
+ font-family: var(--mono);
307
+ font-size: 11.5px;
308
+ background: var(--surface);
309
+ border: 1px solid var(--border);
310
+ border-radius: 6px;
311
+ padding: 12px 40px 12px 16px;
312
+ color: var(--text-secondary);
313
+ line-height: 1.6;
314
+ word-break: break-all;
315
+ white-space: pre-wrap;
316
+ }
317
+
318
+ .copy-code-btn {
319
+ position: absolute;
320
+ bottom: 10px;
321
+ right: 8px;
322
+ background: none;
323
+ border: none;
324
+ color: var(--text-muted);
325
+ cursor: pointer;
326
+ padding: 4px;
327
+ border-radius: 4px;
328
+ transition: all 0.15s;
329
+ display: flex;
330
+ align-items: center;
331
+ }
332
+
333
+ .copy-code-btn:hover {
334
+ color: var(--text);
335
+ background: var(--hover-overlay);
336
+ }
337
+
338
+ /* Detail sections */
339
+ .detail-content {
340
+ padding: 24px;
341
+ }
342
+
343
+ .section {
344
+ margin-bottom: 24px;
345
+ }
346
+
347
+ .section-header {
348
+ font-size: 12px;
349
+ font-weight: 600;
350
+ color: var(--text-secondary);
351
+ text-transform: uppercase;
352
+ letter-spacing: 0.06em;
353
+ margin-bottom: 12px;
354
+ display: flex;
355
+ align-items: center;
356
+ justify-content: space-between;
357
+ }
358
+
359
+ .detail-grid {
360
+ display: grid;
361
+ grid-template-columns: 120px 1fr;
362
+ gap: 0;
363
+ background: var(--surface);
364
+ border: 1px solid var(--border);
365
+ border-radius: 8px;
366
+ overflow: hidden;
367
+ }
368
+
369
+ .detail-grid .label,
370
+ .detail-grid .value {
371
+ padding: 8px 12px;
372
+ border-bottom: 1px solid var(--border);
373
+ font-size: 12px;
374
+ }
375
+
376
+ .detail-grid .label {
377
+ color: var(--text-muted);
378
+ font-weight: 500;
379
+ background: var(--label-bg);
380
+ }
381
+
382
+ .detail-grid .value {
383
+ color: var(--text);
384
+ word-break: break-all;
385
+ }
386
+
387
+ .detail-grid .label:last-of-type,
388
+ .detail-grid .value:last-of-type {
389
+ border-bottom: none;
390
+ }
391
+
392
+ .detail-grid .value .method-badge {
393
+ font-size: 10px;
394
+ }
395
+
396
+ /* Two-column layout for details + headers */
397
+ .detail-columns {
398
+ display: grid;
399
+ grid-template-columns: 1fr 1fr;
400
+ gap: 24px;
401
+ }
402
+
403
+ @media (max-width: 900px) {
404
+ .detail-columns { grid-template-columns: 1fr; }
405
+ }
406
+
407
+ /* Body section */
408
+ .body-controls {
409
+ display: flex;
410
+ align-items: center;
411
+ gap: 12px;
412
+ }
413
+
414
+ .body-controls label {
415
+ display: flex;
416
+ align-items: center;
417
+ gap: 4px;
418
+ font-size: 11px;
419
+ color: var(--text-muted);
420
+ cursor: pointer;
421
+ }
422
+
423
+ .body-controls input[type="checkbox"] {
424
+ accent-color: var(--accent);
425
+ }
426
+
427
+ .body-controls .btn {
428
+ font-size: 10px;
429
+ padding: 2px 8px;
430
+ }
431
+
432
+ .body-pre {
433
+ background: var(--surface);
434
+ border: 1px solid var(--border);
435
+ border-radius: 8px;
436
+ padding: 16px;
437
+ font-family: var(--mono);
438
+ font-size: 12px;
439
+ line-height: 1.6;
440
+ color: var(--text-secondary);
441
+ overflow-x: auto;
442
+ tab-size: 2;
443
+ }
444
+
445
+ .body-pre.word-wrap {
446
+ white-space: pre-wrap;
447
+ word-break: break-word;
448
+ }
449
+
450
+ /* JSON highlighting */
451
+ .json-key { color: var(--json-key); }
452
+ .json-string { color: var(--json-string); }
453
+ .json-number { color: var(--json-number); }
454
+ .json-boolean { color: var(--json-boolean); }
455
+ .json-null { color: var(--json-null); }
456
+
457
+ .none-text {
458
+ color: var(--text-muted);
459
+ font-size: 12px;
460
+ font-style: italic;
461
+ }
462
+
463
+ /* Scrollbar */
464
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
465
+ ::-webkit-scrollbar-track { background: transparent; }
466
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
467
+ ::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
468
+
469
+ /* Toast */
470
+ .toast {
471
+ position: fixed;
472
+ bottom: 20px;
473
+ right: 20px;
474
+ background: var(--surface);
475
+ border: 1px solid var(--border);
476
+ color: var(--text);
477
+ padding: 8px 16px;
478
+ border-radius: 6px;
479
+ font-size: 12px;
480
+ opacity: 0;
481
+ transform: translateY(8px);
482
+ transition: all 0.2s;
483
+ pointer-events: none;
484
+ z-index: 100;
485
+ }
486
+
487
+ .toast.show {
488
+ opacity: 1;
489
+ transform: translateY(0);
490
+ }
491
+
492
+ /* Connection status */
493
+ .status-dot {
494
+ width: 8px;
495
+ height: 8px;
496
+ border-radius: 50%;
497
+ display: inline-block;
498
+ margin-right: 6px;
499
+ position: relative;
500
+ flex-shrink: 0;
501
+ }
502
+
503
+ .status-dot.connected {
504
+ background: var(--green);
505
+ box-shadow: 0 0 4px rgba(74, 222, 128, 0.4);
506
+ }
507
+
508
+ .status-dot.connected::after {
509
+ content: '';
510
+ position: absolute;
511
+ inset: -3px;
512
+ border-radius: 50%;
513
+ border: 1.5px solid var(--green);
514
+ animation: pulse 2s ease-out infinite;
515
+ }
516
+
517
+ @keyframes pulse {
518
+ 0% { opacity: 0.8; transform: scale(1); }
519
+ 70% { opacity: 0; transform: scale(1.8); }
520
+ 100% { opacity: 0; transform: scale(1.8); }
521
+ }
522
+
523
+ .status-dot.disconnected { background: var(--red); }
524
+ .status-dot.disconnected::after { display: none; }
525
+
526
+ /* Theme toggle */
527
+ .theme-toggle {
528
+ position: fixed;
529
+ top: 12px;
530
+ right: 12px;
531
+ z-index: 50;
532
+ background: var(--surface);
533
+ border: 1px solid var(--border);
534
+ color: var(--text-muted);
535
+ cursor: pointer;
536
+ padding: 6px;
537
+ border-radius: 6px;
538
+ display: flex;
539
+ align-items: center;
540
+ justify-content: center;
541
+ transition: all 0.15s;
542
+ }
543
+
544
+ .theme-toggle:hover {
545
+ color: var(--text);
546
+ border-color: var(--border-light);
547
+ background: var(--surface-hover);
548
+ }
549
+
550
+ [data-theme="light"] .method-GET { background: rgba(22,163,74,0.1); }
551
+ [data-theme="light"] .method-POST { background: rgba(37,99,235,0.1); }
552
+ [data-theme="light"] .method-PUT { background: rgba(234,88,12,0.1); }
553
+ [data-theme="light"] .method-DELETE { background: rgba(220,38,38,0.1); }
554
+ [data-theme="light"] .method-PATCH { background: rgba(147,51,234,0.1); }
555
+ [data-theme="light"] .method-HEAD { background: rgba(202,138,4,0.1); }
556
+ [data-theme="light"] .method-OPTIONS { background: rgba(82,82,91,0.1); }
557
+
558
+ [data-theme="light"] .btn-danger:hover {
559
+ background: rgba(220,38,38,0.08);
560
+ border-color: rgba(220,38,38,0.3);
561
+ }
562
+
563
+ [data-theme="light"] .webhook-item .delete-btn:hover {
564
+ background: rgba(220,38,38,0.08);
565
+ }
566
+ </style>
567
+ </head>
568
+ <body>
569
+
570
+ <div id="app">
571
+ <div id="sidebar">
572
+ <div class="sidebar-header">
573
+ <h1><span class="status-dot connected" id="status-dot" title="Connecting…"></span>LocalHook</h1>
574
+ <div class="url-box" id="url-box" onclick="copyUrl()">
575
+ <span id="url-text"></span>
576
+ <span class="copy-hint">Click to copy</span>
577
+ </div>
578
+ </div>
579
+ <div class="sidebar-actions">
580
+ <span class="count" id="count">0 requests</span>
581
+ <button class="btn btn-danger" onclick="clearAll()">Clear All</button>
582
+ </div>
583
+ <div id="webhook-list"></div>
584
+ </div>
585
+ <div id="detail"></div>
586
+ </div>
587
+
588
+ <button class="theme-toggle" onclick="toggleTheme()" id="theme-toggle" title="Toggle theme"></button>
589
+ <div class="toast" id="toast"></div>
590
+
591
+ <script>
592
+ const baseUrl = `${location.protocol}//${location.host}`;
593
+ document.getElementById('url-text').textContent = baseUrl;
594
+
595
+ const state = {
596
+ webhooks: [],
597
+ selected: null,
598
+ formatJson: true,
599
+ wordWrap: true,
600
+ };
601
+
602
+ // --- SSE with polling fallback ---
603
+ const sseBlocked = location.hostname.endsWith('.trycloudflare.com');
604
+
605
+ function setStatus(connected, mode) {
606
+ const dot = document.getElementById('status-dot');
607
+ dot.className = 'status-dot ' + (connected ? 'connected' : 'disconnected');
608
+ dot.title = connected ? 'Connected via ' + mode : 'Disconnected';
609
+ }
610
+ let eventSource;
611
+ let sseFailCount = 0;
612
+ let pollingInterval = null;
613
+
614
+ const HEARTBEAT_TIMEOUT_MS = 7500;
615
+ const RECONNECT_INTERVAL_MS = 5000;
616
+ let lastSseActivity = 0;
617
+ let heartbeatCheckTimer = null;
618
+ let sseReconnectTimer = null;
619
+
620
+ function connectSSE() {
621
+ eventSource = new EventSource('/_/events');
622
+
623
+ eventSource.onmessage = (e) => {
624
+ lastSseActivity = Date.now();
625
+ const data = JSON.parse(e.data);
626
+ if (data.type === 'webhook') {
627
+ state.webhooks.unshift(data.webhook);
628
+ if (state.selected === null) {
629
+ state.selected = data.webhook.id;
630
+ renderDetail();
631
+ }
632
+ renderList();
633
+ } else if (data.type === 'delete') {
634
+ state.webhooks = state.webhooks.filter(w => w.id !== data.id);
635
+ if (state.selected === data.id) {
636
+ state.selected = state.webhooks.length > 0 ? state.webhooks[0].id : null;
637
+ renderDetail();
638
+ }
639
+ renderList();
640
+ } else if (data.type === 'clear') {
641
+ state.webhooks = [];
642
+ state.selected = null;
643
+ renderList();
644
+ renderDetail();
645
+ }
646
+ };
647
+
648
+ eventSource.addEventListener('heartbeat', () => {
649
+ lastSseActivity = Date.now();
650
+ });
651
+
652
+ eventSource.onopen = () => {
653
+ sseFailCount = 0;
654
+ lastSseActivity = Date.now();
655
+ stopPolling();
656
+ stopReconnectTimer();
657
+ startHeartbeatCheck();
658
+ setStatus(true, 'SSE');
659
+ };
660
+
661
+ eventSource.onerror = () => {
662
+ sseFailCount++;
663
+ if (sseFailCount >= 3) {
664
+ handleSSEDead();
665
+ } else {
666
+ setStatus(false);
667
+ }
668
+ };
669
+ }
670
+
671
+ function startHeartbeatCheck() {
672
+ stopHeartbeatCheck();
673
+ heartbeatCheckTimer = setInterval(() => {
674
+ if (lastSseActivity && Date.now() - lastSseActivity > HEARTBEAT_TIMEOUT_MS) {
675
+ handleSSEDead();
676
+ }
677
+ }, 5000);
678
+ }
679
+
680
+ function stopHeartbeatCheck() {
681
+ if (heartbeatCheckTimer) {
682
+ clearInterval(heartbeatCheckTimer);
683
+ heartbeatCheckTimer = null;
684
+ }
685
+ }
686
+
687
+ function handleSSEDead() {
688
+ if (eventSource) { eventSource.close(); eventSource = null; }
689
+ stopHeartbeatCheck();
690
+ setStatus(false);
691
+ if (!pollingInterval) startPolling();
692
+ if (!sseReconnectTimer) {
693
+ sseReconnectTimer = setInterval(() => {
694
+ sseFailCount = 0;
695
+ connectSSE();
696
+ }, RECONNECT_INTERVAL_MS);
697
+ }
698
+ }
699
+
700
+ function stopReconnectTimer() {
701
+ if (sseReconnectTimer) {
702
+ clearInterval(sseReconnectTimer);
703
+ sseReconnectTimer = null;
704
+ }
705
+ }
706
+
707
+ function getPollingInterval() {
708
+ return document.hidden ? 5000 : 1000;
709
+ }
710
+
711
+ function startPolling() {
712
+ setStatus(true, 'polling');
713
+ pollingInterval = setInterval(pollWebhooks, getPollingInterval());
714
+ }
715
+
716
+ function restartPolling() {
717
+ if (pollingInterval) {
718
+ clearInterval(pollingInterval);
719
+ pollingInterval = setInterval(pollWebhooks, getPollingInterval());
720
+ }
721
+ }
722
+
723
+ function stopPolling() {
724
+ if (pollingInterval) {
725
+ clearInterval(pollingInterval);
726
+ pollingInterval = null;
727
+ }
728
+ }
729
+
730
+ document.addEventListener('visibilitychange', () => {
731
+ restartPolling();
732
+ });
733
+
734
+ async function pollWebhooks() {
735
+ try {
736
+ const res = await fetch('/_/api/webhooks');
737
+ const fresh = await res.json();
738
+ setStatus(true, 'polling');
739
+ const oldIds = new Set(state.webhooks.map(w => w.id));
740
+ const newIds = new Set(fresh.map(w => w.id));
741
+
742
+ let changed = fresh.length !== state.webhooks.length;
743
+ if (!changed) {
744
+ for (const id of oldIds) { if (!newIds.has(id)) { changed = true; break; } }
745
+ }
746
+
747
+ if (changed) {
748
+ state.webhooks = fresh;
749
+ if (state.selected && !newIds.has(state.selected)) {
750
+ state.selected = fresh.length > 0 ? fresh[0].id : null;
751
+ } else if (!state.selected && fresh.length > 0) {
752
+ state.selected = fresh[0].id;
753
+ }
754
+ renderList();
755
+ renderDetail();
756
+ }
757
+ } catch {
758
+ setStatus(false);
759
+ }
760
+ }
761
+
762
+ // --- Data loading ---
763
+ async function loadWebhooks() {
764
+ try {
765
+ const res = await fetch('/_/api/webhooks');
766
+ state.webhooks = await res.json();
767
+ if (state.webhooks.length > 0) {
768
+ state.selected = state.webhooks[0].id;
769
+ }
770
+ renderList();
771
+ renderDetail();
772
+ } catch (err) {
773
+ console.error('Failed to load webhooks:', err);
774
+ }
775
+ }
776
+
777
+ // --- Actions ---
778
+ async function deleteWebhook(id, e) {
779
+ if (e) { e.stopPropagation(); }
780
+ await fetch(`/_/api/webhooks/${id}`, { method: 'DELETE' });
781
+ }
782
+
783
+ async function clearAll() {
784
+ if (state.webhooks.length === 0) return;
785
+ await fetch('/_/api/webhooks', { method: 'DELETE' });
786
+ }
787
+
788
+ function selectWebhook(id) {
789
+ state.selected = id;
790
+ renderList();
791
+ renderDetail();
792
+ }
793
+
794
+ function copyUrl() {
795
+ navigator.clipboard.writeText(baseUrl).then(() => showToast('URL copied!'));
796
+ }
797
+
798
+ function copyExample() {
799
+ const cmd = `curl -X POST ${baseUrl}/webhook -H "Content-Type: application/json" -d '{"hello": "world"}'`;
800
+ navigator.clipboard.writeText(cmd).then(() => showToast('Copied!'));
801
+ }
802
+
803
+ function copyBody() {
804
+ const w = state.webhooks.find(w => w.id === state.selected);
805
+ if (!w) return;
806
+ let text = w.body;
807
+ if (state.formatJson) {
808
+ try { text = JSON.stringify(JSON.parse(w.body), null, 2); } catch {}
809
+ }
810
+ navigator.clipboard.writeText(text).then(() => showToast('Copied!'));
811
+ }
812
+
813
+ function toggleFormat() {
814
+ state.formatJson = !state.formatJson;
815
+ renderDetail();
816
+ }
817
+
818
+ function toggleWrap() {
819
+ state.wordWrap = !state.wordWrap;
820
+ renderDetail();
821
+ }
822
+
823
+ // --- Rendering ---
824
+ function renderList() {
825
+ const list = document.getElementById('webhook-list');
826
+ const count = document.getElementById('count');
827
+ const n = state.webhooks.length;
828
+ count.textContent = `${n} request${n !== 1 ? 's' : ''}`;
829
+
830
+ list.innerHTML = state.webhooks.map(w => `
831
+ <div class="webhook-item ${w.id === state.selected ? 'selected' : ''}"
832
+ onclick="selectWebhook('${w.id}')">
833
+ <span class="method-badge method-${w.method}">${esc(w.method)}</span>
834
+ <div class="webhook-item-info">
835
+ <div class="webhook-item-path">${esc(w.path)}</div>
836
+ <div class="webhook-item-time">${formatTime(w.timestamp)}</div>
837
+ </div>
838
+ <button class="delete-btn" onclick="deleteWebhook('${w.id}', event)" title="Delete">&times;</button>
839
+ </div>
840
+ `).join('');
841
+ }
842
+
843
+ function renderDetail() {
844
+ const detail = document.getElementById('detail');
845
+ const w = state.webhooks.find(w => w.id === state.selected);
846
+
847
+ if (!w) {
848
+ detail.innerHTML = `
849
+ <div class="empty-state">
850
+ <h2>No requests yet</h2>
851
+ <div class="empty-url">${esc(baseUrl)}/your-path</div>
852
+ <p>Send any HTTP request to the URL above.<br>Requests will appear here in real-time.</p>
853
+ <div class="empty-code-wrap">
854
+ <code>curl -X POST ${esc(baseUrl)}/webhook -H "Content-Type: application/json" -d '{"hello": "world"}'</code>
855
+ <button class="copy-code-btn" onclick="copyExample()" title="Copy">
856
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
857
+ </button>
858
+ </div>
859
+ </div>`;
860
+ return;
861
+ }
862
+
863
+ const queryKeys = Object.keys(w.query || {});
864
+ const headerEntries = Object.entries(w.headers || {});
865
+
866
+ // Split headers into two columns
867
+ const mid = Math.ceil(headerEntries.length / 2);
868
+ const headersLeft = headerEntries.slice(0, mid);
869
+ const headersRight = headerEntries.slice(mid);
870
+
871
+ let bodyHtml = '';
872
+ if (w.body && w.body.length > 0) {
873
+ let content = w.body;
874
+ let highlighted = false;
875
+ if (state.formatJson) {
876
+ try {
877
+ const parsed = JSON.parse(w.body);
878
+ content = JSON.stringify(parsed, null, 2);
879
+ highlighted = true;
880
+ } catch {}
881
+ }
882
+ const displayContent = highlighted ? highlightJson(content) : esc(content);
883
+ bodyHtml = `<pre class="body-pre ${state.wordWrap ? 'word-wrap' : ''}">${displayContent}</pre>`;
884
+ } else {
885
+ bodyHtml = '<p class="none-text">No body content</p>';
886
+ }
887
+
888
+ detail.innerHTML = `
889
+ <div class="detail-content">
890
+ <div class="detail-columns">
891
+ <div class="section">
892
+ <div class="section-header">Request Details</div>
893
+ <div class="detail-grid">
894
+ <div class="label">Method</div>
895
+ <div class="value"><span class="method-badge method-${w.method}">${esc(w.method)}</span></div>
896
+ <div class="label">URL</div>
897
+ <div class="value">${esc(w.path)}</div>
898
+ <div class="label">Host</div>
899
+ <div class="value">${esc(w.headers?.host || 'localhost')}</div>
900
+ <div class="label">Date</div>
901
+ <div class="value">${formatDate(w.timestamp)}</div>
902
+ <div class="label">Size</div>
903
+ <div class="value">${formatSize(w.size)}</div>
904
+ <div class="label">IP</div>
905
+ <div class="value">${esc(w.ip)}</div>
906
+ <div class="label">ID</div>
907
+ <div class="value" style="font-family:var(--mono);font-size:11px">${esc(w.id)}</div>
908
+ </div>
909
+ </div>
910
+ <div class="section">
911
+ <div class="section-header">Headers</div>
912
+ <div class="detail-grid">
913
+ ${headerEntries.map(([k, v]) => `
914
+ <div class="label">${esc(k)}</div>
915
+ <div class="value">${esc(String(v))}</div>
916
+ `).join('')}
917
+ </div>
918
+ </div>
919
+ </div>
920
+
921
+ ${queryKeys.length > 0 ? `
922
+ <div class="section">
923
+ <div class="section-header">Query Parameters</div>
924
+ <div class="detail-grid" style="max-width:600px">
925
+ ${queryKeys.map(k => `
926
+ <div class="label">${esc(k)}</div>
927
+ <div class="value">${esc(String(w.query[k]))}</div>
928
+ `).join('')}
929
+ </div>
930
+ </div>
931
+ ` : ''}
932
+
933
+ <div class="section">
934
+ <div class="section-header">
935
+ Request Content
936
+ <div class="body-controls">
937
+ <label><input type="checkbox" ${state.formatJson ? 'checked' : ''} onchange="toggleFormat()"> Format JSON</label>
938
+ <label><input type="checkbox" ${state.wordWrap ? 'checked' : ''} onchange="toggleWrap()"> Word Wrap</label>
939
+ <button class="btn" onclick="copyBody()">Copy</button>
940
+ </div>
941
+ </div>
942
+ ${bodyHtml}
943
+ </div>
944
+ </div>`;
945
+ }
946
+
947
+ // --- Helpers ---
948
+ function esc(str) {
949
+ const div = document.createElement('div');
950
+ div.textContent = str || '';
951
+ return div.innerHTML;
952
+ }
953
+
954
+ function formatTime(iso) {
955
+ const d = new Date(iso);
956
+ return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
957
+ }
958
+
959
+ function formatDate(iso) {
960
+ const d = new Date(iso);
961
+ return d.toLocaleDateString(undefined, {
962
+ year: 'numeric', month: '2-digit', day: '2-digit',
963
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
964
+ });
965
+ }
966
+
967
+ function formatSize(bytes) {
968
+ if (bytes === 0) return '0 bytes';
969
+ if (bytes < 1024) return bytes + ' bytes';
970
+ return (bytes / 1024).toFixed(1) + ' KB';
971
+ }
972
+
973
+ function highlightJson(json) {
974
+ return esc(json).replace(
975
+ /("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*")\s*:/g,
976
+ '<span class="json-key">$1</span>:'
977
+ ).replace(
978
+ /:\s*("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*")/g,
979
+ (match, str) => ': <span class="json-string">' + str + '</span>'
980
+ ).replace(
981
+ /:\s*(-?\d+\.?\d*([eE][+-]?\d+)?)\b/g,
982
+ ': <span class="json-number">$1</span>'
983
+ ).replace(
984
+ /:\s*(true|false)\b/g,
985
+ ': <span class="json-boolean">$1</span>'
986
+ ).replace(
987
+ /:\s*(null)\b/g,
988
+ ': <span class="json-null">$1</span>'
989
+ );
990
+ }
991
+
992
+ function showToast(message) {
993
+ const toast = document.getElementById('toast');
994
+ toast.textContent = message;
995
+ toast.classList.add('show');
996
+ setTimeout(() => toast.classList.remove('show'), 1500);
997
+ }
998
+
999
+ // --- Theme ---
1000
+ const sunIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
1001
+ const moonIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
1002
+
1003
+ function getTheme() {
1004
+ return document.documentElement.getAttribute('data-theme') || 'dark';
1005
+ }
1006
+
1007
+ function updateThemeIcon() {
1008
+ document.getElementById('theme-toggle').innerHTML = getTheme() === 'dark' ? sunIcon : moonIcon;
1009
+ }
1010
+
1011
+ function toggleTheme() {
1012
+ const next = getTheme() === 'dark' ? 'light' : 'dark';
1013
+ document.documentElement.setAttribute('data-theme', next);
1014
+ localStorage.setItem('localhook-theme', next);
1015
+ updateThemeIcon();
1016
+ }
1017
+
1018
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
1019
+ if (!localStorage.getItem('localhook-theme')) {
1020
+ document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
1021
+ updateThemeIcon();
1022
+ }
1023
+ });
1024
+
1025
+ // --- Init ---
1026
+ updateThemeIcon();
1027
+ loadWebhooks();
1028
+
1029
+ (async () => {
1030
+ let forcePoll = sseBlocked;
1031
+ try {
1032
+ const res = await fetch('/_/api/public_url');
1033
+ const info = await res.json();
1034
+ if (info.poll) forcePoll = true;
1035
+ } catch {}
1036
+ if (forcePoll) {
1037
+ startPolling();
1038
+ } else {
1039
+ connectSSE();
1040
+ }
1041
+ })();
1042
+ </script>
1043
+ </body>
1044
+ </html>