feat(ui): implement premium mobile-first reader layout with three-tab bottom navigation and assistant FAB (#57)

This pull request delivers a comprehensive mobile-first user experience overhaul for the NexusReader SaaS platform, specifically optimizing the Reader Canvas, D3.js Knowledge Graph representation, Dashboard card grid layout, and the application-wide navigation shell on mobile viewports (< 768px).

### Key Enhancements:
1. **Interactive Three-Tab Bottom Navigation**: Added premium, frosted glassy bottom-bar for mobile viewports to switch between standard reading, D3.js graph visual workspace, and structural concept reviews/quizzes.
2. **Contextual Floating Action Button (FAB)**: Introduced the AI Assistant FAB on mobile canvas layout with responsive animation, state-synchronization to trigger corresponding quiz views, and pulsing badge notification when new quizzes are dynamically generated.
3. **Adaptive D3.js Simulation & Rendering**: Integrated `setMobileMode(isMobile)` logic inside the D3 simulation engine, optimizing forces, rendering compact glyph pills, and installing auto-resize observers.
4. **Architectural & Native AOT Cleanliness**: Clean separation of layouts, fully scoped CSS configurations, functional-safe event orchestration inside `IReaderInteractionService`, and zero compiler errors.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #57
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #57.
This commit is contained in:
2026-05-27 07:44:44 +00:00
committed by Marek Jaisński
parent a9a670d776
commit 30f445ea89
9 changed files with 947 additions and 111 deletions
@@ -16,7 +16,7 @@
@inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger
@implements IDisposable
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "")">
<div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "") @($"active-mobile-tab-{_activeMobileTab.ToString().ToLower()}")">
<div class="reader-pane">
<main>
@Body
@@ -30,108 +30,221 @@
<AuthorizeView>
<Authorized>
<div class="resizer" id="sidebar-resizer"></div>
@if (!_isMobile)
{
<div class="resizer" id="sidebar-resizer"></div>
<div class="intelligence-sidebar">
<IntelligenceToolbar />
<div class="intelligence-content">
<div class="intelligence-header">
<div class="ai-title">
<NexusIcon Name="robot" Size="20"
Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" />
<span>Asystent AI</span>
<div class="intelligence-sidebar">
<IntelligenceToolbar />
<div class="intelligence-content">
<div class="intelligence-header">
<div class="ai-title">
<NexusIcon Name="robot" Size="20"
Class="@($"neon-glow {(QuizService.HasNewQuiz ? "quiz-available" : "")}")" />
<span>Asystent AI</span>
</div>
<button class="close-btn">×</button>
</div>
<button class="close-btn">×</button>
</div>
@if (_activeTab == SidebarTab.Knowledge)
{
<div class="intelligence-scroll-area stacked-layout">
@if (!_isMobile)
{
@if (_activeTab == SidebarTab.Knowledge)
{
<div class="intelligence-scroll-area stacked-layout">
<div class="visual-workspace">
<KnowledgeGraph />
</div>
}
<div class="contextual-intelligence-panel">
<div class="panel-header">
<NexusIcon Name="brain" Size="18" Class="neon-accent-icon" />
<span class="panel-title">Contextual Intelligence Panel</span>
</div>
<div class="panel-body">
@if (_selectedNode != null)
{
<div class="node-details">
<div class="node-header-section">
<span class="node-group-badge @(_selectedNode.Group.ToLower())">@(_selectedNode.Group.ToUpper())</span>
<h3 class="node-label">@_selectedNode.Label</h3>
<div class="contextual-intelligence-panel">
<div class="panel-header">
<NexusIcon Name="brain" Size="18" Class="neon-accent-icon" />
<span class="panel-title">Contextual Intelligence Panel</span>
</div>
<div class="panel-body">
@if (_selectedNode != null)
{
<div class="node-details">
<div class="node-header-section">
<span class="node-group-badge @(_selectedNode.Group.ToLower())">@(_selectedNode.Group.ToUpper())</span>
<h3 class="node-label">@_selectedNode.Label</h3>
</div>
@if (!string.IsNullOrEmpty(_selectedNode.Description))
{
<div class="detail-section">
<p class="node-description">@_selectedNode.Description</p>
</div>
}
@if (!string.IsNullOrEmpty(_selectedNode.Summary))
{
<div class="detail-section summary-section">
<h4 class="section-title neon-sub-header">Podsumowanie</h4>
<p class="node-summary">@_selectedNode.Summary</p>
</div>
}
@if (_selectedNode.KeyTerms != null && _selectedNode.KeyTerms.Any())
{
<div class="detail-section key-terms-section">
<h4 class="section-title neon-sub-header">Kluczowe Pojęcia</h4>
<ul class="key-terms-list">
@foreach (var term in _selectedNode.KeyTerms)
{
<li class="key-term-item">
<span class="term-bullet">•</span>
<span class="term-text">@term</span>
</li>
}
</ul>
</div>
}
</div>
@if (!string.IsNullOrEmpty(_selectedNode.Description))
{
<div class="detail-section">
<p class="node-description">@_selectedNode.Description</p>
</div>
}
@if (!string.IsNullOrEmpty(_selectedNode.Summary))
{
<div class="detail-section summary-section">
<h4 class="section-title neon-sub-header">Podsumowanie</h4>
<p class="node-summary">@_selectedNode.Summary</p>
</div>
}
@if (_selectedNode.KeyTerms != null && _selectedNode.KeyTerms.Any())
{
<div class="detail-section key-terms-section">
<h4 class="section-title neon-sub-header">Kluczowe Pojęcia</h4>
<ul class="key-terms-list">
@foreach (var term in _selectedNode.KeyTerms)
{
<li class="key-term-item">
<span class="term-bullet">•</span>
<span class="term-text">@term</span>
</li>
}
</ul>
</div>
}
</div>
}
else
{
<div class="no-node-selected">
<div class="placeholder-glow"></div>
<p class="placeholder-text">Wybierz węzeł na wykresie, aby wyświetlić szczegóły architektoniczne.</p>
</div>
}
}
else
{
<div class="no-node-selected">
<div class="placeholder-glow"></div>
<p class="placeholder-text">Wybierz węzeł na wykresie, aby wyświetlić szczegóły architektoniczne.</p>
</div>
}
</div>
</div>
</div>
</div>
<div class="sidebar-footer">
<button class="open-quiz-btn neon-glow-btn @(QuizService.HasNewQuiz ? "quiz-pulse-btn" : "")" @onclick="() => SetActiveTab(SidebarTab.Quiz)">
<NexusIcon Name="quiz" Size="18" />
<span>OPEN KNOWLEDGE QUIZ</span>
</button>
</div>
}
else
{
<div class="intelligence-scroll-area quiz-layout">
<div class="quiz-nav">
<button class="back-to-graph-btn" @onclick="() => SetActiveTab(SidebarTab.Knowledge)">
<NexusIcon Name="arrow-left" Size="16" />
<span>← Powrót do wykresu</span>
<div class="sidebar-footer">
<button class="open-quiz-btn neon-glow-btn @(QuizService.HasNewQuiz ? "quiz-pulse-btn" : "")" @onclick="() => SetActiveTab(SidebarTab.Quiz)">
<NexusIcon Name="quiz" Size="18" />
<span>OPEN KNOWLEDGE QUIZ</span>
</button>
</div>
<KnowledgeCheck />
</div>
}
}
else
{
<div class="intelligence-scroll-area quiz-layout">
<div class="quiz-nav">
<button class="back-to-graph-btn" @onclick="() => SetActiveTab(SidebarTab.Knowledge)">
<NexusIcon Name="arrow-left" Size="16" />
<span>← Powrót do wykresu</span>
</button>
</div>
<KnowledgeCheck />
</div>
}
</div>
</div>
</div>
}
else
{
<!-- Mobile full-bleed containers mapped to bottom tab navigation -->
<div class="nexus-mobile-reader-tabs">
<!-- Tab 2: Graph -->
<div class="nexus-mobile-tab-content graph-tab @(_activeMobileTab == MobileReaderTab.Graph ? "active" : "")">
<KnowledgeGraph />
</div>
<!-- Tab 3: Insight (Contextual Intelligence AND Knowledge Quiz) -->
<div class="nexus-mobile-tab-content insight-tab @(_activeMobileTab == MobileReaderTab.Insight ? "active" : "")">
<div class="mobile-insight-container">
<div class="mobile-insight-header">
<div class="mobile-insight-nav">
<button class="mobile-insight-nav-btn @(_activeTab == SidebarTab.Knowledge ? "active" : "")" @onclick="() => SetActiveTab(SidebarTab.Knowledge)">
<NexusIcon Name="brain" Size="16" />
<span>Podgląd pojęcia</span>
</button>
<button class="mobile-insight-nav-btn quiz-btn @(_activeTab == SidebarTab.Quiz ? "active" : "") @(QuizService.HasNewQuiz ? "quiz-pulse" : "")" @onclick="() => SetActiveTab(SidebarTab.Quiz)">
<NexusIcon Name="quiz" Size="16" />
<span>Quiz wiedzy</span>
</button>
</div>
</div>
<div class="mobile-insight-body">
@if (_activeTab == SidebarTab.Knowledge)
{
<div class="contextual-intelligence-panel">
<div class="panel-body">
@if (_selectedNode != null)
{
<div class="node-details">
<div class="node-header-section">
<span class="node-group-badge @(_selectedNode.Group.ToLower())">@(_selectedNode.Group.ToUpper())</span>
<h3 class="node-label">@_selectedNode.Label</h3>
</div>
@if (!string.IsNullOrEmpty(_selectedNode.Description))
{
<div class="detail-section">
<p class="node-description">@_selectedNode.Description</p>
</div>
}
@if (!string.IsNullOrEmpty(_selectedNode.Summary))
{
<div class="detail-section summary-section">
<h4 class="section-title neon-sub-header">Podsumowanie</h4>
<p class="node-summary">@_selectedNode.Summary</p>
</div>
}
@if (_selectedNode.KeyTerms != null && _selectedNode.KeyTerms.Any())
{
<div class="detail-section key-terms-section">
<h4 class="section-title neon-sub-header">Kluczowe Pojęcia</h4>
<ul class="key-terms-list">
@foreach (var term in _selectedNode.KeyTerms)
{
<li class="key-term-item">
<span class="term-bullet">•</span>
<span class="term-text">@term</span>
</li>
}
</ul>
</div>
}
</div>
}
else
{
<div class="no-node-selected">
<div class="placeholder-glow"></div>
<p class="placeholder-text">Wybierz pojęcie na wykresie, aby wyświetlić jego podsumowanie.</p>
</div>
}
</div>
</div>
}
else
{
<div class="mobile-quiz-wrapper">
<KnowledgeCheck />
</div>
}
</div>
</div>
</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>
}
</Authorized>
<Authorizing>
<div class="app-preloader">
@@ -155,7 +268,15 @@
Quiz
}
private enum MobileReaderTab
{
Reader,
Graph,
Insight
}
private SidebarTab _activeTab = SidebarTab.Knowledge;
private MobileReaderTab _activeMobileTab = MobileReaderTab.Reader;
private string? _selectedNodeId;
private GraphNodeDto? _selectedNode;
@@ -169,6 +290,7 @@
QuizService.OnQuizRequested += HandleQuizRequestedAsync;
InteractionService.OnNodeSelected += HandleNodeSelectedAsync;
InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync;
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
var context = PlatformService.GetDeviceContext();
@@ -190,8 +312,25 @@
StateHasChanged();
}
private void SetMobileTab(MobileReaderTab tab)
{
_activeMobileTab = tab;
StateHasChanged();
}
private async Task HandleQuizRequestedAsync(string blockId)
{
_activeTab = SidebarTab.Quiz;
if (_isMobile)
{
_activeMobileTab = MobileReaderTab.Insight;
}
await InvokeAsync(StateHasChanged);
}
private async Task HandleAssistantRequestedAsync()
{
_activeMobileTab = MobileReaderTab.Insight;
_activeTab = SidebarTab.Quiz;
await InvokeAsync(StateHasChanged);
}
@@ -203,6 +342,11 @@
{
_selectedNode = GraphService.CurrentGraphData.Nodes.FirstOrDefault(n => n.Id == nodeId);
}
if (_isMobile)
{
_activeMobileTab = MobileReaderTab.Insight;
_activeTab = SidebarTab.Knowledge;
}
await InvokeAsync(StateHasChanged);
}
@@ -237,6 +381,7 @@
QuizService.OnQuizUpdated -= HandleUpdate;
QuizService.OnQuizRequested -= HandleQuizRequestedAsync;
InteractionService.OnNodeSelected -= HandleNodeSelectedAsync;
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
}
}