/* 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;
});