Add gate groups. Graphics adjustments
This commit is contained in:
@@ -35,7 +35,6 @@ src/
|
|||||||
│ ├── database.py # SQLAlchemy models and DB initialization
|
│ ├── database.py # SQLAlchemy models and DB initialization
|
||||||
│ ├── dependencies.py # FastAPI dependency injection (auth guards)
|
│ ├── dependencies.py # FastAPI dependency injection (auth guards)
|
||||||
│ └── schemas.py # Pydantic request/response schemas
|
│ └── schemas.py # Pydantic request/response schemas
|
||||||
├── models/ # Thin wrappers re-exporting DB models
|
|
||||||
├── routers/
|
├── routers/
|
||||||
│ ├── auth.py # POST /api/auth/admin, POST /api/auth/keypass
|
│ ├── auth.py # POST /api/auth/admin, POST /api/auth/keypass
|
||||||
│ ├── gates.py # User-facing gate list and open endpoints
|
│ ├── gates.py # User-facing gate list and open endpoints
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class GateDB(Base):
|
|||||||
gate_type: Mapped[str] = mapped_column(String, nullable=False) # 'car' | 'pedestrian'
|
gate_type: Mapped[str] = mapped_column(String, nullable=False) # 'car' | 'pedestrian'
|
||||||
avconnect_macro_id: Mapped[str] = mapped_column(String, nullable=False) # AVConnect macro ID
|
avconnect_macro_id: Mapped[str] = mapped_column(String, nullable=False) # AVConnect macro ID
|
||||||
status: Mapped[str] = mapped_column(String, default="enabled") # 'enabled' | 'disabled'
|
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):
|
class ApiCredential(Base):
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class GateResponse(BaseModel):
|
|||||||
gate_type: str
|
gate_type: str
|
||||||
avconnect_macro_id: str
|
avconnect_macro_id: str
|
||||||
status: str
|
status: str
|
||||||
|
group_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class GateCreate(BaseModel):
|
class GateCreate(BaseModel):
|
||||||
@@ -79,6 +80,7 @@ class GateCreate(BaseModel):
|
|||||||
gate_type: str # 'car' | 'pedestrian'
|
gate_type: str # 'car' | 'pedestrian'
|
||||||
avconnect_macro_id: str
|
avconnect_macro_id: str
|
||||||
status: str = "enabled"
|
status: str = "enabled"
|
||||||
|
group_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# ── AVConnect Credentials ─────────────────────────────────────────────────────
|
# ── AVConnect Credentials ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -158,6 +158,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
|
<th>Group</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>AVConnect Macro ID</th>
|
<th>AVConnect Macro ID</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
@@ -323,6 +324,11 @@
|
|||||||
<label for="gate-name">Name</label>
|
<label for="gate-name">Name</label>
|
||||||
<input id="gate-name" type="text" placeholder="e.g. Main entrance - Car" required />
|
<input id="gate-name" type="text" placeholder="e.g. Main entrance - Car" required />
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label for="gate-type">Type</label>
|
<label for="gate-type">Type</label>
|
||||||
<select id="gate-type">
|
<select id="gate-type">
|
||||||
|
|||||||
@@ -141,12 +141,12 @@ async function loadKeypasses() {
|
|||||||
<td>${gatesCell}</td>
|
<td>${gatesCell}</td>
|
||||||
<td>${expiresCell}</td>
|
<td>${expiresCell}</td>
|
||||||
<td>${badge}</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"
|
${!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>` : ""}
|
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"
|
${!kp.revoked && expMs >= now ? `<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
|
||||||
data-kp-id="${kp.id}">Revoke</button>` : ""}
|
data-kp-id="${kp.id}">Revoke</button>` : ""}
|
||||||
</td>`;
|
</div></td>`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,17 +345,18 @@ async function loadGates() {
|
|||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${g.id}</td>
|
<td>${g.id}</td>
|
||||||
<td>${esc(g.name)}</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>${g.gate_type === "car" ? "🚘 Car" : "🚶 Pedestrian"}</td>
|
||||||
<td><code style="font-size:.85em">${esc(g.avconnect_macro_id)}</code></td>
|
<td><code style="font-size:.85em">${esc(g.avconnect_macro_id)}</code></td>
|
||||||
<td>${badge}</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"
|
${g.status === 'enabled' ? `<button class="btn btn-primary" style="font-size:.8rem;padding:.35rem .9rem"
|
||||||
data-open-id="${g.id}">Open</button>` : ''}
|
data-open-id="${g.id}">Open</button>` : ''}
|
||||||
${isAdmin ? `<button class="btn btn-ghost" style="font-size:.8rem;padding:.35rem .9rem"
|
${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>
|
data-edit-id="${g.id}" data-gate='${JSON.stringify(g)}'>Edit</button>
|
||||||
<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
|
<button class="btn btn-danger" style="font-size:.8rem;padding:.35rem .9rem"
|
||||||
data-del-id="${g.id}">Delete</button>` : ''}
|
data-del-id="${g.id}">Delete</button>` : ''}
|
||||||
</td>`;
|
</div></td>`;
|
||||||
tbody.appendChild(tr);
|
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-modal-title").textContent = gate ? "Edit Gate" : "Add Gate";
|
||||||
document.getElementById("gate-edit-id").value = gate ? gate.id : "";
|
document.getElementById("gate-edit-id").value = gate ? gate.id : "";
|
||||||
document.getElementById("gate-name").value = gate ? gate.name : "";
|
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-type").value = gate ? gate.gate_type : "car";
|
||||||
document.getElementById("gate-avconnect-macro-id").value = gate ? gate.avconnect_macro_id : "";
|
document.getElementById("gate-avconnect-macro-id").value = gate ? gate.avconnect_macro_id : "";
|
||||||
document.getElementById("gate-status").value = gate ? gate.status : "enabled";
|
document.getElementById("gate-status").value = gate ? gate.status : "enabled";
|
||||||
document.getElementById("gate-error").classList.add("hidden");
|
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");
|
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,
|
gate_type: document.getElementById("gate-type").value,
|
||||||
avconnect_macro_id: document.getElementById("gate-avconnect-macro-id").value.trim(),
|
avconnect_macro_id: document.getElementById("gate-avconnect-macro-id").value.trim(),
|
||||||
status: document.getElementById("gate-status").value,
|
status: document.getElementById("gate-status").value,
|
||||||
|
group_name: document.getElementById("gate-group-name").value.trim() || null,
|
||||||
};
|
};
|
||||||
const errEl = document.getElementById("gate-error");
|
const errEl = document.getElementById("gate-error");
|
||||||
errEl.classList.add("hidden");
|
errEl.classList.add("hidden");
|
||||||
@@ -505,11 +517,11 @@ async function loadAdmins() {
|
|||||||
: '<span class="badge badge-muted" style="font-size:.75em">manager</span>';
|
: '<span class="badge badge-muted" style="font-size:.75em">manager</span>';
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${esc(u.username)}${u.username === me ? ' <span class="badge badge-green" style="font-size:.75em">you</span>' : ""} ${roleBadge}</td>
|
<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 style="text-align:right;display:flex;gap:.5rem;justify-content:flex-end">
|
<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>
|
<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>` : ""}
|
${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.appendChild(tr);
|
||||||
}
|
}
|
||||||
tbody.querySelectorAll("[data-chpw]").forEach(btn => {
|
tbody.querySelectorAll("[data-chpw]").forEach(btn => {
|
||||||
|
|||||||
@@ -64,15 +64,46 @@ function renderGates(gates) {
|
|||||||
loading.classList.add("hidden");
|
loading.classList.add("hidden");
|
||||||
grid.classList.remove("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) {
|
for (const gate of gates) {
|
||||||
const icon = gate.gate_type === "car" ? "🚘" : "🚶";
|
const key = gate.group_name || "";
|
||||||
const label = gate.gate_type === "car" ? "Car" : "Pedestrian";
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
const btn = document.createElement("button");
|
groups.get(key).push(gate);
|
||||||
btn.className = `gate-btn ${gate.gate_type}`;
|
}
|
||||||
btn.dataset.gateId = gate.id;
|
const hasNamedGroups = [...groups.keys()].some(k => k !== "");
|
||||||
btn.innerHTML = `<span class="icon">${icon}</span><span>${gate.name}</span>`;
|
const sortedKeys = [...groups.keys()].sort((a, b) => {
|
||||||
btn.addEventListener("click", () => handleOpenGate(btn, gate));
|
if (a === "" && b !== "") return 1;
|
||||||
grid.appendChild(btn);
|
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 = `<span class="icon">${icon}</span><span>${gate.name}</span>`;
|
||||||
|
btn.addEventListener("click", () => handleOpenGate(btn, gate));
|
||||||
|
groupGrid.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.appendChild(groupGrid);
|
||||||
|
grid.appendChild(section);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,6 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -64,11 +63,24 @@
|
|||||||
.app-header .sub { color: var(--text-muted); font-size: .8rem; }
|
.app-header .sub { color: var(--text-muted); font-size: .8rem; }
|
||||||
|
|
||||||
#gates-grid {
|
#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;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1.25rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
/* ── Loading state ───────────────────────────────────────────────────── */
|
/* ── Loading state ───────────────────────────────────────────────────── */
|
||||||
#loading-gates {
|
#loading-gates {
|
||||||
@@ -86,8 +98,7 @@
|
|||||||
<div id="login-view">
|
<div id="login-view">
|
||||||
<img src="/static/logo.svg" alt="Lagomare" style="width:72px;height:72px;object-fit:contain;margin-bottom:.5rem" />
|
<img src="/static/logo.svg" alt="Lagomare" style="width:72px;height:72px;object-fit:contain;margin-bottom:.5rem" />
|
||||||
<h1>Lagomare Gates</h1>
|
<h1>Lagomare Gates</h1>
|
||||||
<p class="subtitle">Enter your keypass to continue</p>
|
<div class="card" style="margin-top:2rem">
|
||||||
<div class="card">
|
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="keypass-input">Keypass</label>
|
<label for="keypass-input">Keypass</label>
|
||||||
@@ -113,12 +124,9 @@
|
|||||||
<!-- ── Gates view ──────────────────────────────────────────────────────── -->
|
<!-- ── Gates view ──────────────────────────────────────────────────────── -->
|
||||||
<div id="gates-view" class="hidden">
|
<div id="gates-view" class="hidden">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div style="display:flex;align-items:center;gap:.6rem">
|
<div style="display:flex;align-items:center;gap:.75rem">
|
||||||
<img src="/static/logo.svg" alt="" style="width:32px;height:32px;object-fit:contain" />
|
<img src="/static/logo.svg" alt="" style="width:50px;height:50px;object-fit:contain;flex-shrink:0" />
|
||||||
<div>
|
<div class="app-header h2">Lagomare Gates</div>
|
||||||
<div class="app-header h2">Lagomare Gates</div>
|
|
||||||
<div class="sub">Select a gate to open</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button id="logout-btn" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">
|
<button id="logout-btn" class="btn btn-ghost" style="font-size:.85rem;padding:.5rem 1rem">
|
||||||
Logout
|
Logout
|
||||||
|
|||||||
Reference in New Issue
Block a user