From e4d0d82f7f6062d0981f6364a072738860b9b5a5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 13:48:35 -0400 Subject: [PATCH] feat(adminui): collapsible nav sidebar with cookie state + LoginLayout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the ScadaLink CentralUI sidebar pattern into the OtOpcUa AdminUI: - Drop the top app-bar. Brand moves into the side rail's header — same visual rhythm as ScadaLink's NavMenu. - New NavSection.razor: collapsible eyebrow toggle (rail-eyebrow-toggle CSS) with a chevron + label. Mirrors ScadaLink/Components/Layout/NavSection. - New NavSidebar.razor: interactive island carrying the three section groups (Navigation / Scripting / Live) + session block. Marked @rendermode InteractiveServer; MainLayout itself stays static-rendered because layouts can't take a RenderFragment Body across an interactive boundary. - New wwwroot/js/nav-state.js: window.navState.get/.set persists the expanded-section list to the otopcua_nav cookie (one-year lifetime, SameSite=Lax). Same shape as ScadaLink's scadabridge_nav. - New LoginLayout.razor + @layout LoginLayout on Login.razor: the login page now renders without the side rail — clean centred card. - MainLayout.razor: slimmed down to the d-flex shell + hamburger toggle + + @Body. - Login.razor: also drops the trailing "LDAP bind against the configured directory..." footer that the user asked to remove. - site.css: adds .side-rail .brand styles (mirrored from ScadaLink) and the .rail-eyebrow-toggle / .rail-eyebrow-chevron / .rail-section-body styles for the new collapsible UI. Auto-expand on page load: NavSidebar seeds the expanded set from the current URL's first path segment (in OnInitialized so it works even on the very first server render) and from the cookie (in OnAfterRenderAsync once JS interop is available). LocationChanged hooks keep the expanded state in sync as the user navigates between sections. --- .../Components/App.razor | 1 + .../Components/Layout/LoginLayout.razor | 5 + .../Components/Layout/MainLayout.razor | 65 +------ .../Components/Layout/NavSection.razor | 36 ++++ .../Components/Layout/NavSidebar.razor | 160 ++++++++++++++++++ .../Components/Pages/Login.razor | 11 +- .../ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor | 1 + .../wwwroot/css/site.css | 43 +++++ .../wwwroot/js/nav-state.js | 19 +++ 9 files changed, 274 insertions(+), 67 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/LoginLayout.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSection.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSidebar.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/nav-state.js diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor index cb20c57..36cbdf1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor @@ -21,6 +21,7 @@ + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/LoginLayout.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/LoginLayout.razor new file mode 100644 index 0000000..4d92e80 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/LoginLayout.razor @@ -0,0 +1,5 @@ +@inherits LayoutComponentBase + +@* Minimal layout for the login page: no side rail, no brand block. The page + renders its own centred card. Mirrors ScadaLink CentralUI's LoginLayout. *@ +@Body diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor index e5a89e5..a88862c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor @@ -1,24 +1,9 @@ @inherits LayoutComponentBase -
- OtOpcUa - - admin console - - - - @context.User.Identity?.Name - - signed in - - - - - signed out - - - -
+@* Layout chrome ported from ScadaLink CentralUI: no separate top bar — brand sits + at the top of the side rail. The sidebar itself is the interactive island + (); MainLayout stays statically rendered so the Body RenderFragment + doesn't have to cross an interactive boundary. *@
@* Hamburger toggle: visible only on viewports
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSection.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSection.razor new file mode 100644 index 0000000..5c697c7 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSection.razor @@ -0,0 +1,36 @@ +@* A collapsible sidebar nav section: an uppercase-eyebrow button that toggles + the visibility of its child nav items. Mirrors the ScadaLink NavSection at + /Users/dohertj2/Desktop/scadalink-design/src/ScadaLink.CentralUI/Components/Layout/NavSection.razor + but uses OtOpcUa's rail-eyebrow + rail-link classes. *@ + + +@if (Expanded) +{ +
+ @ChildContent +
+} + +@code { + /// Section label shown in the eyebrow (e.g. "Scripting"). + [Parameter, EditorRequired] + public string Title { get; set; } = string.Empty; + + /// Whether the section is expanded — its child links rendered. + [Parameter] + public bool Expanded { get; set; } + + /// Raised when the eyebrow button is clicked. + [Parameter] + public EventCallback OnToggle { get; set; } + + /// The section's child nav links, rendered only while expanded. + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSidebar.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSidebar.razor new file mode 100644 index 0000000..28f9f77 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSidebar.razor @@ -0,0 +1,160 @@ +@rendermode InteractiveServer +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.JSInterop +@implements IDisposable +@inject NavigationManager Navigation +@inject IJSRuntime JS + +@* Interactive sidebar — extracted from MainLayout so the layout itself can stay + statically rendered (layouts can't take RenderFragment Body across an interactive + boundary). Hosts the collapsible NavSection groups and cookie persistence. *@ + + + +@code { + // Expanded-section state persists in the `otopcua_nav` cookie via + // wwwroot/js/nav-state.js (window.navState.get/.set). Same pattern as + // ScadaLink CentralUI's NavMenu. + + private static readonly string[] SectionIds = { "nav", "scripting", "live" }; + + private readonly HashSet _expanded = new(StringComparer.Ordinal); + + protected override void OnInitialized() + { + Navigation.LocationChanged += OnLocationChanged; + // Seed from the URL so the current page's section is expanded on the + // initial render — works even before JS interop is ready. + EnsureCurrentSectionExpanded(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + + string saved; + try + { + saved = await JS.InvokeAsync("navState.get") ?? string.Empty; + } + catch (JSDisconnectedException) { return; } + catch (InvalidOperationException) { return; } + + foreach (var id in saved.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (Array.IndexOf(SectionIds, id) >= 0) + _expanded.Add(id); + } + + if (EnsureCurrentSectionExpanded()) + await PersistAsync(); + + StateHasChanged(); + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + if (EnsureCurrentSectionExpanded()) + { + _ = PersistAsync(); + _ = InvokeAsync(StateHasChanged); + } + } + + private async Task ToggleAsync(string id) + { + if (!_expanded.Remove(id)) + _expanded.Add(id); + await PersistAsync(); + } + + private bool EnsureCurrentSectionExpanded() + { + var section = CurrentSection(); + return section is not null && _expanded.Add(section); + } + + private string? CurrentSection() + { + var relative = Navigation.ToBaseRelativePath(Navigation.Uri); + var firstSegment = relative.Split('?', '#')[0] + .Split('/', StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + + return firstSegment switch + { + null or "" => "nav", + "fleet" or "hosts" or "clusters" or "reservations" or "certificates" or "role-grants" => "nav", + "virtual-tags" or "scripted-alarms" or "scripts" or "script-log" => "scripting", + "deployments" or "alerts" or "alarms-historian" => "live", + _ => null, + }; + } + + private async Task PersistAsync() + { + try + { + await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded)); + } + catch (JSDisconnectedException) { } + catch (InvalidOperationException) { } + } + + public void Dispose() + { + Navigation.LocationChanged -= OnLocationChanged; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor index af87ffc..6a4d5d4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor @@ -1,8 +1,11 @@ @page "/login" +@layout LoginLayout @* Login MUST stay anonymously reachable — otherwise the fallback authorization policy would lock operators out of the only way in (Admin-001). Static-rendered on purpose: the form POSTs to /auth/login while ASP.NET still owns an unstarted HTTP response. - Calling SignInAsync from an interactive circuit would be too late. *@ + Calling SignInAsync from an interactive circuit would be too late. + + Uses LoginLayout (no side rail) so the page renders as a clean centred card. *@ @attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor index e952953..87b0159 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor @@ -6,3 +6,4 @@ @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.JSInterop @using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared +@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Layout diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/site.css b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/site.css index d1f485d..6bced4c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/site.css +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/site.css @@ -49,6 +49,19 @@ } } +/* Brand block pinned at the top of the side rail. Mirrors ScadaLink's + .sidebar .brand styling — used now that the top app-bar was dropped. */ +.side-rail .brand { + color: var(--ink); + font-size: 1.1rem; + font-weight: 600; + letter-spacing: 0.02em; + padding: 1rem; + border-bottom: 1px solid var(--rule); + margin-bottom: 0.4rem; +} +.side-rail .brand .mark { color: var(--accent); } + .rail-eyebrow { font-size: 0.68rem; font-weight: 600; @@ -58,6 +71,36 @@ padding: 0.3rem 0.6rem; } +/* Collapsible variant — rendered by NavSection.razor. Looks like .rail-eyebrow + plus a leading chevron; clicking flips chevron + expanded state. */ +.rail-eyebrow-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; + background: transparent; + border: 0; + text-align: left; + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--ink-faint); + padding: 0.45rem 0.6rem 0.3rem; + cursor: pointer; +} +.rail-eyebrow-toggle:hover { color: var(--ink); } +.rail-eyebrow-chevron { + display: inline-block; + width: 0.7rem; + font-size: 0.55rem; + color: var(--ink-faint); +} +.rail-section-body { + display: flex; + flex-direction: column; +} + .rail-link { display: block; padding: 0.4rem 0.6rem; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/nav-state.js b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/nav-state.js new file mode 100644 index 0000000..75c10ad --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/nav-state.js @@ -0,0 +1,19 @@ +// Sidebar nav collapse state — persisted in the `otopcua_nav` cookie so it +// survives full page reloads and reconnects. Invoked from MainLayout.razor via +// JS interop (window.navState.get / .set). Mirrors the ScadaLink pattern at +// /Users/dohertj2/Desktop/scadalink-design/src/ScadaLink.CentralUI/wwwroot/js/nav-state.js. +window.navState = { + // Returns the raw cookie value (comma-separated expanded section ids), or + // an empty string when the cookie is absent. + get: function () { + const match = document.cookie.match(/(?:^|;\s*)otopcua_nav=([^;]*)/); + return match ? decodeURIComponent(match[1]) : ""; + }, + // Writes the cookie with a one-year lifetime. SameSite=Lax; not HttpOnly + // (JS must write it) and not sensitive. + set: function (value) { + const oneYearSeconds = 60 * 60 * 24 * 365; + document.cookie = "otopcua_nav=" + encodeURIComponent(value) + + ";path=/;max-age=" + oneYearSeconds + ";samesite=lax"; + } +};