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
|
*.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/
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user