/* app.js - Lagomare Gates frontend */ // ── Token helpers ───────────────────────────────────────────────────────────── const TOKEN_KEY = "lg_keypass_token"; function saveToken(t) { localStorage.setItem(TOKEN_KEY, t); } function clearToken() { localStorage.removeItem(TOKEN_KEY); } function getToken() { return localStorage.getItem(TOKEN_KEY); } function tokenValid(t) { if (!t) return false; try { const payload = JSON.parse(atob(t.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))); return payload.exp * 1000 > Date.now(); } catch { return false; } } // ── API helper ──────────────────────────────────────────────────────────────── async function apiFetch(method, path, body) { const token = getToken(); const headers = { "Content-Type": "application/json" }; if (token) headers["Authorization"] = `Bearer ${token}`; const res = await fetch(path, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); if (res.status === 401) { clearToken(); showLogin(); throw new Error("Session expired or invalid keypass"); } if (!res.ok) { const json = await res.json().catch(() => null); throw new Error((json && json.detail) || `Error ${res.status}`); } if (res.status === 204) return null; return res.json(); } // ── Views ───────────────────────────────────────────────────────────────────── function showLogin() { document.getElementById("login-view").classList.remove("hidden"); document.getElementById("gates-view").classList.add("hidden"); } function showGatesView() { document.getElementById("login-view").classList.add("hidden"); document.getElementById("gates-view").classList.remove("hidden"); } // ── Gate rendering ──────────────────────────────────────────────────────────── function renderGates(gates) { const grid = document.getElementById("gates-grid"); const loading = document.getElementById("loading-gates"); grid.innerHTML = ""; if (!gates.length) { loading.textContent = "No gates configured."; loading.classList.remove("hidden"); grid.classList.add("hidden"); return; } loading.classList.add("hidden"); grid.classList.remove("hidden"); // Group gates by group_name; null/empty = ungrouped (rendered last, no heading) const groups = new Map(); for (const gate of gates) { const key = gate.group_name || ""; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(gate); } const hasNamedGroups = [...groups.keys()].some(k => k !== ""); const sortedKeys = [...groups.keys()].sort((a, b) => { if (a === "" && b !== "") return 1; if (a !== "" && b === "") return -1; return a.localeCompare(b); }); for (const key of sortedKeys) { const section = document.createElement("div"); section.className = "gate-group"; if (hasNamedGroups && key) { const title = document.createElement("div"); title.className = "gate-group-title"; title.textContent = key; section.appendChild(title); } const groupGrid = document.createElement("div"); groupGrid.className = "gate-group-grid"; for (const gate of groups.get(key)) { const icon = gate.gate_type === "car" ? "🚘" : "🚶"; const btn = document.createElement("button"); btn.className = `gate-btn ${gate.gate_type}`; btn.dataset.gateId = gate.id; btn.innerHTML = `${icon}${gate.name}`; btn.addEventListener("click", () => handleOpenGate(btn, gate)); groupGrid.appendChild(btn); } section.appendChild(groupGrid); grid.appendChild(section); } } async function loadGates() { try { const gates = await apiFetch("GET", "/api/gates"); renderGates(gates); } catch (e) { document.getElementById("loading-gates").textContent = e.message; document.getElementById("loading-gates").classList.remove("hidden"); } } // ── Open gate action ────────────────────────────────────────────────────────── let _pendingGate = null; document.getElementById("confirm-cancel").addEventListener("click", () => { document.getElementById("confirm-modal").classList.add("hidden"); _pendingGate = null; }); document.getElementById("confirm-ok").addEventListener("click", () => { document.getElementById("confirm-modal").classList.add("hidden"); if (_pendingGate) { const { btn, gateId } = _pendingGate; _pendingGate = null; _doOpenGate(btn, gateId); } }); async function handleOpenGate(btn, gate) { _pendingGate = { btn, gateId: gate.id }; document.getElementById("confirm-gate-name").textContent = gate.name; document.getElementById("confirm-modal").classList.remove("hidden"); } async function _doOpenGate(btn, gateId) { btn.disabled = true; btn.classList.add("loading"); btn.classList.remove("ok", "fail"); try { await apiFetch("POST", `/api/gates/${encodeURIComponent(gateId)}/open`); btn.classList.remove("loading"); btn.classList.add("ok"); showToast("Gate opened ✓", false); setTimeout(() => btn.classList.remove("ok"), 2000); } catch (e) { btn.classList.remove("loading"); btn.classList.add("fail"); showToast(e.message, true); setTimeout(() => btn.classList.remove("fail"), 2000); } finally { btn.disabled = false; } } // ── Toast ───────────────────────────────────────────────────────────────────── let _toastTimer; function showToast(msg, isError = false) { const el = document.getElementById("toast"); clearTimeout(_toastTimer); el.textContent = msg; el.className = `toast ${isError ? "error" : "success"}`; _toastTimer = setTimeout(() => el.classList.add("fade"), 2600); setTimeout(() => { el.className = "toast hidden"; }, 3000); } // ── Login form ──────────────────────────────────────────────────────────────── document.getElementById("login-form").addEventListener("submit", async (e) => { e.preventDefault(); const code = document.getElementById("keypass-input").value.trim(); if (!code) return; const btn = document.getElementById("login-btn"); const errEl = document.getElementById("login-error"); btn.disabled = true; errEl.classList.add("hidden"); try { const data = await apiFetch("POST", "/api/auth/keypass", { code }); saveToken(data.token); document.getElementById("keypass-input").value = ""; showGatesView(); loadGates(); } catch (e) { errEl.textContent = e.message; errEl.classList.remove("hidden"); } finally { btn.disabled = false; } }); // ── Logout ──────────────────────────────────────────────────────────────────── document.getElementById("logout-btn").addEventListener("click", () => { clearToken(); showLogin(); }); // ── Init ────────────────────────────────────────────────────────────────────── (function init() { const t = getToken(); if (tokenValid(t)) { showGatesView(); loadGates(); } else { clearToken(); showLogin(); } })(); // ── Service worker registration ─────────────────────────────────────────────── if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js").catch(() => {}); } // ── PWA install banner ──────────────────────────────────────────────────────── const INSTALL_DISMISSED_KEY = "lg_install_dismissed"; let _deferredInstallPrompt = null; window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); if (sessionStorage.getItem(INSTALL_DISMISSED_KEY)) return; _deferredInstallPrompt = e; document.getElementById("install-banner").classList.remove("hidden"); }); document.getElementById("install-btn").addEventListener("click", async () => { const banner = document.getElementById("install-banner"); banner.classList.add("hidden"); if (!_deferredInstallPrompt) return; _deferredInstallPrompt.prompt(); await _deferredInstallPrompt.userChoice; _deferredInstallPrompt = null; }); document.getElementById("install-dismiss").addEventListener("click", () => { document.getElementById("install-banner").classList.add("hidden"); sessionStorage.setItem(INSTALL_DISMISSED_KEY, "1"); _deferredInstallPrompt = null; }); window.addEventListener("appinstalled", () => { document.getElementById("install-banner").classList.add("hidden"); _deferredInstallPrompt = null; });