webui: accordion sidebar and general-purpose status badges

Replace flat nav-section-header labels with collapsible <details>/<summary>
accordion groups (Network, Wireless, VPN, Services, System). Groups default
open; user state persists via localStorage. Tablet icon-only mode always
shows all items regardless of open/closed state.

Add badge-up/down/warning/info/neutral utility classes with full dark mode
support, complementing the existing firewall-specific badge variants.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-14 22:07:23 +02:00
parent bc535099ec
commit 6271e40ce0
3 changed files with 236 additions and 135 deletions
+65 -6
View File
@@ -182,20 +182,50 @@ a:hover {
}
.sidebar-nav {
list-style: none;
flex: 1;
padding: 1rem 0;
padding: 0.5rem 0;
overflow-y: auto;
}
.nav-section-header {
/* Accordion Nav Groups */
details.nav-group {
border-top: 1px solid var(--sidebar-border);
}
details.nav-group:first-child {
border-top: none;
}
details.nav-group > summary.nav-group-summary {
list-style: none;
cursor: pointer;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--sidebar-fg);
opacity: 0.5;
padding: 1rem 1rem 0.25rem;
pointer-events: none;
padding: 0.875rem 1rem 0.375rem;
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
transition: opacity 0.15s;
}
details.nav-group > summary.nav-group-summary:hover { opacity: 0.8; }
details.nav-group > summary.nav-group-summary::-webkit-details-marker { display: none; }
details.nav-group > summary.nav-group-summary::marker { display: none; }
details.nav-group > summary.nav-group-summary::after {
content: '▸';
font-size: 0.7rem;
}
details[open].nav-group > summary.nav-group-summary::after {
content: '▾';
}
.nav-group-items {
list-style: none;
padding-bottom: 0.5rem;
}
.nav-icon {
@@ -796,6 +826,31 @@ a:hover {
.dark .badge-drop { background: var(--slate-800); color: var(--slate-400); }
.dark .badge-continue { background: rgba(59, 130, 246, 0.2); color: #93c5fd; }
/* General-purpose status badges */
.badge-up { background: #dcfce7; color: #166534; }
.badge-down { background: #fef2f2; color: #991b1b; }
.badge-warning { background: #fef9c3; color: #854d0e; }
.badge-info { background: #dbeafe; color: #1e40af; }
.badge-neutral { background: var(--slate-100); color: var(--slate-600); }
@media (prefers-color-scheme: dark) {
.badge-up { background: rgba(22, 163, 74, 0.2); color: #86efac; }
.badge-down { background: rgba(220, 38, 38, 0.2); color: #fca5a5; }
.badge-warning { background: rgba(245, 158, 11, 0.2); color: #fcd34d; }
.badge-info { background: rgba(59, 130, 246, 0.2); color: #93c5fd; }
.badge-neutral { background: var(--slate-800); color: var(--slate-400); }
}
.dark .badge-up { background: rgba(22, 163, 74, 0.2); color: #86efac; }
.dark .badge-down { background: rgba(220, 38, 38, 0.2); color: #fca5a5; }
.dark .badge-warning { background: rgba(245, 158, 11, 0.2); color: #fcd34d; }
.dark .badge-info { background: rgba(59, 130, 246, 0.2); color: #93c5fd; }
.dark .badge-neutral { background: var(--slate-800); color: var(--slate-400); }
.light .badge-up { background: #dcfce7; color: #166534; }
.light .badge-down { background: #fef2f2; color: #991b1b; }
.light .badge-warning { background: #fef9c3; color: #854d0e; }
.light .badge-info { background: #dbeafe; color: #1e40af; }
.light .badge-neutral { background: var(--slate-100); color: var(--slate-600); }
/* Interfaces */
.iface-name { white-space: nowrap; font-weight: 500; }
.iface-name a { color: var(--primary); text-decoration: none; }
@@ -1047,9 +1102,13 @@ a:hover {
display: none;
}
.nav-section-header {
/* Accordion in icon-only mode: always show items, hide section headers */
details.nav-group > summary.nav-group-summary {
display: none;
}
details.nav-group:not([open]) > .nav-group-items {
display: block !important;
}
.nav-link {
justify-content: center;
+20
View File
@@ -185,6 +185,26 @@
});
})();
// Accordion nav group persistence
(function() {
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('details.nav-group').forEach(function(d) {
var label = d.querySelector('summary');
if (!label) return;
var key = 'nav-group:' + label.textContent.trim();
var saved = localStorage.getItem(key);
if (saved === 'closed') {
d.removeAttribute('open');
} else if (saved === 'open') {
d.setAttribute('open', '');
}
d.addEventListener('toggle', function() {
localStorage.setItem(key, d.open ? 'open' : 'closed');
});
});
});
})();
// Sidebar toggle (mobile)
(function() {
document.addEventListener('DOMContentLoaded', function() {
+151 -129
View File
@@ -3,141 +3,163 @@
<div class="sidebar-header">
<img src="/assets/img/logo.png" alt="Infix" class="sidebar-logo">
</div>
<ul class="sidebar-nav">
<li class="nav-section-header">Network</li>
<li>
<a href="/"
hx-get="/"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "dashboard"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/dashboard.svg')"></span>
Dashboard
</a>
</li>
<li>
<a href="/interfaces"
hx-get="/interfaces"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "interfaces"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/interfaces.svg')"></span>
Interfaces
</a>
</li>
<li>
<a href="/routing"
hx-get="/routing"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "routing"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/routing.svg')"></span>
Routing
</a>
</li>
<li>
<a href="/firewall"
hx-get="/firewall"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "firewall"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/firewall.svg')"></span>
Firewall
</a>
</li>
<div class="sidebar-nav">
<details class="nav-group" open>
<summary class="nav-group-summary">Network</summary>
<ul class="nav-group-items">
<li>
<a href="/"
hx-get="/"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "dashboard"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/dashboard.svg')"></span>
Dashboard
</a>
</li>
<li>
<a href="/interfaces"
hx-get="/interfaces"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "interfaces"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/interfaces.svg')"></span>
Interfaces
</a>
</li>
<li>
<a href="/routing"
hx-get="/routing"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "routing"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/routing.svg')"></span>
Routing
</a>
</li>
<li>
<a href="/firewall"
hx-get="/firewall"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "firewall"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/firewall.svg')"></span>
Firewall
</a>
</li>
</ul>
</details>
{{if and .Capabilities (.Capabilities.Has "wifi")}}
<li class="nav-section-header">Wireless</li>
<li>
<a href="/wifi"
hx-get="/wifi"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "wifi"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/wifi.svg')"></span>
WiFi
</a>
</li>
<details class="nav-group" open>
<summary class="nav-group-summary">Wireless</summary>
<ul class="nav-group-items">
<li>
<a href="/wifi"
hx-get="/wifi"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "wifi"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/wifi.svg')"></span>
WiFi
</a>
</li>
</ul>
</details>
{{end}}
<li class="nav-section-header">VPN</li>
<li>
<a href="/vpn"
hx-get="/vpn"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "vpn"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/wireguard.svg')"></span>
WireGuard
</a>
</li>
<details class="nav-group" open>
<summary class="nav-group-summary">VPN</summary>
<ul class="nav-group-items">
<li>
<a href="/vpn"
hx-get="/vpn"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "vpn"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/wireguard.svg')"></span>
WireGuard
</a>
</li>
</ul>
</details>
<li class="nav-section-header">Services</li>
<li>
<a href="/dhcp"
hx-get="/dhcp"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "dhcp"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/dhcp.svg')"></span>
DHCP
</a>
</li>
<li>
<a href="/ntp"
hx-get="/ntp"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "ntp"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/ntp.svg')"></span>
NTP
</a>
</li>
<li>
<a href="/lldp"
hx-get="/lldp"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "lldp"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/lldp.svg')"></span>
LLDP
</a>
</li>
<details class="nav-group" open>
<summary class="nav-group-summary">Services</summary>
<ul class="nav-group-items">
<li>
<a href="/dhcp"
hx-get="/dhcp"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "dhcp"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/dhcp.svg')"></span>
DHCP
</a>
</li>
<li>
<a href="/ntp"
hx-get="/ntp"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "ntp"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/ntp.svg')"></span>
NTP
</a>
</li>
<li>
<a href="/lldp"
hx-get="/lldp"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "lldp"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/lldp.svg')"></span>
LLDP
</a>
</li>
</ul>
</details>
<li class="nav-section-header">System</li>
{{if and .Capabilities (.Capabilities.Has "containers")}}
<li>
<a href="/containers"
hx-get="/containers"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "containers"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/containers.svg')"></span>
Containers
</a>
</li>
{{end}}
<li>
<a href="/firmware"
hx-get="/firmware"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "firmware"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/firmware.svg')"></span>
Firmware
</a>
</li>
<li>
<a href="/keystore"
hx-get="/keystore"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "keystore"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/keystore.svg')"></span>
Keystore
</a>
</li>
</ul>
<details class="nav-group" open>
<summary class="nav-group-summary">System</summary>
<ul class="nav-group-items">
{{if and .Capabilities (.Capabilities.Has "containers")}}
<li>
<a href="/containers"
hx-get="/containers"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "containers"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/containers.svg')"></span>
Containers
</a>
</li>
{{end}}
<li>
<a href="/firmware"
hx-get="/firmware"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "firmware"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/firmware.svg')"></span>
Firmware
</a>
</li>
<li>
<a href="/keystore"
hx-get="/keystore"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "keystore"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/keystore.svg')"></span>
Keystore
</a>
</li>
</ul>
</details>
</div>
<div class="sidebar-footer">
<button id="theme-toggle" class="btn btn-sidebar-action theme-toggle" aria-label="Toggle theme">
<svg id="icon-auto" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">