refactor(ui/security): centralize state management, remove eval/async-void, enforce secure configuration, and implement interactive mobile citations

This commit is contained in:
2026-05-31 19:53:36 +02:00
parent 57b988e16f
commit 7fbbdc6139
20 changed files with 431 additions and 143 deletions
+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
@@ -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
@@ -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"]
?? 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>();
@@ -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
@@ -3,6 +3,7 @@
@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.Services @using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Models
@using System.Net.Http.Json @using System.Net.Http.Json
@namespace NexusReader.UI.Shared.Components.Organisms @namespace NexusReader.UI.Shared.Components.Organisms
@inject HttpClient Http @inject HttpClient Http
@@ -136,6 +137,34 @@
</button> </button>
</div> </div>
</footer> </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>
</div> </div>
@@ -147,23 +176,7 @@
private bool _isLoading; private bool _isLoading;
private string _activeBookTitle = string.Empty; private string _activeBookTitle = string.Empty;
private List<ChatMessage> _chatMessages = new(); private List<ChatMessage> _chatMessages = new();
private CitationDto? _selectedCitation;
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 OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
@@ -191,7 +204,20 @@
private void HandleCitationClick(string citationId) private void HandleCitationClick(string citationId)
{ {
// For mobile, citations are simple notifications or alerts, or scroll requests _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() private async Task AskQuestionAsync()
@@ -414,3 +414,132 @@
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 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; }
}
@@ -1,8 +1,10 @@
@using NexusReader.UI.Shared.Components.Atoms @using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Models
@using NexusReader.Application.Utilities @using NexusReader.Application.Utilities
@namespace NexusReader.UI.Shared.Components.Organisms @namespace NexusReader.UI.Shared.Components.Organisms
@inject IReaderInteractionService InteractionService @inject IReaderInteractionService InteractionService
@inject IReaderStateService StateService
<div class="nexus-unified-mobile-toolbar"> <div class="nexus-unified-mobile-toolbar">
<!-- LEFT SLOT: Progress & Section Checkpoints --> <!-- LEFT SLOT: Progress & Section Checkpoints -->
@@ -77,7 +79,7 @@
<div class="checkpoints-list"> <div class="checkpoints-list">
@foreach (var cp in Checkpoints) @foreach (var cp in Checkpoints)
{ {
var isCurrent = cp == InteractionService.CurrentBlockId; var isCurrent = cp == StateService.CurrentBlockId;
<div class="checkpoint-item @(isCurrent ? "active" : "")" @onclick="() => SelectCheckpoint(cp)"> <div class="checkpoint-item @(isCurrent ? "active" : "")" @onclick="() => SelectCheckpoint(cp)">
<div class="checkpoint-indicator"> <div class="checkpoint-indicator">
<div class="indicator-dot"></div> <div class="indicator-dot"></div>
@@ -105,12 +107,6 @@
private bool IsCheckpointsOpen { get; set; } private bool IsCheckpointsOpen { get; set; }
public enum MobileReaderTab
{
Reader,
Graph,
Concepts
}
private double GetDashOffset() private double GetDashOffset()
{ {
@@ -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,6 +12,7 @@
@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
@@ -96,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()
{ {
@@ -160,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)
{ {
@@ -226,6 +217,7 @@
[JSInvokable] [JSInvokable]
public async Task HandleScrollPercentChanged(int percent) public async Task HandleScrollPercentChanged(int percent)
{ {
StateService.CurrentScrollPercentage = percent;
await InteractionService.NotifyScrollPercentChanged(percent); await InteractionService.NotifyScrollPercentChanged(percent);
} }
@@ -233,6 +225,7 @@
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 InteractionService.NotifyBlockReached(blockId);
await Coordinator.OnBlockReachedAsync(blockId, content); await Coordinator.OnBlockReachedAsync(blockId, content);
@@ -338,7 +331,7 @@
.Where(b => !string.IsNullOrEmpty(b.Id) && b.Id.Contains("seg")) .Where(b => !string.IsNullOrEmpty(b.Id) && b.Id.Contains("seg"))
.Select(b => b.Id) .Select(b => b.Id)
.ToList(); .ToList();
InteractionService.CurrentCheckpoints = checkpoints; StateService.CurrentCheckpoints = checkpoints;
if (_isInteractive) if (_isInteractive)
{ {
@@ -372,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)
{ {
@@ -395,7 +389,7 @@
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;
@@ -405,15 +399,32 @@
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested; InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
InteractionService.OnTextSelected -= HandleTextSelected; InteractionService.OnTextSelected -= HandleTextSelected;
SyncService.OnProgressReceived -= HandleSyncProgressReceived; SyncService.OnProgressReceived -= HandleSyncProgressReceived;
_selfReference?.Dispose();
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 try
{ {
if (_scrollListenerReference != null) if (_scrollListenerReference != null)
{ {
_ = _scrollListenerReference.DisposeAsync(); await _scrollListenerReference.DisposeAsync();
} }
} }
catch { } catch { }
_selfReference?.Dispose();
} }
} }
@@ -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">
@@ -225,10 +228,10 @@
<MobileReaderToolbar <MobileReaderToolbar
ScrollPercentage="@_scrollPercentage" ScrollPercentage="@_scrollPercentage"
ActiveTab="@GetToolbarTab(_activeMobileTab)" ActiveTab="@_activeMobileTab"
OnTabChanged="HandleMobileTabChanged" OnTabChanged="SetMobileTab"
OnAssistantClick="OpenAssistant" OnAssistantClick="OpenAssistant"
Checkpoints="@InteractionService.CurrentCheckpoints" /> Checkpoints="@StateService.CurrentCheckpoints" />
<GlobalIntelligence IsOpen="@_isAssistantOpen" OnClose="CloseAssistant" /> <GlobalIntelligence IsOpen="@_isAssistantOpen" OnClose="CloseAssistant" />
} }
@@ -255,25 +258,29 @@
Quiz Quiz
} }
private enum MobileReaderTab
{
Reader,
Graph,
Concepts
}
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 int _scrollPercentage;
private bool _isAssistantOpen; 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()
{ {
FocusMode.OnFocusModeChanged += HandleUpdate; FocusMode.OnFocusModeChanged += HandleUpdate;
@@ -310,29 +317,6 @@
StateHasChanged(); StateHasChanged();
} }
private MobileReaderToolbar.MobileReaderTab GetToolbarTab(MobileReaderTab layoutTab)
{
return layoutTab switch
{
MobileReaderTab.Reader => MobileReaderToolbar.MobileReaderTab.Reader,
MobileReaderTab.Graph => MobileReaderToolbar.MobileReaderTab.Graph,
MobileReaderTab.Concepts => MobileReaderToolbar.MobileReaderTab.Concepts,
_ => MobileReaderToolbar.MobileReaderTab.Reader
};
}
private void HandleMobileTabChanged(MobileReaderToolbar.MobileReaderTab toolbarTab)
{
_activeMobileTab = toolbarTab switch
{
MobileReaderToolbar.MobileReaderTab.Reader => MobileReaderTab.Reader,
MobileReaderToolbar.MobileReaderTab.Graph => MobileReaderTab.Graph,
MobileReaderToolbar.MobileReaderTab.Concepts => MobileReaderTab.Concepts,
_ => MobileReaderTab.Reader
};
StateHasChanged();
}
private void OpenAssistant() private void OpenAssistant()
{ {
_isAssistantOpen = true; _isAssistantOpen = true;
@@ -411,31 +395,27 @@
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(); 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)
{ {
@@ -456,7 +436,7 @@
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;
@@ -465,6 +445,25 @@
InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync; InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync;
InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged; 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();
} }
} }
@@ -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()
{ {
@@ -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!"); await JSRuntime.InvokeVoidAsync("console.log", "Intercepted JS console statement from Blazor WebView interop trigger!");
#endif }
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
@@ -10,10 +12,6 @@ public interface IReaderInteractionService
event Func<int, Task>? OnScrollPercentChanged; event Func<int, Task>? OnScrollPercentChanged;
event Func<string, Task>? OnBlockReached; event Func<string, Task>? OnBlockReached;
int CurrentScrollPercentage { get; set; }
List<string> CurrentCheckpoints { get; set; }
string CurrentBlockId { get; set; }
Task NotifyNodeSelected(string nodeId); Task NotifyNodeSelected(string nodeId);
Task RequestScrollToBlock(string blockId); Task RequestScrollToBlock(string blockId);
Task RequestHighlightBlock(string blockId); Task RequestHighlightBlock(string blockId);
@@ -23,4 +21,3 @@ public interface IReaderInteractionService
Task NotifyBlockReached(string blockId); 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
@@ -10,10 +12,6 @@ public sealed class ReaderInteractionService : IReaderInteractionService
public event Func<int, Task>? OnScrollPercentChanged; public event Func<int, Task>? OnScrollPercentChanged;
public event Func<string, Task>? OnBlockReached; public event Func<string, Task>? OnBlockReached;
public int CurrentScrollPercentage { get; set; }
public List<string> CurrentCheckpoints { get; set; } = new();
public string CurrentBlockId { get; set; } = string.Empty;
public async Task NotifyNodeSelected(string nodeId) public async Task NotifyNodeSelected(string nodeId)
{ {
if (OnNodeSelected != null) await OnNodeSelected(nodeId); if (OnNodeSelected != null) await OnNodeSelected(nodeId);
@@ -41,13 +39,12 @@ public sealed class ReaderInteractionService : IReaderInteractionService
public async Task NotifyScrollPercentChanged(int percent) public async Task NotifyScrollPercentChanged(int percent)
{ {
CurrentScrollPercentage = percent;
if (OnScrollPercentChanged != null) await OnScrollPercentChanged(percent); if (OnScrollPercentChanged != null) await OnScrollPercentChanged(percent);
} }
public async Task NotifyBlockReached(string blockId) public async Task NotifyBlockReached(string blockId)
{ {
CurrentBlockId = blockId;
if (OnBlockReached != null) await OnBlockReached(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; }
}
}
@@ -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>();
+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>();