feat(ui): implement premium mobile-first reader toolbar, bottom navigation, and auth ux stabilization (#61)

# Description

This Pull Request integrates the premium mobile-first layout enhancements, a responsive, full-bleed Reader Toolbar, and critical authorization flow stabilizations for the NexusReader Blazor application (targeting .NET 10 with Native AOT compatibility).

Resolves #62
Resolves #63
Resolves #15

## Key Changes

### 📱 1. Mobile-First Reader Layout & Toolbar
- **Full-Bleed Responsive Layout**: Redesigned `ReaderLayout` to feature a premium mobile-first three-tab bottom navigation system (Chapters, Graph, Assistant) and a glassmorphic floating action button (FAB) for the AI assistant.
- **Header & Escaping Routes**: Built `MobileReaderToolbar` with seamless exit paths back to the "Pulpit" (dashboard) and smooth transitions.
- **Custom Iconography**: Added the custom `NexusIcon` component supporting dynamic theme styling and responsive layouts without relying on external CSS frameworks.

### 🔐 2. Authentication Flow UX Stabilization
- **WASM Transition Hydration**: Implemented `AuthenticationStatePersister` and loading preloaders to eliminate authorization race conditions during Blazor WASM interactive hydration.
- **AOT-Compatible JWT Validation**: Integrated a robust, AOT-compatible `JwtTokenValidator` with unit tests (`JwtTokenValidatorTests.cs`) to cleanly parse claims without throwing performance-heavy runtime exceptions.
- **Secure Header Propagation**: Standardized token transmission in WASM (`AuthenticationHeaderHandler.cs`) and MAUI Hybrid client layers (`MobileAuthenticationHeaderHandler.cs`), ensuring cookies are correctly propagated.

### 📊 3. D3.js Knowledge Graph & Interaction
- **Dynamic Viewport Synchronization**: Refactored `knowledgeGraph.js` to ensure the SVG graph behaves correctly under flexbox containment, handles panel expansion/collapse gracefully, and avoids infinite loop redraws.
- **Interaction Hook**: Connected graph node clicks directly to chapter jumps via a new `IReaderInteractionService` abstraction.

### 🏗️ 4. Infrastructure & Central Package Management (CPM)
- **Beta Deployment Configuration**: Added `.env.test.template`, `docker-compose.test.yml`, and `appsettings.Test.json` with hardened environment security guards.
- **Docker-Compose Cache Optimization**: Maintained CPM consistency during multi-stage Docker builds.

## Verification & Build Results
- Run a successful local build check:
  ```bash
  dotnet build NexusReader.slnx --no-restore
  ```
  **Status**: Successfully completed with `0` compilation errors.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #61
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #61.
This commit is contained in:
2026-05-31 17:55:21 +00:00
committed by Marek Jaisński
parent a90507ad8a
commit 21c9a66cce
27 changed files with 1963 additions and 238 deletions
@@ -1,6 +1,7 @@
@inherits LayoutComponentBase
@using NexusReader.Application.Abstractions.Services
@using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Models
@using NexusReader.UI.Shared.Components.Molecules
@using NexusReader.UI.Shared.Components.Organisms
@using NexusReader.Application.Queries.Graph
@@ -9,12 +10,14 @@
@inject IFocusModeService FocusMode
@inject IQuizStateService QuizService
@inject IReaderInteractionService InteractionService
@inject IReaderStateService StateService
@inject IKnowledgeGraphService GraphService
@inject IJSRuntime JS
@inject IIdentityService IdentityService
@inject NavigationManager NavigationManager
@inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger
@implements IDisposable
@implements IAsyncDisposable
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "") @($"active-mobile-tab-{_activeMobileTab.ToString().ToLower()}")">
<div class="reader-pane">
@@ -141,8 +144,8 @@
<KnowledgeGraph />
</div>
<!-- Tab 3: Insight (Contextual Intelligence AND Knowledge Quiz) -->
<div class="nexus-mobile-tab-content insight-tab @(_activeMobileTab == MobileReaderTab.Insight ? "active" : "")">
<!-- Tab 3: Concepts/Quiz -->
<div class="nexus-mobile-tab-content insight-tab @(_activeMobileTab == MobileReaderTab.Concepts ? "active" : "")">
<div class="mobile-insight-container">
<div class="mobile-insight-header">
<div class="mobile-insight-nav">
@@ -223,27 +226,14 @@
</div>
</div>
<!-- Three-Tab Fixed Bottom Navigation Bar -->
<div class="nexus-mobile-bottom-nav">
<button class="bottom-nav-item @(_activeMobileTab == MobileReaderTab.Reader ? "active" : "")" @onclick="() => SetMobileTab(MobileReaderTab.Reader)">
<NexusIcon Name="book-open" Size="20" />
<span>Czytnik</span>
</button>
<button class="bottom-nav-item @(_activeMobileTab == MobileReaderTab.Graph ? "active" : "")" @onclick="() => SetMobileTab(MobileReaderTab.Graph)">
<NexusIcon Name="network" Size="20" />
<span>Wykres</span>
</button>
<button class="bottom-nav-item @(_activeMobileTab == MobileReaderTab.Insight ? "active" : "")" @onclick="() => SetMobileTab(MobileReaderTab.Insight)">
<span class="insight-icon-wrapper">
<NexusIcon Name="brain" Size="20" />
@if (QuizService.HasNewQuiz)
{
<span class="nav-quiz-indicator"></span>
}
</span>
<span>Analiza</span>
</button>
</div>
<MobileReaderToolbar
ScrollPercentage="@_scrollPercentage"
ActiveTab="@_activeMobileTab"
OnTabChanged="SetMobileTab"
OnAssistantClick="OpenAssistant"
Checkpoints="@StateService.CurrentCheckpoints" />
<GlobalIntelligence IsOpen="@_isAssistantOpen" OnClose="CloseAssistant" />
}
</Authorized>
<Authorizing>
@@ -268,21 +258,28 @@
Quiz
}
private enum MobileReaderTab
{
Reader,
Graph,
Insight
}
private SidebarTab _activeTab = SidebarTab.Knowledge;
private MobileReaderTab _activeMobileTab = MobileReaderTab.Reader;
private string? _selectedNodeId;
private GraphNodeDto? _selectedNode;
private string _platformClass = "platform-desktop";
private bool _isMobile = false;
private DotNetObjectReference<ReaderLayout>? _selfReference;
private IJSObjectReference? _viewportModule;
private bool _isAssistantOpen;
private int _scrollPercentage
{
get => StateService.CurrentScrollPercentage;
set => StateService.CurrentScrollPercentage = value;
}
private MobileReaderTab _activeMobileTab
{
get => StateService.ActiveTab;
set => StateService.ActiveTab = value;
}
protected override void OnInitialized()
{
@@ -292,6 +289,7 @@
InteractionService.OnNodeSelected += HandleNodeSelectedAsync;
InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync;
InteractionService.OnScrollPercentChanged += HandleScrollPercentChanged;
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
var context = PlatformService.GetDeviceContext();
@@ -319,20 +317,45 @@
StateHasChanged();
}
private void OpenAssistant()
{
_isAssistantOpen = true;
StateHasChanged();
}
private void CloseAssistant()
{
_isAssistantOpen = false;
StateHasChanged();
}
private async Task HandleScrollPercentChanged(int percent)
{
_scrollPercentage = percent;
await InvokeAsync(StateHasChanged);
}
private async Task HandleQuizRequestedAsync(string blockId)
{
_activeTab = SidebarTab.Quiz;
if (_isMobile)
{
_activeMobileTab = MobileReaderTab.Insight;
_activeMobileTab = MobileReaderTab.Concepts;
}
await InvokeAsync(StateHasChanged);
}
private async Task HandleAssistantRequestedAsync()
{
_activeMobileTab = MobileReaderTab.Insight;
_activeTab = SidebarTab.Quiz;
if (_isMobile)
{
OpenAssistant();
}
else
{
_activeMobileTab = MobileReaderTab.Concepts;
_activeTab = SidebarTab.Quiz;
}
await InvokeAsync(StateHasChanged);
}
@@ -345,7 +368,7 @@
}
if (_isMobile)
{
_activeMobileTab = MobileReaderTab.Insight;
_activeMobileTab = MobileReaderTab.Concepts;
_activeTab = SidebarTab.Knowledge;
}
await InvokeAsync(StateHasChanged);
@@ -372,31 +395,27 @@
Logger.LogError(ex, "Failed to initialize layout resizer JS module.");
}
await InitViewportDetectionAsync();
try
{
_viewportModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
await InitViewportDetectionAsync();
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to import viewport utilities JS module.");
}
}
}
private async Task InitViewportDetectionAsync()
{
if (_viewportModule == null) return;
try
{
_selfReference = DotNetObjectReference.Create(this);
var isMobileViewport = await JS.InvokeAsync<bool>("eval", "window.innerWidth < 768");
var isMobileViewport = await _viewportModule.InvokeAsync<bool>("isMobileViewport");
await OnViewportChanged(isMobileViewport);
await JS.InvokeVoidAsync("eval", @"
window.registerViewportObserver = (dotNetHelper) => {
let currentIsMobile = window.innerWidth < 768;
window.addEventListener('resize', () => {
let isMobile = window.innerWidth < 768;
if (isMobile !== currentIsMobile) {
currentIsMobile = isMobile;
dotNetHelper.invokeMethodAsync('OnViewportChanged', isMobile);
}
});
}
");
await JS.InvokeVoidAsync("registerViewportObserver", _selfReference);
await _viewportModule.InvokeVoidAsync("registerViewportObserver", _selfReference);
}
catch (Exception ex)
{
@@ -417,14 +436,34 @@
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose()
public async ValueTask DisposeAsync()
{
FocusMode.OnFocusModeChanged -= HandleUpdate;
QuizService.OnQuizUpdated -= HandleUpdate;
QuizService.OnQuizRequested -= HandleQuizRequestedAsync;
InteractionService.OnNodeSelected -= HandleNodeSelectedAsync;
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged;
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
try
{
if (_viewportModule != null)
{
if (_selfReference != null)
{
await _viewportModule.InvokeVoidAsync("unregisterViewportObserver", _selfReference);
}
await _viewportModule.DisposeAsync();
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Teardown of viewport observer module failed during component disposal.");
}
_selfReference?.Dispose();
}
}