feat(adminui): MainLayout delegates to ZB.MOM.WW.Theme ThemeShell + kit nav
This commit is contained in:
@@ -1,28 +1,55 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
|
||||||
@* Layout chrome ported from ScadaLink CentralUI: no separate top bar — brand sits
|
@* Thin delegation to the shared ZB.MOM.WW.Theme side-rail chassis. ThemeShell owns
|
||||||
at the top of the side rail. The sidebar itself is the interactive island
|
the brand bar, the CSS-only narrow-viewport hamburger, and the responsive collapse,
|
||||||
(<NavSidebar/>); MainLayout stays statically rendered so the Body RenderFragment
|
so MainLayout no longer carries its own .app-shell / hamburger wrapper. Nav sections
|
||||||
doesn't have to cross an interactive boundary. *@
|
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">
|
<ThemeShell Product="OtOpcUa" Accent="#2f5fd0">
|
||||||
@* Hamburger toggle: visible only on viewports <lg.
|
<Nav>
|
||||||
Bootstrap collapse JS lives in bootstrap.bundle.min.js (loaded in App.razor). *@
|
<NavRailSection Title="Navigation" Key="nav">
|
||||||
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
|
<NavRailItem Href="/" Text="Overview" Match="NavLinkMatch.All" />
|
||||||
type="button"
|
<NavRailItem Href="/fleet" Text="Fleet status" />
|
||||||
data-bs-toggle="collapse"
|
<NavRailItem Href="/hosts" Text="Host status" />
|
||||||
data-bs-target="#sidebar-collapse"
|
<NavRailItem Href="/clusters" Text="Clusters" />
|
||||||
aria-controls="sidebar-collapse"
|
<NavRailItem Href="/reservations" Text="Reservations" />
|
||||||
aria-expanded="false"
|
<NavRailItem Href="/certificates" Text="Certificates" />
|
||||||
aria-label="Toggle navigation">
|
<NavRailItem Href="/role-grants" Text="Role grants" />
|
||||||
☰
|
</NavRailSection>
|
||||||
</button>
|
<NavRailSection Title="Scripting" Key="scripting">
|
||||||
|
<NavRailItem Href="/virtual-tags" Text="Virtual tags" />
|
||||||
<div class="collapse d-lg-block" id="sidebar-collapse">
|
<NavRailItem Href="/scripted-alarms" Text="Scripted alarms" />
|
||||||
<NavSidebar />
|
<NavRailItem Href="/scripts" Text="Scripts" />
|
||||||
</div>
|
<NavRailItem Href="/script-log" Text="Script log" />
|
||||||
|
</NavRailSection>
|
||||||
<main class="page">
|
<NavRailSection Title="Live" Key="live">
|
||||||
@Body
|
<NavRailItem Href="/deployments" Text="Deployments" />
|
||||||
</main>
|
<NavRailItem Href="/alerts" Text="Alerts" />
|
||||||
</div>
|
<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">▮</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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user