Add gate groups. Graphics adjustments

This commit is contained in:
Ettore
2026-05-07 23:42:27 +02:00
parent d30b320595
commit 893a5e4750
7 changed files with 86 additions and 27 deletions

View File

@@ -35,7 +35,6 @@ src/
│ ├── database.py # SQLAlchemy models and DB initialization
│ ├── dependencies.py # FastAPI dependency injection (auth guards)
│ └── schemas.py # Pydantic request/response schemas
├── models/ # Thin wrappers re-exporting DB models
├── routers/
│ ├── auth.py # POST /api/auth/admin, POST /api/auth/keypass
│ ├── gates.py # User-facing gate list and open endpoints

View File

@@ -31,6 +31,7 @@ class GateDB(Base):
gate_type: Mapped[str] = mapped_column(String, nullable=False) # 'car' | 'pedestrian'
avconnect_macro_id: Mapped[str] = mapped_column(String, nullable=False) # AVConnect macro ID
status: Mapped[str] = mapped_column(String, default="enabled") # 'enabled' | 'disabled'
group_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) # display group label
class ApiCredential(Base):

View File

@@ -72,6 +72,7 @@ class GateResponse(BaseModel):
gate_type: str
avconnect_macro_id: str
status: str
group_name: Optional[str] = None
class GateCreate(BaseModel):
@@ -79,6 +80,7 @@ class GateCreate(BaseModel):
gate_type: str # 'car' | 'pedestrian'
avconnect_macro_id: str
status: str = "enabled"
group_name: Optional[str] = None
# ── AVConnect Credentials ─────────────────────────────────────────────────────

View File

@@ -158,6 +158,7 @@
<tr>
<th>ID</th>
<th>Name</th>
<th>Group</th>
<th>Type</th>
<th>AVConnect Macro ID</th>
<th>Status</th>
@@ -323,6 +324,11 @@
<label for="gate-name">Name</label>
<input id="gate-name" type="text" placeholder="e.g. Main entrance - Car" required />
</div>
<div class="field">
<label for="gate-group-name">Group <span style="color:var(--text-muted);font-weight:400">(optional)</span></label>
<input id="gate-group-name" type="text" placeholder="e.g. Main entrance" list="gate-group-list" autocomplete="off" />
<datalist id="gate-group-list"></datalist>
</div>
<div class="field">
<label for="gate-type">Type</label>
<select id="gate-type">

View File

@@ -141,12 +141,12 @@ async function loadKeypasses() {
<td>${gatesCell}</td>
<td>${expiresCell}</td>
<td>${badge}</td>
<td style="text-align:right">
<td><div style="display:flex;gap:.5rem;justify-content:flex-end;white-space:nowrap">
${!kp.revoked ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
data-edit-kp='${JSON.stringify({id:kp.id, description:kp.description, expires_at:kp.expires_at, allowed_gate_ids:kp.allowed_gate_ids})}'>Edit</button>` : ""}
${!kp.revoked && expMs >= now ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
data-kp-id="${kp.id}">Revoke</button>` : ""}
</td>`;
</div></td>`;
tbody.appendChild(tr);
}
@@ -345,17 +345,18 @@ async function loadGates() {
tr.innerHTML = `
<td>${g.id}</td>
<td>${esc(g.name)}</td>
<td>${g.group_name ? esc(g.group_name) : '<span style="color:var(--text-muted)">—</span>'}</td>
<td>${g.gate_type === "car" ? "🚘 Car" : "🚶 Pedestrian"}</td>
<td><code style="font-size:.85em">${esc(g.avconnect_macro_id)}</code></td>
<td>${badge}</td>
<td style="text-align:right;white-space:nowrap;display:flex;gap:.5rem;justify-content:flex-end">
<td><div style="text-align:right;white-space:nowrap;display:flex;gap:.5rem;justify-content:flex-end">
${g.status === 'enabled' ? `<button class="btn btn-primary" style="font-size:.8rem;padding:.35rem .9rem"
data-open-id="${g.id}">Open</button>` : ''}
${isAdmin ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
data-edit-id="${g.id}" data-gate='${JSON.stringify(g)}'>Edit</button>
<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
data-del-id="${g.id}">Delete</button>` : ''}
</td>`;
</div></td>`;
tbody.appendChild(tr);
}
@@ -390,10 +391,20 @@ function openGateModal(gate = null) {
document.getElementById("gate-modal-title").textContent = gate ? "Edit Gate" : "Add Gate";
document.getElementById("gate-edit-id").value = gate ? gate.id : "";
document.getElementById("gate-name").value = gate ? gate.name : "";
document.getElementById("gate-group-name").value = gate ? (gate.group_name || "") : "";
document.getElementById("gate-type").value = gate ? gate.gate_type : "car";
document.getElementById("gate-avconnect-macro-id").value = gate ? gate.avconnect_macro_id : "";
document.getElementById("gate-status").value = gate ? gate.status : "enabled";
document.getElementById("gate-error").classList.add("hidden");
// Populate group suggestions from existing gates
const dl = document.getElementById("gate-group-list");
dl.innerHTML = "";
const groups = [...new Set((_allGates || []).map(g => g.group_name).filter(Boolean))].sort();
for (const g of groups) {
const opt = document.createElement("option");
opt.value = g;
dl.appendChild(opt);
}
document.getElementById("gate-modal").classList.remove("hidden");
}
@@ -409,6 +420,7 @@ document.getElementById("gate-form").addEventListener("submit", async e => {
gate_type: document.getElementById("gate-type").value,
avconnect_macro_id: document.getElementById("gate-avconnect-macro-id").value.trim(),
status: document.getElementById("gate-status").value,
group_name: document.getElementById("gate-group-name").value.trim() || null,
};
const errEl = document.getElementById("gate-error");
errEl.classList.add("hidden");
@@ -505,11 +517,11 @@ async function loadAdmins() {
: '<span class="badge badge-muted" style="font-size:.75em">manager</span>';
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${esc(u.username)}${u.username === me ? ' <span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge}</td>
<td style="text-align:right;display:flex;gap:.5rem;justify-content:flex-end">
<td><div style="display:flex;align-items:center;gap:.4rem;flex-wrap:nowrap">${esc(u.username)}${u.username === me ? '<span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge}</div></td>
<td><div style="text-align:right;display:flex;gap:.5rem;justify-content:flex-end">
<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem" data-chpw="${esc(u.username)}">Change password</button>
${u.username !== me ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem" data-del-admin="${esc(u.username)}">Delete</button>` : ""}
</td>`;
</div></td>`;
tbody.appendChild(tr);
}
tbody.querySelectorAll("[data-chpw]").forEach(btn => {

View File

@@ -64,15 +64,46 @@ function renderGates(gates) {
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 label = gate.gate_type === "car" ? "Car" : "Pedestrian";
const btn = document.createElement("button");
btn.className = `gate-btn ${gate.gate_type}`;
btn.dataset.gateId = gate.id;
btn.innerHTML = `<span class="icon">${icon}</span><span>${gate.name}</span>`;
btn.addEventListener("click", () => handleOpenGate(btn, gate));
grid.appendChild(btn);
groupGrid.appendChild(btn);
}
section.appendChild(groupGrid);
grid.appendChild(section);
}
}

View File

@@ -55,7 +55,6 @@
justify-content: space-between;
padding: 1rem 1.25rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
@@ -64,11 +63,24 @@
.app-header .sub { color: var(--text-muted); font-size: .8rem; }
#gates-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1.25rem;
flex: 1;
}
.gate-group-title {
font-size: .8rem;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: .6rem;
}
.gate-group-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
padding: 1.25rem;
flex: 1;
}
/* ── Loading state ───────────────────────────────────────────────────── */
#loading-gates {
@@ -86,8 +98,7 @@
<div id="login-view">
<img src="/static/logo.svg" alt="Lagomare" style="width:72px;height:72px;object-fit:contain;margin-bottom:.5rem" />
<h1>Lagomare Gates</h1>
<p class="subtitle">Enter your keypass to continue</p>
<div class="card">
<div class="card" style="margin-top:2rem">
<form id="login-form">
<div class="field">
<label for="keypass-input">Keypass</label>
@@ -113,12 +124,9 @@
<!-- ── Gates view ──────────────────────────────────────────────────────── -->
<div id="gates-view" class="hidden">
<header class="app-header">
<div style="display:flex;align-items:center;gap:.6rem">
<img src="/static/logo.svg" alt="" style="width:32px;height:32px;object-fit:contain" />
<div>
<div style="display:flex;align-items:center;gap:.75rem">
<img src="/static/logo.svg" alt="" style="width:50px;height:50px;object-fit:contain;flex-shrink:0" />
<div class="app-header h2">Lagomare Gates</div>
<div class="sub">Select a gate to open</div>
</div>
</div>
<button id="logout-btn" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">
Logout