Compare commits
12 Commits
develop
...
a79bd27a53
| Author | SHA1 | Date | |
|---|---|---|---|
| a79bd27a53 | |||
| 09248b2898 | |||
| 76b828395d | |||
| 816bf48d15 | |||
| e0c64c4c82 | |||
| ae25d14ee7 | |||
| ee87014fee | |||
| 30f445ea89 | |||
| e42546d82f | |||
| a9a670d776 | |||
| b867d08e63 | |||
| 539ad79f18 |
@@ -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
|
||||
@@ -29,6 +29,7 @@ Thumbs.db
|
||||
*.epub
|
||||
|
||||
.fake
|
||||
.env
|
||||
src/NexusReader.Web/nexus.db
|
||||
src/NexusReader.Web/wwwroot/covers/
|
||||
src/NexusReader.Web/wwwroot/uploads/
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
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 ["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.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"]
|
||||
COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"]
|
||||
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/"]
|
||||
|
||||
RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj"
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, "Admin123!");
|
||||
var adminPassword = Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD") ?? "Admin123!";
|
||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, adminPassword);
|
||||
|
||||
dbContext.Users.Add(adminUser);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
@@ -56,9 +56,14 @@ public static class DependencyInjection
|
||||
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
||||
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";
|
||||
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
|
||||
if (!string.IsNullOrEmpty(pgConnectionString))
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Threading;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
|
||||
namespace NexusReader.Maui.Infrastructure.Identity;
|
||||
|
||||
@@ -55,9 +56,14 @@ public class MobileAuthenticationHeaderHandler : DelegatingHandler
|
||||
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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,30 @@
|
||||
private ElementReference _containerRef;
|
||||
private bool _isInteractive;
|
||||
private string? _currentActiveBlockId;
|
||||
private bool _isMobile = false;
|
||||
private DotNetObjectReference<ReaderCanvas>? _selfReference;
|
||||
|
||||
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()
|
||||
@@ -105,6 +131,8 @@
|
||||
{
|
||||
await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId);
|
||||
}
|
||||
|
||||
await InitViewportDetectionAsync();
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
try
|
||||
@@ -286,14 +352,21 @@
|
||||
|
||||
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;
|
||||
InteractionService.OnTextSelected -= HandleTextSelected;
|
||||
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
|
||||
_selfReference?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,39 @@
|
||||
@using NexusReader.Application.Abstractions.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>
|
||||
<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,48 +46,49 @@
|
||||
</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>
|
||||
<span class="nav-text">Concenters</span>
|
||||
</NavLink>
|
||||
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
@@ -90,6 +121,8 @@
|
||||
[Inject] private NavigationManager NavigationManager { get; set; } = default!;
|
||||
|
||||
private bool _isSyncing = false;
|
||||
private bool _isMobileMenuOpen = false;
|
||||
private bool _isFullyLoaded = false;
|
||||
|
||||
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()
|
||||
{
|
||||
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,6 +30,8 @@
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@if (!_isMobile)
|
||||
{
|
||||
<div class="resizer" id="sidebar-resizer"></div>
|
||||
|
||||
<div class="intelligence-sidebar">
|
||||
@@ -47,12 +49,9 @@
|
||||
@if (_activeTab == SidebarTab.Knowledge)
|
||||
{
|
||||
<div class="intelligence-scroll-area stacked-layout">
|
||||
@if (!_isMobile)
|
||||
{
|
||||
<div class="visual-workspace">
|
||||
<KnowledgeGraph />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="contextual-intelligence-panel">
|
||||
<div class="panel-header">
|
||||
@@ -132,6 +131,120 @@
|
||||
}
|
||||
</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,12 +268,21 @@
|
||||
Quiz
|
||||
}
|
||||
|
||||
private enum MobileReaderTab
|
||||
{
|
||||
Reader,
|
||||
Graph,
|
||||
Insight
|
||||
}
|
||||
|
||||
private SidebarTab _activeTab = SidebarTab.Knowledge;
|
||||
private MobileReaderTab _activeMobileTab = MobileReaderTab.Reader;
|
||||
private string? _selectedNodeId;
|
||||
private GraphNodeDto? _selectedNode;
|
||||
|
||||
private string _platformClass = "platform-desktop";
|
||||
private bool _isMobile = false;
|
||||
private DotNetObjectReference<ReaderLayout>? _selfReference;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
@@ -169,6 +291,7 @@
|
||||
QuizService.OnQuizRequested += HandleQuizRequestedAsync;
|
||||
|
||||
InteractionService.OnNodeSelected += HandleNodeSelectedAsync;
|
||||
InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync;
|
||||
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
|
||||
|
||||
var context = PlatformService.GetDeviceContext();
|
||||
@@ -190,8 +313,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 +343,11 @@
|
||||
{
|
||||
_selectedNode = GraphService.CurrentGraphData.Nodes.FirstOrDefault(n => n.Id == nodeId);
|
||||
}
|
||||
if (_isMobile)
|
||||
{
|
||||
_activeMobileTab = MobileReaderTab.Insight;
|
||||
_activeTab = SidebarTab.Knowledge;
|
||||
}
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
@@ -226,6 +371,47 @@
|
||||
{
|
||||
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.OnQuizRequested -= HandleQuizRequestedAsync;
|
||||
InteractionService.OnNodeSelected -= HandleNodeSelectedAsync;
|
||||
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
|
||||
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
|
||||
_selfReference?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,3 +468,290 @@ main {
|
||||
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: 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 NavigationManager NavigationManager
|
||||
@inject IJSRuntime JS
|
||||
@inject IConfiguration Configuration
|
||||
|
||||
<div class="login-page-container">
|
||||
<div class="mesh-bg"></div>
|
||||
@@ -80,8 +81,14 @@
|
||||
</EditForm>
|
||||
|
||||
<div class="auth-footer">
|
||||
@if (_allowPasswordReset)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="auth-legal">
|
||||
@@ -95,20 +102,33 @@
|
||||
<input type="hidden" name="email" value="@_loginModel.Email" />
|
||||
<input type="hidden" name="password" value="@_loginModel.Password" />
|
||||
<input type="hidden" name="rememberMe" value="@(_loginModel.RememberMe ? "true" : "false")" />
|
||||
<input type="hidden" name="returnUrl" value="@ReturnUrl" />
|
||||
</form>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
[Parameter]
|
||||
[SupplyParameterFromQuery(Name = "error")]
|
||||
public string? ErrorCode { get; set; }
|
||||
|
||||
[Parameter]
|
||||
[SupplyParameterFromQuery(Name = "returnUrl")]
|
||||
public string? ReturnUrl { get; set; }
|
||||
|
||||
private LoginModel _loginModel = new();
|
||||
private string? _errorMessage;
|
||||
private bool _isSubmitting;
|
||||
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))
|
||||
{
|
||||
_errorMessage = ErrorCode switch
|
||||
@@ -118,9 +138,19 @@
|
||||
"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.",
|
||||
"InvalidCredentials" => "Nieprawidłowy e-mail lub hasło.",
|
||||
"RegistrationDisabled" => "Rejestracja jest wyłączona w tym środowisku.",
|
||||
_ => "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()
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
@inject IIdentityService IdentityService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JS
|
||||
@inject IConfiguration Configuration
|
||||
|
||||
<div class="login-page-container">
|
||||
<div class="mesh-bg"></div>
|
||||
@@ -81,6 +82,15 @@
|
||||
private string? _errorMessage;
|
||||
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()
|
||||
{
|
||||
_isSubmitting = true;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
@inject ILogger<SerilogDemo> Logger
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
#if DEBUG
|
||||
<div class="serilog-demo-container">
|
||||
@if (_isDebug)
|
||||
{
|
||||
<div class="serilog-demo-container">
|
||||
<div class="header-card glass-panel">
|
||||
<div class="header-content">
|
||||
<NexusIcon Name="cpu" Size="36" Class="header-icon" />
|
||||
@@ -87,31 +88,42 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
#else
|
||||
<div class="serilog-demo-container">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="serilog-demo-container">
|
||||
<div class="glass-panel" style="text-align: center; padding: 3rem;">
|
||||
<h2>Diagnostics Unavailable</h2>
|
||||
<p>This page is only available in DEBUG builds.</p>
|
||||
</div>
|
||||
</div>
|
||||
#endif
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
#if DEBUG
|
||||
private readonly bool _isDebug = true;
|
||||
#else
|
||||
private readonly bool _isDebug = false;
|
||||
#endif
|
||||
|
||||
private void LogInfo()
|
||||
{
|
||||
#if DEBUG
|
||||
Logger.LogInformation("Structured native log triggered by user from SerilogDemo. Button: LogInfo");
|
||||
#endif
|
||||
}
|
||||
|
||||
private void LogWarning()
|
||||
{
|
||||
#if DEBUG
|
||||
Logger.LogWarning("Potential warning log triggered from Blazor razor component at {Time}", DateTime.UtcNow);
|
||||
#endif
|
||||
}
|
||||
|
||||
private void LogError()
|
||||
{
|
||||
#if DEBUG
|
||||
try
|
||||
{
|
||||
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!");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private async Task TriggerJsLog()
|
||||
{
|
||||
#if DEBUG
|
||||
await JSRuntime.InvokeVoidAsync("console.log", "Intercepted JS console statement from Blazor WebView interop trigger!");
|
||||
#endif
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task TriggerJsException()
|
||||
{
|
||||
#if DEBUG
|
||||
await JSRuntime.InvokeVoidAsync("eval", "throw new Error('Simulated runtime JS Exception triggered from Blazor UI button click!');");
|
||||
}
|
||||
#endif
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<AuthenticationStatePersister />
|
||||
|
||||
<ErrorBoundary @ref="_errorBoundary">
|
||||
<ChildContent>
|
||||
<Router AppAssembly="@typeof(Routes).Assembly">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.Constants;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly INativeStorageService _storageService;
|
||||
private readonly PersistentComponentState _persistentState;
|
||||
|
||||
// SECURITY NOTE: We currently store roles in local storage to persist state across refreshes.
|
||||
// In a production SaaS environment, consider using ProtectedBrowserStorage (Blazor Server)
|
||||
// or encrypted storage/JWT claims validation to prevent client-side role tampering.
|
||||
private const string TokenKey = StorageKeys.AuthToken;
|
||||
|
||||
public NexusAuthenticationStateProvider(INativeStorageService storageService)
|
||||
public NexusAuthenticationStateProvider(INativeStorageService storageService, PersistentComponentState persistentState)
|
||||
{
|
||||
_storageService = storageService;
|
||||
_persistentState = persistentState;
|
||||
}
|
||||
|
||||
public void ClearCache()
|
||||
@@ -34,11 +38,23 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
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 token = tokenResult.IsSuccess ? tokenResult.Value : null;
|
||||
|
||||
// 1. Try Token-based auth
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
if (!string.IsNullOrWhiteSpace(token) && !JwtTokenValidator.IsExpired(token))
|
||||
{
|
||||
var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
|
||||
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
|
||||
@@ -116,3 +132,10 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
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>? 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
@using NexusReader.UI.Shared.Components.Organisms
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using Microsoft.Extensions.Configuration
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using NexusReader.Application.DTOs.User
|
||||
@using NexusReader.Application.Queries.Reader
|
||||
|
||||
@@ -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) {
|
||||
@@ -222,6 +311,8 @@ export function mount(containerId, data, dotNetHelper) {
|
||||
|
||||
if (node) {
|
||||
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
|
||||
const pillWidth = getPillWidth(d);
|
||||
const halfWidth = pillWidth / 2;
|
||||
@@ -252,10 +343,12 @@ export function updateData(data) {
|
||||
// Keep existing node positions if they match by ID
|
||||
const oldNodes = new Map(simulation.nodes().map(d => [d.id, 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)) {
|
||||
const old = oldNodes.get(d.id);
|
||||
d.x = old.x;
|
||||
d.y = old.y;
|
||||
if (old.x !== undefined && isFinite(old.x) && !isNaN(old.x)) d.x = old.x;
|
||||
if (old.y !== undefined && isFinite(old.y) && !isNaN(old.y)) d.y = old.y;
|
||||
d.vx = old.vx;
|
||||
d.vy = old.vy;
|
||||
}
|
||||
@@ -317,22 +410,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 +430,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 +464,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()) {
|
||||
@@ -387,6 +475,7 @@ export function setActiveNode(nodeId) {
|
||||
|
||||
const firstMatch = targetNode.filter((d, i) => i === 0);
|
||||
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
|
||||
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)
|
||||
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,
|
||||
@@ -441,12 +544,25 @@ export function handleResize(containerId) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container || !svgElement || !simulation) return;
|
||||
|
||||
width = container.clientWidth;
|
||||
height = container.clientHeight;
|
||||
const newWidth = container.clientWidth;
|
||||
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]);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollToNode(id) {
|
||||
@@ -480,21 +596,26 @@ export function zoomReset() {
|
||||
|
||||
export function zoomToFit() {
|
||||
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
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
node.each(d => {
|
||||
if (d && d.x !== undefined && d.y !== undefined && isFinite(d.x) && isFinite(d.y)) {
|
||||
const pw = getPillWidth(d) / 2;
|
||||
minX = Math.min(minX, d.x - pw);
|
||||
maxX = Math.max(maxX, d.x + pw);
|
||||
minY = Math.min(minY, 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 graphHeight = maxY - minY;
|
||||
if (graphWidth <= 0 || graphHeight <= 0 || isNaN(graphWidth) || isNaN(graphHeight)) return;
|
||||
|
||||
const midX = (minX + maxX) / 2;
|
||||
const midY = (minY + maxY) / 2;
|
||||
|
||||
@@ -505,6 +626,8 @@ export function zoomToFit() {
|
||||
1.2 // Max scale
|
||||
);
|
||||
|
||||
if (isNaN(scale) || !isFinite(scale) || scale <= 0) return;
|
||||
|
||||
svgElement.transition().duration(750).call(
|
||||
zoomBehavior.transform,
|
||||
d3.zoomIdentity
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
|
||||
namespace NexusReader.Web.Client.Handlers;
|
||||
|
||||
@@ -48,9 +49,14 @@ public class AuthenticationHeaderHandler : DelegatingHandler
|
||||
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var response = await base.SendAsync(request, cancellationToken);
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new
|
||||
builder.Services.AddSingleton<IBookStorageService>(new ThrowingBookStorageService());
|
||||
builder.Services.AddSingleton<IEbookRepository>(new ThrowingEbookRepository());
|
||||
builder.Services.AddSingleton<IQuizResultRepository>(new ThrowingQuizResultRepository());
|
||||
builder.Services.AddSingleton<IConceptsMapReadRepository>(new ThrowingConceptsMapReadRepository());
|
||||
builder.Services.AddSingleton<ISyncBroadcaster>(new ThrowingSyncBroadcaster());
|
||||
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 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 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)
|
||||
=> throw new NotSupportedException("EPUB text extraction is not supported in the WASM client.");
|
||||
}
|
||||
|
||||
|
||||
@@ -252,6 +252,35 @@ app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
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.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||
|
||||
@@ -489,7 +518,7 @@ app.MapGet("/identity/login/google", (string? returnUrl) =>
|
||||
var properties = new AuthenticationProperties
|
||||
{
|
||||
RedirectUri = "/identity/callback/google",
|
||||
Items = { { "returnUrl", returnUrl ?? "/" } }
|
||||
Items = { { "returnUrl", string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl } }
|
||||
};
|
||||
return Results.Challenge(properties, new[] { "Google" });
|
||||
});
|
||||
@@ -520,7 +549,13 @@ app.MapGet("/identity/callback/google", async (
|
||||
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);
|
||||
if (email != null)
|
||||
{
|
||||
@@ -563,7 +598,7 @@ app.MapPost("/account/login-form", async (
|
||||
if (result.Succeeded)
|
||||
{
|
||||
logger.LogInformation("User logged in: {Email}", email);
|
||||
return Results.Redirect(returnUrl ?? "/");
|
||||
return Results.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
|
||||
}
|
||||
|
||||
var error = result.IsLockedOut ? "LockedOut" : "InvalidCredentials";
|
||||
|
||||
@@ -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>
|
||||
<ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\src\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
</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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user