diff --git a/README.md b/README.md
index 46f9967..800dc14 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/core/database.py b/src/core/database.py
index 51871d4..f2ca88c 100644
--- a/src/core/database.py
+++ b/src/core/database.py
@@ -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):
diff --git a/src/core/schemas.py b/src/core/schemas.py
index 7ca5bec..7e9d799 100644
--- a/src/core/schemas.py
+++ b/src/core/schemas.py
@@ -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 ─────────────────────────────────────────────────────
diff --git a/src/static/admin.html b/src/static/admin.html
index ceb28cd..05799fd 100644
--- a/src/static/admin.html
+++ b/src/static/admin.html
@@ -158,6 +158,7 @@
| ID |
Name |
+ Group |
Type |
AVConnect Macro ID |
Status |
@@ -323,6 +324,11 @@
+
+
+
+
+
`;
tbody.appendChild(tr);
}
@@ -345,17 +345,18 @@ async function loadGates() {
tr.innerHTML = `
${g.id} |
${esc(g.name)} |
+ ${g.group_name ? esc(g.group_name) : '—'} |
${g.gate_type === "car" ? "🚘 Car" : "🚶 Pedestrian"} |
${esc(g.avconnect_macro_id)} |
${badge} |
-
+ |
${g.status === 'enabled' ? `` : ''}
${isAdmin ? `
` : ''}
- | `;
+ `;
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() {
: 'manager';
const tr = document.createElement("tr");
tr.innerHTML = `
- ${esc(u.username)}${u.username === me ? ' you' : ""} ${roleBadge} |
-
+ | ${esc(u.username)}${u.username === me ? 'you' : ""} ${roleBadge} |
+
${u.username !== me ? `` : ""}
- | `;
+ `;
tbody.appendChild(tr);
}
tbody.querySelectorAll("[data-chpw]").forEach(btn => {
diff --git a/src/static/app.js b/src/static/app.js
index 96a37a0..a9f9b7d 100644
--- a/src/static/app.js
+++ b/src/static/app.js
@@ -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 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 = `${icon}${gate.name}`;
- btn.addEventListener("click", () => handleOpenGate(btn, gate));
- grid.appendChild(btn);
+ 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);
}
}
diff --git a/src/static/index.html b/src/static/index.html
index e99f9c8..57850d2 100644
--- a/src/static/index.html
+++ b/src/static/index.html
@@ -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 @@
Lagomare Gates
-
Enter your keypass to continue
-