feat(auth): Stabilize authentication flow and implement premium hydration preloader #60

Merged
mjasin merged 7 commits from feat/mobile-ux-overhaul into infra/beta-deploy-test 2026-05-27 10:29:53 +00:00
9 changed files with 947 additions and 111 deletions
Showing only changes of commit e42546d82f - Show all commits
@@ -13,6 +13,8 @@
@inject IReaderInteractionService InteractionService
@inject ISyncService SyncService
@inject AuthenticationStateProvider AuthStateProvider
@inject IQuizStateService QuizService
@inject IPlatformService PlatformService
@inject ILogger<ReaderCanvas> Logger
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
@@ -53,6 +55,17 @@
BlockId="@_selectedBlockId"
Coordinates="@_selectionCoords"
FullPageContent="@GetFullPageContent()" />
@if (_isMobile)
{
<button class="nexus-mobile-assistant-fab @(QuizService.HasNewQuiz ? "has-new-quiz" : "")" @onclick="HandleAssistantFabClick" aria-label="Asystent AI">
<NexusIcon Name="robot" Size="24" Class="neon-glow" />
@if (QuizService.HasNewQuiz)
{
<span class="fab-badge"></span>
}
</button>
}
</div>
@code {
@@ -68,17 +81,29 @@
private ElementReference _containerRef;
private bool _isInteractive;
private string? _currentActiveBlockId;
private bool _isMobile = false;
protected override async Task OnInitializedAsync()
{
await Coordinator.ClearAsync();
ThemeService.OnThemeChanged += HandleUpdate;
NavigationService.OnNavigationChanged += OnNavigationChanged;
QuizService.OnQuizUpdated += HandleUpdate;
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
InteractionService.OnTextSelected += HandleTextSelected;
SyncService.OnProgressReceived += HandleSyncProgressReceived;
var context = PlatformService.GetDeviceContext();
if (context.IsSuccess)
{
_isMobile = context.Value.DeviceType switch
{
DeviceType.Phone or DeviceType.Tablet => true,
_ => false
};
}
}
protected override async Task OnParametersSetAsync()
@@ -286,10 +311,16 @@
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
private async Task HandleAssistantFabClick()
{
await InteractionService.RequestAssistant();
}
public void Dispose()
{
ThemeService.OnThemeChanged -= HandleUpdate;
NavigationService.OnNavigationChanged -= OnNavigationChanged;
QuizService.OnQuizUpdated -= HandleUpdate;
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
@@ -4,9 +4,31 @@
@using NexusReader.Application.Abstractions.Services
@using NexusReader.UI.Shared.Services
<div class="hub-container">
<div class="hub-container @(_isMobileMenuOpen ? "mobile-menu-open" : "")">
<AuthorizeView>
<Authorized>
<!-- Mobile Sticky Top-bar -->
<div class="nexus-mobile-topbar">
<button class="hamburger-btn" @onclick="ToggleMobileMenu" aria-label="Toggle Menu">
<NexusIcon Name="menu" Size="24" />
</button>
<div class="mobile-logo">
<NexusIcon Name="diamond" Size="20" Class="logo-icon pulsing-logo" />
<span class="logo-text">Nexus</span>
</div>
<div class="mobile-user-pill">
<div class="user-avatar-mini">
@context.User.Identity?.Name?[0].ToString().ToUpper()
</div>
</div>
</div>
<!-- Mobile Backdrop overlay -->
@if (_isMobileMenuOpen)
{
<div class="mobile-sidebar-backdrop" @onclick="CloseMobileMenu"></div>
}
<aside class="hub-sidebar">
<div class="sidebar-header">
<div class="logo">
@@ -16,43 +38,43 @@
</div>
<nav class="sidebar-nav">
<NavLink class="nav-item" href="/" Match="NavLinkMatch.All">
<NavLink class="nav-item" href="/" Match="NavLinkMatch.All" @onclick="CloseMobileMenu">
<div class="nav-icon">
<NexusIcon Name="home" Size="18" />
</div>
<span class="nav-text">Dashboard</span>
</NavLink>
<NavLink class="nav-item" href="/library">
<NavLink class="nav-item" href="/library" @onclick="CloseMobileMenu">
<div class="nav-icon">
<NexusIcon Name="book-open" Size="18" />
</div>
<span class="nav-text">Library</span>
</NavLink>
<NavLink class="nav-item" href="/concepts-map">
<NavLink class="nav-item" href="/concepts-map" @onclick="CloseMobileMenu">
<div class="nav-icon">
<NexusIcon Name="map" Size="18" />
</div>
<span class="nav-text">Concepts Map</span>
</NavLink>
<NavLink class="nav-item" href="/intelligence">
<NavLink class="nav-item" href="/intelligence" @onclick="CloseMobileMenu">
<div class="nav-icon">
<NexusIcon Name="cpu" Size="18" />
</div>
<span class="nav-text">Global AI Q&A</span>
</NavLink>
<NavLink class="nav-item" href="/profile">
<NavLink class="nav-item" href="/profile" @onclick="CloseMobileMenu">
<div class="nav-icon">
<NexusIcon Name="message-square" Size="18" />
</div>
<span class="nav-text">Profile</span>
</NavLink>
<NavLink class="nav-item" href="/settings">
<NavLink class="nav-item" href="/settings" @onclick="CloseMobileMenu">
<div class="nav-icon">
<NexusIcon Name="settings" Size="18" />
</div>
<span class="nav-text">Settings</span>
</NavLink>
<NavLink class="nav-item" href="/concenters">
<NavLink class="nav-item" href="/concenters" @onclick="CloseMobileMenu">
<div class="nav-icon">
<NexusIcon Name="target" Size="18" />
</div>
@@ -90,6 +112,7 @@
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
private bool _isSyncing = false;
private bool _isMobileMenuOpen = false;
protected override async Task OnInitializedAsync()
{
@@ -104,8 +127,19 @@
}
}
private void ToggleMobileMenu()
{
_isMobileMenuOpen = !_isMobileMenuOpen;
}
private void CloseMobileMenu()
{
_isMobileMenuOpen = false;
}
private async Task HandleLogout()
{
CloseMobileMenu();
await IdentityService.LogoutAsync();
NavigationManager.NavigateTo("/account/logout-form", true);
}
@@ -190,4 +190,157 @@
to { transform: rotate(360deg); }
}
/* Mobile Styles */
.nexus-mobile-topbar {
display: none;
}
@media (max-width: 768px) {
.nexus-mobile-topbar {
display: flex;
align-items: center;
justify-content: space-between;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
background: rgba(18, 18, 18, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding: 0 1.25rem;
z-index: 150;
}
.hamburger-btn {
background: transparent;
border: none;
color: #e0e0e0;
cursor: pointer;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
transition: background-color 0.2s;
min-height: 48px;
min-width: 48px;
touch-action: manipulation;
}
.hamburger-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
.mobile-logo {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pulsing-logo {
animation: pulse-glow 2s infinite ease-in-out;
}
@keyframes pulse-glow {
0%, 100% {
filter: drop-shadow(0 0 5px rgba(0, 255, 153, 0.4));
opacity: 0.8;
}
50% {
filter: drop-shadow(0 0 12px rgba(0, 255, 153, 0.8));
opacity: 1;
}
}
.mobile-user-pill {
display: flex;
align-items: center;
}
.user-avatar-mini {
width: 32px;
height: 32px;
background: linear-gradient(135deg, var(--nexus-neon) 0%, #0099ff 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
font-weight: 700;
color: #121212;
box-shadow: 0 0 10px rgba(0, 255, 153, 0.2);
}
.mobile-sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 190;
animation: fade-in 0.2s ease-out;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
::deep .hub-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 280px;
height: 100%;
background: #141414;
z-index: 200;
transform: translateX(-100%);
will-change: transform;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: none;
}
.mobile-menu-open ::deep .hub-sidebar {
transform: translateX(0);
box-shadow: 10px 0 30px rgba(0, 0, 0, 0.5);
}
.hub-container {
flex-direction: column;
}
.hub-main {
margin-top: 60px;
width: 100%;
height: calc(100vh - 60px);
}
.hub-content {
padding: 1.25rem;
}
::deep .sidebar-header {
padding: 1.5rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
::deep .sidebar-nav {
padding: 1rem 0;
}
::deep .nav-item {
padding: 0.9rem 1.25rem;
font-size: 0.95rem;
min-height: 48px; /* Touch target */
}
::deep .sidebar-footer {
padding: 1rem 1.25rem;
}
}
@@ -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;
}
}
@@ -467,4 +467,286 @@ main {
.back-to-graph-btn:hover {
color: var(--nexus-neon, #00f0ff);
background: rgba(255, 255, 255, 0.02);
}
/* Mobile-First Platform Customization */
.platform-mobile {
grid-template-columns: 1fr !important;
height: 100vh !important;
position: relative;
overflow: hidden;
}
.platform-mobile .reader-pane {
width: 100vw !important;
height: calc(100vh - 60px) !important; /* reserve bottom nav height */
position: absolute;
top: 0;
left: 0;
display: none;
z-index: 10;
}
/* Three-tab mobile views depending on the active mobile tab class */
.app-container.platform-mobile.active-mobile-tab-reader .reader-pane {
display: flex;
}
.app-container.platform-mobile.active-mobile-tab-graph .nexus-mobile-reader-tabs .graph-tab {
display: block;
}
.app-container.platform-mobile.active-mobile-tab-insight .nexus-mobile-reader-tabs .insight-tab {
display: block;
}
/* Mobile full-bleed tabs container */
.nexus-mobile-reader-tabs {
display: none;
}
.platform-mobile .nexus-mobile-reader-tabs {
display: block;
width: 100vw;
height: calc(100vh - 60px);
position: absolute;
top: 0;
left: 0;
background: #0d0d0d;
overflow: hidden;
z-index: 15;
}
.nexus-mobile-tab-content {
display: none;
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
/* Active tab display with smooth slide-up / fade-in transition */
.nexus-mobile-tab-content.active {
display: flex;
flex-direction: column;
animation: tab-transition 0.25s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes tab-transition {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Inside Mobile Graph Tab: full bleed and responsive */
.nexus-mobile-tab-content.graph-tab {
background: #09090b;
}
.nexus-mobile-tab-content.graph-tab :deep(svg) {
width: 100% !important;
height: 100% !important;
}
/* Mobile Insight container & tabs */
.mobile-insight-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
}
.mobile-insight-header {
background: rgba(13, 13, 13, 0.95);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding: 0.75rem 1rem;
flex-shrink: 0;
}
.mobile-insight-nav {
display: flex;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 2px;
}
.mobile-insight-nav-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
font-family: var(--nexus-font-sans, "Outfit", sans-serif);
font-size: 0.8rem;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.mobile-insight-nav-btn.active {
background: rgba(0, 240, 255, 0.1);
color: var(--nexus-neon, #00f0ff);
box-shadow: 0 0 10px rgba(0, 240, 255, 0.15);
}
.mobile-insight-nav-btn.quiz-btn.quiz-pulse {
animation: quiz-pulse-btn-anim 1.5s infinite;
}
@keyframes quiz-pulse-btn-anim {
0% { color: rgba(255, 255, 255, 0.5); }
50% { color: #f43f5e; text-shadow: 0 0 8px rgba(244, 63, 94, 0.6); }
100% { color: rgba(255, 255, 255, 0.5); }
}
.mobile-insight-body {
flex: 1;
overflow-y: auto;
background: #09090b;
}
.mobile-insight-body .contextual-intelligence-panel {
background: transparent;
border: none;
}
.mobile-insight-body .contextual-intelligence-panel .panel-body {
padding: 1.25rem;
}
.mobile-quiz-wrapper {
padding: 1.25rem;
height: 100%;
overflow-y: auto;
}
/* Three-Tab Bottom Navigation Bar styling */
.nexus-mobile-bottom-nav {
display: none;
}
.platform-mobile .nexus-mobile-bottom-nav {
display: flex;
justify-content: space-around;
align-items: center;
position: absolute;
bottom: 0;
left: 0;
width: 100vw;
height: 60px;
background: rgba(13, 13, 13, 0.95);
backdrop-filter: blur(16px);
border-top: 1px solid rgba(255, 255, 255, 0.05);
z-index: 100;
}
.bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
height: 100%;
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
font-family: var(--nexus-font-sans, "Outfit", sans-serif);
font-size: 0.65rem;
font-weight: 500;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.bottom-nav-item.active {
color: var(--nexus-neon, #00f0ff);
text-shadow: 0 0 10px rgba(0, 240, 255, 0.2);
}
.bottom-nav-item.active :deep(svg) {
filter: drop-shadow(0 0 5px var(--nexus-neon, #00f0ff));
}
.insight-icon-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.nav-quiz-indicator {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background-color: #f43f5e;
border-radius: 50%;
box-shadow: 0 0 8px #f43f5e;
animation: indicator-flash 1.5s infinite ease-in-out;
}
@keyframes indicator-flash {
0% { transform: scale(0.8); opacity: 0.6; }
50% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(0.8); opacity: 0.6; }
}
/* Assistant FAB styling inside ReaderCanvas */
:global(.nexus-mobile-assistant-fab) {
position: fixed;
bottom: 75px;
right: 20px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(0, 240, 255, 0.15) 0%, rgba(0, 100, 255, 0.15) 100%);
border: 1px solid rgba(0, 240, 255, 0.4);
box-shadow: 0 4px 20px rgba(0, 240, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 99;
backdrop-filter: blur(8px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
:global(.nexus-mobile-assistant-fab:hover) {
transform: scale(1.1) translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 240, 255, 0.4);
border-color: var(--nexus-neon, #00f0ff);
}
:global(.nexus-mobile-assistant-fab:active) {
transform: scale(0.95);
}
:global(.nexus-mobile-assistant-fab.has-new-quiz) {
border-color: #f43f5e;
box-shadow: 0 4px 20px rgba(244, 63, 94, 0.3);
}
:global(.nexus-mobile-assistant-fab .fab-badge) {
position: absolute;
top: 2px;
right: 2px;
width: 10px;
height: 10px;
background-color: #f43f5e;
border-radius: 50%;
box-shadow: 0 0 10px #f43f5e;
animation: indicator-flash 1.5s infinite ease-in-out;
}
@@ -528,3 +528,81 @@
overflow: hidden;
}
/* Mobile Dashboard Overrides */
@media (max-width: 768px) {
.dashboard-content {
padding: 1.25rem 0.75rem;
}
.profile-header {
padding: 1.5rem 1rem;
border-radius: 16px;
}
.profile-visual {
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
align-items: center;
text-align: left;
gap: 1.25rem;
}
.avatar-wrapper {
width: 70px;
height: 70px;
margin: 0;
}
.user-info {
flex: 1;
}
.user-name {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.user-role {
font-size: 0.85rem;
}
.status-pills {
width: 100%;
margin-top: 0.5rem;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.5rem;
}
.status-pill {
padding: 0.35rem 0.75rem;
font-size: 0.75rem;
}
.main-grid {
grid-template-columns: 1fr !important;
gap: 1.25rem !important;
}
.secondary-grid {
grid-template-columns: 1fr !important;
gap: 1.25rem !important;
}
/* Force all widgets to take 100% width and fit inside parent container nicely */
.glass-panel {
width: 100% !important;
padding: 1.25rem !important;
box-sizing: border-box;
}
/* Expand touch-targets to 48px min height for interactive elements */
.btn-nexus, .quiz-option, .satellite, .logout-btn, .nav-item, .quiz-item {
min-height: 48px;
display: flex;
align-items: center;
touch-action: manipulation;
}
}
@@ -6,11 +6,13 @@ public interface IReaderInteractionService
event Func<string, Task>? OnScrollToBlockRequested;
event Func<string, Task>? OnHighlightBlockRequested;
event Func<string, string, SelectionCoordinates, Task>? OnTextSelected;
event Func<Task>? OnAssistantRequested;
Task NotifyNodeSelected(string nodeId);
Task RequestScrollToBlock(string blockId);
Task RequestHighlightBlock(string blockId);
Task NotifyTextSelected(string text, string blockId, SelectionCoordinates coords);
Task RequestAssistant();
}
public record SelectionCoordinates(double Top, double Left, double Width);
@@ -6,6 +6,7 @@ public sealed class ReaderInteractionService : IReaderInteractionService
public event Func<string, Task>? OnScrollToBlockRequested;
public event Func<string, Task>? OnHighlightBlockRequested;
public event Func<string, string, SelectionCoordinates, Task>? OnTextSelected;
public event Func<Task>? OnAssistantRequested;
public async Task NotifyNodeSelected(string nodeId)
{
@@ -26,4 +27,9 @@ public sealed class ReaderInteractionService : IReaderInteractionService
{
if (OnTextSelected != null) await OnTextSelected(text, blockId, coords);
}
public async Task RequestAssistant()
{
if (OnAssistantRequested != null) await OnAssistantRequested();
}
}
@@ -113,6 +113,85 @@ let svgElement;
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver;
let isMobileMode = false;
let activeNodeId = null;
const getNodeGlyph = d => {
if (!d) return 'C';
const type = getNodeType(d);
const group = getNodeGroup(d);
if (type === 'rule') return '§';
if (type === 'definition') return 'D';
if (type === 'table') return 'T';
if (type === 'section') return 'S';
if (group === 'bridge') return 'B';
if (group === 'current') return '★';
return 'C';
};
function updateNodeAppearances() {
if (!node) return;
node.each(function(d) {
const g = d3.select(this);
const rect = g.select(".node-pill");
const text = g.select("text");
const isCurrent = getNodeGroup(d) === 'current';
const isSelected = activeNodeId && d.id === activeNodeId;
const showFull = !isMobileMode || isSelected || isCurrent;
if (showFull) {
rect.transition().duration(250)
.attr("x", -getPillWidth(d) / 2)
.attr("width", getPillWidth(d))
.attr("height", 30)
.attr("rx", 15)
.attr("y", -15);
text.text(getDisplayLabel(d))
.attr("font-size", isCurrent || isSelected ? "0.85rem" : "0.8rem")
.attr("font-weight", isCurrent || isSelected ? "600" : "normal");
} else {
rect.transition().duration(250)
.attr("x", -15)
.attr("width", 30)
.attr("height", 30)
.attr("rx", 15)
.attr("y", -15);
text.text(getNodeGlyph(d))
.attr("font-size", "0.9rem")
.attr("font-weight", "bold");
}
});
}
export function setMobileMode(isMobile) {
isMobileMode = isMobile;
if (!simulation) return;
if (isMobile) {
simulation.force("charge", d3.forceManyBody().strength(-60));
simulation.force("link").distance(180);
simulation.force("collide", d3.forceCollide().radius(d => {
const isCurrent = getNodeGroup(d) === 'current';
const isSelected = activeNodeId && d.id === activeNodeId;
if (isCurrent || isSelected) {
return (getPillWidth(d) / 2) + 15;
}
return 20;
}));
} else {
simulation.force("charge", d3.forceManyBody().strength(-400));
simulation.force("link").distance(120);
simulation.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
}
updateNodeAppearances();
simulation.alpha(0.3).restart();
}
export function mount(containerId, data, dotNetHelper) {
const container = document.getElementById(containerId);
if (!container) return;
@@ -204,11 +283,21 @@ export function mount(containerId, data, dotNetHelper) {
});
resizeObserver.observe(container);
isMobileMode = window.innerWidth < 768;
simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id).distance(120))
.force("charge", d3.forceManyBody().strength(-400))
.force("link", d3.forceLink().id(d => d.id).distance(isMobileMode ? 180 : 120))
.force("charge", d3.forceManyBody().strength(isMobileMode ? -60 : -400))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
.force("collide", d3.forceCollide().radius(d => {
if (isMobileMode) {
const isCurrent = getNodeGroup(d) === 'current';
const isSelected = activeNodeId && d.id === activeNodeId;
if (isCurrent || isSelected) return (getPillWidth(d) / 2) + 15;
return 20;
}
return (getPillWidth(d) / 2) + 20;
}));
simulation.on("tick", () => {
if (link) {
@@ -317,22 +406,14 @@ export function updateData(data) {
g.append("rect")
.attr("class", "node-pill")
.attr("x", d => -getPillWidth(d) / 2)
.attr("y", -15)
.attr("width", d => getPillWidth(d))
.attr("height", 30)
.attr("rx", 15)
.attr("fill", "rgba(20, 20, 20, 0.95)")
.attr("stroke", d => getCategoryStyle(d).color)
.attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2);
g.append("text")
.text(d => getDisplayLabel(d))
.attr("text-anchor", "middle")
.attr("y", 5)
.attr("fill", d => getCategoryStyle(d).textColor)
.attr("font-size", "0.8rem")
.attr("font-weight", d => getNodeGroup(d) === 'current' ? '600' : 'normal');
.attr("fill", d => getCategoryStyle(d).textColor);
g.append("title")
.text(d => d.description ? `${d.label}\n\n${d.description}` : d.label);
@@ -345,6 +426,8 @@ export function updateData(data) {
exit => exit.transition().duration(500).style("opacity", 0).remove()
);
updateNodeAppearances();
simulation.nodes(data.nodes);
simulation.force("link").links(validLinks);
simulation.alpha(0.5).restart();
@@ -377,6 +460,7 @@ function drag(simulation) {
export function setActiveNode(nodeId) {
if (!svgElement || !node) return;
activeNodeId = nodeId;
// Safety check: ensure we only target the first occurrence if IDs are duplicated
const targetNode = node.filter(d => d.id === nodeId);
if (targetNode.empty()) {
@@ -399,6 +483,20 @@ export function setActiveNode(nodeId) {
// Dim others (only exact matches for nodeId will be fully opaque)
dimNodes(nodeId);
// Dynamic collision update if in mobile mode to expand active node
if (isMobileMode && simulation) {
simulation.force("collide", d3.forceCollide().radius(d => {
const isCurrent = getNodeGroup(d) === 'current';
const isSelected = activeNodeId && d.id === activeNodeId;
if (isCurrent || isSelected) {
return (getPillWidth(d) / 2) + 15;
}
return 20;
}));
}
updateNodeAppearances();
// Smooth transition to the first matching node
svgElement.transition().duration(1000).call(
zoomBehavior.transform,
@@ -446,7 +544,14 @@ export function handleResize(containerId) {
svgElement.attr("viewBox", [0, 0, width, height]);
simulation.force("center", d3.forceCenter(width / 2, height / 2));
simulation.alpha(0.3).restart();
const prevMobileMode = isMobileMode;
isMobileMode = window.innerWidth < 768;
if (isMobileMode !== prevMobileMode) {
setMobileMode(isMobileMode);
} else {
simulation.alpha(0.3).restart();
}
}
export function scrollToNode(id) {