feat(adminui): MainLayout delegates to ZB.MOM.WW.Theme ThemeShell + kit nav

This commit is contained in:
Joseph Doherty
2026-06-03 03:10:49 -04:00
parent 11bcff6af5
commit 70d764b063
3 changed files with 52 additions and 221 deletions
@@ -1,28 +1,55 @@
@inherits LayoutComponentBase
@using Microsoft.AspNetCore.Components.Routing
@* 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
(<NavSidebar/>); MainLayout stays statically rendered so the Body RenderFragment
doesn't have to cross an interactive boundary. *@
@* Thin delegation to the shared ZB.MOM.WW.Theme side-rail chassis. ThemeShell owns
the brand bar, the CSS-only narrow-viewport hamburger, and the responsive collapse,
so MainLayout no longer carries its own .app-shell / hamburger wrapper. Nav sections
are static <details> (NavRailSection) whose expand state is persisted to localStorage
by the kit's <ThemeScripts/> (emitted in App.razor) — replacing the old interactive
NavSidebar island + cookie/URL auto-expand. *@
<div class="app-shell d-flex flex-column flex-lg-row">
@* Hamburger toggle: visible only on viewports <lg.
Bootstrap collapse JS lives in bootstrap.bundle.min.js (loaded in App.razor). *@
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
type="button"
data-bs-toggle="collapse"
data-bs-target="#sidebar-collapse"
aria-controls="sidebar-collapse"
aria-expanded="false"
aria-label="Toggle navigation">
&#9776;
</button>
<div class="collapse d-lg-block" id="sidebar-collapse">
<NavSidebar />
</div>
<main class="page">
@Body
</main>
</div>
<ThemeShell Product="OtOpcUa" Accent="#2f5fd0">
<Nav>
<NavRailSection Title="Navigation" Key="nav">
<NavRailItem Href="/" Text="Overview" Match="NavLinkMatch.All" />
<NavRailItem Href="/fleet" Text="Fleet status" />
<NavRailItem Href="/hosts" Text="Host status" />
<NavRailItem Href="/clusters" Text="Clusters" />
<NavRailItem Href="/reservations" Text="Reservations" />
<NavRailItem Href="/certificates" Text="Certificates" />
<NavRailItem Href="/role-grants" Text="Role grants" />
</NavRailSection>
<NavRailSection Title="Scripting" Key="scripting">
<NavRailItem Href="/virtual-tags" Text="Virtual tags" />
<NavRailItem Href="/scripted-alarms" Text="Scripted alarms" />
<NavRailItem Href="/scripts" Text="Scripts" />
<NavRailItem Href="/script-log" Text="Script log" />
</NavRailSection>
<NavRailSection Title="Live" Key="live">
<NavRailItem Href="/deployments" Text="Deployments" />
<NavRailItem Href="/alerts" Text="Alerts" />
<NavRailItem Href="/alarms-historian" Text="Alarms historian" />
</NavRailSection>
</Nav>
<RailFooter>
<AuthorizeView>
<Authorized>
<div class="rail-eyebrow">Session</div>
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
<div class="rail-roles">
@string.Join(", ", context.User.Claims
.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
</div>
<form method="post" action="/auth/logout">
<AntiforgeryToken />
<button class="rail-btn" type="submit">Sign out</button>
</form>
</Authorized>
<NotAuthorized>
<div class="rail-eyebrow">Session</div>
<a class="rail-btn" href="/login">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</RailFooter>
<ChildContent>@Body</ChildContent>
</ThemeShell>
@@ -1,36 +0,0 @@
@* 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. *@
<button type="button"
class="rail-eyebrow-toggle"
@onclick="OnToggle"
aria-expanded="@(Expanded ? "true" : "false")">
<span class="rail-eyebrow-chevron">@(Expanded ? "▼" : "▶")</span>
<span class="rail-eyebrow-label">@Title</span>
</button>
@if (Expanded)
{
<div class="rail-section-body">
@ChildContent
</div>
}
@code {
/// <summary>Section label shown in the eyebrow (e.g. "Scripting").</summary>
[Parameter, EditorRequired]
public string Title { get; set; } = string.Empty;
/// <summary>Whether the section is expanded — its child links rendered.</summary>
[Parameter]
public bool Expanded { get; set; }
/// <summary>Raised when the eyebrow button is clicked.</summary>
[Parameter]
public EventCallback OnToggle { get; set; }
/// <summary>The section's child nav links, rendered only while expanded.</summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@@ -1,160 +0,0 @@
@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. *@
<nav class="side-rail">
<div class="brand"><span class="mark">&#9646;</span> OtOpcUa</div>
<NavSection Title="Navigation"
Expanded="@_expanded.Contains("nav")"
OnToggle="@(() => ToggleAsync("nav"))">
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Overview</NavLink>
<NavLink class="rail-link" href="/fleet" Match="NavLinkMatch.Prefix">Fleet status</NavLink>
<NavLink class="rail-link" href="/hosts" Match="NavLinkMatch.Prefix">Host status</NavLink>
<NavLink class="rail-link" href="/clusters" Match="NavLinkMatch.Prefix">Clusters</NavLink>
<NavLink class="rail-link" href="/reservations" Match="NavLinkMatch.Prefix">Reservations</NavLink>
<NavLink class="rail-link" href="/certificates" Match="NavLinkMatch.Prefix">Certificates</NavLink>
<NavLink class="rail-link" href="/role-grants" Match="NavLinkMatch.Prefix">Role grants</NavLink>
</NavSection>
<NavSection Title="Scripting"
Expanded="@_expanded.Contains("scripting")"
OnToggle="@(() => ToggleAsync("scripting"))">
<NavLink class="rail-link" href="/virtual-tags" Match="NavLinkMatch.Prefix">Virtual tags</NavLink>
<NavLink class="rail-link" href="/scripted-alarms" Match="NavLinkMatch.Prefix">Scripted alarms</NavLink>
<NavLink class="rail-link" href="/scripts" Match="NavLinkMatch.Prefix">Scripts</NavLink>
<NavLink class="rail-link" href="/script-log" Match="NavLinkMatch.Prefix">Script log</NavLink>
</NavSection>
<NavSection Title="Live"
Expanded="@_expanded.Contains("live")"
OnToggle="@(() => ToggleAsync("live"))">
<NavLink class="rail-link" href="/deployments" Match="NavLinkMatch.Prefix">Deployments</NavLink>
<NavLink class="rail-link" href="/alerts" Match="NavLinkMatch.Prefix">Alerts</NavLink>
<NavLink class="rail-link" href="/alarms-historian" Match="NavLinkMatch.Prefix">Alarms historian</NavLink>
</NavSection>
<div class="rail-foot">
<AuthorizeView>
<Authorized>
<div class="rail-eyebrow">Session</div>
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
<div class="rail-roles">
@string.Join(", ", context.User.Claims
.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
</div>
<form method="post" action="/auth/logout">
<AntiforgeryToken />
<button class="rail-btn" type="submit">Sign out</button>
</form>
</Authorized>
<NotAuthorized>
<div class="rail-eyebrow">Session</div>
<a class="rail-btn" href="/login">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</div>
</nav>
@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<string> _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<string>("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;
}
}