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"; + } +};