10 Commits

Author SHA1 Message Date
mjasin 09248b2898 feat(auth): fix post-login redirection ArgumentException and introduce premium client-side hydration preloader 2026-05-27 12:24:01 +02:00
mjasin 816bf48d15 feat: implement native AOT-friendly JwtTokenValidator to prevent sending expired bearer tokens in auth handlers 2026-05-27 11:55:12 +02:00
mjasin e0c64c4c82 merge: Resolve conflicts from base branch infra/beta-deploy-test 2026-05-27 11:44:07 +02:00
mjasin ae25d14ee7 feat(auth): synchronize SSR cookie authentication with WASM client and support returnUrl navigation 2026-05-27 11:19:48 +02:00
mjasin ee87014fee feat(mobile-ux): optimize layout synchronization and stabilize D3 knowledge graph on mobile viewports 2026-05-27 10:12:23 +02:00
Antigravity 30f445ea89 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>
2026-05-27 07:44:44 +00:00
mjasin e42546d82f feat(ui): implement premium mobile-first reader layout with three-tab bottom navigation and assistant FAB 2026-05-27 09:36:02 +02:00
mjasin a9a670d776 refactor: move debug-only logic to runtime condition in SerilogDemo page 2026-05-26 20:15:19 +02:00
mjasin b867d08e63 chore: add Central Package Management files and NexusReader.Data project to Dockerfile build process 2026-05-26 20:12:33 +02:00
mjasin 539ad79f18 feat(infra): configure beta deployment to Test environment with hardened security and feature flags 2026-05-26 19:55:47 +02:00
30 changed files with 1652 additions and 220 deletions
+41
View File
@@ -0,0 +1,41 @@
# ===================================================================
# NexusReader — Test Environment Variables
# ===================================================================
# Copy this file to `.env` and fill in the values before deployment:
# cp .env.test.template .env
#
# Then deploy with:
# docker compose -f docker-compose.test.yml up -d --build
# ===================================================================
# === PostgreSQL ===
POSTGRES_USER=nexus_user
POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
POSTGRES_DB=nexus_test_db
POSTGRES_PORT=5433
# === Neo4j ===
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
# === Qdrant (leave empty to disable API key auth) ===
QDRANT_API_KEY=
# === Web App ===
WEB_PORT=5050
# === Google OAuth (placeholder for test) ===
GOOGLE_CLIENT_ID=placeholder
GOOGLE_CLIENT_SECRET=placeholder
# === Gemini AI (placeholder for test) ===
GOOGLE_AI_API_KEY=placeholder
# === Admin Seed Password ===
NEXUS_ADMIN_PASSWORD=aQ13EdSw2
# === Non-standard ports for auxiliary services ===
QDRANT_HTTP_PORT=6343
QDRANT_GRPC_PORT=6344
NEO4J_HTTP_PORT=7484
NEO4J_BOLT_PORT=7697
+1
View File
@@ -29,6 +29,7 @@ Thumbs.db
*.epub *.epub
.fake .fake
.env
src/NexusReader.Web/nexus.db src/NexusReader.Web/nexus.db
src/NexusReader.Web/wwwroot/covers/ src/NexusReader.Web/wwwroot/covers/
src/NexusReader.Web/wwwroot/uploads/ src/NexusReader.Web/wwwroot/uploads/
+5
View File
@@ -2,12 +2,17 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src WORKDIR /src
# Copy props files and solution-level configurations for Central Package Management
COPY ["Directory.Build.props", "./"]
COPY ["Directory.Packages.props", "./"]
# Copy csproj files and restore dependencies # Copy csproj files and restore dependencies
COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"] COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"]
COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"] COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"]
COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"] COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"]
COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"] COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"]
COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"] COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"]
COPY ["src/NexusReader.Data/NexusReader.Data.csproj", "src/NexusReader.Data/"]
COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"] COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"]
RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj" RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj"
+97
View File
@@ -0,0 +1,97 @@
services:
db:
image: pgvector/pgvector:pg17
container_name: nexus-db-test
environment:
POSTGRES_USER: ${POSTGRES_USER:-nexus_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_DB: ${POSTGRES_DB:-nexus_test_db}
ports:
- "${POSTGRES_PORT:-5433}:5432"
volumes:
- pgdata_test:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nexus_user} -d ${POSTGRES_DB:-nexus_test_db}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- nexus-test
restart: unless-stopped
web:
build:
context: .
dockerfile: Dockerfile
container_name: nexus-web-test
ports:
- "${WEB_PORT:-5050}:5000"
environment:
- ASPNETCORE_ENVIRONMENT=Test
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_test_db};Username=${POSTGRES_USER:-nexus_user};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
- ConnectionStrings__QdrantConnection=http://qdrant:6334
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder}
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder}
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder}
- NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:-aQ13EdSw2}
depends_on:
db:
condition: service_healthy
qdrant:
condition: service_healthy
neo4j:
condition: service_healthy
networks:
- nexus-test
restart: unless-stopped
qdrant:
image: qdrant/qdrant:latest
container_name: nexus-qdrant-test
environment:
- QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY:-}
ports:
- "${QDRANT_HTTP_PORT:-6343}:6333"
- "${QDRANT_GRPC_PORT:-6344}:6334"
volumes:
- qdrant_test_data:/qdrant/storage
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'"]
interval: 5s
timeout: 5s
retries: 5
networks:
- nexus-test
restart: unless-stopped
neo4j:
image: neo4j:5-community
container_name: nexus-neo4j-test
environment:
- NEO4J_AUTH=${NEO4J_USERNAME:-neo4j}/${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
ports:
- "${NEO4J_HTTP_PORT:-7484}:7474"
- "${NEO4J_BOLT_PORT:-7697}:7687"
volumes:
- neo4j_test_data:/data
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"]
interval: 10s
timeout: 10s
retries: 10
start_period: 30s
networks:
- nexus-test
restart: unless-stopped
volumes:
pgdata_test:
qdrant_test_data:
neo4j_test_data:
networks:
nexus-test:
driver: bridge
@@ -68,7 +68,8 @@ public static class DbInitializer
SecurityStamp = Guid.NewGuid().ToString() SecurityStamp = Guid.NewGuid().ToString()
}; };
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, "Admin123!"); var adminPassword = Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD") ?? "Admin123!";
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, adminPassword);
dbContext.Users.Add(adminUser); dbContext.Users.Add(adminUser);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
@@ -56,9 +56,14 @@ public static class DependencyInjection
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334"; var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
services.AddSingleton<QdrantClient>(sp => new QdrantClient(new Uri(qdrantUrl))); services.AddSingleton<QdrantClient>(sp => new QdrantClient(new Uri(qdrantUrl)));
// Neo4j Driver registration // Neo4j Driver registration (supports optional authentication)
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687"; var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
services.AddSingleton<IDriver>(sp => GraphDatabase.Driver(neo4jUrl, AuthTokens.None)); var neo4jUser = configuration["Neo4j:Username"];
var neo4jPass = configuration["Neo4j:Password"];
var neo4jAuth = !string.IsNullOrEmpty(neo4jUser)
? AuthTokens.Basic(neo4jUser, neo4jPass ?? string.Empty)
: AuthTokens.None;
services.AddSingleton<IDriver>(sp => GraphDatabase.Driver(neo4jUrl, neo4jAuth));
// Hangfire registration // Hangfire registration
if (!string.IsNullOrEmpty(pgConnectionString)) if (!string.IsNullOrEmpty(pgConnectionString))
@@ -3,6 +3,7 @@ using System.Threading;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.UI.Shared.Services;
namespace NexusReader.Maui.Infrastructure.Identity; namespace NexusReader.Maui.Infrastructure.Identity;
@@ -55,9 +56,14 @@ public class MobileAuthenticationHeaderHandler : DelegatingHandler
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value)) if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
{ {
originalToken = tokenResult.Value; originalToken = tokenResult.Value;
// Only attach the Bearer token if it is not expired
if (!JwtTokenValidator.IsExpired(originalToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
} }
} }
}
var response = await base.SendAsync(request, cancellationToken); var response = await base.SendAsync(request, cancellationToken);
@@ -0,0 +1,41 @@
@using Microsoft.AspNetCore.Components.Authorization
@inject PersistentComponentState ApplicationState
@inject AuthenticationStateProvider AuthenticationStateProvider
@implements IDisposable
@code {
private PersistingComponentStateSubscription _subscription;
protected override void OnInitialized()
{
_subscription = ApplicationState.RegisterOnPersisting(PersistAuthenticationStateAsync);
}
private async Task PersistAuthenticationStateAsync()
{
var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var principal = authenticationState.User;
if (principal.Identity?.IsAuthenticated == true)
{
var email = principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? principal.Identity.Name;
var tenantId = principal.FindFirst("TenantId")?.Value ?? "global";
var roles = string.Join(",", principal.FindAll(System.Security.Claims.ClaimTypes.Role).Select(c => c.Value));
if (email != null)
{
ApplicationState.PersistAsJson("UserInfo", new UserInfo
{
Email = email,
TenantId = tenantId,
Roles = roles
});
}
}
}
public void Dispose()
{
_subscription.Dispose();
}
}
@@ -13,6 +13,8 @@
@inject IReaderInteractionService InteractionService @inject IReaderInteractionService InteractionService
@inject ISyncService SyncService @inject ISyncService SyncService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject IQuizStateService QuizService
@inject IPlatformService PlatformService
@inject ILogger<ReaderCanvas> Logger @inject ILogger<ReaderCanvas> Logger
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")"> <div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
@@ -53,6 +55,17 @@
BlockId="@_selectedBlockId" BlockId="@_selectedBlockId"
Coordinates="@_selectionCoords" Coordinates="@_selectionCoords"
FullPageContent="@GetFullPageContent()" /> 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> </div>
@code { @code {
@@ -68,17 +81,30 @@
private ElementReference _containerRef; private ElementReference _containerRef;
private bool _isInteractive; private bool _isInteractive;
private string? _currentActiveBlockId; private string? _currentActiveBlockId;
private bool _isMobile = false;
private DotNetObjectReference<ReaderCanvas>? _selfReference;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await Coordinator.ClearAsync(); await Coordinator.ClearAsync();
ThemeService.OnThemeChanged += HandleUpdate; ThemeService.OnThemeChanged += HandleUpdate;
NavigationService.OnNavigationChanged += OnNavigationChanged; NavigationService.OnNavigationChanged += OnNavigationChanged;
QuizService.OnQuizUpdated += HandleUpdate;
InteractionService.OnScrollToBlockRequested += HandleScrollRequested; InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested; InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
InteractionService.OnTextSelected += HandleTextSelected; InteractionService.OnTextSelected += HandleTextSelected;
SyncService.OnProgressReceived += HandleSyncProgressReceived; 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() protected override async Task OnParametersSetAsync()
@@ -105,6 +131,8 @@
{ {
await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId); await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId);
} }
await InitViewportDetectionAsync();
} }
if (ViewModel != null && !_isJsInitialized) if (ViewModel != null && !_isJsInitialized)
@@ -115,6 +143,44 @@
} }
} }
private async Task InitViewportDetectionAsync()
{
try
{
_selfReference = DotNetObjectReference.Create(this);
var isMobileViewport = await JS.InvokeAsync<bool>("eval", "window.innerWidth < 768");
await OnViewportChanged(isMobileViewport);
await JS.InvokeVoidAsync("eval", @"
window.registerCanvasViewportObserver = (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("registerCanvasViewportObserver", _selfReference);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to initialize viewport detection in ReaderCanvas.");
}
}
[JSInvokable]
public async Task OnViewportChanged(bool isMobile)
{
if (_isMobile != isMobile)
{
_isMobile = isMobile;
await InvokeAsync(StateHasChanged);
}
}
private async Task InitializeSelectionListenerAsync() private async Task InitializeSelectionListenerAsync()
{ {
try try
@@ -286,14 +352,21 @@
private Task HandleUpdate() => InvokeAsync(StateHasChanged); private Task HandleUpdate() => InvokeAsync(StateHasChanged);
private async Task HandleAssistantFabClick()
{
await InteractionService.RequestAssistant();
}
public void Dispose() public void Dispose()
{ {
ThemeService.OnThemeChanged -= HandleUpdate; ThemeService.OnThemeChanged -= HandleUpdate;
NavigationService.OnNavigationChanged -= OnNavigationChanged; NavigationService.OnNavigationChanged -= OnNavigationChanged;
QuizService.OnQuizUpdated -= HandleUpdate;
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested; InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested; InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
InteractionService.OnTextSelected -= HandleTextSelected; InteractionService.OnTextSelected -= HandleTextSelected;
SyncService.OnProgressReceived -= HandleSyncProgressReceived; SyncService.OnProgressReceived -= HandleSyncProgressReceived;
_selfReference?.Dispose();
} }
} }
@@ -4,9 +4,39 @@
@using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Abstractions.Services
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
<div class="hub-container"> @if (!_isFullyLoaded)
{
<div class="app-preloader" style="backdrop-filter: blur(15px); background: rgba(18, 18, 18, 0.95); z-index: 100000;">
<div class="preloader-spinner"></div>
<div class="preloader-text">Synchronizing Secure Session...</div>
</div>
}
<div class="hub-container @(_isMobileMenuOpen ? "mobile-menu-open" : "")">
<AuthorizeView> <AuthorizeView>
<Authorized> <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"> <aside class="hub-sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="logo"> <div class="logo">
@@ -16,48 +46,49 @@
</div> </div>
<nav class="sidebar-nav"> <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"> <div class="nav-icon">
<NexusIcon Name="home" Size="18" /> <NexusIcon Name="home" Size="18" />
</div> </div>
<span class="nav-text">Dashboard</span> <span class="nav-text">Dashboard</span>
</NavLink> </NavLink>
<NavLink class="nav-item" href="/library"> <NavLink class="nav-item" href="/library" @onclick="CloseMobileMenu">
<div class="nav-icon"> <div class="nav-icon">
<NexusIcon Name="book-open" Size="18" /> <NexusIcon Name="book-open" Size="18" />
</div> </div>
<span class="nav-text">Library</span> <span class="nav-text">Library</span>
</NavLink> </NavLink>
<NavLink class="nav-item" href="/concepts-map"> <NavLink class="nav-item" href="/concepts-map" @onclick="CloseMobileMenu">
<div class="nav-icon"> <div class="nav-icon">
<NexusIcon Name="map" Size="18" /> <NexusIcon Name="map" Size="18" />
</div> </div>
<span class="nav-text">Concepts Map</span> <span class="nav-text">Concepts Map</span>
</NavLink> </NavLink>
<NavLink class="nav-item" href="/intelligence"> <NavLink class="nav-item" href="/intelligence" @onclick="CloseMobileMenu">
<div class="nav-icon"> <div class="nav-icon">
<NexusIcon Name="cpu" Size="18" /> <NexusIcon Name="cpu" Size="18" />
</div> </div>
<span class="nav-text">Global AI Q&A</span> <span class="nav-text">Global AI Q&A</span>
</NavLink> </NavLink>
<NavLink class="nav-item" href="/profile"> <NavLink class="nav-item" href="/profile" @onclick="CloseMobileMenu">
<div class="nav-icon"> <div class="nav-icon">
<NexusIcon Name="message-square" Size="18" /> <NexusIcon Name="message-square" Size="18" />
</div> </div>
<span class="nav-text">Profile</span> <span class="nav-text">Profile</span>
</NavLink> </NavLink>
<NavLink class="nav-item" href="/settings"> <NavLink class="nav-item" href="/settings" @onclick="CloseMobileMenu">
<div class="nav-icon"> <div class="nav-icon">
<NexusIcon Name="settings" Size="18" /> <NexusIcon Name="settings" Size="18" />
</div> </div>
<span class="nav-text">Settings</span> <span class="nav-text">Settings</span>
</NavLink> </NavLink>
<NavLink class="nav-item" href="/concenters"> <NavLink class="nav-item" href="/concenters" @onclick="CloseMobileMenu">
<div class="nav-icon"> <div class="nav-icon">
<NexusIcon Name="target" Size="18" /> <NexusIcon Name="target" Size="18" />
</div> </div>
<span class="nav-text">Concenters</span> <span class="nav-text">Concenters</span>
</NavLink> </NavLink>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -90,6 +121,8 @@
[Inject] private NavigationManager NavigationManager { get; set; } = default!; [Inject] private NavigationManager NavigationManager { get; set; } = default!;
private bool _isSyncing = false; private bool _isSyncing = false;
private bool _isMobileMenuOpen = false;
private bool _isFullyLoaded = false;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -104,8 +137,28 @@
} }
} }
protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
{
_isFullyLoaded = true;
StateHasChanged();
}
}
private void ToggleMobileMenu()
{
_isMobileMenuOpen = !_isMobileMenuOpen;
}
private void CloseMobileMenu()
{
_isMobileMenuOpen = false;
}
private async Task HandleLogout() private async Task HandleLogout()
{ {
CloseMobileMenu();
await IdentityService.LogoutAsync(); await IdentityService.LogoutAsync();
NavigationManager.NavigateTo("/account/logout-form", true); NavigationManager.NavigateTo("/account/logout-form", true);
} }
@@ -190,4 +190,157 @@
to { transform: rotate(360deg); } 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 @inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger
@implements IDisposable @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"> <div class="reader-pane">
<main> <main>
@Body @Body
@@ -30,6 +30,8 @@
<AuthorizeView> <AuthorizeView>
<Authorized> <Authorized>
@if (!_isMobile)
{
<div class="resizer" id="sidebar-resizer"></div> <div class="resizer" id="sidebar-resizer"></div>
<div class="intelligence-sidebar"> <div class="intelligence-sidebar">
@@ -47,12 +49,9 @@
@if (_activeTab == SidebarTab.Knowledge) @if (_activeTab == SidebarTab.Knowledge)
{ {
<div class="intelligence-scroll-area stacked-layout"> <div class="intelligence-scroll-area stacked-layout">
@if (!_isMobile)
{
<div class="visual-workspace"> <div class="visual-workspace">
<KnowledgeGraph /> <KnowledgeGraph />
</div> </div>
}
<div class="contextual-intelligence-panel"> <div class="contextual-intelligence-panel">
<div class="panel-header"> <div class="panel-header">
@@ -132,6 +131,120 @@
} }
</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> </Authorized>
<Authorizing> <Authorizing>
<div class="app-preloader"> <div class="app-preloader">
@@ -155,12 +268,21 @@
Quiz Quiz
} }
private enum MobileReaderTab
{
Reader,
Graph,
Insight
}
private SidebarTab _activeTab = SidebarTab.Knowledge; private SidebarTab _activeTab = SidebarTab.Knowledge;
private MobileReaderTab _activeMobileTab = MobileReaderTab.Reader;
private string? _selectedNodeId; private string? _selectedNodeId;
private GraphNodeDto? _selectedNode; private GraphNodeDto? _selectedNode;
private string _platformClass = "platform-desktop"; private string _platformClass = "platform-desktop";
private bool _isMobile = false; private bool _isMobile = false;
private DotNetObjectReference<ReaderLayout>? _selfReference;
protected override void OnInitialized() protected override void OnInitialized()
{ {
@@ -169,6 +291,7 @@
QuizService.OnQuizRequested += HandleQuizRequestedAsync; QuizService.OnQuizRequested += HandleQuizRequestedAsync;
InteractionService.OnNodeSelected += HandleNodeSelectedAsync; InteractionService.OnNodeSelected += HandleNodeSelectedAsync;
InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync;
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync; GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
var context = PlatformService.GetDeviceContext(); var context = PlatformService.GetDeviceContext();
@@ -190,8 +313,25 @@
StateHasChanged(); StateHasChanged();
} }
private void SetMobileTab(MobileReaderTab tab)
{
_activeMobileTab = tab;
StateHasChanged();
}
private async Task HandleQuizRequestedAsync(string blockId) 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; _activeTab = SidebarTab.Quiz;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -203,6 +343,11 @@
{ {
_selectedNode = GraphService.CurrentGraphData.Nodes.FirstOrDefault(n => n.Id == nodeId); _selectedNode = GraphService.CurrentGraphData.Nodes.FirstOrDefault(n => n.Id == nodeId);
} }
if (_isMobile)
{
_activeMobileTab = MobileReaderTab.Insight;
_activeTab = SidebarTab.Knowledge;
}
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -226,6 +371,47 @@
{ {
Logger.LogError(ex, "Failed to initialize layout resizer JS module."); Logger.LogError(ex, "Failed to initialize layout resizer JS module.");
} }
await InitViewportDetectionAsync();
}
}
private async Task InitViewportDetectionAsync()
{
try
{
_selfReference = DotNetObjectReference.Create(this);
var isMobileViewport = await JS.InvokeAsync<bool>("eval", "window.innerWidth < 768");
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);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to initialize viewport detection.");
}
}
[JSInvokable]
public async Task OnViewportChanged(bool isMobile)
{
if (_isMobile != isMobile)
{
_isMobile = isMobile;
_platformClass = _isMobile ? "platform-mobile" : "platform-desktop";
await InvokeAsync(StateHasChanged);
} }
} }
@@ -237,6 +423,8 @@
QuizService.OnQuizUpdated -= HandleUpdate; QuizService.OnQuizUpdated -= HandleUpdate;
QuizService.OnQuizRequested -= HandleQuizRequestedAsync; QuizService.OnQuizRequested -= HandleQuizRequestedAsync;
InteractionService.OnNodeSelected -= HandleNodeSelectedAsync; InteractionService.OnNodeSelected -= HandleNodeSelectedAsync;
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync; GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
_selfReference?.Dispose();
} }
} }
@@ -468,3 +468,290 @@ main {
color: var(--nexus-neon, #00f0ff); color: var(--nexus-neon, #00f0ff);
background: rgba(255, 255, 255, 0.02); 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: none; /* Keep hidden by default */
width: 100vw;
height: calc(100vh - 60px);
position: absolute;
top: 0;
left: 0;
background: #0d0d0d;
overflow: hidden;
z-index: 15;
}
.app-container.platform-mobile.active-mobile-tab-graph .nexus-mobile-reader-tabs,
.app-container.platform-mobile.active-mobile-tab-insight .nexus-mobile-reader-tabs {
display: block; /* Show only when graph or insight tabs are active */
}
.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;
}
@@ -7,6 +7,7 @@
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IJSRuntime JS @inject IJSRuntime JS
@inject IConfiguration Configuration
<div class="login-page-container"> <div class="login-page-container">
<div class="mesh-bg"></div> <div class="mesh-bg"></div>
@@ -80,8 +81,14 @@
</EditForm> </EditForm>
<div class="auth-footer"> <div class="auth-footer">
@if (_allowPasswordReset)
{
<a href="/account/forgot-password" class="auth-link">Zapomniałem hasła?</a> <a href="/account/forgot-password" class="auth-link">Zapomniałem hasła?</a>
}
@if (_allowRegistration)
{
<p class="auth-switch">Nie masz konta? <a href="/account/register">Zarejestruj się</a></p> <p class="auth-switch">Nie masz konta? <a href="/account/register">Zarejestruj się</a></p>
}
</div> </div>
<div class="auth-legal"> <div class="auth-legal">
@@ -95,20 +102,33 @@
<input type="hidden" name="email" value="@_loginModel.Email" /> <input type="hidden" name="email" value="@_loginModel.Email" />
<input type="hidden" name="password" value="@_loginModel.Password" /> <input type="hidden" name="password" value="@_loginModel.Password" />
<input type="hidden" name="rememberMe" value="@(_loginModel.RememberMe ? "true" : "false")" /> <input type="hidden" name="rememberMe" value="@(_loginModel.RememberMe ? "true" : "false")" />
<input type="hidden" name="returnUrl" value="@ReturnUrl" />
</form> </form>
@code { @code {
[CascadingParameter]
private Task<AuthenticationState>? AuthStateTask { get; set; }
[Parameter] [Parameter]
[SupplyParameterFromQuery(Name = "error")] [SupplyParameterFromQuery(Name = "error")]
public string? ErrorCode { get; set; } public string? ErrorCode { get; set; }
[Parameter]
[SupplyParameterFromQuery(Name = "returnUrl")]
public string? ReturnUrl { get; set; }
private LoginModel _loginModel = new(); private LoginModel _loginModel = new();
private string? _errorMessage; private string? _errorMessage;
private bool _isSubmitting; private bool _isSubmitting;
private bool _showPassword; private bool _showPassword;
private bool _allowRegistration;
private bool _allowPasswordReset;
protected override void OnInitialized() protected override async Task OnInitializedAsync()
{ {
_allowRegistration = Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true;
_allowPasswordReset = Configuration.GetValue<bool?>("Features:AllowPasswordReset") ?? true;
if (!string.IsNullOrEmpty(ErrorCode)) if (!string.IsNullOrEmpty(ErrorCode))
{ {
_errorMessage = ErrorCode switch _errorMessage = ErrorCode switch
@@ -118,9 +138,19 @@
"UserAlreadyExists" => "Użytkownik o tym adresie e-mail już istnieje. Zaloguj się tradycyjnie hasłem.", "UserAlreadyExists" => "Użytkownik o tym adresie e-mail już istnieje. Zaloguj się tradycyjnie hasłem.",
"LockedOut" => "Twoje konto zostało zablokowane. Spróbuj ponownie później.", "LockedOut" => "Twoje konto zostało zablokowane. Spróbuj ponownie później.",
"InvalidCredentials" => "Nieprawidłowy e-mail lub hasło.", "InvalidCredentials" => "Nieprawidłowy e-mail lub hasło.",
"RegistrationDisabled" => "Rejestracja jest wyłączona w tym środowisku.",
_ => "Wystąpił nieoczekiwany błąd podczas logowania." _ => "Wystąpił nieoczekiwany błąd podczas logowania."
}; };
} }
if (AuthStateTask != null)
{
var authState = await AuthStateTask;
if (authState.User.Identity?.IsAuthenticated == true)
{
NavigationManager.NavigateTo(string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl);
}
}
} }
private async Task HandleLogin() private async Task HandleLogin()
@@ -7,6 +7,7 @@
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IJSRuntime JS @inject IJSRuntime JS
@inject IConfiguration Configuration
<div class="login-page-container"> <div class="login-page-container">
<div class="mesh-bg"></div> <div class="mesh-bg"></div>
@@ -81,6 +82,15 @@
private string? _errorMessage; private string? _errorMessage;
private bool _isSubmitting; private bool _isSubmitting;
protected override void OnInitialized()
{
var allowRegistration = Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true;
if (!allowRegistration)
{
NavigationManager.NavigateTo("/account/login?error=RegistrationDisabled", replace: true);
}
}
private async Task HandleRegister() private async Task HandleRegister()
{ {
_isSubmitting = true; _isSubmitting = true;
@@ -528,3 +528,81 @@
overflow: hidden; 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;
}
}
@@ -2,8 +2,9 @@
@inject ILogger<SerilogDemo> Logger @inject ILogger<SerilogDemo> Logger
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
#if DEBUG @if (_isDebug)
<div class="serilog-demo-container"> {
<div class="serilog-demo-container">
<div class="header-card glass-panel"> <div class="header-card glass-panel">
<div class="header-content"> <div class="header-content">
<NexusIcon Name="cpu" Size="36" Class="header-icon" /> <NexusIcon Name="cpu" Size="36" Class="header-icon" />
@@ -87,31 +88,42 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
#else }
<div class="serilog-demo-container"> else
{
<div class="serilog-demo-container">
<div class="glass-panel" style="text-align: center; padding: 3rem;"> <div class="glass-panel" style="text-align: center; padding: 3rem;">
<h2>Diagnostics Unavailable</h2> <h2>Diagnostics Unavailable</h2>
<p>This page is only available in DEBUG builds.</p> <p>This page is only available in DEBUG builds.</p>
</div> </div>
</div> </div>
#endif }
@code { @code {
#if DEBUG #if DEBUG
private readonly bool _isDebug = true;
#else
private readonly bool _isDebug = false;
#endif
private void LogInfo() private void LogInfo()
{ {
#if DEBUG
Logger.LogInformation("Structured native log triggered by user from SerilogDemo. Button: LogInfo"); Logger.LogInformation("Structured native log triggered by user from SerilogDemo. Button: LogInfo");
#endif
} }
private void LogWarning() private void LogWarning()
{ {
#if DEBUG
Logger.LogWarning("Potential warning log triggered from Blazor razor component at {Time}", DateTime.UtcNow); Logger.LogWarning("Potential warning log triggered from Blazor razor component at {Time}", DateTime.UtcNow);
#endif
} }
private void LogError() private void LogError()
{ {
#if DEBUG
try try
{ {
throw new InvalidOperationException("Simulated native C# operation exception triggered in Diagnostic dashboard."); throw new InvalidOperationException("Simulated native C# operation exception triggered in Diagnostic dashboard.");
@@ -120,16 +132,22 @@
{ {
Logger.LogError(ex, "Captured exception successfully in native Serilog pipeline!"); Logger.LogError(ex, "Captured exception successfully in native Serilog pipeline!");
} }
#endif
} }
private async Task TriggerJsLog() private async Task TriggerJsLog()
{ {
#if DEBUG
await JSRuntime.InvokeVoidAsync("console.log", "Intercepted JS console statement from Blazor WebView interop trigger!"); await JSRuntime.InvokeVoidAsync("console.log", "Intercepted JS console statement from Blazor WebView interop trigger!");
#endif
await Task.CompletedTask;
} }
private async Task TriggerJsException() private async Task TriggerJsException()
{ {
#if DEBUG
await JSRuntime.InvokeVoidAsync("eval", "throw new Error('Simulated runtime JS Exception triggered from Blazor UI button click!');"); await JSRuntime.InvokeVoidAsync("eval", "throw new Error('Simulated runtime JS Exception triggered from Blazor UI button click!');");
}
#endif #endif
await Task.CompletedTask;
}
} }
+2
View File
@@ -1,3 +1,5 @@
<AuthenticationStatePersister />
<ErrorBoundary @ref="_errorBoundary"> <ErrorBoundary @ref="_errorBoundary">
<ChildContent> <ChildContent>
<Router AppAssembly="@typeof(Routes).Assembly"> <Router AppAssembly="@typeof(Routes).Assembly">
@@ -6,11 +6,13 @@ public interface IReaderInteractionService
event Func<string, Task>? OnScrollToBlockRequested; event Func<string, Task>? OnScrollToBlockRequested;
event Func<string, Task>? OnHighlightBlockRequested; event Func<string, Task>? OnHighlightBlockRequested;
event Func<string, string, SelectionCoordinates, Task>? OnTextSelected; event Func<string, string, SelectionCoordinates, Task>? OnTextSelected;
event Func<Task>? OnAssistantRequested;
Task NotifyNodeSelected(string nodeId); Task NotifyNodeSelected(string nodeId);
Task RequestScrollToBlock(string blockId); Task RequestScrollToBlock(string blockId);
Task RequestHighlightBlock(string blockId); Task RequestHighlightBlock(string blockId);
Task NotifyTextSelected(string text, string blockId, SelectionCoordinates coords); Task NotifyTextSelected(string text, string blockId, SelectionCoordinates coords);
Task RequestAssistant();
} }
public record SelectionCoordinates(double Top, double Left, double Width); public record SelectionCoordinates(double Top, double Left, double Width);
@@ -0,0 +1,52 @@
using System;
using System.Text.Json;
namespace NexusReader.UI.Shared.Services;
/// <summary>
/// A lightweight, Native AOT-friendly JWT validator that decodes the payload of a JWT token
/// to verify expiration without standard library dependencies.
/// </summary>
public static class JwtTokenValidator
{
public static bool IsExpired(string? token)
{
if (string.IsNullOrWhiteSpace(token)) return true;
try
{
var parts = token.Split('.');
if (parts.Length != 3) return true;
var payload = parts[1];
// Pad the base64 string
var padLength = 4 - (payload.Length % 4);
if (padLength < 4)
{
payload += new string('=', padLength);
}
// Base64URL to standard Base64 conversion
payload = payload.Replace('-', '+').Replace('_', '/');
var bytes = Convert.FromBase64String(payload);
using var jsonDoc = JsonDocument.Parse(bytes);
if (jsonDoc.RootElement.TryGetProperty("exp", out var expElement))
{
var exp = expElement.GetInt64();
var expTime = DateTimeOffset.FromUnixTimeSeconds(exp);
// Allow a small 10-second clock skew buffer
return expTime <= DateTimeOffset.UtcNow.AddSeconds(10);
}
}
catch
{
return true; // Treat invalid token as expired
}
return true;
}
}
@@ -4,20 +4,24 @@ using Microsoft.AspNetCore.Components.Authorization;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Constants; using NexusReader.Application.Constants;
using Microsoft.AspNetCore.Components;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
public class NexusAuthenticationStateProvider : AuthenticationStateProvider public class NexusAuthenticationStateProvider : AuthenticationStateProvider
{ {
private readonly INativeStorageService _storageService; private readonly INativeStorageService _storageService;
private readonly PersistentComponentState _persistentState;
// SECURITY NOTE: We currently store roles in local storage to persist state across refreshes. // SECURITY NOTE: We currently store roles in local storage to persist state across refreshes.
// In a production SaaS environment, consider using ProtectedBrowserStorage (Blazor Server) // In a production SaaS environment, consider using ProtectedBrowserStorage (Blazor Server)
// or encrypted storage/JWT claims validation to prevent client-side role tampering. // or encrypted storage/JWT claims validation to prevent client-side role tampering.
private const string TokenKey = StorageKeys.AuthToken; private const string TokenKey = StorageKeys.AuthToken;
public NexusAuthenticationStateProvider(INativeStorageService storageService) public NexusAuthenticationStateProvider(INativeStorageService storageService, PersistentComponentState persistentState)
{ {
_storageService = storageService; _storageService = storageService;
_persistentState = persistentState;
} }
public void ClearCache() public void ClearCache()
@@ -34,11 +38,23 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
{ {
if (_cachedState != null) return _cachedState; if (_cachedState != null) return _cachedState;
// 0. Hydrate state from SSR if available in PersistentComponentState
if (_persistentState.TryTakeFromJson<UserInfo>("UserInfo", out var userInfo) && userInfo != null)
{
// Save to local storage for subsequent client-only transitions/refreshes
await _storageService.SaveSecureString(StorageKeys.UserEmail, userInfo.Email);
await _storageService.SaveSecureString(StorageKeys.UserTenant, userInfo.TenantId);
await _storageService.SaveSecureString(StorageKeys.UserRoles, userInfo.Roles);
_cachedState = CreateState(userInfo.Email, userInfo.TenantId, "FederatedHydration", userInfo.Roles);
return _cachedState;
}
var tokenResult = await _storageService.GetSecureString(TokenKey); var tokenResult = await _storageService.GetSecureString(TokenKey);
var token = tokenResult.IsSuccess ? tokenResult.Value : null; var token = tokenResult.IsSuccess ? tokenResult.Value : null;
// 1. Try Token-based auth // 1. Try Token-based auth
if (!string.IsNullOrWhiteSpace(token)) if (!string.IsNullOrWhiteSpace(token) && !JwtTokenValidator.IsExpired(token))
{ {
var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail); var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant); var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
@@ -116,3 +132,10 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest))); NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest)));
} }
} }
public class UserInfo
{
public string Email { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string Roles { get; set; } = string.Empty;
}
@@ -6,6 +6,7 @@ public sealed class ReaderInteractionService : IReaderInteractionService
public event Func<string, Task>? OnScrollToBlockRequested; public event Func<string, Task>? OnScrollToBlockRequested;
public event Func<string, Task>? OnHighlightBlockRequested; public event Func<string, Task>? OnHighlightBlockRequested;
public event Func<string, string, SelectionCoordinates, Task>? OnTextSelected; public event Func<string, string, SelectionCoordinates, Task>? OnTextSelected;
public event Func<Task>? OnAssistantRequested;
public async Task NotifyNodeSelected(string nodeId) public async Task NotifyNodeSelected(string nodeId)
{ {
@@ -26,4 +27,9 @@ public sealed class ReaderInteractionService : IReaderInteractionService
{ {
if (OnTextSelected != null) await OnTextSelected(text, blockId, coords); if (OnTextSelected != null) await OnTextSelected(text, blockId, coords);
} }
public async Task RequestAssistant()
{
if (OnAssistantRequested != null) await OnAssistantRequested();
}
} }
+1
View File
@@ -16,6 +16,7 @@
@using NexusReader.UI.Shared.Components.Organisms @using NexusReader.UI.Shared.Components.Organisms
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using Microsoft.Extensions.Logging @using Microsoft.Extensions.Logging
@using Microsoft.Extensions.Configuration
@using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.DTOs.User @using NexusReader.Application.DTOs.User
@using NexusReader.Application.Queries.Reader @using NexusReader.Application.Queries.Reader
@@ -113,6 +113,85 @@ let svgElement;
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver; 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) { export function mount(containerId, data, dotNetHelper) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (!container) return; if (!container) return;
@@ -204,11 +283,21 @@ export function mount(containerId, data, dotNetHelper) {
}); });
resizeObserver.observe(container); resizeObserver.observe(container);
isMobileMode = window.innerWidth < 768;
simulation = d3.forceSimulation() simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(d => d.id).distance(120)) .force("link", d3.forceLink().id(d => d.id).distance(isMobileMode ? 180 : 120))
.force("charge", d3.forceManyBody().strength(-400)) .force("charge", d3.forceManyBody().strength(isMobileMode ? -60 : -400))
.force("center", d3.forceCenter(width / 2, height / 2)) .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", () => { simulation.on("tick", () => {
if (link) { if (link) {
@@ -222,6 +311,8 @@ export function mount(containerId, data, dotNetHelper) {
if (node) { if (node) {
node.attr("transform", d => { node.attr("transform", d => {
if (d.x === undefined || isNaN(d.x) || !isFinite(d.x)) d.x = width / 2;
if (d.y === undefined || isNaN(d.y) || !isFinite(d.y)) d.y = height / 2;
// Keep within bounds with padding // Keep within bounds with padding
const pillWidth = getPillWidth(d); const pillWidth = getPillWidth(d);
const halfWidth = pillWidth / 2; const halfWidth = pillWidth / 2;
@@ -252,10 +343,12 @@ export function updateData(data) {
// Keep existing node positions if they match by ID // Keep existing node positions if they match by ID
const oldNodes = new Map(simulation.nodes().map(d => [d.id, d])); const oldNodes = new Map(simulation.nodes().map(d => [d.id, d]));
data.nodes.forEach(d => { data.nodes.forEach(d => {
if (d.x !== undefined && (!isFinite(d.x) || isNaN(d.x))) d.x = undefined;
if (d.y !== undefined && (!isFinite(d.y) || isNaN(d.y))) d.y = undefined;
if (oldNodes.has(d.id)) { if (oldNodes.has(d.id)) {
const old = oldNodes.get(d.id); const old = oldNodes.get(d.id);
d.x = old.x; if (old.x !== undefined && isFinite(old.x) && !isNaN(old.x)) d.x = old.x;
d.y = old.y; if (old.y !== undefined && isFinite(old.y) && !isNaN(old.y)) d.y = old.y;
d.vx = old.vx; d.vx = old.vx;
d.vy = old.vy; d.vy = old.vy;
} }
@@ -317,22 +410,14 @@ export function updateData(data) {
g.append("rect") g.append("rect")
.attr("class", "node-pill") .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("fill", "rgba(20, 20, 20, 0.95)")
.attr("stroke", d => getCategoryStyle(d).color) .attr("stroke", d => getCategoryStyle(d).color)
.attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2); .attr("stroke-width", d => getNodeGroup(d) === 'current' ? 2 : 1.2);
g.append("text") g.append("text")
.text(d => getDisplayLabel(d))
.attr("text-anchor", "middle") .attr("text-anchor", "middle")
.attr("y", 5) .attr("y", 5)
.attr("fill", d => getCategoryStyle(d).textColor) .attr("fill", d => getCategoryStyle(d).textColor);
.attr("font-size", "0.8rem")
.attr("font-weight", d => getNodeGroup(d) === 'current' ? '600' : 'normal');
g.append("title") g.append("title")
.text(d => d.description ? `${d.label}\n\n${d.description}` : d.label); .text(d => d.description ? `${d.label}\n\n${d.description}` : d.label);
@@ -345,6 +430,8 @@ export function updateData(data) {
exit => exit.transition().duration(500).style("opacity", 0).remove() exit => exit.transition().duration(500).style("opacity", 0).remove()
); );
updateNodeAppearances();
simulation.nodes(data.nodes); simulation.nodes(data.nodes);
simulation.force("link").links(validLinks); simulation.force("link").links(validLinks);
simulation.alpha(0.5).restart(); simulation.alpha(0.5).restart();
@@ -377,6 +464,7 @@ function drag(simulation) {
export function setActiveNode(nodeId) { export function setActiveNode(nodeId) {
if (!svgElement || !node) return; if (!svgElement || !node) return;
activeNodeId = nodeId;
// Safety check: ensure we only target the first occurrence if IDs are duplicated // Safety check: ensure we only target the first occurrence if IDs are duplicated
const targetNode = node.filter(d => d.id === nodeId); const targetNode = node.filter(d => d.id === nodeId);
if (targetNode.empty()) { if (targetNode.empty()) {
@@ -387,6 +475,7 @@ export function setActiveNode(nodeId) {
const firstMatch = targetNode.filter((d, i) => i === 0); const firstMatch = targetNode.filter((d, i) => i === 0);
const d = firstMatch.datum(); const d = firstMatch.datum();
if (!d || d.x === undefined || d.y === undefined || isNaN(d.x) || !isFinite(d.x) || isNaN(d.y) || !isFinite(d.y)) return;
// Reset all active classes // Reset all active classes
rootGroup.selectAll(".node-pill").classed("nexus-node-active", false); rootGroup.selectAll(".node-pill").classed("nexus-node-active", false);
@@ -399,6 +488,20 @@ export function setActiveNode(nodeId) {
// Dim others (only exact matches for nodeId will be fully opaque) // Dim others (only exact matches for nodeId will be fully opaque)
dimNodes(nodeId); 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 // Smooth transition to the first matching node
svgElement.transition().duration(1000).call( svgElement.transition().duration(1000).call(
zoomBehavior.transform, zoomBehavior.transform,
@@ -441,12 +544,25 @@ export function handleResize(containerId) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (!container || !svgElement || !simulation) return; if (!container || !svgElement || !simulation) return;
width = container.clientWidth; const newWidth = container.clientWidth;
height = container.clientHeight; const newHeight = container.clientHeight;
// If container is hidden (size is 0), skip resize to avoid collapsing coordinates to (0,0) or NaN
if (newWidth <= 0 || newHeight <= 0) return;
width = newWidth;
height = newHeight;
svgElement.attr("viewBox", [0, 0, width, height]); svgElement.attr("viewBox", [0, 0, width, height]);
simulation.force("center", d3.forceCenter(width / 2, height / 2)); simulation.force("center", d3.forceCenter(width / 2, height / 2));
const prevMobileMode = isMobileMode;
isMobileMode = window.innerWidth < 768;
if (isMobileMode !== prevMobileMode) {
setMobileMode(isMobileMode);
} else {
simulation.alpha(0.3).restart(); simulation.alpha(0.3).restart();
}
} }
export function scrollToNode(id) { export function scrollToNode(id) {
@@ -480,21 +596,26 @@ export function zoomReset() {
export function zoomToFit() { export function zoomToFit() {
if (!node || node.empty() || !svgElement || !zoomBehavior) return; if (!node || node.empty() || !svgElement || !zoomBehavior) return;
if (width <= 0 || height <= 0 || isNaN(width) || isNaN(height)) return;
// Get the actual bounding box of the nodes // Get the actual bounding box of the nodes
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
node.each(d => { node.each(d => {
if (d && d.x !== undefined && d.y !== undefined && isFinite(d.x) && isFinite(d.y)) {
const pw = getPillWidth(d) / 2; const pw = getPillWidth(d) / 2;
minX = Math.min(minX, d.x - pw); minX = Math.min(minX, d.x - pw);
maxX = Math.max(maxX, d.x + pw); maxX = Math.max(maxX, d.x + pw);
minY = Math.min(minY, d.y - 15); minY = Math.min(minY, d.y - 15);
maxY = Math.max(maxY, d.y + 15); maxY = Math.max(maxY, d.y + 15);
}
}); });
if (minX === Infinity) return; if (minX === Infinity || maxX === minX || maxY === minY) return;
const graphWidth = maxX - minX; const graphWidth = maxX - minX;
const graphHeight = maxY - minY; const graphHeight = maxY - minY;
if (graphWidth <= 0 || graphHeight <= 0 || isNaN(graphWidth) || isNaN(graphHeight)) return;
const midX = (minX + maxX) / 2; const midX = (minX + maxX) / 2;
const midY = (minY + maxY) / 2; const midY = (minY + maxY) / 2;
@@ -505,6 +626,8 @@ export function zoomToFit() {
1.2 // Max scale 1.2 // Max scale
); );
if (isNaN(scale) || !isFinite(scale) || scale <= 0) return;
svgElement.transition().duration(750).call( svgElement.transition().duration(750).call(
zoomBehavior.transform, zoomBehavior.transform,
d3.zoomIdentity d3.zoomIdentity
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Http; using Microsoft.AspNetCore.Components.WebAssembly.Http;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.UI.Shared.Services;
namespace NexusReader.Web.Client.Handlers; namespace NexusReader.Web.Client.Handlers;
@@ -48,9 +49,14 @@ public class AuthenticationHeaderHandler : DelegatingHandler
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value)) if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
{ {
originalToken = tokenResult.Value; originalToken = tokenResult.Value;
// Only attach the Bearer token if it is not expired
if (!JwtTokenValidator.IsExpired(originalToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
} }
} }
}
var response = await base.SendAsync(request, cancellationToken); var response = await base.SendAsync(request, cancellationToken);
+10
View File
@@ -52,6 +52,7 @@ builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new
builder.Services.AddSingleton<IBookStorageService>(new ThrowingBookStorageService()); builder.Services.AddSingleton<IBookStorageService>(new ThrowingBookStorageService());
builder.Services.AddSingleton<IEbookRepository>(new ThrowingEbookRepository()); builder.Services.AddSingleton<IEbookRepository>(new ThrowingEbookRepository());
builder.Services.AddSingleton<IQuizResultRepository>(new ThrowingQuizResultRepository()); builder.Services.AddSingleton<IQuizResultRepository>(new ThrowingQuizResultRepository());
builder.Services.AddSingleton<IConceptsMapReadRepository>(new ThrowingConceptsMapReadRepository());
builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster()); builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
builder.Services.AddSingleton<IEpubExtractor>(new ThrowingEpubExtractor()); builder.Services.AddSingleton<IEpubExtractor>(new ThrowingEpubExtractor());
@@ -104,6 +105,14 @@ public class ThrowingQuizResultRepository : IQuizResultRepository
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage); public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
} }
public class ThrowingConceptsMapReadRepository : IConceptsMapReadRepository
{
private const string ErrorMessage = "ConceptsMap repository operations are not supported in the WASM client. Use the API endpoint for data access.";
public Task<string?> GetLastReadPageIdAsync(string userId, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
public Task<List<KnowledgeUnit>> GetKnowledgeUnitsForBookAsync(Guid bookId, string tenantId, CancellationToken cancellationToken = default) => throw new NotSupportedException(ErrorMessage);
}
public class ThrowingSyncBroadcaster : ISyncBroadcaster public class ThrowingSyncBroadcaster : ISyncBroadcaster
{ {
public Task BroadcastProgressAsync(string userId, string pageId, DateTime timestamp, string? excludedConnectionId, CancellationToken cancellationToken = default) public Task BroadcastProgressAsync(string userId, string pageId, DateTime timestamp, string? excludedConnectionId, CancellationToken cancellationToken = default)
@@ -118,3 +127,4 @@ public class ThrowingEpubExtractor : IEpubExtractor
public Task<FluentResults.Result<List<string>>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default) public Task<FluentResults.Result<List<string>>> ExtractChaptersTextAsync(string relativePath, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("EPUB text extraction is not supported in the WASM client."); => throw new NotSupportedException("EPUB text extraction is not supported in the WASM client.");
} }
+38 -3
View File
@@ -252,6 +252,35 @@ app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseAntiforgery(); app.UseAntiforgery();
// Feature flags: block registration & password reset in restricted environments (e.g. Test)
var allowRegistration = app.Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true;
var allowPasswordReset = app.Configuration.GetValue<bool?>("Features:AllowPasswordReset") ?? true;
if (!allowRegistration || !allowPasswordReset)
{
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value?.ToLowerInvariant();
if (!allowRegistration && path is "/identity/register" or "/account/register")
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Registration is disabled in this environment.");
return;
}
if (!allowPasswordReset && path is "/identity/forgotpassword" or "/account/forgot-password")
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Password reset is disabled in this environment.");
return;
}
await next();
});
}
app.MapStaticAssets(); app.MapStaticAssets();
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub"); app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
@@ -489,7 +518,7 @@ app.MapGet("/identity/login/google", (string? returnUrl) =>
var properties = new AuthenticationProperties var properties = new AuthenticationProperties
{ {
RedirectUri = "/identity/callback/google", RedirectUri = "/identity/callback/google",
Items = { { "returnUrl", returnUrl ?? "/" } } Items = { { "returnUrl", string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl } }
}; };
return Results.Challenge(properties, new[] { "Google" }); return Results.Challenge(properties, new[] { "Google" });
}); });
@@ -520,7 +549,13 @@ app.MapGet("/identity/callback/google", async (
return Results.Redirect("/account/login?error=LockedOut"); return Results.Redirect("/account/login?error=LockedOut");
} }
// New user provisioning // New user provisioning (blocked when registration is disabled)
if (!allowRegistration)
{
logger.LogWarning("Google provisioning blocked: registration is disabled in this environment.");
return Results.Redirect("/account/login?error=RegistrationDisabled");
}
var email = info.Principal.FindFirstValue(ClaimTypes.Email); var email = info.Principal.FindFirstValue(ClaimTypes.Email);
if (email != null) if (email != null)
{ {
@@ -563,7 +598,7 @@ app.MapPost("/account/login-form", async (
if (result.Succeeded) if (result.Succeeded)
{ {
logger.LogInformation("User logged in: {Email}", email); logger.LogInformation("User logged in: {Email}", email);
return Results.Redirect(returnUrl ?? "/"); return Results.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
} }
var error = result.IsLockedOut ? "LockedOut" : "InvalidCredentials"; var error = result.IsLockedOut ? "LockedOut" : "InvalidCredentials";
+13
View File
@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Features": {
"AllowRegistration": false,
"AllowPasswordReset": false
},
"ApiBaseUrl": "http://localhost:5000"
}
@@ -16,5 +16,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" /> <ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" />
<ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" /> <ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,71 @@
using System;
using System.Text;
using FluentAssertions;
using NexusReader.UI.Shared.Services;
using Xunit;
namespace NexusReader.Application.Tests.Services;
public class JwtTokenValidatorTests
{
private string CreateMockToken(long exp)
{
// {"alg":"HS256","typ":"JWT"}
var header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
var payloadJson = $"{{\"exp\":{exp}}}";
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
var payload = Convert.ToBase64String(payloadBytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
return $"{header}.{payload}.signature";
}
[Fact]
public void IsExpired_WithNullOrEmptyToken_ShouldReturnTrue()
{
JwtTokenValidator.IsExpired(null).Should().BeTrue();
JwtTokenValidator.IsExpired("").Should().BeTrue();
JwtTokenValidator.IsExpired(" ").Should().BeTrue();
}
[Fact]
public void IsExpired_WithMalformedToken_ShouldReturnTrue()
{
JwtTokenValidator.IsExpired("not.a.valid.token.format.here").Should().BeTrue();
JwtTokenValidator.IsExpired("part1.part2").Should().BeTrue();
JwtTokenValidator.IsExpired("justonestring").Should().BeTrue();
}
[Fact]
public void IsExpired_WithExpiredToken_ShouldReturnTrue()
{
// Expired 1 hour ago
var expiredTime = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds();
var token = CreateMockToken(expiredTime);
JwtTokenValidator.IsExpired(token).Should().BeTrue();
}
[Fact]
public void IsExpired_WithValidToken_ShouldReturnFalse()
{
// Valid for 1 hour in the future
var futureTime = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds();
var token = CreateMockToken(futureTime);
JwtTokenValidator.IsExpired(token).Should().BeFalse();
}
[Fact]
public void IsExpired_WithTokenInsideSkewBuffer_ShouldReturnTrue()
{
// Expiring in 5 seconds (within the 10-second skew buffer)
var skewTime = DateTimeOffset.UtcNow.AddSeconds(5).ToUnixTimeSeconds();
var token = CreateMockToken(skewTime);
JwtTokenValidator.IsExpired(token).Should().BeTrue();
}
}