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
27 changed files with 1963 additions and 238 deletions
Showing only changes of commit 21c9a66cce - Show all commits
+1 -1
View File
@@ -32,7 +32,7 @@ GOOGLE_CLIENT_SECRET=placeholder
GOOGLE_AI_API_KEY=placeholder GOOGLE_AI_API_KEY=placeholder
# === Admin Seed Password === # === Admin Seed Password ===
NEXUS_ADMIN_PASSWORD=aQ13EdSw2 NEXUS_ADMIN_PASSWORD=CHANGE_ME
# === Non-standard ports for auxiliary services === # === Non-standard ports for auxiliary services ===
QDRANT_HTTP_PORT=6343 QDRANT_HTTP_PORT=6343
+1 -1
View File
1
@@ -36,7 +36,7 @@ services:
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder} - Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder}
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder} - Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder}
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder} - Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder}
- NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:-aQ13EdSw2} - NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:?NEXUS_ADMIN_PASSWORD is required}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
2
@@ -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,10 @@ public static class DbInitializer
SecurityStamp = Guid.NewGuid().ToString() SecurityStamp = Guid.NewGuid().ToString()
}; };
var adminPassword = Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD") ?? "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")
?? "Admin123!";
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, adminPassword); adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, adminPassword);
dbContext.Users.Add(adminUser); dbContext.Users.Add(adminUser);
+1
View File
@@ -69,6 +69,7 @@ public static class MauiProgram
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

@@ -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
@@ -0,0 +1,351 @@
@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 AuthenticationStateProvider AuthStateProvider
@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 {
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback OnClose { 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",
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",
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.
Text = userQuestion,
Segments = new List<ResponseSegment> { new ResponseSegment { Text = userQuestion, IsCitation = false } }
});
StateHasChanged();
try
{
Guid? ebookId = null;
if (NavigationService.CurrentEbookId != Guid.Empty)
{
ebookId = NavigationService.CurrentEbookId;
}
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "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
{
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`.
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 regex = new System.Text.RegularExpressions.Regex(
@"\[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);
var matches = regex.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 = System.Text.RegularExpressions.Regex.Replace(html, @"\*\*(.*?)\*\*", "<strong>$1</strong>");
html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "<em>$1</em>");
html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "<pre class=\"nexus-mobile-code-block\"><code>$1</code></pre>");
html = System.Text.RegularExpressions.Regex.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,13 +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 IQuizStateService QuizService
@inject IPlatformService PlatformService @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">
@@ -56,16 +80,7 @@
Coordinates="@_selectionCoords" Coordinates="@_selectionCoords"
FullPageContent="@GetFullPageContent()" /> FullPageContent="@GetFullPageContent()" />
@if (_isMobile)
{
<button class="nexus-mobile-assistant-fab @(QuizService.HasNewQuiz ? "has-new-quiz" : "")" @onclick="HandleAssistantFabClick" aria-label="Asystent AI">
<NexusIcon Name="robot" Size="24" Class="neon-glow" />
@if (QuizService.HasNewQuiz)
{
<span class="fab-badge"></span>
}
</button>
}
</div> </div>
@code { @code {
@@ -83,6 +98,7 @@
private string? _currentActiveBlockId; private string? _currentActiveBlockId;
private bool _isMobile = false; private bool _isMobile = false;
private DotNetObjectReference<ReaderCanvas>? _selfReference; private DotNetObjectReference<ReaderCanvas>? _selfReference;
private IJSObjectReference? _viewportModule;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -147,23 +163,11 @@
{ {
try try
{ {
_viewportModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
_selfReference = DotNetObjectReference.Create(this); _selfReference = DotNetObjectReference.Create(this);
var isMobileViewport = await JS.InvokeAsync<bool>("eval", "window.innerWidth < 768"); var isMobileViewport = await _viewportModule.InvokeAsync<bool>("isMobileViewport");
await OnViewportChanged(isMobileViewport); await OnViewportChanged(isMobileViewport);
await _viewportModule.InvokeVoidAsync("registerViewportObserver", _selfReference);
await JS.InvokeVoidAsync("eval", @"
window.registerCanvasViewportObserver = (dotNetHelper) => {
let currentIsMobile = window.innerWidth < 768;
window.addEventListener('resize', () => {
let isMobile = window.innerWidth < 768;
if (isMobile !== currentIsMobile) {
currentIsMobile = isMobile;
dotNetHelper.invokeMethodAsync('OnViewportChanged', isMobile);
}
});
}
");
await JS.InvokeVoidAsync("registerCanvasViewportObserver", _selfReference);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -194,12 +198,15 @@
} }
} }
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");
mjasin marked this conversation as resolved Outdated
Outdated
Review

🟡 Design — DotNetObjectReference created inside InitializeObserverAsync without being stored or disposed

In InitializeObserverAsync, a new DotNetObjectReference.Create(this) is passed to initObserver. This reference is not assigned to a field, so it cannot be disposed in DisposeAsync. The unmanaged JS side will hold a reference to the GC'd .NET object indefinitely, which is a memory leak.

The same pattern was correctly fixed for _selfReference (for the viewport observer). Apply the same treatment here — create a second _observerSelfReference field, assign it, and dispose it in DisposeAsync:

// At field level:
private DotNetObjectReference<ReaderCanvas>? _observerSelfReference;

// In InitializeObserverAsync:
_observerSelfReference = DotNetObjectReference.Create(this);
await module.InvokeVoidAsync("initObserver", _observerSelfReference, ...);

// In DisposeAsync:
_observerSelfReference?.Dispose();
🟡 **Design — `DotNetObjectReference` created inside `InitializeObserverAsync` without being stored or disposed** In `InitializeObserverAsync`, a new `DotNetObjectReference.Create(this)` is passed to `initObserver`. This reference is not assigned to a field, so it cannot be disposed in `DisposeAsync`. The unmanaged JS side will hold a reference to the GC'd .NET object indefinitely, which is a memory leak. The same pattern was correctly fixed for `_selfReference` (for the viewport observer). Apply the same treatment here — create a second `_observerSelfReference` field, assign it, and dispose it in `DisposeAsync`: ```csharp // At field level: private DotNetObjectReference<ReaderCanvas>? _observerSelfReference; // In InitializeObserverAsync: _observerSelfReference = DotNetObjectReference.Create(this); await module.InvokeVoidAsync("initObserver", _observerSelfReference, ...); // In DisposeAsync: _observerSelfReference?.Dispose(); ```
await module.InvokeVoidAsync("initObserver", DotNetObjectReference.Create(this), ".reader-flow-container", ".block-wrapper"); await module.InvokeVoidAsync("initObserver", DotNetObjectReference.Create(this), ".reader-flow-container", ".block-wrapper");
_scrollListenerReference = await module.InvokeAsync<IJSObjectReference>("initScrollListener", DotNetObjectReference.Create(this), ".reader-flow-container");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -207,10 +214,19 @@
} }
} }
[JSInvokable]
public async Task HandleScrollPercentChanged(int percent)
{
StateService.CurrentScrollPercentage = percent;
await InteractionService.NotifyScrollPercentChanged(percent);
}
mjasin marked this conversation as resolved Outdated
Outdated
Review

🟡 Design — Still unresolved. Untracked DotNetObjectReference in InitializeObserverAsync.

DotNetObjectReference.Create(this) is called twice inside InitializeObserverAsync (once for initObserver and once already implicitly for initScrollListener via the _scrollListenerReference returned value). The reference passed to initObserver is not stored in a field and therefore cannot be disposed, creating a GC-root memory leak from the JS side.

Create a dedicated _observerSelfReference field:

// Field
private DotNetObjectReference<ReaderCanvas>? _observerSelfReference;

// In InitializeObserverAsync:
_observerSelfReference = DotNetObjectReference.Create(this);
await module.InvokeVoidAsync("initObserver", _observerSelfReference, ".reader-flow-container", ".block-wrapper");

// In DisposeAsync:
_observerSelfReference?.Dispose();
🟡 **Design — Still unresolved. Untracked `DotNetObjectReference` in `InitializeObserverAsync`.** `DotNetObjectReference.Create(this)` is called twice inside `InitializeObserverAsync` (once for `initObserver` and once already implicitly for `initScrollListener` via the `_scrollListenerReference` returned value). The reference passed to `initObserver` is not stored in a field and therefore cannot be disposed, creating a GC-root memory leak from the JS side. Create a dedicated `_observerSelfReference` field: ```csharp // Field private DotNetObjectReference<ReaderCanvas>? _observerSelfReference; // In InitializeObserverAsync: _observerSelfReference = DotNetObjectReference.Create(this); await module.InvokeVoidAsync("initObserver", _observerSelfReference, ".reader-flow-container", ".block-wrapper"); // In DisposeAsync: _observerSelfReference?.Dispose(); ```
[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)
@@ -310,6 +326,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);
@@ -342,7 +365,8 @@
{ {
try try
{ {
await JS.InvokeVoidAsync("eval", $"document.getElementById('{id}')?.scrollIntoView({{ behavior: 'smooth', block: 'center' }});"); var module = _viewportModule ?? await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
await module.InvokeVoidAsync("scrollIntoView", id);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -352,12 +376,20 @@
private Task HandleUpdate() => InvokeAsync(StateHasChanged); private Task HandleUpdate() => InvokeAsync(StateHasChanged);
private void HandleEscape()
{
if (ViewModel != null)
{
Navigation.NavigateTo("/");
}
}
private async Task HandleAssistantFabClick() private async Task HandleAssistantFabClick()
{ {
await InteractionService.RequestAssistant(); await InteractionService.RequestAssistant();
} }
public void Dispose() public async ValueTask DisposeAsync()
{ {
ThemeService.OnThemeChanged -= HandleUpdate; ThemeService.OnThemeChanged -= HandleUpdate;
NavigationService.OnNavigationChanged -= OnNavigationChanged; NavigationService.OnNavigationChanged -= OnNavigationChanged;
@@ -367,6 +399,32 @@
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 { }
_selfReference?.Dispose(); _selfReference?.Dispose();
} }
} }
@@ -258,4 +258,118 @@
@keyframes fadeIn { @keyframes fadeIn {
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);
} }
@@ -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,12 +10,14 @@
@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" : "") @($"active-mobile-tab-{_activeMobileTab.ToString().ToLower()}")"> <div class="app-container @_platformClass @(FocusMode.IsFocusModeActive ? "focus-mode-active" : "") @($"active-mobile-tab-{_activeMobileTab.ToString().ToLower()}")">
<div class="reader-pane"> <div class="reader-pane">
@@ -141,8 +144,8 @@
<KnowledgeGraph /> <KnowledgeGraph />
</div> </div>
<!-- Tab 3: Insight (Contextual Intelligence AND Knowledge Quiz) --> <!-- Tab 3: Concepts/Quiz -->
<div class="nexus-mobile-tab-content insight-tab @(_activeMobileTab == MobileReaderTab.Insight ? "active" : "")"> <div class="nexus-mobile-tab-content insight-tab @(_activeMobileTab == MobileReaderTab.Concepts ? "active" : "")">
<div class="mobile-insight-container"> <div class="mobile-insight-container">
<div class="mobile-insight-header"> <div class="mobile-insight-header">
<div class="mobile-insight-nav"> <div class="mobile-insight-nav">
@@ -223,27 +226,14 @@
</div> </div>
</div> </div>
<!-- Three-Tab Fixed Bottom Navigation Bar --> <MobileReaderToolbar
<div class="nexus-mobile-bottom-nav"> ScrollPercentage="@_scrollPercentage"
<button class="bottom-nav-item @(_activeMobileTab == MobileReaderTab.Reader ? "active" : "")" @onclick="() => SetMobileTab(MobileReaderTab.Reader)"> ActiveTab="@_activeMobileTab"
<NexusIcon Name="book-open" Size="20" /> OnTabChanged="SetMobileTab"
<span>Czytnik</span> OnAssistantClick="OpenAssistant"
</button> Checkpoints="@StateService.CurrentCheckpoints" />
<button class="bottom-nav-item @(_activeMobileTab == MobileReaderTab.Graph ? "active" : "")" @onclick="() => SetMobileTab(MobileReaderTab.Graph)">
<NexusIcon Name="network" Size="20" /> <GlobalIntelligence IsOpen="@_isAssistantOpen" OnClose="CloseAssistant" />
<span>Wykres</span>
</button>
<button class="bottom-nav-item @(_activeMobileTab == MobileReaderTab.Insight ? "active" : "")" @onclick="() => SetMobileTab(MobileReaderTab.Insight)">
<span class="insight-icon-wrapper">
<NexusIcon Name="brain" Size="20" />
@if (QuizService.HasNewQuiz)
{
<span class="nav-quiz-indicator"></span>
}
</span>
<span>Analiza</span>
</button>
</div>
} }
</Authorized> </Authorized>
<Authorizing> <Authorizing>
@@ -268,21 +258,28 @@
Quiz Quiz
} }
private enum MobileReaderTab
{
Reader,
Graph,
Insight
}
private SidebarTab _activeTab = SidebarTab.Knowledge; private SidebarTab _activeTab = SidebarTab.Knowledge;
private MobileReaderTab _activeMobileTab = MobileReaderTab.Reader;
private string? _selectedNodeId; private string? _selectedNodeId;
private GraphNodeDto? _selectedNode; private GraphNodeDto? _selectedNode;
private string _platformClass = "platform-desktop"; private string _platformClass = "platform-desktop";
private bool _isMobile = false; private bool _isMobile = false;
private DotNetObjectReference<ReaderLayout>? _selfReference; 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()
{ {
@@ -292,6 +289,7 @@
InteractionService.OnNodeSelected += HandleNodeSelectedAsync; InteractionService.OnNodeSelected += HandleNodeSelectedAsync;
InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync; InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync;
InteractionService.OnScrollPercentChanged += HandleScrollPercentChanged;
GraphService.OnGraphUpdated += HandleGraphUpdatedAsync; GraphService.OnGraphUpdated += HandleGraphUpdatedAsync;
var context = PlatformService.GetDeviceContext(); var context = PlatformService.GetDeviceContext();
@@ -319,20 +317,45 @@
StateHasChanged(); 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) if (_isMobile)
{ {
_activeMobileTab = MobileReaderTab.Insight; _activeMobileTab = MobileReaderTab.Concepts;
} }
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
private async Task HandleAssistantRequestedAsync() private async Task HandleAssistantRequestedAsync()
{ {
_activeMobileTab = MobileReaderTab.Insight; if (_isMobile)
_activeTab = SidebarTab.Quiz; {
OpenAssistant();
}
else
{
_activeMobileTab = MobileReaderTab.Concepts;
_activeTab = SidebarTab.Quiz;
}
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
@@ -345,7 +368,7 @@
} }
if (_isMobile) if (_isMobile)
{ {
_activeMobileTab = MobileReaderTab.Insight; _activeMobileTab = MobileReaderTab.Concepts;
_activeTab = SidebarTab.Knowledge; _activeTab = SidebarTab.Knowledge;
} }
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
@@ -372,31 +395,27 @@
Logger.LogError(ex, "Failed to initialize layout resizer JS module."); Logger.LogError(ex, "Failed to initialize layout resizer JS module.");
} }
await InitViewportDetectionAsync(); 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() private async Task InitViewportDetectionAsync()
{ {
if (_viewportModule == null) return;
try try
{ {
_selfReference = DotNetObjectReference.Create(this); _selfReference = DotNetObjectReference.Create(this);
var isMobileViewport = await JS.InvokeAsync<bool>("eval", "window.innerWidth < 768"); var isMobileViewport = await _viewportModule.InvokeAsync<bool>("isMobileViewport");
await OnViewportChanged(isMobileViewport); await OnViewportChanged(isMobileViewport);
await _viewportModule.InvokeVoidAsync("registerViewportObserver", _selfReference);
await JS.InvokeVoidAsync("eval", @"
window.registerViewportObserver = (dotNetHelper) => {
let currentIsMobile = window.innerWidth < 768;
window.addEventListener('resize', () => {
let isMobile = window.innerWidth < 768;
if (isMobile !== currentIsMobile) {
currentIsMobile = isMobile;
dotNetHelper.invokeMethodAsync('OnViewportChanged', isMobile);
}
});
}
");
await JS.InvokeVoidAsync("registerViewportObserver", _selfReference);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -417,14 +436,34 @@
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.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(); _selfReference?.Dispose();
} }
} }
1
@@ -479,7 +479,7 @@ main {
.platform-mobile .reader-pane { .platform-mobile .reader-pane {
width: 100vw !important; width: 100vw !important;
height: calc(100vh - 60px) !important; /* reserve bottom nav height */ height: 100vh !important; /* full viewport height */
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@@ -496,7 +496,7 @@ main {
display: block; display: block;
} }
.app-container.platform-mobile.active-mobile-tab-insight .nexus-mobile-reader-tabs .insight-tab { .app-container.platform-mobile.active-mobile-tab-concepts .nexus-mobile-reader-tabs .insight-tab {
display: block; display: block;
} }
@@ -508,7 +508,7 @@ main {
.platform-mobile .nexus-mobile-reader-tabs { .platform-mobile .nexus-mobile-reader-tabs {
display: none; /* Keep hidden by default */ display: none; /* Keep hidden by default */
width: 100vw; width: 100vw;
height: calc(100vh - 60px); height: 100vh; /* full viewport height */
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@@ -518,8 +518,8 @@ main {
} }
.app-container.platform-mobile.active-mobile-tab-graph .nexus-mobile-reader-tabs, .app-container.platform-mobile.active-mobile-tab-graph .nexus-mobile-reader-tabs,
.app-container.platform-mobile.active-mobile-tab-insight .nexus-mobile-reader-tabs { .app-container.platform-mobile.active-mobile-tab-concepts .nexus-mobile-reader-tabs {
display: block; /* Show only when graph or insight tabs are active */ display: block; /* Show only when graph or concepts tabs are active */
} }
.nexus-mobile-tab-content { .nexus-mobile-tab-content {
@@ -553,7 +553,17 @@ main {
background: #09090b; background: #09090b;
} }
.nexus-mobile-tab-content.graph-tab :deep(svg) { .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; width: 100% !important;
height: 100% !important; height: 100% !important;
} }
@@ -637,121 +647,4 @@ main {
overflow-y: auto; overflow-y: auto;
} }
/* Three-Tab Bottom Navigation Bar styling */ /* Obsolescence managed: consolidated mobile toolbar and sheet styled inside respective components */
.nexus-mobile-bottom-nav {
display: none;
}
.platform-mobile .nexus-mobile-bottom-nav {
display: flex;
justify-content: space-around;
align-items: center;
position: absolute;
bottom: 0;
left: 0;
width: 100vw;
height: 60px;
background: rgba(13, 13, 13, 0.95);
backdrop-filter: blur(16px);
border-top: 1px solid rgba(255, 255, 255, 0.05);
z-index: 100;
}
.bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
height: 100%;
background: none;
border: none;
color: rgba(255, 255, 255, 0.4);
font-family: var(--nexus-font-sans, "Outfit", sans-serif);
font-size: 0.65rem;
font-weight: 500;
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.bottom-nav-item.active {
color: var(--nexus-neon, #00f0ff);
text-shadow: 0 0 10px rgba(0, 240, 255, 0.2);
}
.bottom-nav-item.active :deep(svg) {
filter: drop-shadow(0 0 5px var(--nexus-neon, #00f0ff));
}
.insight-icon-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.nav-quiz-indicator {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background-color: #f43f5e;
border-radius: 50%;
box-shadow: 0 0 8px #f43f5e;
animation: indicator-flash 1.5s infinite ease-in-out;
}
@keyframes indicator-flash {
0% { transform: scale(0.8); opacity: 0.6; }
50% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(0.8); opacity: 0.6; }
}
/* Assistant FAB styling inside ReaderCanvas */
:global(.nexus-mobile-assistant-fab) {
position: fixed;
bottom: 75px;
right: 20px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(0, 240, 255, 0.15) 0%, rgba(0, 100, 255, 0.15) 100%);
border: 1px solid rgba(0, 240, 255, 0.4);
box-shadow: 0 4px 20px rgba(0, 240, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 99;
backdrop-filter: blur(8px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
:global(.nexus-mobile-assistant-fab:hover) {
transform: scale(1.1) translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 240, 255, 0.4);
border-color: var(--nexus-neon, #00f0ff);
}
:global(.nexus-mobile-assistant-fab:active) {
transform: scale(0.95);
}
:global(.nexus-mobile-assistant-fab.has-new-quiz) {
border-color: #f43f5e;
box-shadow: 0 4px 20px rgba(244, 63, 94, 0.3);
}
:global(.nexus-mobile-assistant-fab .fab-badge) {
position: absolute;
top: 2px;
right: 2px;
width: 10px;
height: 10px;
background-color: #f43f5e;
border-radius: 50%;
box-shadow: 0 0 10px #f43f5e;
animation: indicator-flash 1.5s infinite ease-in-out;
}
@@ -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;
}
@@ -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
@@ -109,21 +109,16 @@ else
private void LogInfo() private void LogInfo()
{ {
#if DEBUG
Logger.LogInformation("Structured native log triggered by user from SerilogDemo. Button: LogInfo"); Logger.LogInformation("Structured native log triggered by user from SerilogDemo. Button: LogInfo");
#endif
} }
private void LogWarning() private void LogWarning()
{ {
#if DEBUG
Logger.LogWarning("Potential warning log triggered from Blazor razor component at {Time}", DateTime.UtcNow); Logger.LogWarning("Potential warning log triggered from Blazor razor component at {Time}", DateTime.UtcNow);
#endif
} }
private void LogError() private void LogError()
{ {
#if DEBUG
try try
{ {
throw new InvalidOperationException("Simulated native C# operation exception triggered in Diagnostic dashboard."); throw new InvalidOperationException("Simulated native C# operation exception triggered in Diagnostic dashboard.");
@@ -132,22 +127,31 @@ else
{ {
Logger.LogError(ex, "Captured exception successfully in native Serilog pipeline!"); Logger.LogError(ex, "Captured exception successfully in native Serilog pipeline!");
} }
#endif
} }
private async Task TriggerJsLog() private async Task TriggerJsLog()
{ {
#if DEBUG try
await JSRuntime.InvokeVoidAsync("console.log", "Intercepted JS console statement from Blazor WebView interop trigger!"); {
#endif await JSRuntime.InvokeVoidAsync("console.log", "Intercepted JS console statement from Blazor WebView interop trigger!");
await Task.CompletedTask; }
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to execute console.log from diagnostic panel.");
}
} }
private async Task TriggerJsException() private async Task TriggerJsException()
{ {
#if DEBUG try
await JSRuntime.InvokeVoidAsync("eval", "throw new Error('Simulated runtime JS Exception triggered from Blazor UI button click!');"); {
#endif // Triggers a TypeError by invoking a non-existent method, which is completely CSP-compliant and works without eval()
await Task.CompletedTask; await JSRuntime.InvokeVoidAsync("window.nonExistentFunctionTriggeringException");
}
catch (Exception ex)
{
Logger.LogError(ex, "Simulated runtime JS Exception triggered and captured in Blazor UI");
}
} }
} }
@@ -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
@@ -7,12 +9,15 @@ public interface IReaderInteractionService
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<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 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; }
}
@@ -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
@@ -7,6 +9,8 @@ public sealed class ReaderInteractionService : IReaderInteractionService
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<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)
{ {
@@ -32,4 +36,15 @@ public sealed class ReaderInteractionService : IReaderInteractionService
{ {
if (OnAssistantRequested != null) await OnAssistantRequested(); 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,39 @@
using NexusReader.UI.Shared.Models;
namespace NexusReader.UI.Shared.Services;
/// <summary>
/// Thread-safe implementation of IReaderStateService.
/// </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; }
}
}
@@ -200,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])
@@ -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,40 @@
/**
* 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;
}
+1
View File
@@ -23,6 +23,7 @@ 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>();
@@ -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])
+1
View File
@@ -53,6 +53,7 @@ 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>();