apilive 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js ADDED
@@ -0,0 +1,1026 @@
1
+ // apilive UI.
2
+ // Raw keys are posted once to the local apilive process and never kept here;
3
+ // this page only ever holds masked copies and opaque refs.
4
+
5
+ const TOKEN = document.querySelector('meta[name="apilive-token"]').content;
6
+ const $ = (id) => document.getElementById(id);
7
+ const ACK_KEY = "apilive-ack-v1";
8
+
9
+ const ICONS = {
10
+ lock: '<rect width="16" height="10" x="4" y="11" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
11
+ github: '<path fill="currentColor" stroke="none" d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 0-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.2.5-2.3 1.3-3.1-.2-.4-.6-1.6 0-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8.1 3.2.8.8 1.3 1.9 1.3 3.2 0 4.6-2.8 5.6-5.5 5.9.5.4.9 1 .9 2.2v3.3c0 .3.1.7.8.6A12 12 0 0 0 12 .3"/>',
12
+ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>',
13
+ moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
14
+ chevron: '<path d="m6 9 6 6 6-6"/>',
15
+ refresh: '<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>',
16
+ plus: '<path d="M12 5v14M5 12h14"/>',
17
+ more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
18
+ trash: '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
19
+ x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
20
+ copy: '<rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
21
+ external: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
22
+ shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
23
+ info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>',
24
+ alert: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
25
+ file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
26
+ search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
27
+ download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
28
+ key: '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
29
+ check: '<path d="M20 6 9 17l-5-5"/>',
30
+ eye: '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
31
+ swap: '<path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/>',
32
+ table: '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18M3 15h18"/>',
33
+ };
34
+
35
+ const STATUS = {
36
+ pending: "Not checked",
37
+ checking: "Checking…",
38
+ live: "Live",
39
+ invalid: "Invalid",
40
+ limited: "Rate-limited",
41
+ no_credit: "No credit",
42
+ error: "Unverified",
43
+ };
44
+
45
+ const EXPLAIN = {
46
+ invalid: ["The provider rejected this key", "It may be revoked, mistyped or expired, or belong to a different provider."],
47
+ no_credit: ["This key works, but the account has no credit", "Add credit or billing with the provider to use it."],
48
+ limited: ["This key works, but is rate-limited right now", "Too many requests on this key. Try again in a moment."],
49
+ error: ["apilive couldn't verify this key", "The provider didn't give a clear answer. It may be down, or blocked on your network."],
50
+ pending: ["Not checked yet", "Check this key to see its status."],
51
+ };
52
+
53
+ const DOC_NAV = [
54
+ ["Documentation", ["guide", "changelog"]],
55
+ ["Legal", ["privacy", "terms", "license"]],
56
+ ["Help", ["support", "security"]],
57
+ ];
58
+
59
+ const state = {
60
+ providers: [],
61
+ byId: {},
62
+ docs: {},
63
+ entries: [], // { ref, provider, masked, name, sources, origin, confident, candidates, result, checking, checkedAt }
64
+ filter: "all",
65
+ query: "",
66
+ sheetRef: null,
67
+ cwd: "",
68
+ maxKeys: 100,
69
+ lastChecked: null,
70
+ update: null,
71
+ };
72
+
73
+ // ------------------------------------------------------------------ helpers
74
+
75
+ function h(tag, attrs, ...kids) {
76
+ const el = document.createElement(tag);
77
+ for (const [k, v] of Object.entries(attrs || {})) {
78
+ if (v == null || v === false) continue;
79
+ if (k === "class") el.className = v;
80
+ else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
81
+ else el.setAttribute(k, v === true ? "" : v);
82
+ }
83
+ for (const kid of kids.flat(Infinity)) {
84
+ if (kid == null || kid === false) continue;
85
+ el.append(kid instanceof Node ? kid : String(kid));
86
+ }
87
+ return el;
88
+ }
89
+
90
+ function icon(name, cls = "") {
91
+ const span = h("span", { class: `icon ${cls}`.trim(), "aria-hidden": "true" });
92
+ span.innerHTML = `<svg viewBox="0 0 24 24">${ICONS[name] || ""}</svg>`;
93
+ return span;
94
+ }
95
+
96
+ function hydrateIcons(root = document) {
97
+ root.querySelectorAll("[data-icon]").forEach((el) => {
98
+ el.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${ICONS[el.dataset.icon] || ""}</svg>`;
99
+ });
100
+ }
101
+
102
+ async function api(method, path, body) {
103
+ const res = await fetch(path, {
104
+ method,
105
+ headers: { "x-apilive-token": TOKEN, ...(body ? { "Content-Type": "application/json" } : {}) },
106
+ body: body ? JSON.stringify(body) : undefined,
107
+ });
108
+ const data = await res.json().catch(() => ({}));
109
+ if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
110
+ return data;
111
+ }
112
+
113
+ function toast(message, { error = false } = {}) {
114
+ const el = h("div", { class: `toast${error ? " is-error" : ""}`, role: "status" }, icon(error ? "alert" : "check"), h("span", {}, message));
115
+ $("toasts").append(el);
116
+ setTimeout(() => {
117
+ el.classList.add("is-leaving");
118
+ setTimeout(() => el.remove(), 220);
119
+ }, 2600);
120
+ }
121
+
122
+ async function copy(text, message = "Copied to clipboard") {
123
+ try {
124
+ await navigator.clipboard.writeText(text);
125
+ toast(message);
126
+ } catch {
127
+ toast("Couldn't access the clipboard", { error: true });
128
+ }
129
+ }
130
+
131
+ function maskPreview(k) {
132
+ if (k.length <= 12) return k.slice(0, 2) + "…";
133
+ return `${k.slice(0, Math.min(8, Math.floor(k.length / 5)))}…${k.slice(-4)}`;
134
+ }
135
+
136
+ const pName = (id) => state.byId[id]?.name || "Unknown provider";
137
+ const statusOf = (e) => (e.checking ? "checking" : e.result?.status || "pending");
138
+ const isIssue = (s) => ["invalid", "no_credit", "error", "limited"].includes(s);
139
+ const find = (ref) => state.entries.find((e) => e.ref === ref);
140
+ const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
141
+ const isTyping = () => /INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName) || document.activeElement?.isContentEditable;
142
+
143
+ function mark(providerId, cls = "") {
144
+ const p = state.byId[providerId];
145
+ return h("span", { class: `mono-mark ${cls}`.trim(), "aria-hidden": "true" }, p ? p.mark : "?");
146
+ }
147
+
148
+ function statusEl(s) {
149
+ return h("span", { class: "status", "data-s": s }, s === "checking" ? h("span", { class: "spinner" }) : h("span", { class: "status-dot" }), STATUS[s]);
150
+ }
151
+
152
+ // ------------------------------------------------------------------ theme
153
+
154
+ function initTheme() {
155
+ let saved = null;
156
+ try {
157
+ saved = localStorage.getItem("apilive-theme");
158
+ } catch {}
159
+ if (saved === "light" || saved === "dark") document.documentElement.dataset.theme = saved;
160
+ $("themeBtn").addEventListener("click", () => {
161
+ const current = document.documentElement.dataset.theme || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
162
+ const next = current === "dark" ? "light" : "dark";
163
+ document.documentElement.dataset.theme = next;
164
+ try {
165
+ localStorage.setItem("apilive-theme", next);
166
+ } catch {}
167
+ });
168
+ }
169
+
170
+ // ------------------------------------------------------------------ acknowledgement
171
+
172
+ function acked() {
173
+ try {
174
+ return localStorage.getItem(ACK_KEY) === "1";
175
+ } catch {
176
+ return false;
177
+ }
178
+ }
179
+
180
+ let afterAck = null;
181
+ function requireAck(fn) {
182
+ if (acked()) return fn();
183
+ afterAck = fn;
184
+ $("ackCheck").checked = false;
185
+ $("ackContinue").disabled = true;
186
+ if (!$("ackDialog").open) $("ackDialog").showModal();
187
+ }
188
+
189
+ function wireAck() {
190
+ const dlg = $("ackDialog");
191
+ dlg.addEventListener("cancel", (e) => e.preventDefault());
192
+ $("ackCheck").addEventListener("change", (e) => ($("ackContinue").disabled = !e.target.checked));
193
+ $("ackContinue").addEventListener("click", () => {
194
+ try {
195
+ localStorage.setItem(ACK_KEY, "1");
196
+ } catch {}
197
+ dlg.close();
198
+ const fn = afterAck;
199
+ afterAck = null;
200
+ fn?.();
201
+ });
202
+ dlg.querySelectorAll("[data-close-link]").forEach((a) => a.addEventListener("click", () => dlg.close()));
203
+ }
204
+
205
+ // ------------------------------------------------------------------ router
206
+
207
+ function route() {
208
+ const raw = location.hash.replace(/^#/, "") || "/";
209
+ const [path, anchor] = raw.split("#");
210
+ const m = path.match(/^\/docs\/([a-z]+)/);
211
+ closeSheet();
212
+ closeMenu();
213
+ if (m && state.docs[m[1]]) {
214
+ showView("docs", m[1] === "changelog" ? "changelog" : "docs");
215
+ loadDoc(m[1], anchor);
216
+ } else {
217
+ showView("keys", "keys");
218
+ if (!acked()) requireAck(() => {});
219
+ }
220
+ }
221
+
222
+ function showView(view, nav) {
223
+ $("view-keys").hidden = view !== "keys";
224
+ $("view-docs").hidden = view !== "docs";
225
+ document.querySelectorAll("[data-nav]").forEach((a) => {
226
+ if (a.dataset.nav === nav) a.setAttribute("aria-current", "page");
227
+ else a.removeAttribute("aria-current");
228
+ });
229
+ window.scrollTo(0, 0);
230
+ }
231
+
232
+ // ------------------------------------------------------------------ docs
233
+
234
+ const docCache = {};
235
+
236
+ async function loadDoc(slug, anchor) {
237
+ const body = $("docBody");
238
+ renderDocNav(slug);
239
+ if (!docCache[slug]) {
240
+ body.replaceChildren(h("p", { class: "doc-loading" }, "Loading…"));
241
+ try {
242
+ docCache[slug] = await api("GET", `/api/docs/${slug}`);
243
+ } catch (e) {
244
+ body.replaceChildren(h("p", { class: "doc-loading" }, `Couldn't load this page: ${e.message}`));
245
+ return;
246
+ }
247
+ }
248
+ const doc = docCache[slug];
249
+ body.innerHTML = doc.slug === "license" ? `<h1>License</h1>${doc.html}` : doc.html;
250
+ document.title = `${doc.title} · apilive`;
251
+
252
+ const toc = $("docToc");
253
+ const items = doc.toc.filter((t) => t.level === 2 || t.level === 3);
254
+ toc.replaceChildren(
255
+ ...(items.length > 2
256
+ ? [
257
+ h("h4", {}, "On this page"),
258
+ ...items.map((t) =>
259
+ h("a", { href: `#/docs/${slug}#${t.id}`, class: `lvl-${t.level}`, onclick: (e) => (e.preventDefault(), scrollToId(t.id)) }, t.text),
260
+ ),
261
+ ]
262
+ : []),
263
+ );
264
+ if (anchor) requestAnimationFrame(() => scrollToId(anchor));
265
+ else body.focus({ preventScroll: true });
266
+ }
267
+
268
+ function scrollToId(id) {
269
+ const el = document.getElementById(id);
270
+ if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
271
+ }
272
+
273
+ function renderDocNav(active) {
274
+ $("docsNav").replaceChildren(
275
+ ...DOC_NAV.flatMap(([group, slugs]) => [
276
+ h("h4", {}, group),
277
+ ...slugs
278
+ .filter((s) => state.docs[s])
279
+ .map((s) => h("a", { href: `#/docs/${s}`, "aria-current": s === active ? "page" : null }, state.docs[s])),
280
+ ]),
281
+ );
282
+ }
283
+
284
+ function wireDocs() {
285
+ $("docBody").addEventListener("click", (e) => {
286
+ const a = e.target.closest("a");
287
+ const href = a?.getAttribute("href");
288
+ if (href && href.startsWith("#") && !href.startsWith("#/")) {
289
+ e.preventDefault();
290
+ scrollToId(href.slice(1));
291
+ }
292
+ });
293
+ }
294
+
295
+ // ------------------------------------------------------------------ add dialog
296
+
297
+ const input = $("keyInput");
298
+ const select = $("providerSelect");
299
+
300
+ function detectLocal(text) {
301
+ const forced = select.value || null;
302
+ const out = [];
303
+ for (const raw of text.split(/\r?\n/)) {
304
+ const line = raw.trim();
305
+ if (!line || line.startsWith("#")) continue;
306
+ const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*[=:]\s*["'`]?([^"'`\s#]+)/);
307
+ let name = null;
308
+ let value = line.replace(/^Bearer\s+/i, "").replace(/^["'`]|["'`]$/g, "");
309
+ if (m && m[2].length >= 16) {
310
+ name = m[1];
311
+ value = m[2];
312
+ } else if (m && /^[A-Z][A-Z0-9]*_[A-Z0-9_]*$/.test(m[1])) {
313
+ continue; // NAME=placeholder
314
+ }
315
+ if (/\s/.test(value) || value.length < 8) continue;
316
+ let provider = forced;
317
+ let confident = Boolean(forced);
318
+ if (!provider) {
319
+ const byEnv = name && state.providers.find((p) => p.env.includes(name));
320
+ const exact = !byEnv && state.providers.find((p) => p.pattern && new RegExp(p.pattern).test(value));
321
+ const loose = !byEnv && !exact ? state.providers.filter((p) => p.loose && new RegExp(p.loose).test(value)) : [];
322
+ provider = (byEnv || exact || loose[0])?.id || null;
323
+ confident = Boolean(byEnv || exact);
324
+ }
325
+ out.push({ provider, confident, preview: maskPreview(value), name });
326
+ }
327
+ return out;
328
+ }
329
+
330
+ function updatePreview() {
331
+ const found = input.value.trim() ? detectLocal(input.value) : [];
332
+ const n = found.length;
333
+ $("addSubmit").disabled = n === 0;
334
+ $("addSubmit").textContent = n > 1 ? `Check ${n} keys` : "Check key";
335
+ const pv = $("preview");
336
+ if (!input.value.trim()) return pv.replaceChildren();
337
+ if (!n) return pv.replaceChildren(h("div", { class: "preview-empty" }, "No API keys found in this text."));
338
+ const shown = found.slice(0, 6);
339
+ pv.replaceChildren(
340
+ ...shown.map((f) =>
341
+ h(
342
+ "div",
343
+ { class: "preview-row" },
344
+ mark(f.provider),
345
+ h("span", {}, f.provider ? pName(f.provider) : h("span", { class: "muted" }, "Unknown. Choose a provider after adding")),
346
+ f.provider && !f.confident ? h("span", { class: "tag" }, "Guess") : null,
347
+ h("span", { class: "key-text" }, f.preview),
348
+ ),
349
+ ),
350
+ ...(n > shown.length ? [h("div", { class: "preview-more" }, `and ${n - shown.length} more`)] : []),
351
+ );
352
+ }
353
+
354
+ function openAdd() {
355
+ requireAck(() => {
356
+ closeSheet();
357
+ const dlg = $("addDialog");
358
+ if (!dlg.open) dlg.showModal();
359
+ input.focus();
360
+ updatePreview();
361
+ });
362
+ }
363
+
364
+ async function submitAdd() {
365
+ const text = input.value.trim();
366
+ if (!text) return;
367
+ const btn = $("addSubmit");
368
+ btn.disabled = true;
369
+ try {
370
+ const { added, duplicates, limited, maxKeys } = await api("POST", "/api/keys", { text, provider: select.value || null });
371
+ if (!added.length) {
372
+ toast(limited ? `Limit reached: apilive checks at most ${maxKeys} keys per session` : duplicates ? "Those keys are already in the list" : "No API keys found in that text", { error: !duplicates });
373
+ return;
374
+ }
375
+ $("addDialog").close();
376
+ input.value = "";
377
+ select.value = "";
378
+ for (const e of added.slice().reverse()) state.entries.unshift(prepare(e));
379
+ render();
380
+ runChecks(added.filter((e) => e.provider).map((e) => e.ref));
381
+ const notes = [duplicates && `${duplicates} already listed`, limited && `${limited} over the ${maxKeys}-key limit`].filter(Boolean);
382
+ toast(`Added ${plural(added.length, "key")}${notes.length ? ` · ${notes.join(", ")}` : ""}`);
383
+ const unknown = added.find((e) => !e.provider);
384
+ if (unknown) openSheet(unknown.ref);
385
+ } catch (e) {
386
+ toast(e.message, { error: true });
387
+ } finally {
388
+ btn.disabled = false;
389
+ updatePreview();
390
+ }
391
+ }
392
+
393
+ function wireAdd() {
394
+ const dlg = $("addDialog");
395
+ input.addEventListener("input", updatePreview);
396
+ select.addEventListener("change", updatePreview);
397
+ input.addEventListener("keydown", (e) => {
398
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
399
+ e.preventDefault();
400
+ submitAdd();
401
+ }
402
+ });
403
+ $("addSubmit").addEventListener("click", submitAdd);
404
+ dlg.querySelectorAll("[data-close]").forEach((b) => b.addEventListener("click", () => dlg.close()));
405
+ dlg.addEventListener("click", (e) => e.target === dlg && dlg.close());
406
+ }
407
+
408
+ const prepare = (e) => ({ ...e, result: null, checking: false, checkedAt: null });
409
+
410
+ // ------------------------------------------------------------------ checks
411
+
412
+ const queue = [];
413
+ let active = 0;
414
+
415
+ function runChecks(refs) {
416
+ requireAck(() => {
417
+ for (const ref of refs) {
418
+ const e = find(ref);
419
+ if (!e || !e.provider || e.checking) continue;
420
+ e.checking = true;
421
+ queue.push(ref);
422
+ }
423
+ render();
424
+ pump();
425
+ });
426
+ }
427
+
428
+ function pump() {
429
+ while (active < 6 && queue.length) {
430
+ const ref = queue.shift();
431
+ active++;
432
+ checkOne(ref).finally(() => {
433
+ active--;
434
+ pump();
435
+ });
436
+ }
437
+ }
438
+
439
+ async function checkOne(ref) {
440
+ const e = find(ref);
441
+ if (!e) return;
442
+ try {
443
+ e.result = await api("POST", "/api/check", { ref });
444
+ } catch (err) {
445
+ e.result = { status: "error", message: /fetch|network/i.test(err.message) ? "Lost connection to apilive. Is it still running?" : err.message };
446
+ }
447
+ e.checking = false;
448
+ e.checkedAt = new Date();
449
+ state.lastChecked = e.checkedAt;
450
+ if (!find(ref)) return;
451
+ render();
452
+ if (state.sheetRef === ref) renderSheet();
453
+ }
454
+
455
+ async function removeEntry(ref) {
456
+ state.entries = state.entries.filter((e) => e.ref !== ref);
457
+ if (state.sheetRef === ref) closeSheet();
458
+ render();
459
+ api("DELETE", `/api/keys/${ref}`).catch(() => {});
460
+ toast("Key removed from memory");
461
+ }
462
+
463
+ async function setProvider(ref, provider) {
464
+ try {
465
+ const updated = await api("PATCH", `/api/keys/${ref}`, { provider });
466
+ const e = find(ref);
467
+ Object.assign(e, updated, { result: null });
468
+ render();
469
+ renderSheet();
470
+ runChecks([ref]);
471
+ } catch (err) {
472
+ toast(err.message, { error: true });
473
+ }
474
+ }
475
+
476
+ // ------------------------------------------------------------------ table
477
+
478
+ function visibleEntries() {
479
+ const q = state.query.trim().toLowerCase();
480
+ return state.entries.filter((e) => {
481
+ const s = statusOf(e);
482
+ if (state.filter === "live" && s !== "live") return false;
483
+ if (state.filter === "issues" && !isIssue(s)) return false;
484
+ if (state.filter === "pending" && s !== "pending") return false;
485
+ if (!q) return true;
486
+ return [pName(e.provider), e.masked, ...(e.sources || [])].join(" ").toLowerCase().includes(q);
487
+ });
488
+ }
489
+
490
+ function render() {
491
+ const has = state.entries.length > 0;
492
+ const rows = visibleEntries();
493
+ $("tbody").replaceChildren(...rows.map(buildRow));
494
+ $("table").hidden = !has;
495
+ $("emptyState").hidden = has;
496
+ $("noMatch").hidden = !has || rows.length > 0;
497
+ $("cardFoot").hidden = !has;
498
+ renderTabs();
499
+ renderFoot();
500
+ renderBanner();
501
+ $("recheckAllBtn").disabled = !state.entries.some((e) => e.provider && !e.checking);
502
+ }
503
+
504
+ function buildRow(e) {
505
+ const s = statusOf(e);
506
+ const r = e.result || {};
507
+ // A factory, not a node: a DOM node can only be mounted in one cell.
508
+ const pending = () => (s === "checking" ? h("span", { class: "skeleton" }) : h("span", { class: "muted" }, "—"));
509
+ const live = s === "live";
510
+
511
+ const menuBtn = h(
512
+ "button",
513
+ {
514
+ class: "btn-icon row-menu-btn",
515
+ type: "button",
516
+ "aria-label": `Actions for ${pName(e.provider)} key`,
517
+ "aria-haspopup": "menu",
518
+ onclick: (ev) => {
519
+ ev.stopPropagation();
520
+ openRowMenu(ev.currentTarget, e);
521
+ },
522
+ },
523
+ icon("more"),
524
+ );
525
+
526
+ const tr = h(
527
+ "tr",
528
+ {
529
+ tabindex: "0",
530
+ class: state.sheetRef === e.ref ? "is-active" : null,
531
+ "aria-label": `${pName(e.provider)}, ${STATUS[s]}. Open details`,
532
+ onclick: () => openSheet(e.ref),
533
+ onkeydown: (ev) => {
534
+ if (ev.key === "Enter" || ev.key === " ") {
535
+ ev.preventDefault();
536
+ openSheet(e.ref);
537
+ }
538
+ },
539
+ },
540
+ h(
541
+ "td",
542
+ {},
543
+ h(
544
+ "div",
545
+ { class: "cell-provider" },
546
+ mark(e.provider),
547
+ h("span", { class: "provider-name" }, e.provider ? pName(e.provider) : "Unknown"),
548
+ !e.provider ? h("span", { class: "tag" }, "Choose provider") : !e.confident ? h("span", { class: "tag", title: "Detected from the key format. Open to confirm." }, "Guess") : null,
549
+ ),
550
+ ),
551
+ h("td", {}, h("span", { class: "key-text in-table" }, e.masked)),
552
+ h("td", { class: "col-source" }, h("span", { class: "source-text", title: (e.sources || []).join("\n") }, e.sources?.length ? e.sources.join(", ") : "Pasted")),
553
+ h("td", {}, statusEl(s)),
554
+ h(
555
+ "td",
556
+ { class: "num col-latency" },
557
+ r.latencyMs != null && s !== "checking" ? h("span", { class: r.latencyMs > 1500 ? "warn-text" : null }, `${r.latencyMs} ms`) : pending(),
558
+ ),
559
+ h("td", { class: "num col-models" }, live && r.modelCount ? String(r.modelCount) : pending()),
560
+ h("td", { class: "num col-credit" }, live && r.balance ? r.balance : pending()),
561
+ h("td", { class: "col-actions" }, menuBtn),
562
+ );
563
+ return tr;
564
+ }
565
+
566
+ function counts() {
567
+ const c = { all: state.entries.length, live: 0, issues: 0, pending: 0, checking: 0 };
568
+ for (const e of state.entries) {
569
+ const s = statusOf(e);
570
+ if (s === "live") c.live++;
571
+ else if (isIssue(s)) c.issues++;
572
+ else if (s === "checking") c.checking++;
573
+ else c.pending++;
574
+ }
575
+ return c;
576
+ }
577
+
578
+ function renderTabs() {
579
+ const c = counts();
580
+ const tabs = [
581
+ ["all", "All", c.all],
582
+ ["live", "Live", c.live],
583
+ ["issues", "Needs attention", c.issues],
584
+ ["pending", "Not checked", c.pending],
585
+ ];
586
+ $("tabs").replaceChildren(
587
+ ...tabs
588
+ .filter(([id, , n]) => id === "all" || id === state.filter || n > 0)
589
+ .map(([id, label, n]) =>
590
+ h(
591
+ "button",
592
+ {
593
+ type: "button",
594
+ role: "tab",
595
+ "aria-selected": String(state.filter === id),
596
+ onclick: () => {
597
+ state.filter = id;
598
+ render();
599
+ },
600
+ },
601
+ label,
602
+ h("span", { class: "count" }, n),
603
+ ),
604
+ ),
605
+ );
606
+ }
607
+
608
+ function renderFoot() {
609
+ const c = counts();
610
+ const lat = state.entries.filter((e) => statusOf(e) === "live" && e.result?.latencyMs != null).map((e) => e.result.latencyMs);
611
+ const avg = lat.length ? Math.round(lat.reduce((a, b) => a + b, 0) / lat.length) : null;
612
+ $("cardFoot").replaceChildren(
613
+ h(
614
+ "div",
615
+ { class: "foot-stats" },
616
+ h("span", {}, h("b", {}, c.all), ` ${c.all === 1 ? "key" : "keys"}`),
617
+ h("span", {}, h("b", {}, c.live), " live"),
618
+ c.issues ? h("span", {}, h("b", {}, c.issues), " need attention") : null,
619
+ avg != null ? h("span", {}, "avg ", h("b", {}, `${avg} ms`)) : null,
620
+ c.checking ? h("span", {}, `checking ${c.checking}…`) : null,
621
+ ),
622
+ h("span", {}, state.lastChecked ? `Last checked ${state.lastChecked.toLocaleTimeString()}` : "Nothing checked yet"),
623
+ );
624
+ document.title = c.all && !c.checking && state.lastChecked ? `${c.live}/${c.all} live · apilive` : "apilive";
625
+ }
626
+
627
+ function renderBanner() {
628
+ const envEntries = state.entries.filter((e) => e.origin === "env");
629
+ const unchecked = envEntries.filter((e) => !e.result && !e.checking);
630
+ $("envBanner").hidden = !unchecked.length;
631
+ if (!unchecked.length) return;
632
+ const where = [...new Set(envEntries.flatMap((e) => e.sources.map((s) => s.split(" · ").pop())))].map((s) => (s === "env" ? "environment variables" : s));
633
+ $("envBannerTitle").textContent = `Found ${plural(unchecked.length, "key")} in ${state.cwd || "this folder"}`;
634
+ $("envBannerSub").textContent = `From ${where.join(", ")}. Nothing is sent until you check.`;
635
+ }
636
+
637
+ // ------------------------------------------------------------------ menus
638
+
639
+ let menuAnchor = null;
640
+
641
+ function openMenu(anchor, items) {
642
+ const menu = $("menu");
643
+ if (menuAnchor === anchor && !menu.hidden) return closeMenu();
644
+ menu.replaceChildren(
645
+ ...items.map((it) => {
646
+ if (it === "sep") return h("hr");
647
+ if (it.note) return h("div", { class: "menu-note" }, it.note);
648
+ const attrs = { role: "menuitem", class: it.danger ? "danger" : null };
649
+ if (it.href) return h("a", { ...attrs, href: it.href, target: "_blank", rel: "noopener noreferrer", onclick: closeMenu }, icon(it.icon), it.label);
650
+ return h(
651
+ "button",
652
+ {
653
+ ...attrs,
654
+ type: "button",
655
+ disabled: it.disabled,
656
+ onclick: () => {
657
+ closeMenu();
658
+ it.onClick();
659
+ },
660
+ },
661
+ icon(it.icon),
662
+ it.label,
663
+ );
664
+ }),
665
+ );
666
+ menu.hidden = false;
667
+ menuAnchor = anchor;
668
+ anchor.setAttribute("aria-expanded", "true");
669
+ const r = anchor.getBoundingClientRect();
670
+ const mw = menu.offsetWidth;
671
+ const mh = menu.offsetHeight;
672
+ const top = r.bottom + 6 + mh > innerHeight ? r.top - mh - 6 : r.bottom + 6;
673
+ menu.style.top = `${Math.max(8, top)}px`;
674
+ menu.style.left = `${Math.min(innerWidth - mw - 8, Math.max(8, r.right - mw))}px`;
675
+ menu.querySelector("button, a")?.focus();
676
+ }
677
+
678
+ function closeMenu() {
679
+ const menu = $("menu");
680
+ if (menu.hidden) return;
681
+ menu.hidden = true;
682
+ menuAnchor?.setAttribute("aria-expanded", "false");
683
+ menuAnchor = null;
684
+ }
685
+
686
+ function openRowMenu(anchor, e) {
687
+ const p = state.byId[e.provider];
688
+ openMenu(anchor, [
689
+ { label: "Re-check", icon: "refresh", disabled: !e.provider || e.checking, onClick: () => runChecks([e.ref]) },
690
+ { label: "View details", icon: "eye", onClick: () => openSheet(e.ref) },
691
+ { label: "Change provider", icon: "swap", onClick: () => openSheet(e.ref, { focusProvider: true }) },
692
+ p ? { label: `Open ${p.name} dashboard`, icon: "external", href: p.keyUrl } : null,
693
+ "sep",
694
+ { label: "Remove", icon: "trash", danger: true, onClick: () => removeEntry(e.ref) },
695
+ ].filter(Boolean));
696
+ }
697
+
698
+ function openExportMenu(anchor) {
699
+ openMenu(anchor, [
700
+ { label: "Copy as Markdown", icon: "copy", onClick: exportMarkdown },
701
+ { label: "Download CSV", icon: "table", onClick: () => download("csv") },
702
+ { label: "Download JSON", icon: "download", onClick: () => download("json") },
703
+ "sep",
704
+ { note: "Keys are always masked in exports." },
705
+ ]);
706
+ }
707
+
708
+ // ------------------------------------------------------------------ sheet
709
+
710
+ let sheetReturnFocus = null;
711
+
712
+ function openSheet(ref, { focusProvider = false } = {}) {
713
+ if (!find(ref)) return;
714
+ closeMenu();
715
+ if (!state.sheetRef) sheetReturnFocus = document.activeElement;
716
+ state.sheetRef = ref;
717
+ $("sheet").hidden = false;
718
+ $("sheetBackdrop").hidden = false;
719
+ renderSheet();
720
+ render();
721
+ const target = focusProvider ? $("sheet").querySelector("select") : $("sheet").querySelector("[data-sheet-close]");
722
+ (target || $("sheet").querySelector("[data-sheet-close]"))?.focus();
723
+ }
724
+
725
+ function closeSheet() {
726
+ if (!state.sheetRef) return;
727
+ state.sheetRef = null;
728
+ $("sheet").hidden = true;
729
+ $("sheetBackdrop").hidden = true;
730
+ render();
731
+ if (sheetReturnFocus?.isConnected) sheetReturnFocus.focus();
732
+ }
733
+
734
+ function renderSheet() {
735
+ const e = find(state.sheetRef);
736
+ if (!e) return closeSheet();
737
+ const s = statusOf(e);
738
+ const r = e.result || {};
739
+ const p = state.byId[e.provider];
740
+ const sheet = $("sheet");
741
+
742
+ const providerField = h(
743
+ "div",
744
+ { class: "select" },
745
+ h(
746
+ "select",
747
+ { "aria-label": "Provider for this key", onchange: (ev) => ev.target.value && ev.target.value !== e.provider && setProvider(e.ref, ev.target.value) },
748
+ !e.provider ? h("option", { value: "" }, "Choose a provider…") : null,
749
+ e.candidates?.length ? h("optgroup", { label: "Matches this key format" }, e.candidates.map((id) => h("option", { value: id, selected: id === e.provider }, pName(id)))) : null,
750
+ h(
751
+ "optgroup",
752
+ { label: "All providers" },
753
+ state.providers.filter((x) => !e.candidates?.includes(x.id)).map((x) => h("option", { value: x.id, selected: x.id === e.provider }, x.name)),
754
+ ),
755
+ ),
756
+ icon("chevron", "icon-sm"),
757
+ );
758
+
759
+ const rows = [
760
+ ["Status", statusEl(s)],
761
+ ["Provider", e.confident && e.provider ? pName(e.provider) : providerField],
762
+ ["Key", h("span", { class: "key-text" }, e.masked)],
763
+ ["Found in", e.sources?.length ? e.sources.join(", ") : "Pasted"],
764
+ r.latencyMs != null ? ["Latency", `${r.latencyMs} ms`] : null,
765
+ r.httpStatus ? ["HTTP status", String(r.httpStatus)] : null,
766
+ r.region ? ["Endpoint", r.region] : null,
767
+ r.balance ? ["Credit", r.balance] : null,
768
+ r.account ? ["Account", r.account] : null,
769
+ r.rateLimit ? ["Rate limit", r.rateLimit] : null,
770
+ e.checkedAt ? ["Checked", e.checkedAt.toLocaleTimeString()] : null,
771
+ p ? ["Manage", h("a", { href: p.keyUrl, target: "_blank", rel: "noopener noreferrer" }, "Key dashboard", icon("external", "icon-sm"))] : null,
772
+ ].filter(Boolean);
773
+
774
+ const parts = [];
775
+ if (!e.confident && e.provider) {
776
+ parts.push(
777
+ h(
778
+ "div",
779
+ { class: "alert", "data-s": "limited" },
780
+ icon("info"),
781
+ h("div", {}, h("strong", {}, "Provider guessed from the key format"), h("p", {}, "Several providers use this format. Confirm the provider below. apilive never tries a key against more than one provider.")),
782
+ ),
783
+ );
784
+ } else if (!e.provider) {
785
+ parts.push(h("div", { class: "alert", "data-s": "pending" }, icon("info"), h("div", {}, h("strong", {}, "Choose a provider"), h("p", {}, "apilive couldn't tell which provider this key belongs to."))));
786
+ } else if (EXPLAIN[s] && s !== "pending") {
787
+ const [title, text] = EXPLAIN[s];
788
+ parts.push(h("div", { class: "alert", "data-s": s }, icon(s === "error" ? "info" : "alert"), h("div", {}, h("strong", {}, title), h("p", {}, text), r.message ? h("code", {}, r.message) : null)));
789
+ }
790
+
791
+ parts.push(h("section", {}, h("h3", {}, "Details"), h("dl", { class: "props" }, rows.map(([k, v]) => h("div", {}, h("dt", {}, k), h("dd", {}, v))))));
792
+
793
+ if (r.models?.length) {
794
+ const list = h("ul", { class: "model-list" });
795
+ const fill = (q = "") => {
796
+ const items = r.models.filter((m) => m.toLowerCase().includes(q.toLowerCase()));
797
+ list.replaceChildren(
798
+ ...(items.length
799
+ ? items.map((m) => h("li", {}, h("button", { type: "button", title: "Copy model ID", onclick: () => copy(m, `Copied ${m}`) }, m, icon("copy", "icon-sm"))))
800
+ : [h("li", { class: "none" }, "No models match.")]),
801
+ );
802
+ };
803
+ fill();
804
+ parts.push(
805
+ h(
806
+ "section",
807
+ {},
808
+ h(
809
+ "div",
810
+ { class: "models-tools" },
811
+ h("h3", {}, `Models (${r.models.length})`),
812
+ h(
813
+ "label",
814
+ { class: "search" },
815
+ icon("search"),
816
+ h("input", { type: "search", placeholder: "Filter models", "aria-label": "Filter models", oninput: (ev) => fill(ev.target.value) }),
817
+ ),
818
+ ),
819
+ list,
820
+ ),
821
+ );
822
+ }
823
+
824
+ sheet.replaceChildren(
825
+ h(
826
+ "div",
827
+ { class: "sheet-head" },
828
+ mark(e.provider, "lg"),
829
+ h("div", { class: "sheet-title" }, h("h2", { id: "sheetTitle" }, e.provider ? pName(e.provider) : "Unknown provider"), h("span", { class: "key-text" }, e.masked)),
830
+ h("button", { class: "btn-icon", type: "button", "aria-label": "Close details", "data-sheet-close": true, onclick: closeSheet }, icon("x")),
831
+ ),
832
+ h("div", { class: "sheet-body" }, parts),
833
+ h(
834
+ "div",
835
+ { class: "sheet-foot" },
836
+ h("button", { class: "btn btn-danger btn-sm", type: "button", onclick: () => removeEntry(e.ref) }, icon("trash"), "Remove"),
837
+ h("span", { class: "spacer" }),
838
+ r.models?.length ? h("button", { class: "btn btn-secondary btn-sm", type: "button", onclick: () => copy(r.models.join("\n"), `Copied ${plural(r.models.length, "model ID")}`) }, icon("copy"), "Copy models") : null,
839
+ h("button", { class: "btn btn-primary btn-sm", type: "button", disabled: !e.provider || e.checking, onclick: () => runChecks([e.ref]) }, icon("refresh"), e.checking ? "Checking…" : s === "pending" ? "Check" : "Re-check"),
840
+ ),
841
+ );
842
+ }
843
+
844
+ // ------------------------------------------------------------------ export
845
+
846
+ function report() {
847
+ return state.entries.map((e) => ({
848
+ provider: pName(e.provider),
849
+ key: e.masked,
850
+ source: e.sources?.join(", ") || "pasted",
851
+ status: statusOf(e),
852
+ latencyMs: e.result?.latencyMs ?? null,
853
+ models: e.result?.modelCount ?? null,
854
+ credit: e.result?.balance ?? null,
855
+ message: e.result && e.result.status !== "live" ? (e.result.message ?? null) : null,
856
+ checkedAt: e.checkedAt?.toISOString() ?? null,
857
+ }));
858
+ }
859
+
860
+ function exportMarkdown() {
861
+ const lines = report().map((r) => `| ${r.provider} | \`${r.key}\` | ${STATUS[r.status]} | ${r.latencyMs != null ? r.latencyMs + " ms" : "—"} | ${r.models ?? "—"} | ${r.credit ?? "—"} |`);
862
+ copy(
863
+ [
864
+ `**apilive report**, ${new Date().toLocaleString()}`,
865
+ "",
866
+ "| Provider | Key | Status | Latency | Models | Credit |",
867
+ "|---|---|---|---:|---:|---|",
868
+ ...lines,
869
+ ].join("\n"),
870
+ "Markdown report copied",
871
+ );
872
+ }
873
+
874
+ function download(kind) {
875
+ const rows = report();
876
+ let blob;
877
+ if (kind === "csv") {
878
+ const cols = Object.keys(rows[0] || { provider: "" });
879
+ const cell = (v) => {
880
+ const s = v == null ? "" : String(v);
881
+ // Neutralise spreadsheet formulas (CSV injection).
882
+ const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
883
+ return /[",\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
884
+ };
885
+ blob = new Blob([[cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n")], { type: "text/csv" });
886
+ } else {
887
+ blob = new Blob([JSON.stringify({ generatedAt: new Date().toISOString(), results: rows }, null, 2)], { type: "application/json" });
888
+ }
889
+ const a = h("a", { href: URL.createObjectURL(blob), download: `apilive-report.${kind}` });
890
+ document.body.append(a);
891
+ a.click();
892
+ a.remove();
893
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
894
+ toast(`Downloaded apilive-report.${kind}`);
895
+ }
896
+
897
+ // ------------------------------------------------------------------ update
898
+
899
+ async function loadUpdate() {
900
+ try {
901
+ const u = await api("GET", "/api/update");
902
+ state.update = u;
903
+ if (!u.updateAvailable) return;
904
+ $("updateChipText").textContent = `Update: v${u.latest}`;
905
+ $("updateChip").hidden = false;
906
+ $("updateDesc").textContent = `apilive ${u.latest} is available. You have ${u.current}.`;
907
+ $("updateCommandText").textContent = u.command || "npx apilive@latest";
908
+ $("releaseNotesLink").href = u.releasesUrl;
909
+ } catch {}
910
+ }
911
+
912
+ function wireUpdate() {
913
+ const dlg = $("updateDialog");
914
+ $("updateChip").addEventListener("click", () => dlg.showModal());
915
+ $("updateCommand").addEventListener("click", () => copy($("updateCommandText").textContent, "Command copied"));
916
+ dlg.querySelectorAll("[data-close]").forEach((b) => b.addEventListener("click", () => dlg.close()));
917
+ dlg.addEventListener("click", (e) => e.target === dlg && dlg.close());
918
+ }
919
+
920
+ // ------------------------------------------------------------------ providers
921
+
922
+ function renderProviders() {
923
+ $("providerList").replaceChildren(
924
+ ...state.providers.map((p) =>
925
+ h(
926
+ "li",
927
+ {},
928
+ h(
929
+ "a",
930
+ { href: p.keyUrl, target: "_blank", rel: "noopener noreferrer", title: `Open the ${p.name} API key page` },
931
+ mark(p.id),
932
+ h("span", { class: "pl-text" }, h("span", { class: "pl-name" }, p.name), h("span", { class: "pl-env" }, p.env[0])),
933
+ ),
934
+ ),
935
+ ),
936
+ );
937
+ }
938
+
939
+ // ------------------------------------------------------------------ wiring
940
+
941
+ function wire() {
942
+ $("addBtn").addEventListener("click", openAdd);
943
+ $("emptyAddBtn").addEventListener("click", openAdd);
944
+ $("recheckAllBtn").addEventListener("click", () => runChecks(state.entries.map((e) => e.ref)));
945
+ $("envCheckBtn").addEventListener("click", () => runChecks(state.entries.filter((e) => e.origin === "env").map((e) => e.ref)));
946
+ $("exportBtn").addEventListener("click", (e) => {
947
+ e.stopPropagation();
948
+ if (!state.entries.length) return toast("Nothing to export yet", { error: true });
949
+ openExportMenu(e.currentTarget);
950
+ });
951
+ $("searchInput").addEventListener("input", (e) => {
952
+ state.query = e.target.value;
953
+ render();
954
+ });
955
+ $("sheetBackdrop").addEventListener("click", closeSheet);
956
+
957
+ document.addEventListener("click", (e) => {
958
+ if (!$("menu").contains(e.target)) closeMenu();
959
+ });
960
+ window.addEventListener("resize", closeMenu);
961
+ window.addEventListener("scroll", closeMenu, { passive: true });
962
+ window.addEventListener("hashchange", route);
963
+
964
+ document.addEventListener("keydown", (e) => {
965
+ if (e.key === "Escape") {
966
+ if (!$("menu").hidden) return closeMenu();
967
+ if (state.sheetRef) return closeSheet();
968
+ }
969
+ if (document.querySelector("dialog[open]") || e.metaKey || e.ctrlKey || e.altKey || isTyping()) return;
970
+ if ($("view-keys").hidden) return;
971
+ if (e.key === "k" || e.key === "K") {
972
+ e.preventDefault();
973
+ openAdd();
974
+ } else if (e.key === "/") {
975
+ e.preventDefault();
976
+ $("searchInput").focus();
977
+ } else if ((e.key === "r" || e.key === "R") && !state.sheetRef) {
978
+ e.preventDefault();
979
+ if (!$("recheckAllBtn").disabled) runChecks(state.entries.map((x) => x.ref));
980
+ }
981
+ });
982
+
983
+ // Simple focus trap for the sheet.
984
+ $("sheet").addEventListener("keydown", (e) => {
985
+ if (e.key !== "Tab") return;
986
+ const f = [...$("sheet").querySelectorAll("button:not(:disabled), a[href], input, select")];
987
+ if (!f.length) return;
988
+ if (e.shiftKey && document.activeElement === f[0]) {
989
+ e.preventDefault();
990
+ f[f.length - 1].focus();
991
+ } else if (!e.shiftKey && document.activeElement === f[f.length - 1]) {
992
+ e.preventDefault();
993
+ f[0].focus();
994
+ }
995
+ });
996
+ }
997
+
998
+ async function init() {
999
+ initTheme();
1000
+ hydrateIcons();
1001
+ wire();
1002
+ wireAck();
1003
+ wireAdd();
1004
+ wireDocs();
1005
+ wireUpdate();
1006
+ try {
1007
+ const s = await api("GET", "/api/state");
1008
+ state.providers = s.providers;
1009
+ state.byId = Object.fromEntries(s.providers.map((p) => [p.id, p]));
1010
+ state.docs = Object.fromEntries(s.docs.map((d) => [d.slug, d.title]));
1011
+ state.cwd = s.cwd;
1012
+ state.maxKeys = s.maxKeys;
1013
+ state.entries = s.keys.map(prepare);
1014
+ $("footerVersion").textContent = `v${s.version}`;
1015
+ $("demoChip").hidden = !s.demo;
1016
+ for (const p of s.providers) select.append(h("option", { value: p.id }, p.name));
1017
+ } catch {
1018
+ toast("Can't reach apilive. Is it still running in your terminal?", { error: true });
1019
+ }
1020
+ renderProviders();
1021
+ render();
1022
+ route();
1023
+ loadUpdate();
1024
+ }
1025
+
1026
+ init();