| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251 |
- @using RackPeek.Domain
- @using Shared.Rcl.Components
- @inherits LayoutComponentBase
- @implements IAsyncDisposable
- @inject NavigationManager Nav
- @inject IJSRuntime JS
- <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono"
- data-testid="app-root">
- <header class="border-b border-zinc-800 bg-zinc-900" data-testid="app-header">
- <!-- Top row: brand + search (always visible) + nav cluster | mobile dropdown -->
- <div class="flex items-center gap-3 sm:gap-4 px-6 sm:px-8 lg:px-12 py-4">
- <NavLink href=""
- data-testid="brand-link"
- class="hover:text-emerald-400 flex-shrink-0"
- activeClass="text-emerald-400 font-semibold">
- <div class="flex items-baseline gap-2"
- data-testid="brand-text">
- <span class="text-xl font-bold text-emerald-400 tracking-wider">rackpeek</span>
- <span class="text-[10px] text-zinc-500 tracking-wide">@RpkConstants.Version</span>
- </div>
- </NavLink>
- <!-- Single search instance, sized responsively. Sits next to the
- brand on the left at every viewport — fills available space
- between brand and the nav cluster. -->
- <div class="flex-1 min-w-0 max-w-[180px] sm:max-w-[240px] min-[1000px]:max-w-xs xl:max-w-md">
- <GlobalSearch/>
- </div>
- <!-- Desktop: git + nav pushed to the right -->
- <div class="hidden min-[1000px]:flex items-center gap-5 ml-auto flex-shrink-0">
- @if (RpkConstants.HasGitServices)
- {
- <GitStatusIndicator/>
- }
- <nav class="flex items-center gap-4 text-sm" data-testid="main-nav">
- @foreach (NavItem item in NavItems)
- {
- <NavLink href="@item.Href"
- Match="@item.Match"
- class="hover:text-emerald-400"
- activeClass="text-emerald-400 font-semibold"
- data-testid="@($"nav-{item.TestId}")">
- @item.Label
- </NavLink>
- }
- </nav>
- </div>
- <!-- Mobile: page dropdown anchored to the right -->
- <div class="min-[1000px]:hidden ml-auto relative flex-shrink-0" id="rpk-mobile-nav">
- <button class="flex items-center gap-2 px-3 py-1.5 text-sm rounded border border-zinc-800 text-zinc-200 hover:text-emerald-400 hover:border-zinc-700"
- data-testid="nav-toggle"
- aria-haspopup="menu"
- aria-expanded="@(_dropdownOpen ? "true" : "false")"
- @onclick="ToggleDropdown">
- <span>@CurrentPageLabel</span>
- <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
- stroke="currentColor" stroke-width="2">
- @if (_dropdownOpen)
- {
- <path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/>
- }
- else
- {
- <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
- }
- </svg>
- </button>
- @if (_dropdownOpen)
- {
- <div class="absolute right-0 mt-2 w-52 bg-zinc-900 border border-zinc-800 rounded shadow-lg overflow-hidden z-50"
- data-testid="mobile-nav"
- role="menu">
- @foreach (NavItem item in NavItems)
- {
- <NavLink href="@item.Href"
- Match="@item.Match"
- class="block px-3 py-2 text-sm border-b border-zinc-800 last:border-b-0 hover:text-emerald-400 hover:bg-zinc-800/50"
- activeClass="text-emerald-400 bg-emerald-500/10"
- data-testid="@($"nav-mobile-{item.TestId}")"
- @onclick="CloseDropdown">
- @item.Label
- </NavLink>
- }
- </div>
- }
- </div>
- <!-- Community / source links — anchored to the far right of the
- header at every breakpoint. The two clusters above carry
- `ml-auto`; this group sits immediately to their right. -->
- <div class="flex items-center gap-3 flex-shrink-0">
- <a href="https://github.com/Timmoth/RackPeek"
- target="_blank"
- rel="noopener noreferrer"
- aria-label="RackPeek on GitHub"
- class="text-zinc-400 hover:text-emerald-400 transition"
- data-testid="social-github">
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
- fill="currentColor" class="w-5 h-5">
- <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
- </svg>
- </a>
- <a href="https://discord.gg/egXRPdesee"
- target="_blank"
- rel="noopener noreferrer"
- aria-label="Join the RackPeek Discord"
- class="text-zinc-400 hover:text-emerald-400 transition"
- data-testid="social-discord">
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
- fill="currentColor" class="w-5 h-5">
- <path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
- </svg>
- </a>
- </div>
- </div>
- <!-- Mobile-only second row: git status (only shown when configured) -->
- @if (RpkConstants.HasGitServices)
- {
- <div class="min-[1000px]:hidden border-t border-zinc-800 px-6 py-3">
- <GitStatusIndicator/>
- </div>
- }
- </header>
- <main class="p-6" data-testid="page-content">
- @Body
- </main>
- </div>
- @code {
- private const string _outsideDismissId = "rpk-mobile-nav";
- private const string _containerSelector = "#rpk-mobile-nav";
- private bool _dropdownOpen;
- private bool _listenerRegistered;
- private DotNetObjectReference<MainLayout>? _selfRef;
- protected override void OnInitialized()
- {
- _selfRef = DotNetObjectReference.Create(this);
- Nav.LocationChanged += HandleLocationChanged;
- }
- private void HandleLocationChanged(object? sender, LocationChangedEventArgs e) =>
- InvokeAsync(StateHasChanged);
- private void ToggleDropdown() => _dropdownOpen = !_dropdownOpen;
- private void CloseDropdown() => _dropdownOpen = false;
- [JSInvokable]
- public Task DismissDropdownFromJs()
- {
- if (!_dropdownOpen) return Task.CompletedTask;
- _dropdownOpen = false;
- return InvokeAsync(StateHasChanged);
- }
- protected override async Task OnAfterRenderAsync(bool firstRender)
- {
- // Mirror the dropdown state into the JS dismiss-listener registration
- // so the listener only exists while it's needed.
- if (_dropdownOpen && !_listenerRegistered)
- {
- await JS.InvokeVoidAsync(
- "rackpeekUi.registerOutsideDismiss",
- _outsideDismissId,
- _containerSelector,
- _selfRef,
- nameof(DismissDropdownFromJs));
- _listenerRegistered = true;
- }
- else if (!_dropdownOpen && _listenerRegistered)
- {
- await JS.InvokeVoidAsync("rackpeekUi.unregisterOutsideDismiss", _outsideDismissId);
- _listenerRegistered = false;
- }
- }
- public async ValueTask DisposeAsync()
- {
- Nav.LocationChanged -= HandleLocationChanged;
- if (_listenerRegistered)
- {
- try { await JS.InvokeVoidAsync("rackpeekUi.unregisterOutsideDismiss", _outsideDismissId); }
- catch { /* page unloading */ }
- }
- _selfRef?.Dispose();
- }
- private string CurrentPageLabel
- {
- get
- {
- var path = Nav.ToBaseRelativePath(Nav.Uri).Split('?')[0].Split('#')[0].Trim('/');
- NavItem? best = null;
- var bestLength = -1;
- foreach (NavItem item in NavItems)
- {
- if (!Matches(path, item)) continue;
- if (item.Href.Length > bestLength)
- {
- best = item;
- bestLength = item.Href.Length;
- }
- }
- return best?.Label ?? "Menu";
- }
- }
- private static bool Matches(string path, NavItem item)
- {
- if (item.Match == NavLinkMatch.All)
- return string.Equals(path, item.Href, StringComparison.OrdinalIgnoreCase);
- // Prefix match — but never let the empty-string "Home" entry win as
- // a prefix of every route.
- if (string.IsNullOrEmpty(item.Href)) return false;
- return path.Equals(item.Href, StringComparison.OrdinalIgnoreCase)
- || path.StartsWith(item.Href + "/", StringComparison.OrdinalIgnoreCase);
- }
- private static readonly NavItem[] NavItems =
- {
- new("", "home", "Home", NavLinkMatch.All),
- new("cli", "cli", "CLI"),
- new("yaml", "yaml", "Yaml"),
- new("hardware/tree", "hardware", "Hardware"),
- new("systems/list", "systems", "Systems"),
- new("services/list", "services", "Services"),
- new("visualise", "visualise", "Visualise"),
- new("docs", "docs", "Docs")
- };
- private sealed record NavItem(string Href, string TestId, string Label, NavLinkMatch Match = NavLinkMatch.Prefix);
- }
|