locadot 2.4.0 → 2.6.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,1543 @@
1
+ "use strict";
2
+ /** Hosts: one page (All / Shared tabs), the add-host dialog, the hosts table, and the share/access/password dialogs. */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.css = exports.hostDialogs = exports.allowDialog = exports.hostsView = exports.jsSharingPage = exports.jsHostRows = exports.jsProtect = exports.jsShare = exports.jsHostsTable = exports.jsAddHost = void 0;
5
+ exports.jsAddHost = ` // ---------- add host dialog ----------
6
+
7
+ function showAddError(message, hint) {
8
+ clear(addErrorEl);
9
+ var text = document.createElement("span");
10
+ text.textContent = message;
11
+ addErrorEl.appendChild(text);
12
+ if (hint) {
13
+ var code = document.createElement("code");
14
+ code.className = "mono";
15
+ code.textContent = hint;
16
+ addErrorEl.appendChild(code);
17
+ addErrorEl.appendChild(makeCopyButton(function () { return hint; }));
18
+ }
19
+ addErrorEl.hidden = false;
20
+ }
21
+
22
+ function hideAddError() {
23
+ addErrorEl.hidden = true;
24
+ clear(addErrorEl);
25
+ }
26
+
27
+ function markFieldInvalid(el, invalid) {
28
+ el.classList.toggle("invalid", !!invalid);
29
+ el.setAttribute("aria-invalid", invalid ? "true" : "false");
30
+ }
31
+
32
+ var addHostDialogEl = document.getElementById("add-host-dialog");
33
+ var addHostCloseBtn = document.getElementById("add-host-close");
34
+ var addHostCancelBtn = document.getElementById("add-host-cancel");
35
+ var addHostOpenBtns = [document.getElementById("add-host-btn"), document.getElementById("empty-add-host-btn")].filter(function (b) { return !!b; });
36
+
37
+ function openAddHostDialog() {
38
+ hideAddError();
39
+ markFieldInvalid(addHostInput, false);
40
+ markFieldInvalid(addTargetInput, false);
41
+ addHostDialogEl.showModal();
42
+ setTimeout(function () { addHostInput.focus(); }, 0);
43
+ }
44
+
45
+ function closeAddHostDialog() {
46
+ addHostDialogEl.close();
47
+ }
48
+
49
+ addHostOpenBtns.forEach(function (b) { b.addEventListener("click", openAddHostDialog); });
50
+ addHostCloseBtn.addEventListener("click", closeAddHostDialog);
51
+ addHostCancelBtn.addEventListener("click", closeAddHostDialog);
52
+ addHostDialogEl.addEventListener("click", function (e) {
53
+ if (e.target === addHostDialogEl) closeAddHostDialog();
54
+ });
55
+
56
+ addHostInput.addEventListener("input", function () {
57
+ if (addHostInput.value.trim()) markFieldInvalid(addHostInput, false);
58
+ hideAddError();
59
+ });
60
+ addTargetInput.addEventListener("input", function () {
61
+ if (addTargetInput.value.trim()) markFieldInvalid(addTargetInput, false);
62
+ hideAddError();
63
+ });
64
+
65
+ addForm.addEventListener("focusout", function (e) {
66
+ if (e.relatedTarget && addForm.contains(e.relatedTarget)) return;
67
+ hideAddError();
68
+ markFieldInvalid(addHostInput, false);
69
+ markFieldInvalid(addTargetInput, false);
70
+ });
71
+
72
+ addForm.addEventListener("submit", function (e) {
73
+ e.preventDefault();
74
+ hideAddError();
75
+ var hostVal = addHostInput.value.trim();
76
+ var targetVal = addTargetInput.value.trim();
77
+ if (!hostVal || !targetVal) {
78
+ markFieldInvalid(addHostInput, !hostVal);
79
+ markFieldInvalid(addTargetInput, !targetVal);
80
+ showAddError("Host and target are required.");
81
+ (!hostVal ? addHostInput : addTargetInput).focus();
82
+ return;
83
+ }
84
+ markFieldInvalid(addHostInput, false);
85
+ markFieldInvalid(addTargetInput, false);
86
+ if (hostVal.indexOf(".") === -1) hostVal = hostVal + ".localhost";
87
+ var insecureVal = !!addInsecureInput.checked;
88
+ var corsVal = !!addCorsInput.checked;
89
+ addSubmitBtn.disabled = true;
90
+ addSubmitBtn.classList.add("busy");
91
+ apiFetch("/api/hosts", { method: "POST", body: JSON.stringify({ host: hostVal, target: targetVal, insecure: insecureVal, cors: corsVal }) })
92
+ .then(function () {
93
+ addForm.reset();
94
+ applyHostDefaults();
95
+ pendingHighlightHost = hostVal;
96
+ closeAddHostDialog();
97
+ showToast("Added " + hostVal, "success");
98
+ return loadHosts();
99
+ })
100
+ .catch(function (err) {
101
+ showAddError(err.message, err.hint);
102
+ })
103
+ .then(function () {
104
+ addSubmitBtn.disabled = false;
105
+ addSubmitBtn.classList.remove("busy");
106
+ });
107
+ });
108
+
109
+ `;
110
+ exports.jsHostsTable = ` // ---------- hosts table ----------
111
+
112
+ function statusPill(row) {
113
+ var wrap = document.createElement("span");
114
+ wrap.className = "pill";
115
+ var dot = document.createElement("span");
116
+ dot.className = "dot " + (row.probe.up ? "up" : "down");
117
+ wrap.appendChild(dot);
118
+ var text = document.createElement("span");
119
+ if (row.probe.up) {
120
+ var statusPart = row.probe.status !== undefined ? String(row.probe.status) : "?";
121
+ var msPart = typeof row.probe.ms === "number" ? Math.round(row.probe.ms) + "ms" : "-";
122
+ text.textContent = statusPart + " \\u00b7 " + msPart;
123
+ } else {
124
+ text.textContent = row.probe.error || "down";
125
+ }
126
+ wrap.appendChild(text);
127
+ return wrap;
128
+ }
129
+
130
+ function makeCell(content, cls) {
131
+ var td = document.createElement("td");
132
+ if (cls) td.className = cls;
133
+ if (content !== undefined && content !== null) td.textContent = content;
134
+ return td;
135
+ }
136
+
137
+ function tunnelInfo(row) {
138
+ return row.tunnel || { enabled: false, status: "off" };
139
+ }
140
+
141
+ function tunnelCell(row) {
142
+ var td = document.createElement("td");
143
+ td.className = "tunnel-cell";
144
+ var t = tunnelInfo(row);
145
+
146
+ var statusWrap = document.createElement("div");
147
+ statusWrap.className = "tunnel-status";
148
+ if (t.status === "up" && t.url) {
149
+ var link = document.createElement("a");
150
+ link.href = t.url;
151
+ link.target = "_blank";
152
+ link.rel = "noopener noreferrer";
153
+ link.className = "mono";
154
+ link.textContent = t.url.replace("https://", "");
155
+ link.title = t.url;
156
+ statusWrap.appendChild(link);
157
+ if (t.mode === "custom") {
158
+ var modeBadge = document.createElement("span");
159
+ modeBadge.className = "badge badge-accent";
160
+ modeBadge.textContent = "custom";
161
+ statusWrap.appendChild(modeBadge);
162
+ }
163
+ statusWrap.appendChild(makeCopyButton(function () { return t.url; }));
164
+ } else if (t.status === "starting") {
165
+ var spin = document.createElement("span");
166
+ spin.className = "spinner";
167
+ statusWrap.appendChild(spin);
168
+ statusWrap.appendChild(document.createTextNode("Starting\\u2026"));
169
+ } else if (t.status === "login") {
170
+ var loginDot = document.createElement("span");
171
+ loginDot.className = "dot warn";
172
+ statusWrap.appendChild(loginDot);
173
+ var loginText = document.createElement("span");
174
+ loginText.className = "tunnel-login";
175
+ var loginLabel = document.createElement("span");
176
+ loginLabel.textContent = "Waiting for Cloudflare login";
177
+ loginText.appendChild(loginLabel);
178
+ if (t.loginUrl) {
179
+ var loginLink = document.createElement("a");
180
+ loginLink.href = t.loginUrl;
181
+ loginLink.target = "_blank";
182
+ loginLink.rel = "noopener noreferrer";
183
+ loginLink.textContent = "Log in to Cloudflare ↗";
184
+ loginText.appendChild(loginLink);
185
+ }
186
+ statusWrap.appendChild(loginText);
187
+ } else if (t.status === "error") {
188
+ var errDot = document.createElement("span");
189
+ errDot.className = "dot down";
190
+ statusWrap.appendChild(errDot);
191
+ var errText = document.createElement("span");
192
+ errText.className = "tunnel-error-text";
193
+ errText.textContent = "Error: " + (t.error || "tunnel failed to start");
194
+ errText.title = t.error || "Tunnel error";
195
+ statusWrap.appendChild(errText);
196
+ } else {
197
+ var dash = document.createElement("span");
198
+ dash.className = "dim";
199
+ dash.textContent = "\\u2014";
200
+ statusWrap.appendChild(dash);
201
+ }
202
+ td.appendChild(statusWrap);
203
+ return td;
204
+ }
205
+
206
+ function optionsCell(row) {
207
+ var td = document.createElement("td");
208
+ var flags = [];
209
+ if (row.insecure) flags.push(["insecure", "Upstream TLS certificate is not verified", "badge-warn"]);
210
+ if (row.cors) flags.push(["cors", "Origin/Referer rewritten to the target; any origin may call this host", "badge-cors"]);
211
+ if (row.allow && row.allow.length) flags.push(["internal access", "Internal access: " + row.allow.join(", "), "badge-accent"]);
212
+ if (row.protect) flags.push(["password", "Password protected on: " + protectScopeNames(row.protect.scopes), "badge-accent"]);
213
+ if (row.httpsRedirect === true) flags.push(["https only", "Plain http redirects to https, whatever the global setting", "badge-accent"]);
214
+ if (row.httpsRedirect === false) flags.push(["http allowed", "Plain http is never redirected, whatever the global setting", "badge-warn"]);
215
+ if (flags.length) {
216
+ td.className = "badges";
217
+ flags.forEach(function (flag) {
218
+ var badge = document.createElement("span");
219
+ badge.className = "badge " + flag[2];
220
+ badge.textContent = flag[0];
221
+ badge.title = flag[1];
222
+ td.appendChild(badge);
223
+ });
224
+ } else {
225
+ td.className = "dim";
226
+ td.textContent = "\\u2014";
227
+ }
228
+ return td;
229
+ }
230
+
231
+ function copyValue(text, label) {
232
+ function ok() { showToast(label || "Copied", "success"); }
233
+ function fail() { showToast("Couldn't copy", "error"); }
234
+ if (navigator.clipboard && navigator.clipboard.writeText) {
235
+ navigator.clipboard.writeText(text).then(ok, fail);
236
+ return;
237
+ }
238
+ try {
239
+ var ta = document.createElement("textarea");
240
+ ta.className = "clip-helper";
241
+ ta.value = text;
242
+ document.body.appendChild(ta);
243
+ ta.select();
244
+ document.execCommand("copy");
245
+ document.body.removeChild(ta);
246
+ ok();
247
+ } catch (e) {
248
+ fail();
249
+ }
250
+ }
251
+
252
+ function stopSharing(row) {
253
+ confirmDialog({ title: "Stop sharing " + row.host + "?", message: "The public URL for " + row.host + " stops working.", confirmLabel: "Stop sharing", danger: true }).then(function (ok) {
254
+ if (!ok) return;
255
+ apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ tunnel: false }) })
256
+ .then(function () {
257
+ showToast("Stopping tunnel for " + row.host, "success");
258
+ return loadHosts();
259
+ })
260
+ .catch(function (err) {
261
+ apiError(err, "Couldn't change sharing for " + row.host);
262
+ });
263
+ });
264
+ }
265
+
266
+ function removeHost(row) {
267
+ (prefs.confirm ? confirmDialog({ title: "Remove host " + row.host + "?", message: "This can't be undone.", confirmLabel: "Remove host", danger: true }) : Promise.resolve(true)).then(function (ok) {
268
+ if (!ok) return;
269
+ apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "DELETE" })
270
+ .then(function () {
271
+ showToast("Removed " + row.host, "success");
272
+ return loadHosts();
273
+ })
274
+ .catch(function (err) {
275
+ apiError(err, "Couldn't remove " + row.host);
276
+ });
277
+ });
278
+ }
279
+
280
+ function primaryShareAction(row) {
281
+ var t = tunnelInfo(row);
282
+ var btn = document.createElement("button");
283
+ btn.type = "button";
284
+ btn.className = "btn btn-sm";
285
+ if (t.status === "up" && t.url) {
286
+ btn.classList.add("btn-secondary");
287
+ btn.textContent = "Copy link";
288
+ btn.title = "Copy the public URL";
289
+ btn.addEventListener("click", function () { copyText(t.url, btn); });
290
+ } else if (t.enabled || t.status === "starting" || t.status === "login") {
291
+ btn.classList.add("btn-secondary");
292
+ btn.textContent = t.status === "login" ? "Sign in\\u2026" : "Sharing\\u2026";
293
+ btn.title = "View sharing progress";
294
+ btn.addEventListener("click", function () { openShareDialog(row); });
295
+ } else if (t.status === "error") {
296
+ btn.classList.add("btn-primary");
297
+ btn.textContent = "Retry";
298
+ btn.title = "Try sharing again";
299
+ btn.addEventListener("click", function () { openShareDialog(row); });
300
+ } else {
301
+ btn.classList.add("btn-primary");
302
+ btn.textContent = "Share";
303
+ btn.title = "Share on a public URL";
304
+ if (!cloudflaredInstalled) {
305
+ btn.disabled = true;
306
+ btn.title = "Install cloudflared first";
307
+ }
308
+ btn.addEventListener("click", function () { openShareDialog(row); });
309
+ }
310
+ return btn;
311
+ }
312
+
313
+ function hostMenuItems(row) {
314
+ var t = tunnelInfo(row);
315
+ var isOn = t.enabled || t.status === "up" || t.status === "starting" || t.status === "login";
316
+ var items = [];
317
+ items.push({ label: "Edit", icon: ICONS.edit, onSelect: function () { enterEditMode(row); } });
318
+ if (isOn) {
319
+ items.push({ label: "Share settings\\u2026", icon: ICONS.settings, onSelect: function () { openShareDialog(row); } });
320
+ items.push({ label: "Stop sharing", icon: ICONS.stop, danger: true, onSelect: function () { stopSharing(row); } });
321
+ } else {
322
+ items.push({ label: "Share\\u2026", icon: ICONS.globe, disabled: !cloudflaredInstalled, hint: cloudflaredInstalled ? undefined : "needs cloudflared", onSelect: function () { openShareDialog(row); } });
323
+ }
324
+ items.push({ label: "Internal access\\u2026", icon: ICONS.shield, hint: (row.allow && row.allow.length) ? String(row.allow.length) : undefined, onSelect: function () { openAllowDialog(row); } });
325
+ items.push({ label: row.protect ? "Change password\\u2026" : "Password protection\\u2026", icon: row.protect ? ICONS.lock : ICONS.unlock, hint: row.protect ? "on" : undefined, onSelect: function () { openProtectDialog(row); } });
326
+ items.push({ label: "Copy local URL", icon: ICONS.copy, onSelect: function () { copyValue((row.urls && (row.urls.https || row.urls.http)) || row.host, "Copied " + row.host); } });
327
+ items.push("-");
328
+ items.push({ label: "Remove", icon: ICONS.trash, danger: true, onSelect: function () { removeHost(row); } });
329
+ return items;
330
+ }
331
+
332
+ `;
333
+ exports.jsShare = ` // ---------- share dialog ----------
334
+
335
+ var shareDialogEl = document.getElementById("share-dialog");
336
+ var shareHostEl = document.getElementById("share-host");
337
+ var shareCloseBtn = document.getElementById("share-close");
338
+ var shareCancelBtn = document.getElementById("share-cancel");
339
+ var shareSubmitBtn = document.getElementById("share-submit");
340
+ var shareOptionRandom = document.getElementById("share-option-random");
341
+ var shareOptionCustom = document.getElementById("share-option-custom");
342
+ var shareCustomExtra = document.getElementById("share-custom-extra");
343
+ var shareDomainInput = document.getElementById("share-domain-input");
344
+ var shareDomainError = document.getElementById("share-domain-error");
345
+ var shareDomainHint = document.getElementById("share-domain-hint");
346
+ var shareErrorEl = document.getElementById("share-error");
347
+ var shareOptionsEl = document.getElementById("share-options");
348
+ var shareProgressEl = document.getElementById("share-progress");
349
+ var shareProgressIcon = document.getElementById("share-progress-icon");
350
+ var shareProgressTitle = document.getElementById("share-progress-title");
351
+ var shareProgressText = document.getElementById("share-progress-text");
352
+ var shareStepsEl = document.getElementById("share-steps");
353
+ var shareStepsDomain = document.getElementById("share-steps-domain");
354
+ var shareAuthLink = document.getElementById("share-auth-link");
355
+ var shareDescEl = shareDialogEl.querySelector(".modal-desc");
356
+ var sharePoll = null;
357
+ var shareAuthWin = null;
358
+ var shareAuthOpened = false;
359
+ var shareRow = null;
360
+ var shareMode = "random";
361
+ var HOSTNAME_RE = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,}$/;
362
+
363
+ function normalizeHostname(value) {
364
+ return String(value || "").trim().toLowerCase().replace(/^https?:\\/\\//, "").replace(/\\/$/, "");
365
+ }
366
+
367
+ function validateHostname(raw) {
368
+ var value = normalizeHostname(raw);
369
+ if (!value) return "Enter a hostname.";
370
+ if (value.slice(-10) === ".localhost") return "Hostname can't be a .localhost address.";
371
+ if (!HOSTNAME_RE.test(value)) return "Enter a valid hostname, e.g. app.example.com.";
372
+ return null;
373
+ }
374
+
375
+ function setShareMode(mode) {
376
+ shareMode = mode;
377
+ shareOptionRandom.setAttribute("aria-checked", String(mode === "random"));
378
+ shareOptionCustom.setAttribute("aria-checked", String(mode === "custom"));
379
+ shareCustomExtra.hidden = mode !== "custom";
380
+ updateDomainHint();
381
+ shareDomainError.hidden = true;
382
+ shareDomainError.textContent = "";
383
+ shareErrorEl.hidden = true;
384
+ shareErrorEl.textContent = "";
385
+ if (mode === "custom") setTimeout(function () { shareDomainInput.focus(); }, 0);
386
+ }
387
+
388
+ function coveringZone(hostname) {
389
+ for (var i = 0; i < cloudflareZones.length; i++) {
390
+ var zone = cloudflareZones[i];
391
+ if (hostname === zone || hostname.slice(-(zone.length + 1)) === "." + zone) return zone;
392
+ }
393
+ return null;
394
+ }
395
+
396
+ function updateDomainHint() {
397
+ var value = normalizeHostname(shareDomainInput.value);
398
+ var zone = value && !validateHostname(value) ? coveringZone(value) : null;
399
+ shareDomainHint.textContent = zone
400
+ ? "Uses your Cloudflare login for " + zone + ". The DNS record is created or updated for you."
401
+ : "Cloudflare opens in a new tab so you can authorize this domain's zone. Each zone you authorize is remembered.";
402
+ }
403
+
404
+ var ICON_CLOUD = '<svg viewBox="0 0 24 24"><path d="M7 18a4.5 4.5 0 0 1-.5-8.97A6 6 0 0 1 18 9.5a4 4 0 0 1-.5 8.5z"/></svg>';
405
+
406
+ function stopSharePoll() {
407
+ if (sharePoll) clearInterval(sharePoll);
408
+ sharePoll = null;
409
+ }
410
+
411
+ function closeAuthWindow() {
412
+ if (shareAuthWin && !shareAuthOpened && !shareAuthWin.closed) shareAuthWin.close();
413
+ shareAuthWin = null;
414
+ }
415
+
416
+ function showShareOptions() {
417
+ shareOptionsEl.hidden = false;
418
+ shareDescEl.hidden = false;
419
+ shareProgressEl.hidden = true;
420
+ shareSubmitBtn.hidden = false;
421
+ shareCancelBtn.textContent = "Cancel";
422
+ }
423
+
424
+ function showShareProgress(domain, t) {
425
+ shareOptionsEl.hidden = true;
426
+ shareDescEl.hidden = true;
427
+ shareProgressEl.hidden = false;
428
+ shareSubmitBtn.hidden = true;
429
+ shareCancelBtn.textContent = "Close";
430
+ shareCancelBtn.disabled = false;
431
+ var login = t.status === "login";
432
+ if (login) {
433
+ shareProgressIcon.innerHTML = ICON_CLOUD;
434
+ shareProgressTitle.textContent = "Authorize " + domain + " on Cloudflare";
435
+ shareProgressText.textContent = "locadot doesn't have a Cloudflare login for this domain yet.";
436
+ } else {
437
+ shareProgressIcon.innerHTML = '<span class="spinner"></span>';
438
+ shareProgressTitle.textContent = "Setting up " + domain;
439
+ shareProgressText.textContent = t.loginUrl === undefined && shareAuthOpened
440
+ ? "Authorized. Creating the tunnel and the DNS record…"
441
+ : "Checking your Cloudflare login, then creating the tunnel and the DNS record…";
442
+ }
443
+ shareStepsEl.hidden = !login;
444
+ shareStepsDomain.textContent = domain;
445
+ shareAuthLink.hidden = !(login && t.loginUrl);
446
+ if (login && t.loginUrl) {
447
+ shareAuthLink.href = t.loginUrl;
448
+ if (shareAuthWin && !shareAuthOpened && !shareAuthWin.closed) {
449
+ shareAuthWin.location.href = t.loginUrl;
450
+ shareAuthOpened = true;
451
+ }
452
+ }
453
+ }
454
+
455
+ function watchShare(host, domain) {
456
+ stopSharePoll();
457
+ showShareProgress(domain, { status: "starting" });
458
+ var tick = function () {
459
+ fetch("/api/hosts").then(parseJsonOrThrow).then(function (hosts) {
460
+ if (shareRow === null || shareRow.host !== host) return;
461
+ var row = null;
462
+ for (var i = 0; i < hosts.length; i++) if (hosts[i].host === host) row = hosts[i];
463
+ var t = row ? tunnelInfo(row) : { status: "error", error: host + " was removed." };
464
+ if (t.status === "up") {
465
+ stopSharePoll();
466
+ closeAuthWindow();
467
+ closeShareDialog();
468
+ showToast(host + " is live at " + t.url, "success");
469
+ renderHosts(hosts);
470
+ } else if (t.status === "error" || t.status === "off") {
471
+ stopSharePoll();
472
+ closeAuthWindow();
473
+ showShareOptions();
474
+ shareErrorEl.textContent = t.error || "Sharing stopped.";
475
+ shareErrorEl.hidden = false;
476
+ shareSubmitBtn.textContent = "Try again";
477
+ renderHosts(hosts);
478
+ } else {
479
+ showShareProgress(domain, t);
480
+ }
481
+ }).catch(function () {});
482
+ };
483
+ sharePoll = setInterval(tick, 1500);
484
+ tick();
485
+ }
486
+
487
+ function closeShareDialog() {
488
+ stopSharePoll();
489
+ closeAuthWindow();
490
+ shareRow = null;
491
+ shareDialogEl.close();
492
+ }
493
+
494
+ function openShareDialog(row) {
495
+ shareRow = row;
496
+ shareHostEl.textContent = row.host;
497
+ shareErrorEl.hidden = true;
498
+ shareErrorEl.textContent = "";
499
+ shareDomainError.hidden = true;
500
+ shareDomainError.textContent = "";
501
+ var hasDomain = typeof row.tunnelDomain === "string" && !!row.tunnelDomain;
502
+ shareDomainInput.value = hasDomain ? row.tunnelDomain : "";
503
+ shareAuthOpened = false;
504
+ setShareMode(hasDomain ? "custom" : "random");
505
+ shareSubmitBtn.disabled = false;
506
+ shareSubmitBtn.classList.remove("busy");
507
+ shareCancelBtn.disabled = false;
508
+ shareDialogEl.showModal();
509
+ // Already mid-flight: jump straight to the live progress view instead of the picker.
510
+ var t = tunnelInfo(row);
511
+ if (t.status === "starting" || t.status === "login") {
512
+ watchShare(row.host, row.tunnelDomain || row.host);
513
+ } else {
514
+ showShareOptions();
515
+ shareSubmitBtn.textContent = "Start sharing";
516
+ }
517
+ }
518
+
519
+ shareOptionRandom.addEventListener("click", function () { setShareMode("random"); });
520
+ shareOptionCustom.addEventListener("click", function () { setShareMode("custom"); });
521
+ shareOptionRandom.addEventListener("keydown", function (e) {
522
+ if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); setShareMode("custom"); shareOptionCustom.focus(); }
523
+ });
524
+ shareOptionCustom.addEventListener("keydown", function (e) {
525
+ if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); setShareMode("random"); shareOptionRandom.focus(); }
526
+ });
527
+ shareCancelBtn.addEventListener("click", closeShareDialog);
528
+ shareCloseBtn.addEventListener("click", closeShareDialog);
529
+ shareDialogEl.addEventListener("click", function (e) {
530
+ if (e.target === shareDialogEl) closeShareDialog();
531
+ });
532
+ shareDialogEl.addEventListener("close", function () {
533
+ stopSharePoll();
534
+ closeAuthWindow();
535
+ shareRow = null;
536
+ });
537
+ shareDomainInput.addEventListener("input", updateDomainHint);
538
+ shareDomainInput.addEventListener("keydown", function (e) {
539
+ if (e.key === "Enter") { e.preventDefault(); shareSubmitBtn.click(); }
540
+ });
541
+
542
+ shareSubmitBtn.addEventListener("click", function () {
543
+ if (!shareRow) return;
544
+ var row = shareRow;
545
+ var body = { tunnel: true };
546
+ if (shareMode === "custom") {
547
+ var err = validateHostname(shareDomainInput.value);
548
+ if (err) {
549
+ shareDomainError.textContent = err;
550
+ shareDomainError.hidden = false;
551
+ return;
552
+ }
553
+ body.domain = normalizeHostname(shareDomainInput.value);
554
+ }
555
+ shareSubmitBtn.disabled = true;
556
+ shareSubmitBtn.classList.add("busy");
557
+ shareCancelBtn.disabled = true;
558
+ shareErrorEl.hidden = true;
559
+ // Opened now, inside the click, so the browser allows it; pointed at Cloudflare once the sign-in URL arrives.
560
+ shareAuthOpened = false;
561
+ closeAuthWindow();
562
+ if (body.domain && !coveringZone(body.domain)) {
563
+ shareAuthWin = window.open("", "_blank");
564
+ if (shareAuthWin) {
565
+ try {
566
+ shareAuthWin.document.title = "Connecting to Cloudflare…";
567
+ shareAuthWin.document.body.style.cssText = "font:14px system-ui,sans-serif;color:#666;display:grid;place-items:center;height:100vh;margin:0";
568
+ shareAuthWin.document.body.textContent = "Connecting to Cloudflare…";
569
+ } catch (e) {}
570
+ }
571
+ }
572
+ apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify(body) })
573
+ .then(function () {
574
+ if (body.domain) {
575
+ watchShare(row.host, body.domain);
576
+ return loadHosts();
577
+ }
578
+ showToast("Starting tunnel for " + row.host, "success");
579
+ closeShareDialog();
580
+ return loadHosts();
581
+ })
582
+ .catch(function (err2) {
583
+ closeAuthWindow();
584
+ shareErrorEl.textContent = (err2 && err2.message) ? err2.message : String(err2);
585
+ shareErrorEl.hidden = false;
586
+ })
587
+ .then(function () {
588
+ shareSubmitBtn.disabled = false;
589
+ shareSubmitBtn.classList.remove("busy");
590
+ shareCancelBtn.disabled = false;
591
+ });
592
+ });
593
+
594
+ var allowDialog = document.getElementById("allow-dialog");
595
+ var allowHostEl = document.getElementById("allow-host");
596
+ var allowWarnEl = document.getElementById("allow-warn");
597
+ var allowListEl = document.getElementById("allow-list");
598
+ var allowInput = document.getElementById("allow-input");
599
+ var allowAddBtn = document.getElementById("allow-add");
600
+ var allowErrorEl = document.getElementById("allow-error");
601
+ var allowCancelBtn = document.getElementById("allow-cancel");
602
+ var allowSaveBtn = document.getElementById("allow-save");
603
+ var allowCloseBtn = document.getElementById("allow-close");
604
+ var allowOriginal = [];
605
+ var allowCurrent = [];
606
+ var allowRow = null;
607
+
608
+ function allowListsEqual(a, b) {
609
+ if (a.length !== b.length) return false;
610
+ for (var i = 0; i < a.length; i++) {
611
+ if (a[i] !== b[i]) return false;
612
+ }
613
+ return true;
614
+ }
615
+
616
+ function updateAllowSave() {
617
+ allowSaveBtn.disabled = allowListsEqual(allowCurrent, allowOriginal);
618
+ }
619
+
620
+ function renderAllowList() {
621
+ clear(allowListEl);
622
+ if (!allowCurrent.length) {
623
+ var empty = document.createElement("div");
624
+ empty.className = "dim allow-empty";
625
+ empty.textContent = "No internal addresses. Shared visitors reach public hosts only.";
626
+ allowListEl.appendChild(empty);
627
+ return;
628
+ }
629
+ allowCurrent.forEach(function (entry) {
630
+ var entryRow = document.createElement("div");
631
+ entryRow.className = "allow-row";
632
+ var text = document.createElement("span");
633
+ text.className = "mono";
634
+ text.textContent = entry;
635
+ entryRow.appendChild(text);
636
+ var removeBtn = document.createElement("button");
637
+ removeBtn.type = "button";
638
+ removeBtn.className = "allow-remove";
639
+ removeBtn.textContent = "\\u00d7";
640
+ removeBtn.setAttribute("aria-label", "Remove " + entry);
641
+ removeBtn.addEventListener("click", function () {
642
+ allowCurrent = allowCurrent.filter(function (item) { return item !== entry; });
643
+ renderAllowList();
644
+ updateAllowSave();
645
+ });
646
+ entryRow.appendChild(removeBtn);
647
+ allowListEl.appendChild(entryRow);
648
+ });
649
+ }
650
+
651
+ function allowAddEntry() {
652
+ var value = allowInput.value.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\\/\\//, "").replace(/\\/$/, "");
653
+ allowErrorEl.hidden = true;
654
+ allowErrorEl.textContent = "";
655
+ if (!value || /\\s/.test(value)) {
656
+ allowErrorEl.textContent = "Enter a host or host:port, e.g. localhost:3000.";
657
+ allowErrorEl.hidden = false;
658
+ return;
659
+ }
660
+ if (allowCurrent.indexOf(value) !== -1) {
661
+ allowErrorEl.textContent = value + " is already in the list.";
662
+ allowErrorEl.hidden = false;
663
+ return;
664
+ }
665
+ allowCurrent.push(value);
666
+ allowInput.value = "";
667
+ renderAllowList();
668
+ updateAllowSave();
669
+ }
670
+
671
+ function closeAllowDialog() {
672
+ allowDialog.close();
673
+ }
674
+
675
+ function openAllowDialog(row) {
676
+ allowRow = row;
677
+ allowOriginal = (row.allow || []).slice();
678
+ allowCurrent = allowOriginal.slice();
679
+ allowHostEl.textContent = row.host;
680
+ allowWarnEl.hidden = !!row.cors;
681
+ allowErrorEl.hidden = true;
682
+ allowErrorEl.textContent = "";
683
+ allowInput.value = "";
684
+ renderAllowList();
685
+ allowSaveBtn.disabled = true;
686
+ allowSaveBtn.classList.remove("busy");
687
+ allowCancelBtn.disabled = false;
688
+ allowDialog.showModal();
689
+ setTimeout(function () { allowInput.focus(); }, 0);
690
+ }
691
+
692
+ allowAddBtn.addEventListener("click", allowAddEntry);
693
+ allowInput.addEventListener("keydown", function (e) {
694
+ if (e.key === "Enter") { e.preventDefault(); allowAddEntry(); }
695
+ });
696
+ allowCancelBtn.addEventListener("click", closeAllowDialog);
697
+ allowCloseBtn.addEventListener("click", closeAllowDialog);
698
+ allowDialog.addEventListener("click", function (e) {
699
+ if (e.target === allowDialog) closeAllowDialog();
700
+ });
701
+
702
+ allowSaveBtn.addEventListener("click", function () {
703
+ if (!allowRow) return;
704
+ var host = allowRow.host;
705
+ allowSaveBtn.disabled = true;
706
+ allowSaveBtn.classList.add("busy");
707
+ allowCancelBtn.disabled = true;
708
+ apiFetch("/api/hosts/" + encodeURIComponent(host), { method: "PUT", body: JSON.stringify({ allow: allowCurrent }) })
709
+ .then(function () {
710
+ showToast("Saved internal access for " + host, "success");
711
+ closeAllowDialog();
712
+ return loadHosts();
713
+ })
714
+ .catch(function (err) {
715
+ allowErrorEl.textContent = err && err.message ? err.message : String(err);
716
+ allowErrorEl.hidden = false;
717
+ allowSaveBtn.disabled = false;
718
+ allowSaveBtn.classList.remove("busy");
719
+ allowCancelBtn.disabled = false;
720
+ });
721
+ });
722
+
723
+ `;
724
+ exports.jsProtect = ` // ---------- protect dialog ----------
725
+
726
+ var PROTECT_SCOPES = ["shared", "remote", "local"];
727
+ var PROTECT_SCOPE_LABELS = { shared: "Shared link", remote: "Remote access", local: "This machine & network" };
728
+
729
+ function protectScopeNames(scopes) {
730
+ return scopes.map(function (s) { return PROTECT_SCOPE_LABELS[s] || s; }).join(", ");
731
+ }
732
+
733
+ var protectDialogEl = document.getElementById("protect-dialog");
734
+ var protectHostEl = document.getElementById("protect-host");
735
+ var protectCloseBtn = document.getElementById("protect-close");
736
+ var protectCancelBtn = document.getElementById("protect-cancel");
737
+ var protectSubmitBtn = document.getElementById("protect-submit");
738
+ var protectRemoveBtn = document.getElementById("protect-remove");
739
+ var protectPasswordInput = document.getElementById("protect-password");
740
+ var protectToggleBtn = document.getElementById("protect-toggle");
741
+ var protectErrorEl = document.getElementById("protect-error");
742
+ var protectHintEl = document.getElementById("protect-hint");
743
+ var protectScopeErrorEl = document.getElementById("protect-scope-error");
744
+ var protectOptionEls = PROTECT_SCOPES.map(function (s) { return document.getElementById("protect-option-" + s); });
745
+ var protectRow = null;
746
+ var protectScopes = [];
747
+
748
+ function protectRenderScopes() {
749
+ protectOptionEls.forEach(function (el) {
750
+ el.setAttribute("aria-checked", String(protectScopes.indexOf(el.getAttribute("data-scope")) !== -1));
751
+ });
752
+ }
753
+
754
+ function protectToggleScope(el) {
755
+ var scope = el.getAttribute("data-scope");
756
+ var on = protectScopes.indexOf(scope) === -1;
757
+ if (on) protectScopes.push(scope);
758
+ else protectScopes = protectScopes.filter(function (s) { return s !== scope; });
759
+ protectRenderScopes();
760
+ protectScopeErrorEl.hidden = true;
761
+ protectScopeErrorEl.textContent = "";
762
+ }
763
+
764
+ protectOptionEls.forEach(function (el) {
765
+ el.addEventListener("click", function () { protectToggleScope(el); });
766
+ });
767
+
768
+ protectToggleBtn.addEventListener("click", function () {
769
+ var showing = protectPasswordInput.type === "text";
770
+ protectPasswordInput.type = showing ? "password" : "text";
771
+ protectToggleBtn.setAttribute("aria-label", showing ? "Show password" : "Hide password");
772
+ });
773
+
774
+ function closeProtectDialog() {
775
+ protectRow = null;
776
+ protectDialogEl.close();
777
+ }
778
+
779
+ function openProtectDialog(row) {
780
+ protectRow = row;
781
+ protectHostEl.textContent = row.host;
782
+ protectPasswordInput.value = "";
783
+ protectPasswordInput.type = "password";
784
+ protectToggleBtn.setAttribute("aria-label", "Show password");
785
+ protectErrorEl.hidden = true;
786
+ protectErrorEl.textContent = "";
787
+ protectScopeErrorEl.hidden = true;
788
+ protectScopeErrorEl.textContent = "";
789
+ protectScopes = row.protect ? row.protect.scopes.slice() : [];
790
+ protectRenderScopes();
791
+ if (row.protect) {
792
+ protectPasswordInput.placeholder = "Leave blank to keep the current password";
793
+ protectHintEl.textContent = "Set " + fmtRelative(row.protect.updatedAt);
794
+ protectHintEl.hidden = false;
795
+ protectRemoveBtn.hidden = false;
796
+ protectSubmitBtn.textContent = "Save";
797
+ } else {
798
+ protectPasswordInput.placeholder = "";
799
+ protectHintEl.hidden = true;
800
+ protectRemoveBtn.hidden = true;
801
+ protectSubmitBtn.textContent = "Protect host";
802
+ }
803
+ protectSubmitBtn.disabled = false;
804
+ protectSubmitBtn.classList.remove("busy");
805
+ protectCancelBtn.disabled = false;
806
+ protectRemoveBtn.disabled = false;
807
+ protectDialogEl.showModal();
808
+ setTimeout(function () { protectPasswordInput.focus(); }, 0);
809
+ }
810
+
811
+ protectCancelBtn.addEventListener("click", closeProtectDialog);
812
+ protectCloseBtn.addEventListener("click", closeProtectDialog);
813
+ protectDialogEl.addEventListener("click", function (e) {
814
+ if (e.target === protectDialogEl) closeProtectDialog();
815
+ });
816
+ protectDialogEl.addEventListener("close", function () { protectRow = null; });
817
+
818
+ protectRemoveBtn.addEventListener("click", function () {
819
+ if (!protectRow) return;
820
+ var row = protectRow;
821
+ confirmDialog({ title: "Remove password protection?", message: "Anyone will be able to reach " + row.host + " without a password.", confirmLabel: "Remove protection", danger: true }).then(function (ok) {
822
+ if (!ok) return;
823
+ protectRemoveBtn.disabled = true;
824
+ protectSubmitBtn.disabled = true;
825
+ protectCancelBtn.disabled = true;
826
+ apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ protect: null }) })
827
+ .then(function () {
828
+ showToast("Removed password protection for " + row.host, "success");
829
+ closeProtectDialog();
830
+ return loadHosts();
831
+ })
832
+ .catch(function (err) {
833
+ protectErrorEl.textContent = err && err.message ? err.message : String(err);
834
+ protectErrorEl.hidden = false;
835
+ protectRemoveBtn.disabled = false;
836
+ protectSubmitBtn.disabled = false;
837
+ protectCancelBtn.disabled = false;
838
+ });
839
+ });
840
+ });
841
+
842
+ protectSubmitBtn.addEventListener("click", function () {
843
+ if (!protectRow) return;
844
+ var row = protectRow;
845
+ var password = protectPasswordInput.value;
846
+ protectErrorEl.hidden = true;
847
+ protectErrorEl.textContent = "";
848
+ protectScopeErrorEl.hidden = true;
849
+ protectScopeErrorEl.textContent = "";
850
+ var required = !row.protect;
851
+ if ((required || password) && password.length < 8) {
852
+ protectErrorEl.textContent = "Use at least 8 characters.";
853
+ protectErrorEl.hidden = false;
854
+ return;
855
+ }
856
+ if (!protectScopes.length) {
857
+ protectScopeErrorEl.textContent = "Pick at least one place to ask for the password.";
858
+ protectScopeErrorEl.hidden = false;
859
+ return;
860
+ }
861
+ var protectBody = { scopes: protectScopes.slice() };
862
+ if (password) protectBody.password = password;
863
+ protectSubmitBtn.disabled = true;
864
+ protectSubmitBtn.classList.add("busy");
865
+ protectCancelBtn.disabled = true;
866
+ protectRemoveBtn.disabled = true;
867
+ apiFetch("/api/hosts/" + encodeURIComponent(row.host), { method: "PUT", body: JSON.stringify({ protect: protectBody }) })
868
+ .then(function () {
869
+ showToast("Password protection saved for " + row.host, "success");
870
+ closeProtectDialog();
871
+ return loadHosts();
872
+ })
873
+ .catch(function (err) {
874
+ protectErrorEl.textContent = err && err.message ? err.message : String(err);
875
+ protectErrorEl.hidden = false;
876
+ })
877
+ .then(function () {
878
+ protectSubmitBtn.disabled = false;
879
+ protectSubmitBtn.classList.remove("busy");
880
+ protectCancelBtn.disabled = false;
881
+ protectRemoveBtn.disabled = false;
882
+ });
883
+ });
884
+
885
+ `;
886
+ exports.jsHostRows = ` function labelCells(tr, labels) {
887
+ for (var i = 0; i < tr.children.length; i++) tr.children[i].setAttribute("data-label", labels[i] || "");
888
+ }
889
+
890
+ var pendingHighlightHost = null;
891
+
892
+ function buildDisplayRow(tr, row) {
893
+ clear(tr);
894
+ tr.classList.toggle("row-down", !row.probe.up);
895
+
896
+ var hostTd = document.createElement("td");
897
+ hostTd.className = "host-td mono";
898
+ var hostWrap = document.createElement("span");
899
+ hostWrap.className = "host-cell";
900
+ var href = (row.urls && (row.urls.https || row.urls.http)) || "";
901
+ var a = document.createElement("a");
902
+ if (/^https?:\\/\\//.test(href)) a.href = href;
903
+ a.target = "_blank";
904
+ a.rel = "noopener noreferrer";
905
+ a.textContent = row.host;
906
+ hostWrap.appendChild(a);
907
+ hostWrap.appendChild(makeCopyButton(function () { return href || row.host; }));
908
+ if (row.remote) {
909
+ var viaBadge = document.createElement("span");
910
+ viaBadge.className = "badge badge-via";
911
+ viaBadge.textContent = "via " + row.remote.name;
912
+ viaBadge.title = "Imported from remote \\"" + row.remote.name + "\\"";
913
+ hostWrap.appendChild(viaBadge);
914
+ }
915
+ hostTd.appendChild(hostWrap);
916
+ var targetLine = document.createElement("div");
917
+ targetLine.className = "host-target mono";
918
+ var targetText = row.remote ? (row.remote.name + ": " + row.remote.host) : row.target;
919
+ targetLine.textContent = targetText;
920
+ targetLine.title = targetText;
921
+ hostTd.appendChild(targetLine);
922
+ tr.appendChild(hostTd);
923
+
924
+ tr.appendChild(optionsCell(row));
925
+
926
+ var statusTd = document.createElement("td");
927
+ statusTd.className = "status-td";
928
+ statusTd.appendChild(statusPill(row));
929
+ var st = row.stats || { hits: 0, errors: 0 };
930
+ var traffic = st.hits + " hits";
931
+ if (st.errors) traffic += " \\u00b7 " + st.errors + " err";
932
+ if (typeof st.avgMs === "number") traffic += " \\u00b7 " + Math.round(st.avgMs) + "ms";
933
+ var trafficLine = document.createElement("div");
934
+ trafficLine.className = "traffic";
935
+ trafficLine.textContent = traffic;
936
+ trafficLine.title = "Last access: " + (st.lastAccess ? fmtRelative(st.lastAccess) : "never");
937
+ statusTd.appendChild(trafficLine);
938
+ tr.appendChild(statusTd);
939
+
940
+ tr.appendChild(tunnelCell(row));
941
+
942
+ var actionsTd = document.createElement("td");
943
+ actionsTd.className = "actions-td";
944
+ var actionsWrap = document.createElement("div");
945
+ actionsWrap.className = "row-actions";
946
+ actionsTd.appendChild(actionsWrap);
947
+ if (row.remote) {
948
+ var removeBtn = document.createElement("button");
949
+ removeBtn.type = "button";
950
+ removeBtn.className = "btn btn-sm btn-ghost";
951
+ removeBtn.textContent = "Remove";
952
+ removeBtn.addEventListener("click", function () { removeHost(row); });
953
+ actionsWrap.appendChild(removeBtn);
954
+ } else {
955
+ actionsWrap.appendChild(primaryShareAction(row));
956
+ actionsWrap.appendChild(makeMoreButton(function () { return hostMenuItems(row); }, { label: "Actions for " + row.host, small: true }));
957
+ }
958
+ tr.appendChild(actionsTd);
959
+ labelCells(tr, ["", "Options", "Status", "Public URL", ""]);
960
+ }
961
+
962
+ function enterEditMode(row) {
963
+ editingHost = row.host;
964
+ var tr = rowElements[row.host];
965
+ if (!tr) return;
966
+ clear(tr);
967
+
968
+ var hostTd = makeCell(null, "host-td mono");
969
+ var hostName = document.createElement("div");
970
+ hostName.className = "host-cell";
971
+ hostName.textContent = row.host;
972
+ hostTd.appendChild(hostName);
973
+ tr.appendChild(hostTd);
974
+
975
+ var targetInput = document.createElement("input");
976
+ targetInput.type = "text";
977
+ targetInput.className = "edit-input mono";
978
+ targetInput.value = row.target;
979
+ targetInput.title = row.target;
980
+ targetInput.setAttribute("aria-label", "Target for " + row.host);
981
+ hostTd.appendChild(targetInput);
982
+
983
+ var insecureTd = document.createElement("td");
984
+ var insecureLabel = document.createElement("label");
985
+ insecureLabel.className = "inline-check";
986
+ var insecureInput = document.createElement("input");
987
+ insecureInput.type = "checkbox";
988
+ insecureInput.checked = !!row.insecure;
989
+ insecureLabel.appendChild(insecureInput);
990
+ insecureLabel.appendChild(document.createTextNode("insecure"));
991
+ insecureTd.appendChild(insecureLabel);
992
+ var corsLabel = document.createElement("label");
993
+ corsLabel.className = "inline-check";
994
+ var corsInput = document.createElement("input");
995
+ corsInput.type = "checkbox";
996
+ corsInput.checked = !!row.cors;
997
+ corsLabel.appendChild(corsInput);
998
+ corsLabel.appendChild(document.createTextNode("cors"));
999
+ insecureTd.appendChild(corsLabel);
1000
+ var redirectLabel = document.createElement("label");
1001
+ redirectLabel.className = "inline-check";
1002
+ var redirectInput = document.createElement("select");
1003
+ redirectInput.className = "input input-sm";
1004
+ [["", "https redirect: global"], ["on", "https redirect: on"], ["off", "https redirect: off"]].forEach(function (opt) {
1005
+ var o = document.createElement("option");
1006
+ o.value = opt[0];
1007
+ o.textContent = opt[1];
1008
+ redirectInput.appendChild(o);
1009
+ });
1010
+ redirectInput.value = row.httpsRedirect === true ? "on" : row.httpsRedirect === false ? "off" : "";
1011
+ redirectInput.setAttribute("aria-label", "https redirect for " + row.host);
1012
+ redirectLabel.appendChild(redirectInput);
1013
+ insecureTd.appendChild(redirectLabel);
1014
+ tr.appendChild(insecureTd);
1015
+
1016
+ var hintTd = makeCell("Enter to save \\u00b7 Esc to cancel", "dim edit-hint");
1017
+ hintTd.colSpan = 2;
1018
+ tr.appendChild(hintTd);
1019
+
1020
+ var actionsTd = document.createElement("td");
1021
+ var actionsWrap = document.createElement("div");
1022
+ actionsWrap.className = "row-actions";
1023
+ actionsTd.appendChild(actionsWrap);
1024
+ var saveBtn = document.createElement("button");
1025
+ saveBtn.type = "button";
1026
+ saveBtn.className = "btn btn-sm btn-primary";
1027
+ saveBtn.textContent = "Save";
1028
+ var cancelBtn = document.createElement("button");
1029
+ cancelBtn.type = "button";
1030
+ cancelBtn.className = "btn btn-sm btn-ghost";
1031
+ cancelBtn.textContent = "Cancel";
1032
+
1033
+ saveBtn.addEventListener("click", function () {
1034
+ var newTarget = targetInput.value.trim();
1035
+ if (!newTarget) {
1036
+ targetInput.classList.add("invalid");
1037
+ targetInput.setAttribute("aria-invalid", "true");
1038
+ targetInput.focus();
1039
+ return;
1040
+ }
1041
+ saveBtn.disabled = true;
1042
+ saveBtn.classList.add("busy");
1043
+ cancelBtn.disabled = true;
1044
+ apiFetch("/api/hosts/" + encodeURIComponent(row.host), {
1045
+ method: "PUT",
1046
+ body: JSON.stringify({
1047
+ target: newTarget,
1048
+ insecure: !!insecureInput.checked,
1049
+ cors: !!corsInput.checked,
1050
+ httpsRedirect: redirectInput.value === "on" ? true : redirectInput.value === "off" ? false : null
1051
+ })
1052
+ })
1053
+ .then(function () {
1054
+ editingHost = null;
1055
+ showToast("Updated " + row.host, "success");
1056
+ return loadHosts();
1057
+ })
1058
+ .catch(function (err) {
1059
+ hintTd.textContent = "Couldn't save: " + (err && err.message ? err.message : err) + ". Fix it and press Enter to retry.";
1060
+ hintTd.className = "edit-hint edit-error";
1061
+ saveBtn.disabled = false;
1062
+ saveBtn.classList.remove("busy");
1063
+ cancelBtn.disabled = false;
1064
+ });
1065
+ });
1066
+
1067
+ cancelBtn.addEventListener("click", function () {
1068
+ editingHost = null;
1069
+ buildDisplayRow(tr, row);
1070
+ });
1071
+
1072
+ targetInput.addEventListener("keydown", function (e) {
1073
+ if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); }
1074
+ if (e.key === "Escape") { e.preventDefault(); cancelBtn.click(); }
1075
+ });
1076
+ setTimeout(function () { targetInput.focus(); targetInput.select(); }, 0);
1077
+
1078
+ actionsWrap.appendChild(saveBtn);
1079
+ actionsWrap.appendChild(cancelBtn);
1080
+ tr.appendChild(actionsTd);
1081
+ labelCells(tr, ["", "Options", "", ""]);
1082
+ }
1083
+
1084
+ function renderHosts(hosts) {
1085
+ try { localStorage.setItem("locadot.hostCount", String(hosts.length || 1)); } catch (e) {}
1086
+ hideHostsSkeleton();
1087
+ lastHosts = hosts;
1088
+ if (inviteRoleSelect.value === "viewer" && !inviteHostsWrap.hidden) renderInviteHostChecks();
1089
+ emptyEl.hidden = hosts.length !== 0;
1090
+ tableWrap.hidden = hosts.length === 0;
1091
+
1092
+ var seen = {};
1093
+ hosts.forEach(function (row, i) {
1094
+ seen[row.host] = true;
1095
+ if (editingHost === row.host) return;
1096
+ var tr = rowElements[row.host];
1097
+ var isNew = !tr;
1098
+ if (!tr) {
1099
+ tr = document.createElement("tr");
1100
+ rowElements[row.host] = tr;
1101
+ }
1102
+ buildDisplayRow(tr, row);
1103
+ tbody.appendChild(tr);
1104
+ if (isNew) animateIn(tr, i);
1105
+ if (pendingHighlightHost === row.host) {
1106
+ pendingHighlightHost = null;
1107
+ tr.classList.add("row-highlight");
1108
+ if (typeof tr.scrollIntoView === "function") tr.scrollIntoView({ block: "nearest", behavior: motionOk() ? "smooth" : "auto" });
1109
+ setTimeout(function () { tr.classList.remove("row-highlight"); }, 2200);
1110
+ }
1111
+ });
1112
+ Object.keys(rowElements).forEach(function (host) {
1113
+ if (!seen[host]) {
1114
+ var tr = rowElements[host];
1115
+ if (tr && tr.parentNode) tr.parentNode.removeChild(tr);
1116
+ delete rowElements[host];
1117
+ if (editingHost === host) editingHost = null;
1118
+ }
1119
+ });
1120
+ hostsCountEl.textContent = String(hosts.length);
1121
+ hostsCountEl.hidden = false;
1122
+ sbHostsCountEl.textContent = String(hosts.length);
1123
+ sbHostsCountEl.hidden = false;
1124
+ applyHostsFilter();
1125
+ }
1126
+
1127
+ function applyHostsFilter() {
1128
+ var q = (hostsFilterInput.value || "").trim().toLowerCase();
1129
+ Object.keys(rowElements).forEach(function (host) {
1130
+ var tr = rowElements[host];
1131
+ var target = "";
1132
+ for (var i = 0; i < lastHosts.length; i++) if (lastHosts[i].host === host) { target = lastHosts[i].target || ""; break; }
1133
+ tr.hidden = !!q && (host + " " + target).toLowerCase().indexOf(q) === -1;
1134
+ });
1135
+ }
1136
+ hostsFilterInput.addEventListener("input", applyHostsFilter);
1137
+
1138
+ function loadHosts() {
1139
+ return fetch("/api/hosts").then(parseJsonOrThrow).then(renderHosts);
1140
+ }
1141
+
1142
+ var hostsTabsEl = document.querySelector('.tabs[data-tabs="hosts"]');
1143
+ if (hostsTabsEl) {
1144
+ initTabs(hostsTabsEl, {
1145
+ onChange: function (tab) { if (tab === "shared") renderShareList(); }
1146
+ });
1147
+ }
1148
+
1149
+ `;
1150
+ exports.jsSharingPage = ` // ---------- shared hosts tab ----------
1151
+
1152
+ var shareListEl = document.getElementById("share-list");
1153
+ var shareEmptyEl = document.getElementById("share-empty");
1154
+ var shareCountEl = document.getElementById("share-count");
1155
+
1156
+ function renderShareList() {
1157
+ var hosts = (lastHosts || []).filter(function (h) { return !h.remote && isShared(h); });
1158
+ clear(shareListEl);
1159
+ shareEmptyEl.hidden = hosts.length !== 0;
1160
+ shareCountEl.textContent = String(hosts.length);
1161
+ shareCountEl.hidden = hosts.length === 0;
1162
+ hosts.slice().sort(function (a, b) { return a.host.localeCompare(b.host); }).forEach(function (h, i) {
1163
+ var t = tunnelInfo(h);
1164
+ var side = document.createElement("div");
1165
+ side.className = "ov-side";
1166
+ if (t.status === "up" && t.url) {
1167
+ var a = document.createElement("a");
1168
+ a.className = "ov-link";
1169
+ a.href = t.url;
1170
+ a.target = "_blank";
1171
+ a.rel = "noopener noreferrer";
1172
+ a.textContent = t.url.replace(/^https:\\/\\//, "");
1173
+ side.appendChild(a);
1174
+ side.appendChild(makeCopyButton(function () { return t.url; }));
1175
+ }
1176
+ var stopBtn = document.createElement("button");
1177
+ stopBtn.type = "button";
1178
+ stopBtn.className = "btn btn-sm btn-danger";
1179
+ stopBtn.textContent = "Stop sharing";
1180
+ stopBtn.addEventListener("click", function () { stopSharing(h); });
1181
+ side.appendChild(stopBtn);
1182
+ var sub = t.status === "up" ? "public" : t.status === "starting" ? "starting tunnel\\u2026" : t.status === "login" ? "waiting for sign-in\\u2026" : h.target;
1183
+ var dot = t.status === "up" ? "up" : (t.status === "starting" || t.status === "login") ? "warn" : "";
1184
+ var row = ovRow(h.host, sub, side, dot);
1185
+ animateIn(row, i);
1186
+ shareListEl.appendChild(row);
1187
+ });
1188
+ }
1189
+
1190
+ `;
1191
+ exports.hostsView = ` <section class="view" data-view="hosts" aria-label="Hosts" hidden>
1192
+ <div class="page-head">
1193
+ <div>
1194
+ <h2 class="page-title">Hosts</h2>
1195
+ <p class="page-sub">Local hostnames and the dev servers they proxy to.</p>
1196
+ </div>
1197
+ <div class="page-actions">
1198
+ <button type="button" id="add-host-btn" class="btn btn-primary"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Add host</button>
1199
+ </div>
1200
+ </div>
1201
+ <div class="tabs" role="tablist" data-tabs="hosts" aria-label="Hosts views">
1202
+ <button type="button" role="tab" data-tab="all">All</button>
1203
+ <button type="button" role="tab" data-tab="shared">Shared</button>
1204
+ </div>
1205
+
1206
+ <div data-tab-panel="all">
1207
+ <section id="hosts-card" class="card" aria-label="Registered hosts">
1208
+ <div class="card-head">
1209
+ <div class="card-title-wrap">
1210
+ <h3 class="card-title">Hosts</h3>
1211
+ <span id="hosts-count" class="badge badge-count" hidden></span>
1212
+ </div>
1213
+ <div class="card-tools">
1214
+ <input type="search" id="hosts-filter" class="input input-sm input-search" placeholder="Filter hosts" aria-label="Filter hosts" autocomplete="off">
1215
+ </div>
1216
+ </div>
1217
+ <div id="hosts-skeleton" class="table-wrap" aria-hidden="true">
1218
+ <table>
1219
+ <tbody>
1220
+ <tr class="skeleton-row"><td colspan="5"><span class="skel-bar"></span></td></tr>
1221
+ <tr class="skeleton-row"><td colspan="5"><span class="skel-bar"></span></td></tr>
1222
+ <tr class="skeleton-row"><td colspan="5"><span class="skel-bar"></span></td></tr>
1223
+ </tbody>
1224
+ </table>
1225
+ </div>
1226
+ <div id="hosts-error" class="empty" hidden>
1227
+ <span class="empty-icon" aria-hidden="true">!</span>
1228
+ <p>Hosts couldn't be loaded while the proxy is unreachable.</p>
1229
+ </div>
1230
+ <div id="empty" class="empty" hidden>
1231
+ <span class="empty-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></span>
1232
+ <p class="empty-title">No hosts yet</p>
1233
+ <p class="empty-desc">Add your first host to start proxying a local dev server.</p>
1234
+ <div class="empty-actions">
1235
+ <button type="button" id="empty-add-host-btn" class="btn btn-primary">Add your first host</button>
1236
+ </div>
1237
+ <pre class="code-block">locadot add --host app.localhost --port 3000</pre>
1238
+ </div>
1239
+ <div id="table-wrap" class="table-wrap" hidden>
1240
+ <table id="hosts-table">
1241
+ <thead>
1242
+ <tr>
1243
+ <th scope="col">Host</th>
1244
+ <th scope="col">Options</th>
1245
+ <th scope="col">Status</th>
1246
+ <th scope="col">Public URL</th>
1247
+ <th scope="col" class="actions">Actions</th>
1248
+ </tr>
1249
+ </thead>
1250
+ <tbody id="tbody"></tbody>
1251
+ </table>
1252
+ </div>
1253
+ </section>
1254
+ </div>
1255
+
1256
+ <div data-tab-panel="shared" hidden>
1257
+ <div class="col-primary">
1258
+ <section id="share-card" class="card" aria-label="Shared hosts">
1259
+ <div class="card-head">
1260
+ <div class="card-title-wrap"><h3 class="card-title">Shared hosts</h3><span id="share-count" class="badge badge-count" hidden></span></div>
1261
+ </div>
1262
+ <div id="share-list" class="ov-list"></div>
1263
+ <div id="share-empty" class="empty" hidden>
1264
+ <span class="empty-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg></span>
1265
+ <p>No hosts are public yet. Share one from the All tab.</p>
1266
+ </div>
1267
+ </section>
1268
+ <section id="sharing-card" class="card" aria-label="Cloudflare Tunnel">
1269
+ <div class="card-head">
1270
+ <div class="card-title-wrap"><h3 class="card-title">Cloudflare Tunnel</h3></div>
1271
+ </div>
1272
+ <div class="stat-list">
1273
+ <div class="stat-row">
1274
+ <div class="stat-main">
1275
+ <div class="label">Cloudflare Tunnel</div>
1276
+ <div id="tile-cloudflared-value" class="tile-value">—</div>
1277
+ <div id="tile-cloudflared-sub" class="tile-sub mono"></div>
1278
+ <div class="tile-sub">Share a host on a public https://*.trycloudflare.com URL with the Share button. No Cloudflare account needed.</div>
1279
+ <div class="tile-actions">
1280
+ <button type="button" id="cloudflared-install-btn" class="btn btn-sm btn-primary" hidden>Install cloudflared</button>
1281
+ </div>
1282
+ </div>
1283
+ </div>
1284
+ </div>
1285
+ </section>
1286
+ </div>
1287
+ </div>
1288
+ </section>
1289
+
1290
+ `;
1291
+ exports.allowDialog = `<dialog id="allow-dialog">
1292
+ <div class="modal-head">
1293
+ <div>
1294
+ <h3 class="modal-title">Internal access</h3>
1295
+ <p id="allow-host" class="modal-sub mono"></p>
1296
+ </div>
1297
+ <button type="button" id="allow-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
1298
+ </div>
1299
+ <div class="modal-body">
1300
+ <p class="modal-desc">People who open this mapping through a tunnel or remote access can only reach public hosts. Allow the internal addresses this app calls, such as its API on localhost.</p>
1301
+ <div id="allow-warn" class="modal-warn" hidden>Bypass CORS is off for this mapping. These addresses take effect once it's on, because the page reaches them through locadot's pass-through.</div>
1302
+ <div id="allow-list" class="allow-list"></div>
1303
+ <div class="allow-add-row">
1304
+ <input type="text" id="allow-input" class="input" placeholder="localhost:3000" aria-label="Address" autocomplete="off">
1305
+ <button type="button" id="allow-add" class="btn btn-sm">Add</button>
1306
+ </div>
1307
+ <p class="modal-hint">host:port, or a host alone for any port. locadot's own ports are always blocked.</p>
1308
+ <div id="allow-error" class="field-error" role="alert" hidden></div>
1309
+ </div>
1310
+ <div class="modal-foot">
1311
+ <button type="button" id="allow-cancel" class="btn">Cancel</button>
1312
+ <button type="button" id="allow-save" class="btn btn-primary" disabled>Save changes</button>
1313
+ </div>
1314
+ </dialog>
1315
+ `;
1316
+ exports.hostDialogs = `<dialog id="add-host-dialog">
1317
+ <div class="modal-head">
1318
+ <div>
1319
+ <h3 class="modal-title">Add host</h3>
1320
+ <p class="modal-sub">Proxy a local hostname to a dev server.</p>
1321
+ </div>
1322
+ <button type="button" id="add-host-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
1323
+ </div>
1324
+ <form id="add-form" novalidate>
1325
+ <div class="modal-body">
1326
+ <div class="form-grid">
1327
+ <div class="field">
1328
+ <label for="add-host">Host</label>
1329
+ <input type="text" id="add-host" name="host" placeholder="app.localhost" autocomplete="off" aria-describedby="add-error" required>
1330
+ </div>
1331
+ <div class="field">
1332
+ <label for="add-target">Target</label>
1333
+ <input type="text" id="add-target" name="target" placeholder="3000, 127.0.0.1:8080, https://&hellip;" autocomplete="off" aria-describedby="add-error" required>
1334
+ </div>
1335
+ <div class="form-checks">
1336
+ <div class="field field-checkbox">
1337
+ <input type="checkbox" id="add-insecure" name="insecure">
1338
+ <label for="add-insecure">Insecure TLS</label>
1339
+ </div>
1340
+ <div class="field field-checkbox" title="Send Origin/Referer as the target's own and let any origin call this host">
1341
+ <input type="checkbox" id="add-cors" name="cors">
1342
+ <label for="add-cors">Bypass CORS</label>
1343
+ </div>
1344
+ </div>
1345
+ </div>
1346
+ <div id="add-error" class="field-error" role="alert" hidden></div>
1347
+ </div>
1348
+ <div class="modal-foot">
1349
+ <button type="button" id="add-host-cancel" class="btn">Cancel</button>
1350
+ <button type="submit" id="add-submit" class="btn btn-primary">Add proxy</button>
1351
+ </div>
1352
+ </form>
1353
+ </dialog>
1354
+ <dialog id="share-dialog">
1355
+ <div class="modal-head">
1356
+ <div>
1357
+ <h3 class="modal-title">Share publicly</h3>
1358
+ <p id="share-host" class="modal-sub mono"></p>
1359
+ </div>
1360
+ <button type="button" id="share-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
1361
+ </div>
1362
+ <div class="modal-body">
1363
+ <p class="modal-desc">Anyone with the link will be able to reach this host, without signing in.</p>
1364
+ <div id="share-options" class="share-options" role="radiogroup" aria-label="Sharing method">
1365
+ <button type="button" id="share-option-random" class="option-card" role="radio" aria-checked="false">
1366
+ <span class="option-dot" aria-hidden="true"></span>
1367
+ <span class="option-body">
1368
+ <span class="option-title">Random URL</span>
1369
+ <span class="option-desc">https://&lt;random&gt;.trycloudflare.com &middot; No account needed. Changes each time sharing restarts.</span>
1370
+ </span>
1371
+ </button>
1372
+ <button type="button" id="share-option-custom" class="option-card" role="radio" aria-checked="false">
1373
+ <span class="option-dot" aria-hidden="true"></span>
1374
+ <span class="option-body">
1375
+ <span class="option-title">Custom domain</span>
1376
+ <span class="option-desc">Your own hostname on a domain in your Cloudflare account. Stable URL.</span>
1377
+ <span id="share-custom-extra" class="option-extra" hidden>
1378
+ <label for="share-domain-input" class="dim">Hostname</label>
1379
+ <input type="text" id="share-domain-input" class="input mono" placeholder="app.example.com" autocomplete="off">
1380
+ <div id="share-domain-error" class="field-error" role="alert" hidden></div>
1381
+ <p id="share-domain-hint" class="modal-hint"></p>
1382
+ </span>
1383
+ </span>
1384
+ </button>
1385
+ </div>
1386
+ <div id="share-progress" class="share-progress" hidden>
1387
+ <div id="share-progress-icon" class="share-progress-icon" aria-hidden="true"></div>
1388
+ <div class="share-progress-body">
1389
+ <p id="share-progress-title" class="share-progress-title"></p>
1390
+ <p id="share-progress-text" class="share-progress-text"></p>
1391
+ <ol id="share-steps" class="share-steps" hidden>
1392
+ <li>Sign in to Cloudflare in the tab that opens.</li>
1393
+ <li>Pick the zone <span id="share-steps-domain" class="mono"></span> is in, then <strong>Authorize</strong>.</li>
1394
+ <li>Come back here. This dialog updates by itself.</li>
1395
+ </ol>
1396
+ <a id="share-auth-link" class="btn btn-primary" target="_blank" rel="noopener noreferrer" hidden>Open Cloudflare &#8599;</a>
1397
+ </div>
1398
+ </div>
1399
+ <div id="share-error" class="field-error" role="alert" hidden></div>
1400
+ </div>
1401
+ <div class="modal-foot">
1402
+ <button type="button" id="share-cancel" class="btn">Cancel</button>
1403
+ <button type="button" id="share-submit" class="btn btn-primary">Start sharing</button>
1404
+ </div>
1405
+ </dialog>
1406
+ <dialog id="protect-dialog">
1407
+ <div class="modal-head">
1408
+ <div>
1409
+ <h3 class="modal-title">Password protection</h3>
1410
+ <p id="protect-host" class="modal-sub mono"></p>
1411
+ </div>
1412
+ <button type="button" id="protect-close" class="btn btn-ghost modal-close" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
1413
+ </div>
1414
+ <div class="modal-body">
1415
+ <p class="modal-desc">Visitors see a sign-in page and need this password before they reach the app.</p>
1416
+ <div class="field">
1417
+ <label for="protect-password">Password</label>
1418
+ <div class="field-password">
1419
+ <input type="password" id="protect-password" class="input" autocomplete="new-password">
1420
+ <button type="button" id="protect-toggle" class="field-toggle" aria-label="Show password">
1421
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
1422
+ </button>
1423
+ </div>
1424
+ <p id="protect-hint" class="modal-hint" hidden></p>
1425
+ <div id="protect-error" class="field-error" role="alert" hidden></div>
1426
+ </div>
1427
+ <div class="field">
1428
+ <label>Ask for it on</label>
1429
+ <div id="protect-options" class="share-options" role="group" aria-label="Ask for the password on">
1430
+ <button type="button" id="protect-option-shared" class="option-card" role="checkbox" aria-checked="false" data-scope="shared">
1431
+ <span class="option-check" aria-hidden="true"></span>
1432
+ <span class="option-body">
1433
+ <span class="option-title">Shared link</span>
1434
+ <span class="option-desc">Visitors on the public tunnel URL (Share).</span>
1435
+ </span>
1436
+ </button>
1437
+ <button type="button" id="protect-option-remote" class="option-card" role="checkbox" aria-checked="false" data-scope="remote">
1438
+ <span class="option-check" aria-hidden="true"></span>
1439
+ <span class="option-body">
1440
+ <span class="option-title">Remote access</span>
1441
+ <span class="option-desc">Machines connected to this one through remote access.</span>
1442
+ </span>
1443
+ </button>
1444
+ <button type="button" id="protect-option-local" class="option-card" role="checkbox" aria-checked="false" data-scope="local">
1445
+ <span class="option-check" aria-hidden="true"></span>
1446
+ <span class="option-body">
1447
+ <span class="option-title">This machine &amp; network</span>
1448
+ <span class="option-desc">Browsers on this computer or your local network.</span>
1449
+ </span>
1450
+ </button>
1451
+ </div>
1452
+ <div id="protect-scope-error" class="field-error" role="alert" hidden></div>
1453
+ </div>
1454
+ </div>
1455
+ <div class="modal-foot">
1456
+ <button type="button" id="protect-remove" class="btn btn-danger" hidden>Remove protection</button>
1457
+ <button type="button" id="protect-cancel" class="btn">Cancel</button>
1458
+ <button type="button" id="protect-submit" class="btn btn-primary">Protect host</button>
1459
+ </div>
1460
+ </dialog>
1461
+ `;
1462
+ /** Styles for these pages, appended after theme.ts. */
1463
+ exports.css = `
1464
+ /* ---------- Hosts page ---------- */
1465
+
1466
+ @keyframes hosts-row-flash {
1467
+ 0% { background: var(--accent-bg); box-shadow: inset 3px 0 0 var(--accent); }
1468
+ 100% { background: transparent; box-shadow: none; }
1469
+ }
1470
+ #hosts-table tr.row-highlight { animation: hosts-row-flash 2200ms ease; }
1471
+
1472
+ #hosts-table .status-td .pill { min-width: 96px; }
1473
+ #hosts-table td.actions-td { text-align: right; }
1474
+ #hosts-table td.badges { flex-wrap: wrap; }
1475
+
1476
+ /* Keep every column visible without a scrollbar at the 1100-1279px desktop range
1477
+ (theme.ts's own narrow-column rules only kick in at >=1200px). */
1478
+ #hosts-table .tunnel-status a { max-width: 130px; }
1479
+ #hosts-table .host-target { max-width: 170px; }
1480
+ #hosts-table .row-actions { gap: 6px; }
1481
+ #hosts-table th, #hosts-table td { padding-left: 8px; padding-right: 8px; }
1482
+ #hosts-table th:first-child, #hosts-table td:first-child { padding-left: 14px; }
1483
+ #hosts-table th:last-child, #hosts-table td:last-child { padding-right: 14px; }
1484
+
1485
+ @media (pointer: coarse) {
1486
+ #hosts-table .row-actions .btn, #hosts-table .row-actions .btn-more,
1487
+ #share-list .btn { min-height: 44px; }
1488
+ }
1489
+
1490
+ /* Card layout for tablet & phone: each row becomes a stacked card, not a squashed table. */
1491
+ @media (max-width: 899px) {
1492
+ #hosts-table { min-width: 0; }
1493
+ #hosts-table thead { display: none; }
1494
+ #hosts-table, #hosts-table tbody { display: block; width: 100%; }
1495
+ #hosts-table tr {
1496
+ display: grid;
1497
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1498
+ column-gap: 16px;
1499
+ row-gap: 12px;
1500
+ padding: 16px 20px;
1501
+ border-bottom: 1px solid var(--border);
1502
+ }
1503
+ #hosts-table tr:last-child { border-bottom: none; }
1504
+ #hosts-table tr.row-down { box-shadow: inset 3px 0 0 var(--down); }
1505
+ #hosts-table td {
1506
+ display: flex;
1507
+ flex-direction: column;
1508
+ align-items: flex-start;
1509
+ gap: 4px;
1510
+ padding: 0;
1511
+ border: none;
1512
+ white-space: normal;
1513
+ text-align: left;
1514
+ min-width: 0;
1515
+ }
1516
+ #hosts-table td::before { content: attr(data-label); color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
1517
+ #hosts-table td[data-label=""]::before { content: none; }
1518
+ #hosts-table td:first-child { grid-column: 1 / 3; grid-row: 1; padding-left: 0; }
1519
+ #hosts-table td:last-child { grid-column: 3; grid-row: 1; align-items: flex-end; justify-content: flex-start; }
1520
+ #hosts-table td.status-td { align-items: flex-start; }
1521
+ #hosts-table td.host-td .edit-input { margin-top: 8px; width: 100%; }
1522
+ #hosts-table td.badges { flex-direction: row; flex-wrap: wrap; align-items: center; }
1523
+ #hosts-table td.badges::before { flex-basis: 100%; }
1524
+ #hosts-table .host-target { max-width: 100%; }
1525
+ #hosts-table .host-cell .copy-btn, #hosts-table .tunnel-status .copy-btn { opacity: 1; }
1526
+ #hosts-table .tunnel-status, #hosts-table .tunnel-status a, #hosts-table .tunnel-error-text { max-width: 100%; min-width: 0; }
1527
+ #hosts-table .row-actions { flex-wrap: nowrap; justify-content: flex-end; }
1528
+ }
1529
+ @media (max-width: 560px) {
1530
+ #hosts-table tr { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); padding: 14px 16px; }
1531
+ #hosts-table td:first-child { grid-column: 1 / -1; }
1532
+ #hosts-table td:last-child { grid-column: 1 / -1; grid-row: auto; align-items: stretch; }
1533
+ #hosts-table td:last-child .row-actions { justify-content: space-between; }
1534
+ #hosts-table td.tunnel-td, #hosts-table td[data-label="Public URL"] { grid-column: 1 / -1; }
1535
+ }
1536
+
1537
+ #share-list .ov-side { flex-wrap: wrap; justify-content: flex-end; gap: 8px; row-gap: 6px; }
1538
+ @media (max-width: 640px) {
1539
+ #share-list .ov-row { flex-wrap: wrap; }
1540
+ #share-list .ov-side { flex: 1 1 100%; min-width: 0; justify-content: flex-start; }
1541
+ #share-list .ov-side a.ov-link { max-width: 100%; }
1542
+ }
1543
+ `;