@delt/claude-alarm 0.4.8 → 0.5.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.
@@ -1,1224 +1,1339 @@
1
- <!DOCTYPE html>
2
- <html lang="en" data-theme="dark">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Claude Alarm - Dashboard</title>
7
- <style>
8
- :root, [data-theme="dark"] {
9
- --bg: #0f1117;
10
- --surface: #1a1d27;
11
- --border: #2a2d3a;
12
- --text: #e1e4ed;
13
- --text-dim: #8b8fa3;
14
- --accent: #e0a86d;
15
- --accent-dim: #c48d52;
16
- --green: #3dd68c;
17
- --yellow: #f59e0b;
18
- --red: #ef4444;
19
- --blue: #60a5fa;
20
- }
21
- [data-theme="light"] {
22
- --bg: #f5f5f7;
23
- --surface: #ffffff;
24
- --border: #d1d5db;
25
- --text: #1f2937;
26
- --text-dim: #6b7280;
27
- --accent: #c48d52;
28
- --accent-dim: #a87642;
29
- --green: #22c55e;
30
- --yellow: #d97706;
31
- --red: #ef4444;
32
- --blue: #3b82f6;
33
- }
34
- * { margin: 0; padding: 0; box-sizing: border-box; }
35
- body {
36
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
37
- background: var(--bg);
38
- color: var(--text);
39
- height: 100vh;
40
- overflow: hidden;
41
- }
42
- /* Hide scrollbars */
43
- ::-webkit-scrollbar { display: none; }
44
- * { -ms-overflow-style: none; scrollbar-width: none; }
45
-
46
- header {
47
- border-bottom: 1px solid var(--border);
48
- padding: 16px 24px;
49
- display: flex;
50
- align-items: center;
51
- justify-content: space-between;
52
- }
53
- header h1 { font-size: 18px; font-weight: 600; }
54
- .header-right {
55
- display: flex;
56
- align-items: center;
57
- gap: 12px;
58
- }
59
- .theme-toggle {
60
- background: var(--surface);
61
- border: 1px solid var(--border);
62
- border-radius: 6px;
63
- padding: 4px 10px;
64
- cursor: pointer;
65
- font-size: 16px;
66
- line-height: 1;
67
- color: var(--text);
68
- }
69
- .theme-toggle:hover { border-color: var(--accent); }
70
- .status-badge {
71
- display: inline-flex;
72
- align-items: center;
73
- gap: 6px;
74
- font-size: 13px;
75
- color: var(--text-dim);
76
- }
77
- .status-dot {
78
- width: 8px; height: 8px;
79
- border-radius: 50%;
80
- background: var(--red);
81
- }
82
- .status-dot.connected { background: var(--green); }
83
-
84
- .container {
85
- display: grid;
86
- grid-template-columns: 300px 1fr 320px;
87
- height: calc(100vh - 57px);
88
- }
89
-
90
- /* Sessions panel */
91
- .sessions-panel {
92
- border-right: 1px solid var(--border);
93
- overflow-y: auto;
94
- padding: 12px;
95
- }
96
- .sessions-header {
97
- display: flex;
98
- align-items: center;
99
- justify-content: space-between;
100
- padding: 8px 8px 12px;
101
- position: sticky;
102
- top: 0;
103
- background: var(--bg);
104
- z-index: 1;
105
- }
106
- .sessions-header h2 {
107
- font-size: 13px;
108
- text-transform: uppercase;
109
- letter-spacing: 0.5px;
110
- color: var(--text-dim);
111
- }
112
- .add-session-btn {
113
- background: none;
114
- border: 1px solid var(--border);
115
- border-radius: 6px;
116
- color: var(--text-dim);
117
- cursor: pointer;
118
- font-size: 16px;
119
- width: 28px;
120
- height: 28px;
121
- display: flex;
122
- align-items: center;
123
- justify-content: center;
124
- transition: border-color 0.15s, color 0.15s;
125
- }
126
- .add-session-btn:hover { border-color: var(--accent); color: var(--accent); }
127
- .cmd-popup {
128
- position: absolute;
129
- top: 44px;
130
- left: 12px;
131
- right: 12px;
132
- background: var(--surface);
133
- border: 1px solid var(--border);
134
- border-radius: 8px;
135
- padding: 12px;
136
- z-index: 10;
137
- box-shadow: 0 4px 12px rgba(0,0,0,0.3);
138
- display: none;
139
- }
140
- .cmd-popup.active { display: block; }
141
- .cmd-popup-title {
142
- font-size: 12px;
143
- color: var(--text-dim);
144
- margin-bottom: 8px;
145
- }
146
- .session-card {
147
- background: var(--surface);
148
- border: 1px solid var(--border);
149
- border-radius: 8px;
150
- padding: 12px;
151
- margin-bottom: 8px;
152
- cursor: pointer;
153
- transition: border-color 0.15s;
154
- }
155
- .session-card:hover, .session-card.active {
156
- border-color: var(--accent);
157
- }
158
- .session-name {
159
- font-size: 14px;
160
- font-weight: 500;
161
- margin-bottom: 4px;
162
- display: flex;
163
- align-items: center;
164
- gap: 6px;
165
- }
166
- .unread-badge {
167
- background: var(--accent);
168
- color: #fff;
169
- font-size: 10px;
170
- font-weight: 600;
171
- min-width: 18px;
172
- height: 18px;
173
- border-radius: 9px;
174
- display: flex;
175
- align-items: center;
176
- justify-content: center;
177
- padding: 0 5px;
178
- }
179
- .session-status {
180
- display: inline-block;
181
- font-size: 11px;
182
- padding: 2px 8px;
183
- border-radius: 10px;
184
- margin-top: 6px;
185
- font-weight: 500;
186
- }
187
- .session-status.idle { background: rgba(139,143,163,0.15); color: var(--text-dim); }
188
- .session-status.working { background: rgba(96,165,250,0.15); color: var(--blue); }
189
- .session-status.waiting_input { background: rgba(245,197,66,0.15); color: var(--yellow); }
190
- .session-cwd {
191
- font-size: 11px;
192
- color: var(--text-dim);
193
- font-family: monospace;
194
- margin-top: 4px;
195
- overflow: hidden;
196
- text-overflow: ellipsis;
197
- white-space: nowrap;
198
- }
199
- .no-sessions {
200
- color: var(--text-dim);
201
- font-size: 13px;
202
- text-align: center;
203
- padding: 40px 20px;
204
- }
205
- .cmd-copy {
206
- display: flex;
207
- align-items: center;
208
- background: var(--bg);
209
- border: 1px solid var(--border);
210
- border-radius: 6px;
211
- padding: 6px 10px;
212
- margin-top: 12px;
213
- font-size: 11px;
214
- font-family: monospace;
215
- cursor: pointer;
216
- transition: border-color 0.15s;
217
- text-align: left;
218
- gap: 6px;
219
- }
220
- .cmd-copy:hover { border-color: var(--accent); }
221
- .cmd-copy .cmd-text { flex: 1; color: var(--text); word-break: break-all; }
222
- .cmd-copy .cmd-icon { color: var(--text-dim); font-size: 14px; flex-shrink: 0; }
223
- .cmd-copy.copied { border-color: var(--green); }
224
- .cmd-copy.copied .cmd-icon { color: var(--green); }
225
-
226
- /* Messages panel */
227
- .messages-panel {
228
- display: flex;
229
- flex-direction: column;
230
- }
231
- .messages-header {
232
- padding: 12px 20px;
233
- border-bottom: 1px solid var(--border);
234
- font-size: 14px;
235
- font-weight: 500;
236
- }
237
- .messages-list {
238
- flex: 1;
239
- overflow-y: auto;
240
- padding: 16px 20px;
241
- }
242
- .message {
243
- margin-bottom: 12px;
244
- padding: 10px 14px;
245
- border-radius: 8px;
246
- font-size: 13px;
247
- line-height: 1.5;
248
- max-width: 80%;
249
- }
250
- .message.from-session {
251
- background: var(--surface);
252
- border: 1px solid var(--border);
253
- }
254
- .message.from-dashboard {
255
- background: rgba(224,168,109,0.12);
256
- border: 1px solid rgba(224,168,109,0.25);
257
- margin-left: auto;
258
- }
259
- .message-meta {
260
- font-size: 11px;
261
- color: var(--text-dim);
262
- margin-bottom: 4px;
263
- }
264
- .message-body img {
265
- max-width: 100%;
266
- border-radius: 6px;
267
- margin-top: 4px;
268
- }
269
-
270
- /* Typing indicator */
271
- .typing-indicator {
272
- display: none;
273
- padding: 10px 14px;
274
- margin-bottom: 12px;
275
- max-width: 80px;
276
- }
277
- .typing-indicator.active { display: block; }
278
- .typing-dots {
279
- display: flex;
280
- gap: 4px;
281
- align-items: center;
282
- }
283
- .typing-dots span {
284
- width: 6px;
285
- height: 6px;
286
- border-radius: 50%;
287
- background: var(--text-dim);
288
- animation: typing 1.4s infinite ease-in-out;
289
- }
290
- .typing-dots span:nth-child(2) { animation-delay: 0.2s; }
291
- .typing-dots span:nth-child(3) { animation-delay: 0.4s; }
292
- @keyframes typing {
293
- 0%, 60%, 100% { opacity: 0.3; transform: scale(1); }
294
- 30% { opacity: 1; transform: scale(1.2); }
295
- }
296
-
297
- /* Scroll to bottom button */
298
- .scroll-bottom {
299
- position: absolute;
300
- bottom: 80px;
301
- right: 30px;
302
- width: 36px;
303
- height: 36px;
304
- border-radius: 50%;
305
- background: var(--surface);
306
- border: 1px solid var(--border);
307
- color: var(--text);
308
- cursor: pointer;
309
- font-size: 16px;
310
- display: none;
311
- align-items: center;
312
- justify-content: center;
313
- z-index: 5;
314
- transition: border-color 0.15s;
315
- }
316
- .scroll-bottom:hover { border-color: var(--accent); }
317
- .scroll-bottom.visible { display: flex; }
318
-
319
- /* Image preview */
320
- .image-preview {
321
- border-top: 1px solid var(--border);
322
- padding: 8px 20px;
323
- display: none;
324
- align-items: center;
325
- gap: 8px;
326
- background: var(--surface);
327
- }
328
- .image-preview.active { display: flex; }
329
- .image-preview img {
330
- max-height: 60px;
331
- border-radius: 4px;
332
- border: 1px solid var(--border);
333
- }
334
- .image-preview .preview-name {
335
- flex: 1;
336
- font-size: 12px;
337
- color: var(--text-dim);
338
- overflow: hidden;
339
- text-overflow: ellipsis;
340
- white-space: nowrap;
341
- }
342
- .image-preview .preview-cancel {
343
- background: none;
344
- border: none;
345
- color: var(--red);
346
- cursor: pointer;
347
- font-size: 18px;
348
- padding: 2px 6px;
349
- }
350
-
351
- /* Drag overlay */
352
- .drag-overlay {
353
- position: absolute;
354
- inset: 0;
355
- background: rgba(224,168,109,0.15);
356
- border: 2px dashed var(--accent);
357
- border-radius: 8px;
358
- display: none;
359
- align-items: center;
360
- justify-content: center;
361
- font-size: 16px;
362
- color: var(--accent);
363
- z-index: 10;
364
- pointer-events: none;
365
- }
366
- .drag-overlay.active { display: flex; }
367
-
368
- .message-input-area {
369
- border-top: 1px solid var(--border);
370
- padding: 12px 20px;
371
- display: flex;
372
- gap: 8px;
373
- align-items: flex-end;
374
- }
375
- .message-input-area textarea {
376
- flex: 1;
377
- background: var(--surface);
378
- border: 1px solid var(--border);
379
- border-radius: 6px;
380
- padding: 8px 12px;
381
- color: var(--text);
382
- font-size: 13px;
383
- outline: none;
384
- resize: none;
385
- min-height: 36px;
386
- max-height: 120px;
387
- font-family: inherit;
388
- line-height: 1.4;
389
- }
390
- .message-input-area textarea:focus { border-color: var(--accent); }
391
- .message-input-area button {
392
- background: var(--accent);
393
- color: white;
394
- border: none;
395
- border-radius: 6px;
396
- padding: 8px 12px;
397
- cursor: pointer;
398
- font-size: 13px;
399
- font-weight: 500;
400
- min-height: 36px;
401
- }
402
- .message-input-area button:hover { background: var(--accent-dim); }
403
- .message-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
404
- .attach-btn {
405
- background: var(--surface) !important;
406
- border: 1px solid var(--border) !important;
407
- color: var(--text) !important;
408
- font-size: 18px !important;
409
- padding: 6px 10px !important;
410
- }
411
- .attach-btn:hover { border-color: var(--accent) !important; }
412
- .attach-btn:disabled { display: none !important; }
413
-
414
- /* Notifications panel */
415
- .notifications-panel {
416
- border-left: 1px solid var(--border);
417
- overflow-y: auto;
418
- padding: 12px;
419
- }
420
- .notifications-panel > .notif-header + #notifList { /* spacer */ }
421
- .notif-item {
422
- background: var(--surface);
423
- border: 1px solid var(--border);
424
- border-radius: 8px;
425
- padding: 10px 12px;
426
- margin-bottom: 8px;
427
- font-size: 13px;
428
- cursor: pointer;
429
- }
430
- .notif-item:hover { border-color: var(--accent); }
431
- .notif-item:hover .notif-dismiss { opacity: 1; }
432
- .notif-item .notif-title { font-weight: 500; margin-bottom: 2px; display: flex; align-items: center; }
433
- .notif-item .notif-title-text { flex: 1; }
434
- .notif-dismiss {
435
- opacity: 0;
436
- background: none;
437
- border: none;
438
- color: var(--text-dim);
439
- cursor: pointer;
440
- font-size: 14px;
441
- padding: 0 2px;
442
- transition: opacity 0.15s;
443
- }
444
- .notif-dismiss:hover { color: var(--red); }
445
- .notif-item .notif-message { color: var(--text-dim); }
446
- .notif-item .notif-time { font-size: 11px; color: var(--text-dim); margin-top: 4px; }
447
- .notif-header {
448
- display: flex;
449
- align-items: center;
450
- justify-content: space-between;
451
- padding: 8px 8px 12px;
452
- position: sticky;
453
- top: 0;
454
- background: var(--bg);
455
- z-index: 1;
456
- }
457
- .notif-header h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); padding: 0; }
458
- .notif-clear-all {
459
- background: none;
460
- border: 1px solid var(--accent);
461
- border-radius: 4px;
462
- color: var(--accent);
463
- cursor: pointer;
464
- font-size: 11px;
465
- padding: 2px 8px;
466
- }
467
- .notif-clear-all:hover { border-color: var(--red); color: var(--red); }
468
- .notif-level {
469
- display: inline-block;
470
- width: 6px; height: 6px;
471
- border-radius: 50%;
472
- margin-right: 6px;
473
- }
474
- .notif-level.info { background: var(--blue); }
475
- .notif-level.success { background: var(--green); }
476
- .notif-level.warning { background: var(--yellow); }
477
- .notif-level.error { background: var(--red); }
478
-
479
- .message-body h2, .message-body h3, .message-body h4 { margin: 8px 0 4px; font-size: 14px; }
480
- .message-body h2 { font-size: 16px; }
481
- .message-body h3 { font-size: 15px; }
482
- .message-body pre {
483
- background: var(--bg);
484
- border: 1px solid var(--border);
485
- border-radius: 6px;
486
- padding: 8px 10px;
487
- overflow-x: auto;
488
- margin: 6px 0;
489
- font-size: 12px;
490
- }
491
- .message-body code {
492
- background: var(--bg);
493
- padding: 1px 4px;
494
- border-radius: 3px;
495
- font-size: 12px;
496
- font-family: monospace;
497
- }
498
- .message-body pre code { background: none; padding: 0; }
499
- .message-body table { border-collapse: collapse; margin: 6px 0; font-size: 12px; width: 100%; }
500
- .message-body th, .message-body td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; }
501
- .message-body th { background: var(--bg); font-weight: 600; }
502
- .message-body ul { margin: 4px 0; padding-left: 20px; }
503
- .message-body li { margin: 2px 0; }
504
- .message-body strong { font-weight: 600; }
505
-
506
- .empty-state {
507
- color: var(--text-dim);
508
- font-size: 13px;
509
- text-align: center;
510
- padding: 40px 20px;
511
- }
512
-
513
- /* Token auth overlay */
514
- .token-overlay {
515
- position: fixed;
516
- inset: 0;
517
- background: rgba(0,0,0,0.8);
518
- display: flex;
519
- align-items: center;
520
- justify-content: center;
521
- z-index: 100;
522
- }
523
- .token-overlay.hidden { display: none; }
524
- .token-form {
525
- background: var(--surface);
526
- border: 1px solid var(--border);
527
- border-radius: 12px;
528
- padding: 32px;
529
- max-width: 400px;
530
- width: 90%;
531
- text-align: center;
532
- }
533
- .token-form h2 { font-size: 18px; margin-bottom: 8px; }
534
- .token-form p { font-size: 13px; color: var(--text-dim); margin-bottom: 20px; }
535
- .token-form input {
536
- width: 100%;
537
- background: var(--bg);
538
- border: 1px solid var(--border);
539
- border-radius: 6px;
540
- padding: 10px 12px;
541
- color: var(--text);
542
- font-size: 14px;
543
- font-family: monospace;
544
- outline: none;
545
- margin-bottom: 12px;
546
- }
547
- .token-form input:focus { border-color: var(--accent); }
548
- .token-form button {
549
- background: var(--accent);
550
- color: white;
551
- border: none;
552
- border-radius: 6px;
553
- padding: 10px 24px;
554
- cursor: pointer;
555
- font-size: 14px;
556
- font-weight: 500;
557
- }
558
- .token-form button:hover { background: var(--accent-dim); }
559
- .token-error { color: var(--red); font-size: 12px; margin-top: 8px; display: none; }
560
-
561
- /* Mobile tabs */
562
- .mobile-tabs {
563
- display: none;
564
- border-bottom: 1px solid var(--border);
565
- }
566
- .mobile-tabs button {
567
- flex: 1;
568
- background: none;
569
- border: none;
570
- border-bottom: 2px solid transparent;
571
- color: var(--text-dim);
572
- padding: 10px;
573
- font-size: 13px;
574
- cursor: pointer;
575
- }
576
- .mobile-tabs button.active {
577
- color: var(--accent);
578
- border-bottom-color: var(--accent);
579
- }
580
-
581
- /* Mobile responsive */
582
- @media (max-width: 768px) {
583
- .container {
584
- grid-template-columns: 1fr;
585
- height: calc(100vh - 97px);
586
- }
587
- .sessions-panel, .messages-panel, .notifications-panel {
588
- border: none;
589
- display: none;
590
- }
591
- .sessions-panel.mobile-active,
592
- .messages-panel.mobile-active,
593
- .notifications-panel.mobile-active {
594
- display: flex;
595
- flex-direction: column;
596
- }
597
- .sessions-panel.mobile-active {
598
- display: block;
599
- overflow-y: auto;
600
- }
601
- .notifications-panel.mobile-active {
602
- display: block;
603
- overflow-y: auto;
604
- }
605
- .mobile-tabs { display: flex; }
606
- .message { max-width: 95%; }
607
- }
608
- </style>
609
- </head>
610
- <body>
611
- <header>
612
- <h1>Claude Alarm</h1>
613
- <div class="header-right">
614
- <button class="theme-toggle" id="webhookToggle" title="Webhook settings">&#9881;</button>
615
- <button class="theme-toggle" id="themeToggle" title="Toggle theme">&#9790;</button>
616
- <div class="status-badge">
617
- <span class="status-dot" id="connDot"></span>
618
- <span id="connLabel">Connecting...</span>
619
- </div>
620
- </div>
621
- </header>
622
-
623
- <div class="token-overlay hidden" id="tokenOverlay">
624
- <div class="token-form">
625
- <h2>Authentication Required</h2>
626
- <p>Enter the hub token to connect. Find it by running: <code>claude-alarm token</code></p>
627
- <input type="text" id="tokenInput" placeholder="Paste token here..." autocomplete="off">
628
- <button id="tokenSubmit">Connect</button>
629
- <div class="token-error" id="tokenError">Connection failed. Check your token.</div>
630
- </div>
631
- </div>
632
-
633
- <div class="token-overlay hidden" id="webhookOverlay">
634
- <div class="token-form" style="max-width:500px;text-align:left">
635
- <h2 style="text-align:center;margin-bottom:4px">Webhook Settings</h2>
636
- <p style="text-align:center;margin-bottom:16px">Send notifications to Slack, Discord, or any webhook endpoint.</p>
637
- <div id="webhookList" style="max-height:240px;overflow-y:auto"></div>
638
- <button id="webhookAdd" style="width:100%;margin-top:10px;background:none;color:var(--text-dim);border:1px dashed var(--border);border-radius:6px;padding:10px;cursor:pointer;font-size:13px;transition:border-color 0.15s">+ Add Webhook</button>
639
- <div style="display:flex;gap:8px;margin-top:20px">
640
- <button id="webhookSave" style="flex:1;padding:10px">Save</button>
641
- <button id="webhookCancel" style="flex:1;padding:10px;background:none;color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:14px;font-weight:500">Cancel</button>
642
- </div>
643
- </div>
644
- </div>
645
-
646
- <div class="mobile-tabs" id="mobileTabs">
647
- <button class="active" data-tab="sessions">Sessions</button>
648
- <button data-tab="messages">Messages</button>
649
- <button data-tab="notifications">Notifications</button>
650
- </div>
651
- <div class="container">
652
- <div class="sessions-panel" style="position:relative">
653
- <div class="sessions-header">
654
- <h2>Sessions</h2>
655
- <button class="add-session-btn" id="addSessionBtn" title="Add session">+</button>
656
- </div>
657
- <div class="cmd-popup" id="cmdPopup">
658
- <div class="cmd-popup-title">Run in terminal to connect:</div>
659
- <div class="cmd-copy" id="cmdCopy1" title="Click to copy">
660
- <span class="cmd-text">claude --dangerously-load-development-channels server:claude-alarm</span>
661
- <span class="cmd-icon">&#128203;</span>
662
- </div>
663
- <div style="margin-top:6px;font-size:11px;color:var(--text-dim)">with auto-approve:</div>
664
- <div class="cmd-copy" id="cmdCopy2" title="Click to copy">
665
- <span class="cmd-text">claude --dangerously-load-development-channels server:claude-alarm --dangerously-skip-permissions</span>
666
- <span class="cmd-icon">&#128203;</span>
667
- </div>
668
- </div>
669
- <div id="sessionsList"></div>
670
- </div>
671
-
672
- <div class="messages-panel" style="position:relative">
673
- <div class="messages-header" id="messagesHeader">Select a session</div>
674
- <div class="messages-list" id="messagesList">
675
- <div class="empty-state">Select a session to view messages</div>
676
- </div>
677
- <button class="scroll-bottom" id="scrollBottom" title="Scroll to bottom">&#8595;</button>
678
- <div class="drag-overlay" id="dragOverlay">Drop image here</div>
679
- <div class="image-preview" id="imagePreview">
680
- <img id="previewImg" src="" alt="preview">
681
- <span class="preview-name" id="previewName"></span>
682
- <button class="preview-cancel" id="previewCancel">&times;</button>
683
- </div>
684
- <div class="message-input-area">
685
- <button class="attach-btn" id="attachBtn" disabled title="Attach image">&#128206;</button>
686
- <input type="file" id="fileInput" accept="image/*" style="display:none">
687
- <textarea id="msgInput" placeholder="Send a message to session... (Shift+Enter for new line)" disabled rows="1"></textarea>
688
- <button id="sendBtn" disabled>Send</button>
689
- </div>
690
- </div>
691
-
692
- <div class="notifications-panel">
693
- <div class="notif-header">
694
- <h2>Notifications</h2>
695
- <button class="notif-clear-all" id="notifClearAll">Clear all</button>
696
- </div>
697
- <div id="notifList">
698
- <div class="empty-state">No notifications yet</div>
699
- </div>
700
- </div>
701
- </div>
702
-
703
- <script>
704
- (function() {
705
- const state = {
706
- ws: null,
707
- sessions: {},
708
- selectedSession: null,
709
- messages: {},
710
- notifications: [],
711
- token: null,
712
- pendingImage: null,
713
- unread: {},
714
- waitingReply: {},
715
- };
716
-
717
- const $ = (sel) => document.querySelector(sel);
718
-
719
- // --- Theme ---
720
- function initTheme() {
721
- const saved = localStorage.getItem('claude-alarm-theme') || 'dark';
722
- document.documentElement.setAttribute('data-theme', saved);
723
- updateThemeIcon(saved);
724
- }
725
- function toggleTheme() {
726
- const current = document.documentElement.getAttribute('data-theme');
727
- const next = current === 'dark' ? 'light' : 'dark';
728
- document.documentElement.setAttribute('data-theme', next);
729
- localStorage.setItem('claude-alarm-theme', next);
730
- updateThemeIcon(next);
731
- }
732
- function updateThemeIcon(theme) {
733
- $('#themeToggle').innerHTML = theme === 'dark' ? '&#9790;' : '&#9728;';
734
- }
735
- $('#themeToggle').addEventListener('click', toggleTheme);
736
- initTheme();
737
-
738
- // --- Token handling ---
739
- function getToken() {
740
- const params = new URLSearchParams(location.search);
741
- const urlToken = params.get('token');
742
- if (urlToken) {
743
- sessionStorage.setItem('claude-alarm-token', urlToken);
744
- return urlToken;
745
- }
746
- return sessionStorage.getItem('claude-alarm-token');
747
- }
748
-
749
- function showTokenForm() { $('#tokenOverlay').classList.remove('hidden'); }
750
- function hideTokenForm() { $('#tokenOverlay').classList.add('hidden'); }
751
-
752
- $('#tokenSubmit').addEventListener('click', () => {
753
- const token = $('#tokenInput').value.trim();
754
- if (!token) return;
755
- state.token = token;
756
- sessionStorage.setItem('claude-alarm-token', token);
757
- $('#tokenError').style.display = 'none';
758
- hideTokenForm();
759
- connect();
760
- });
761
- $('#tokenInput').addEventListener('keydown', (e) => {
762
- if (e.key === 'Enter') $('#tokenSubmit').click();
763
- });
764
-
765
- // --- WebSocket ---
766
- function connect() {
767
- const proto = location.protocol === 'https:' ? 'wss' : 'ws';
768
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
769
- const ws = new WebSocket(`${proto}://${location.host}/ws/dashboard${tokenQuery}`);
770
-
771
- ws.onopen = () => {
772
- state.ws = ws;
773
- hideTokenForm();
774
- $('#connDot').classList.add('connected');
775
- $('#connLabel').textContent = 'Connected';
776
- };
777
- ws.onclose = () => {
778
- state.ws = null;
779
- $('#connDot').classList.remove('connected');
780
- $('#connLabel').textContent = 'Disconnected';
781
- setTimeout(connect, 3000);
782
- };
783
- ws.onerror = () => {
784
- if (!state.token) { showTokenForm(); } else { $('#tokenError').style.display = 'block'; showTokenForm(); }
785
- ws.close();
786
- };
787
- ws.onmessage = (e) => { try { handleMessage(JSON.parse(e.data)); } catch {} };
788
- }
789
-
790
- function handleMessage(msg) {
791
- switch (msg.type) {
792
- case 'sessions_list':
793
- state.sessions = {};
794
- msg.sessions.forEach(s => { state.sessions[s.id] = s; });
795
- renderSessions();
796
- break;
797
- case 'session_connected':
798
- state.sessions[msg.session.id] = msg.session;
799
- if (!state.messages[msg.session.id]) state.messages[msg.session.id] = [];
800
- renderSessions();
801
- break;
802
- case 'session_disconnected':
803
- delete state.sessions[msg.sessionId];
804
- if (state.selectedSession === msg.sessionId) { state.selectedSession = null; renderMessages(); }
805
- renderSessions();
806
- break;
807
- case 'session_updated':
808
- state.sessions[msg.session.id] = msg.session;
809
- renderSessions();
810
- break;
811
- case 'reply_from_session':
812
- if (!state.messages[msg.sessionId]) state.messages[msg.sessionId] = [];
813
- state.messages[msg.sessionId].push({ from: 'session', content: msg.content, time: msg.timestamp });
814
- state.waitingReply[msg.sessionId] = false;
815
- if (state.selectedSession === msg.sessionId) { renderMessages(); }
816
- else { state.unread[msg.sessionId] = (state.unread[msg.sessionId] || 0) + 1; renderSessions(); }
817
- state.notifications.unshift({ sessionId: msg.sessionId, title: 'Reply', message: msg.content.slice(0, 100), level: 'info', time: msg.timestamp });
818
- renderNotifications();
819
- break;
820
- case 'notification':
821
- state.notifications.unshift({ sessionId: msg.sessionId, title: msg.title, message: msg.message, level: msg.level || 'info', time: msg.timestamp });
822
- renderNotifications();
823
- break;
824
- }
825
- }
826
-
827
- // --- Render ---
828
- function renderSessions() {
829
- const el = $('#sessionsList');
830
- const ids = Object.keys(state.sessions);
831
- if (!ids.length) {
832
- el.innerHTML = '<div class="no-sessions">No active sessions.<br>Click + to see connection commands.</div>';
833
- return;
834
- }
835
- el.innerHTML = ids.map(id => {
836
- const s = state.sessions[id];
837
- const active = state.selectedSession === id ? ' active' : '';
838
- const cwdDisplay = s.cwd ? s.cwd.replace(/^.*[/\\]/, '') : '';
839
- const unread = state.unread[id] || 0;
840
- return `<div class="session-card${active}" data-id="${id}">
841
- <div class="session-name">${esc(s.name)}${unread ? `<span class="unread-badge">${unread}</span>` : ''}</div>
842
- ${cwdDisplay ? `<div class="session-cwd" title="${esc(s.cwd)}">${esc(cwdDisplay)}</div>` : ''}
843
- <span class="session-status ${s.status}">${s.status.replace('_', ' ')}</span>
844
- </div>`;
845
- }).join('');
846
- el.querySelectorAll('.session-card').forEach(card => {
847
- card.addEventListener('click', () => selectSession(card.dataset.id));
848
- });
849
- updateImageUI();
850
- }
851
-
852
- function selectSession(id) {
853
- state.selectedSession = id;
854
- state.unread[id] = 0;
855
- if (!state.messages[id]) state.messages[id] = [];
856
- $('#msgInput').disabled = false;
857
- $('#sendBtn').disabled = false;
858
- $('#msgInput').placeholder = 'Send a message to session... (Shift+Enter for new line)';
859
- renderSessions();
860
- renderMessages();
861
- updateImageUI();
862
- }
863
-
864
- function updateImageUI() {
865
- const s = state.selectedSession ? state.sessions[state.selectedSession] : null;
866
- const canImage = s && s.isLocal;
867
- $('#attachBtn').disabled = !canImage;
868
- }
869
-
870
- function renderMessages() {
871
- const el = $('#messagesList');
872
- const header = $('#messagesHeader');
873
-
874
- if (!state.selectedSession) {
875
- header.textContent = 'Select a session';
876
- el.innerHTML = '<div class="empty-state">Select a session to view messages</div>';
877
- $('#msgInput').disabled = true;
878
- $('#sendBtn').disabled = true;
879
- return;
880
- }
881
-
882
- const s = state.sessions[state.selectedSession];
883
- header.textContent = s ? s.name : state.selectedSession.slice(0, 12);
884
-
885
- const msgs = state.messages[state.selectedSession] || [];
886
- if (!msgs.length) {
887
- el.innerHTML = '<div class="empty-state">No messages yet</div>';
888
- return;
889
- }
890
-
891
- el.innerHTML = msgs.map(m => {
892
- const cls = m.from === 'session' ? 'from-session' : 'from-dashboard';
893
- const timeStr = relativeTime(m.time);
894
- let content;
895
- if (m.imageData) {
896
- content = `<img src="${m.imageData}" alt="${esc(m.imageName || 'image')}">`;
897
- if (m.content) content = esc(m.content).replace(/\n/g, '<br>') + '<br>' + content;
898
- } else {
899
- content = m.from === 'session' ? renderMarkdown(m.content) : esc(m.content).replace(/\n/g, '<br>');
900
- }
901
- return `<div class="message ${cls}">
902
- <div class="message-meta">${m.from === 'session' ? 'Claude' : 'You'} &middot; ${timeStr}</div>
903
- <div class="message-body">${content}</div>
904
- </div>`;
905
- }).join('');
906
-
907
- if (state.waitingReply[state.selectedSession]) {
908
- el.innerHTML += '<div class="typing-indicator active"><div class="typing-dots"><span></span><span></span><span></span></div></div>';
909
- }
910
- setTimeout(() => { el.scrollTop = el.scrollHeight; }, 0);
911
- }
912
-
913
- function renderNotifications() {
914
- const el = $('#notifList');
915
- const clearBtn = $('#notifClearAll');
916
- if (!state.notifications.length) {
917
- el.innerHTML = '<div class="empty-state">No notifications yet</div>';
918
- clearBtn.style.display = 'none';
919
- return;
920
- }
921
- clearBtn.style.display = 'block';
922
- el.innerHTML = state.notifications.slice(0, 50).map((n, i) => {
923
- const timeStr = relativeTime(n.time);
924
- const session = state.sessions[n.sessionId];
925
- const sName = session ? session.name : n.sessionId.slice(0, 8);
926
- return `<div class="notif-item" data-session="${n.sessionId}" data-index="${i}">
927
- <div class="notif-title"><span class="notif-level ${n.level}"></span><span class="notif-title-text">${esc(n.title)}</span><button class="notif-dismiss" data-index="${i}">&times;</button></div>
928
- <div class="notif-message">${esc(n.message)}</div>
929
- <div class="notif-time">${sName} &middot; ${timeStr}</div>
930
- </div>`;
931
- }).join('');
932
- el.querySelectorAll('.notif-item').forEach(item => {
933
- item.addEventListener('click', (e) => {
934
- if (e.target.classList.contains('notif-dismiss')) return;
935
- const sid = item.dataset.session;
936
- if (sid && state.sessions[sid]) selectSession(sid);
937
- });
938
- });
939
- el.querySelectorAll('.notif-dismiss').forEach(btn => {
940
- btn.addEventListener('click', (e) => {
941
- e.stopPropagation();
942
- const idx = parseInt(btn.dataset.index);
943
- state.notifications.splice(idx, 1);
944
- renderNotifications();
945
- });
946
- });
947
- }
948
-
949
- // --- Image handling ---
950
- function handleImageFile(file) {
951
- if (!file || !file.type.startsWith('image/')) return;
952
- if (file.size > 10 * 1024 * 1024) { alert('Image must be under 10MB'); return; }
953
- const reader = new FileReader();
954
- reader.onload = () => {
955
- state.pendingImage = { data: reader.result, mimeType: file.type, name: file.name };
956
- $('#imagePreview').classList.add('active');
957
- $('#previewImg').src = reader.result;
958
- $('#previewName').textContent = file.name;
959
- };
960
- reader.readAsDataURL(file);
961
- }
962
-
963
- function clearPendingImage() {
964
- state.pendingImage = null;
965
- $('#imagePreview').classList.remove('active');
966
- $('#previewImg').src = '';
967
- $('#previewName').textContent = '';
968
- }
969
-
970
- $('#previewCancel').addEventListener('click', clearPendingImage);
971
- $('#attachBtn').addEventListener('click', () => $('#fileInput').click());
972
- $('#fileInput').addEventListener('change', (e) => {
973
- if (e.target.files[0]) handleImageFile(e.target.files[0]);
974
- e.target.value = '';
975
- });
976
-
977
- // Ctrl+V paste
978
- document.addEventListener('paste', (e) => {
979
- if (!state.selectedSession) return;
980
- const s = state.sessions[state.selectedSession];
981
- if (!s || !s.isLocal) return;
982
- const items = e.clipboardData?.items;
983
- if (!items) return;
984
- for (const item of items) {
985
- if (item.type.startsWith('image/')) {
986
- e.preventDefault();
987
- handleImageFile(item.getAsFile());
988
- return;
989
- }
990
- }
991
- });
992
-
993
- // Drag and drop
994
- const msgPanel = document.querySelector('.messages-panel');
995
- let dragCounter = 0;
996
- msgPanel.addEventListener('dragenter', (e) => {
997
- e.preventDefault();
998
- dragCounter++;
999
- const s = state.selectedSession ? state.sessions[state.selectedSession] : null;
1000
- if (s && s.isLocal) $('#dragOverlay').classList.add('active');
1001
- });
1002
- msgPanel.addEventListener('dragleave', (e) => {
1003
- e.preventDefault();
1004
- dragCounter--;
1005
- if (dragCounter <= 0) { dragCounter = 0; $('#dragOverlay').classList.remove('active'); }
1006
- });
1007
- msgPanel.addEventListener('dragover', (e) => e.preventDefault());
1008
- msgPanel.addEventListener('drop', (e) => {
1009
- e.preventDefault();
1010
- dragCounter = 0;
1011
- $('#dragOverlay').classList.remove('active');
1012
- const file = e.dataTransfer?.files?.[0];
1013
- if (file) handleImageFile(file);
1014
- });
1015
-
1016
- // --- Send ---
1017
- function sendMessage() {
1018
- const input = $('#msgInput');
1019
- const content = input.value.trim();
1020
- const hasImage = !!state.pendingImage;
1021
- if (!content && !hasImage) return;
1022
- if (!state.selectedSession || !state.ws) return;
1023
-
1024
- // Send text
1025
- if (content) {
1026
- state.ws.send(JSON.stringify({ type: 'message_to_session', sessionId: state.selectedSession, content }));
1027
- }
1028
-
1029
- // Send image
1030
- if (hasImage) {
1031
- state.ws.send(JSON.stringify({
1032
- type: 'image_upload',
1033
- sessionId: state.selectedSession,
1034
- imageData: state.pendingImage.data,
1035
- mimeType: state.pendingImage.mimeType,
1036
- originalName: state.pendingImage.name,
1037
- }));
1038
- }
1039
-
1040
- // Add to local messages
1041
- if (!state.messages[state.selectedSession]) state.messages[state.selectedSession] = [];
1042
- state.messages[state.selectedSession].push({
1043
- from: 'dashboard',
1044
- content: content || '',
1045
- imageData: hasImage ? state.pendingImage.data : null,
1046
- imageName: hasImage ? state.pendingImage.name : null,
1047
- time: Date.now(),
1048
- });
1049
-
1050
- input.value = '';
1051
- input.style.height = 'auto';
1052
- clearPendingImage();
1053
- state.waitingReply[state.selectedSession] = true;
1054
- renderMessages();
1055
- }
1056
-
1057
- $('#sendBtn').addEventListener('click', sendMessage);
1058
- $('#msgInput').addEventListener('keydown', (e) => {
1059
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
1060
- });
1061
- $('#msgInput').addEventListener('input', function() {
1062
- this.style.height = 'auto';
1063
- this.style.height = Math.min(this.scrollHeight, 120) + 'px';
1064
- });
1065
-
1066
- function relativeTime(ts) {
1067
- const diff = Math.floor((Date.now() - ts) / 1000);
1068
- if (diff < 5) return 'just now';
1069
- if (diff < 60) return diff + 's ago';
1070
- if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
1071
- if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
1072
- return new Date(ts).toLocaleDateString();
1073
- }
1074
-
1075
- function esc(s) {
1076
- const d = document.createElement('div');
1077
- d.textContent = s;
1078
- return d.innerHTML;
1079
- }
1080
-
1081
- function renderMarkdown(text) {
1082
- let html = esc(text);
1083
- html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
1084
- html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
1085
- html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
1086
- html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
1087
- html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
1088
- html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
1089
- html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>');
1090
- html = html.replace(/^(\|.+\|)\n\|[-| :]+\|\n((?:\|.+\|\n?)*)/gm, (_, header, body) => {
1091
- const ths = header.split('|').filter(c => c.trim()).map(c => `<th>${c.trim()}</th>`).join('');
1092
- const rows = body.trim().split('\n').map(row => {
1093
- const tds = row.split('|').filter(c => c.trim()).map(c => `<td>${c.trim()}</td>`).join('');
1094
- return `<tr>${tds}</tr>`;
1095
- }).join('');
1096
- return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
1097
- });
1098
- html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
1099
- html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
1100
- html = html.replace(/\n/g, '<br>');
1101
- html = html.replace(/<br>\s*(<\/?(?:pre|h[2-4]|ul|li|table|thead|tbody|tr))/g, '$1');
1102
- html = html.replace(/(<\/(?:pre|h[2-4]|ul|table)>)\s*<br>/g, '$1');
1103
- return html;
1104
- }
1105
-
1106
- // Clear all notifications
1107
- $('#notifClearAll').addEventListener('click', () => {
1108
- state.notifications = [];
1109
- renderNotifications();
1110
- });
1111
-
1112
- // Scroll to bottom button
1113
- const msgList = $('#messagesList');
1114
- const scrollBtn = $('#scrollBottom');
1115
- msgList.addEventListener('scroll', () => {
1116
- const gap = msgList.scrollHeight - msgList.scrollTop - msgList.clientHeight;
1117
- scrollBtn.classList.toggle('visible', gap > 100);
1118
- });
1119
- scrollBtn.addEventListener('click', () => {
1120
- msgList.scrollTo({ top: msgList.scrollHeight, behavior: 'smooth' });
1121
- });
1122
-
1123
- // --- Add session popup ---
1124
- $('#addSessionBtn').addEventListener('click', (e) => {
1125
- e.stopPropagation();
1126
- $('#cmdPopup').classList.toggle('active');
1127
- });
1128
- document.addEventListener('click', (e) => {
1129
- if (!e.target.closest('#cmdPopup') && !e.target.closest('#addSessionBtn')) {
1130
- $('#cmdPopup').classList.remove('active');
1131
- }
1132
- });
1133
- document.querySelectorAll('#cmdPopup .cmd-copy').forEach(btn => {
1134
- btn.addEventListener('click', () => {
1135
- const text = btn.querySelector('.cmd-text').textContent;
1136
- navigator.clipboard.writeText(text).then(() => {
1137
- btn.classList.add('copied');
1138
- btn.querySelector('.cmd-icon').innerHTML = '&#10003;';
1139
- setTimeout(() => {
1140
- btn.classList.remove('copied');
1141
- btn.querySelector('.cmd-icon').innerHTML = '&#128203;';
1142
- }, 2000);
1143
- });
1144
- });
1145
- });
1146
-
1147
- // --- Webhook settings ---
1148
- let webhookData = [];
1149
- $('#webhookToggle').addEventListener('click', async () => {
1150
- try {
1151
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
1152
- const res = await fetch(`/api/webhooks${tokenQuery}`);
1153
- const data = await res.json();
1154
- webhookData = data.webhooks || [];
1155
- } catch { webhookData = []; }
1156
- renderWebhooks();
1157
- $('#webhookOverlay').classList.remove('hidden');
1158
- });
1159
- $('#webhookCancel').addEventListener('click', () => $('#webhookOverlay').classList.add('hidden'));
1160
- $('#webhookAdd').addEventListener('click', () => {
1161
- webhookData.push({ url: '' });
1162
- renderWebhooks();
1163
- });
1164
- $('#webhookSave').addEventListener('click', async () => {
1165
- const valid = webhookData.filter(w => w.url.trim());
1166
- try {
1167
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
1168
- await fetch(`/api/webhooks${tokenQuery}`, {
1169
- method: 'POST',
1170
- headers: { 'Content-Type': 'application/json' },
1171
- body: JSON.stringify({ webhooks: valid }),
1172
- });
1173
- } catch {}
1174
- $('#webhookOverlay').classList.add('hidden');
1175
- });
1176
- function renderWebhooks() {
1177
- const el = $('#webhookList');
1178
- if (!webhookData.length) {
1179
- el.innerHTML = '<div style="text-align:center;color:var(--text-dim);padding:16px;font-size:13px">No webhooks configured</div>';
1180
- return;
1181
- }
1182
- el.innerHTML = webhookData.map((w, i) => `<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center">
1183
- <input type="text" value="${esc(w.url)}" placeholder="https://hooks.slack.com/services/..." style="flex:1;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px 12px;color:var(--text);font-size:13px;outline:none;font-family:monospace" data-idx="${i}">
1184
- <button style="background:none;border:1px solid var(--border);border-radius:6px;color:var(--text-dim);cursor:pointer;padding:6px 10px;font-size:14px;transition:color 0.15s" data-rm="${i}">&times;</button>
1185
- </div>`).join('');
1186
- el.querySelectorAll('input').forEach(inp => {
1187
- inp.addEventListener('input', () => { webhookData[parseInt(inp.dataset.idx)].url = inp.value; });
1188
- inp.addEventListener('focus', () => { inp.style.borderColor = 'var(--accent)'; });
1189
- inp.addEventListener('blur', () => { inp.style.borderColor = 'var(--border)'; });
1190
- });
1191
- el.querySelectorAll('[data-rm]').forEach(btn => {
1192
- btn.addEventListener('click', () => {
1193
- webhookData.splice(parseInt(btn.dataset.rm), 1);
1194
- renderWebhooks();
1195
- });
1196
- btn.addEventListener('mouseenter', () => { btn.style.color = 'var(--red)'; });
1197
- btn.addEventListener('mouseleave', () => { btn.style.color = 'var(--text-dim)'; });
1198
- });
1199
- }
1200
-
1201
- // Refresh relative times every 30s
1202
- setInterval(() => { renderMessages(); renderNotifications(); }, 30000);
1203
-
1204
- // Mobile tabs
1205
- document.querySelectorAll('#mobileTabs button').forEach(btn => {
1206
- btn.addEventListener('click', () => {
1207
- document.querySelectorAll('#mobileTabs button').forEach(b => b.classList.remove('active'));
1208
- btn.classList.add('active');
1209
- const tab = btn.dataset.tab;
1210
- document.querySelectorAll('.sessions-panel, .messages-panel, .notifications-panel').forEach(p => p.classList.remove('mobile-active'));
1211
- document.querySelector(`.${tab === 'sessions' ? 'sessions' : tab === 'messages' ? 'messages' : 'notifications'}-panel`).classList.add('mobile-active');
1212
- });
1213
- });
1214
- // Default mobile tab
1215
- if (window.innerWidth <= 768) {
1216
- document.querySelector('.sessions-panel').classList.add('mobile-active');
1217
- }
1218
-
1219
- state.token = getToken();
1220
- connect();
1221
- })();
1222
- </script>
1223
- </body>
1224
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Claude Alarm - Dashboard</title>
7
+ <style>
8
+ :root, [data-theme="dark"] {
9
+ --bg: #0f1117;
10
+ --surface: #1a1d27;
11
+ --border: #2a2d3a;
12
+ --text: #e1e4ed;
13
+ --text-dim: #8b8fa3;
14
+ --accent: #e0a86d;
15
+ --accent-dim: #c48d52;
16
+ --green: #3dd68c;
17
+ --yellow: #f59e0b;
18
+ --red: #ef4444;
19
+ --blue: #60a5fa;
20
+ }
21
+ [data-theme="light"] {
22
+ --bg: #f5f5f7;
23
+ --surface: #ffffff;
24
+ --border: #d1d5db;
25
+ --text: #1f2937;
26
+ --text-dim: #6b7280;
27
+ --accent: #c48d52;
28
+ --accent-dim: #a87642;
29
+ --green: #22c55e;
30
+ --yellow: #d97706;
31
+ --red: #ef4444;
32
+ --blue: #3b82f6;
33
+ }
34
+ * { margin: 0; padding: 0; box-sizing: border-box; }
35
+ body {
36
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
37
+ background: var(--bg);
38
+ color: var(--text);
39
+ height: 100vh;
40
+ overflow: hidden;
41
+ }
42
+ /* Hide scrollbars */
43
+ ::-webkit-scrollbar { display: none; }
44
+ * { -ms-overflow-style: none; scrollbar-width: none; }
45
+
46
+ header {
47
+ border-bottom: 1px solid var(--border);
48
+ padding: 16px 24px;
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: space-between;
52
+ }
53
+ header h1 { font-size: 18px; font-weight: 600; }
54
+ .header-right {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 12px;
58
+ }
59
+ .theme-toggle {
60
+ background: var(--surface);
61
+ border: 1px solid var(--border);
62
+ border-radius: 6px;
63
+ padding: 4px 10px;
64
+ cursor: pointer;
65
+ font-size: 16px;
66
+ line-height: 1;
67
+ color: var(--text);
68
+ }
69
+ .theme-toggle:hover { border-color: var(--accent); }
70
+ .status-badge {
71
+ display: inline-flex;
72
+ align-items: center;
73
+ gap: 6px;
74
+ font-size: 13px;
75
+ color: var(--text-dim);
76
+ }
77
+ .status-dot {
78
+ width: 8px; height: 8px;
79
+ border-radius: 50%;
80
+ background: var(--red);
81
+ }
82
+ .status-dot.connected { background: var(--green); }
83
+
84
+ .container {
85
+ display: grid;
86
+ grid-template-columns: 300px 1fr 320px;
87
+ height: calc(100vh - 57px);
88
+ }
89
+
90
+ /* Sessions panel */
91
+ .sessions-panel {
92
+ border-right: 1px solid var(--border);
93
+ overflow-y: auto;
94
+ padding: 12px;
95
+ }
96
+ .sessions-header {
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: space-between;
100
+ padding: 8px 8px 12px;
101
+ position: sticky;
102
+ top: 0;
103
+ background: var(--bg);
104
+ z-index: 1;
105
+ }
106
+ .sessions-header h2 {
107
+ font-size: 13px;
108
+ text-transform: uppercase;
109
+ letter-spacing: 0.5px;
110
+ color: var(--text-dim);
111
+ }
112
+ .add-session-btn {
113
+ background: none;
114
+ border: 1px solid var(--border);
115
+ border-radius: 6px;
116
+ color: var(--text-dim);
117
+ cursor: pointer;
118
+ font-size: 16px;
119
+ width: 28px;
120
+ height: 28px;
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: center;
124
+ transition: border-color 0.15s, color 0.15s;
125
+ }
126
+ .add-session-btn:hover { border-color: var(--accent); color: var(--accent); }
127
+ .cmd-popup {
128
+ position: absolute;
129
+ top: 44px;
130
+ left: 12px;
131
+ right: 12px;
132
+ background: var(--surface);
133
+ border: 1px solid var(--border);
134
+ border-radius: 8px;
135
+ padding: 12px;
136
+ z-index: 10;
137
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
138
+ display: none;
139
+ }
140
+ .cmd-popup.active { display: block; }
141
+ .cmd-popup-title {
142
+ font-size: 12px;
143
+ color: var(--text-dim);
144
+ margin-bottom: 8px;
145
+ }
146
+ .session-card {
147
+ background: var(--surface);
148
+ border: 1px solid var(--border);
149
+ border-radius: 8px;
150
+ padding: 12px;
151
+ margin-bottom: 8px;
152
+ cursor: pointer;
153
+ transition: border-color 0.15s;
154
+ }
155
+ .session-card:hover, .session-card.active {
156
+ border-color: var(--accent);
157
+ }
158
+ .session-name {
159
+ font-size: 14px;
160
+ font-weight: 500;
161
+ margin-bottom: 4px;
162
+ display: flex;
163
+ align-items: center;
164
+ gap: 6px;
165
+ }
166
+ .unread-badge {
167
+ background: var(--accent);
168
+ color: #fff;
169
+ font-size: 10px;
170
+ font-weight: 600;
171
+ min-width: 18px;
172
+ height: 18px;
173
+ border-radius: 9px;
174
+ display: flex;
175
+ align-items: center;
176
+ justify-content: center;
177
+ padding: 0 5px;
178
+ }
179
+ .session-status {
180
+ display: inline-block;
181
+ font-size: 11px;
182
+ padding: 2px 8px;
183
+ border-radius: 10px;
184
+ margin-top: 6px;
185
+ font-weight: 500;
186
+ }
187
+ .session-status.idle { background: rgba(139,143,163,0.15); color: var(--text-dim); }
188
+ .session-status.working { background: rgba(96,165,250,0.15); color: var(--blue); }
189
+ .session-status.waiting_input { background: rgba(245,197,66,0.15); color: var(--yellow); }
190
+ .session-cwd {
191
+ font-size: 11px;
192
+ color: var(--text-dim);
193
+ font-family: monospace;
194
+ margin-top: 4px;
195
+ overflow: hidden;
196
+ text-overflow: ellipsis;
197
+ white-space: nowrap;
198
+ }
199
+ .no-sessions {
200
+ color: var(--text-dim);
201
+ font-size: 13px;
202
+ text-align: center;
203
+ padding: 40px 20px;
204
+ }
205
+ .cmd-copy {
206
+ display: flex;
207
+ align-items: center;
208
+ background: var(--bg);
209
+ border: 1px solid var(--border);
210
+ border-radius: 6px;
211
+ padding: 6px 10px;
212
+ margin-top: 12px;
213
+ font-size: 11px;
214
+ font-family: monospace;
215
+ cursor: pointer;
216
+ transition: border-color 0.15s;
217
+ text-align: left;
218
+ gap: 6px;
219
+ }
220
+ .cmd-copy:hover { border-color: var(--accent); }
221
+ .cmd-copy .cmd-text { flex: 1; color: var(--text); word-break: break-all; }
222
+ .cmd-copy .cmd-icon { color: var(--text-dim); font-size: 14px; flex-shrink: 0; }
223
+ .cmd-copy.copied { border-color: var(--green); }
224
+ .cmd-copy.copied .cmd-icon { color: var(--green); }
225
+
226
+ /* Messages panel */
227
+ .messages-panel {
228
+ display: flex;
229
+ flex-direction: column;
230
+ overflow: hidden;
231
+ min-height: 0;
232
+ }
233
+ .messages-header {
234
+ padding: 12px 20px;
235
+ border-bottom: 1px solid var(--border);
236
+ font-size: 14px;
237
+ font-weight: 500;
238
+ }
239
+ .messages-list {
240
+ flex: 1;
241
+ overflow-y: auto;
242
+ min-height: 0;
243
+ padding: 16px 20px;
244
+ }
245
+ .message {
246
+ margin-bottom: 12px;
247
+ padding: 10px 14px;
248
+ border-radius: 8px;
249
+ font-size: 13px;
250
+ line-height: 1.5;
251
+ max-width: 80%;
252
+ }
253
+ .message.from-session {
254
+ background: var(--surface);
255
+ border: 1px solid var(--border);
256
+ }
257
+ .message.from-dashboard {
258
+ background: rgba(224,168,109,0.12);
259
+ border: 1px solid rgba(224,168,109,0.25);
260
+ margin-left: auto;
261
+ }
262
+ .message-meta {
263
+ font-size: 11px;
264
+ color: var(--text-dim);
265
+ margin-bottom: 4px;
266
+ }
267
+ .message-body img {
268
+ max-width: 100%;
269
+ border-radius: 6px;
270
+ margin-top: 4px;
271
+ }
272
+
273
+ /* Typing indicator */
274
+ .typing-indicator {
275
+ display: none;
276
+ padding: 10px 14px;
277
+ margin-bottom: 12px;
278
+ max-width: 80px;
279
+ }
280
+ .typing-indicator.active { display: block; }
281
+ .typing-dots {
282
+ display: flex;
283
+ gap: 4px;
284
+ align-items: center;
285
+ }
286
+ .typing-dots span {
287
+ width: 6px;
288
+ height: 6px;
289
+ border-radius: 50%;
290
+ background: var(--text-dim);
291
+ animation: typing 1.4s infinite ease-in-out;
292
+ }
293
+ .typing-dots span:nth-child(2) { animation-delay: 0.2s; }
294
+ .typing-dots span:nth-child(3) { animation-delay: 0.4s; }
295
+ @keyframes typing {
296
+ 0%, 60%, 100% { opacity: 0.3; transform: scale(1); }
297
+ 30% { opacity: 1; transform: scale(1.2); }
298
+ }
299
+
300
+ /* Scroll to bottom button */
301
+ .scroll-bottom {
302
+ position: absolute;
303
+ bottom: 80px;
304
+ right: 30px;
305
+ width: 36px;
306
+ height: 36px;
307
+ border-radius: 50%;
308
+ background: var(--surface);
309
+ border: 1px solid var(--border);
310
+ color: var(--text);
311
+ cursor: pointer;
312
+ font-size: 16px;
313
+ display: none;
314
+ align-items: center;
315
+ justify-content: center;
316
+ z-index: 5;
317
+ transition: border-color 0.15s;
318
+ }
319
+ .scroll-bottom:hover { border-color: var(--accent); }
320
+ .scroll-bottom.visible { display: flex; }
321
+
322
+ /* Image preview */
323
+ .image-preview {
324
+ border-top: 1px solid var(--border);
325
+ padding: 8px 20px;
326
+ display: none;
327
+ align-items: center;
328
+ gap: 8px;
329
+ background: var(--surface);
330
+ }
331
+ .image-preview.active { display: flex; }
332
+ .image-preview img {
333
+ max-height: 60px;
334
+ border-radius: 4px;
335
+ border: 1px solid var(--border);
336
+ }
337
+ .image-preview .preview-name {
338
+ flex: 1;
339
+ font-size: 12px;
340
+ color: var(--text-dim);
341
+ overflow: hidden;
342
+ text-overflow: ellipsis;
343
+ white-space: nowrap;
344
+ }
345
+ .image-preview .preview-cancel {
346
+ background: none;
347
+ border: none;
348
+ color: var(--red);
349
+ cursor: pointer;
350
+ font-size: 18px;
351
+ padding: 2px 6px;
352
+ }
353
+
354
+ /* Drag overlay */
355
+ .drag-overlay {
356
+ position: absolute;
357
+ inset: 0;
358
+ background: rgba(224,168,109,0.15);
359
+ border: 2px dashed var(--accent);
360
+ border-radius: 8px;
361
+ display: none;
362
+ align-items: center;
363
+ justify-content: center;
364
+ font-size: 16px;
365
+ color: var(--accent);
366
+ z-index: 10;
367
+ pointer-events: none;
368
+ }
369
+ .drag-overlay.active { display: flex; }
370
+
371
+ .message-input-area {
372
+ border-top: 1px solid var(--border);
373
+ padding: 12px 20px;
374
+ display: flex;
375
+ gap: 8px;
376
+ align-items: flex-end;
377
+ }
378
+ .message-input-area textarea {
379
+ flex: 1;
380
+ background: var(--surface);
381
+ border: 1px solid var(--border);
382
+ border-radius: 6px;
383
+ padding: 8px 12px;
384
+ color: var(--text);
385
+ font-size: 13px;
386
+ outline: none;
387
+ resize: none;
388
+ min-height: 36px;
389
+ max-height: 120px;
390
+ font-family: inherit;
391
+ line-height: 1.4;
392
+ }
393
+ .message-input-area textarea:focus { border-color: var(--accent); }
394
+ .message-input-area button {
395
+ background: var(--accent);
396
+ color: white;
397
+ border: none;
398
+ border-radius: 6px;
399
+ padding: 8px 12px;
400
+ cursor: pointer;
401
+ font-size: 13px;
402
+ font-weight: 500;
403
+ min-height: 36px;
404
+ }
405
+ .message-input-area button:hover { background: var(--accent-dim); }
406
+ .message-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
407
+ .attach-btn {
408
+ background: var(--surface) !important;
409
+ border: 1px solid var(--border) !important;
410
+ color: var(--text) !important;
411
+ font-size: 18px !important;
412
+ padding: 6px 10px !important;
413
+ }
414
+ .attach-btn:hover { border-color: var(--accent) !important; }
415
+ .attach-btn:disabled { display: none !important; }
416
+
417
+ /* Notifications panel */
418
+ .notifications-panel {
419
+ border-left: 1px solid var(--border);
420
+ overflow-y: auto;
421
+ padding: 12px;
422
+ }
423
+ .notifications-panel > .notif-header + #notifList { /* spacer */ }
424
+ .notif-item {
425
+ background: var(--surface);
426
+ border: 1px solid var(--border);
427
+ border-radius: 8px;
428
+ padding: 10px 12px;
429
+ margin-bottom: 8px;
430
+ font-size: 13px;
431
+ cursor: pointer;
432
+ }
433
+ .notif-item:hover { border-color: var(--accent); }
434
+ .notif-item:hover .notif-dismiss { opacity: 1; }
435
+ .notif-item .notif-title { font-weight: 500; margin-bottom: 2px; display: flex; align-items: center; }
436
+ .notif-item .notif-title-text { flex: 1; }
437
+ .notif-dismiss {
438
+ opacity: 0;
439
+ background: none;
440
+ border: none;
441
+ color: var(--text-dim);
442
+ cursor: pointer;
443
+ font-size: 14px;
444
+ padding: 0 2px;
445
+ transition: opacity 0.15s;
446
+ }
447
+ .notif-dismiss:hover { color: var(--red); }
448
+ .notif-item .notif-message { color: var(--text-dim); }
449
+ .notif-item .notif-time { font-size: 11px; color: var(--text-dim); margin-top: 4px; }
450
+ .notif-header {
451
+ display: flex;
452
+ align-items: center;
453
+ justify-content: space-between;
454
+ padding: 8px 8px 12px;
455
+ position: sticky;
456
+ top: 0;
457
+ background: var(--bg);
458
+ z-index: 1;
459
+ }
460
+ .notif-header h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); padding: 0; }
461
+ .notif-clear-all {
462
+ background: none;
463
+ border: 1px solid var(--accent);
464
+ border-radius: 4px;
465
+ color: var(--accent);
466
+ cursor: pointer;
467
+ font-size: 11px;
468
+ padding: 2px 8px;
469
+ }
470
+ .notif-clear-all:hover { border-color: var(--red); color: var(--red); }
471
+ .notif-level {
472
+ display: inline-block;
473
+ width: 6px; height: 6px;
474
+ border-radius: 50%;
475
+ margin-right: 6px;
476
+ }
477
+ .notif-level.info { background: var(--blue); }
478
+ .notif-level.success { background: var(--green); }
479
+ .notif-level.warning { background: var(--yellow); }
480
+ .notif-level.error { background: var(--red); }
481
+
482
+ .message-body h2, .message-body h3, .message-body h4 { margin: 8px 0 4px; font-size: 14px; }
483
+ .message-body h2 { font-size: 16px; }
484
+ .message-body h3 { font-size: 15px; }
485
+ .message-body pre {
486
+ background: var(--bg);
487
+ border: 1px solid var(--border);
488
+ border-radius: 6px;
489
+ padding: 8px 10px;
490
+ overflow-x: auto;
491
+ margin: 6px 0;
492
+ font-size: 12px;
493
+ }
494
+ .message-body code {
495
+ background: var(--bg);
496
+ padding: 1px 4px;
497
+ border-radius: 3px;
498
+ font-size: 12px;
499
+ font-family: monospace;
500
+ }
501
+ .message-body pre code { background: none; padding: 0; }
502
+ .message-body table { border-collapse: collapse; margin: 6px 0; font-size: 12px; width: 100%; }
503
+ .message-body th, .message-body td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; }
504
+ .message-body th { background: var(--bg); font-weight: 600; }
505
+ .message-body ul { margin: 4px 0; padding-left: 20px; }
506
+ .message-body li { margin: 2px 0; }
507
+ .message-body strong { font-weight: 600; }
508
+
509
+ .empty-state {
510
+ color: var(--text-dim);
511
+ font-size: 13px;
512
+ text-align: center;
513
+ padding: 40px 20px;
514
+ }
515
+
516
+ /* Token auth overlay */
517
+ .token-overlay {
518
+ position: fixed;
519
+ inset: 0;
520
+ background: rgba(0,0,0,0.8);
521
+ display: flex;
522
+ align-items: center;
523
+ justify-content: center;
524
+ z-index: 100;
525
+ }
526
+ .token-overlay.hidden { display: none; }
527
+ .token-form {
528
+ background: var(--surface);
529
+ border: 1px solid var(--border);
530
+ border-radius: 12px;
531
+ padding: 32px;
532
+ max-width: 400px;
533
+ width: 90%;
534
+ text-align: center;
535
+ }
536
+ .token-form h2 { font-size: 18px; margin-bottom: 8px; }
537
+ .token-form p { font-size: 13px; color: var(--text-dim); margin-bottom: 20px; }
538
+ .token-form input {
539
+ width: 100%;
540
+ background: var(--bg);
541
+ border: 1px solid var(--border);
542
+ border-radius: 6px;
543
+ padding: 10px 12px;
544
+ color: var(--text);
545
+ font-size: 14px;
546
+ font-family: monospace;
547
+ outline: none;
548
+ margin-bottom: 12px;
549
+ }
550
+ .token-form input:focus { border-color: var(--accent); }
551
+ .token-form button {
552
+ background: var(--accent);
553
+ color: white;
554
+ border: none;
555
+ border-radius: 6px;
556
+ padding: 10px 24px;
557
+ cursor: pointer;
558
+ font-size: 14px;
559
+ font-weight: 500;
560
+ }
561
+ .token-form button:hover { background: var(--accent-dim); }
562
+ .token-error { color: var(--red); font-size: 12px; margin-top: 8px; display: none; }
563
+
564
+ /* Mobile tabs */
565
+ .mobile-tabs {
566
+ display: none;
567
+ border-bottom: 1px solid var(--border);
568
+ }
569
+ .mobile-tabs button {
570
+ flex: 1;
571
+ background: none;
572
+ border: none;
573
+ border-bottom: 2px solid transparent;
574
+ color: var(--text-dim);
575
+ padding: 10px;
576
+ font-size: 13px;
577
+ cursor: pointer;
578
+ }
579
+ .mobile-tabs button.active {
580
+ color: var(--accent);
581
+ border-bottom-color: var(--accent);
582
+ }
583
+
584
+ /* Mobile responsive */
585
+ @media (max-width: 768px) {
586
+ .container {
587
+ grid-template-columns: 1fr;
588
+ height: calc(100vh - 97px);
589
+ }
590
+ .sessions-panel, .messages-panel, .notifications-panel {
591
+ border: none;
592
+ display: none;
593
+ }
594
+ .sessions-panel.mobile-active,
595
+ .messages-panel.mobile-active,
596
+ .notifications-panel.mobile-active {
597
+ display: flex;
598
+ flex-direction: column;
599
+ }
600
+ .sessions-panel.mobile-active {
601
+ display: block;
602
+ overflow-y: auto;
603
+ }
604
+ .notifications-panel.mobile-active {
605
+ display: block;
606
+ overflow-y: auto;
607
+ }
608
+ .mobile-tabs { display: flex; }
609
+ .message { max-width: 95%; }
610
+ }
611
+ </style>
612
+ </head>
613
+ <body>
614
+ <header>
615
+ <h1>Claude Alarm</h1>
616
+ <div class="header-right">
617
+ <button class="theme-toggle" id="settingsToggle" title="Notification settings">&#9881;</button>
618
+ <button class="theme-toggle" id="themeToggle" title="Toggle theme">&#9790;</button>
619
+ <div class="status-badge">
620
+ <span class="status-dot" id="connDot"></span>
621
+ <span id="connLabel">Connecting...</span>
622
+ </div>
623
+ </div>
624
+ </header>
625
+
626
+ <div class="token-overlay hidden" id="tokenOverlay">
627
+ <div class="token-form">
628
+ <h2>Authentication Required</h2>
629
+ <p>Enter the hub token to connect. Find it by running: <code>claude-alarm token</code></p>
630
+ <input type="text" id="tokenInput" placeholder="Paste token here..." autocomplete="off">
631
+ <button id="tokenSubmit">Connect</button>
632
+ <div class="token-error" id="tokenError">Connection failed. Check your token.</div>
633
+ </div>
634
+ </div>
635
+
636
+ <div class="token-overlay hidden" id="settingsOverlay">
637
+ <div class="token-form" style="max-width:520px;text-align:left">
638
+ <h2 style="text-align:center;margin-bottom:12px">Notification Settings</h2>
639
+ <div style="display:flex;border-bottom:1px solid var(--border);margin-bottom:16px">
640
+ <button class="settings-tab active" data-settings-tab="webhook" style="flex:1;padding:10px;background:none;border:none;border-bottom:2px solid var(--accent);color:var(--text);cursor:pointer;font-size:13px;font-weight:600;transition:all 0.15s">Webhook</button>
641
+ <button class="settings-tab" data-settings-tab="telegram" style="flex:1;padding:10px;background:none;border:none;border-bottom:2px solid transparent;color:var(--text-dim);cursor:pointer;font-size:13px;font-weight:500;transition:all 0.15s">Telegram</button>
642
+ </div>
643
+ <div id="settingsWebhook">
644
+ <p style="margin-bottom:12px;font-size:12px;color:var(--text-dim)">Send notifications to Slack, Discord, or any webhook endpoint.</p>
645
+ <div id="webhookList" style="max-height:200px;overflow-y:auto"></div>
646
+ <button id="webhookAdd" style="width:100%;margin-top:10px;background:none;color:var(--text-dim);border:1px dashed var(--border);border-radius:6px;padding:10px;cursor:pointer;font-size:13px;transition:border-color 0.15s">+ Add Webhook</button>
647
+ <div style="display:flex;gap:8px;margin-top:16px">
648
+ <button id="webhookSave" style="flex:1;padding:10px">Save</button>
649
+ <button id="settingsCancel1" style="flex:1;padding:10px;background:none;color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:14px;font-weight:500">Cancel</button>
650
+ </div>
651
+ </div>
652
+ <div id="settingsTelegram" style="display:none">
653
+ <p style="margin-bottom:12px;font-size:12px;color:var(--text-dim)">Connect a Telegram bot to receive notifications and send messages.</p>
654
+ <div style="margin-bottom:12px">
655
+ <label style="display:block;font-size:12px;color:var(--text-dim);margin-bottom:4px">Bot Token</label>
656
+ <input type="text" id="tgBotToken" placeholder="123456:ABC-DEF..." autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;font-family:monospace">
657
+ </div>
658
+ <div style="margin-bottom:12px">
659
+ <label style="display:block;font-size:12px;color:var(--text-dim);margin-bottom:4px">Chat ID</label>
660
+ <input type="text" id="tgChatId" placeholder="-1001234567890" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;font-family:monospace">
661
+ </div>
662
+ <div style="margin-bottom:16px;display:flex;align-items:center;gap:8px">
663
+ <input type="checkbox" id="tgEnabled" style="width:16px;height:16px;accent-color:var(--accent)">
664
+ <label for="tgEnabled" style="font-size:13px;color:var(--text)">Enable Telegram notifications</label>
665
+ </div>
666
+ <div id="tgStatus" style="display:none;margin-bottom:12px;padding:8px 12px;border-radius:6px;font-size:12px"></div>
667
+ <div style="display:flex;gap:8px">
668
+ <button id="tgTest" style="flex:1;padding:10px;background:none;color:var(--accent);border:1px solid var(--accent);border-radius:6px;cursor:pointer;font-size:13px;font-weight:500">Test</button>
669
+ <button id="tgSave" style="flex:1;padding:10px">Save</button>
670
+ <button id="settingsCancel2" style="flex:1;padding:10px;background:none;color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:14px;font-weight:500">Cancel</button>
671
+ </div>
672
+ </div>
673
+ </div>
674
+ </div>
675
+
676
+ <div class="mobile-tabs" id="mobileTabs">
677
+ <button class="active" data-tab="sessions">Sessions</button>
678
+ <button data-tab="messages">Messages</button>
679
+ <button data-tab="notifications">Notifications</button>
680
+ </div>
681
+ <div class="container">
682
+ <div class="sessions-panel" style="position:relative">
683
+ <div class="sessions-header">
684
+ <h2>Sessions</h2>
685
+ <button class="add-session-btn" id="addSessionBtn" title="Add session">+</button>
686
+ </div>
687
+ <div class="cmd-popup" id="cmdPopup">
688
+ <div class="cmd-popup-title">Run in terminal to connect:</div>
689
+ <div class="cmd-copy" id="cmdCopy1" title="Click to copy">
690
+ <span class="cmd-text">claude --dangerously-load-development-channels server:claude-alarm</span>
691
+ <span class="cmd-icon">&#128203;</span>
692
+ </div>
693
+ <div style="margin-top:6px;font-size:11px;color:var(--text-dim)">with auto-approve:</div>
694
+ <div class="cmd-copy" id="cmdCopy2" title="Click to copy">
695
+ <span class="cmd-text">claude --dangerously-load-development-channels server:claude-alarm --dangerously-skip-permissions</span>
696
+ <span class="cmd-icon">&#128203;</span>
697
+ </div>
698
+ </div>
699
+ <div id="sessionsList"></div>
700
+ </div>
701
+
702
+ <div class="messages-panel" style="position:relative">
703
+ <div class="messages-header" id="messagesHeader">Select a session</div>
704
+ <div class="messages-list" id="messagesList">
705
+ <div class="empty-state">Select a session to view messages</div>
706
+ </div>
707
+ <button class="scroll-bottom" id="scrollBottom" title="Scroll to bottom">&#8595;</button>
708
+ <div class="drag-overlay" id="dragOverlay">Drop image here</div>
709
+ <div class="image-preview" id="imagePreview">
710
+ <img id="previewImg" src="" alt="preview">
711
+ <span class="preview-name" id="previewName"></span>
712
+ <button class="preview-cancel" id="previewCancel">&times;</button>
713
+ </div>
714
+ <div class="message-input-area">
715
+ <button class="attach-btn" id="attachBtn" disabled title="Attach image">&#128206;</button>
716
+ <input type="file" id="fileInput" accept="image/*" style="display:none">
717
+ <textarea id="msgInput" placeholder="Send a message to session... (Shift+Enter for new line)" disabled rows="1"></textarea>
718
+ <button id="sendBtn" disabled>Send</button>
719
+ </div>
720
+ </div>
721
+
722
+ <div class="notifications-panel">
723
+ <div class="notif-header">
724
+ <h2>Notifications</h2>
725
+ <button class="notif-clear-all" id="notifClearAll">Clear all</button>
726
+ </div>
727
+ <div id="notifList">
728
+ <div class="empty-state">No notifications yet</div>
729
+ </div>
730
+ </div>
731
+ </div>
732
+
733
+ <script>
734
+ (function() {
735
+ const state = {
736
+ ws: null,
737
+ sessions: {},
738
+ selectedSession: null,
739
+ messages: {},
740
+ notifications: [],
741
+ token: null,
742
+ pendingImage: null,
743
+ unread: {},
744
+ waitingReply: {},
745
+ };
746
+
747
+ const $ = (sel) => document.querySelector(sel);
748
+
749
+ // --- Theme ---
750
+ function initTheme() {
751
+ const saved = localStorage.getItem('claude-alarm-theme') || 'dark';
752
+ document.documentElement.setAttribute('data-theme', saved);
753
+ updateThemeIcon(saved);
754
+ }
755
+ function toggleTheme() {
756
+ const current = document.documentElement.getAttribute('data-theme');
757
+ const next = current === 'dark' ? 'light' : 'dark';
758
+ document.documentElement.setAttribute('data-theme', next);
759
+ localStorage.setItem('claude-alarm-theme', next);
760
+ updateThemeIcon(next);
761
+ }
762
+ function updateThemeIcon(theme) {
763
+ $('#themeToggle').innerHTML = theme === 'dark' ? '&#9790;' : '&#9728;';
764
+ }
765
+ $('#themeToggle').addEventListener('click', toggleTheme);
766
+ initTheme();
767
+
768
+ // --- Token handling ---
769
+ function getToken() {
770
+ const params = new URLSearchParams(location.search);
771
+ const urlToken = params.get('token');
772
+ if (urlToken) {
773
+ sessionStorage.setItem('claude-alarm-token', urlToken);
774
+ return urlToken;
775
+ }
776
+ return sessionStorage.getItem('claude-alarm-token');
777
+ }
778
+
779
+ function showTokenForm() { $('#tokenOverlay').classList.remove('hidden'); }
780
+ function hideTokenForm() { $('#tokenOverlay').classList.add('hidden'); }
781
+
782
+ $('#tokenSubmit').addEventListener('click', () => {
783
+ const token = $('#tokenInput').value.trim();
784
+ if (!token) return;
785
+ state.token = token;
786
+ sessionStorage.setItem('claude-alarm-token', token);
787
+ $('#tokenError').style.display = 'none';
788
+ hideTokenForm();
789
+ connect();
790
+ });
791
+ $('#tokenInput').addEventListener('keydown', (e) => {
792
+ if (e.key === 'Enter') $('#tokenSubmit').click();
793
+ });
794
+
795
+ // --- WebSocket ---
796
+ function connect() {
797
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
798
+ const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
799
+ const ws = new WebSocket(`${proto}://${location.host}/ws/dashboard${tokenQuery}`);
800
+
801
+ ws.onopen = () => {
802
+ state.ws = ws;
803
+ hideTokenForm();
804
+ $('#connDot').classList.add('connected');
805
+ $('#connLabel').textContent = 'Connected';
806
+ };
807
+ ws.onclose = () => {
808
+ state.ws = null;
809
+ $('#connDot').classList.remove('connected');
810
+ $('#connLabel').textContent = 'Disconnected';
811
+ setTimeout(connect, 3000);
812
+ };
813
+ ws.onerror = () => {
814
+ if (!state.token) { showTokenForm(); } else { $('#tokenError').style.display = 'block'; showTokenForm(); }
815
+ ws.close();
816
+ };
817
+ ws.onmessage = (e) => { try { handleMessage(JSON.parse(e.data)); } catch {} };
818
+ }
819
+
820
+ function handleMessage(msg) {
821
+ switch (msg.type) {
822
+ case 'sessions_list':
823
+ state.sessions = {};
824
+ msg.sessions.forEach(s => { state.sessions[s.id] = s; });
825
+ renderSessions();
826
+ break;
827
+ case 'session_connected':
828
+ state.sessions[msg.session.id] = msg.session;
829
+ if (!state.messages[msg.session.id]) state.messages[msg.session.id] = [];
830
+ renderSessions();
831
+ break;
832
+ case 'session_disconnected':
833
+ delete state.sessions[msg.sessionId];
834
+ if (state.selectedSession === msg.sessionId) { state.selectedSession = null; renderMessages(); }
835
+ renderSessions();
836
+ break;
837
+ case 'session_updated':
838
+ state.sessions[msg.session.id] = msg.session;
839
+ renderSessions();
840
+ break;
841
+ case 'reply_from_session':
842
+ if (!state.messages[msg.sessionId]) state.messages[msg.sessionId] = [];
843
+ state.messages[msg.sessionId].push({ from: 'session', content: msg.content, time: msg.timestamp });
844
+ state.waitingReply[msg.sessionId] = false;
845
+ if (state.selectedSession === msg.sessionId) { renderMessages(); }
846
+ else { state.unread[msg.sessionId] = (state.unread[msg.sessionId] || 0) + 1; renderSessions(); }
847
+ state.notifications.unshift({ sessionId: msg.sessionId, title: 'Reply', message: msg.content.slice(0, 100), level: 'info', time: msg.timestamp });
848
+ renderNotifications();
849
+ break;
850
+ case 'notification':
851
+ state.notifications.unshift({ sessionId: msg.sessionId, title: msg.title, message: msg.message, level: msg.level || 'info', time: msg.timestamp });
852
+ renderNotifications();
853
+ break;
854
+ }
855
+ }
856
+
857
+ // --- Render ---
858
+ function renderSessions() {
859
+ const el = $('#sessionsList');
860
+ const ids = Object.keys(state.sessions);
861
+ if (!ids.length) {
862
+ el.innerHTML = '<div class="no-sessions">No active sessions.<br>Click + to see connection commands.</div>';
863
+ return;
864
+ }
865
+ el.innerHTML = ids.map(id => {
866
+ const s = state.sessions[id];
867
+ const active = state.selectedSession === id ? ' active' : '';
868
+ const cwdDisplay = s.cwd ? s.cwd.replace(/^.*[/\\]/, '') : '';
869
+ const unread = state.unread[id] || 0;
870
+ return `<div class="session-card${active}" data-id="${id}">
871
+ <div class="session-name">${esc(s.name)}${unread ? `<span class="unread-badge">${unread}</span>` : ''}</div>
872
+ ${cwdDisplay ? `<div class="session-cwd" title="${esc(s.cwd)}">${esc(cwdDisplay)}</div>` : ''}
873
+ <span class="session-status ${s.status}">${s.status.replace('_', ' ')}</span>
874
+ </div>`;
875
+ }).join('');
876
+ el.querySelectorAll('.session-card').forEach(card => {
877
+ card.addEventListener('click', () => selectSession(card.dataset.id));
878
+ });
879
+ updateImageUI();
880
+ }
881
+
882
+ function selectSession(id) {
883
+ state.selectedSession = id;
884
+ state.unread[id] = 0;
885
+ if (!state.messages[id]) state.messages[id] = [];
886
+ $('#msgInput').disabled = false;
887
+ $('#sendBtn').disabled = false;
888
+ $('#msgInput').placeholder = 'Send a message to session... (Shift+Enter for new line)';
889
+ renderSessions();
890
+ renderMessages();
891
+ updateImageUI();
892
+ }
893
+
894
+ function updateImageUI() {
895
+ const s = state.selectedSession ? state.sessions[state.selectedSession] : null;
896
+ const canImage = s && s.isLocal;
897
+ $('#attachBtn').disabled = !canImage;
898
+ }
899
+
900
+ function renderMessages() {
901
+ const el = $('#messagesList');
902
+ const header = $('#messagesHeader');
903
+
904
+ if (!state.selectedSession) {
905
+ header.textContent = 'Select a session';
906
+ el.innerHTML = '<div class="empty-state">Select a session to view messages</div>';
907
+ $('#msgInput').disabled = true;
908
+ $('#sendBtn').disabled = true;
909
+ return;
910
+ }
911
+
912
+ const s = state.sessions[state.selectedSession];
913
+ header.textContent = s ? s.name : state.selectedSession.slice(0, 12);
914
+
915
+ const msgs = state.messages[state.selectedSession] || [];
916
+ if (!msgs.length) {
917
+ el.innerHTML = '<div class="empty-state">No messages yet</div>';
918
+ return;
919
+ }
920
+
921
+ el.innerHTML = msgs.map(m => {
922
+ const cls = m.from === 'session' ? 'from-session' : 'from-dashboard';
923
+ const timeStr = relativeTime(m.time);
924
+ let content;
925
+ if (m.imageData) {
926
+ content = `<img src="${m.imageData}" alt="${esc(m.imageName || 'image')}">`;
927
+ if (m.content) content = esc(m.content).replace(/\n/g, '<br>') + '<br>' + content;
928
+ } else {
929
+ content = m.from === 'session' ? renderMarkdown(m.content) : esc(m.content).replace(/\n/g, '<br>');
930
+ }
931
+ return `<div class="message ${cls}">
932
+ <div class="message-meta">${m.from === 'session' ? 'Claude' : 'You'} &middot; ${timeStr}</div>
933
+ <div class="message-body">${content}</div>
934
+ </div>`;
935
+ }).join('');
936
+
937
+ if (state.waitingReply[state.selectedSession]) {
938
+ el.innerHTML += '<div class="typing-indicator active"><div class="typing-dots"><span></span><span></span><span></span></div></div>';
939
+ }
940
+ setTimeout(() => { el.scrollTop = el.scrollHeight; }, 0);
941
+ }
942
+
943
+ function renderNotifications() {
944
+ const el = $('#notifList');
945
+ const clearBtn = $('#notifClearAll');
946
+ if (!state.notifications.length) {
947
+ el.innerHTML = '<div class="empty-state">No notifications yet</div>';
948
+ clearBtn.style.display = 'none';
949
+ return;
950
+ }
951
+ clearBtn.style.display = 'block';
952
+ el.innerHTML = state.notifications.slice(0, 50).map((n, i) => {
953
+ const timeStr = relativeTime(n.time);
954
+ const session = state.sessions[n.sessionId];
955
+ const sName = session ? session.name : n.sessionId.slice(0, 8);
956
+ return `<div class="notif-item" data-session="${n.sessionId}" data-index="${i}">
957
+ <div class="notif-title"><span class="notif-level ${n.level}"></span><span class="notif-title-text">${esc(n.title)}</span><button class="notif-dismiss" data-index="${i}">&times;</button></div>
958
+ <div class="notif-message">${esc(n.message)}</div>
959
+ <div class="notif-time">${sName} &middot; ${timeStr}</div>
960
+ </div>`;
961
+ }).join('');
962
+ el.querySelectorAll('.notif-item').forEach(item => {
963
+ item.addEventListener('click', (e) => {
964
+ if (e.target.classList.contains('notif-dismiss')) return;
965
+ const sid = item.dataset.session;
966
+ if (sid && state.sessions[sid]) selectSession(sid);
967
+ });
968
+ });
969
+ el.querySelectorAll('.notif-dismiss').forEach(btn => {
970
+ btn.addEventListener('click', (e) => {
971
+ e.stopPropagation();
972
+ const idx = parseInt(btn.dataset.index);
973
+ state.notifications.splice(idx, 1);
974
+ renderNotifications();
975
+ });
976
+ });
977
+ }
978
+
979
+ // --- Image handling ---
980
+ function handleImageFile(file) {
981
+ if (!file || !file.type.startsWith('image/')) return;
982
+ if (file.size > 10 * 1024 * 1024) { alert('Image must be under 10MB'); return; }
983
+ const reader = new FileReader();
984
+ reader.onload = () => {
985
+ state.pendingImage = { data: reader.result, mimeType: file.type, name: file.name };
986
+ $('#imagePreview').classList.add('active');
987
+ $('#previewImg').src = reader.result;
988
+ $('#previewName').textContent = file.name;
989
+ };
990
+ reader.readAsDataURL(file);
991
+ }
992
+
993
+ function clearPendingImage() {
994
+ state.pendingImage = null;
995
+ $('#imagePreview').classList.remove('active');
996
+ $('#previewImg').src = '';
997
+ $('#previewName').textContent = '';
998
+ }
999
+
1000
+ $('#previewCancel').addEventListener('click', clearPendingImage);
1001
+ $('#attachBtn').addEventListener('click', () => $('#fileInput').click());
1002
+ $('#fileInput').addEventListener('change', (e) => {
1003
+ if (e.target.files[0]) handleImageFile(e.target.files[0]);
1004
+ e.target.value = '';
1005
+ });
1006
+
1007
+ // Ctrl+V paste
1008
+ document.addEventListener('paste', (e) => {
1009
+ if (!state.selectedSession) return;
1010
+ const s = state.sessions[state.selectedSession];
1011
+ if (!s || !s.isLocal) return;
1012
+ const items = e.clipboardData?.items;
1013
+ if (!items) return;
1014
+ for (const item of items) {
1015
+ if (item.type.startsWith('image/')) {
1016
+ e.preventDefault();
1017
+ handleImageFile(item.getAsFile());
1018
+ return;
1019
+ }
1020
+ }
1021
+ });
1022
+
1023
+ // Drag and drop
1024
+ const msgPanel = document.querySelector('.messages-panel');
1025
+ let dragCounter = 0;
1026
+ msgPanel.addEventListener('dragenter', (e) => {
1027
+ e.preventDefault();
1028
+ dragCounter++;
1029
+ const s = state.selectedSession ? state.sessions[state.selectedSession] : null;
1030
+ if (s && s.isLocal) $('#dragOverlay').classList.add('active');
1031
+ });
1032
+ msgPanel.addEventListener('dragleave', (e) => {
1033
+ e.preventDefault();
1034
+ dragCounter--;
1035
+ if (dragCounter <= 0) { dragCounter = 0; $('#dragOverlay').classList.remove('active'); }
1036
+ });
1037
+ msgPanel.addEventListener('dragover', (e) => e.preventDefault());
1038
+ msgPanel.addEventListener('drop', (e) => {
1039
+ e.preventDefault();
1040
+ dragCounter = 0;
1041
+ $('#dragOverlay').classList.remove('active');
1042
+ const file = e.dataTransfer?.files?.[0];
1043
+ if (file) handleImageFile(file);
1044
+ });
1045
+
1046
+ // --- Send ---
1047
+ function sendMessage() {
1048
+ const input = $('#msgInput');
1049
+ const content = input.value.trim();
1050
+ const hasImage = !!state.pendingImage;
1051
+ if (!content && !hasImage) return;
1052
+ if (!state.selectedSession || !state.ws) return;
1053
+
1054
+ // Send text
1055
+ if (content) {
1056
+ state.ws.send(JSON.stringify({ type: 'message_to_session', sessionId: state.selectedSession, content }));
1057
+ }
1058
+
1059
+ // Send image
1060
+ if (hasImage) {
1061
+ state.ws.send(JSON.stringify({
1062
+ type: 'image_upload',
1063
+ sessionId: state.selectedSession,
1064
+ imageData: state.pendingImage.data,
1065
+ mimeType: state.pendingImage.mimeType,
1066
+ originalName: state.pendingImage.name,
1067
+ }));
1068
+ }
1069
+
1070
+ // Add to local messages
1071
+ if (!state.messages[state.selectedSession]) state.messages[state.selectedSession] = [];
1072
+ state.messages[state.selectedSession].push({
1073
+ from: 'dashboard',
1074
+ content: content || '',
1075
+ imageData: hasImage ? state.pendingImage.data : null,
1076
+ imageName: hasImage ? state.pendingImage.name : null,
1077
+ time: Date.now(),
1078
+ });
1079
+
1080
+ input.value = '';
1081
+ input.style.height = 'auto';
1082
+ clearPendingImage();
1083
+ state.waitingReply[state.selectedSession] = true;
1084
+ renderMessages();
1085
+ }
1086
+
1087
+ $('#sendBtn').addEventListener('click', sendMessage);
1088
+ $('#msgInput').addEventListener('keydown', (e) => {
1089
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
1090
+ });
1091
+ $('#msgInput').addEventListener('input', function() {
1092
+ this.style.height = 'auto';
1093
+ this.style.height = Math.min(this.scrollHeight, 120) + 'px';
1094
+ });
1095
+
1096
+ function relativeTime(ts) {
1097
+ const diff = Math.floor((Date.now() - ts) / 1000);
1098
+ if (diff < 5) return 'just now';
1099
+ if (diff < 60) return diff + 's ago';
1100
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
1101
+ if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
1102
+ return new Date(ts).toLocaleDateString();
1103
+ }
1104
+
1105
+ function esc(s) {
1106
+ const d = document.createElement('div');
1107
+ d.textContent = s;
1108
+ return d.innerHTML;
1109
+ }
1110
+
1111
+ function renderMarkdown(text) {
1112
+ let html = esc(text);
1113
+ html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
1114
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
1115
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
1116
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
1117
+ html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
1118
+ html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
1119
+ html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>');
1120
+ html = html.replace(/^(\|.+\|)\n\|[-| :]+\|\n((?:\|.+\|\n?)*)/gm, (_, header, body) => {
1121
+ const ths = header.split('|').filter(c => c.trim()).map(c => `<th>${c.trim()}</th>`).join('');
1122
+ const rows = body.trim().split('\n').map(row => {
1123
+ const tds = row.split('|').filter(c => c.trim()).map(c => `<td>${c.trim()}</td>`).join('');
1124
+ return `<tr>${tds}</tr>`;
1125
+ }).join('');
1126
+ return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
1127
+ });
1128
+ html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
1129
+ html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
1130
+ html = html.replace(/\n/g, '<br>');
1131
+ html = html.replace(/<br>\s*(<\/?(?:pre|h[2-4]|ul|li|table|thead|tbody|tr))/g, '$1');
1132
+ html = html.replace(/(<\/(?:pre|h[2-4]|ul|table)>)\s*<br>/g, '$1');
1133
+ return html;
1134
+ }
1135
+
1136
+ // Clear all notifications
1137
+ $('#notifClearAll').addEventListener('click', () => {
1138
+ state.notifications = [];
1139
+ renderNotifications();
1140
+ });
1141
+
1142
+ // Scroll to bottom button
1143
+ const msgList = $('#messagesList');
1144
+ const scrollBtn = $('#scrollBottom');
1145
+ msgList.addEventListener('scroll', () => {
1146
+ const gap = msgList.scrollHeight - msgList.scrollTop - msgList.clientHeight;
1147
+ scrollBtn.classList.toggle('visible', gap > 100);
1148
+ });
1149
+ scrollBtn.addEventListener('click', () => {
1150
+ msgList.scrollTo({ top: msgList.scrollHeight, behavior: 'smooth' });
1151
+ });
1152
+
1153
+ // --- Add session popup ---
1154
+ $('#addSessionBtn').addEventListener('click', (e) => {
1155
+ e.stopPropagation();
1156
+ $('#cmdPopup').classList.toggle('active');
1157
+ });
1158
+ document.addEventListener('click', (e) => {
1159
+ if (!e.target.closest('#cmdPopup') && !e.target.closest('#addSessionBtn')) {
1160
+ $('#cmdPopup').classList.remove('active');
1161
+ }
1162
+ });
1163
+ document.querySelectorAll('#cmdPopup .cmd-copy').forEach(btn => {
1164
+ btn.addEventListener('click', () => {
1165
+ const text = btn.querySelector('.cmd-text').textContent;
1166
+ navigator.clipboard.writeText(text).then(() => {
1167
+ btn.classList.add('copied');
1168
+ btn.querySelector('.cmd-icon').innerHTML = '&#10003;';
1169
+ setTimeout(() => {
1170
+ btn.classList.remove('copied');
1171
+ btn.querySelector('.cmd-icon').innerHTML = '&#128203;';
1172
+ }, 2000);
1173
+ });
1174
+ });
1175
+ });
1176
+
1177
+ // --- Settings modal (Webhook + Telegram) ---
1178
+ let webhookData = [];
1179
+ const closeSettings = () => $('#settingsOverlay').classList.add('hidden');
1180
+
1181
+ // Tab switching
1182
+ document.querySelectorAll('.settings-tab').forEach(tab => {
1183
+ tab.addEventListener('click', () => {
1184
+ document.querySelectorAll('.settings-tab').forEach(t => {
1185
+ t.classList.remove('active');
1186
+ t.style.borderBottomColor = 'transparent';
1187
+ t.style.color = 'var(--text-dim)';
1188
+ t.style.fontWeight = '500';
1189
+ });
1190
+ tab.classList.add('active');
1191
+ tab.style.borderBottomColor = 'var(--accent)';
1192
+ tab.style.color = 'var(--text)';
1193
+ tab.style.fontWeight = '600';
1194
+ const target = tab.dataset.settingsTab;
1195
+ $('#settingsWebhook').style.display = target === 'webhook' ? 'block' : 'none';
1196
+ $('#settingsTelegram').style.display = target === 'telegram' ? 'block' : 'none';
1197
+ });
1198
+ });
1199
+
1200
+ // Open settings
1201
+ $('#settingsToggle').addEventListener('click', async () => {
1202
+ // Load webhook data
1203
+ try {
1204
+ const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
1205
+ const res = await fetch(`/api/webhooks${tokenQuery}`);
1206
+ const data = await res.json();
1207
+ webhookData = data.webhooks || [];
1208
+ } catch { webhookData = []; }
1209
+ renderWebhooks();
1210
+ // Load telegram data
1211
+ try {
1212
+ const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
1213
+ const res = await fetch(`/api/telegram${tokenQuery}`);
1214
+ const data = await res.json();
1215
+ const tg = data.telegram || {};
1216
+ if (!$('#tgBotToken').value || $('#tgBotToken').value.includes('...')) {
1217
+ $('#tgBotToken').value = tg.botToken || '';
1218
+ }
1219
+ $('#tgChatId').value = tg.chatId || '';
1220
+ $('#tgEnabled').checked = !!tg.enabled;
1221
+ } catch {}
1222
+ $('#tgStatus').style.display = 'none';
1223
+ $('#settingsOverlay').classList.remove('hidden');
1224
+ });
1225
+
1226
+ // Cancel buttons
1227
+ $('#settingsCancel1').addEventListener('click', closeSettings);
1228
+ $('#settingsCancel2').addEventListener('click', closeSettings);
1229
+
1230
+ // Webhook
1231
+ $('#webhookAdd').addEventListener('click', () => {
1232
+ webhookData.push({ url: '' });
1233
+ renderWebhooks();
1234
+ });
1235
+ $('#webhookSave').addEventListener('click', async () => {
1236
+ const valid = webhookData.filter(w => w.url.trim());
1237
+ try {
1238
+ const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
1239
+ await fetch(`/api/webhooks${tokenQuery}`, {
1240
+ method: 'POST',
1241
+ headers: { 'Content-Type': 'application/json' },
1242
+ body: JSON.stringify({ webhooks: valid }),
1243
+ });
1244
+ } catch {}
1245
+ closeSettings();
1246
+ });
1247
+ function renderWebhooks() {
1248
+ const el = $('#webhookList');
1249
+ if (!webhookData.length) {
1250
+ el.innerHTML = '<div style="text-align:center;color:var(--text-dim);padding:16px;font-size:13px">No webhooks configured</div>';
1251
+ return;
1252
+ }
1253
+ el.innerHTML = webhookData.map((w, i) => `<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center">
1254
+ <input type="text" value="${esc(w.url)}" placeholder="https://hooks.slack.com/services/..." style="flex:1;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px 12px;color:var(--text);font-size:13px;outline:none;font-family:monospace" data-idx="${i}">
1255
+ <button style="background:none;border:1px solid var(--border);border-radius:6px;color:var(--text-dim);cursor:pointer;padding:6px 10px;font-size:14px;transition:color 0.15s" data-rm="${i}">&times;</button>
1256
+ </div>`).join('');
1257
+ el.querySelectorAll('input').forEach(inp => {
1258
+ inp.addEventListener('input', () => { webhookData[parseInt(inp.dataset.idx)].url = inp.value; });
1259
+ inp.addEventListener('focus', () => { inp.style.borderColor = 'var(--accent)'; });
1260
+ inp.addEventListener('blur', () => { inp.style.borderColor = 'var(--border)'; });
1261
+ });
1262
+ el.querySelectorAll('[data-rm]').forEach(btn => {
1263
+ btn.addEventListener('click', () => {
1264
+ webhookData.splice(parseInt(btn.dataset.rm), 1);
1265
+ renderWebhooks();
1266
+ });
1267
+ btn.addEventListener('mouseenter', () => { btn.style.color = 'var(--red)'; });
1268
+ btn.addEventListener('mouseleave', () => { btn.style.color = 'var(--text-dim)'; });
1269
+ });
1270
+ }
1271
+
1272
+ // Telegram
1273
+ function showTgStatus(msg, ok) {
1274
+ const el = $('#tgStatus');
1275
+ el.textContent = msg;
1276
+ el.style.display = 'block';
1277
+ el.style.background = ok ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)';
1278
+ el.style.color = ok ? 'var(--green)' : 'var(--red)';
1279
+ }
1280
+
1281
+ $('#tgTest').addEventListener('click', async () => {
1282
+ const botToken = $('#tgBotToken').value.trim();
1283
+ const chatId = $('#tgChatId').value.trim();
1284
+ if (!botToken || !chatId) { showTgStatus('Bot Token and Chat ID are required.', false); return; }
1285
+ if (botToken.includes('...')) { showTgStatus('Please enter full Bot Token (not masked).', false); return; }
1286
+ try {
1287
+ const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
1288
+ const res = await fetch(`/api/telegram/test${tokenQuery}`, {
1289
+ method: 'POST',
1290
+ headers: { 'Content-Type': 'application/json' },
1291
+ body: JSON.stringify({ botToken, chatId }),
1292
+ });
1293
+ const data = await res.json();
1294
+ if (data.ok) { showTgStatus('Test message sent! Check your Telegram.', true); }
1295
+ else { showTgStatus(data.error || 'Test failed.', false); }
1296
+ } catch (e) { showTgStatus('Connection error.', false); }
1297
+ });
1298
+
1299
+ $('#tgSave').addEventListener('click', async () => {
1300
+ const botToken = $('#tgBotToken').value.trim();
1301
+ const chatId = $('#tgChatId').value.trim();
1302
+ const enabled = $('#tgEnabled').checked;
1303
+ if (enabled && botToken.includes('...')) { showTgStatus('Please enter full Bot Token to enable.', false); return; }
1304
+ try {
1305
+ const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
1306
+ await fetch(`/api/telegram${tokenQuery}`, {
1307
+ method: 'POST',
1308
+ headers: { 'Content-Type': 'application/json' },
1309
+ body: JSON.stringify({ telegram: { botToken, chatId, enabled } }),
1310
+ });
1311
+ showTgStatus('Saved!', true);
1312
+ setTimeout(closeSettings, 800);
1313
+ } catch { showTgStatus('Save failed.', false); }
1314
+ });
1315
+
1316
+ // Refresh relative times every 30s
1317
+ setInterval(() => { renderMessages(); renderNotifications(); }, 30000);
1318
+
1319
+ // Mobile tabs
1320
+ document.querySelectorAll('#mobileTabs button').forEach(btn => {
1321
+ btn.addEventListener('click', () => {
1322
+ document.querySelectorAll('#mobileTabs button').forEach(b => b.classList.remove('active'));
1323
+ btn.classList.add('active');
1324
+ const tab = btn.dataset.tab;
1325
+ document.querySelectorAll('.sessions-panel, .messages-panel, .notifications-panel').forEach(p => p.classList.remove('mobile-active'));
1326
+ document.querySelector(`.${tab === 'sessions' ? 'sessions' : tab === 'messages' ? 'messages' : 'notifications'}-panel`).classList.add('mobile-active');
1327
+ });
1328
+ });
1329
+ // Default mobile tab
1330
+ if (window.innerWidth <= 768) {
1331
+ document.querySelector('.sessions-panel').classList.add('mobile-active');
1332
+ }
1333
+
1334
+ state.token = getToken();
1335
+ connect();
1336
+ })();
1337
+ </script>
1338
+ </body>
1339
+ </html>