From 6f091d850acbeaebcdddc733d2ae3ae07a636649 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 28 Mar 2026 20:49:58 +0100 Subject: [PATCH] webui: anonymous login, user menu, theme toggle on login page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anonymise the login page: generic "Login" title, lock icon instead of product logo — makes the device harder to fingerprint. Add a floating theme toggle button to the login page so users can switch theme before logging in (cycles auto→light→dark). Replace the four cluttered sidebar footer buttons (theme, download config, reboot, logout) with an always-visible topbar user menu. The dropdown shows the username, three theme options with a checkmark on the active one, download config, reboot, and logout. Refactor all handler page-data structs to embed PageData instead of repeating the four base fields, and add Username (sourced from the request context) so the topbar can display the logged-in user. Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/common.go | 13 ++ src/webui/internal/handlers/containers.go | 14 +- src/webui/internal/handlers/dashboard.go | 15 +- src/webui/internal/handlers/dhcp.go | 14 +- src/webui/internal/handlers/firewall.go | 15 +- src/webui/internal/handlers/interfaces.go | 41 ++--- src/webui/internal/handlers/keystore.go | 13 +- src/webui/internal/handlers/lldp.go | 14 +- src/webui/internal/handlers/ntp.go | 14 +- src/webui/internal/handlers/routing.go | 10 +- src/webui/internal/handlers/system.go | 17 +- src/webui/internal/handlers/vpn.go | 14 +- src/webui/internal/handlers/wifi.go | 14 +- src/webui/static/css/style.css | 203 +++++++++++++++++----- src/webui/static/js/app.js | 121 ++++++++++--- src/webui/templates/layouts/base.html | 82 +++++++-- src/webui/templates/layouts/sidebar.html | 33 ---- src/webui/templates/pages/login.html | 66 ++++--- 18 files changed, 427 insertions(+), 286 deletions(-) diff --git a/src/webui/internal/handlers/common.go b/src/webui/internal/handlers/common.go index 74018bc5..cf00b8d9 100644 --- a/src/webui/internal/handlers/common.go +++ b/src/webui/internal/handlers/common.go @@ -4,12 +4,15 @@ package handlers import ( "context" + "net/http" + "github.com/kernelkit/webui/internal/restconf" "github.com/kernelkit/webui/internal/security" ) // PageData is the base template data passed to every page. type PageData struct { + Username string CsrfToken string PageTitle string ActivePage string @@ -19,3 +22,13 @@ type PageData struct { func csrfToken(ctx context.Context) string { return security.TokenFromContext(ctx) } + +func newPageData(r *http.Request, page, title string) PageData { + return PageData{ + Username: restconf.CredentialsFromContext(r.Context()).Username, + CsrfToken: csrfToken(r.Context()), + PageTitle: title, + ActivePage: page, + Capabilities: CapabilitiesFromContext(r.Context()), + } +} diff --git a/src/webui/internal/handlers/containers.go b/src/webui/internal/handlers/containers.go index 19807383..887bb3d7 100644 --- a/src/webui/internal/handlers/containers.go +++ b/src/webui/internal/handlers/containers.go @@ -67,12 +67,9 @@ type ContainerEntry struct { // containersData is the template data for the containers page. type containersData struct { - CsrfToken string - PageTitle string - ActivePage string - Capabilities *Capabilities - Containers []ContainerEntry - Error string + PageData + Containers []ContainerEntry + Error string } // ContainersHandler serves the containers status page. @@ -84,10 +81,7 @@ type ContainersHandler struct { // Overview renders the containers list page. func (h *ContainersHandler) Overview(w http.ResponseWriter, r *http.Request) { data := containersData{ - CsrfToken: csrfToken(r.Context()), - PageTitle: "Containers", - ActivePage: "containers", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "containers", "Containers"), } // Detach from the request context so that RESTCONF calls survive diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go index de0f2248..b63ea7ff 100644 --- a/src/webui/internal/handlers/dashboard.go +++ b/src/webui/internal/handlers/dashboard.go @@ -177,12 +177,8 @@ type hwComponentJSON struct { // Template data structures. type dashboardData struct { - CsrfToken string - Username string - ActivePage string - PageTitle string - Capabilities *Capabilities - Hostname string + PageData + Hostname string OSName string OSVersion string Machine string @@ -232,13 +228,8 @@ type DashboardHandler struct { // Index renders the dashboard (GET /). func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { - creds := restconf.CredentialsFromContext(r.Context()) data := dashboardData{ - Username: creds.Username, - CsrfToken: csrfToken(r.Context()), - ActivePage: "dashboard", - PageTitle: "Dashboard", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "dashboard", "Dashboard"), } // Detach from the request context so that RESTCONF calls survive diff --git a/src/webui/internal/handlers/dhcp.go b/src/webui/internal/handlers/dhcp.go index 4c81731d..966c1273 100644 --- a/src/webui/internal/handlers/dhcp.go +++ b/src/webui/internal/handlers/dhcp.go @@ -44,12 +44,9 @@ type DHCPData struct { // ─── Page data ─────────────────────────────────────────────────────────────── type dhcpPageData struct { - CsrfToken string - PageTitle string - ActivePage string - Capabilities *Capabilities - DHCP *DHCPData - Error string + PageData + DHCP *DHCPData + Error string } // ─── Handler ───────────────────────────────────────────────────────────────── @@ -63,10 +60,7 @@ type DHCPHandler struct { // Overview renders the DHCP page (GET /dhcp). func (h *DHCPHandler) Overview(w http.ResponseWriter, r *http.Request) { data := dhcpPageData{ - CsrfToken: csrfToken(r.Context()), - PageTitle: "DHCP Server", - ActivePage: "dhcp", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "dhcp", "DHCP Server"), } ctx := context.WithoutCancel(r.Context()) diff --git a/src/webui/internal/handlers/firewall.go b/src/webui/internal/handlers/firewall.go index 64c48582..dcf03765 100644 --- a/src/webui/internal/handlers/firewall.go +++ b/src/webui/internal/handlers/firewall.go @@ -58,12 +58,8 @@ type policyJSON struct { // Template data structures. type firewallData struct { - CsrfToken string - Username string - ActivePage string - PageTitle string - Capabilities *Capabilities - Enabled bool + PageData + Enabled bool EnabledText string DefaultZone string Lockdown bool @@ -111,13 +107,8 @@ type FirewallHandler struct { // Overview renders the firewall overview (GET /firewall). func (h *FirewallHandler) Overview(w http.ResponseWriter, r *http.Request) { - creds := restconf.CredentialsFromContext(r.Context()) data := firewallData{ - Username: creds.Username, - CsrfToken: csrfToken(r.Context()), - ActivePage: "firewall", - PageTitle: "Firewall", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "firewall", "Firewall"), } var fw firewallWrapper diff --git a/src/webui/internal/handlers/interfaces.go b/src/webui/internal/handlers/interfaces.go index 58687bc5..0bc28180 100644 --- a/src/webui/internal/handlers/interfaces.go +++ b/src/webui/internal/handlers/interfaces.go @@ -184,13 +184,9 @@ type ethFrameStats struct { // Template data structures. type interfacesData struct { - CsrfToken string - Username string - ActivePage string - Capabilities *Capabilities - PageTitle string - Interfaces []ifaceEntry - Error string + PageData + Interfaces []ifaceEntry + Error string } type ifaceEntry struct { @@ -221,13 +217,8 @@ type InterfacesHandler struct { // Overview renders the interfaces page (GET /interfaces). func (h *InterfacesHandler) Overview(w http.ResponseWriter, r *http.Request) { - creds := restconf.CredentialsFromContext(r.Context()) data := interfacesData{ - Username: creds.Username, - CsrfToken: csrfToken(r.Context()), - ActivePage: "interfaces", - PageTitle: "Interfaces", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "interfaces", "Interfaces"), } var ifaces interfacesWrapper @@ -389,12 +380,8 @@ func makeIfaceEntry(iface ifaceJSON, indent string) ifaceEntry { // Template data for the interface detail page. type ifaceDetailData struct { - CsrfToken string - Username string - ActivePage string - Capabilities *Capabilities - PageTitle string - Name string + PageData + Name string Type string Status string StatusUp bool @@ -486,11 +473,9 @@ func (h *InterfacesHandler) fetchInterface(r *http.Request, name string) (*iface } // buildDetailData converts raw RESTCONF interface data to template data. -func buildDetailData(username, csrf string, iface *ifaceJSON) ifaceDetailData { +func buildDetailData(r *http.Request, iface *ifaceJSON) ifaceDetailData { d := ifaceDetailData{ - Username: username, - CsrfToken: csrf, - Name: iface.Name, + Name: iface.Name, Type: prettyIfType(iface.Type), Status: iface.OperStatus, StatusUp: iface.OperStatus == "up", @@ -727,7 +712,6 @@ func formatCount(n int64) string { // Detail renders the interface detail page (GET /interfaces/{name}). func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") - creds := restconf.CredentialsFromContext(r.Context()) iface, err := h.fetchInterface(r, name) if err != nil { @@ -736,10 +720,8 @@ func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) { return } - data := buildDetailData(creds.Username, csrfToken(r.Context()), iface) - data.ActivePage = "interfaces" - data.PageTitle = "Interface " + name - data.Capabilities = CapabilitiesFromContext(r.Context()) + data := buildDetailData(r, iface) + data.PageData = newPageData(r, "interfaces", "Interface "+name) tmplName := "iface-detail.html" if r.Header.Get("HX-Request") == "true" { @@ -754,7 +736,6 @@ func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) { // Counters renders the counters fragment for htmx polling (GET /interfaces/{name}/counters). func (h *InterfacesHandler) Counters(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") - creds := restconf.CredentialsFromContext(r.Context()) iface, err := h.fetchInterface(r, name) if err != nil { @@ -763,7 +744,7 @@ func (h *InterfacesHandler) Counters(w http.ResponseWriter, r *http.Request) { return } - data := buildDetailData(creds.Username, csrfToken(r.Context()), iface) + data := buildDetailData(r, iface) if err := h.CountersTemplate.ExecuteTemplate(w, "iface-counters", data); err != nil { log.Printf("template error: %v", err) diff --git a/src/webui/internal/handlers/keystore.go b/src/webui/internal/handlers/keystore.go index f76f0631..098c332e 100644 --- a/src/webui/internal/handlers/keystore.go +++ b/src/webui/internal/handlers/keystore.go @@ -58,11 +58,7 @@ type certificateJSON struct { // Template data structures. type keystoreData struct { - CsrfToken string - Username string - ActivePage string - PageTitle string - Capabilities *Capabilities + PageData SymmetricKeys []symKeyEntry AsymmetricKeys []asymKeyEntry Empty bool @@ -93,13 +89,8 @@ type KeystoreHandler struct { // Overview renders the keystore overview (GET /keystore). func (h *KeystoreHandler) Overview(w http.ResponseWriter, r *http.Request) { - creds := restconf.CredentialsFromContext(r.Context()) data := keystoreData{ - Username: creds.Username, - CsrfToken: csrfToken(r.Context()), - ActivePage: "keystore", - PageTitle: "Keystore", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "keystore", "Keystore"), } var ks keystoreWrapper diff --git a/src/webui/internal/handlers/lldp.go b/src/webui/internal/handlers/lldp.go index c3d9e19b..33b19c05 100644 --- a/src/webui/internal/handlers/lldp.go +++ b/src/webui/internal/handlers/lldp.go @@ -30,12 +30,9 @@ type LLDPNeighbor struct { // ─── Page data ─────────────────────────────────────────────────────────────── type lldpPageData struct { - CsrfToken string - PageTitle string - ActivePage string - Capabilities *Capabilities - Neighbors []LLDPNeighbor - Error string + PageData + Neighbors []LLDPNeighbor + Error string } // ─── Handler ───────────────────────────────────────────────────────────────── @@ -49,10 +46,7 @@ type LLDPHandler struct { // Overview renders the LLDP page (GET /lldp). func (h *LLDPHandler) Overview(w http.ResponseWriter, r *http.Request) { data := lldpPageData{ - CsrfToken: csrfToken(r.Context()), - PageTitle: "LLDP Neighbors", - ActivePage: "lldp", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "lldp", "LLDP Neighbors"), } ctx := context.WithoutCancel(r.Context()) diff --git a/src/webui/internal/handlers/ntp.go b/src/webui/internal/handlers/ntp.go index 8e4a40a1..0b456ffa 100644 --- a/src/webui/internal/handlers/ntp.go +++ b/src/webui/internal/handlers/ntp.go @@ -40,12 +40,9 @@ type NTPData struct { // ─── Page data ─────────────────────────────────────────────────────────────── type ntpPageData struct { - CsrfToken string - PageTitle string - ActivePage string - Capabilities *Capabilities - NTP *NTPData - Error string + PageData + NTP *NTPData + Error string } // ─── Handler ───────────────────────────────────────────────────────────────── @@ -59,10 +56,7 @@ type NTPHandler struct { // Overview renders the NTP page (GET /ntp). func (h *NTPHandler) Overview(w http.ResponseWriter, r *http.Request) { data := ntpPageData{ - CsrfToken: csrfToken(r.Context()), - PageTitle: "NTP", - ActivePage: "ntp", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "ntp", "NTP"), } ctx := context.WithoutCancel(r.Context()) diff --git a/src/webui/internal/handlers/routing.go b/src/webui/internal/handlers/routing.go index 69a983e0..c8464cc7 100644 --- a/src/webui/internal/handlers/routing.go +++ b/src/webui/internal/handlers/routing.go @@ -41,10 +41,7 @@ type OSPFIface struct { } type routingData struct { - CsrfToken string - PageTitle string - ActivePage string - Capabilities *Capabilities + PageData Routes []RouteEntry OSPFNeighbors []OSPFNeighbor OSPFIfaces []OSPFIface @@ -180,10 +177,7 @@ type ospfNeighborJSON struct { func (h *RoutingHandler) Overview(w http.ResponseWriter, r *http.Request) { data := routingData{ - CsrfToken: csrfToken(r.Context()), - PageTitle: "Routing", - ActivePage: "routing", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "routing", "Routing"), } ctx := context.WithoutCancel(r.Context()) diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go index 3f9664d9..a8ad9289 100644 --- a/src/webui/internal/handlers/system.go +++ b/src/webui/internal/handlers/system.go @@ -106,12 +106,8 @@ type fwSlotBundle struct { // Template data for the firmware page. type firmwareData struct { - CsrfToken string - Username string - ActivePage string - PageTitle string - Capabilities *Capabilities - Slots []slotEntry + PageData + Slots []slotEntry Installer *installerEntry Error string Message string @@ -135,14 +131,9 @@ type installerEntry struct { // Firmware renders the firmware overview page (GET /firmware). func (h *SystemHandler) Firmware(w http.ResponseWriter, r *http.Request) { - creds := restconf.CredentialsFromContext(r.Context()) data := firmwareData{ - Username: creds.Username, - CsrfToken: csrfToken(r.Context()), - ActivePage: "firmware", - PageTitle: "Firmware", - Capabilities: CapabilitiesFromContext(r.Context()), - Message: r.URL.Query().Get("msg"), + PageData: newPageData(r, "firmware", "Firmware"), + Message: r.URL.Query().Get("msg"), } var sw fwSoftwareWrapper diff --git a/src/webui/internal/handlers/vpn.go b/src/webui/internal/handlers/vpn.go index 6def10e9..9edbc9e7 100644 --- a/src/webui/internal/handlers/vpn.go +++ b/src/webui/internal/handlers/vpn.go @@ -47,12 +47,9 @@ type WGTunnel struct { // vpnData is the template data struct for the VPN page. type vpnData struct { - CsrfToken string - PageTitle string - ActivePage string - Capabilities *Capabilities - Tunnels []WGTunnel - Error string + PageData + Tunnels []WGTunnel + Error string } // VPNHandler serves the VPN/WireGuard status page. @@ -64,10 +61,7 @@ type VPNHandler struct { // Overview renders the VPN page (GET /vpn). func (h *VPNHandler) Overview(w http.ResponseWriter, r *http.Request) { data := vpnData{ - CsrfToken: csrfToken(r.Context()), - PageTitle: "VPN", - ActivePage: "vpn", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "vpn", "VPN"), } // Detach from the request context so that RESTCONF calls survive diff --git a/src/webui/internal/handlers/wifi.go b/src/webui/internal/handlers/wifi.go index 89c80cf9..4a59c066 100644 --- a/src/webui/internal/handlers/wifi.go +++ b/src/webui/internal/handlers/wifi.go @@ -137,12 +137,9 @@ type WiFiScan struct { // wifiData is the top-level template data for the WiFi page. type wifiData struct { - CsrfToken string - PageTitle string - ActivePage string - Capabilities *Capabilities - Radios []WiFiRadio - Error string + PageData + Radios []WiFiRadio + Error string } // WiFiHandler serves the WiFi status page. @@ -154,10 +151,7 @@ type WiFiHandler struct { // Overview renders the WiFi page (GET /wifi). func (h *WiFiHandler) Overview(w http.ResponseWriter, r *http.Request) { data := wifiData{ - CsrfToken: csrfToken(r.Context()), - PageTitle: "WiFi", - ActivePage: "wifi", - Capabilities: CapabilitiesFromContext(r.Context()), + PageData: newPageData(r, "wifi", "WiFi"), } // Detach from the request context so that RESTCONF calls survive diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 9f69573d..70416f1a 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -146,7 +146,8 @@ a:hover { .layout { display: flex; - min-height: 100vh; + height: 100vh; + overflow: hidden; } #sidebar { @@ -157,13 +158,20 @@ a:hover { flex-direction: column; flex-shrink: 0; border-right: 1px solid var(--sidebar-border); + overflow-y: auto; +} + +.main-column { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; } #content { flex: 1; padding: 2rem; overflow-y: auto; - height: 100vh; } /* ========================================================================== @@ -1006,31 +1014,26 @@ details[open].nav-group > summary.nav-group-summary::after { .signal-poor { color: var(--danger); font-weight: 600; } /* ========================================================================== - Topbar & Theme Toggle + Topbar & User Menu ========================================================================== */ .topbar { - display: none; - align-items: center; - justify-content: flex-end; - padding: 0.5rem 1rem; - background: var(--surface); - border-bottom: 1px solid var(--border); -} - -.theme-toggle { - background: none; - border: none; - cursor: pointer; - color: var(--sidebar-fg); display: flex; align-items: center; - gap: 0.625rem; - padding: 0; + justify-content: flex-end; + padding: 0 1rem; + height: 48px; + background: var(--surface); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + z-index: 100; } -.theme-toggle:hover { - color: #fff; +.topbar-right { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; } .hamburger-btn { @@ -1040,7 +1043,7 @@ details[open].nav-group > summary.nav-group-summary::after { padding: 0.375rem; cursor: pointer; color: var(--fg-muted); - display: none; /* Hidden by default; shown via media query */ + display: none; align-items: center; } .hamburger-btn:hover { @@ -1048,6 +1051,137 @@ details[open].nav-group > summary.nav-group-summary::after { border-color: var(--fg-muted); } +/* User menu button */ +.user-menu { + position: relative; +} + +.user-menu-btn { + display: flex; + align-items: center; + gap: 0.4rem; + background: none; + border: none; + border-radius: var(--radius); + padding: 0.35rem 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--fg-muted); + cursor: pointer; + transition: color 0.15s; +} +.user-menu-btn:hover { + color: var(--fg); +} +.user-menu-btn .chevron { + color: var(--fg-muted); + transition: transform 0.2s; +} +.user-menu-btn[aria-expanded="true"] .chevron { + transform: rotate(180deg); +} + +/* Dropdown panel — opens on hover */ +.user-dropdown { + position: absolute; + top: 100%; + right: 0; + min-width: 190px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 0.375rem 0; + z-index: 200; + /* Hidden by default; shown on hover or when button is aria-expanded */ + opacity: 0; + pointer-events: none; + transform: translateY(-4px); + transition: opacity 0.15s, transform 0.15s; +} +.user-menu:hover .user-dropdown, +.user-menu-btn[aria-expanded="true"] + .user-dropdown { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.dropdown-section-label { + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fg-muted); + padding: 0.25rem 0.875rem 0.125rem; +} + +.dropdown-divider { + border: none; + border-top: 1px solid var(--border); + margin: 0.375rem 0; +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.45rem 0.875rem; + font-size: 0.875rem; + color: var(--fg-muted); + background: none; + border: none; + cursor: pointer; + text-decoration: none; + text-align: left; + transition: background 0.1s, color 0.1s; + box-sizing: border-box; +} +.dropdown-item:hover, +.dropdown-item:focus { + background: var(--border-subtle); + color: var(--fg); + text-decoration: none; +} +.dropdown-item svg { flex-shrink: 0; color: var(--fg-muted); transition: color 0.1s; } +.dropdown-item:hover svg, +.dropdown-item:focus svg { color: var(--fg); } +.dropdown-item-danger:hover, +.dropdown-item-danger:focus { color: var(--danger); } +.dropdown-item-danger:hover svg, +.dropdown-item-danger:focus svg { color: var(--danger); } + +/* Theme checkmark — hidden by default, shown on active option */ +.theme-check { margin-left: auto; flex-shrink: 0; opacity: 0; transition: opacity 0.1s; } +.theme-opt.is-active .theme-check { opacity: 1; } + +/* Login page floating theme button */ +.login-theme-btn { + position: fixed; + bottom: 1.25rem; + right: 1.25rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.45rem; + cursor: pointer; + color: var(--fg-muted); + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow-sm); + transition: all 0.15s; +} +.login-theme-btn:hover { color: var(--fg); border-color: var(--fg-muted); } + +/* Login lock icon */ +.login-icon { + display: flex; + justify-content: center; + margin-bottom: 1.5rem; + color: var(--fg-muted); +} + /* ========================================================================== Responsive Layout ========================================================================== */ @@ -1059,7 +1193,7 @@ details[open].nav-group > summary.nav-group-summary::after { top: 0; left: 0; height: 100%; - z-index: 200; + z-index: 300; transform: translateX(-100%); transition: transform 0.25s ease; width: var(--sidebar-width); @@ -1074,22 +1208,17 @@ details[open].nav-group > summary.nav-group-summary::after { position: fixed; inset: 0; background: rgba(0,0,0,0.4); - z-index: 100; + z-index: 200; } #content { height: auto; - min-height: 100vh; + min-height: 100%; } .hamburger-btn { display: flex; } - - .topbar { - display: flex; - justify-content: space-between; - } } /* Tablet (769px–1024px): Icon-only sidebar */ @@ -1115,22 +1244,6 @@ details[open].nav-group > summary.nav-group-summary::after { padding: 0.6rem; } - .sidebar-footer .btn-sidebar-action span:not(.nav-icon) { - display: none; - } - - .sidebar-footer .btn-logout span:not(.nav-icon) { - display: none; - } - - .theme-toggle span { - display: none; - } - - .theme-toggle { - justify-content: center; - } - .sidebar-header { display: flex; align-items: center; diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 81119d08..fe69109a 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -135,35 +135,38 @@ } })(); -// Dark mode toggle (auto / light / dark) +// Theme (auto / light / dark) — shared by main app and login page (function() { function getTheme() { var m = document.cookie.split(';').map(function(c){return c.trim();}).find(function(c){return c.indexOf('theme=')===0;}); return m ? m.split('=')[1] : null; } + function applyTheme(mode) { - var el = document.documentElement; - el.classList.remove('dark', 'light'); - if (mode === 'dark') { - el.classList.add('dark'); - } else if (mode === 'light') { - el.classList.add('light'); - } - updateIcon(mode || 'auto'); + document.documentElement.classList.remove('dark', 'light'); + if (mode === 'dark') document.documentElement.classList.add('dark'); + else if (mode === 'light') document.documentElement.classList.add('light'); + updateDropdownCheck(mode || 'auto'); + updateLoginToggleIcon(mode || 'auto'); } - function updateIcon(mode) { - var btn = document.getElementById('theme-toggle'); + + function updateDropdownCheck(mode) { + document.querySelectorAll('.theme-opt').forEach(function(btn) { + btn.classList.toggle('is-active', btn.getAttribute('data-theme') === mode); + }); + } + + function updateLoginToggleIcon(mode) { + // Login page floating toggle — cycle auto→light→dark + var btn = document.getElementById('login-theme-toggle'); if (!btn) return; - var icons = btn.querySelectorAll('svg'); - for (var i = 0; i < icons.length; i++) icons[i].style.display = 'none'; - var id = mode === 'dark' ? 'icon-dark' : mode === 'light' ? 'icon-light' : 'icon-auto'; - var active = btn.querySelector('#' + id); - if (active) active.style.display = ''; - btn.setAttribute('aria-label', - mode === 'dark' ? 'Theme: dark (click for auto)' : - mode === 'light' ? 'Theme: light (click for dark)' : - 'Theme: auto (click for light)'); + var ids = {auto: 'lti-auto', light: 'lti-light', dark: 'lti-dark'}; + Object.keys(ids).forEach(function(k) { + var el = btn.querySelector('#' + ids[k]); + if (el) el.style.display = (k === mode) ? '' : 'none'; + }); } + function setTheme(mode) { if (mode) { document.cookie = 'theme=' + mode + '; path=/; max-age=31536000; samesite=lax'; @@ -172,16 +175,32 @@ } applyTheme(mode); } - var saved = getTheme(); - applyTheme(saved); + + // Apply saved theme immediately (before DOMContentLoaded to avoid flash) + applyTheme(getTheme()); + document.addEventListener('DOMContentLoaded', function() { - var btn = document.getElementById('theme-toggle'); - if (btn) btn.addEventListener('click', function() { - var cur = getTheme(); - if (!cur) setTheme('light'); - else if (cur === 'light') setTheme('dark'); - else setTheme(null); + // Apply checkmarks now that DOM is ready + updateDropdownCheck(getTheme() || 'auto'); + + // Dropdown theme options (main app) + document.querySelectorAll('.theme-opt').forEach(function(btn) { + btn.addEventListener('click', function() { + var t = btn.getAttribute('data-theme'); + setTheme(t === 'auto' ? null : t); + }); }); + + // Login page floating toggle — cycles auto → light → dark → auto + var loginBtn = document.getElementById('login-theme-toggle'); + if (loginBtn) { + loginBtn.addEventListener('click', function() { + var cur = getTheme(); + if (!cur || cur === 'auto') setTheme('light'); + else if (cur === 'light') setTheme('dark'); + else setTheme(null); + }); + } }); })(); @@ -205,6 +224,25 @@ }); })(); +// User menu — hover handled by CSS; JS manages aria-expanded and keyboard +(function() { + document.addEventListener('DOMContentLoaded', function() { + var menu = document.getElementById('user-menu'); + var btn = document.getElementById('user-menu-btn'); + if (!menu || !btn) return; + + menu.addEventListener('mouseenter', function() { + btn.setAttribute('aria-expanded', 'true'); + }); + menu.addEventListener('mouseleave', function() { + btn.setAttribute('aria-expanded', 'false'); + }); + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') btn.setAttribute('aria-expanded', 'false'); + }); + }); +})(); + // Sidebar toggle (mobile) (function() { document.addEventListener('DOMContentLoaded', function() { @@ -230,3 +268,30 @@ }); }); })(); + +// ─── data-autofocus: WM-friendly focus on visible ────────────────────────── +// Focus the [data-autofocus] element only when the page is actually +// visible — calling .focus() on a background tab/desktop is treated +// as an attention hint by Cinnamon/Muffin (and similar X11 WMs) +// and pulls the whole browser window to the foreground. +(function() { + function focusAutofocusWhenVisible() { + var el = document.querySelector('[data-autofocus]'); + if (!el) return; + var apply = function () { + if (document.visibilityState !== 'hidden') el.focus({ preventScroll: true }); + }; + if (document.visibilityState !== 'hidden') { + apply(); + return; + } + var handler = function () { + if (document.visibilityState !== 'hidden') { + document.removeEventListener('visibilitychange', handler); + apply(); + } + }; + document.addEventListener('visibilitychange', handler); + } + document.addEventListener('DOMContentLoaded', focusAutofocusWhenVisible); +})(); diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html index f2278db1..18037c2b 100644 --- a/src/webui/templates/layouts/base.html +++ b/src/webui/templates/layouts/base.html @@ -7,7 +7,7 @@ - {{if .PageTitle}}{{.PageTitle}} — {{end}}Infix + {{if .PageTitle}}{{.PageTitle}} — {{end}}Management @@ -15,21 +15,75 @@ -
-
- -
{{template "sidebar" .}} -
- {{block "content" .}}{{end}} -
+
+
+ +
+
+ +
+ + + + + + + + Download Config + + + +
+ + +
+
+
+
+
+
+ {{block "content" .}}{{end}} +
+
diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index 1947ca17..da54e7e7 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -160,38 +160,5 @@ - {{end}} diff --git a/src/webui/templates/pages/login.html b/src/webui/templates/pages/login.html index 78d5bb71..ac6530a7 100644 --- a/src/webui/templates/pages/login.html +++ b/src/webui/templates/pages/login.html @@ -6,7 +6,7 @@ - Infix — Login + Login @@ -14,26 +14,52 @@