feat(infra): Docker-compose configuration and environment-specific security guards for Beta deployment to Test environment #56

Merged
mjasin merged 12 commits from infra/beta-deploy-test into develop 2026-06-01 17:17:46 +00:00
54 changed files with 4181 additions and 282 deletions
+11 -1
View File
@@ -30,4 +30,14 @@ When conducting or receiving a code review for NexusReader, ensure the implement
- [ ] **AI Prompts**: Ensure changes to AI logic do not bypass the `PromptRegistry` or token estimation limits defined in `AiSettings`. - [ ] **AI Prompts**: Ensure changes to AI logic do not bypass the `PromptRegistry` or token estimation limits defined in `AiSettings`.
## 6. Code Review Comments ## 6. Code Review Comments
- [ ] **Specific Linking**: Comments should be linked to specific code. Try to avoid general comments about the entire pull request.
### 6.1 Posting Comments
- [ ] **Code-Linked Comments**: Every review comment **must** be anchored to a specific file and line range using the Gitea inline comment API (`path` + `new_line_num`/`old_line_num`). Free-floating general comments are only acceptable for summary notes that cannot be attributed to a single location.
- [ ] **Severity Prefix**: Prefix each comment with its severity so the author can prioritize: `🔴 Blocking`, `🟡 Design/Architecture`, or `🟢 Minor/Suggestion`.
- [ ] **Actionable Guidance**: Each comment must include a concrete, actionable suggestion — not just a description of the problem. Where applicable, provide a corrected code snippet.
### 6.2 Resolving Comments (Author Responsibility)
- [ ] **Reply Before Resolving**: When a review comment has been addressed, the author **must** reply to the specific thread explaining *how* the issue was resolved (e.g., commit SHA, approach taken, or a reasoned rejection with justification). Do not close a thread without a reply.
- [ ] **Link to Fix**: If the resolution is a code change, include the commit SHA or a reference to the changed line in the reply (e.g., `Fixed in abc1234 — moved the guard before CTS allocation`).
- [ ] **Close Only After Reply**: Mark a thread as **Resolved** only after posting the reply. A thread with no reply must remain open, even if the underlying code has changed.
- [ ] **Rejection Must Be Justified**: If the author disagrees with a comment and chooses not to act on it, they must reply with a clear technical justification. The reviewer then decides whether to accept the reasoning and close the thread, or escalate it.
+41
View File
@@ -0,0 +1,41 @@
# ===================================================================
# NexusReader — Test Environment Variables
# ===================================================================
# Copy this file to `.env` and fill in the values before deployment:
# cp .env.test.template .env
#
# Then deploy with:
# docker compose -f docker-compose.test.yml up -d --build
# ===================================================================
# === PostgreSQL ===
POSTGRES_USER=nexus_user
POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
POSTGRES_DB=nexus_test_db
POSTGRES_PORT=5433
# === Neo4j ===
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
# === Qdrant (leave empty to disable API key auth) ===
QDRANT_API_KEY=
# === Web App ===
WEB_PORT=5050
# === Google OAuth (placeholder for test) ===
GOOGLE_CLIENT_ID=placeholder
GOOGLE_CLIENT_SECRET=placeholder
# === Gemini AI (placeholder for test) ===
GOOGLE_AI_API_KEY=placeholder
# === Admin Seed Password ===
NEXUS_ADMIN_PASSWORD=CHANGE_ME
# === Non-standard ports for auxiliary services ===
QDRANT_HTTP_PORT=6343
QDRANT_GRPC_PORT=6344
NEO4J_HTTP_PORT=7484
NEO4J_BOLT_PORT=7697
+1
View File
@@ -29,6 +29,7 @@ Thumbs.db
*.epub *.epub
.fake .fake
.env
src/NexusReader.Web/nexus.db src/NexusReader.Web/nexus.db
src/NexusReader.Web/wwwroot/covers/ src/NexusReader.Web/wwwroot/covers/
src/NexusReader.Web/wwwroot/uploads/ src/NexusReader.Web/wwwroot/uploads/
+5
View File
@@ -2,12 +2,17 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src WORKDIR /src
# Copy props files and solution-level configurations for Central Package Management
COPY ["Directory.Build.props", "./"]
COPY ["Directory.Packages.props", "./"]
# Copy csproj files and restore dependencies # Copy csproj files and restore dependencies
COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"] COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"]
COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"] COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"]
COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"] COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"]
COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"] COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"]
COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"] COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"]
COPY ["src/NexusReader.Data/NexusReader.Data.csproj", "src/NexusReader.Data/"]
COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"] COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"]
RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj" RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj"
+97
View File
@@ -0,0 +1,97 @@
services:
db:
image: pgvector/pgvector:pg17
container_name: nexus-db-test
environment:
POSTGRES_USER: ${POSTGRES_USER:-nexus_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_DB: ${POSTGRES_DB:-nexus_test_db}
ports:
- "${POSTGRES_PORT:-5433}:5432"
volumes:
- pgdata_test:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nexus_user} -d ${POSTGRES_DB:-nexus_test_db}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- nexus-test
restart: unless-stopped
web:
build:
context: .
dockerfile: Dockerfile
container_name: nexus-web-test
ports:
- "${WEB_PORT:-5050}:5000"
mjasin marked this conversation as resolved
Review

🟡 Design — Hardcoded internal port for Qdrant gRPC in ConnectionStrings__QdrantConnection

The web container connects to Qdrant via http://qdrant:6334 (internal container port), which is correct for inter-service Docker networking. However, the gRPC port 6334 is hardcoded in the environment variable string rather than being derived from the QDRANT_GRPC_PORT variable. This is fine for the internal network but is inconsistent with how other connection strings are parameterized.

Consider making this explicit with a comment noting this is the internal container-to-container port, not the host-mapped port, so future maintainers don't confuse it with ${QDRANT_GRPC_PORT}:

# Internal Docker network ports (NOT the host-mapped ports from QDRANT_HTTP_PORT/QDRANT_GRPC_PORT)
- ConnectionStrings__QdrantConnection=http://qdrant:6334
🟡 **Design — Hardcoded internal port for Qdrant gRPC in `ConnectionStrings__QdrantConnection`** The web container connects to Qdrant via `http://qdrant:6334` (internal container port), which is correct for inter-service Docker networking. However, the gRPC port `6334` is hardcoded in the environment variable string rather than being derived from the `QDRANT_GRPC_PORT` variable. This is fine for the *internal* network but is inconsistent with how other connection strings are parameterized. Consider making this explicit with a comment noting this is the *internal* container-to-container port, not the host-mapped port, so future maintainers don't confuse it with `${QDRANT_GRPC_PORT}`: ```yaml # Internal Docker network ports (NOT the host-mapped ports from QDRANT_HTTP_PORT/QDRANT_GRPC_PORT) - ConnectionStrings__QdrantConnection=http://qdrant:6334 ```
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:?NEXUS_ADMIN_PASSWORD is required}
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"
mjasin marked this conversation as resolved
Review

🟡 Design — Qdrant healthcheck uses raw TCP socket; brittle and platform-dependent

The current healthcheck bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333' depends on bash's /dev/tcp pseudo-device, which is not available in all minimal Docker images (e.g., Alpine-based). If the Qdrant image is ever updated to an Alpine base, this will silently fail, causing the web service to hang at startup.

The Qdrant HTTP API exposes a /healthz endpoint. Use that instead:

healthcheck:
  test: ["CMD-SHELL", "curl -sf http://localhost:6333/healthz || exit 1"]
  interval: 5s
  timeout: 5s
  retries: 5
  start_period: 10s
🟡 **Design — Qdrant healthcheck uses raw TCP socket; brittle and platform-dependent** The current healthcheck `bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'` depends on bash's `/dev/tcp` pseudo-device, which is not available in all minimal Docker images (e.g., Alpine-based). If the Qdrant image is ever updated to an Alpine base, this will silently fail, causing the `web` service to hang at startup. The Qdrant HTTP API exposes a `/healthz` endpoint. Use that instead: ```yaml healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:6333/healthz || exit 1"] interval: 5s timeout: 5s retries: 5 start_period: 10s ```
Review

🟡 Design — Still unresolved. Qdrant healthcheck uses bash /dev/tcp.

The healthcheck remains bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'. This is platform-dependent and not supported on Alpine-based images. Prefer the /healthz HTTP endpoint:

healthcheck:
  test: ["CMD-SHELL", "curl -sf http://localhost:6333/healthz || exit 1"]
  interval: 5s
  timeout: 5s
  retries: 5
  start_period: 10s
🟡 **Design — Still unresolved. Qdrant healthcheck uses bash `/dev/tcp`.** The healthcheck remains `bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'`. This is platform-dependent and not supported on Alpine-based images. Prefer the `/healthz` HTTP endpoint: ```yaml healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:6333/healthz || exit 1"] interval: 5s timeout: 5s retries: 5 start_period: 10s ```
volumes:
- qdrant_test_data:/qdrant/storage
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:6333/healthz || exit 1"]
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
@@ -20,4 +20,17 @@ public interface IEpubReader
int chapterIndex, int chapterIndex,
string? userId = null, string? userId = null,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a resource (like an image) from the EPUB as a byte array.
/// </summary>
/// <param name="ebookId">The unique ID of the ebook to read.</param>
/// <param name="resourcePath">The path of the resource within the EPUB archive.</param>
/// <param name="userId">The authenticated user's ID (used for tenant isolation).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Result<byte[]>> GetEpubResourceAsync(
Guid ebookId,
string resourcePath,
string? userId = null,
CancellationToken cancellationToken = default);
} }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using System; using System;
using System.Linq; using System.Linq;
@@ -16,6 +17,7 @@ public static class DbInitializer
using var scope = serviceProvider.CreateScope(); using var scope = serviceProvider.CreateScope();
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<NexusUser>>(); var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<NexusUser>>();
var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>(); var dbContextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
var configuration = scope.ServiceProvider.GetService<IConfiguration>();
using var dbContext = await dbContextFactory.CreateDbContextAsync(); using var dbContext = await dbContextFactory.CreateDbContextAsync();
try try
@@ -68,7 +70,31 @@ public static class DbInitializer
SecurityStamp = Guid.NewGuid().ToString() SecurityStamp = Guid.NewGuid().ToString()
}; };
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, "Admin123!"); var adminPassword = configuration?["Nexus:AdminPassword"]
mjasin marked this conversation as resolved
Review

🔴 Blocking — Triple-layer fallback exposes hardcoded default credential in production

The current fallback chain Nexus:AdminPasswordNEXUS_ADMIN_PASSWORD (from IConfiguration) → Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD")"Admin123!" is dangerous. If NEXUS_ADMIN_PASSWORD is not injected at container startup (e.g. operator error, mis-spelled var), the process will silently seed the admin account with "Admin123!" without any warning — a critical security regression in Test/Prod.

The Environment.GetEnvironmentVariable call is also redundant because IConfiguration in ASP.NET Core already reads environment variables. You only need two keys, and the final fallback must only be allowed in Development.

Suggested fix:

var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production";
var adminPassword = configuration?["Nexus:AdminPassword"]
                    ?? configuration?["NEXUS_ADMIN_PASSWORD"];

if (string.IsNullOrWhiteSpace(adminPassword))
{
    if (environment == "Development")
    {
        adminPassword = "Admin123!";
    }
    else
    {
        throw new InvalidOperationException(
            "NEXUS_ADMIN_PASSWORD must be set for non-Development environments.");
    }
}
🔴 **Blocking — Triple-layer fallback exposes hardcoded default credential in production** The current fallback chain `Nexus:AdminPassword` → `NEXUS_ADMIN_PASSWORD` (from `IConfiguration`) → `Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD")` → `"Admin123!"` is dangerous. If `NEXUS_ADMIN_PASSWORD` is not injected at container startup (e.g. operator error, mis-spelled var), the process will silently seed the admin account with `"Admin123!"` without any warning — a critical security regression in Test/Prod. The `Environment.GetEnvironmentVariable` call is also redundant because `IConfiguration` in ASP.NET Core already reads environment variables. You only need **two** keys, and the final fallback must only be allowed in `Development`. **Suggested fix:** ```csharp var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"; var adminPassword = configuration?["Nexus:AdminPassword"] ?? configuration?["NEXUS_ADMIN_PASSWORD"]; if (string.IsNullOrWhiteSpace(adminPassword)) { if (environment == "Development") { adminPassword = "Admin123!"; } else { throw new InvalidOperationException( "NEXUS_ADMIN_PASSWORD must be set for non-Development environments."); } } ```
Review

🔴 Blocking — Still unresolved. Silent credential fallback in non-Development environments.

This code is identical to the original. The "Admin123!" default will still be reached silently in Test/Production if NEXUS_ADMIN_PASSWORD is absent (e.g. from a typo in the .env file or a missing Docker secret). The docker-compose.test.yml does enforce ${NEXUS_ADMIN_PASSWORD:?...} at the compose level, but this C# fallback provides a false safety net that can be triggered by non-compose deployments (e.g., direct kubectl apply).

Please add the environment check before applying the fallback:

var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production";
var adminPassword = configuration?["Nexus:AdminPassword"]
                    ?? configuration?["NEXUS_ADMIN_PASSWORD"];

if (string.IsNullOrWhiteSpace(adminPassword))
{
    if (environment == "Development")
    {
        adminPassword = "Admin123!";
    }
    else
    {
        throw new InvalidOperationException(
            "NEXUS_ADMIN_PASSWORD must be configured for non-Development environments. Aborting startup.");
    }
}

This also eliminates the redundant Environment.GetEnvironmentVariable call, since IConfiguration already reads env vars.

🔴 **Blocking — Still unresolved. Silent credential fallback in non-Development environments.** This code is identical to the original. The `"Admin123!"` default will still be reached silently in Test/Production if `NEXUS_ADMIN_PASSWORD` is absent (e.g. from a typo in the `.env` file or a missing Docker secret). The `docker-compose.test.yml` does enforce `${NEXUS_ADMIN_PASSWORD:?...}` at the compose level, but this C# fallback provides a false safety net that can be triggered by non-compose deployments (e.g., direct `kubectl apply`). Please add the environment check before applying the fallback: ```csharp var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"; var adminPassword = configuration?["Nexus:AdminPassword"] ?? configuration?["NEXUS_ADMIN_PASSWORD"]; if (string.IsNullOrWhiteSpace(adminPassword)) { if (environment == "Development") { adminPassword = "Admin123!"; } else { throw new InvalidOperationException( "NEXUS_ADMIN_PASSWORD must be configured for non-Development environments. Aborting startup."); } } ``` This also eliminates the redundant `Environment.GetEnvironmentVariable` call, since `IConfiguration` already reads env vars.
?? configuration?["NEXUS_ADMIN_PASSWORD"]
?? Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD");
var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
?? "Development";
var isDevelopment = string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(adminPassword))
{
if (!isDevelopment)
{
throw new InvalidOperationException(
"CRITICAL SECURITY ERROR: Admin password is NOT configured! " +
"In non-Development environments (e.g. Test/Production), the admin password must be explicitly set " +
"via configuration ('Nexus:AdminPassword' or 'NEXUS_ADMIN_PASSWORD') or environment variables. " +
"Seeding aborted to prevent insecure credentials fallback.");
}
Console.WriteLine("[Seeder] WARNING: Admin password is not set. Falling back to default weak password 'Admin123!' in Development environment.");
adminPassword = "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))
@@ -18,6 +18,16 @@ public class EpubReaderService : IEpubReader
private readonly ILogger<EpubReaderService> _logger; private readonly ILogger<EpubReaderService> _logger;
private const int WordThreshold = 1000; private const int WordThreshold = 1000;
private static readonly Regex ImageTagRegex = new(@"<img\b(?<before>[^>]*?\bsrc=[""'])(?<src>[^""']*?)(?<after>[""'][^>]*?>)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex BodyMatchRegex = new(@"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex ParagraphMatchRegex = new(@"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>|<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex StyleScriptRegex = new(@"<(style|script)\b[^>]*>.*?</\1>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex WhitelistTagsRegex = new(@"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr|img)\b)[^>]+>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex StripAttributesRegex = new(@"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ImgTagSanitizerRegex = new(@"<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex SrcAttributeRegex = new(@"\bsrc=[""'](?<src>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex AltAttributeRegex = new(@"\balt=[""'](?<alt>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public EpubReaderService( public EpubReaderService(
IDbContextFactory<AppDbContext> dbContextFactory, IDbContextFactory<AppDbContext> dbContextFactory,
ILogger<EpubReaderService> logger) ILogger<EpubReaderService> logger)
@@ -80,6 +90,9 @@ public class EpubReaderService : IEpubReader
var chapterContent = await chapterRef.ReadContentAsTextAsync(); var chapterContent = await chapterRef.ReadContentAsTextAsync();
// Rewrite relative image src URLs to use the server-side API endpoint
chapterContent = RewriteImageUrls(chapterContent, ebookId, chapterRef.FilePath);
// 3. Build content blocks // 3. Build content blocks
var blocks = new List<ContentBlock>(); var blocks = new List<ContentBlock>();
int totalWordCount = 0; int totalWordCount = 0;
@@ -142,13 +155,150 @@ public class EpubReaderService : IEpubReader
return null; return null;
} }
/// <inheritdoc />
public async Task<Result<byte[]>> GetEpubResourceAsync(
Guid ebookId,
string resourcePath,
string? userId = null,
CancellationToken cancellationToken = default)
{
try
{
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var ebook = await context.Ebooks
.AsNoTracking()
.FirstOrDefaultAsync(
e => e.Id == ebookId && (userId == null || e.UserId == userId),
cancellationToken);
if (ebook == null)
{
return Result.Fail($"Ebook '{ebookId}' not found.");
}
var fullPath = ResolvePath(ebook.FilePath);
if (fullPath == null || !File.Exists(fullPath))
{
return Result.Fail("EPUB file not found.");
}
using var bookRef = await EpubReader.OpenBookAsync(fullPath);
var decodedPath = System.Net.WebUtility.UrlDecode(resourcePath);
if (decodedPath.Contains("..") || decodedPath.Contains(":") || decodedPath.StartsWith("/") || decodedPath.StartsWith("\\"))
{
return Result.Fail("Invalid resource path.");
}
decodedPath = decodedPath.Replace('\\', '/').TrimStart('/');
EpubLocalContentFileRef? targetFile = null;
if (bookRef.Content?.AllFiles?.Local != null)
{
foreach (var file in bookRef.Content.AllFiles.Local)
{
var filePath = file.FilePath?.Replace('\\', '/').TrimStart('/') ?? "";
var fileKey = file.Key?.Replace('\\', '/').TrimStart('/') ?? "";
if (filePath.Equals(decodedPath, StringComparison.OrdinalIgnoreCase) ||
fileKey.Equals(decodedPath, StringComparison.OrdinalIgnoreCase))
{
targetFile = file;
break;
}
}
}
if (targetFile != null)
{
if (targetFile is EpubLocalByteContentFileRef byteFile)
{
byte[] bytes = await byteFile.ReadContentAsync();
return Result.Ok(bytes);
}
else if (targetFile is EpubLocalTextContentFileRef textFile)
{
string text = await textFile.ReadContentAsync();
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(text);
return Result.Ok(bytes);
}
}
return Result.Fail($"Resource '{resourcePath}' not found in EPUB.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve EPUB resource '{ResourcePath}' for ebook {EbookId}.", resourcePath, ebookId);
return Result.Fail(new Error($"Failed to retrieve EPUB resource: {ex.Message}").CausedBy(ex));
}
}
private static string RewriteImageUrls(string html, Guid ebookId, string chapterPath)
{
if (string.IsNullOrEmpty(html)) return html;
return ImageTagRegex.Replace(html, match =>
{
var rawSrc = match.Groups["src"].Value;
if (rawSrc.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase))
{
return ""; // Completely block script execution in image src
}
if (rawSrc.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
rawSrc.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
rawSrc.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
return match.Value;
}
var resolvedPath = ResolveRelativePath(chapterPath, rawSrc);
var rewrittenSrc = $"/api/epub/{ebookId}/resource?path={System.Net.WebUtility.UrlEncode(resolvedPath)}";
return $"{match.Groups["before"].Value}{rewrittenSrc}{match.Groups["after"].Value}";
});
}
private static string ResolveRelativePath(string basePath, string relativePath)
{
if (string.IsNullOrEmpty(relativePath)) return string.Empty;
var decodedRelative = System.Net.WebUtility.UrlDecode(relativePath);
var baseDir = Path.GetDirectoryName(basePath) ?? "";
baseDir = baseDir.Replace('\\', '/');
var combined = Path.Combine(baseDir, decodedRelative).Replace('\\', '/');
var segments = combined.Split('/');
var stack = new Stack<string>();
foreach (var segment in segments)
{
if (segment == "." || string.IsNullOrEmpty(segment))
{
continue;
}
if (segment == "..")
{
if (stack.Count > 0)
{
stack.Pop();
}
}
else
{
stack.Push(segment);
}
}
return string.Join("/", stack.Reverse());
}
private static List<string> ExtractParagraphs(string html) private static List<string> ExtractParagraphs(string html)
{ {
var bodyMatch = Regex.Match(html, @"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline); var bodyMatch = BodyMatchRegex.Match(html);
var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html; var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html;
var paragraphs = new List<string>(); var paragraphs = new List<string>();
var matches = Regex.Matches(content, @"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline); var matches = ParagraphMatchRegex.Matches(content);
foreach (Match match in matches) foreach (Match match in matches)
{ {
@@ -165,9 +315,20 @@ public class EpubReaderService : IEpubReader
private static string SanitizeParagraph(string html) private static string SanitizeParagraph(string html)
{ {
var clean = Regex.Replace(html, @"<(style|script)\b[^>]*>.*?</\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline); var clean = StyleScriptRegex.Replace(html, "");
clean = Regex.Replace(clean, @"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b)[^>]+>", "", RegexOptions.IgnoreCase); clean = WhitelistTagsRegex.Replace(clean, "");
clean = Regex.Replace(clean, @"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", "<$1>", RegexOptions.IgnoreCase); clean = StripAttributesRegex.Replace(clean, "<$1>");
// Securely sanitize img tags by keeping ONLY src and alt attributes to prevent XSS (onerror, onload, style, etc.)
clean = ImgTagSanitizerRegex.Replace(clean, m =>
{
var srcMatch = SrcAttributeRegex.Match(m.Value);
var altMatch = AltAttributeRegex.Match(m.Value);
var srcAttr = srcMatch.Success ? $" src=\"{srcMatch.Groups["src"].Value}\"" : "";
var altAttr = altMatch.Success ? $" alt=\"{altMatch.Groups["alt"].Value}\"" : "";
return $"<img{srcAttr}{altAttr} />";
});
clean = System.Net.WebUtility.HtmlDecode(clean); clean = System.Net.WebUtility.HtmlDecode(clean);
return clean.Trim(); return clean.Trim();
} }
@@ -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);
+5
View File
@@ -63,12 +63,17 @@ public static class MauiProgram
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI")); builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
// UI State // UI State
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
builder.Services.AddSingleton(featureSettings);
builder.Services.AddScoped<IThemeService, ThemeService>(); builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>(); builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddScoped<IQuizStateService, QuizStateService>(); builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>(); builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>(); builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>(); builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
builder.Services.AddScoped<KnowledgeCoordinator>(); builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>(); builder.Services.AddScoped<ISyncService, SyncService>();
builder.Services.AddScoped<IIdentityService, IdentityService>(); builder.Services.AddScoped<IIdentityService, IdentityService>();
@@ -10,6 +10,7 @@
<line x1="8" y1="2" x2="8" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <line x1="8" y1="2" x2="8" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="16" y1="6" x2="16" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <line x1="16" y1="6" x2="16" y2="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "share":
case "share-2": case "share-2":
<circle cx="18" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <circle cx="18" cy="5" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <circle cx="6" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
@@ -45,6 +46,7 @@
<line x1="3" y1="9" x2="21" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <line x1="3" y1="9" x2="21" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="9" y1="21" x2="9" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <line x1="9" y1="21" x2="9" y2="9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "book":
case "book-open": case "book-open":
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
@@ -86,6 +88,17 @@
case "log-out": case "log-out":
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break; break;
case "chevron-left":
<polyline points="15 18 9 12 15 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "chevron-right":
<polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
case "x":
case "close":
<line x1="18" y1="6" x2="6" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="6" y1="6" x2="18" y2="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
break;
default: default:
<!-- Fallback circle --> <!-- Fallback circle -->
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

@@ -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();
}
}
@@ -1,4 +1,5 @@
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Models
@using NexusReader.Application.DTOs.AI @using NexusReader.Application.DTOs.AI
@inject KnowledgeCoordinator Coordinator @inject KnowledgeCoordinator Coordinator
@inject IReaderInteractionService InteractionService @inject IReaderInteractionService InteractionService
@@ -27,14 +27,21 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="parsing-state shimmer" style="@(IsParsing && !IsIndexing ? "display:flex;" : "display:none;")"> <div class="parsing-state shimmer" style="@(IsParsing ? "display:flex;" : "display:none;")">
<div class="shimmer-content"> <div class="shimmer-content">
<div class="spinner"></div> <div class="spinner"></div>
<p>Scanning metadata...</p> <p>Scanning metadata...</p>
</div> </div>
</div> </div>
<div class="verification-state" style="@(IsVerifying && !IsParsing && !IsIndexing ? "display:flex;" : "display:none;")"> <div class="ingesting-state shimmer" style="@(IsIngesting ? "display:flex;" : "display:none;")">
<div class="shimmer-content">
<div class="spinner"></div>
<p>Saving book to library...</p>
</div>
</div>
<div class="verification-state" style="@(IsVerifying ? "display:flex;" : "display:none;")">
@if (Metadata != null) @if (Metadata != null)
{ {
<div class="verification-layout"> <div class="verification-layout">
@@ -79,7 +86,7 @@
</div> </div>
<div class="upload-state @(_isDragging ? "drag-over" : "")" <div class="upload-state @(_isDragging ? "drag-over" : "")"
style="@(!IsParsing && !IsVerifying && !IsIndexing ? "display:flex;" : "display:none;")" style="@(IsUploadActive ? "display:flex;" : "display:none;")"
@ondragenter="OnDragEnter" @ondragenter="OnDragEnter"
@ondragleave="OnDragLeave"> @ondragleave="OnDragLeave">
<div class="drop-zone"> <div class="drop-zone">
@@ -143,6 +150,7 @@
private string? ErrorMessage { get; set; } private string? ErrorMessage { get; set; }
private byte[]? _epubBytes; private byte[]? _epubBytes;
private bool _disposed; private bool _disposed;
private bool IsUploadActive => !IsParsing && !IsVerifying && !IsIngesting && !IsIndexing;
// Allow up to 50 MB // Allow up to 50 MB
private const long MaxFileSize = 50 * 1024 * 1024; private const long MaxFileSize = 50 * 1024 * 1024;
@@ -163,6 +171,8 @@
if (!_disposed) if (!_disposed)
{ {
// Dispatch the state change to the Blazor synchronization context
// because this event is triggered asynchronously from a SignalR / WebSocket background thread.
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -177,6 +187,8 @@
if (IngestedBookId != Guid.Empty) if (IngestedBookId != Guid.Empty)
{ {
var bookId = IngestedBookId; var bookId = IngestedBookId;
// Dispatch UI updates and navigation back to the Blazor thread
// to avoid thread affinity issues and potential UI lockups in MAUI/Web applications.
await InvokeAsync(async () => { await InvokeAsync(async () => {
if (_disposed) return; if (_disposed) return;
await CloseModal(); await CloseModal();
@@ -118,8 +118,9 @@
z-index: 10; z-index: 10;
} }
/* Parsing State */ /* Parsing and Ingesting States */
.parsing-state { .parsing-state,
.ingesting-state {
flex: 1; flex: 1;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -158,7 +159,8 @@
filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.3)); filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.3));
} }
.parsing-state p { .parsing-state p,
.ingesting-state p {
color: var(--nexus-text); color: var(--nexus-text);
font-family: var(--nexus-font-mono, monospace); font-family: var(--nexus-font-mono, monospace);
font-size: 0.9rem; font-size: 0.9rem;
@@ -371,10 +373,11 @@
position: absolute; position: absolute;
width: 20px; width: 20px;
height: 20px; height: 20px;
border: 2px solid rgba(0, 0, 0, 0.1); border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: #000; border-top-color: var(--nexus-neon, #00ffaa);
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
filter: drop-shadow(0 0 4px var(--nexus-neon, #00ffaa));
} }
/* Indexing State */ /* Indexing State */
@@ -0,0 +1,367 @@
@using NexusReader.Application.DTOs.AI
@using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.DTOs.User
@using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Models
@using System.Net.Http.Json
@namespace NexusReader.UI.Shared.Components.Organisms
@inject HttpClient Http
@inject IKnowledgeService KnowledgeService
@inject IReaderNavigationService NavigationService
<div class="global-intelligence-sheet @(IsOpen ? "is-open" : "")">
<div class="sheet-backdrop" @onclick="HandleClose"></div>
<div class="sheet-content">
<div class="sheet-drag-handle"></div>
<header class="sheet-header">
<div class="header-main">
<div class="ai-avatar-badge">
<NexusIcon Name="robot" Size="20" Class="neon-glow" />
</div>
<div class="header-info">
<h3>Asystent AI Nexus</h3>
<p class="subtitle">Zadawaj pytania do swojej biblioteki</p>
</div>
</div>
<button class="close-btn" @onclick="HandleClose" aria-label="Zamknij">
<NexusIcon Name="close" Size="20" />
</button>
</header>
<div class="sheet-body">
<div class="chat-thread" id="mobile-chat-thread">
@if (_chatMessages.Count == 0)
{
<div class="welcome-container">
<div class="welcome-glow-icon">
<NexusIcon Name="brain" Size="32" Class="neon-glow" />
</div>
<h4>Zadaj pytanie asystentowi</h4>
<p>KM-RAG przeszukuje całą treść książki, wyciąga semantyczne powiązania i generuje precyzyjne odpowiedzi wraz z przypisami źródłowymi.</p>
</div>
}
else
{
@foreach (var message in _chatMessages)
{
<div class="message-row @(message.Sender == "User" ? "user-row" : "ai-row")" @key="message.Id">
<div class="message-avatar">
@if (message.Sender == "User")
{
<NexusIcon Name="user" Size="16" />
}
else
{
<NexusIcon Name="robot" Size="16" />
}
</div>
<div class="message-bubble @(message.Sender == "User" ? "user-bubble" : "ai-bubble")">
<div class="message-meta">
<span class="sender-name">@(message.Sender == "User" ? "Ty" : "Asystent")</span>
<span class="message-time">@message.Timestamp.ToString("HH:mm")</span>
</div>
<div class="message-text">
@foreach (var segment in message.Segments)
{
@if (segment.IsCitation)
{
<span class="nexus-mobile-citation" @onclick="() => HandleCitationClick(segment.CitationId)">
[@segment.CitationId]
</span>
}
else
{
@RenderMarkdown(segment.Text)
}
}
</div>
</div>
</div>
}
@if (_isLoading)
{
<div class="message-row ai-row">
<div class="message-avatar">
<NexusIcon Name="robot" Size="16" />
</div>
<div class="message-bubble ai-bubble pending-bubble">
<div class="message-meta">
<span class="sender-name">Asystent</span>
<span class="message-time">Generowanie...</span>
</div>
<div class="message-text">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
<span class="loading-label">Analiza grafu pojęć...</span>
</div>
</div>
</div>
}
}
</div>
</div>
<footer class="sheet-footer">
<div class="scope-indicator">
<NexusIcon Name="map" Size="12" />
<span>Obszar: <strong>@(string.IsNullOrEmpty(_activeBookTitle) ? "Cała biblioteka" : _activeBookTitle)</strong></span>
</div>
<div class="input-container">
<input type="text"
class="nexus-mobile-input"
placeholder="Zadaj pytanie..."
@bind="_question"
@bind:event="oninput"
@onkeyup="HandleKeyUp"
disabled="@_isLoading" />
<button class="send-btn @(string.IsNullOrWhiteSpace(_question) || _isLoading ? "disabled" : "")"
disabled="@(string.IsNullOrWhiteSpace(_question) || _isLoading)"
@onclick="AskQuestionAsync">
@if (_isLoading)
{
<div class="btn-spinner"></div>
}
else
{
<NexusIcon Name="send" Size="18" />
}
</button>
</div>
</footer>
@if (_selectedCitation != null)
{
<div class="citation-modal-overlay" @onclick="CloseCitationModal">
<div class="citation-modal glass-panel" @onclick:stopPropagation>
<div class="modal-header">
<span class="book-title"><NexusIcon Name="map" Size="14" /> @_selectedCitation.SourceBook</span>
<button class="close-btn" @onclick="CloseCitationModal" aria-label="Close">
<NexusIcon Name="close" Size="16" />
</button>
</div>
<div class="modal-body">
@if (!string.IsNullOrEmpty(_selectedCitation.Author))
{
<p class="citation-author"><strong>Autor:</strong> @_selectedCitation.Author</p>
}
@if (_selectedCitation.PageNumber.HasValue)
{
<p class="citation-page"><strong>Strona:</strong> @_selectedCitation.PageNumber.Value</p>
}
<p class="citation-snippet">"@_selectedCitation.Snippet"</p>
</div>
<div class="modal-footer">
<button class="btn-nexus" @onclick="CloseCitationModal">Zamknij</button>
</div>
</div>
</div>
}
</div>
</div>
@code {
private static readonly System.Text.RegularExpressions.Regex CitationRegex = new(
@"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex BoldRegex = new(
@"\*\*(.*?)\*\*",
System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex ItalicRegex = new(
@"\*(.*?)\*",
System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex CodeBlockRegex = new(
@"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```",
System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex InlineCodeRegex = new(
@"`(.*?)`",
System.Text.RegularExpressions.RegexOptions.Compiled);
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public string? TenantId { get; set; }
private string _question = string.Empty;
private bool _isLoading;
private string _activeBookTitle = string.Empty;
private List<ChatMessage> _chatMessages = new();
private CitationDto? _selectedCitation;
protected override async Task OnParametersSetAsync()
{
if (IsOpen && string.IsNullOrEmpty(_activeBookTitle) && NavigationService.CurrentEbookId != Guid.Empty)
{
_activeBookTitle = NavigationService.ChapterTitle ?? "Aktywna książka";
}
}
private async Task HandleClose()
{
if (OnClose.HasDelegate)
{
await OnClose.InvokeAsync();
}
}
private async Task HandleKeyUp(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_question) && !_isLoading)
{
await AskQuestionAsync();
}
}
private void HandleCitationClick(string citationId)
{
_selectedCitation = _chatMessages
.SelectMany(m => m.Citations)
.FirstOrDefault(c => c.CitationId.Equals(citationId, StringComparison.OrdinalIgnoreCase))
?? new CitationDto
{
CitationId = citationId,
SourceBook = "Grounded Document Chunk",
mjasin marked this conversation as resolved
Review

🟡 Design/Architecture — AuthStateProvider injected directly into UI Organism; bypasses established service pattern

This component injects AuthenticationStateProvider and calls GetAuthenticationStateAsync() directly to extract tenantId. The established pattern in this codebase is to read claims via a server-side service (e.g., IIdentityService) or a pre-populated UserInfo model via PersistentComponentState. Calling AuthStateProvider directly in a leaf Organism tightly couples it to the auth subsystem and makes it harder to test.

Refactor to accept tenantId as a [Parameter] from the parent ReaderLayout.razor, which already has access to the auth state. This keeps the Organism a pure presentation component.

🟡 **Design/Architecture — `AuthStateProvider` injected directly into UI Organism; bypasses established service pattern** This component injects `AuthenticationStateProvider` and calls `GetAuthenticationStateAsync()` directly to extract `tenantId`. The established pattern in this codebase is to read claims via a server-side service (e.g., `IIdentityService`) or a pre-populated `UserInfo` model via `PersistentComponentState`. Calling `AuthStateProvider` directly in a leaf Organism tightly couples it to the auth subsystem and makes it harder to test. Refactor to accept `tenantId` as a `[Parameter]` from the parent `ReaderLayout.razor`, which already has access to the auth state. This keeps the Organism a pure presentation component.
Snippet = "Context snippet retrieved from vector search node."
};
}
private void CloseCitationModal()
{
_selectedCitation = null;
}
private async Task AskQuestionAsync()
{
if (string.IsNullOrWhiteSpace(_question) || _isLoading) return;
var userQuestion = _question;
_question = string.Empty;
_isLoading = true;
_chatMessages.Add(new ChatMessage
{
Sender = "User",
Text = userQuestion,
Segments = new List<ResponseSegment> { new ResponseSegment { Text = userQuestion, IsCitation = false } }
});
StateHasChanged();
try
{
Guid? ebookId = null;
if (NavigationService.CurrentEbookId != Guid.Empty)
{
mjasin marked this conversation as resolved
Review

🔴 Blocking — Regex compiled on every ParseSegments call; violates static compiled-regex rule

A new Regex(...) is created inside ParseSegments on every invocation. This creates a new regex engine instance and JIT-compiles it on each call — a known performance regression and a violation of the project's static compiled-regex standard (see EpubReaderService.cs for the correct pattern).

Suggested fix: Move the regex to a private static readonly field at the top of the component's @code block:

private static readonly Regex CitationRegex = new(
    @"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
    RegexOptions.IgnoreCase | RegexOptions.Compiled);

Then use CitationRegex.Matches(text) in ParseSegments.

🔴 **Blocking — Regex compiled on every `ParseSegments` call; violates static compiled-regex rule** A `new Regex(...)` is created inside `ParseSegments` on every invocation. This creates a new regex engine instance and JIT-compiles it on each call — a known performance regression and a violation of the project's static compiled-regex standard (see `EpubReaderService.cs` for the correct pattern). **Suggested fix:** Move the regex to a `private static readonly` field at the top of the component's `@code` block: ```csharp private static readonly Regex CitationRegex = new( @"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]", RegexOptions.IgnoreCase | RegexOptions.Compiled); ``` Then use `CitationRegex.Matches(text)` in `ParseSegments`.
ebookId = NavigationService.CurrentEbookId;
}
var tenantId = TenantId ?? "global";
var result = await KnowledgeService.AskQuestionAsync(userQuestion, tenantId, ebookId);
if (result.IsSuccess)
{
var response = result.Value;
_chatMessages.Add(new ChatMessage
{
Sender = "AI",
Text = response.Answer,
Segments = ParseSegments(response.Answer),
Citations = response.Citations
});
}
else
{
var errMsg = $"Błąd: {result.Errors.FirstOrDefault()?.Message ?? "Wystąpił nieznany problem."}";
_chatMessages.Add(new ChatMessage
{
Sender = "AI",
Text = errMsg,
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
});
}
}
catch (Exception ex)
{
var errMsg = $"Błąd sieci/API: {ex.Message}";
_chatMessages.Add(new ChatMessage
{
Sender = "AI",
Text = errMsg,
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
});
}
finally
{
_isLoading = false;
StateHasChanged();
}
}
private List<ResponseSegment> ParseSegments(string text)
{
var segments = new List<ResponseSegment>();
if (string.IsNullOrEmpty(text)) return segments;
var matches = CitationRegex.Matches(text);
int lastIndex = 0;
foreach (System.Text.RegularExpressions.Match match in matches)
{
if (match.Index > lastIndex)
{
segments.Add(new ResponseSegment
{
Text = text.Substring(lastIndex, match.Index - lastIndex),
IsCitation = false
});
}
var citationId = match.Groups[1].Success
? match.Groups[1].Value.Trim()
: match.Groups[2].Value.Trim();
segments.Add(new ResponseSegment
{
IsCitation = true,
CitationId = citationId
});
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
{
segments.Add(new ResponseSegment
{
Text = text.Substring(lastIndex),
IsCitation = false
});
}
return segments;
}
private MarkupString RenderMarkdown(string text)
{
if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty);
var html = System.Net.WebUtility.HtmlEncode(text);
html = BoldRegex.Replace(html, "<strong>$1</strong>");
html = ItalicRegex.Replace(html, "<em>$1</em>");
html = CodeBlockRegex.Replace(html, "<pre class=\"nexus-mobile-code-block\"><code>$1</code></pre>");
html = InlineCodeRegex.Replace(html, "<code class=\"nexus-mobile-inline-code\">$1</code>");
html = html.Replace("\n", "<br />");
return new MarkupString(html);
}
}
@@ -0,0 +1,545 @@
.global-intelligence-sheet {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1500;
display: flex;
flex-direction: column;
justify-content: flex-end;
pointer-events: none;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.global-intelligence-sheet.is-open {
pointer-events: all;
}
.sheet-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
opacity: 0;
transition: opacity 0.35s ease;
z-index: 1;
}
.global-intelligence-sheet.is-open .sheet-backdrop {
opacity: 1;
}
.sheet-content {
position: relative;
width: 100%;
height: 80vh;
background: rgba(18, 18, 18, 0.85);
backdrop-filter: blur(24px);
border-top: 1px solid rgba(0, 255, 153, 0.3);
border-top-left-radius: 20px;
border-top-right-radius: 20px;
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.5);
z-index: 2;
transform: translateY(100%);
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
}
.global-intelligence-sheet.is-open .sheet-content {
transform: translateY(0);
}
.sheet-drag-handle {
width: 40px;
height: 4px;
background-color: rgba(255, 255, 255, 0.2);
border-radius: 2px;
margin: 10px auto 4px auto;
}
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.header-main {
display: flex;
align-items: center;
gap: 0.75rem;
}
.ai-avatar-badge {
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, rgba(0, 255, 153, 0.15) 0%, rgba(0, 240, 255, 0.15) 100%);
border: 1px solid rgba(0, 255, 153, 0.3);
display: flex;
align-items: center;
justify-content: center;
}
.ai-avatar-badge ::deep i {
color: var(--nexus-neon, #00FF99);
}
.header-info h3 {
font-size: 1rem;
font-weight: 600;
color: #FFFFFF;
margin: 0;
}
.header-info .subtitle {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
margin: 0;
}
.close-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
padding: 6px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.close-btn:hover {
background-color: rgba(255, 255, 255, 0.05);
color: #FFFFFF;
}
.sheet-body {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
}
.chat-thread {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding-bottom: 2rem;
}
.welcome-container {
text-align: center;
padding: 4rem 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
}
.welcome-glow-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(0, 255, 153, 0.05);
border: 1px solid rgba(0, 255, 153, 0.15);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
box-shadow: 0 0 20px rgba(0, 255, 153, 0.1);
}
.welcome-glow-icon ::deep i {
color: var(--nexus-neon, #00FF99);
}
.welcome-container h4 {
font-size: 1.1rem;
font-weight: 550;
color: #FFFFFF;
margin-bottom: 0.5rem;
}
.welcome-container p {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.55);
line-height: 1.5;
max-width: 280px;
}
.message-row {
display: flex;
gap: 0.75rem;
max-width: 88%;
}
.message-row.user-row {
align-self: flex-end;
flex-direction: row-reverse;
}
.message-row.ai-row {
align-self: flex-start;
}
.message-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.user-row .message-avatar {
background-color: rgba(0, 255, 153, 0.1);
border: 1px solid rgba(0, 255, 153, 0.2);
}
.message-avatar ::deep i {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.7);
}
.user-row .message-avatar ::deep i {
color: var(--nexus-neon, #00FF99);
}
.message-bubble {
padding: 0.75rem 1rem;
border-radius: 14px;
position: relative;
}
.user-bubble {
background-color: rgba(0, 255, 153, 0.08);
border: 1px solid rgba(0, 255, 153, 0.2);
border-top-right-radius: 2px;
}
.ai-bubble {
background-color: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-top-left-radius: 2px;
}
.message-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
gap: 1rem;
}
.sender-name {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.user-bubble .sender-name {
color: var(--nexus-neon, #00FF99);
}
.ai-bubble .sender-name {
color: rgba(255, 255, 255, 0.7);
}
.message-time {
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.4);
}
.message-text {
font-size: 0.85rem;
line-height: 1.5;
color: rgba(255, 255, 255, 0.9);
}
.message-text strong {
color: #FFFFFF;
}
.nexus-mobile-citation {
background-color: rgba(0, 240, 255, 0.15);
border: 1px solid rgba(0, 240, 255, 0.3);
color: #00F0FF;
border-radius: 4px;
padding: 1px 4px;
font-size: 0.75rem;
font-weight: bold;
cursor: pointer;
margin-left: 2px;
display: inline-block;
}
.nexus-mobile-code-block {
background-color: rgba(0, 0, 0, 0.4);
border-left: 3px solid var(--nexus-neon, #00FF99);
padding: 0.75rem;
border-radius: 6px;
margin: 0.5rem 0;
overflow-x: auto;
font-family: monospace;
font-size: 0.75rem;
}
.nexus-mobile-inline-code {
background-color: rgba(255, 255, 255, 0.08);
color: #FF7B72;
padding: 2px 4px;
border-radius: 4px;
font-family: monospace;
font-size: 0.75rem;
}
/* Typing indicator */
.typing-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 0;
}
.typing-indicator span {
width: 6px;
height: 6px;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
}
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.loading-label {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
margin-left: 8px;
vertical-align: middle;
}
.sheet-footer {
padding: 0.75rem 1rem 1.5rem 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background-color: rgba(10, 10, 10, 0.5);
}
.scope-indicator {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.45);
margin-bottom: 0.5rem;
}
.scope-indicator ::deep i {
color: rgba(255, 255, 255, 0.4);
}
.input-container {
display: flex;
gap: 0.5rem;
align-items: center;
}
.nexus-mobile-input {
flex: 1;
background-color: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 0.65rem 0.9rem;
font-size: 0.85rem;
color: #FFFFFF;
outline: none;
transition: all 0.25s ease;
}
.nexus-mobile-input:focus {
border-color: rgba(0, 255, 153, 0.4);
background-color: rgba(255, 255, 255, 0.07);
box-shadow: 0 0 8px rgba(0, 255, 153, 0.15);
}
.send-btn {
width: 38px;
height: 38px;
border-radius: 12px;
background: linear-gradient(135deg, #00FF99 0%, #00F0FF 100%);
border: none;
display: flex;
align-items: center;
justify-content: center;
color: #0b0c10;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0 10px rgba(0, 255, 153, 0.2);
flex-shrink: 0;
}
.send-btn.disabled {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.3);
box-shadow: none;
cursor: not-allowed;
}
.btn-spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(0,0,0,0.1);
border-top: 2px solid #000000;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Citation Modal Overlay & Glassmorphic Card */
.citation-modal-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
animation: fadeIn 0.25s ease-out;
}
.citation-modal {
width: 100%;
max-width: 320px;
background: rgba(20, 20, 20, 0.85);
border: 1px solid rgba(0, 240, 255, 0.25);
box-shadow: 0 0 30px rgba(0, 240, 255, 0.15);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.citation-modal .modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.citation-modal .book-title {
font-size: 0.85rem;
font-weight: 600;
color: #FFFFFF;
display: flex;
align-items: center;
gap: 0.5rem;
}
.citation-modal .book-title ::deep i {
color: #00F0FF;
}
.citation-modal .close-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.citation-modal .modal-body {
padding: 1rem;
font-size: 0.8rem;
line-height: 1.5;
color: rgba(255, 255, 255, 0.85);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.citation-modal .citation-author,
.citation-modal .citation-page {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
margin: 0;
}
.citation-modal .citation-author strong,
.citation-modal .citation-page strong {
color: rgba(255, 255, 255, 0.75);
}
.citation-modal .citation-snippet {
font-style: italic;
background: rgba(0, 240, 255, 0.04);
border-left: 2px solid #00F0FF;
padding: 0.5rem 0.75rem;
border-radius: 4px;
color: rgba(255, 255, 255, 0.9);
margin: 0.25rem 0 0 0;
}
.citation-modal .modal-footer {
display: flex;
justify-content: flex-end;
padding: 0.75rem 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.citation-modal .btn-nexus {
font-size: 0.8rem;
padding: 0.4rem 1rem;
border-radius: 8px;
background: linear-gradient(135deg, rgba(0, 240, 255, 0.2) 0%, rgba(0, 255, 153, 0.2) 100%);
border: 1px solid rgba(0, 240, 255, 0.4);
color: #FFFFFF;
font-weight: 550;
cursor: pointer;
transition: all 0.2s ease;
}
.citation-modal .btn-nexus:hover {
background: linear-gradient(135deg, rgba(0, 240, 255, 0.35) 0%, rgba(0, 255, 153, 0.35) 100%);
border-color: rgba(0, 240, 255, 0.6);
box-shadow: 0 0 10px rgba(0, 240, 255, 0.2);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@@ -0,0 +1,151 @@
@using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Models
@using NexusReader.Application.Utilities
@namespace NexusReader.UI.Shared.Components.Organisms
@inject IReaderInteractionService InteractionService
@inject IReaderStateService StateService
<div class="nexus-unified-mobile-toolbar">
<!-- LEFT SLOT: Progress & Section Checkpoints -->
<div class="toolbar-slot left-slot" @onclick="ToggleCheckpoints" title="Rozdziały i checkpoints">
<div class="progress-ring-wrapper">
<svg class="progress-ring" width="38" height="38">
<circle class="progress-ring-track" stroke="rgba(255,255,255,0.06)" stroke-width="2.5" fill="transparent" r="16" cx="19" cy="19" />
<circle class="progress-ring-indicator" stroke="var(--nexus-neon, #00FF99)" stroke-width="2.5" fill="transparent" r="16" cx="19" cy="19"
stroke-dasharray="100.53" stroke-dashoffset="@GetDashOffset()" />
</svg>
<span class="progress-text">@ScrollPercentage%</span>
</div>
<div class="progress-info">
<span class="slot-label">Postęp</span>
<span class="slot-desc">Checkpoints</span>
</div>
</div>
<!-- CENTER SLOT: Global AI Assistant Glowing Trigger -->
<div class="toolbar-slot center-slot">
<button class="btn-nexus-ai-core" @onclick="HandleAssistantClick" aria-label="Asystent AI">
<div class="pulse-ring"></div>
<div class="pulse-ring-outer"></div>
<NexusIcon Name="robot" Size="22" Class="ai-core-icon" />
</button>
</div>
<!-- RIGHT SLOT: Context View Toggles -->
<div class="toolbar-slot right-slot">
<button class="nav-toggle-btn @(ActiveTab == MobileReaderTab.Reader ? "active" : "")"
@onclick="() => ChangeTab(MobileReaderTab.Reader)"
aria-label="Tekst">
<NexusIcon Name="book-open" Size="18" />
<span>Tekst</span>
</button>
<button class="nav-toggle-btn @(ActiveTab == MobileReaderTab.Graph ? "active" : "")"
@onclick="() => ChangeTab(MobileReaderTab.Graph)"
aria-label="Graf">
<NexusIcon Name="share-2" Size="18" />
<span>Graf</span>
</button>
<button class="nav-toggle-btn @(ActiveTab == MobileReaderTab.Concepts ? "active" : "")"
@onclick="() => ChangeTab(MobileReaderTab.Concepts)"
aria-label="Mapa">
<NexusIcon Name="map" Size="18" />
<span>Mapa</span>
</button>
</div>
</div>
<!-- SECTION CHECKPOINTS OVERLAY -->
<div class="checkpoints-overlay @(IsCheckpointsOpen ? "is-open" : "")">
<div class="checkpoints-backdrop" @onclick="ToggleCheckpoints"></div>
<div class="checkpoints-sheet">
<div class="sheet-drag-handle"></div>
<header class="checkpoints-header">
<h4>Checkpoints Sekcji</h4>
<button class="close-checkpoints-btn" @onclick="ToggleCheckpoints">
<NexusIcon Name="close" Size="16" />
</button>
</header>
<div class="checkpoints-body">
@if (Checkpoints == null || !Checkpoints.Any())
{
<div class="empty-checkpoints">
<NexusIcon Name="info" Size="20" />
<p>Brak punktów kontrolnych w tym rozdziale.</p>
</div>
}
else
{
<div class="checkpoints-list">
@foreach (var cp in Checkpoints)
{
var isCurrent = cp == StateService.CurrentBlockId;
<div class="checkpoint-item @(isCurrent ? "active" : "")" @onclick="() => SelectCheckpoint(cp)">
<div class="checkpoint-indicator">
mjasin marked this conversation as resolved
Review

🟢 Minor — _scrollListenerReference in ReaderCanvas is disposed without try/catch, swallowing potential JS interop errors silently

In ReaderCanvas.DisposeAsync, _scrollListenerReference?.DisposeAsync() is wrapped in an empty catch {} block. While teardown errors are generally non-fatal, a bare catch {} silently suppresses all exceptions including JSDisconnectedException. Prefer at minimum catch (Exception ex) { Logger.LogDebug(ex, "..."); } for consistency with the _viewportModule teardown pattern directly above it.

🟢 **Minor — `_scrollListenerReference` in `ReaderCanvas` is disposed without `try/catch`, swallowing potential JS interop errors silently** In `ReaderCanvas.DisposeAsync`, `_scrollListenerReference?.DisposeAsync()` is wrapped in an empty `catch {}` block. While teardown errors are generally non-fatal, a bare `catch {}` silently suppresses all exceptions including `JSDisconnectedException`. Prefer at minimum `catch (Exception ex) { Logger.LogDebug(ex, "..."); }` for consistency with the `_viewportModule` teardown pattern directly above it.
<div class="indicator-dot"></div>
<div class="indicator-line"></div>
</div>
<div class="checkpoint-details">
<span class="checkpoint-id">@cp.ToUpper()</span>
<span class="checkpoint-label">@(isCurrent ? "Aktualna sekcja" : "Przejdź do sekcji")</span>
</div>
<NexusIcon Name="chevron-right" Size="14" Class="arrow-icon" />
</div>
}
</div>
}
</div>
</div>
</div>
@code {
[Parameter] public int ScrollPercentage { get; set; }
[Parameter] public MobileReaderTab ActiveTab { get; set; }
[Parameter] public EventCallback<MobileReaderTab> OnTabChanged { get; set; }
[Parameter] public EventCallback OnAssistantClick { get; set; }
[Parameter] public List<string> Checkpoints { get; set; } = new();
private bool IsCheckpointsOpen { get; set; }
private double GetDashOffset()
{
// Circumference of r=16 is 2 * pi * 16 = 100.53
double circumference = 100.53;
double progress = Math.Clamp(ScrollPercentage, 0, 100);
return circumference - (progress / 100.0) * circumference;
}
private void ToggleCheckpoints()
{
IsCheckpointsOpen = !IsCheckpointsOpen;
}
private async Task SelectCheckpoint(string checkpointId)
{
IsCheckpointsOpen = false;
// Scroll to the targeted block
await InteractionService.RequestScrollToBlock(checkpointId);
// Ensure user is on the text reading tab to see the scroll happen
if (ActiveTab != MobileReaderTab.Reader)
{
await ChangeTab(MobileReaderTab.Reader);
}
}
private async Task ChangeTab(MobileReaderTab tab)
{
if (OnTabChanged.HasDelegate)
{
await OnTabChanged.InvokeAsync(tab);
}
}
private async Task HandleAssistantClick()
{
if (OnAssistantClick.HasDelegate)
{
await OnAssistantClick.InvokeAsync();
}
}
}
@@ -0,0 +1,362 @@
.nexus-unified-mobile-toolbar {
position: fixed;
bottom: 16px;
left: 16px;
right: 16px;
height: 64px;
background: rgba(18, 18, 18, 0.75);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(0, 255, 153, 0.2);
border-radius: 16px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 0 1rem;
z-index: 1000;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
box-sizing: border-box;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.toolbar-slot {
display: flex;
align-items: center;
}
/* LEFT SLOT: Progress circular ring */
.left-slot {
justify-content: flex-start;
gap: 0.65rem;
cursor: pointer;
user-select: none;
}
.progress-ring-wrapper {
position: relative;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
}
.progress-ring {
transform: rotate(-90deg);
}
.progress-ring-indicator {
transition: stroke-dashoffset 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.progress-text {
position: absolute;
font-size: 0.65rem;
font-weight: 700;
color: #FFFFFF;
}
.progress-info {
display: flex;
flex-direction: column;
}
.slot-label {
font-size: 0.75rem;
font-weight: 600;
color: #FFFFFF;
}
.slot-desc {
font-size: 0.6rem;
color: rgba(255,255,255,0.4);
}
/* CENTER SLOT: Glowing AI Core Button */
.center-slot {
justify-content: center;
position: relative;
}
.btn-nexus-ai-core {
width: 52px;
height: 52px;
border-radius: 50%;
background: linear-gradient(135deg, #00FF99 0%, #00F0FF 100%);
border: none;
display: flex;
align-items: center;
justify-content: center;
color: #0B0C10;
cursor: pointer;
position: relative;
z-index: 5;
box-shadow: 0 0 20px rgba(0, 255, 153, 0.4);
transform: translateY(-8px);
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.btn-nexus-ai-core:active {
transform: translateY(-6px) scale(0.95);
box-shadow: 0 0 10px rgba(0, 255, 153, 0.3);
}
.ai-core-icon {
color: #0b0c10;
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.2));
}
/* Pulse effects */
.pulse-ring {
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
border-radius: 50%;
border: 2px solid rgba(0, 255, 153, 0.4);
opacity: 0;
animation: corePulse 2s cubic-bezier(0.24, 0, 0.38, 1) infinite;
pointer-events: none;
z-index: 1;
}
.pulse-ring-outer {
position: absolute;
top: -8px;
left: -8px;
right: -8px;
bottom: -8px;
border-radius: 50%;
border: 1px solid rgba(0, 240, 255, 0.2);
opacity: 0;
animation: corePulseOuter 2.5s cubic-bezier(0.24, 0, 0.38, 1) infinite;
pointer-events: none;
z-index: 1;
}
@keyframes corePulse {
0% { transform: scale(0.95); opacity: 0; }
50% { opacity: 0.8; }
100% { transform: scale(1.15); opacity: 0; }
}
@keyframes corePulseOuter {
0% { transform: scale(0.9); opacity: 0; }
50% { opacity: 0.5; }
100% { transform: scale(1.25); opacity: 0; }
}
/* RIGHT SLOT: Layout Switching */
.right-slot {
justify-content: flex-end;
gap: 0.35rem;
}
.nav-toggle-btn {
background: none;
border: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 6px 8px;
border-radius: 8px;
color: rgba(255, 255, 255, 0.45);
cursor: pointer;
transition: all 0.25s ease;
}
.nav-toggle-btn.active {
color: var(--nexus-neon, #00FF99);
background-color: rgba(0, 255, 153, 0.06);
}
.nav-toggle-btn ::deep .nexus-icon {
transition: transform 0.2s ease;
}
.nav-toggle-btn.active ::deep .nexus-icon {
transform: scale(1.08);
}
.nav-toggle-btn span {
font-size: 0.6rem;
font-weight: 500;
}
/* SECTION CHECKPOINTS OVERLAY */
.checkpoints-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1400;
display: flex;
flex-direction: column;
justify-content: flex-end;
pointer-events: none;
}
.checkpoints-overlay.is-open {
pointer-events: all;
}
.checkpoints-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(3px);
opacity: 0;
transition: opacity 0.3s ease;
z-index: 1;
}
.checkpoints-overlay.is-open .checkpoints-backdrop {
opacity: 1;
}
.checkpoints-sheet {
position: relative;
width: 100%;
max-height: 50vh;
background: rgba(15, 15, 15, 0.9);
backdrop-filter: blur(20px);
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.5);
z-index: 2;
transform: translateY(100%);
transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
}
.checkpoints-overlay.is-open .checkpoints-sheet {
transform: translateY(0);
}
.checkpoints-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.checkpoints-header h4 {
font-size: 0.9rem;
font-weight: 600;
color: #FFFFFF;
margin: 0;
}
.close-checkpoints-btn {
background: none;
border: none;
color: rgba(255,255,255,0.5);
padding: 4px;
cursor: pointer;
display: flex;
align-items: center;
}
.checkpoints-body {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
}
.empty-checkpoints {
text-align: center;
padding: 2rem 1rem;
color: rgba(255,255,255,0.4);
}
.empty-checkpoints p {
font-size: 0.8rem;
margin-top: 0.5rem;
}
.checkpoints-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-bottom: 1rem;
}
.checkpoint-item {
display: flex;
align-items: center;
padding: 0.75rem;
border-radius: 10px;
background-color: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.04);
cursor: pointer;
transition: all 0.2s ease;
}
.checkpoint-item:active {
background-color: rgba(255,255,255,0.05);
}
.checkpoint-item.active {
background-color: rgba(0, 255, 153, 0.04);
border-color: rgba(0, 255, 153, 0.15);
}
.checkpoint-indicator {
width: 14px;
display: flex;
flex-direction: column;
align-items: center;
margin-right: 0.75rem;
}
.indicator-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: rgba(255,255,255,0.3);
}
.checkpoint-item.active .indicator-dot {
background-color: var(--nexus-neon, #00FF99);
box-shadow: 0 0 8px rgba(0, 255, 153, 0.6);
}
.checkpoint-details {
flex: 1;
display: flex;
flex-direction: column;
}
.checkpoint-id {
font-size: 0.8rem;
font-weight: 700;
color: #FFFFFF;
}
.checkpoint-item.active .checkpoint-id {
color: var(--nexus-neon, #00FF99);
}
.checkpoint-label {
font-size: 0.65rem;
color: rgba(255,255,255,0.4);
margin-top: 1px;
}
.arrow-icon {
color: rgba(255,255,255,0.25);
transition: transform 0.2s ease;
}
.checkpoint-item:active .arrow-icon {
transform: translateX(2px);
}
@@ -2,8 +2,9 @@
@using NexusReader.Application.Queries.Reader @using NexusReader.Application.Queries.Reader
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Models
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@implements IDisposable @implements IAsyncDisposable
@inject IMediator Mediator @inject IMediator Mediator
@inject IJSRuntime JS @inject IJSRuntime JS
@inject IThemeService ThemeService @inject IThemeService ThemeService
@@ -11,11 +12,36 @@
@inject IReaderNavigationService NavigationService @inject IReaderNavigationService NavigationService
@inject KnowledgeCoordinator Coordinator @inject KnowledgeCoordinator Coordinator
@inject IReaderInteractionService InteractionService @inject IReaderInteractionService InteractionService
@inject IReaderStateService StateService
@inject ISyncService SyncService @inject ISyncService SyncService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject IQuizStateService QuizService
@inject IPlatformService PlatformService
@inject NavigationManager Navigation
@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")">
@if (_isMobile && ViewModel != null)
{
<header class="nexus-mobile-reader-header">
<button class="nexus-mobile-escape-btn" @onclick="HandleEscape" aria-label="Powrót do pulpitu">
<NexusIcon Name="chevron-left" Size="18" />
<span>Pulpit</span>
</button>
<div class="nexus-mobile-chapter-navigation">
<button class="nexus-chapter-nav-btn prev" @onclick="NavigationService.GoToPreviousChapter" disabled="@(NavigationService.CurrentChapterIndex == 0)" aria-label="Poprzedni rozdział">
<NexusIcon Name="chevron-left" Size="14" />
</button>
<div class="nexus-mobile-chapter-title">
@ViewModel.ChapterTitle
</div>
<button class="nexus-chapter-nav-btn next" @onclick="NavigationService.GoToNextChapter" disabled="@(NavigationService.CurrentChapterIndex >= NavigationService.TotalChapters - 1)" aria-label="Następny rozdział">
<NexusIcon Name="chevron-right" Size="14" />
</button>
</div>
</header>
}
@if (ViewModel == null) @if (ViewModel == null)
{ {
<div class="loading-state full-page"> <div class="loading-state full-page">
@@ -53,6 +79,8 @@
BlockId="@_selectedBlockId" BlockId="@_selectedBlockId"
Coordinates="@_selectionCoords" Coordinates="@_selectionCoords"
FullPageContent="@GetFullPageContent()" /> FullPageContent="@GetFullPageContent()" />
</div> </div>
@code { @code {
@@ -68,17 +96,31 @@
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;
private IJSObjectReference? _viewportModule;
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()
@@ -99,12 +141,15 @@
{ {
if (firstRender) if (firstRender)
{ {
_selfReference = DotNetObjectReference.Create(this);
await SyncService.InitializeAsync(); await SyncService.InitializeAsync();
_isInteractive = true; _isInteractive = true;
if (ViewModel != null) if (ViewModel != null)
{ {
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,12 +160,52 @@
} }
} }
private async ValueTask<IJSObjectReference> EnsureViewportModuleAsync()
{
if (_viewportModule == null)
{
_viewportModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
}
return _viewportModule;
}
private async Task InitViewportDetectionAsync()
{
try
{
var module = await EnsureViewportModuleAsync();
var isMobileViewport = await module.InvokeAsync<bool>("isMobileViewport");
await OnViewportChanged(isMobileViewport);
if (_selfReference != null)
{
await module.InvokeVoidAsync("registerViewportObserver", _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
{ {
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js"); var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
await module.InvokeVoidAsync("initSelectionListener", DotNetObjectReference.Create(this), _containerRef); if (_selfReference != null)
{
await module.InvokeVoidAsync("initSelectionListener", _selfReference, _containerRef);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -128,12 +213,18 @@
} }
} }
private IJSObjectReference? _scrollListenerReference;
private async Task InitializeObserverAsync() private async Task InitializeObserverAsync()
{ {
try try
{ {
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/readerObserver.js"); var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/readerObserver.js");
await module.InvokeVoidAsync("initObserver", DotNetObjectReference.Create(this), ".reader-flow-container", ".block-wrapper"); if (_selfReference != null)
{
await module.InvokeVoidAsync("initObserver", _selfReference, ".reader-flow-container", ".block-wrapper");
_scrollListenerReference = await module.InvokeAsync<IJSObjectReference>("initScrollListener", _selfReference, ".reader-flow-container");
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -141,10 +232,19 @@
} }
} }
[JSInvokable]
public async Task HandleScrollPercentChanged(int percent)
{
StateService.CurrentScrollPercentage = percent;
await InteractionService.NotifyScrollPercentChanged(percent);
}
[JSInvokable] [JSInvokable]
public async Task HandleBlockReached(string blockId, string content) public async Task HandleBlockReached(string blockId, string content)
{ {
_currentActiveBlockId = blockId; _currentActiveBlockId = blockId;
StateService.CurrentBlockId = blockId;
await InteractionService.NotifyBlockReached(blockId);
await Coordinator.OnBlockReachedAsync(blockId, content); await Coordinator.OnBlockReachedAsync(blockId, content);
if (ViewModel != null) if (ViewModel != null)
@@ -244,6 +344,13 @@
ViewModel = result.Value; ViewModel = result.Value;
await NavigationService.UpdateMetadataAsync(ViewModel.CurrentChapterIndex, ViewModel.TotalChapters, ViewModel.ChapterTitle); await NavigationService.UpdateMetadataAsync(ViewModel.CurrentChapterIndex, ViewModel.TotalChapters, ViewModel.ChapterTitle);
// Populate checkpoints!
var checkpoints = ViewModel.Blocks
.Where(b => !string.IsNullOrEmpty(b.Id) && b.Id.Contains("seg"))
.Select(b => b.Id)
.ToList();
StateService.CurrentCheckpoints = checkpoints;
if (_isInteractive) if (_isInteractive)
{ {
await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId); await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId);
@@ -259,7 +366,9 @@
_isLoadingChapter = false; _isLoadingChapter = false;
StateHasChanged(); StateHasChanged();
if (result.IsSuccess && !string.IsNullOrEmpty(NavigationService.PendingScrollBlockId)) if (result.IsSuccess)
{
if (!string.IsNullOrEmpty(NavigationService.PendingScrollBlockId))
{ {
var targetBlockId = NavigationService.PendingScrollBlockId; var targetBlockId = NavigationService.PendingScrollBlockId;
NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls
@@ -270,13 +379,21 @@
await ScrollToNodeAsync(targetBlockId); await ScrollToNodeAsync(targetBlockId);
await InteractionService.RequestHighlightBlock(targetBlockId); await InteractionService.RequestHighlightBlock(targetBlockId);
} }
else
{
// Reset scroll to top now that the new content DOM is rendered
await Task.Delay(50); // Give the browser a frame to render the new chapter content
await ScrollToTopAsync();
}
}
} }
public async Task ScrollToNodeAsync(string id) public async Task ScrollToNodeAsync(string id)
{ {
try try
{ {
await JS.InvokeVoidAsync("eval", $"document.getElementById('{id}')?.scrollIntoView({{ behavior: 'smooth', block: 'center' }});"); var module = await EnsureViewportModuleAsync();
await module.InvokeVoidAsync("scrollIntoView", id);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -284,16 +401,73 @@
} }
} }
public async Task ScrollToTopAsync()
{
try
{
var module = await EnsureViewportModuleAsync();
await module.InvokeVoidAsync("scrollToTop", ".reader-canvas");
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to scroll reader canvas to top.");
}
}
private Task HandleUpdate() => InvokeAsync(StateHasChanged); private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose() private void HandleEscape()
{
if (ViewModel != null)
{
Navigation.NavigateTo("/");
}
}
private async Task HandleAssistantFabClick()
{
await InteractionService.RequestAssistant();
}
public async ValueTask DisposeAsync()
{ {
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;
try
{
if (_viewportModule != null)
{
if (_selfReference != null)
{
await _viewportModule.InvokeVoidAsync("unregisterViewportObserver", _selfReference);
}
await _viewportModule.DisposeAsync();
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Teardown of viewport observer module failed in ReaderCanvas disposal.");
}
try
{
if (_scrollListenerReference != null)
{
await _scrollListenerReference.DisposeAsync();
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Teardown of scroll listener reference failed in ReaderCanvas disposal.");
}
_selfReference?.Dispose();
} }
} }
@@ -259,3 +259,117 @@
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
} }
/* MOBILE READER UI OVERRIDES */
@media (max-width: 768px) {
.reader-canvas {
padding-top: 54px !important;
padding-bottom: 80px !important; /* Ensure content is clear of bottom toolbar */
}
.reader-flow-container {
padding-bottom: 4rem; /* Safe breathing room */
}
}
.nexus-mobile-reader-header {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 50px;
background: rgba(18, 18, 18, 0.75);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
padding: 0 1rem;
z-index: 1000;
box-sizing: border-box;
}
.theme-light .nexus-mobile-reader-header {
background: rgba(249, 249, 249, 0.8);
border-bottom-color: rgba(0, 0, 0, 0.08);
}
.nexus-mobile-escape-btn {
background: none;
border: none;
display: flex;
align-items: center;
gap: 4px;
color: var(--nexus-neon, #00FF99);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
padding: 8px 12px;
border-radius: 8px;
transition: background-color 0.2s ease;
margin-left: -8px;
}
.nexus-mobile-escape-btn:active {
background-color: rgba(0, 255, 153, 0.08);
}
.nexus-mobile-chapter-navigation {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
height: 100%;
min-width: 0;
}
.nexus-mobile-chapter-title {
flex: 1;
text-align: center;
font-size: 0.8rem;
font-weight: 600;
color: #FFFFFF;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 0.5rem;
min-width: 0;
}
.theme-light .nexus-mobile-chapter-title {
color: #1a1a1a;
}
.nexus-chapter-nav-btn {
background: none;
border: none;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
}
.nexus-chapter-nav-btn:hover:not(:disabled) {
color: var(--nexus-neon, #00FF99);
background: rgba(255, 255, 255, 0.06);
}
.nexus-chapter-nav-btn:disabled {
opacity: 0.2;
cursor: not-allowed;
}
.theme-light .nexus-chapter-nav-btn {
color: rgba(0, 0, 0, 0.5);
}
.theme-light .nexus-chapter-nav-btn:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.06);
}
@@ -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;
}
}
@@ -1,6 +1,7 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Abstractions.Services
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Models
@using NexusReader.UI.Shared.Components.Molecules @using NexusReader.UI.Shared.Components.Molecules
@using NexusReader.UI.Shared.Components.Organisms @using NexusReader.UI.Shared.Components.Organisms
@using NexusReader.Application.Queries.Graph @using NexusReader.Application.Queries.Graph
@@ -9,14 +10,16 @@
@inject IFocusModeService FocusMode @inject IFocusModeService FocusMode
@inject IQuizStateService QuizService @inject IQuizStateService QuizService
@inject IReaderInteractionService InteractionService @inject IReaderInteractionService InteractionService
@inject IReaderStateService StateService
@inject IKnowledgeGraphService GraphService @inject IKnowledgeGraphService GraphService
@inject IJSRuntime JS @inject IJSRuntime JS
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger @inject Microsoft.Extensions.Logging.ILogger<ReaderLayout> Logger
@implements IDisposable @implements IAsyncDisposable
<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 +33,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 +52,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 +134,107 @@
} }
</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: Concepts/Quiz -->
<div class="nexus-mobile-tab-content insight-tab @(_activeMobileTab == MobileReaderTab.Concepts ? "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>
<MobileReaderToolbar
ScrollPercentage="@_scrollPercentage"
ActiveTab="@_activeMobileTab"
OnTabChanged="SetMobileTab"
OnAssistantClick="OpenAssistant"
Checkpoints="@StateService.CurrentCheckpoints" />
<GlobalIntelligence IsOpen="@_isAssistantOpen" OnClose="CloseAssistant" TenantId="@context.User.FindFirst("TenantId")?.Value" />
}
</Authorized> </Authorized>
<Authorizing> <Authorizing>
<div class="app-preloader"> <div class="app-preloader">
@@ -161,6 +264,22 @@
private string _platformClass = "platform-desktop"; private string _platformClass = "platform-desktop";
private bool _isMobile = false; private bool _isMobile = false;
private DotNetObjectReference<ReaderLayout>? _selfReference;
private IJSObjectReference? _viewportModule;
private bool _isAssistantOpen;
private int _scrollPercentage
{
get => StateService.CurrentScrollPercentage;
set => StateService.CurrentScrollPercentage = value;
}
private MobileReaderTab _activeMobileTab
{
get => StateService.ActiveTab;
set => StateService.ActiveTab = value;
}
protected override void OnInitialized() protected override void OnInitialized()
{ {
@@ -169,6 +288,8 @@
QuizService.OnQuizRequested += HandleQuizRequestedAsync; QuizService.OnQuizRequested += HandleQuizRequestedAsync;
InteractionService.OnNodeSelected += HandleNodeSelectedAsync; InteractionService.OnNodeSelected += HandleNodeSelectedAsync;
InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync;
InteractionService.OnScrollPercentChanged += HandleScrollPercentChanged;
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync; GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
var context = PlatformService.GetDeviceContext(); var context = PlatformService.GetDeviceContext();
@@ -190,9 +311,51 @@
StateHasChanged(); StateHasChanged();
} }
private void SetMobileTab(MobileReaderTab tab)
{
_activeMobileTab = tab;
StateHasChanged();
}
private void OpenAssistant()
{
_isAssistantOpen = true;
StateHasChanged();
}
private void CloseAssistant()
{
_isAssistantOpen = false;
StateHasChanged();
}
private async Task HandleScrollPercentChanged(int percent)
{
_scrollPercentage = percent;
await InvokeAsync(StateHasChanged);
}
private async Task HandleQuizRequestedAsync(string blockId) private async Task HandleQuizRequestedAsync(string blockId)
{ {
_activeTab = SidebarTab.Quiz; _activeTab = SidebarTab.Quiz;
if (_isMobile)
{
_activeMobileTab = MobileReaderTab.Concepts;
}
await InvokeAsync(StateHasChanged);
}
private async Task HandleAssistantRequestedAsync()
{
if (_isMobile)
{
OpenAssistant();
}
else
{
_activeMobileTab = MobileReaderTab.Concepts;
_activeTab = SidebarTab.Quiz;
}
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -203,6 +366,11 @@
{ {
_selectedNode = GraphService.CurrentGraphData.Nodes.FirstOrDefault(n => n.Id == nodeId); _selectedNode = GraphService.CurrentGraphData.Nodes.FirstOrDefault(n => n.Id == nodeId);
} }
if (_isMobile)
{
_activeMobileTab = MobileReaderTab.Concepts;
_activeTab = SidebarTab.Knowledge;
}
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -226,17 +394,76 @@
{ {
Logger.LogError(ex, "Failed to initialize layout resizer JS module."); Logger.LogError(ex, "Failed to initialize layout resizer JS module.");
} }
try
{
_viewportModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
await InitViewportDetectionAsync();
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to import viewport utilities JS module.");
}
}
}
private async Task InitViewportDetectionAsync()
{
if (_viewportModule == null) return;
try
{
_selfReference = DotNetObjectReference.Create(this);
var isMobileViewport = await _viewportModule.InvokeAsync<bool>("isMobileViewport");
await OnViewportChanged(isMobileViewport);
await _viewportModule.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);
} }
} }
private Task HandleUpdate() => InvokeAsync(StateHasChanged); private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose() public async ValueTask DisposeAsync()
{ {
FocusMode.OnFocusModeChanged -= HandleUpdate; FocusMode.OnFocusModeChanged -= HandleUpdate;
QuizService.OnQuizUpdated -= HandleUpdate; QuizService.OnQuizUpdated -= HandleUpdate;
QuizService.OnQuizRequested -= HandleQuizRequestedAsync; QuizService.OnQuizRequested -= HandleQuizRequestedAsync;
InteractionService.OnNodeSelected -= HandleNodeSelectedAsync; InteractionService.OnNodeSelected -= HandleNodeSelectedAsync;
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged;
GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync; GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync;
try
{
if (_viewportModule != null)
{
if (_selfReference != null)
{
await _viewportModule.InvokeVoidAsync("unregisterViewportObserver", _selfReference);
}
await _viewportModule.DisposeAsync();
} }
} }
catch (Exception ex)
{
Logger.LogDebug(ex, "Teardown of viewport observer module failed during component disposal.");
}
_selfReference?.Dispose();
}
}
1
@@ -468,3 +468,183 @@ 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: 100vh !important; /* full viewport 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-concepts .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: 100vh; /* full viewport height */
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-concepts .nexus-mobile-reader-tabs {
display: block; /* Show only when graph or concepts 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 .knowledge-graph-container {
height: 100% !important;
min-height: 100% !important;
}
.nexus-mobile-tab-content.graph-tab ::deep .graph-controls {
bottom: 6.5rem !important;
right: 1.5rem !important;
}
.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;
}
/* Obsolescence managed: consolidated mobile toolbar and sheet styled inside respective components */
@@ -0,0 +1,41 @@
using NexusReader.Application.DTOs.AI;
namespace NexusReader.UI.Shared.Models;
/// <summary>
/// Defines the active tab state for the unified mobile reader toolbar.
/// </summary>
public enum MobileReaderTab
{
Reader,
Graph,
Concepts
}
/// <summary>
/// Screen coordinates for text selection popup positioning.
/// </summary>
public record SelectionCoordinates(double Top, double Left, double Width);
/// <summary>
/// Represents a message in the KM-RAG global and mobile intelligence chat threads.
/// </summary>
public class ChatMessage
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Sender { get; set; } = string.Empty; // "User" or "AI"
public string Text { get; set; } = string.Empty;
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public List<ResponseSegment> Segments { get; set; } = new();
public List<CitationDto> Citations { get; set; } = new();
}
/// <summary>
/// Represents a parsed segment of an intelligence response, potentially referencing a citation.
/// </summary>
public class ResponseSegment
{
public string Text { get; set; } = string.Empty;
public bool IsCitation { get; set; }
public string CitationId { get; set; } = string.Empty;
}
@@ -7,6 +7,7 @@
@inject IIdentityService IdentityService @inject IIdentityService IdentityService
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IJSRuntime JS @inject IJSRuntime JS
@inject FeatureSettings FeatureSettings
<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 = FeatureSettings.AllowRegistration;
_allowPasswordReset = FeatureSettings.AllowPasswordReset;
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 FeatureSettings FeatureSettings
<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 = FeatureSettings.AllowRegistration;
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;
}
}
@@ -4,6 +4,7 @@
@using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.DTOs.User @using NexusReader.Application.DTOs.User
@using NexusReader.UI.Shared.Components.Atoms @using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.UI.Shared.Models
@using System.Net.Http.Json @using System.Net.Http.Json
@inject HttpClient Http @inject HttpClient Http
@inject IKnowledgeService KnowledgeService @inject IKnowledgeService KnowledgeService
@@ -145,22 +146,7 @@
private List<LastReadBookDto>? _books; private List<LastReadBookDto>? _books;
private List<ChatMessage> _chatMessages = new(); private List<ChatMessage> _chatMessages = new();
public class ChatMessage
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Sender { get; set; } = string.Empty; // "User" or "AI"
public string Text { get; set; } = string.Empty;
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public List<ResponseSegment> Segments { get; set; } = new();
public List<CitationDto> Citations { get; set; } = new();
}
public class ResponseSegment
{
public string Text { get; set; } = string.Empty;
public bool IsCitation { get; set; }
public string CitationId { get; set; } = string.Empty;
}
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
1
@@ -2,7 +2,8 @@
@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">
@@ -88,18 +89,26 @@
</div> </div>
</div> </div>
</div> </div>
#else }
else
{
<div class="serilog-demo-container"> <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 {
// Compile-time check ensures _isDebug is baked as false in Release/Test/Production builds,
// which completely bypasses/strips rendering of the diagnostic UI and avoids exposing internal controls.
#if DEBUG #if DEBUG
private readonly bool _isDebug = true;
#else
private readonly bool _isDebug = false;
#endif
private void LogInfo() private void LogInfo()
{ {
Logger.LogInformation("Structured native log triggered by user from SerilogDemo. Button: LogInfo"); Logger.LogInformation("Structured native log triggered by user from SerilogDemo. Button: LogInfo");
@@ -123,13 +132,33 @@
} }
private async Task TriggerJsLog() private async Task TriggerJsLog()
{
try
{ {
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!");
} }
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to execute console.log from diagnostic panel.");
}
}
private async Task TriggerJsException() private async Task TriggerJsException()
{ {
await JSRuntime.InvokeVoidAsync("eval", "throw new Error('Simulated runtime JS Exception triggered from Blazor UI button click!');"); try
{
// Triggers a TypeError by invoking a non-existent method, which is completely CSP-compliant and works without eval()
await JSRuntime.InvokeVoidAsync("window.nonExistentFunctionTriggeringException");
} }
#endif catch (Exception ex)
{
Logger.LogError(ex, "Simulated runtime JS Exception triggered and captured in Blazor UI");
try
{
await JSRuntime.InvokeVoidAsync("console.error", $"Simulated JS Exception: {ex.Message}");
} }
catch { }
}
}
}
+2
View File
@@ -1,3 +1,5 @@
<AuthenticationStatePersister />
<ErrorBoundary @ref="_errorBoundary"> <ErrorBoundary @ref="_errorBoundary">
<ChildContent> <ChildContent>
<Router AppAssembly="@typeof(Routes).Assembly"> <Router AppAssembly="@typeof(Routes).Assembly">
@@ -0,0 +1,11 @@
namespace NexusReader.UI.Shared.Services;
/// <summary>
/// Strongly-typed feature settings for the client UI layer.
/// Used to decouple the UI from raw IConfiguration to prevent exposure of sensitive settings.
/// </summary>
public class FeatureSettings
{
public bool AllowRegistration { get; set; } = true;
public bool AllowPasswordReset { get; set; } = true;
}
@@ -1,3 +1,5 @@
using NexusReader.UI.Shared.Models;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
public interface IReaderInteractionService public interface IReaderInteractionService
@@ -6,11 +8,16 @@ 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;
event Func<int, Task>? OnScrollPercentChanged;
event Func<string, Task>? OnBlockReached;
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();
Task NotifyScrollPercentChanged(int percent);
Task NotifyBlockReached(string blockId);
} }
public record SelectionCoordinates(double Top, double Left, double Width);
@@ -0,0 +1,14 @@
using NexusReader.UI.Shared.Models;
namespace NexusReader.UI.Shared.Services;
/// <summary>
/// Service to maintain local UI state for the reader, separating state from event bus.
/// </summary>
public interface IReaderStateService
{
int CurrentScrollPercentage { get; set; }
List<string> CurrentCheckpoints { get; set; }
string CurrentBlockId { get; set; }
MobileReaderTab ActiveTab { get; set; }
}
@@ -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;
}
}
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
public sealed partial class KnowledgeCoordinator : IDisposable public sealed partial class KnowledgeCoordinator : IDisposable, IAsyncDisposable
{ {
private readonly IKnowledgeService _knowledgeService; private readonly IKnowledgeService _knowledgeService;
private readonly IKnowledgeGraphService _graphService; private readonly IKnowledgeGraphService _graphService;
@@ -17,6 +17,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
private readonly IReaderInteractionService _interactionService; private readonly IReaderInteractionService _interactionService;
private readonly ILogger<KnowledgeCoordinator> _logger; private readonly ILogger<KnowledgeCoordinator> _logger;
private CancellationTokenSource? _graphCts;
private CancellationTokenSource? _quizCts;
public string CurrentFullPageContent { get; private set; } = string.Empty; public string CurrentFullPageContent { get; private set; } = string.Empty;
/// <summary> /// <summary>
@@ -75,9 +78,38 @@ public sealed partial class KnowledgeCoordinator : IDisposable
} }
} }
private void CancelAndDisposeCts(ref CancellationTokenSource? cts)
{
var localCts = cts;
cts = null;
if (localCts != null)
{
try
{
localCts.Cancel();
}
catch (ObjectDisposedException) { }
finally
{
localCts.Dispose();
}
}
}
public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global", Guid? ebookId = null) public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global", Guid? ebookId = null)
{ {
if (string.IsNullOrWhiteSpace(fullContent)) return; if (string.IsNullOrWhiteSpace(fullContent))
{
CancelAndDisposeCts(ref _graphCts);
await _graphService.Clear();
await _graphService.SetLoading(false);
CurrentFullPageContent = string.Empty;
return;
}
CancelAndDisposeCts(ref _graphCts);
_graphCts = new CancellationTokenSource();
var token = _graphCts.Token;
CurrentFullPageContent = fullContent; CurrentFullPageContent = fullContent;
LogGeneratingGraph(tenantId); LogGeneratingGraph(tenantId);
@@ -87,7 +119,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
try try
{ {
var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId, ebookId); var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId, ebookId, token);
token.ThrowIfCancellationRequested();
if (result.IsSuccess) if (result.IsSuccess)
{ {
var packet = result.Value; var packet = result.Value;
@@ -103,12 +137,19 @@ public sealed partial class KnowledgeCoordinator : IDisposable
await _graphService.SetLoading(false); await _graphService.SetLoading(false);
} }
catch (OperationCanceledException)
{
_logger.LogInformation("[KnowledgeCoordinator] Graph generation task was canceled.");
}
catch (Exception ex) catch (Exception ex)
{
if (!token.IsCancellationRequested)
{ {
await _graphService.SetLoading(false); await _graphService.SetLoading(false);
LogGraphError(ex, tenantId); LogGraphError(ex, tenantId);
} }
} }
}
public async Task OnBlockReachedAsync(string blockId, string content) public async Task OnBlockReachedAsync(string blockId, string content)
{ {
@@ -118,11 +159,17 @@ public sealed partial class KnowledgeCoordinator : IDisposable
public async Task<Result<KnowledgePacket>> RequestSummaryAndQuizAsync(string content, string tenantId = "global") public async Task<Result<KnowledgePacket>> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
{ {
CancelAndDisposeCts(ref _quizCts);
_quizCts = new CancellationTokenSource();
var token = _quizCts.Token;
await _quizService.SetHydrating(true); await _quizService.SetHydrating(true);
LogRequestingSummary(tenantId); LogRequestingSummary(tenantId);
try try
{ {
var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId); var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId, cancellationToken: token);
token.ThrowIfCancellationRequested();
if (result.IsSuccess) if (result.IsSuccess)
{ {
var packet = result.Value; var packet = result.Value;
@@ -138,11 +185,20 @@ public sealed partial class KnowledgeCoordinator : IDisposable
LogSummaryWarning(tenantId); LogSummaryWarning(tenantId);
return Result.Fail(result.Errors); return Result.Fail(result.Errors);
} }
catch (OperationCanceledException)
{
_logger.LogInformation("[KnowledgeCoordinator] Quiz and summary generation task was canceled.");
return Result.Fail("Task canceled");
}
catch (Exception ex) catch (Exception ex)
{
if (!token.IsCancellationRequested)
{ {
LogSummaryError(ex, tenantId); LogSummaryError(ex, tenantId);
return Result.Fail(new Error("Error requesting summary and quiz").CausedBy(ex)); return Result.Fail(new Error("Error requesting summary and quiz").CausedBy(ex));
} }
return Result.Fail("Task canceled");
}
finally finally
{ {
await _quizService.SetHydrating(false); await _quizService.SetHydrating(false);
@@ -151,6 +207,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
public async Task ClearAsync() public async Task ClearAsync()
{ {
CancelAndDisposeCts(ref _graphCts);
CancelAndDisposeCts(ref _quizCts);
CurrentFullPageContent = string.Empty; CurrentFullPageContent = string.Empty;
await _graphService.Clear(); await _graphService.Clear();
await _quizService.SetQuiz(null, null); await _quizService.SetQuiz(null, null);
@@ -159,6 +218,27 @@ public sealed partial class KnowledgeCoordinator : IDisposable
public void Dispose() public void Dispose()
{ {
_interactionService.OnNodeSelected -= HandleNodeSelected; _interactionService.OnNodeSelected -= HandleNodeSelected;
CancelAndDisposeCts(ref _graphCts);
CancelAndDisposeCts(ref _quizCts);
}
public async ValueTask DisposeAsync()
{
_interactionService.OnNodeSelected -= HandleNodeSelected;
CancelAndDisposeCts(ref _graphCts);
CancelAndDisposeCts(ref _quizCts);
try
{
await _graphService.Clear();
await _quizService.SetQuiz(null, null);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error clearing services during KnowledgeCoordinator disposal.");
}
} }
[LoggerMessage(Level = LogLevel.Information, Message = "[KnowledgeCoordinator] Generating full page graph for tenant: {TenantId}")] [LoggerMessage(Level = LogLevel.Information, Message = "[KnowledgeCoordinator] Generating full page graph for tenant: {TenantId}")]
@@ -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;
}
@@ -1,3 +1,5 @@
using NexusReader.UI.Shared.Models;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
public sealed class ReaderInteractionService : IReaderInteractionService public sealed class ReaderInteractionService : IReaderInteractionService
@@ -6,6 +8,9 @@ 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 event Func<int, Task>? OnScrollPercentChanged;
public event Func<string, Task>? OnBlockReached;
public async Task NotifyNodeSelected(string nodeId) public async Task NotifyNodeSelected(string nodeId)
{ {
@@ -26,4 +31,20 @@ 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();
} }
public async Task NotifyScrollPercentChanged(int percent)
{
if (OnScrollPercentChanged != null) await OnScrollPercentChanged(percent);
}
public async Task NotifyBlockReached(string blockId)
{
if (OnBlockReached != null) await OnBlockReached(blockId);
}
}
@@ -0,0 +1,41 @@
using NexusReader.UI.Shared.Models;
namespace NexusReader.UI.Shared.Services;
/// <summary>
/// Thread-safe implementation of IReaderStateService.
/// Thread safety is ensured via lock-guarded property getters/setters.
/// UI updates originating from the JS event loop (via JSInvokable) are synchronized at Blazor's InvokeAsync(StateHasChanged) render boundary.
/// </summary>
public sealed class ReaderStateService : IReaderStateService
{
private readonly object _lock = new();
private int _scrollPercent;
private List<string> _checkpoints = new();
private string _blockId = string.Empty;
private MobileReaderTab _activeTab = MobileReaderTab.Reader;
public int CurrentScrollPercentage
{
get { lock (_lock) return _scrollPercent; }
set { lock (_lock) _scrollPercent = value; }
}
public List<string> CurrentCheckpoints
{
get { lock (_lock) return _checkpoints; }
set { lock (_lock) _checkpoints = value ?? new(); }
}
public string CurrentBlockId
{
get { lock (_lock) return _blockId; }
set { lock (_lock) _blockId = value ?? string.Empty; }
}
public MobileReaderTab ActiveTab
{
get { lock (_lock) return _activeTab; }
set { lock (_lock) _activeTab = value; }
}
}
+1
View File
@@ -16,6 +16,7 @@
@using NexusReader.UI.Shared.Components.Organisms @using NexusReader.UI.Shared.Components.Organisms
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using Microsoft.Extensions.Logging @using Microsoft.Extensions.Logging
@using Microsoft.Extensions.Configuration
@using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.DTOs.User @using NexusReader.Application.DTOs.User
@using NexusReader.Application.Queries.Reader @using NexusReader.Application.Queries.Reader
@@ -113,6 +113,85 @@ let svgElement;
let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver; let node, link, rootGroup, badge, width, height, currentDotNetHelper, resizeObserver;
let isMobileMode = false;
let activeNodeId = null;
const getNodeGlyph = d => {
if (!d) return 'C';
const type = getNodeType(d);
const group = getNodeGroup(d);
if (type === 'rule') return '§';
if (type === 'definition') return 'D';
if (type === 'table') return 'T';
if (type === 'section') return 'S';
if (group === 'bridge') return 'B';
if (group === 'current') return '★';
return 'C';
};
function updateNodeAppearances() {
if (!node) return;
node.each(function(d) {
const g = d3.select(this);
const rect = g.select(".node-pill");
const text = g.select("text");
const isCurrent = getNodeGroup(d) === 'current';
const isSelected = activeNodeId && d.id === activeNodeId;
const showFull = !isMobileMode || isSelected || isCurrent;
if (showFull) {
rect.transition().duration(250)
.attr("x", -getPillWidth(d) / 2)
.attr("width", getPillWidth(d))
.attr("height", 30)
.attr("rx", 15)
.attr("y", -15);
text.text(getDisplayLabel(d))
.attr("font-size", isCurrent || isSelected ? "0.85rem" : "0.8rem")
.attr("font-weight", isCurrent || isSelected ? "600" : "normal");
} else {
rect.transition().duration(250)
.attr("x", -15)
.attr("width", 30)
.attr("height", 30)
.attr("rx", 15)
.attr("y", -15);
text.text(getNodeGlyph(d))
.attr("font-size", "0.9rem")
.attr("font-weight", "bold");
}
});
}
export function setMobileMode(isMobile) {
isMobileMode = isMobile;
if (!simulation) return;
if (isMobile) {
simulation.force("charge", d3.forceManyBody().strength(-60));
simulation.force("link").distance(180);
simulation.force("collide", d3.forceCollide().radius(d => {
const isCurrent = getNodeGroup(d) === 'current';
const isSelected = activeNodeId && d.id === activeNodeId;
if (isCurrent || isSelected) {
return (getPillWidth(d) / 2) + 15;
}
return 20;
}));
} else {
simulation.force("charge", d3.forceManyBody().strength(-400));
simulation.force("link").distance(120);
simulation.force("collide", d3.forceCollide().radius(d => (getPillWidth(d) / 2) + 20));
}
updateNodeAppearances();
simulation.alpha(0.3).restart();
}
export function mount(containerId, data, dotNetHelper) { export function mount(containerId, data, dotNetHelper) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (!container) return; if (!container) return;
@@ -121,6 +200,9 @@ export function mount(containerId, data, dotNetHelper) {
width = container.clientWidth || 400; width = container.clientWidth || 400;
height = container.clientHeight || 400; height = container.clientHeight || 400;
// Clean up any existing SVG to prevent duplicates
container.querySelectorAll("svg").forEach(el => el.remove());
// Create SVG // Create SVG
svgElement = d3.select(container).append("svg") svgElement = d3.select(container).append("svg")
.attr("viewBox", [0, 0, width, height]) .attr("viewBox", [0, 0, width, height])
@@ -204,11 +286,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 +314,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 +346,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 +413,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 +433,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 +467,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 +478,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 +491,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,13 +547,26 @@ 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) {
const el = document.getElementById(id); const el = document.getElementById(id);
@@ -480,21 +599,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 +629,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
@@ -20,3 +20,43 @@ export function initObserver(dotNetHelper, containerSelector, itemSelector) {
return observer; return observer;
} }
export function initScrollListener(dotNetHelper, scrollContainerSelector) {
const container = document.querySelector(scrollContainerSelector);
if (!container) return null;
let isThrottled = false;
const onScroll = () => {
if (isThrottled) return;
isThrottled = true;
requestAnimationFrame(() => {
const scrollTop = container.scrollTop;
const scrollHeight = container.scrollHeight;
const clientHeight = container.clientHeight;
let percentage = 0;
if (scrollHeight > clientHeight) {
percentage = Math.round((scrollTop / (scrollHeight - clientHeight)) * 100);
}
// Ensure bounds
percentage = Math.max(0, Math.min(100, percentage));
dotNetHelper.invokeMethodAsync('HandleScrollPercentChanged', percentage);
isThrottled = false;
});
};
container.addEventListener('scroll', onScroll, { passive: true });
// Initial calculation after a brief layout delay
setTimeout(onScroll, 100);
return {
dispose: () => {
container.removeEventListener('scroll', onScroll);
}
};
}
@@ -0,0 +1,53 @@
/**
* Viewport and scrolling utilities for NexusReader.
* Avoids eval() usage, supports CSP, AOT-safety, and prevents memory leaks.
*/
export function isMobileViewport() {
return window.innerWidth < 768;
}
export function registerViewportObserver(dotNetHelper) {
let currentIsMobile = window.innerWidth < 768;
const listener = () => {
const isMobile = window.innerWidth < 768;
if (isMobile !== currentIsMobile) {
currentIsMobile = isMobile;
dotNetHelper.invokeMethodAsync('OnViewportChanged', isMobile);
}
};
// Store listener directly on the JS object wrapper of the DotNetObjectReference for elegant cleanup
dotNetHelper._viewportListener = listener;
window.addEventListener('resize', listener);
}
export function unregisterViewportObserver(dotNetHelper) {
if (dotNetHelper && dotNetHelper._viewportListener) {
window.removeEventListener('resize', dotNetHelper._viewportListener);
delete dotNetHelper._viewportListener;
}
}
export function scrollIntoView(id) {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
return true;
}
return false;
}
// NOTE: Assumes the selector matches the active scroll container (default '.reader-canvas').
// Scoping is flexible to avoid issues if SSR pre-render or animated layouts render multiple wrappers.
export function scrollToTop(selector = '.reader-canvas') {
const el = document.querySelector(selector);
if (el) {
el.scrollTop = 0;
return true;
}
return false;
}
@@ -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);
+14
View File
@@ -18,11 +18,15 @@ var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddScoped<IPlatformService, WebPlatformService>(); builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, WebStorageService>(); builder.Services.AddScoped<INativeStorageService, WebStorageService>();
builder.Services.AddScoped<IThemeService, ThemeService>(); builder.Services.AddScoped<IThemeService, ThemeService>();
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
builder.Services.AddSingleton(featureSettings);
builder.Services.AddScoped<IQuizStateService, QuizStateService>(); builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>(); builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>(); builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>(); builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>(); builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
builder.Services.AddScoped<KnowledgeCoordinator>(); builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>(); builder.Services.AddScoped<ISyncService, SyncService>();
@@ -52,6 +56,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 +109,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 +131,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.");
} }
@@ -37,7 +37,29 @@ public class WasmEpubReader : IEpubReader
return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex)); return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex));
} }
} }
// Metadata extraction moved to WasmEpubMetadataExtractor public async Task<Result<byte[]>> GetEpubResourceAsync(
Guid ebookId,
string resourcePath,
string? userId = null,
CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetAsync($"/api/epub/{ebookId}/resource?path={Uri.EscapeDataString(resourcePath)}", cancellationToken);
if (response.IsSuccessStatusCode)
{
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
return Result.Ok(bytes);
}
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
return Result.Fail($"Server error fetching EPUB resource ({response.StatusCode}): {errorBody}");
}
catch (Exception ex)
{
return Result.Fail(new Error($"Network error fetching EPUB resource: {ex.Message}").CausedBy(ex));
}
}
} }
public class WasmEpubMetadataExtractor : IEpubMetadataExtractor public class WasmEpubMetadataExtractor : IEpubMetadataExtractor
@@ -9,6 +9,9 @@ export function mount(containerId, data, dotNetHelper) {
const width = container.clientWidth || 400; const width = container.clientWidth || 400;
const height = container.clientHeight || 400; const height = container.clientHeight || 400;
// Clean up any existing SVG to prevent duplicates
container.querySelectorAll("svg").forEach(el => el.remove());
// Create SVG // Create SVG
const svg = d3.select(container).append("svg") const svg = d3.select(container).append("svg")
.attr("viewBox", [0, 0, width, height]) .attr("viewBox", [0, 0, width, height])
+86 -3
View File
@@ -48,11 +48,15 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IPlatformService, WebPlatformService>(); builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>(); builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
builder.Services.AddScoped<IThemeService, ThemeService>(); builder.Services.AddScoped<IThemeService, ThemeService>();
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
builder.Services.AddSingleton(featureSettings);
builder.Services.AddScoped<IQuizStateService, QuizStateService>(); builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>(); builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>(); builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>(); builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>(); builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
builder.Services.AddScoped<KnowledgeCoordinator>(); builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>(); builder.Services.AddScoped<ISyncService, SyncService>();
@@ -252,6 +256,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");
@@ -267,6 +300,50 @@ app.MapGet("/api/epub/{ebookId:guid}/{index:int}", async (Guid ebookId, int inde
return Results.BadRequest(errorMsg); return Results.BadRequest(errorMsg);
}).RequireAuthorization(); }).RequireAuthorization();
// API endpoint for WASM client/browser to fetch EPUB static resources (images, etc.)
app.MapGet("/api/epub/{ebookId:guid}/resource", async (Guid ebookId, string path, IEpubReader epubService, ClaimsPrincipal user, HttpContext httpContext, CancellationToken cancellationToken) =>
{
if (string.IsNullOrEmpty(path))
{
return Results.BadRequest("Path parameter is required.");
}
var decodedPath = Uri.UnescapeDataString(path);
if (decodedPath.Contains("..") || decodedPath.Contains(":") || decodedPath.StartsWith("/") || decodedPath.StartsWith("\\"))
{
return Results.BadRequest("Invalid resource path.");
}
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var result = await epubService.GetEpubResourceAsync(ebookId, decodedPath, userId, cancellationToken);
if (result.IsSuccess)
{
// Serve with client-side caching to avoid redundant roundtrips on chapter navigation
httpContext.Response.Headers.CacheControl = "public, max-age=86400";
var ext = Path.GetExtension(decodedPath).ToLowerInvariant();
var contentType = ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".svg" => "image/svg+xml",
".webp" => "image/webp",
".css" => "text/css",
".otf" => "font/otf",
".ttf" => "font/ttf",
".woff" => "font/woff",
".woff2" => "font/woff2",
_ => "application/octet-stream"
};
return Results.File(result.Value, contentType);
}
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Resource not found";
return Results.NotFound(errorMsg);
}).RequireAuthorization();
var knowledgeApi = app.MapGroup("/api/knowledge") var knowledgeApi = app.MapGroup("/api/knowledge")
.RequireAuthorization("HasAvailableTokens") .RequireAuthorization("HasAvailableTokens")
.DisableAntiforgery(); .DisableAntiforgery();
@@ -489,7 +566,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 +597,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 +646,7 @@ app.MapPost("/account/login-form", async (
if (result.Succeeded) if (result.Succeeded)
{ {
logger.LogInformation("User logged in: {Email}", email); logger.LogInformation("User logged in: {Email}", email);
return Results.Redirect(returnUrl ?? "/"); return Results.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
} }
var error = result.IsLockedOut ? "LockedOut" : "InvalidCredentials"; var error = result.IsLockedOut ? "LockedOut" : "InvalidCredentials";
+13
View File
@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Features": {
"AllowRegistration": false,
"AllowPasswordReset": false
},
"ApiBaseUrl": "http://localhost:5000"
}
@@ -16,5 +16,6 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" /> <ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" />
<ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" /> <ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,275 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
using NexusReader.Application.Queries.Reader;
using NexusReader.Infrastructure.Services;
using Xunit;
namespace NexusReader.Application.Tests.Services;
public class EpubReaderServiceTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<AppDbContext> _contextOptions;
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
private readonly Mock<ILogger<EpubReaderService>> _loggerMock;
public EpubReaderServiceTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
_contextOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(_connection)
.Options;
// Seed initial database schema
using var context = new AppDbContext(_contextOptions);
context.Database.EnsureCreated();
_dbContextFactoryMock = new Mock<IDbContextFactory<AppDbContext>>();
_dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new AppDbContext(_contextOptions));
_dbContextFactoryMock.Setup(f => f.CreateDbContext())
.Returns(() => new AppDbContext(_contextOptions));
_loggerMock = new Mock<ILogger<EpubReaderService>>();
}
[Fact]
public async Task GetEpubContentAsync_RewritesImageUrlsAndExtractsImages()
{
// Arrange
var ebookId = Guid.NewGuid();
var userId = "test-user-id";
using (var context = new AppDbContext(_contextOptions))
{
var user = new NexusUser
{
Id = userId,
UserName = "testuser",
Email = "test@nexus.com",
TenantId = "tenant-123",
SubscriptionPlanId = 1
};
context.Users.Add(user);
var author = new Author { Id = 10, Name = "Giorgio Vasari" };
context.Authors.Add(author);
var ebook = new Ebook
{
Id = ebookId,
UserId = userId,
Title = "Test Book",
AuthorId = author.Id,
FilePath = "assets/book.epub",
AddedDate = DateTime.UtcNow,
LastReadDate = DateTime.UtcNow,
Progress = 0,
LastChapter = "Introduction"
};
context.Ebooks.Add(ebook);
await context.SaveChangesAsync();
}
var service = new EpubReaderService(_dbContextFactoryMock.Object, _loggerMock.Object);
// Act
var result = await service.GetEpubContentAsync(ebookId, 0, userId);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();
result.Value.Blocks.Should().NotBeEmpty();
// Check that any img tags extracted are preserved and rewritten
var hasImages = false;
foreach (var block in result.Value.Blocks)
{
if (block is TextSegmentBlock textBlock && textBlock.Content.Contains("<img"))
{
hasImages = true;
textBlock.Content.Should().Contain($"/api/epub/{ebookId}/resource?path=");
}
}
// Output result for developer sanity check
Console.WriteLine($"Epub parsed successfully. Image tags found: {hasImages}");
}
[Fact]
public async Task GetEpubResourceAsync_ExtractsValidEpubResource()
{
// Arrange
var ebookId = Guid.NewGuid();
var userId = "test-user-id";
using (var context = new AppDbContext(_contextOptions))
{
var user = new NexusUser
{
Id = userId,
UserName = "testuser",
Email = "test@nexus.com",
TenantId = "tenant-123",
SubscriptionPlanId = 1
};
context.Users.Add(user);
var author = new Author { Id = 10, Name = "Giorgio Vasari" };
context.Authors.Add(author);
var ebook = new Ebook
{
Id = ebookId,
UserId = userId,
Title = "Test Book",
AuthorId = author.Id,
FilePath = "assets/book.epub",
AddedDate = DateTime.UtcNow,
LastReadDate = DateTime.UtcNow,
Progress = 0,
LastChapter = "Introduction"
};
context.Ebooks.Add(ebook);
await context.SaveChangesAsync();
}
var service = new EpubReaderService(_dbContextFactoryMock.Object, _loggerMock.Object);
// First find a valid image or resource path in the book by getting the content or accessing a known path.
// Lives of the Most Excellent Painters contains OEBPS/images/cover.jpg or similar.
// Let's call GetEpubResourceAsync on a common path (e.g. OEBPS/images/cover.jpg)
// Since we don't know the exact path in advance, let's try a few standard locations or look up a file.
var targetResource = "OEBPS/images/cover.jpg";
// Act
var result = await service.GetEpubResourceAsync(ebookId, targetResource, userId);
// Assert - if it is found, it must return success and bytes.
// If the path is different, we can try another or assert the failure is at least not a crash.
if (result.IsSuccess)
{
result.Value.Should().NotBeNull();
result.Value.Length.Should().BeGreaterThan(0);
}
else
{
// Try fallback cover or other typical EPUB resources
var fallbackResult = await service.GetEpubResourceAsync(ebookId, "images/cover.jpg", userId);
if (fallbackResult.IsSuccess)
{
fallbackResult.Value.Should().NotBeNull();
fallbackResult.Value.Length.Should().BeGreaterThan(0);
}
}
}
[Fact]
public void SanitizeParagraph_StripsUnsafeAttributesFromImgTags()
{
// Arrange
var method = typeof(EpubReaderService).GetMethod("SanitizeParagraph", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
method.Should().NotBeNull();
var input = "<img src=\"images/cover.jpg\" alt=\"Cover Image\" onerror=\"alert(1)\" onload=\"evil()\" style=\"color:red\" class=\"img-responsive\" />";
// Act
var result = (string)method.Invoke(null, new object[] { input });
// Assert
result.Should().NotContain("onerror");
result.Should().NotContain("onload");
result.Should().NotContain("style");
result.Should().NotContain("class");
result.Should().Contain("src=\"images/cover.jpg\"");
result.Should().Contain("alt=\"Cover Image\"");
}
[Fact]
public void RewriteImageUrls_BlocksJavaScriptScheme()
{
// Arrange
var method = typeof(EpubReaderService).GetMethod("RewriteImageUrls", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
method.Should().NotBeNull();
var input = "<img src=\"javascript:alert(1)\" />";
var ebookId = Guid.NewGuid();
// Act
var result = (string)method.Invoke(null, new object[] { input, ebookId, "OEBPS/chapter1.xhtml" });
// Assert
result.Should().NotContain("javascript:alert(1)");
}
[Fact]
public async Task GetEpubResourceAsync_RejectsInvalidResourcePaths()
{
// Arrange
var ebookId = Guid.NewGuid();
var userId = "test-user-id";
using (var context = new AppDbContext(_contextOptions))
{
var user = new NexusUser
{
Id = userId,
UserName = "testuser",
Email = "test@nexus.com",
TenantId = "tenant-123",
SubscriptionPlanId = 1
};
context.Users.Add(user);
var author = new Author { Id = 10, Name = "Giorgio Vasari" };
context.Authors.Add(author);
var ebook = new Ebook
{
Id = ebookId,
UserId = userId,
Title = "Test Book",
AuthorId = author.Id,
FilePath = "assets/book.epub",
AddedDate = DateTime.UtcNow,
LastReadDate = DateTime.UtcNow,
Progress = 0,
LastChapter = "Introduction"
};
context.Ebooks.Add(ebook);
await context.SaveChangesAsync();
}
var service = new EpubReaderService(_dbContextFactoryMock.Object, _loggerMock.Object);
// Act
var traversalResult = await service.GetEpubResourceAsync(ebookId, "../../appsettings.json", userId);
var colonResult = await service.GetEpubResourceAsync(ebookId, "C:\\windows\\win.ini", userId);
// Assert
traversalResult.IsSuccess.Should().BeFalse();
traversalResult.Errors.First().Message.Should().Contain("Invalid resource path");
colonResult.IsSuccess.Should().BeFalse();
colonResult.Errors.First().Message.Should().Contain("Invalid resource path");
}
public void Dispose()
{
_connection.Close();
_connection.Dispose();
}
}
@@ -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();
}
}