227 lines
21 KiB
JavaScript
227 lines
21 KiB
JavaScript
"use strict";
|
|
/* Kazeia-central — shell d'application (clinique-apaisé, sans build). */
|
|
|
|
const S = { initialized: false, unlocked: false, devices: [], selected: null, section: "flotte" };
|
|
|
|
// ---- helpers --------------------------------------------------------------
|
|
const $ = (s, r = document) => r.querySelector(s);
|
|
function elFrom(html) { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstChild; }
|
|
async function api(path, opts) {
|
|
const r = await fetch("/api" + path, opts);
|
|
if (!r.ok) { let d = r.status; try { d = (await r.json()).detail || d; } catch {} const e = new Error(d); e.status = r.status; throw e; }
|
|
return r.status === 204 ? null : r.json();
|
|
}
|
|
function toast(msg, kind = "info", ms = 3800) {
|
|
const t = elFrom(`<div class="toast ${kind}">${msg}</div>`); $("#toasts").appendChild(t);
|
|
setTimeout(() => t.remove(), ms);
|
|
}
|
|
const esc = (s) => (s == null ? "" : String(s)).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
const badge = (txt, cls = "") => `<span class="badge ${cls}">${esc(txt)}</span>`;
|
|
const dot = (cls) => `<span class="dot ${cls}"></span>`;
|
|
const fmtTs = (ms) => ms ? new Date(ms).toLocaleString("fr-FR") : "—";
|
|
const scopeBadge = (s) => badge(s || "?", { exclusive: "warn", global: "ok", pending: "" }[s] || "");
|
|
function deviceLabel(serial) { const d = S.devices.find(x => x.serial === serial); return d && d.label ? d.label : serial; }
|
|
async function withBusy(btn, fn) { const o = btn.textContent; btn.disabled = true; btn.innerHTML = `<span class="spinner"></span>`; try { return await fn(); } finally { btn.disabled = false; btn.textContent = o; } }
|
|
|
|
// ---- sections -------------------------------------------------------------
|
|
const SECTIONS = [
|
|
{ group: "🔧 Technique", domain: "tech" },
|
|
{ id: "flotte", ic: "🛰", label: "Flotte", render: renderFlotte },
|
|
{ id: "voix", ic: "🎙", label: "Voix", render: renderVoix },
|
|
{ id: "updates", ic: "⬆", label: "Mises à jour", render: renderUpdates },
|
|
{ id: "config", ic: "⚙", label: "Config & moteur", render: () => stub("Config & moteur", "Config sampling (température, presets…) et prompts moteur, avec push par tablette. Dépend des méthodes call() base64-JSON côté app patiente (PROVIDER_RPC_SPEC).") },
|
|
{ id: "audit", ic: "📋", label: "Audit", render: renderAudit },
|
|
{ group: "⚕️ Médical / clinique", domain: "med" },
|
|
{ id: "patients", ic: "👤", label: "Patients & profils", render: () => stub("Patients & fiche patient", "Dossier patient chiffré côté Kazeia-central (identité, contact, clinique) + profils on-device + langue par patient. La fiche est constructible maintenant ; la propagation de la langue passe par l'app patiente (PROFILE_LANGUAGE_SPEC livrée).") },
|
|
{ id: "conversations", ic: "💬", label: "Conversations", render: () => stub("Conversations cliniques", "Rapatriement chiffré des conversations + export PDF/JSON + purge + collecteur de fond. Cœur RGPD. Dépend de conversations_export côté app patiente.") },
|
|
{ id: "rag", ic: "📚", label: "RAG thérapeutique", render: () => stub("Corpus RAG thérapeutique", "Authoring du corpus clinique + test retrieval + publication OTA. Dépend de rag_upsert_json côté app patiente.") },
|
|
{ id: "questionnaires", ic: "📝", label: "Questionnaires", render: () => stub("Questionnaires cliniques", "Passation d'échelles/questionnaires cliniques au patient et suivi des scores dans le temps. À concevoir avec toi.") },
|
|
];
|
|
|
|
// ---- boot + shell ---------------------------------------------------------
|
|
boot();
|
|
async function boot() {
|
|
try { const st = await api("/lock-status"); S.initialized = st.initialized; S.unlocked = st.unlocked; }
|
|
catch (e) { $("#app").innerHTML = `<div class="gate"><div class="panel"><div class="logo">Kazeia<span class="dot">·</span>central</div><p class="err">serveur injoignable : ${esc(e.message)}</p></div></div>`; return; }
|
|
if (!S.unlocked) return renderGate();
|
|
await renderShell();
|
|
}
|
|
|
|
function renderGate() {
|
|
const first = !S.initialized;
|
|
$("#app").innerHTML = `<div class="gate"><div class="panel">
|
|
<div class="logo">Kazeia<span class="dot">·</span>central</div>
|
|
<p>${first ? "Choisis un mot de passe opérateur (première fois)" : "Déverrouille le coffre opérateur"}</p>
|
|
<input id="pw" type="password" placeholder="mot de passe" autofocus>
|
|
<button class="btn-primary" id="gobtn">${first ? "Initialiser" : "Déverrouiller"}</button>
|
|
<div id="gerr" class="err" style="margin-top:10px"></div>
|
|
</div></div>`;
|
|
const go = async () => {
|
|
try { await api("/unlock", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password: $("#pw").value }) }); S.unlocked = true; await renderShell(); }
|
|
catch (e) { $("#gerr").textContent = e.message; }
|
|
};
|
|
$("#gobtn").onclick = go;
|
|
$("#pw").onkeydown = (e) => { if (e.key === "Enter") go(); };
|
|
}
|
|
|
|
async function renderShell() {
|
|
$("#app").innerHTML = `
|
|
<div class="topbar">
|
|
<span class="brand">Kazeia<span class="dot">·</span>central</span>
|
|
<span class="ctx">Tablette : <select id="devsel"></select></span>
|
|
<span class="spacer"></span>
|
|
<span class="lockpill on" id="lockpill">🔓 coffre ouvert</span>
|
|
<button class="btn-sm btn-ghost" onclick="lockStore()">verrouiller</button>
|
|
</div>
|
|
<nav class="sidebar" id="sidebar"></nav>
|
|
<main><div id="content"></div></main>`;
|
|
$("#sidebar").innerHTML = SECTIONS.map(s => s.group
|
|
? `<div class="nav-label ${s.domain === "med" ? "dom-med" : "dom-tech"}">${s.group}</div>`
|
|
: `<div class="nav-item" data-id="${s.id}"><span class="ic">${s.ic}</span>${s.label}</div>`).join("");
|
|
$("#sidebar").querySelectorAll(".nav-item").forEach(n => n.onclick = () => { S.section = n.dataset.id; renderSection(); });
|
|
await loadDevices();
|
|
renderSection();
|
|
}
|
|
|
|
async function loadDevices() {
|
|
try { S.devices = await api("/devices"); } catch (e) { S.devices = []; toast("erreur adb : " + e.message, "err"); }
|
|
if (S.devices.length && !S.selected) S.selected = S.devices[0].serial;
|
|
const sel = $("#devsel");
|
|
if (sel) {
|
|
sel.innerHTML = `<option value="">— aucune —</option>` + S.devices.map(d =>
|
|
`<option value="${d.serial}" ${d.serial === S.selected ? "selected" : ""}>${esc(d.label || d.serial)} ${d.state !== "device" ? "(" + d.state + ")" : ""}</option>`).join("");
|
|
sel.onchange = () => { S.selected = sel.value || null; renderSection(); };
|
|
}
|
|
}
|
|
|
|
function renderSection() {
|
|
document.querySelectorAll(".nav-item").forEach(n => n.classList.toggle("active", n.dataset.id === S.section));
|
|
const sec = SECTIONS.find(s => s.id === S.section) || SECTIONS.find(s => s.render);
|
|
const c = $("#content"); c.innerHTML = `<div class="empty"><span class="spinner"></span></div>`;
|
|
Promise.resolve(sec.render(c)).catch(e => { c.innerHTML = `<div class="card"><div class="err">${esc(e.message)}</div></div>`; });
|
|
}
|
|
|
|
function pageHead(title, sub, actions = "") {
|
|
return `<div class="page-head"><div><h1>${title}</h1>${sub ? `<div class="sub">${sub}</div>` : ""}</div><div class="actions">${actions}</div></div>`;
|
|
}
|
|
function stub(title, desc) {
|
|
return `${pageHead(title)}<div class="card"><div class="empty"><div class="big">🚧</div><p>${esc(desc)}</p><p class="muted">Section prévue par l'architecture — non encore implémentée.</p></div></div>`;
|
|
}
|
|
|
|
// ---- FLOTTE ---------------------------------------------------------------
|
|
async function renderFlotte(c) {
|
|
const rep = await api("/fleet/overview").catch(e => ({ error: e.message }));
|
|
const rows = Array.isArray(rep) ? rep : [];
|
|
const head = pageHead("Flotte", `${S.devices.length} tablette(s)`,
|
|
`<button class="btn-sm" onclick="renderSection()">↻ Rafraîchir</button>`);
|
|
const table = rep.error ? `<div class="err">${esc(rep.error)}</div>` : rows.length ? `<table>
|
|
<tr><th>tablette</th><th>état</th><th>RAM</th><th>MAJ</th><th>RAG</th><th>profils</th><th>sessions</th><th>crashes</th></tr>
|
|
${rows.map(r => { const o = r.result || {}; const sel = r.serial === S.selected;
|
|
return `<tr style="cursor:pointer;${sel ? "background:var(--primary-soft)" : ""}" onclick="selectDevice('${r.serial}')">
|
|
<td><b>${esc(r.label || r.serial)}</b><div class="muted mono">${esc(r.serial)}</div></td>
|
|
${r.ok === false ? `<td colspan="7" class="err">${esc(r.error)}</td>` : `
|
|
<td>${dot(o.online ? "ok" : "off")} ${o.online ? "en ligne" : "hors-ligne"}</td>
|
|
<td>${o.mem_available_mb ?? "—"} Mo</td>
|
|
<td>${esc(o.update_phase || "—")}${o.app_update_available ? " ⬆" : ""}</td>
|
|
<td>${dot(o.rag_ready ? "ok" : "idle")} ${o.rag_docs ?? 0}</td>
|
|
<td>${o.profiles ?? 0}</td><td>${o.sessions ?? 0}</td>
|
|
<td>${o.crashes ? `<span class="err">${o.crashes}</span>` : 0}</td>`}</tr>`; }).join("")}
|
|
</table>` : `<div class="empty">aucune tablette branchée</div>`;
|
|
c.innerHTML = head + `<div class="card full">${table}</div><div id="devdetail"></div>`;
|
|
if (S.selected) renderDeviceDetail($("#devdetail"), S.selected);
|
|
}
|
|
|
|
function selectDevice(serial) { S.selected = serial; const sel = $("#devsel"); if (sel) sel.value = serial; renderSection(); }
|
|
|
|
async function renderDeviceDetail(host, serial) {
|
|
host.innerHTML = `<div class="page-head" style="margin-top:22px"><h1 style="font-size:16px">Détail · ${esc(deviceLabel(serial))}</h1></div><div class="grid" id="ddgrid"><div class="empty"><span class="spinner"></span></div></div>`;
|
|
const get = (p, fn) => api(`/devices/${serial}/${p}`).then(fn).catch(e => `<div class="card"><h3>${p}</h3><div class="err">${esc(e.message)}</div></div>`);
|
|
const cards = await Promise.all([
|
|
get("state", s => card("État runtime", kv("pid", s.pid) + kv("uptime (s)", s.uptime_seconds) + kv("PSS (Mo)", s.pss_mb) + kv("RAM dispo (Mo)", s.mem_available_mb))),
|
|
get("updates", u => card("Mises à jour", kv("phase", u.phase) + kv("MAJ dispo", u.app_update_available ? "oui" : "non") + kv("version", u.app_version_name || "—"))),
|
|
get("rag/status", r => card("RAG", kv("état", (r.ready ? "prêt" : "non prêt")) + kv("modèle", r.model || "—") + kv("docs", r.doc_count ?? 0))),
|
|
get("profiles", ps => card("Profils (" + ps.length + ")", ps.length ? `<table><tr><th>nom</th><th>voix</th><th>PIN</th><th>tours</th></tr>${ps.map(p => `<tr><td>${esc(p.display_name || p.id)}</td><td>${esc(p.voice_id || "—")}</td><td>${p.has_pin ? "🔒" : "—"}</td><td>${p.turn_count ?? 0}</td></tr>`).join("")}</table>` : `<span class="muted">aucun</span>`, "full")),
|
|
get("conversations", cs => card("Conversations (" + cs.length + ")", cs.length ? `<table><tr><th>profil</th><th>session</th><th>tours</th><th>début</th></tr>${cs.map(x => `<tr><td>${esc(x.profile_id)}</td><td class="mono">${esc(x.session_id)}</td><td>${x.turn_count ?? 0}</td><td>${fmtTs(x.started_at)}</td></tr>`).join("")}</table>` : `<span class="muted">aucune session</span>`, "full")),
|
|
get("crashes", cr => card("Crashes (" + cr.length + ")", cr.length ? cr.map(x => kv(x.component || "?", x.message || "")).join("") : `<span class="muted">aucun</span>`)),
|
|
]);
|
|
$("#ddgrid").innerHTML = cards.join("");
|
|
}
|
|
const card = (title, body, cls = "") => `<div class="card ${cls}"><h3>${title}</h3>${body}</div>`;
|
|
const kv = (k, v) => `<div class="kv"><span class="k">${k}</span><span class="v">${v ?? "—"}</span></div>`;
|
|
|
|
// ---- MISES À JOUR ---------------------------------------------------------
|
|
async function renderUpdates(c) {
|
|
const rep = await api("/fleet/overview").catch(e => ({ error: e.message }));
|
|
const rows = Array.isArray(rep) ? rep : [];
|
|
c.innerHTML = pageHead("Mises à jour", "OTA piloté par tablette",
|
|
`<button class="btn-sm" id="chk">Vérifier le parc</button> <button class="btn-sm btn-primary" id="inst">Installer le parc</button>`)
|
|
+ `<div class="card full">${rep.error ? `<div class="err">${esc(rep.error)}</div>` : `<table><tr><th>tablette</th><th>phase</th><th>MAJ dispo</th><th>version</th></tr>
|
|
${rows.map(r => { const o = r.result || {}; return `<tr><td><b>${esc(r.label || r.serial)}</b></td><td>${esc(o.update_phase || "—")}</td><td>${o.app_update_available ? badge("disponible", "warn") : "—"}</td><td>${esc(o.app_version_name || "—")}</td></tr>`; }).join("")}</table>`}</div>`;
|
|
$("#chk").onclick = (e) => withBusy(e.target, async () => { const r = await api("/fleet/update-check", { method: "POST" }); toast(`${r.filter(x => x.ok).length}/${r.length} ont accepté la vérification`, "ok"); });
|
|
$("#inst").onclick = (e) => { if (!confirm("Lancer l'installation OTA sur tout le parc ?")) return; withBusy(e.target, async () => { const r = await api("/fleet/update-install", { method: "POST" }); toast(`installation lancée sur ${r.filter(x => x.ok).length}/${r.length}`, "ok"); }); };
|
|
}
|
|
|
|
// ---- AUDIT ----------------------------------------------------------------
|
|
async function renderAudit(c) {
|
|
const log = await api("/audit").catch(e => ({ error: e.message }));
|
|
const rows = Array.isArray(log) ? log : [];
|
|
c.innerHTML = pageHead("Audit", "piste d'accès opérateur (§8)") + `<div class="card full">${log.error ? `<div class="err">${esc(log.error)}</div>` : rows.length ? `<table><tr><th>date</th><th>acteur</th><th>action</th><th>cible</th></tr>
|
|
${rows.map(a => `<tr><td>${fmtTs(a.ts)}</td><td>${esc(a.actor || "—")}</td><td>${badge(a.action, "primary")}</td><td class="mono">${esc(a.target || "—")}</td></tr>`).join("")}</table>` : `<div class="empty">aucune entrée</div>`}</div>`;
|
|
}
|
|
|
|
// ---- VOIX -----------------------------------------------------------------
|
|
async function renderVoix(c) {
|
|
const serial = S.selected;
|
|
const reqs = [api("/voices/catalog").catch(e => ({ error: e.message })), api("/voices/autosync").catch(e => ({ error: e.message }))];
|
|
if (serial) reqs.push(api(`/voices/${serial}`).catch(e => ({ error: e.message })), api(`/voices/${serial}/admin`).catch(e => ({ error: e.message })));
|
|
const [cat, auto, tablet, admin] = await Promise.all(reqs);
|
|
|
|
const cv = Array.isArray(cat) ? cat : [];
|
|
const catCard = card(`Catalogue voix — flotte (${cv.length})`, cat.error ? `<div class="err">${esc(cat.error)}</div>` :
|
|
cv.length ? `<table><tr><th>voix</th><th>portée</th><th>verrou</th><th>déployée sur</th><th></th></tr>
|
|
${cv.map(v => `<tr><td><b>${esc(v.name || v.voice_id)}</b><div class="muted mono">${esc(v.voice_id)}</div></td>
|
|
<td>${scopeBadge(v.scope)}</td>
|
|
<td>${v.locked_profile_id ? "🔒 " + esc(v.owner_name || v.locked_profile_id) : `<span class="muted">généraliste</span>`}</td>
|
|
<td class="muted">${(v.deployed_serials || []).map(deviceLabel).map(esc).join(", ") || "—"}</td>
|
|
<td style="white-space:nowrap">${v.locked_profile_id ? `<button class="btn-sm" onclick="unlockVoice('${v.voice_id}')">déverrouiller</button>` : `<button class="btn-sm" onclick="lockVoicePrompt('${v.voice_id}')">verrouiller</button>`}
|
|
${v.has_wav ? `<button class="btn-sm" onclick="reenrollVoice(this,'${v.voice_id}')">ré-enrôler</button>` : ""}</td></tr>`).join("")}
|
|
</table>` : `<div class="empty">catalogue vide — enregistre des voix via l'app admin puis synchronise</div>`, "full");
|
|
|
|
const autoCard = card("Déclencheur automatique", auto.error ? `<div class="err">${esc(auto.error)}</div>` :
|
|
`<div class="kv"><span class="k">état</span><span class="v">${dot(auto.enabled ? "ok" : "idle")} ${auto.enabled ? "armé" : "désarmé"}</span></div>
|
|
<div style="display:flex;gap:10px;align-items:center;margin-top:10px">
|
|
<button onclick="toggleAutosync(${!auto.enabled})">${auto.enabled ? "Désarmer" : "Armer"}</button>
|
|
<label class="chk"><input type="checkbox" id="auto_del" ${auto.delete_source ? "checked" : ""}> supprimer le WAV device après</label></div>`);
|
|
|
|
let cards = catCard + autoCard;
|
|
if (!serial) {
|
|
cards += card("Par tablette", `<div class="empty">Choisis une tablette (en haut) pour voir ce qui y est chargé et les voix chargeables dessus.</div>`, "full");
|
|
} else {
|
|
const av = Array.isArray(admin) ? admin : [];
|
|
const adminCard = card(`Voix admin à enrôler — ${esc(deviceLabel(serial))} (${av.length})`, admin.error ? `<div class="err">${esc(admin.error)}</div>` :
|
|
(av.length ? `<table><tr><th>nom</th><th>portée</th><th>propriétaire</th><th>statut</th></tr>${av.map(v => `<tr><td><b>${esc(v.name || v.voice_id)}</b></td><td>${scopeBadge(v.scope)}</td><td>${esc(v.owner_name || v.owner_profile_id || "—")}</td><td>${esc(v.status)}</td></tr>`).join("")}</table>` : `<div class="empty">aucune voix admin en attente</div>`)
|
|
+ `<div style="display:flex;gap:10px;align-items:center;margin-top:12px"><button class="btn-primary" onclick="syncAdmin(this,'${serial}')">Synchroniser (enrôler + déployer)</button><label class="chk"><input type="checkbox" id="sync_del"> supprimer le WAV device après</label></div>`, "full");
|
|
|
|
const loaded = (tablet && tablet.deployed) || [], loadable = (tablet && tablet.deployable) || [];
|
|
const tabCard = card(`${esc(deviceLabel(serial))} — chargées (${loaded.length}) · chargeables (${loadable.filter(v => v.deployable).length})`, tablet && tablet.error ? `<div class="err">${esc(tablet.error)}</div>` :
|
|
`<table><tr><th>voix</th><th>portée</th><th>état</th><th></th></tr>
|
|
${loaded.map(v => `<tr><td>${dot("ok")} ${esc(v.name || v.voice_id)}${v.in_catalog === false ? ` <span class="muted">(hors catalogue)</span>` : ""}</td><td>${v.scope ? scopeBadge(v.scope) : "—"}</td><td>chargée</td><td>${v.in_catalog !== false ? `<button class="btn-sm" onclick="undeployVoice(this,'${serial}','${v.voice_id}')">retirer</button>` : ""}</td></tr>`).join("")}
|
|
${loadable.map(v => `<tr><td class="muted">${esc(v.name || v.voice_id)}</td><td>${scopeBadge(v.scope)}</td><td class="muted">${v.deployable ? "chargeable" : esc(v.reason || "—")}</td><td>${v.deployable ? `<button class="btn-sm btn-primary" onclick="deployVoice(this,'${serial}','${v.voice_id}')">charger</button>` : ""}</td></tr>`).join("")}
|
|
</table>`, "full");
|
|
cards += adminCard + tabCard;
|
|
}
|
|
c.innerHTML = pageHead("Voix", "catalogue de flotte · enrôlement OmniVoice") + `<div class="grid">${cards}</div>`;
|
|
}
|
|
|
|
// ---- actions voix ---------------------------------------------------------
|
|
async function toggleAutosync(on) { try { await api("/voices/autosync", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: on, delete_source: $("#auto_del") && $("#auto_del").checked || false }) }); toast(on ? "auto-enrôlement armé" : "désarmé", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }
|
|
async function syncAdmin(btn, serial) { const del = $("#sync_del") && $("#sync_del").checked || false; await withBusy(btn, async () => { try { const r = await api(`/voices/${serial}/admin/sync`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ delete_source: del }) }); toast(`${r.filter(x => x.ok && x.deployed).length}/${r.length} voix déployée(s)`, "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
|
|
async function deployVoice(btn, serial, vid) { await withBusy(btn, async () => { try { const r = await api(`/voices/${serial}/deploy`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ voice_id: vid }) }); r.deployed ? toast("voix chargée", "ok") : toast("non chargée : " + (r.reason || ""), "err"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
|
|
async function undeployVoice(btn, serial, vid) { await withBusy(btn, async () => { try { await api(`/voices/${serial}/undeploy`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ voice_id: vid }) }); toast("voix retirée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
|
|
async function reenrollVoice(btn, vid) { if (!confirm(`Ré-enrôler « ${vid} » (fix §7) et re-pousser sur ses tablettes ?`)) return; await withBusy(btn, async () => { try { const r = await api(`/voices/catalog/${vid}/reenroll`, { method: "POST" }); toast("ré-enrôlé · re-déployé : " + ((r.redeployed || []).join(", ") || "—"), "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
|
|
async function lockVoicePrompt(vid) { const p = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`); if (!p) return; try { await api(`/voices/catalog/${vid}/lock`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ profile_id: p }) }); toast("voix verrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }
|
|
async function unlockVoice(vid) { try { await api(`/voices/catalog/${vid}/unlock`, { method: "POST" }); toast("voix déverrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }
|
|
|
|
// ---- store ----------------------------------------------------------------
|
|
async function lockStore() { try { await api("/lock", { method: "POST" }); S.unlocked = false; renderGate(); } catch (e) { toast(e.message, "err"); } }
|