hotdrop 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1204 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>HotDrop</title>
7
+ <link rel="manifest" href="/manifest.json" />
8
+ <meta name="theme-color" content="#0a0a0a" />
9
+ <meta name="apple-mobile-web-app-capable" content="yes" />
10
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
11
+ <meta name="apple-mobile-web-app-title" content="HotDrop" />
12
+ <link rel="apple-touch-icon" href="/icon-192.svg" />
13
+ <link rel="icon" type="image/svg+xml" href="/icon-192.svg" />
14
+ <link rel="shortcut icon" href="/icon-192.svg" />
15
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Syne:wght@400;600;800&display=swap" rel="stylesheet" />
16
+ <style>
17
+ :root {
18
+ --bg: #0a0a0a;
19
+ --surface: #111;
20
+ --surface2: #161616;
21
+ --border: #222;
22
+ --accent: #e8ff47;
23
+ --accent2: #47c8ff;
24
+ --text: #f0f0f0;
25
+ --muted: #555;
26
+ --danger: #ff4747;
27
+ --success: #47ff9a;
28
+ --mono: 'JetBrains Mono', monospace;
29
+ --sans: 'Syne', sans-serif;
30
+ }
31
+
32
+ * { box-sizing: border-box; margin: 0; padding: 0; }
33
+
34
+ body {
35
+ background: var(--bg);
36
+ color: var(--text);
37
+ font-family: var(--mono);
38
+ height: 100dvh;
39
+ display: flex;
40
+ flex-direction: column;
41
+ overflow: hidden;
42
+ }
43
+
44
+ body::before {
45
+ content: '';
46
+ position: fixed;
47
+ inset: 0;
48
+ background-image:
49
+ linear-gradient(rgba(255,255,255,.012) 1px, transparent 1px),
50
+ linear-gradient(90deg, rgba(255,255,255,.012) 1px, transparent 1px);
51
+ background-size: 40px 40px;
52
+ pointer-events: none;
53
+ z-index: 0;
54
+ }
55
+
56
+ /* ── Layout ── */
57
+ .app { position: relative; z-index: 1; display: flex; flex-direction: column; flex: 1; }
58
+
59
+ header {
60
+ flex-shrink: 0;
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: space-between;
64
+ padding: 18px 20px;
65
+ border-bottom: 1px solid var(--border);
66
+ }
67
+
68
+ .logo { font-family: var(--sans); font-weight: 800; font-size: 1.4rem; letter-spacing: -0.03em; }
69
+ .logo span { color: var(--accent); }
70
+ .logo em { font-style: normal; font-size: 0.7rem; font-family: var(--mono); color: var(--muted); letter-spacing: 0.04em; vertical-align: middle; margin-left: 4px; opacity: 0.85; }
71
+
72
+ .conn-badge {
73
+ font-size: 0.62rem;
74
+ letter-spacing: 0.12em;
75
+ text-transform: uppercase;
76
+ padding: 3px 9px;
77
+ border-radius: 2px;
78
+ border: 1px solid var(--border);
79
+ color: var(--muted);
80
+ transition: all 0.3s;
81
+ }
82
+ .conn-badge.connecting { color: var(--accent2); border-color: var(--accent2); }
83
+ .conn-badge.connected { color: var(--success); border-color: var(--success); animation: pulse 2s infinite; }
84
+ .conn-badge.error { color: var(--danger); border-color: var(--danger); }
85
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
86
+
87
+ main {
88
+ flex: 1;
89
+ padding: 24px 20px 40px;
90
+ max-width: 600px;
91
+ width: 100%;
92
+ margin: 0 auto;
93
+ overflow-y: auto;
94
+ }
95
+
96
+ /* ── Screens ── */
97
+ .screen { display: none; }
98
+ .screen.active { display: block; }
99
+
100
+ /* ── Home screen ── */
101
+ .home-hero {
102
+ text-align: center;
103
+ padding: 32px 0 40px;
104
+ }
105
+ .home-hero .hero-icon { font-size: 3.5rem; margin-bottom: 16px; }
106
+ .home-hero h1 { font-family: var(--sans); font-size: 1.6rem; font-weight: 800; margin-bottom: 8px; }
107
+ .home-hero p { font-size: 0.78rem; color: var(--muted); line-height: 1.7; max-width: 320px; margin: 0 auto; }
108
+
109
+ .home-actions { display: flex; flex-direction: column; gap: 12px; margin-top: 32px; }
110
+
111
+ .btn-primary {
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ gap: 10px;
116
+ width: 100%;
117
+ padding: 16px;
118
+ background: var(--accent);
119
+ color: #000;
120
+ border: none;
121
+ border-radius: 8px;
122
+ font-family: var(--mono);
123
+ font-size: 0.88rem;
124
+ font-weight: 700;
125
+ letter-spacing: 0.05em;
126
+ cursor: pointer;
127
+ transition: opacity 0.15s;
128
+ }
129
+ .btn-primary:hover { opacity: 0.88; }
130
+ .btn-primary:active { opacity: 0.75; }
131
+
132
+ .btn-secondary {
133
+ display: flex;
134
+ align-items: center;
135
+ justify-content: center;
136
+ gap: 10px;
137
+ width: 100%;
138
+ padding: 16px;
139
+ background: transparent;
140
+ color: var(--text);
141
+ border: 1px solid var(--border);
142
+ border-radius: 8px;
143
+ font-family: var(--mono);
144
+ font-size: 0.88rem;
145
+ font-weight: 500;
146
+ cursor: pointer;
147
+ transition: border-color 0.15s, background 0.15s;
148
+ }
149
+ .btn-secondary:hover { border-color: #444; background: var(--surface); }
150
+
151
+ /* ── Create screen ── */
152
+ .room-card {
153
+ background: var(--surface);
154
+ border: 1px solid var(--border);
155
+ border-radius: 10px;
156
+ padding: 24px;
157
+ margin-bottom: 20px;
158
+ }
159
+
160
+ .room-card-header {
161
+ font-size: 0.7rem;
162
+ text-transform: uppercase;
163
+ letter-spacing: 0.12em;
164
+ color: var(--muted);
165
+ margin-bottom: 16px;
166
+ }
167
+
168
+ .room-code {
169
+ font-family: var(--mono);
170
+ font-size: 2.8rem;
171
+ font-weight: 700;
172
+ letter-spacing: 0.22em;
173
+ color: var(--accent);
174
+ text-align: center;
175
+ margin-bottom: 20px;
176
+ }
177
+
178
+ .qr-wrap {
179
+ display: flex;
180
+ justify-content: center;
181
+ margin-bottom: 16px;
182
+ }
183
+
184
+ .qr-wrap img {
185
+ width: 180px;
186
+ height: 180px;
187
+ border-radius: 6px;
188
+ }
189
+
190
+ .waiting-msg {
191
+ text-align: center;
192
+ font-size: 0.75rem;
193
+ color: var(--muted);
194
+ display: flex;
195
+ align-items: center;
196
+ justify-content: center;
197
+ gap: 8px;
198
+ }
199
+
200
+ .dot-pulse {
201
+ display: inline-flex;
202
+ gap: 4px;
203
+ }
204
+ .dot-pulse span {
205
+ width: 5px;
206
+ height: 5px;
207
+ background: var(--muted);
208
+ border-radius: 50%;
209
+ animation: dotbounce 1.2s infinite;
210
+ }
211
+ .dot-pulse span:nth-child(2) { animation-delay: 0.2s; }
212
+ .dot-pulse span:nth-child(3) { animation-delay: 0.4s; }
213
+ @keyframes dotbounce { 0%,80%,100%{opacity:.2} 40%{opacity:1} }
214
+
215
+ /* ── Join screen ── */
216
+ .join-form { margin-bottom: 20px; }
217
+ .join-form label { display: block; font-size: 0.72rem; color: var(--muted); margin-bottom: 8px; letter-spacing: 0.08em; text-transform: uppercase; }
218
+
219
+ .code-input {
220
+ width: 100%;
221
+ background: var(--surface);
222
+ border: 1px solid var(--border);
223
+ border-radius: 8px;
224
+ padding: 16px;
225
+ font-family: var(--mono);
226
+ font-size: 2rem;
227
+ font-weight: 700;
228
+ letter-spacing: 0.22em;
229
+ color: var(--accent);
230
+ text-align: center;
231
+ text-transform: uppercase;
232
+ outline: none;
233
+ transition: border-color 0.15s;
234
+ margin-bottom: 12px;
235
+ }
236
+ .code-input:focus { border-color: var(--accent); }
237
+ .code-input::placeholder { color: var(--border); letter-spacing: 0.1em; }
238
+
239
+ .error-msg {
240
+ font-size: 0.75rem;
241
+ color: var(--danger);
242
+ text-align: center;
243
+ margin-bottom: 12px;
244
+ min-height: 18px;
245
+ }
246
+
247
+ /* ── Transfer screen ── */
248
+ .peer-info {
249
+ display: flex;
250
+ align-items: center;
251
+ gap: 12px;
252
+ background: var(--surface);
253
+ border: 1px solid var(--success);
254
+ border-radius: 8px;
255
+ padding: 14px 16px;
256
+ margin-bottom: 20px;
257
+ font-size: 0.78rem;
258
+ }
259
+ .peer-dot { width: 8px; height: 8px; background: var(--success); border-radius: 50%; flex-shrink: 0; animation: pulse 2s infinite; }
260
+ .peer-info span { color: var(--muted); }
261
+ .peer-info strong { color: var(--success); }
262
+
263
+ .drop-zone {
264
+ border: 2px dashed var(--border);
265
+ border-radius: 10px;
266
+ padding: 40px 20px;
267
+ text-align: center;
268
+ cursor: pointer;
269
+ transition: border-color 0.2s, background 0.2s;
270
+ margin-bottom: 20px;
271
+ position: relative;
272
+ }
273
+ .drop-zone.drag-over { border-color: var(--accent); background: rgba(232,255,71,.04); }
274
+ .drop-zone input { position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%; }
275
+ .drop-icon { font-size: 2.2rem; margin-bottom: 10px; }
276
+ .drop-zone h3 { font-family: var(--sans); font-size: 1rem; font-weight: 700; margin-bottom: 4px; }
277
+ .drop-zone p { font-size: 0.72rem; color: var(--muted); }
278
+
279
+ /* Transfer items */
280
+ .transfers { display: flex; flex-direction: column; gap: 10px; }
281
+
282
+ .transfer-item {
283
+ background: var(--surface);
284
+ border: 1px solid var(--border);
285
+ border-radius: 8px;
286
+ padding: 14px 16px;
287
+ }
288
+
289
+ .transfer-item.incoming { border-color: #1a2a1a; }
290
+ .transfer-item.outgoing { border-color: #1a1a2a; }
291
+ .transfer-item.done { border-color: var(--border); }
292
+
293
+ .transfer-header {
294
+ display: flex;
295
+ align-items: center;
296
+ gap: 10px;
297
+ margin-bottom: 10px;
298
+ }
299
+
300
+ .transfer-icon { font-size: 1.1rem; flex-shrink: 0; }
301
+
302
+ .transfer-meta { flex: 1; min-width: 0; }
303
+ .transfer-name { font-size: 0.8rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
304
+ .transfer-sub { font-size: 0.68rem; color: var(--muted); margin-top: 2px; }
305
+
306
+ .transfer-status { font-size: 0.7rem; flex-shrink: 0; }
307
+ .transfer-status.incoming-lbl { color: var(--accent2); }
308
+ .transfer-status.outgoing-lbl { color: var(--accent); }
309
+ .transfer-status.done-lbl { color: var(--success); }
310
+ .transfer-status.err-lbl { color: var(--danger); }
311
+
312
+ .progress-track { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
313
+ .progress-fill { height: 100%; border-radius: 2px; transition: width 0.15s; }
314
+ .progress-fill.incoming-fill { background: var(--accent2); }
315
+ .progress-fill.outgoing-fill { background: var(--accent); }
316
+
317
+ .download-btn {
318
+ margin-top: 10px;
319
+ display: inline-flex;
320
+ align-items: center;
321
+ gap: 6px;
322
+ background: var(--success);
323
+ color: #000;
324
+ border: none;
325
+ padding: 7px 14px;
326
+ border-radius: 4px;
327
+ font-family: var(--mono);
328
+ font-size: 0.72rem;
329
+ font-weight: 700;
330
+ cursor: pointer;
331
+ }
332
+
333
+ .btn-sm-ghost {
334
+ background: transparent;
335
+ border: 1px solid var(--border);
336
+ color: var(--muted);
337
+ padding: 5px 12px;
338
+ border-radius: 4px;
339
+ cursor: pointer;
340
+ font-family: var(--mono);
341
+ font-size: 0.72rem;
342
+ transition: color 0.15s, border-color 0.15s;
343
+ }
344
+ .btn-sm-ghost:hover { color: var(--text); border-color: #444; }
345
+
346
+ .section-row {
347
+ display: flex;
348
+ align-items: center;
349
+ justify-content: space-between;
350
+ margin-bottom: 12px;
351
+ }
352
+ .section-title { font-family: var(--sans); font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
353
+
354
+ /* ── Install banner ── */
355
+ #install-banner {
356
+ display: none;
357
+ align-items: center;
358
+ justify-content: space-between;
359
+ gap: 12px;
360
+ background: var(--surface);
361
+ border: 1px solid var(--accent);
362
+ border-radius: 6px;
363
+ padding: 12px 16px;
364
+ margin-bottom: 20px;
365
+ font-size: 0.78rem;
366
+ }
367
+ #install-banner.visible { display: flex; }
368
+ #install-banner span { color: var(--accent); }
369
+ .btn-install { background: var(--accent); color: #000; border: none; padding: 7px 14px; font-family: var(--mono); font-size: 0.72rem; font-weight: 700; cursor: pointer; border-radius: 3px; white-space: nowrap; flex-shrink: 0; }
370
+
371
+ /* ── Disconnect banner ── */
372
+ .disconnected-banner {
373
+ display: none;
374
+ align-items: center;
375
+ justify-content: space-between;
376
+ gap: 12px;
377
+ background: var(--surface);
378
+ border: 1px solid var(--danger);
379
+ border-radius: 6px;
380
+ padding: 12px 16px;
381
+ margin-bottom: 20px;
382
+ font-size: 0.78rem;
383
+ color: var(--danger);
384
+ }
385
+ .disconnected-banner.visible { display: flex; }
386
+
387
+ /* ── Toast ── */
388
+ #toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px); background: var(--surface); border: 1px solid var(--border); color: var(--text); padding: 10px 20px; border-radius: 6px; font-size: 0.75rem; opacity: 0; transition: opacity 0.2s, transform 0.2s; pointer-events: none; z-index: 999; white-space: nowrap; }
389
+ #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
390
+ #toast.success { border-color: var(--success); color: var(--success); }
391
+ #toast.error { border-color: var(--danger); color: var(--danger); }
392
+
393
+ /* ── Back button ── */
394
+ .back-btn { background: transparent; border: none; color: var(--muted); cursor: pointer; font-family: var(--mono); font-size: 0.75rem; padding: 0; margin-bottom: 20px; display: flex; align-items: center; gap: 6px; transition: color 0.15s; }
395
+ .back-btn:hover { color: var(--text); }
396
+
397
+ @media (max-width: 400px) {
398
+ .room-code { font-size: 2.2rem; }
399
+ }
400
+
401
+ footer {
402
+ flex-shrink: 0;
403
+ position: relative;
404
+ background: var(--bg);
405
+ border-top: 1px solid var(--border);
406
+ padding: 12px 20px;
407
+ }
408
+
409
+ .footer-inner {
410
+ max-width: 600px;
411
+ margin: 0 auto;
412
+ display: flex;
413
+ flex-direction: column;
414
+ align-items: center;
415
+ gap: 8px;
416
+ }
417
+
418
+ .footer-brand {
419
+ font-size: 0.72rem;
420
+ color: var(--text);
421
+ text-align: center;
422
+ }
423
+
424
+ .footer-brand span { color: var(--accent); font-weight: 700; }
425
+ .footer-brand a { color: var(--accent); text-decoration: none; }
426
+ .footer-brand a:hover { color: var(--text); }
427
+
428
+ .footer-actions {
429
+ display: flex;
430
+ align-items: center;
431
+ justify-content: center;
432
+ gap: 8px;
433
+ flex-wrap: wrap;
434
+ }
435
+
436
+ .footer-btn {
437
+ font-size: 0.7rem;
438
+ font-family: var(--mono);
439
+ color: var(--muted);
440
+ text-decoration: none;
441
+ border: 1px solid var(--border);
442
+ padding: 5px 10px;
443
+ border-radius: 4px;
444
+ transition: color 0.15s, border-color 0.15s;
445
+ white-space: nowrap;
446
+ }
447
+
448
+ .footer-btn:hover { color: var(--text); border-color: #444; }
449
+ .footer-btn.coffee { color: #f5a623; border-color: #f5a623; }
450
+ .footer-btn.coffee:hover { background: rgba(245,166,35,0.08); }
451
+
452
+ @media (min-width: 480px) {
453
+ .footer-inner {
454
+ flex-direction: row;
455
+ justify-content: space-between;
456
+ }
457
+ }
458
+
459
+ /* ── Transfer screen room bar ── */
460
+ .room-info-bar {
461
+ display: flex;
462
+ align-items: center;
463
+ justify-content: space-between;
464
+ background: var(--surface);
465
+ border: 1px solid var(--border);
466
+ border-radius: 8px;
467
+ padding: 10px 16px;
468
+ margin-bottom: 20px;
469
+ font-size: 0.75rem;
470
+ color: var(--muted);
471
+ }
472
+ .room-info-bar strong {
473
+ font-family: var(--mono);
474
+ font-size: 1rem;
475
+ font-weight: 700;
476
+ color: var(--accent);
477
+ letter-spacing: 0.15em;
478
+ margin-left: 6px;
479
+ }
480
+
481
+ /* ── QR modal ── */
482
+ .qr-modal {
483
+ display: none;
484
+ position: fixed;
485
+ inset: 0;
486
+ background: rgba(0,0,0,0.75);
487
+ z-index: 200;
488
+ align-items: center;
489
+ justify-content: center;
490
+ }
491
+ .qr-modal.visible { display: flex; }
492
+ .qr-modal-inner {
493
+ background: var(--surface);
494
+ border: 1px solid var(--border);
495
+ border-radius: 12px;
496
+ padding: 28px 24px;
497
+ text-align: center;
498
+ display: flex;
499
+ flex-direction: column;
500
+ align-items: center;
501
+ gap: 16px;
502
+ }
503
+ .qr-modal-code {
504
+ font-family: var(--mono);
505
+ font-size: 2.2rem;
506
+ font-weight: 700;
507
+ letter-spacing: 0.22em;
508
+ color: var(--accent);
509
+ }
510
+ .qr-modal-hint {
511
+ font-size: 0.72rem;
512
+ color: var(--muted);
513
+ }
514
+ </style>
515
+ </head>
516
+ <body>
517
+ <div class="app">
518
+ <header>
519
+ <div class="logo">Hot<span>Drop</span></div>
520
+ <span class="conn-badge" id="conn-badge">idle</span>
521
+ </header>
522
+
523
+ <main>
524
+ <!-- Install banner -->
525
+ <div id="install-banner">
526
+ <p>Install <span>HotDrop</span> for quick access</p>
527
+ <button class="btn-install" id="install-btn">Install</button>
528
+ </div>
529
+
530
+ <!-- ── Screen: Home ── -->
531
+ <div class="screen active" id="screen-home">
532
+ <div class="home-hero">
533
+ <div class="hero-icon">🔥</div>
534
+ <h1>Drop anything, anywhere</h1>
535
+ <p>Send files directly between devices peer to peer — no cloud, no cables, no account needed.</p>
536
+ </div>
537
+ <div class="home-actions">
538
+ <button class="btn-primary" id="btn-create">
539
+ <span>⚡</span> Create Room
540
+ </button>
541
+ <button class="btn-secondary" id="btn-join-nav">
542
+ <span>🔗</span> Join with Code
543
+ </button>
544
+ </div>
545
+ </div>
546
+
547
+ <!-- ── Screen: Create ── -->
548
+ <div class="screen" id="screen-create">
549
+ <button class="back-btn" id="back-from-create">← back</button>
550
+ <div class="room-card">
551
+ <div class="room-card-header">Room code</div>
552
+ <div class="room-code" id="room-code">——</div>
553
+ <div class="qr-wrap" id="qr-wrap">
554
+ <span style="color:var(--muted);font-size:0.75rem">generating…</span>
555
+ </div>
556
+ <div class="waiting-msg">
557
+ Waiting for someone to join
558
+ <span class="dot-pulse"><span></span><span></span><span></span></span>
559
+ </div>
560
+ </div>
561
+ <p style="font-size:0.72rem;color:var(--muted);text-align:center;line-height:1.7">
562
+ Share the code or let them scan the QR.<br/>Both devices need this page open.
563
+ </p>
564
+ </div>
565
+
566
+ <!-- ── Screen: Join ── -->
567
+ <div class="screen" id="screen-join">
568
+ <button class="back-btn" id="back-from-join">← back</button>
569
+ <div class="join-form">
570
+ <label>Enter room code</label>
571
+ <input class="code-input" id="code-input" maxlength="6" placeholder="XXXXXX" autocomplete="off" autocapitalize="characters" spellcheck="false" />
572
+ <div class="error-msg" id="join-error"></div>
573
+ <button class="btn-primary" id="btn-join">
574
+ <span>🔗</span> Join Room
575
+ </button>
576
+ </div>
577
+ </div>
578
+
579
+ <!-- ── Screen: Transfer ── -->
580
+ <div class="screen" id="screen-transfer">
581
+ <div class="disconnected-banner" id="disconnected-banner">
582
+ <span>⚠ All peers disconnected</span>
583
+ <div style="display:flex;gap:8px;flex-shrink:0">
584
+ <button class="btn-sm-ghost" id="btn-new-session">New session</button>
585
+ <button class="btn-sm-ghost" id="btn-dismiss-banner" style="padding:5px 8px">✕</button>
586
+ </div>
587
+ </div>
588
+
589
+ <div class="peer-info" id="peer-info">
590
+ <span class="peer-dot"></span>
591
+ <span id="peer-info-text">Connected · <strong>1 peer</strong></span>
592
+ </div>
593
+
594
+ <div class="room-info-bar" id="room-info-bar">
595
+ <span>Room:<strong id="transfer-room-code">——</strong></span>
596
+ <button class="btn-sm-ghost" id="btn-show-qr">show QR</button>
597
+ </div>
598
+
599
+ <div class="drop-zone" id="drop-zone">
600
+ <input type="file" id="file-input" multiple />
601
+ <div class="drop-icon">⬆</div>
602
+ <h3>Send files</h3>
603
+ <p>Drop here or tap to browse</p>
604
+ </div>
605
+
606
+ <div id="transfers-wrap" style="display:none">
607
+ <div class="section-row">
608
+ <span class="section-title">Transfers</span>
609
+ <button class="btn-sm-ghost" id="clear-transfers">clear</button>
610
+ </div>
611
+ <div class="transfers" id="transfers"></div>
612
+ </div>
613
+ </div>
614
+ </main>
615
+ </div>
616
+
617
+ <div class="qr-modal" id="qr-modal">
618
+ <div class="qr-modal-inner">
619
+ <div class="qr-modal-code" id="qr-modal-code">——</div>
620
+ <div id="qr-modal-img"></div>
621
+ <p class="qr-modal-hint">Scan to join this room</p>
622
+ <button class="btn-sm-ghost" id="qr-modal-close">close</button>
623
+ </div>
624
+ </div>
625
+
626
+ <div id="toast"></div>
627
+
628
+ <footer>
629
+ <div class="footer-inner">
630
+ <span class="footer-brand">Hot<span>Drop</span> — built by <a href="https://github.com/armedjuror" target="_blank" rel="noopener">armedjuror</a></span>
631
+ <div class="footer-actions">
632
+ <a href="https://github.com/armedjuror/hotdrop" target="_blank" rel="noopener" class="footer-btn">
633
+ Github ⭐ <span id="star-count">—</span>
634
+ </a>
635
+ <a href="https://buymeacoffee.com/armedjuror" target="_blank" rel="noopener" class="footer-btn coffee">
636
+ ☕ Buy me a coffee
637
+ </a>
638
+ </div>
639
+ </div>
640
+ </footer>
641
+ <script>
642
+ // ─────────────────────────────────────────────
643
+ // Config — auto-detects same-origin (Railway) or localhost for dev
644
+ // ─────────────────────────────────────────────
645
+ const { WS_URL, IS_LOCAL } = (() => {
646
+ const host = window.location.hostname;
647
+ const isLocal = host === 'localhost' || host === '127.0.0.1' || /^192\.168\./.test(host) || /^10\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
648
+ const proto = isLocal ? 'ws' : 'wss';
649
+ const port = isLocal ? `:${window.location.port || 5821}` : '';
650
+ return { WS_URL: `${proto}://${host}${port}`, IS_LOCAL: isLocal };
651
+ })();
652
+
653
+ if (IS_LOCAL) {
654
+ document.querySelector('.logo').innerHTML = 'Hot<span>Drop</span><em>.local</em>';
655
+ }
656
+
657
+ const CHUNK_SIZE = 64 * 1024; // 64KB chunks
658
+
659
+ // ─────────────────────────────────────────────
660
+ // State
661
+ // ─────────────────────────────────────────────
662
+ let ws = null;
663
+ const peers = new Map(); // peerId → { pc: RTCPeerConnection, dc: RTCDataChannel | null }
664
+ let myRole = null; // 'creator' | 'joiner'
665
+ let roomCode = null;
666
+
667
+ // Incoming file assembly
668
+ let incomingMeta = null;
669
+ let incomingChunks = [];
670
+ let incomingReceived = 0;
671
+
672
+ // Transfer list
673
+ const transferMap = new Map(); // id → { el, meta }
674
+
675
+ // ─────────────────────────────────────────────
676
+ // Screen management
677
+ // ─────────────────────────────────────────────
678
+ function showScreen(id) {
679
+ document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
680
+ document.getElementById('screen-' + id).classList.add('active');
681
+ }
682
+
683
+ // ─────────────────────────────────────────────
684
+ // Badge
685
+ // ─────────────────────────────────────────────
686
+ function setBadge(text, cls) {
687
+ const el = document.getElementById('conn-badge');
688
+ el.textContent = text;
689
+ el.className = 'conn-badge' + (cls ? ' ' + cls : '');
690
+ }
691
+
692
+ // ─────────────────────────────────────────────
693
+ // WebSocket connection to signaling server
694
+ // ─────────────────────────────────────────────
695
+ function connectWS(onOpen) {
696
+ setBadge('connecting', 'connecting');
697
+ ws = new WebSocket(WS_URL);
698
+
699
+ ws.onopen = () => {
700
+ setBadge('signaling', 'connecting');
701
+ onOpen();
702
+ };
703
+
704
+ ws.onmessage = e => handleSignal(JSON.parse(e.data));
705
+
706
+ ws.onerror = () => {
707
+ setBadge('error', 'error');
708
+ showToast('Cannot reach signaling server', 'error');
709
+ };
710
+
711
+ ws.onclose = () => {
712
+ const anyOpen = [...peers.values()].some(p => p.dc && p.dc.readyState === 'open');
713
+ if (anyOpen) return; // WebRTC already up, WS not needed
714
+ setBadge('disconnected', 'error');
715
+ };
716
+ }
717
+
718
+ function wsSend(data) {
719
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data));
720
+ }
721
+
722
+ // ─────────────────────────────────────────────
723
+ // Signaling handler
724
+ // ─────────────────────────────────────────────
725
+ async function handleSignal(msg) {
726
+ switch (msg.type) {
727
+ case 'created':
728
+ roomCode = msg.code;
729
+ document.getElementById('room-code').textContent = msg.code;
730
+ renderQR(msg.code);
731
+ break;
732
+
733
+ case 'joined':
734
+ roomCode = msg.code;
735
+ // existing peers will send us offers
736
+ break;
737
+
738
+ case 'peer_joined': {
739
+ // Any peer already in the room creates an offer to the new joiner
740
+ setBadge('connecting', 'connecting');
741
+ const pc = await createPeerConnection(msg.peerId);
742
+ const dc = pc.createDataChannel('hotdrop', { ordered: true });
743
+ setupDataChannel(dc, msg.peerId);
744
+ const offer = await pc.createOffer();
745
+ await pc.setLocalDescription(offer);
746
+ wsSend({ type: 'offer', sdp: offer.sdp, to: msg.peerId });
747
+ break;
748
+ }
749
+
750
+ case 'offer': {
751
+ // Got an offer from msg.from — answer it
752
+ const pc = await createPeerConnection(msg.from);
753
+ await pc.setRemoteDescription({ type: 'offer', sdp: msg.sdp });
754
+ const answer = await pc.createAnswer();
755
+ await pc.setLocalDescription(answer);
756
+ wsSend({ type: 'answer', sdp: answer.sdp, to: msg.from });
757
+ pc.ondatachannel = e => setupDataChannel(e.channel, msg.from);
758
+ break;
759
+ }
760
+
761
+ case 'answer': {
762
+ const entry = peers.get(msg.from);
763
+ if (entry) await entry.pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp });
764
+ break;
765
+ }
766
+
767
+ case 'ice': {
768
+ const entry = peers.get(msg.from);
769
+ if (entry && msg.candidate) {
770
+ try { await entry.pc.addIceCandidate(msg.candidate); } catch (_) {}
771
+ }
772
+ break;
773
+ }
774
+
775
+ case 'peer_left':
776
+ onPeerDisconnected(msg.peerId);
777
+ break;
778
+
779
+ case 'error':
780
+ document.getElementById('join-error').textContent = msg.message;
781
+ setBadge('idle');
782
+ break;
783
+ }
784
+ }
785
+
786
+ // ─────────────────────────────────────────────
787
+ // WebRTC
788
+ // ─────────────────────────────────────────────
789
+ async function createPeerConnection(peerId) {
790
+ const pc = new RTCPeerConnection({
791
+ iceServers: [
792
+ { urls: 'stun:stun.l.google.com:19302' },
793
+ { urls: 'stun:stun1.l.google.com:19302' }
794
+ ]
795
+ });
796
+
797
+ peers.set(peerId, { pc, dc: null });
798
+
799
+ pc.onicecandidate = e => {
800
+ if (e.candidate) wsSend({ type: 'ice', candidate: e.candidate, to: peerId });
801
+ };
802
+
803
+ pc.onconnectionstatechange = () => {
804
+ if (pc.connectionState === 'connected') {
805
+ updateConnBadge();
806
+ } else if (['disconnected', 'failed', 'closed'].includes(pc.connectionState)) {
807
+ onPeerDisconnected(peerId);
808
+ }
809
+ };
810
+
811
+ return pc;
812
+ }
813
+
814
+ function setupDataChannel(channel, peerId) {
815
+ channel.binaryType = 'arraybuffer';
816
+
817
+ const entry = peers.get(peerId);
818
+ if (entry) entry.dc = channel;
819
+
820
+ channel.onopen = () => {
821
+ updateConnBadge();
822
+ if (!document.getElementById('screen-transfer').classList.contains('active')) {
823
+ document.getElementById('transfer-room-code').textContent = roomCode;
824
+ document.getElementById('qr-modal-code').textContent = roomCode;
825
+ renderQRInto('qr-modal-img', roomCode);
826
+ showScreen('transfer');
827
+ }
828
+ showToast('Peer connected!', 'success');
829
+ };
830
+
831
+ channel.onclose = () => onPeerDisconnected(peerId);
832
+ channel.onmessage = e => handleDataMessage(e.data);
833
+ }
834
+
835
+ function updateConnBadge() {
836
+ const count = [...peers.values()].filter(p => p.dc && p.dc.readyState === 'open').length;
837
+ if (count > 0) {
838
+ setBadge(count === 1 ? '1 peer' : `${count} peers`, 'connected');
839
+ document.getElementById('peer-info-text').innerHTML =
840
+ `Connected · <strong>${count} ${count === 1 ? 'peer' : 'peers'}</strong>`;
841
+ }
842
+ }
843
+
844
+ function onPeerDisconnected(peerId) {
845
+ const entry = peers.get(peerId);
846
+ if (entry) {
847
+ try { if (entry.dc) entry.dc.close(); } catch (_) {}
848
+ try { if (entry.pc) entry.pc.close(); } catch (_) {}
849
+ peers.delete(peerId);
850
+ }
851
+ const stillConnected = [...peers.values()].filter(p => p.dc && p.dc.readyState === 'open').length;
852
+ if (stillConnected === 0) {
853
+ setBadge('disconnected', 'error');
854
+ document.getElementById('disconnected-banner').classList.add('visible');
855
+ document.getElementById('drop-zone').style.pointerEvents = 'none';
856
+ document.getElementById('drop-zone').style.opacity = '0.4';
857
+ } else {
858
+ updateConnBadge();
859
+ }
860
+ }
861
+
862
+ // ─────────────────────────────────────────────
863
+ // Data channel messages
864
+ // ─────────────────────────────────────────────
865
+ function handleDataMessage(data) {
866
+ if (typeof data === 'string') {
867
+ const msg = JSON.parse(data);
868
+
869
+ if (msg.type === 'file-start') {
870
+ incomingMeta = msg;
871
+ incomingChunks = [];
872
+ incomingReceived = 0;
873
+ addTransferItem(msg.id, msg.name, msg.size, 'incoming');
874
+ }
875
+
876
+ if (msg.type === 'file-end') {
877
+ const blob = new Blob(incomingChunks, { type: incomingMeta.mime || 'application/octet-stream' });
878
+ updateTransferDone(incomingMeta.id, blob, incomingMeta.name);
879
+ incomingMeta = null;
880
+ incomingChunks = [];
881
+ incomingReceived = 0;
882
+ }
883
+
884
+ } else {
885
+ // Binary chunk
886
+ if (!incomingMeta) return;
887
+ incomingChunks.push(data);
888
+ incomingReceived += data.byteLength;
889
+ const pct = Math.min(100, Math.round(incomingReceived / incomingMeta.size * 100));
890
+ updateTransferProgress(incomingMeta.id, pct);
891
+ }
892
+ }
893
+
894
+ // ─────────────────────────────────────────────
895
+ // Send files
896
+ // ─────────────────────────────────────────────
897
+ async function sendFiles(files) {
898
+ const activeDcs = [...peers.values()]
899
+ .filter(p => p.dc && p.dc.readyState === 'open')
900
+ .map(p => p.dc);
901
+
902
+ if (activeDcs.length === 0) {
903
+ showToast('Not connected', 'error');
904
+ return;
905
+ }
906
+
907
+ for (const file of files) {
908
+ const id = Math.random().toString(36).slice(2, 10);
909
+ addTransferItem(id, file.name, file.size, 'outgoing');
910
+
911
+ // Send metadata to all peers
912
+ const startMsg = JSON.stringify({ type: 'file-start', id, name: file.name, size: file.size, mime: file.type });
913
+ for (const dc of activeDcs) dc.send(startMsg);
914
+
915
+ // Send chunks
916
+ const buffer = await file.arrayBuffer();
917
+ let offset = 0;
918
+ let sent = 0;
919
+
920
+ while (offset < buffer.byteLength) {
921
+ // Backpressure — wait on all peers
922
+ for (const dc of activeDcs) {
923
+ while (dc.bufferedAmount > 1024 * 1024) {
924
+ await new Promise(r => setTimeout(r, 20));
925
+ }
926
+ }
927
+ const chunk = buffer.slice(offset, offset + CHUNK_SIZE);
928
+ for (const dc of activeDcs) dc.send(chunk);
929
+ offset += chunk.byteLength;
930
+ sent += chunk.byteLength;
931
+ const pct = Math.min(100, Math.round(sent / file.size * 100));
932
+ updateTransferProgress(id, pct);
933
+ }
934
+
935
+ // Send end signal
936
+ const endMsg = JSON.stringify({ type: 'file-end', id });
937
+ for (const dc of activeDcs) dc.send(endMsg);
938
+ updateTransferProgress(id, 100);
939
+ }
940
+ }
941
+
942
+ // ─────────────────────────────────────────────
943
+ // Transfer UI
944
+ // ─────────────────────────────────────────────
945
+ function addTransferItem(id, name, size, direction) {
946
+ const wrap = document.getElementById('transfers-wrap');
947
+ const list = document.getElementById('transfers');
948
+ wrap.style.display = 'block';
949
+
950
+ const lbl = direction === 'incoming' ? 'receiving' : 'sending';
951
+ const lblCls = direction === 'incoming' ? 'incoming-lbl' : 'outgoing-lbl';
952
+ const fillCls = direction === 'incoming' ? 'incoming-fill' : 'outgoing-fill';
953
+
954
+ const el = document.createElement('div');
955
+ el.className = `transfer-item ${direction}`;
956
+ el.id = `transfer-${id}`;
957
+ el.innerHTML = `
958
+ <div class="transfer-header">
959
+ <span class="transfer-icon">${fileIcon(name)}</span>
960
+ <div class="transfer-meta">
961
+ <div class="transfer-name">${escHtml(name)}</div>
962
+ <div class="transfer-sub">${formatSize(size)}</div>
963
+ </div>
964
+ <span class="transfer-status ${lblCls}" id="status-${id}">${lbl}</span>
965
+ </div>
966
+ <div class="progress-track">
967
+ <div class="progress-fill ${fillCls}" id="fill-${id}" style="width:0%"></div>
968
+ </div>
969
+ <div id="action-${id}"></div>
970
+ `;
971
+ list.prepend(el);
972
+ transferMap.set(id, { el, name, size, direction });
973
+ }
974
+
975
+ function updateTransferProgress(id, pct) {
976
+ const fill = document.getElementById(`fill-${id}`);
977
+ if (fill) fill.style.width = pct + '%';
978
+ }
979
+
980
+ function updateTransferDone(id, blob, name) {
981
+ updateTransferProgress(id, 100);
982
+ const status = document.getElementById(`status-${id}`);
983
+ if (status) { status.textContent = 'done'; status.className = 'transfer-status done-lbl'; }
984
+ const item = document.getElementById(`transfer-${id}`);
985
+ if (item) item.className = 'transfer-item done';
986
+
987
+ const actionEl = document.getElementById(`action-${id}`);
988
+ if (actionEl) {
989
+ const url = URL.createObjectURL(blob);
990
+ const entry = transferMap.get(id);
991
+ if (entry) entry.url = url;
992
+ const btn = document.createElement('button');
993
+ btn.className = 'download-btn';
994
+ btn.innerHTML = '↓ Save file';
995
+ btn.onclick = () => {
996
+ const a = document.createElement('a');
997
+ a.href = url;
998
+ a.download = name;
999
+ a.click();
1000
+ };
1001
+ actionEl.appendChild(btn);
1002
+ }
1003
+ showToast(`Received: ${name}`, 'success');
1004
+ }
1005
+
1006
+ // ─────────────────────────────────────────────
1007
+ // QR code generation (using free API)
1008
+ // ─────────────────────────────────────────────
1009
+ function renderQRInto(containerId, code) {
1010
+ const wrap = document.getElementById(containerId);
1011
+ const doRender = (origin) => {
1012
+ const joinUrl = `${origin}?join=${code}`;
1013
+ const img = document.createElement('img');
1014
+ img.src = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&bgcolor=111111&color=e8ff47&qzone=1&data=${encodeURIComponent(joinUrl)}`;
1015
+ img.alt = 'QR code';
1016
+ img.width = 180;
1017
+ img.height = 180;
1018
+ img.onerror = () => { wrap.innerHTML = `<span style="color:var(--muted);font-size:0.72rem">QR unavailable</span>`; };
1019
+ wrap.innerHTML = '';
1020
+ wrap.appendChild(img);
1021
+ };
1022
+
1023
+ const host = location.hostname;
1024
+ if (host === 'localhost' || host === '127.0.0.1') {
1025
+ fetch('/api/local-ip')
1026
+ .then(r => r.json())
1027
+ .then(({ ip }) => doRender(ip ? `http://${ip}:${location.port || 5821}` : location.origin))
1028
+ .catch(() => doRender(location.origin));
1029
+ } else {
1030
+ doRender(location.origin);
1031
+ }
1032
+ }
1033
+
1034
+ function renderQR(code) {
1035
+ renderQRInto('qr-wrap', code);
1036
+ }
1037
+
1038
+ // ─────────────────────────────────────────────
1039
+ // Auto-join from URL param
1040
+ // ─────────────────────────────────────────────
1041
+ function checkAutoJoin() {
1042
+ const params = new URLSearchParams(location.search);
1043
+ const code = params.get('join');
1044
+ if (code) {
1045
+ document.getElementById('code-input').value = code.toUpperCase();
1046
+ startJoin(code.toUpperCase());
1047
+ }
1048
+ }
1049
+
1050
+ // ─────────────────────────────────────────────
1051
+ // Actions
1052
+ // ─────────────────────────────────────────────
1053
+ function startCreate() {
1054
+ myRole = 'creator';
1055
+ showScreen('create');
1056
+ connectWS(() => wsSend({ type: 'create' }));
1057
+ }
1058
+
1059
+ function startJoin(code) {
1060
+ if (!code) {
1061
+ code = document.getElementById('code-input').value.toUpperCase().trim();
1062
+ }
1063
+ if (code.length < 6) {
1064
+ document.getElementById('join-error').textContent = 'Code must be 6 characters';
1065
+ return;
1066
+ }
1067
+ document.getElementById('join-error').textContent = '';
1068
+ myRole = 'joiner';
1069
+ setBadge('joining', 'connecting');
1070
+ connectWS(() => wsSend({ type: 'join', code }));
1071
+ }
1072
+
1073
+ function resetSession() {
1074
+ for (const { pc, dc } of peers.values()) {
1075
+ try { if (dc) dc.close(); } catch (_) {}
1076
+ try { if (pc) pc.close(); } catch (_) {}
1077
+ }
1078
+ peers.clear();
1079
+ if (ws) { try { ws.close(); } catch (_) {} ws = null; }
1080
+ myRole = null;
1081
+ roomCode = null;
1082
+ incomingMeta = null;
1083
+ incomingChunks = [];
1084
+ incomingReceived = 0;
1085
+ transferMap.forEach(entry => { if (entry.url) URL.revokeObjectURL(entry.url); });
1086
+ transferMap.clear();
1087
+ document.getElementById('transfers').innerHTML = '';
1088
+ document.getElementById('transfers-wrap').style.display = 'none';
1089
+ document.getElementById('disconnected-banner').classList.remove('visible');
1090
+ document.getElementById('drop-zone').style.pointerEvents = '';
1091
+ document.getElementById('drop-zone').style.opacity = '';
1092
+ document.getElementById('code-input').value = '';
1093
+ setBadge('idle');
1094
+ showScreen('home');
1095
+ // Clean URL
1096
+ history.replaceState({}, '', location.pathname);
1097
+ }
1098
+
1099
+ // ─────────────────────────────────────────────
1100
+ // Event listeners
1101
+ // ─────────────────────────────────────────────
1102
+ document.getElementById('btn-create').addEventListener('click', startCreate);
1103
+ document.getElementById('btn-join-nav').addEventListener('click', () => showScreen('join'));
1104
+ document.getElementById('btn-join').addEventListener('click', () => startJoin());
1105
+ document.getElementById('back-from-create').addEventListener('click', resetSession);
1106
+ document.getElementById('back-from-join').addEventListener('click', () => showScreen('home'));
1107
+ document.getElementById('btn-new-session').addEventListener('click', resetSession);
1108
+ document.getElementById('btn-dismiss-banner').addEventListener('click', () => {
1109
+ document.getElementById('disconnected-banner').classList.remove('visible');
1110
+ });
1111
+ document.getElementById('btn-show-qr').addEventListener('click', () => {
1112
+ document.getElementById('qr-modal').classList.add('visible');
1113
+ });
1114
+ document.getElementById('qr-modal-close').addEventListener('click', () => {
1115
+ document.getElementById('qr-modal').classList.remove('visible');
1116
+ });
1117
+ document.getElementById('qr-modal').addEventListener('click', e => {
1118
+ if (e.target === e.currentTarget) e.currentTarget.classList.remove('visible');
1119
+ });
1120
+ document.getElementById('clear-transfers').addEventListener('click', () => {
1121
+ transferMap.forEach(entry => { if (entry.url) URL.revokeObjectURL(entry.url); });
1122
+ document.getElementById('transfers').innerHTML = '';
1123
+ document.getElementById('transfers-wrap').style.display = 'none';
1124
+ transferMap.clear();
1125
+ });
1126
+
1127
+ document.getElementById('code-input').addEventListener('keydown', e => {
1128
+ if (e.key === 'Enter') startJoin();
1129
+ });
1130
+
1131
+ // File input / drop
1132
+ const dropZone = document.getElementById('drop-zone');
1133
+ const fileInput = document.getElementById('file-input');
1134
+
1135
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
1136
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
1137
+ dropZone.addEventListener('drop', e => {
1138
+ e.preventDefault();
1139
+ dropZone.classList.remove('drag-over');
1140
+ sendFiles([...e.dataTransfer.files]);
1141
+ });
1142
+ fileInput.addEventListener('change', () => {
1143
+ sendFiles([...fileInput.files]);
1144
+ fileInput.value = '';
1145
+ });
1146
+
1147
+ // Install prompt
1148
+ let deferredInstall = null;
1149
+ window.addEventListener('beforeinstallprompt', e => {
1150
+ e.preventDefault();
1151
+ deferredInstall = e;
1152
+ document.getElementById('install-banner').classList.add('visible');
1153
+ });
1154
+ document.getElementById('install-btn').addEventListener('click', async () => {
1155
+ if (!deferredInstall) return;
1156
+ deferredInstall.prompt();
1157
+ const { outcome } = await deferredInstall.userChoice;
1158
+ if (outcome === 'accepted') document.getElementById('install-banner').classList.remove('visible');
1159
+ deferredInstall = null;
1160
+ });
1161
+
1162
+ // ─────────────────────────────────────────────
1163
+ // Utils
1164
+ // ─────────────────────────────────────────────
1165
+ function showToast(msg, type = '') {
1166
+ const t = document.getElementById('toast');
1167
+ t.textContent = msg;
1168
+ t.className = 'show' + (type ? ' ' + type : '');
1169
+ clearTimeout(t._to);
1170
+ t._to = setTimeout(() => t.className = '', 3000);
1171
+ }
1172
+
1173
+ function formatSize(b) {
1174
+ if (b < 1024) return b + ' B';
1175
+ if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
1176
+ if (b < 1073741824) return (b/1048576).toFixed(1) + ' MB';
1177
+ return (b/1073741824).toFixed(2) + ' GB';
1178
+ }
1179
+
1180
+ function escHtml(s) {
1181
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1182
+ }
1183
+
1184
+ function fileIcon(name) {
1185
+ const ext = name.split('.').pop().toLowerCase();
1186
+ const m = { pdf:'📄',png:'🖼',jpg:'🖼',jpeg:'🖼',gif:'🖼',webp:'🖼',svg:'🖼',mp4:'🎬',mov:'🎬',avi:'🎬',mkv:'🎬',webm:'🎬',mp3:'🎵',wav:'🎵',flac:'🎵',m4a:'🎵',zip:'📦',tar:'📦',gz:'📦',rar:'📦',js:'💻',ts:'💻',py:'💻',sh:'💻',json:'💻',txt:'📝',md:'📝',doc:'📝',docx:'📝',xls:'📊',xlsx:'📊',csv:'📊' };
1187
+ return m[ext] || '📁';
1188
+ }
1189
+
1190
+ // Service Worker
1191
+ if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
1192
+
1193
+ // Init
1194
+ checkAutoJoin();
1195
+
1196
+ fetch('https://api.github.com/repos/armedjuror/hotdrop')
1197
+ .then(r => r.json())
1198
+ .then(data => {
1199
+ document.getElementById('star-count').textContent = data.stargazers_count;
1200
+ });
1201
+
1202
+ </script>
1203
+ </body>
1204
+ </html>