MainLayout.razor 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. @using RackPeek.Domain
  2. @using Shared.Rcl.Components
  3. @inherits LayoutComponentBase
  4. @implements IAsyncDisposable
  5. @inject NavigationManager Nav
  6. @inject IJSRuntime JS
  7. <!-- Flips to true only after the first interactive render, which cannot happen on
  8. the prerendered HTML. Tests wait on it before typing: input events sent before
  9. the circuit attaches are silently lost and never reach the server-side model. -->
  10. <span hidden data-testid="circuit-probe" data-circuit-ready="@(_circuitReady ? "true" : "false")"></span>
  11. <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono"
  12. data-testid="app-root">
  13. <header class="border-b border-zinc-800 bg-zinc-900" data-testid="app-header">
  14. <!-- Top row: brand + search (always visible) + nav cluster | mobile dropdown -->
  15. <div class="flex items-center gap-3 sm:gap-4 px-6 sm:px-8 lg:px-12 py-4">
  16. <NavLink href=""
  17. data-testid="brand-link"
  18. class="hover:text-emerald-400 flex-shrink-0"
  19. activeClass="text-emerald-400 font-semibold">
  20. <div class="flex items-baseline gap-2"
  21. data-testid="brand-text">
  22. <span class="text-xl font-bold text-emerald-400 tracking-wider">rackpeek</span>
  23. <span class="text-[10px] text-zinc-500 tracking-wide">@RpkConstants.Version</span>
  24. </div>
  25. </NavLink>
  26. <!-- Single search instance, sized responsively. Sits next to the
  27. brand on the left at every viewport — fills available space
  28. between brand and the nav cluster. -->
  29. <div class="flex-1 min-w-0 max-w-[180px] sm:max-w-[240px] min-[1000px]:max-w-xs xl:max-w-md">
  30. <GlobalSearch/>
  31. </div>
  32. <!-- Desktop: git + nav pushed to the right -->
  33. <div class="hidden min-[1000px]:flex items-center gap-5 ml-auto flex-shrink-0">
  34. @if (RpkConstants.HasGitServices)
  35. {
  36. <GitStatusIndicator/>
  37. }
  38. <nav class="flex items-center gap-4 text-sm" data-testid="main-nav">
  39. @foreach (NavItem item in NavItems)
  40. {
  41. <NavLink href="@item.Href"
  42. Match="@item.Match"
  43. class="hover:text-emerald-400"
  44. activeClass="text-emerald-400 font-semibold"
  45. data-testid="@($"nav-{item.TestId}")">
  46. @item.Label
  47. </NavLink>
  48. }
  49. </nav>
  50. </div>
  51. <!-- Mobile: page dropdown anchored to the right -->
  52. <div class="min-[1000px]:hidden ml-auto relative flex-shrink-0" id="rpk-mobile-nav">
  53. <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"
  54. data-testid="nav-toggle"
  55. aria-haspopup="menu"
  56. aria-expanded="@(_dropdownOpen ? "true" : "false")"
  57. @onclick="ToggleDropdown">
  58. <span>@CurrentPageLabel</span>
  59. <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
  60. stroke="currentColor" stroke-width="2">
  61. @if (_dropdownOpen)
  62. {
  63. <path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/>
  64. }
  65. else
  66. {
  67. <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
  68. }
  69. </svg>
  70. </button>
  71. @if (_dropdownOpen)
  72. {
  73. <div class="absolute right-0 mt-2 w-52 bg-zinc-900 border border-zinc-800 rounded shadow-lg overflow-hidden z-50"
  74. data-testid="mobile-nav"
  75. role="menu">
  76. @foreach (NavItem item in NavItems)
  77. {
  78. <NavLink href="@item.Href"
  79. Match="@item.Match"
  80. 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"
  81. activeClass="text-emerald-400 bg-emerald-500/10"
  82. data-testid="@($"nav-mobile-{item.TestId}")"
  83. @onclick="CloseDropdown">
  84. @item.Label
  85. </NavLink>
  86. }
  87. </div>
  88. }
  89. </div>
  90. <!-- Community / source links — anchored to the far right of the
  91. header at every breakpoint. The two clusters above carry
  92. `ml-auto`; this group sits immediately to their right. -->
  93. <div class="flex items-center gap-3 flex-shrink-0">
  94. <a href="https://github.com/Timmoth/RackPeek"
  95. target="_blank"
  96. rel="noopener noreferrer"
  97. aria-label="RackPeek on GitHub"
  98. class="text-zinc-400 hover:text-emerald-400 transition"
  99. data-testid="social-github">
  100. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
  101. fill="currentColor" class="w-5 h-5">
  102. <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"/>
  103. </svg>
  104. </a>
  105. <a href="https://discord.gg/egXRPdesee"
  106. target="_blank"
  107. rel="noopener noreferrer"
  108. aria-label="Join the RackPeek Discord"
  109. class="text-zinc-400 hover:text-emerald-400 transition"
  110. data-testid="social-discord">
  111. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
  112. fill="currentColor" class="w-5 h-5">
  113. <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"/>
  114. </svg>
  115. </a>
  116. </div>
  117. </div>
  118. <!-- Mobile-only second row: git status (only shown when configured) -->
  119. @if (RpkConstants.HasGitServices)
  120. {
  121. <div class="min-[1000px]:hidden border-t border-zinc-800 px-6 py-3">
  122. <GitStatusIndicator/>
  123. </div>
  124. }
  125. </header>
  126. <main class="p-6" data-testid="page-content">
  127. @Body
  128. </main>
  129. </div>
  130. @code {
  131. private const string _outsideDismissId = "rpk-mobile-nav";
  132. private const string _containerSelector = "#rpk-mobile-nav";
  133. private bool _circuitReady;
  134. private bool _dropdownOpen;
  135. private bool _listenerRegistered;
  136. private DotNetObjectReference<MainLayout>? _selfRef;
  137. protected override void OnInitialized()
  138. {
  139. _selfRef = DotNetObjectReference.Create(this);
  140. Nav.LocationChanged += HandleLocationChanged;
  141. }
  142. private void HandleLocationChanged(object? sender, LocationChangedEventArgs e) =>
  143. InvokeAsync(StateHasChanged);
  144. private void ToggleDropdown() => _dropdownOpen = !_dropdownOpen;
  145. private void CloseDropdown() => _dropdownOpen = false;
  146. [JSInvokable]
  147. public Task DismissDropdownFromJs()
  148. {
  149. if (!_dropdownOpen) return Task.CompletedTask;
  150. _dropdownOpen = false;
  151. return InvokeAsync(StateHasChanged);
  152. }
  153. protected override async Task OnAfterRenderAsync(bool firstRender)
  154. {
  155. // OnAfterRender never runs during prerendering, so this re-render proves a
  156. // live circuit to anything watching the probe above.
  157. if (firstRender && !_circuitReady)
  158. {
  159. _circuitReady = true;
  160. StateHasChanged();
  161. }
  162. // Mirror the dropdown state into the JS dismiss-listener registration
  163. // so the listener only exists while it's needed.
  164. if (_dropdownOpen && !_listenerRegistered)
  165. {
  166. await JS.InvokeVoidAsync(
  167. "rackpeekUi.registerOutsideDismiss",
  168. _outsideDismissId,
  169. _containerSelector,
  170. _selfRef,
  171. nameof(DismissDropdownFromJs));
  172. _listenerRegistered = true;
  173. }
  174. else if (!_dropdownOpen && _listenerRegistered)
  175. {
  176. await JS.InvokeVoidAsync("rackpeekUi.unregisterOutsideDismiss", _outsideDismissId);
  177. _listenerRegistered = false;
  178. }
  179. }
  180. public async ValueTask DisposeAsync()
  181. {
  182. Nav.LocationChanged -= HandleLocationChanged;
  183. if (_listenerRegistered)
  184. {
  185. try { await JS.InvokeVoidAsync("rackpeekUi.unregisterOutsideDismiss", _outsideDismissId); }
  186. catch { /* page unloading */ }
  187. }
  188. _selfRef?.Dispose();
  189. }
  190. private string CurrentPageLabel
  191. {
  192. get
  193. {
  194. var path = Nav.ToBaseRelativePath(Nav.Uri).Split('?')[0].Split('#')[0].Trim('/');
  195. NavItem? best = null;
  196. var bestLength = -1;
  197. foreach (NavItem item in NavItems)
  198. {
  199. if (!Matches(path, item)) continue;
  200. if (item.Href.Length > bestLength)
  201. {
  202. best = item;
  203. bestLength = item.Href.Length;
  204. }
  205. }
  206. return best?.Label ?? "Menu";
  207. }
  208. }
  209. private static bool Matches(string path, NavItem item)
  210. {
  211. if (item.Match == NavLinkMatch.All)
  212. return string.Equals(path, item.Href, StringComparison.OrdinalIgnoreCase);
  213. // Prefix match — but never let the empty-string "Home" entry win as
  214. // a prefix of every route.
  215. if (string.IsNullOrEmpty(item.Href)) return false;
  216. return path.Equals(item.Href, StringComparison.OrdinalIgnoreCase)
  217. || path.StartsWith(item.Href + "/", StringComparison.OrdinalIgnoreCase);
  218. }
  219. private static readonly NavItem[] NavItems =
  220. {
  221. new("", "home", "Home", NavLinkMatch.All),
  222. new("cli", "cli", "CLI"),
  223. new("yaml", "yaml", "Yaml"),
  224. new("hardware/tree", "hardware", "Hardware"),
  225. new("systems/list", "systems", "Systems"),
  226. new("services/list", "services", "Services"),
  227. new("visualise", "visualise", "Visualise"),
  228. new("docs", "docs", "Docs")
  229. };
  230. private sealed record NavItem(string Href, string TestId, string Label, NavLinkMatch Match = NavLinkMatch.Prefix);
  231. }