(() => { const el = (id) => document.getElementById(id); const I18N = { vi: { "topbar.allOk": "ỔN ĐỊNH", "topbar.error": "LỖI", "topbar.paused": "TẠM DỪNG", "topbar.running": "ĐANG CHẠY", "topbar.waiting": "Đang chờ mission mới…", "topbar.noMissionsQueue": "Không có mission trong queue…", "topbar.reset": "RESET", "topbar.changeUserData": "Lưu thông tin", "topbar.changePassword": "Đổi mật khẩu", "topbar.logout": "Đăng xuất", "topbar.displayName": "Tên hiển thị", "topbar.reload": "Tải lại", "topbar.saveLayout": "Lưu layout", "topbar.joystickTitle": "Điều khiển tay (Joystick)", "topbar.joystickSpeed": "Tốc độ", "topbar.joystickOff": "Tắt joystick", "topbar.localeVi": "TIẾNG VIỆT", "topbar.localeEn": "ENGLISH", "topbar.startHint": "Bấm để START robot", "topbar.pauseHint": "Bấm để PAUSE robot", "topbar.code": "Mã", "topbar.module": "Module", }, en: { "topbar.allOk": "ALL OK", "topbar.error": "ERROR", "topbar.paused": "PAUSED", "topbar.running": "RUNNING", "topbar.waiting": "Waiting for new missions…", "topbar.noMissionsQueue": "No missions in queue…", "topbar.reset": "RESET", "topbar.changeUserData": "Change user data", "topbar.changePassword": "Change password", "topbar.logout": "Log out", "topbar.displayName": "Display name", "topbar.reload": "Reload", "topbar.saveLayout": "Save layout", "topbar.joystickTitle": "Manual control (Joystick)", "topbar.joystickSpeed": "Speed", "topbar.joystickOff": "Disengage joystick", "topbar.localeVi": "TIẾNG VIỆT", "topbar.localeEn": "ENGLISH", "topbar.startHint": "Click to START the robot", "topbar.pauseHint": "Click to PAUSE the robot", "topbar.code": "Code", "topbar.module": "Module", }, }; const LOCALE_META = { vi: { flag: "🇻🇳", labelKey: "topbar.localeVi" }, en: { flag: "🇺🇸", labelKey: "topbar.localeEn" }, }; let locale = "vi"; let robotStatus = null; let pollTimer = null; let eventsBound = false; let openPanel = null; let joystickActive = false; let joystickPointerId = null; let joystickRaf = null; let lastCmd = { linear: 0, angular: 0 }; function t(key) { return I18N[locale]?.[key] ?? I18N.en[key] ?? key; } function canSeeMissions() { return window.AuthApp?.canAccessPage?.("missions"); } function canControl() { return window.AuthApp?.canWrite?.("missions"); } async function apiJson(path, opts = {}) { const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, ...opts, }); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = null; } if (!res.ok) throw new Error((data && data.error) || text || res.statusText); return data; } function applyLocale(next) { locale = LOCALE_META[next] ? next : "vi"; try { localStorage.setItem("lm_locale", locale); } catch { /* ignore */ } document.documentElement.lang = locale; document.querySelectorAll("[data-i18n]").forEach((node) => { const key = node.dataset.i18n; if (key) node.textContent = t(key); }); const meta = LOCALE_META[locale]; if (el("mirLocaleFlag")) el("mirLocaleFlag").textContent = meta.flag; if (el("mirLocaleLabel")) el("mirLocaleLabel").textContent = t(meta.labelKey); window.dispatchEvent(new CustomEvent("lm:locale-change", { detail: { locale } })); if (robotStatus) renderAll(robotStatus); } function loadLocale() { try { const saved = localStorage.getItem("lm_locale"); if (saved && LOCALE_META[saved]) locale = saved; } catch { /* ignore */ } applyLocale(locale); } function closePanels() { document.querySelectorAll(".mirPanel").forEach((p) => { p.hidden = true; }); document.querySelectorAll(".mirSegment[aria-haspopup='true']").forEach((btn) => { btn.setAttribute("aria-expanded", "false"); }); openPanel = null; } function togglePanel(btn, panel) { if (!btn || !panel) return; const isOpen = btn.getAttribute("aria-expanded") === "true"; closePanels(); if (!isOpen) { panel.hidden = false; btn.setAttribute("aria-expanded", "true"); openPanel = panel; } } function missionStripMessage(status) { const pending = Number(status.queue_pending) || 0; const runnerState = status.runner?.state || "idle"; const msg = status.message || ""; if (runnerState === "running" || runnerState === "paused") { if (msg && msg !== t("topbar.waiting")) return msg; const name = status.runner?.current_action; if (name) return String(name); } if (pending === 0 && runnerState === "idle") return t("topbar.noMissionsQueue"); if (msg && msg !== t("topbar.waiting")) return msg; return t("topbar.waiting"); } function renderControl(status) { const motion = status.motion || "paused"; const running = motion === "running"; const runnerState = status.runner?.state || "idle"; const isError = status.health === "error" || runnerState === "error"; const pauseIcon = el("mirControlIconPause"); const playIcon = el("mirControlIconPlay"); const pillEl = el("mirControlPill"); const msgEl = el("mirMissionMsg"); const btnEl = el("mirSegControl"); const stripEl = el("mirMissionStrip"); if (pauseIcon && playIcon) { pauseIcon.hidden = !running; playIcon.hidden = running; } if (pillEl) { pillEl.textContent = isError ? t("topbar.error") : running ? t("topbar.running") : t("topbar.paused"); pillEl.classList.toggle("is-running", running && !isError); pillEl.classList.toggle("is-paused", !running && !isError); pillEl.classList.toggle("is-error", isError); } if (msgEl) msgEl.textContent = missionStripMessage(status); if (stripEl) stripEl.classList.toggle("is-error", isError); if (btnEl) { btnEl.disabled = !canControl() || status.health === "error"; btnEl.title = running ? t("topbar.pauseHint") : t("topbar.startHint"); btnEl.classList.toggle("is-readonly", !canControl()); } } function renderStatus(status) { const health = status.health || "ok"; const runnerState = status.runner?.state || "idle"; const isError = health === "error" || runnerState === "error"; const labelEl = el("mirStatusLabel"); const iconEl = el("mirStatusIcon"); const bodyEl = el("mirStatusPanelBody"); const footerEl = el("mirStatusPanelFooter"); const segEl = el("mirSegStatus"); if (labelEl) labelEl.textContent = isError ? t("topbar.error") : t("topbar.allOk"); if (iconEl) { iconEl.classList.toggle("is-ok", !isError); iconEl.classList.toggle("is-error", isError); } if (segEl) segEl.classList.toggle("is-error", isError); if (!bodyEl) return; const err = status.error && typeof status.error === "object" ? status.error : null; const runnerErr = runnerState === "error" ? status.runner?.message : ""; const message = status.message || t("topbar.waiting"); if (isError && (err || runnerErr)) { bodyEl.innerHTML = `
${t("topbar.error")}
${err?.code != null ? `
${t("topbar.code")}: ${err.code}
` : ""} ${err?.module ? `
${t("topbar.module")}: ${err.module}
` : ""}
${err?.description || runnerErr || message}
`; if (footerEl) footerEl.hidden = !canControl(); } else { bodyEl.innerHTML = `
${t("topbar.allOk")}
${message}
${status.queue_pending > 0 ? `
${status.queue_pending} mission(s) in queue
` : ""}`; if (footerEl) footerEl.hidden = true; } } function renderBattery(status) { const pct = Math.max(0, Math.min(100, Number(status.battery_percent) || 0)); const labelEl = el("mirBatteryLabel"); const levelEl = el("mirBatteryLevel"); const segEl = el("mirSegBattery"); if (labelEl) labelEl.textContent = `${pct}%`; if (levelEl) levelEl.style.width = `${pct}%`; if (segEl) { segEl.classList.toggle("is-low", pct < 20); segEl.classList.toggle("is-mid", pct >= 20 && pct < 50); segEl.classList.toggle("is-charging", !!status.battery_charging); } } function hideJoystickOverlay() { const overlay = el("joystickOverlay"); if (overlay) overlay.hidden = true; joystickActive = false; const stick = el("joystickStick"); if (stick) stick.style.transform = "translate(0, 0)"; lastCmd = { linear: 0, angular: 0 }; } function renderJoystick(status) { if (!window.AuthApp?.isReady?.()) { hideJoystickOverlay(); return; } const seg = el("mirSegJoystick"); const engaged = !!status.joystick_engaged; if (seg) seg.classList.toggle("is-active", engaged); const overlay = el("joystickOverlay"); if (overlay) overlay.hidden = !engaged; joystickActive = engaged; const speedSel = el("joystickSpeedSelect"); if (speedSel && status.joystick_speed) speedSel.value = status.joystick_speed; if (el("joystickSpeedLabel")) el("joystickSpeedLabel").textContent = status.joystick_speed || "fast"; } function renderAll(status) { if (!window.AuthApp?.isReady?.()) { hideJoystickOverlay(); return; } robotStatus = status; if (!canSeeMissions()) { el("mirTopbar")?.classList.add("mirTopbar--no-missions"); return; } el("mirTopbar")?.classList.remove("mirTopbar--no-missions"); renderControl(status); renderStatus(status); renderBattery(status); renderJoystick(status); } async function fetchStatus() { if (!window.AuthApp?.isReady() || !canSeeMissions()) return; try { const data = await apiJson("/api/robot/status"); renderAll(data); window.dispatchEvent(new CustomEvent("lm:robot-status", { detail: data })); } catch (e) { if (String(e.message || "").includes("not authenticated")) return; } } function startPoll() { stopPoll(); fetchStatus(); pollTimer = setInterval(fetchStatus, 1500); window.MissionsApp?.startQueuePoll?.(); } function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } window.MissionsApp?.stopQueuePoll?.(); } async function toggleRobotMotion() { if (!robotStatus || !canControl()) return; const running = robotStatus.motion === "running"; const path = running ? "/api/robot/pause" : "/api/robot/start"; const data = await apiJson(path, { method: "POST", body: "{}" }); renderAll(data); } async function resetError() { const data = await apiJson("/api/robot/errors/reset", { method: "POST", body: "{}" }); renderAll(data); closePanels(); } async function engageJoystick(engaged, speed) { const payload = { engaged }; if (speed) payload.speed = speed; const data = await apiJson("/api/robot/joystick", { method: "POST", body: JSON.stringify(payload), }); renderAll(data); } function sendCmdVel(linear, angular) { if (!joystickActive) return; if (Math.abs(linear - lastCmd.linear) < 0.02 && Math.abs(angular - lastCmd.angular) < 0.02) return; lastCmd = { linear, angular }; apiJson("/api/robot/cmd_vel", { method: "POST", body: JSON.stringify({ linear, angular }), }).catch(() => {}); } function bindJoystickPad() { const pad = el("joystickPad"); const stick = el("joystickStick"); if (!pad || !stick) return; const center = () => { const r = pad.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, radius: r.width / 2 - 24 }; }; const moveStick = (clientX, clientY) => { const c = center(); let dx = clientX - c.x; let dy = clientY - c.y; const dist = Math.hypot(dx, dy); if (dist > c.radius) { dx = (dx / dist) * c.radius; dy = (dy / dist) * c.radius; } stick.style.transform = `translate(${dx}px, ${dy}px)`; const linear = -dy / c.radius; const angular = dx / c.radius; if (joystickRaf) cancelAnimationFrame(joystickRaf); joystickRaf = requestAnimationFrame(() => sendCmdVel(linear, angular)); }; const resetStick = () => { stick.style.transform = "translate(0, 0)"; sendCmdVel(0, 0); lastCmd = { linear: 0, angular: 0 }; }; const onDown = (evt) => { if (!joystickActive) return; joystickPointerId = evt.pointerId; pad.setPointerCapture(evt.pointerId); moveStick(evt.clientX, evt.clientY); }; const onMove = (evt) => { if (evt.pointerId !== joystickPointerId) return; moveStick(evt.clientX, evt.clientY); }; const onUp = (evt) => { if (evt.pointerId !== joystickPointerId) return; joystickPointerId = null; resetStick(); }; pad.addEventListener("pointerdown", onDown); pad.addEventListener("pointermove", onMove); pad.addEventListener("pointerup", onUp); pad.addEventListener("pointercancel", onUp); } function bindEvents() { if (eventsBound) return; eventsBound = true; el("mirSegControl")?.addEventListener("click", () => { toggleRobotMotion().catch((e) => alert(e.message)); }); el("mirSegStatus")?.addEventListener("click", (evt) => { evt.stopPropagation(); togglePanel(el("mirSegStatus"), el("mirStatusPanel")); }); el("mirSegLocale")?.addEventListener("click", (evt) => { evt.stopPropagation(); togglePanel(el("mirSegLocale"), el("mirLocalePanel")); }); el("mirUserBtn")?.addEventListener("click", (evt) => { evt.stopPropagation(); togglePanel(el("mirUserBtn"), el("mirUserPanel")); }); el("mirErrorResetBtn")?.addEventListener("click", () => { resetError().catch((e) => alert(e.message)); }); document.querySelectorAll(".mirLocaleOption").forEach((btn) => { btn.addEventListener("click", (evt) => { evt.stopPropagation(); applyLocale(btn.dataset.locale || "vi"); closePanels(); }); }); el("mirSegJoystick")?.addEventListener("click", async () => { if (!canControl()) { alert(locale === "vi" ? "Không có quyền điều khiển" : "No control permission"); return; } try { if (robotStatus?.joystick_engaged) await engageJoystick(false); else await engageJoystick(true, el("joystickSpeedSelect")?.value || "fast"); } catch (e) { alert(e.message); } }); el("joystickDisengageBtn")?.addEventListener("click", () => { engageJoystick(false).catch((e) => alert(e.message)); }); el("joystickSpeedSelect")?.addEventListener("change", (evt) => { if (robotStatus?.joystick_engaged) { engageJoystick(true, evt.target.value).catch(() => {}); } }); document.addEventListener("click", (evt) => { if (evt.target.closest(".mirSegment") || evt.target.closest(".mirPanel")) return; closePanels(); }); document.addEventListener("keydown", (evt) => { if (evt.key === "Escape") { closePanels(); if (robotStatus?.joystick_engaged) engageJoystick(false).catch(() => {}); } }); bindJoystickPad(); } function start() { loadLocale(); bindEvents(); if (!window.AuthApp?.isReady()) return; startPoll(); } function stop() { stopPoll(); closePanels(); hideJoystickOverlay(); if (window.AuthApp?.isReady?.()) { engageJoystick(false).catch(() => {}); } } async function disengageJoystick() { hideJoystickOverlay(); if (!window.AuthApp?.isReady?.()) return; try { await engageJoystick(false); } catch { /* session may already be gone */ } } window.TopbarApp = { t, getLocale: () => locale, applyLocale, refresh: fetchStatus, getRobotStatus: () => robotStatus, hideJoystickOverlay, disengageJoystick, updateUserMenu(user) { const role = (user?.group_name || "USER").toUpperCase(); if (el("mirUserLabel")) el("mirUserLabel").textContent = role; if (el("mirUserPanelRole")) el("mirUserPanelRole").textContent = role; if (el("mirUserPanelName")) el("mirUserPanelName").textContent = user?.display_name || user?.username || "—"; if (el("mirProfileDisplayName")) el("mirProfileDisplayName").value = user?.display_name || user?.username || ""; }, }; if (window.AuthApp?.isReady()) start(); else window.addEventListener("lm:auth-ready", () => start(), { once: true }); window.addEventListener("lm:auth-logout", () => stop()); hideJoystickOverlay(); })();