/* app.js - Lagomare Gates frontend */ // ── Leaflet icon fix (assets served from /static/) ─────────────────────────── L.Icon.Default.mergeOptions({ iconUrl: "/static/images/marker-icon.png", iconRetinaUrl: "/static/images/marker-icon-2x.png", shadowUrl: "/static/images/marker-shadow.png", }); // ── 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"); } // ── Map ─────────────────────────────────────────────────────────────────────── let _map = null; let _mapMarkers = []; let _homeMarker = null; const _homeIcon = L.divIcon({ className: "", html: '
🏠
', iconSize: [28, 28], iconAnchor: [14, 24], popupAnchor: [0, -24], }); const _gateIcon = () => L.icon({ iconUrl: "/static/images/gate.svg", iconSize: [36, 36], iconAnchor: [18, 36], popupAnchor: [0, -38], }); async function _ensureMapReady() { if (_map) return; let siteConfig = null; try { siteConfig = await fetch("/api/site-config").then(r => r.json()); } catch { /* ignore */ } _map = L.map("map", { zoomControl: true }); L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: '© OpenStreetMap contributors', maxZoom: 19, }).addTo(_map); if (siteConfig && siteConfig.home) { _homeMarker = L.marker([siteConfig.home.lat, siteConfig.home.lon], { icon: _homeIcon }) .bindPopup(`
${siteConfig.home.name}
Get directions
`) .addTo(_map); } } function _fitMap() { if (!_map) return; const bounds = []; if (_homeMarker) { const ll = _homeMarker.getLatLng(); bounds.push([ll.lat, ll.lng]); } _mapMarkers.forEach(m => { const ll = m.getLatLng(); bounds.push([ll.lat, ll.lng]); }); if (bounds.length === 1) _map.setView(bounds[0], 16); else if (bounds.length > 1) _map.fitBounds(bounds, { padding: [32, 32], maxZoom: 17 }); } async function updateMap(gates) { await _ensureMapReady(); const gatesWithCoords = gates.filter(g => g.lat != null && g.lon != null); if (!_homeMarker && gatesWithCoords.length === 0) return; // nothing to put on the map document.getElementById("map-btn").classList.remove("hidden"); _mapMarkers.forEach(m => m.remove()); _mapMarkers = []; for (const gate of gatesWithCoords) { const popup = L.popup().setContent( `
${gate.name}
Get directions
` ); const marker = L.marker([gate.lat, gate.lon], { icon: _gateIcon() }) .bindPopup(popup) .addTo(_map); _mapMarkers.push(marker); } _fitMap(); } document.getElementById("map-btn").addEventListener("click", () => { document.getElementById("map-modal").classList.remove("hidden"); if (_map) setTimeout(() => { _map.invalidateSize(); _fitMap(); }, 50); }); document.getElementById("map-close").addEventListener("click", () => { document.getElementById("map-modal").classList.add("hidden"); }); document.getElementById("map-modal").addEventListener("click", e => { if (e.target === e.currentTarget) document.getElementById("map-modal").classList.add("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_icon || '🚪'; const btn = document.createElement("button"); btn.className = "gate-btn"; 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); updateMap(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() { // Auto-login when the URL contains ?k=CODE (e.g. scanned from a QR code) const params = new URLSearchParams(window.location.search); const k = params.get("k"); if (k) { // Remove the code from the URL immediately so it doesn't linger in history history.replaceState(null, "", window.location.pathname); fetch("/api/auth/keypass", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code: k.toUpperCase() }), }) .then(res => res.ok ? res.json() : res.json().then(j => Promise.reject(j.detail || "Invalid keypass"))) .then(data => { saveToken(data.token); showGatesView(); loadGates(); }) .catch(msg => { clearToken(); showLogin(); const errEl = document.getElementById("login-error"); errEl.textContent = typeof msg === "string" ? msg : "QR code login failed"; errEl.classList.remove("hidden"); }); return; } const t = getToken(); if (tokenValid(t)) { showGatesView(); loadGates(); } else { clearToken(); showLogin(); } })(); // ── Service worker registration ─────────────────────────────────────────────── if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js").catch(() => {}); } // ── Add to Home Screen ──────────────────────────────────────────────────────── const _isInstalled = window.matchMedia("(display-mode: standalone)").matches || !!navigator.standalone; const _installAppBtn = document.getElementById("install-app-btn"); const _installBanner = document.getElementById("install-banner"); const INSTALL_DISMISSED_KEY = "lg_install_dismissed"; if (_isInstalled) { // Already installed — hide both _installAppBtn.style.display = "none"; } else if (typeof window.AddToHomeScreen === "function") { const _addToHomeScreenInstance = window.AddToHomeScreen({ appName: "Lagomare Gates", appIconUrl: "/static/images/mobile_icon.png", assetUrl: "/static/add-to-homescreen/assets/img/", maxModalDisplayCount: -1, displayOptions: { showMobile: true, showDesktop: true }, allowClose: true, showArrow: true, }); // Header button — always triggers the library UI _installAppBtn.addEventListener("click", () => { _addToHomeScreenInstance.show("en"); }); // Auto-show banner once per session if (!sessionStorage.getItem(INSTALL_DISMISSED_KEY)) { _installBanner.classList.remove("hidden"); } document.getElementById("install-btn").addEventListener("click", () => { _installBanner.classList.add("hidden"); _addToHomeScreenInstance.show("en"); }); document.getElementById("install-dismiss").addEventListener("click", () => { _installBanner.classList.add("hidden"); sessionStorage.setItem(INSTALL_DISMISSED_KEY, "1"); }); }