385 lines
15 KiB
JavaScript
385 lines
15 KiB
JavaScript
/* 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: '<div style="font-size:1.6rem;line-height:1;filter:drop-shadow(0 1px 3px rgba(0,0,0,.5))">🏠</div>',
|
|
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: 19,
|
|
}).addTo(_map);
|
|
if (siteConfig && siteConfig.home) {
|
|
_homeMarker = L.marker([siteConfig.home.lat, siteConfig.home.lon], { icon: _homeIcon })
|
|
.bindPopup(`<div style="min-width:140px">
|
|
<strong>${siteConfig.home.name}</strong><br>
|
|
<a href="https://www.google.com/maps/dir/?api=1&destination=${siteConfig.home.lat},${siteConfig.home.lon}"
|
|
target="_blank" rel="noopener"
|
|
style="display:inline-block;margin-top:.5em;font-size:.85em;text-decoration:underline">
|
|
Get directions
|
|
</a>
|
|
</div>`)
|
|
.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(
|
|
`<div style="min-width:140px">
|
|
<strong>${gate.name}</strong><br>
|
|
<a href="https://www.google.com/maps/dir/?api=1&destination=${gate.lat},${gate.lon}"
|
|
target="_blank" rel="noopener"
|
|
style="display:inline-block;margin-top:.5em;font-size:.85em;text-decoration:underline">
|
|
Get directions
|
|
</a>
|
|
</div>`
|
|
);
|
|
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 = `<span class="icon">${icon}</span><span>${gate.name}</span>`;
|
|
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");
|
|
});
|
|
}
|