diff --git a/.env.test.template b/.env.test.template index a4f765b..ba1f10a 100644 --- a/.env.test.template +++ b/.env.test.template @@ -32,7 +32,7 @@ GOOGLE_CLIENT_SECRET=placeholder GOOGLE_AI_API_KEY=placeholder # === Admin Seed Password === -NEXUS_ADMIN_PASSWORD=aQ13EdSw2 +NEXUS_ADMIN_PASSWORD=CHANGE_ME # === Non-standard ports for auxiliary services === QDRANT_HTTP_PORT=6343 diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 5f36acf..ea3688b 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -36,7 +36,7 @@ services: - Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder} - Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder} - Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder} - - NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:-aQ13EdSw2} + - NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:?NEXUS_ADMIN_PASSWORD is required} depends_on: db: condition: service_healthy diff --git a/src/NexusReader.Data/Persistence/DbInitializer.cs b/src/NexusReader.Data/Persistence/DbInitializer.cs index 93d30de..047b2f5 100644 --- a/src/NexusReader.Data/Persistence/DbInitializer.cs +++ b/src/NexusReader.Data/Persistence/DbInitializer.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; using NexusReader.Domain.Entities; using System; using System.Linq; @@ -16,6 +17,7 @@ public static class DbInitializer using var scope = serviceProvider.CreateScope(); var passwordHasher = scope.ServiceProvider.GetRequiredService>(); var dbContextFactory = scope.ServiceProvider.GetRequiredService>(); + var configuration = scope.ServiceProvider.GetService(); using var dbContext = await dbContextFactory.CreateDbContextAsync(); try @@ -68,7 +70,10 @@ public static class DbInitializer 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); dbContext.Users.Add(adminUser); diff --git a/src/NexusReader.Maui/MauiProgram.cs b/src/NexusReader.Maui/MauiProgram.cs index 9f12805..a2a7733 100644 --- a/src/NexusReader.Maui/MauiProgram.cs +++ b/src/NexusReader.Maui/MauiProgram.cs @@ -69,6 +69,7 @@ public static class MauiProgram builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor b/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor index 7b2f55b..9c10153 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/SelectionAiPanel.razor @@ -1,4 +1,5 @@ @using NexusReader.UI.Shared.Services +@using NexusReader.UI.Shared.Models @using NexusReader.Application.DTOs.AI @inject KnowledgeCoordinator Coordinator @inject IReaderInteractionService InteractionService diff --git a/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor b/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor index ee01b17..15674f9 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor @@ -3,6 +3,7 @@ @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 @@ -136,6 +137,34 @@ + + @if (_selectedCitation != null) + { +
+
+ + + +
+
+ } @@ -147,23 +176,7 @@ private bool _isLoading; private string _activeBookTitle = string.Empty; private List _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 Segments { get; set; } = new(); - public List 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; - } + private CitationDto? _selectedCitation; protected override async Task OnParametersSetAsync() { @@ -191,7 +204,20 @@ 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() diff --git a/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor.css index 192272e..2036e84 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor.css @@ -414,3 +414,132 @@ 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; } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor b/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor index 75c1931..168cd08 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor @@ -1,8 +1,10 @@ @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
@@ -77,7 +79,7 @@
@foreach (var cp in Checkpoints) { - var isCurrent = cp == InteractionService.CurrentBlockId; + var isCurrent = cp == StateService.CurrentBlockId;
@@ -105,12 +107,6 @@ private bool IsCheckpointsOpen { get; set; } - public enum MobileReaderTab - { - Reader, - Graph, - Concepts - } private double GetDashOffset() { diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor index e457ba1..e9a919d 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor @@ -2,8 +2,9 @@ @using NexusReader.Application.Queries.Reader @using Microsoft.JSInterop @using NexusReader.UI.Shared.Services +@using NexusReader.UI.Shared.Models @using Microsoft.AspNetCore.Components.Authorization -@implements IDisposable +@implements IAsyncDisposable @inject IMediator Mediator @inject IJSRuntime JS @inject IThemeService ThemeService @@ -11,6 +12,7 @@ @inject IReaderNavigationService NavigationService @inject KnowledgeCoordinator Coordinator @inject IReaderInteractionService InteractionService +@inject IReaderStateService StateService @inject ISyncService SyncService @inject AuthenticationStateProvider AuthStateProvider @inject IQuizStateService QuizService @@ -96,6 +98,7 @@ private string? _currentActiveBlockId; private bool _isMobile = false; private DotNetObjectReference? _selfReference; + private IJSObjectReference? _viewportModule; protected override async Task OnInitializedAsync() { @@ -160,23 +163,11 @@ { try { + _viewportModule = await JS.InvokeAsync("import", "./_content/NexusReader.UI.Shared/js/viewport.js"); _selfReference = DotNetObjectReference.Create(this); - var isMobileViewport = await JS.InvokeAsync("eval", "window.innerWidth < 768"); + var isMobileViewport = await _viewportModule.InvokeAsync("isMobileViewport"); await OnViewportChanged(isMobileViewport); - - await JS.InvokeVoidAsync("eval", @" - window.registerCanvasViewportObserver = (dotNetHelper) => { - let currentIsMobile = window.innerWidth < 768; - window.addEventListener('resize', () => { - let isMobile = window.innerWidth < 768; - if (isMobile !== currentIsMobile) { - currentIsMobile = isMobile; - dotNetHelper.invokeMethodAsync('OnViewportChanged', isMobile); - } - }); - } - "); - await JS.InvokeVoidAsync("registerCanvasViewportObserver", _selfReference); + await _viewportModule.InvokeVoidAsync("registerViewportObserver", _selfReference); } catch (Exception ex) { @@ -226,6 +217,7 @@ [JSInvokable] public async Task HandleScrollPercentChanged(int percent) { + StateService.CurrentScrollPercentage = percent; await InteractionService.NotifyScrollPercentChanged(percent); } @@ -233,6 +225,7 @@ public async Task HandleBlockReached(string blockId, string content) { _currentActiveBlockId = blockId; + StateService.CurrentBlockId = blockId; await InteractionService.NotifyBlockReached(blockId); await Coordinator.OnBlockReachedAsync(blockId, content); @@ -338,7 +331,7 @@ .Where(b => !string.IsNullOrEmpty(b.Id) && b.Id.Contains("seg")) .Select(b => b.Id) .ToList(); - InteractionService.CurrentCheckpoints = checkpoints; + StateService.CurrentCheckpoints = checkpoints; if (_isInteractive) { @@ -372,7 +365,8 @@ { try { - await JS.InvokeVoidAsync("eval", $"document.getElementById('{id}')?.scrollIntoView({{ behavior: 'smooth', block: 'center' }});"); + var module = _viewportModule ?? await JS.InvokeAsync("import", "./_content/NexusReader.UI.Shared/js/viewport.js"); + await module.InvokeVoidAsync("scrollIntoView", id); } catch (Exception ex) { @@ -395,7 +389,7 @@ await InteractionService.RequestAssistant(); } - public void Dispose() + public async ValueTask DisposeAsync() { ThemeService.OnThemeChanged -= HandleUpdate; NavigationService.OnNavigationChanged -= OnNavigationChanged; @@ -405,15 +399,32 @@ InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested; InteractionService.OnTextSelected -= HandleTextSelected; 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 { if (_scrollListenerReference != null) { - _ = _scrollListenerReference.DisposeAsync(); + await _scrollListenerReference.DisposeAsync(); } } catch { } + + _selfReference?.Dispose(); } } diff --git a/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor b/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor index 5349de0..bdf5c70 100644 --- a/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor +++ b/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor @@ -1,6 +1,7 @@ @inherits LayoutComponentBase @using NexusReader.Application.Abstractions.Services @using NexusReader.UI.Shared.Services +@using NexusReader.UI.Shared.Models @using NexusReader.UI.Shared.Components.Molecules @using NexusReader.UI.Shared.Components.Organisms @using NexusReader.Application.Queries.Graph @@ -9,12 +10,14 @@ @inject IFocusModeService FocusMode @inject IQuizStateService QuizService @inject IReaderInteractionService InteractionService +@inject IReaderStateService StateService @inject IKnowledgeGraphService GraphService @inject IJSRuntime JS @inject IIdentityService IdentityService @inject NavigationManager NavigationManager @inject Microsoft.Extensions.Logging.ILogger Logger -@implements IDisposable +@implements IAsyncDisposable +
@@ -225,10 +228,10 @@ + Checkpoints="@StateService.CurrentCheckpoints" /> } @@ -255,25 +258,29 @@ Quiz } - private enum MobileReaderTab - { - Reader, - Graph, - Concepts - } - private SidebarTab _activeTab = SidebarTab.Knowledge; - private MobileReaderTab _activeMobileTab = MobileReaderTab.Reader; private string? _selectedNodeId; private GraphNodeDto? _selectedNode; private string _platformClass = "platform-desktop"; private bool _isMobile = false; private DotNetObjectReference? _selfReference; + private IJSObjectReference? _viewportModule; - private int _scrollPercentage; 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() { FocusMode.OnFocusModeChanged += HandleUpdate; @@ -310,29 +317,6 @@ 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() { _isAssistantOpen = true; @@ -411,31 +395,27 @@ Logger.LogError(ex, "Failed to initialize layout resizer JS module."); } - await InitViewportDetectionAsync(); + try + { + _viewportModule = await JS.InvokeAsync("import", "./_content/NexusReader.UI.Shared/js/viewport.js"); + await InitViewportDetectionAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to import viewport utilities JS module."); + } } } private async Task InitViewportDetectionAsync() { + if (_viewportModule == null) return; try { _selfReference = DotNetObjectReference.Create(this); - var isMobileViewport = await JS.InvokeAsync("eval", "window.innerWidth < 768"); + var isMobileViewport = await _viewportModule.InvokeAsync("isMobileViewport"); await OnViewportChanged(isMobileViewport); - - await JS.InvokeVoidAsync("eval", @" - window.registerViewportObserver = (dotNetHelper) => { - let currentIsMobile = window.innerWidth < 768; - window.addEventListener('resize', () => { - let isMobile = window.innerWidth < 768; - if (isMobile !== currentIsMobile) { - currentIsMobile = isMobile; - dotNetHelper.invokeMethodAsync('OnViewportChanged', isMobile); - } - }); - } - "); - await JS.InvokeVoidAsync("registerViewportObserver", _selfReference); + await _viewportModule.InvokeVoidAsync("registerViewportObserver", _selfReference); } catch (Exception ex) { @@ -456,7 +436,7 @@ private Task HandleUpdate() => InvokeAsync(StateHasChanged); - public void Dispose() + public async ValueTask DisposeAsync() { FocusMode.OnFocusModeChanged -= HandleUpdate; QuizService.OnQuizUpdated -= HandleUpdate; @@ -465,6 +445,25 @@ InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync; InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged; 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(); } } + + diff --git a/src/NexusReader.UI.Shared/Models/ReaderModels.cs b/src/NexusReader.UI.Shared/Models/ReaderModels.cs new file mode 100644 index 0000000..de527e2 --- /dev/null +++ b/src/NexusReader.UI.Shared/Models/ReaderModels.cs @@ -0,0 +1,41 @@ +using NexusReader.Application.DTOs.AI; + +namespace NexusReader.UI.Shared.Models; + +/// +/// Defines the active tab state for the unified mobile reader toolbar. +/// +public enum MobileReaderTab +{ + Reader, + Graph, + Concepts +} + +/// +/// Screen coordinates for text selection popup positioning. +/// +public record SelectionCoordinates(double Top, double Left, double Width); + +/// +/// Represents a message in the KM-RAG global and mobile intelligence chat threads. +/// +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 Segments { get; set; } = new(); + public List Citations { get; set; } = new(); +} + +/// +/// Represents a parsed segment of an intelligence response, potentially referencing a citation. +/// +public class ResponseSegment +{ + public string Text { get; set; } = string.Empty; + public bool IsCitation { get; set; } + public string CitationId { get; set; } = string.Empty; +} diff --git a/src/NexusReader.UI.Shared/Pages/Intelligence.razor b/src/NexusReader.UI.Shared/Pages/Intelligence.razor index 13404ff..8e7e432 100644 --- a/src/NexusReader.UI.Shared/Pages/Intelligence.razor +++ b/src/NexusReader.UI.Shared/Pages/Intelligence.razor @@ -4,6 +4,7 @@ @using NexusReader.Application.Abstractions.Services @using NexusReader.Application.DTOs.User @using NexusReader.UI.Shared.Components.Atoms +@using NexusReader.UI.Shared.Models @using System.Net.Http.Json @inject HttpClient Http @inject IKnowledgeService KnowledgeService @@ -145,22 +146,7 @@ private List? _books; private List _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 Segments { get; set; } = new(); - public List 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() { diff --git a/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor b/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor index 6166989..fde0f5d 100644 --- a/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor +++ b/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor @@ -109,21 +109,16 @@ else private void LogInfo() { -#if DEBUG Logger.LogInformation("Structured native log triggered by user from SerilogDemo. Button: LogInfo"); -#endif } private void LogWarning() { -#if DEBUG Logger.LogWarning("Potential warning log triggered from Blazor razor component at {Time}", DateTime.UtcNow); -#endif } private void LogError() { -#if DEBUG try { throw new InvalidOperationException("Simulated native C# operation exception triggered in Diagnostic dashboard."); @@ -132,22 +127,31 @@ else { Logger.LogError(ex, "Captured exception successfully in native Serilog pipeline!"); } -#endif } private async Task TriggerJsLog() { -#if DEBUG - await JSRuntime.InvokeVoidAsync("console.log", "Intercepted JS console statement from Blazor WebView interop trigger!"); -#endif - await Task.CompletedTask; + try + { + await JSRuntime.InvokeVoidAsync("console.log", "Intercepted JS console statement from Blazor WebView interop trigger!"); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to execute console.log from diagnostic panel."); + } } private async Task TriggerJsException() { -#if DEBUG - await JSRuntime.InvokeVoidAsync("eval", "throw new Error('Simulated runtime JS Exception triggered from Blazor UI button click!');"); -#endif - await Task.CompletedTask; + try + { + // Triggers a TypeError by invoking a non-existent method, which is completely CSP-compliant and works without eval() + await JSRuntime.InvokeVoidAsync("window.nonExistentFunctionTriggeringException"); + } + catch (Exception ex) + { + Logger.LogError(ex, "Simulated runtime JS Exception triggered and captured in Blazor UI"); + } } } + diff --git a/src/NexusReader.UI.Shared/Services/IReaderInteractionService.cs b/src/NexusReader.UI.Shared/Services/IReaderInteractionService.cs index f2c2afd..1490cd7 100644 --- a/src/NexusReader.UI.Shared/Services/IReaderInteractionService.cs +++ b/src/NexusReader.UI.Shared/Services/IReaderInteractionService.cs @@ -1,3 +1,5 @@ +using NexusReader.UI.Shared.Models; + namespace NexusReader.UI.Shared.Services; public interface IReaderInteractionService @@ -10,10 +12,6 @@ public interface IReaderInteractionService event Func? OnScrollPercentChanged; event Func? OnBlockReached; - int CurrentScrollPercentage { get; set; } - List CurrentCheckpoints { get; set; } - string CurrentBlockId { get; set; } - Task NotifyNodeSelected(string nodeId); Task RequestScrollToBlock(string blockId); Task RequestHighlightBlock(string blockId); @@ -23,4 +21,3 @@ public interface IReaderInteractionService Task NotifyBlockReached(string blockId); } -public record SelectionCoordinates(double Top, double Left, double Width); diff --git a/src/NexusReader.UI.Shared/Services/IReaderStateService.cs b/src/NexusReader.UI.Shared/Services/IReaderStateService.cs new file mode 100644 index 0000000..ed79471 --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/IReaderStateService.cs @@ -0,0 +1,14 @@ +using NexusReader.UI.Shared.Models; + +namespace NexusReader.UI.Shared.Services; + +/// +/// Service to maintain local UI state for the reader, separating state from event bus. +/// +public interface IReaderStateService +{ + int CurrentScrollPercentage { get; set; } + List CurrentCheckpoints { get; set; } + string CurrentBlockId { get; set; } + MobileReaderTab ActiveTab { get; set; } +} diff --git a/src/NexusReader.UI.Shared/Services/ReaderInteractionService.cs b/src/NexusReader.UI.Shared/Services/ReaderInteractionService.cs index be38a3c..a1da8e1 100644 --- a/src/NexusReader.UI.Shared/Services/ReaderInteractionService.cs +++ b/src/NexusReader.UI.Shared/Services/ReaderInteractionService.cs @@ -1,3 +1,5 @@ +using NexusReader.UI.Shared.Models; + namespace NexusReader.UI.Shared.Services; public sealed class ReaderInteractionService : IReaderInteractionService @@ -10,10 +12,6 @@ public sealed class ReaderInteractionService : IReaderInteractionService public event Func? OnScrollPercentChanged; public event Func? OnBlockReached; - public int CurrentScrollPercentage { get; set; } - public List CurrentCheckpoints { get; set; } = new(); - public string CurrentBlockId { get; set; } = string.Empty; - public async Task NotifyNodeSelected(string nodeId) { if (OnNodeSelected != null) await OnNodeSelected(nodeId); @@ -41,13 +39,12 @@ public sealed class ReaderInteractionService : IReaderInteractionService public async Task NotifyScrollPercentChanged(int percent) { - CurrentScrollPercentage = percent; if (OnScrollPercentChanged != null) await OnScrollPercentChanged(percent); } public async Task NotifyBlockReached(string blockId) { - CurrentBlockId = blockId; if (OnBlockReached != null) await OnBlockReached(blockId); } } + diff --git a/src/NexusReader.UI.Shared/Services/ReaderStateService.cs b/src/NexusReader.UI.Shared/Services/ReaderStateService.cs new file mode 100644 index 0000000..4906759 --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/ReaderStateService.cs @@ -0,0 +1,39 @@ +using NexusReader.UI.Shared.Models; + +namespace NexusReader.UI.Shared.Services; + +/// +/// Thread-safe implementation of IReaderStateService. +/// +public sealed class ReaderStateService : IReaderStateService +{ + private readonly object _lock = new(); + private int _scrollPercent; + private List _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 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; } + } +} diff --git a/src/NexusReader.UI.Shared/wwwroot/js/viewport.js b/src/NexusReader.UI.Shared/wwwroot/js/viewport.js new file mode 100644 index 0000000..8c02aaf --- /dev/null +++ b/src/NexusReader.UI.Shared/wwwroot/js/viewport.js @@ -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; +} diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs index 787e6e4..68a7479 100644 --- a/src/NexusReader.Web.Client/Program.cs +++ b/src/NexusReader.Web.Client/Program.cs @@ -23,6 +23,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index 5a31328..033b029 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -53,6 +53,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped();