From 57b988e16fc47789bda5619061ef0e9c04c0f0f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sat, 30 May 2026 20:23:25 +0200 Subject: [PATCH 1/2] feat: implement mobile reader header and navigation components with updated icon support --- .../Components/Atoms/NexusIcon.razor | 13 + .../Organisms/GlobalIntelligence.razor | 325 ++++++++++++++ .../Organisms/GlobalIntelligence.razor.css | 416 ++++++++++++++++++ .../Organisms/MobileReaderToolbar.razor | 155 +++++++ .../Organisms/MobileReaderToolbar.razor.css | 362 +++++++++++++++ .../Components/Organisms/ReaderCanvas.razor | 67 ++- .../Organisms/ReaderCanvas.razor.css | 114 +++++ .../Layout/ReaderLayout.razor | 96 ++-- .../Layout/ReaderLayout.razor.css | 141 +----- .../Services/IReaderInteractionService.cs | 8 + .../Services/ReaderInteractionService.cs | 18 + .../wwwroot/js/knowledgeGraph.js | 3 + .../wwwroot/js/readerObserver.js | 40 ++ .../wwwroot/js/knowledgeGraph.js | 3 + 14 files changed, 1599 insertions(+), 162 deletions(-) create mode 100644 src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor create mode 100644 src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor.css create mode 100644 src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor create mode 100644 src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor.css diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor index 5bc025a..68dd356 100644 --- a/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor @@ -10,6 +10,7 @@ break; + case "share": case "share-2": @@ -45,6 +46,7 @@ break; + case "book": case "book-open": @@ -86,6 +88,17 @@ case "log-out": break; + case "chevron-left": + + break; + case "chevron-right": + + break; + case "x": + case "close": + + + break; default: diff --git a/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor b/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor new file mode 100644 index 0000000..ee01b17 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor @@ -0,0 +1,325 @@ +@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 System.Net.Http.Json +@namespace NexusReader.UI.Shared.Components.Organisms +@inject HttpClient Http +@inject IKnowledgeService KnowledgeService +@inject AuthenticationStateProvider AuthStateProvider +@inject IReaderNavigationService NavigationService + +
+
+
+
+ +
+
+
+ +
+
+

Asystent AI Nexus

+

Zadawaj pytania do swojej biblioteki

+
+
+ +
+ +
+
+ @if (_chatMessages.Count == 0) + { +
+
+ +
+

Zadaj pytanie asystentowi

+

KM-RAG przeszukuje całą treść książki, wyciąga semantyczne powiązania i generuje precyzyjne odpowiedzi wraz z przypisami źródłowymi.

+
+ } + else + { + @foreach (var message in _chatMessages) + { +
+
+ @if (message.Sender == "User") + { + + } + else + { + + } +
+
+
+ @(message.Sender == "User" ? "Ty" : "Asystent") + @message.Timestamp.ToString("HH:mm") +
+
+ @foreach (var segment in message.Segments) + { + @if (segment.IsCitation) + { + + [@segment.CitationId] + + } + else + { + @RenderMarkdown(segment.Text) + } + } +
+
+
+ } + + @if (_isLoading) + { +
+
+ +
+
+
+ Asystent + Generowanie... +
+
+
+ + + +
+ Analiza grafu pojęć... +
+
+
+ } + } +
+
+ +
+
+ + Obszar: @(string.IsNullOrEmpty(_activeBookTitle) ? "Cała biblioteka" : _activeBookTitle) +
+ +
+ + + +
+
+
+
+ +@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 _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 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) + { + // For mobile, citations are simple notifications or alerts, or scroll requests + } + + private async Task AskQuestionAsync() + { + if (string.IsNullOrWhiteSpace(_question) || _isLoading) return; + + var userQuestion = _question; + _question = string.Empty; + _isLoading = true; + + _chatMessages.Add(new ChatMessage + { + Sender = "User", + Text = userQuestion, + Segments = new List { 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 + { + var errMsg = $"Błąd: {result.Errors.FirstOrDefault()?.Message ?? "Wystąpił nieznany problem."}"; + _chatMessages.Add(new ChatMessage + { + Sender = "AI", + Text = errMsg, + Segments = new List { 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 { new ResponseSegment { Text = errMsg, IsCitation = false } } + }); + } + finally + { + _isLoading = false; + StateHasChanged(); + } + } + + private List ParseSegments(string text) + { + var segments = new List(); + 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, @"\*\*(.*?)\*\*", "$1"); + html = System.Text.RegularExpressions.Regex.Replace(html, @"\*(.*?)\*", "$1"); + html = System.Text.RegularExpressions.Regex.Replace(html, @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```", "
$1
"); + html = System.Text.RegularExpressions.Regex.Replace(html, @"`(.*?)`", "$1"); + html = html.Replace("\n", "
"); + + return new MarkupString(html); + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor.css new file mode 100644 index 0000000..192272e --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor.css @@ -0,0 +1,416 @@ +.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); } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor b/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor new file mode 100644 index 0000000..75c1931 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor @@ -0,0 +1,155 @@ +@using NexusReader.UI.Shared.Components.Atoms +@using NexusReader.UI.Shared.Services +@using NexusReader.Application.Utilities +@namespace NexusReader.UI.Shared.Components.Organisms +@inject IReaderInteractionService InteractionService + +
+ +
+
+ + + + + @ScrollPercentage% +
+
+ Postęp + Checkpoints +
+
+ + +
+ +
+ + +
+ + + +
+
+ + +
+
+
+
+
+

Checkpoints Sekcji

+ +
+
+ @if (Checkpoints == null || !Checkpoints.Any()) + { +
+ +

Brak punktów kontrolnych w tym rozdziale.

+
+ } + else + { +
+ @foreach (var cp in Checkpoints) + { + var isCurrent = cp == InteractionService.CurrentBlockId; +
+
+
+
+
+
+ @cp.ToUpper() + @(isCurrent ? "Aktualna sekcja" : "Przejdź do sekcji") +
+ +
+ } +
+ } +
+
+
+ +@code { + [Parameter] public int ScrollPercentage { get; set; } + [Parameter] public MobileReaderTab ActiveTab { get; set; } + [Parameter] public EventCallback OnTabChanged { get; set; } + [Parameter] public EventCallback OnAssistantClick { get; set; } + [Parameter] public List Checkpoints { get; set; } = new(); + + private bool IsCheckpointsOpen { get; set; } + + public enum MobileReaderTab + { + Reader, + Graph, + Concepts + } + + 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(); + } + } +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor.css new file mode 100644 index 0000000..e8569c3 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor.css @@ -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); +} diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor index a6a35dd..e457ba1 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor @@ -15,9 +15,31 @@ @inject AuthenticationStateProvider AuthStateProvider @inject IQuizStateService QuizService @inject IPlatformService PlatformService +@inject NavigationManager Navigation @inject ILogger Logger
+ @if (_isMobile && ViewModel != null) + { +
+ +
+ +
+ @ViewModel.ChapterTitle +
+ +
+
+ } + @if (ViewModel == null) {
@@ -56,16 +78,7 @@ Coordinates="@_selectionCoords" FullPageContent="@GetFullPageContent()" /> - @if (_isMobile) - { - - } +
@code { @@ -194,12 +207,15 @@ } } + private IJSObjectReference? _scrollListenerReference; + private async Task InitializeObserverAsync() { try { var module = await JS.InvokeAsync("import", "./_content/NexusReader.UI.Shared/js/readerObserver.js"); await module.InvokeVoidAsync("initObserver", DotNetObjectReference.Create(this), ".reader-flow-container", ".block-wrapper"); + _scrollListenerReference = await module.InvokeAsync("initScrollListener", DotNetObjectReference.Create(this), ".reader-flow-container"); } catch (Exception ex) { @@ -207,10 +223,17 @@ } } + [JSInvokable] + public async Task HandleScrollPercentChanged(int percent) + { + await InteractionService.NotifyScrollPercentChanged(percent); + } + [JSInvokable] public async Task HandleBlockReached(string blockId, string content) { _currentActiveBlockId = blockId; + await InteractionService.NotifyBlockReached(blockId); await Coordinator.OnBlockReachedAsync(blockId, content); if (ViewModel != null) @@ -310,6 +333,13 @@ ViewModel = result.Value; 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(); + InteractionService.CurrentCheckpoints = checkpoints; + if (_isInteractive) { await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId); @@ -352,6 +382,14 @@ private Task HandleUpdate() => InvokeAsync(StateHasChanged); + private void HandleEscape() + { + if (ViewModel != null) + { + Navigation.NavigateTo("/"); + } + } + private async Task HandleAssistantFabClick() { await InteractionService.RequestAssistant(); @@ -368,5 +406,14 @@ InteractionService.OnTextSelected -= HandleTextSelected; SyncService.OnProgressReceived -= HandleSyncProgressReceived; _selfReference?.Dispose(); + + try + { + if (_scrollListenerReference != null) + { + _ = _scrollListenerReference.DisposeAsync(); + } + } + catch { } } } diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css index 443a865..3d10e86 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css @@ -258,4 +258,118 @@ @keyframes fadeIn { from { opacity: 0; } 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); } \ No newline at end of file diff --git a/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor b/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor index 5909295..5349de0 100644 --- a/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor +++ b/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor @@ -141,8 +141,8 @@
- -
+ +
@@ -223,27 +223,14 @@
- -
- - - -
+ + + } @@ -272,7 +259,7 @@ { Reader, Graph, - Insight + Concepts } private SidebarTab _activeTab = SidebarTab.Knowledge; @@ -284,6 +271,9 @@ private bool _isMobile = false; private DotNetObjectReference? _selfReference; + private int _scrollPercentage; + private bool _isAssistantOpen; + protected override void OnInitialized() { FocusMode.OnFocusModeChanged += HandleUpdate; @@ -292,6 +282,7 @@ InteractionService.OnNodeSelected += HandleNodeSelectedAsync; InteractionService.OnAssistantRequested += HandleAssistantRequestedAsync; + InteractionService.OnScrollPercentChanged += HandleScrollPercentChanged; GraphService.OnGraphUpdated += HandleGraphUpdatedAsync; var context = PlatformService.GetDeviceContext(); @@ -319,20 +310,68 @@ 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; + StateHasChanged(); + } + + private void CloseAssistant() + { + _isAssistantOpen = false; + StateHasChanged(); + } + + private async Task HandleScrollPercentChanged(int percent) + { + _scrollPercentage = percent; + await InvokeAsync(StateHasChanged); + } + private async Task HandleQuizRequestedAsync(string blockId) { _activeTab = SidebarTab.Quiz; if (_isMobile) { - _activeMobileTab = MobileReaderTab.Insight; + _activeMobileTab = MobileReaderTab.Concepts; } await InvokeAsync(StateHasChanged); } private async Task HandleAssistantRequestedAsync() { - _activeMobileTab = MobileReaderTab.Insight; - _activeTab = SidebarTab.Quiz; + if (_isMobile) + { + OpenAssistant(); + } + else + { + _activeMobileTab = MobileReaderTab.Concepts; + _activeTab = SidebarTab.Quiz; + } await InvokeAsync(StateHasChanged); } @@ -345,7 +384,7 @@ } if (_isMobile) { - _activeMobileTab = MobileReaderTab.Insight; + _activeMobileTab = MobileReaderTab.Concepts; _activeTab = SidebarTab.Knowledge; } await InvokeAsync(StateHasChanged); @@ -424,6 +463,7 @@ QuizService.OnQuizRequested -= HandleQuizRequestedAsync; InteractionService.OnNodeSelected -= HandleNodeSelectedAsync; InteractionService.OnAssistantRequested -= HandleAssistantRequestedAsync; + InteractionService.OnScrollPercentChanged -= HandleScrollPercentChanged; GraphService.OnGraphUpdated -= HandleGraphUpdatedAsync; _selfReference?.Dispose(); } diff --git a/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor.css b/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor.css index bd93c37..6e97fae 100644 --- a/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor.css +++ b/src/NexusReader.UI.Shared/Layout/ReaderLayout.razor.css @@ -479,7 +479,7 @@ main { .platform-mobile .reader-pane { width: 100vw !important; - height: calc(100vh - 60px) !important; /* reserve bottom nav height */ + height: 100vh !important; /* full viewport height */ position: absolute; top: 0; left: 0; @@ -496,7 +496,7 @@ main { 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; } @@ -508,7 +508,7 @@ main { .platform-mobile .nexus-mobile-reader-tabs { display: none; /* Keep hidden by default */ width: 100vw; - height: calc(100vh - 60px); + height: 100vh; /* full viewport height */ position: absolute; top: 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-insight .nexus-mobile-reader-tabs { - display: block; /* Show only when graph or insight tabs are active */ +.app-container.platform-mobile.active-mobile-tab-concepts .nexus-mobile-reader-tabs { + display: block; /* Show only when graph or concepts tabs are active */ } .nexus-mobile-tab-content { @@ -553,7 +553,17 @@ main { 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; height: 100% !important; } @@ -637,121 +647,4 @@ main { overflow-y: auto; } -/* Three-Tab Bottom Navigation Bar styling */ -.nexus-mobile-bottom-nav { - display: none; -} - -.platform-mobile .nexus-mobile-bottom-nav { - display: flex; - justify-content: space-around; - align-items: center; - position: absolute; - bottom: 0; - left: 0; - width: 100vw; - height: 60px; - background: rgba(13, 13, 13, 0.95); - backdrop-filter: blur(16px); - border-top: 1px solid rgba(255, 255, 255, 0.05); - z-index: 100; -} - -.bottom-nav-item { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.25rem; - height: 100%; - background: none; - border: none; - color: rgba(255, 255, 255, 0.4); - font-family: var(--nexus-font-sans, "Outfit", sans-serif); - font-size: 0.65rem; - font-weight: 500; - cursor: pointer; - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); -} - -.bottom-nav-item.active { - color: var(--nexus-neon, #00f0ff); - text-shadow: 0 0 10px rgba(0, 240, 255, 0.2); -} - -.bottom-nav-item.active :deep(svg) { - filter: drop-shadow(0 0 5px var(--nexus-neon, #00f0ff)); -} - -.insight-icon-wrapper { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.nav-quiz-indicator { - position: absolute; - top: -2px; - right: -2px; - width: 8px; - height: 8px; - background-color: #f43f5e; - border-radius: 50%; - box-shadow: 0 0 8px #f43f5e; - animation: indicator-flash 1.5s infinite ease-in-out; -} - -@keyframes indicator-flash { - 0% { transform: scale(0.8); opacity: 0.6; } - 50% { transform: scale(1.2); opacity: 1; } - 100% { transform: scale(0.8); opacity: 0.6; } -} - -/* Assistant FAB styling inside ReaderCanvas */ -:global(.nexus-mobile-assistant-fab) { - position: fixed; - bottom: 75px; - right: 20px; - width: 56px; - height: 56px; - border-radius: 50%; - background: linear-gradient(135deg, rgba(0, 240, 255, 0.15) 0%, rgba(0, 100, 255, 0.15) 100%); - border: 1px solid rgba(0, 240, 255, 0.4); - box-shadow: 0 4px 20px rgba(0, 240, 255, 0.25); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - z-index: 99; - backdrop-filter: blur(8px); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -:global(.nexus-mobile-assistant-fab:hover) { - transform: scale(1.1) translateY(-2px); - box-shadow: 0 8px 25px rgba(0, 240, 255, 0.4); - border-color: var(--nexus-neon, #00f0ff); -} - -:global(.nexus-mobile-assistant-fab:active) { - transform: scale(0.95); -} - -:global(.nexus-mobile-assistant-fab.has-new-quiz) { - border-color: #f43f5e; - box-shadow: 0 4px 20px rgba(244, 63, 94, 0.3); -} - -:global(.nexus-mobile-assistant-fab .fab-badge) { - position: absolute; - top: 2px; - right: 2px; - width: 10px; - height: 10px; - background-color: #f43f5e; - border-radius: 50%; - box-shadow: 0 0 10px #f43f5e; - animation: indicator-flash 1.5s infinite ease-in-out; -} \ No newline at end of file +/* Obsolescence managed: consolidated mobile toolbar and sheet styled inside respective components */ \ No newline at end of file diff --git a/src/NexusReader.UI.Shared/Services/IReaderInteractionService.cs b/src/NexusReader.UI.Shared/Services/IReaderInteractionService.cs index c048914..f2c2afd 100644 --- a/src/NexusReader.UI.Shared/Services/IReaderInteractionService.cs +++ b/src/NexusReader.UI.Shared/Services/IReaderInteractionService.cs @@ -7,12 +7,20 @@ public interface IReaderInteractionService event Func? OnHighlightBlockRequested; event Func? OnTextSelected; event Func? OnAssistantRequested; + 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); Task NotifyTextSelected(string text, string blockId, SelectionCoordinates coords); Task RequestAssistant(); + Task NotifyScrollPercentChanged(int percent); + Task NotifyBlockReached(string blockId); } public record SelectionCoordinates(double Top, double Left, double Width); diff --git a/src/NexusReader.UI.Shared/Services/ReaderInteractionService.cs b/src/NexusReader.UI.Shared/Services/ReaderInteractionService.cs index 03a65ad..be38a3c 100644 --- a/src/NexusReader.UI.Shared/Services/ReaderInteractionService.cs +++ b/src/NexusReader.UI.Shared/Services/ReaderInteractionService.cs @@ -7,6 +7,12 @@ public sealed class ReaderInteractionService : IReaderInteractionService public event Func? OnHighlightBlockRequested; public event Func? OnTextSelected; public event Func? OnAssistantRequested; + 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) { @@ -32,4 +38,16 @@ public sealed class ReaderInteractionService : IReaderInteractionService { if (OnAssistantRequested != null) await OnAssistantRequested(); } + + 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/wwwroot/js/knowledgeGraph.js b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js index 24965c8..d5f671f 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/knowledgeGraph.js @@ -200,6 +200,9 @@ export function mount(containerId, data, dotNetHelper) { width = container.clientWidth || 400; height = container.clientHeight || 400; + // Clean up any existing SVG to prevent duplicates + container.querySelectorAll("svg").forEach(el => el.remove()); + // Create SVG svgElement = d3.select(container).append("svg") .attr("viewBox", [0, 0, width, height]) diff --git a/src/NexusReader.UI.Shared/wwwroot/js/readerObserver.js b/src/NexusReader.UI.Shared/wwwroot/js/readerObserver.js index b2518f7..bedcc25 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/readerObserver.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/readerObserver.js @@ -20,3 +20,43 @@ export function initObserver(dotNetHelper, containerSelector, itemSelector) { 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); + } + }; +} diff --git a/src/NexusReader.Web.Client/wwwroot/js/knowledgeGraph.js b/src/NexusReader.Web.Client/wwwroot/js/knowledgeGraph.js index 13be344..70fb651 100644 --- a/src/NexusReader.Web.Client/wwwroot/js/knowledgeGraph.js +++ b/src/NexusReader.Web.Client/wwwroot/js/knowledgeGraph.js @@ -9,6 +9,9 @@ export function mount(containerId, data, dotNetHelper) { const width = container.clientWidth || 400; const height = container.clientHeight || 400; + // Clean up any existing SVG to prevent duplicates + container.querySelectorAll("svg").forEach(el => el.remove()); + // Create SVG const svg = d3.select(container).append("svg") .attr("viewBox", [0, 0, width, height]) -- 2.52.0 From 7fbbdc6139c604e09153a7744b1386419c991c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sun, 31 May 2026 19:53:36 +0200 Subject: [PATCH 2/2] refactor(ui/security): centralize state management, remove eval/async-void, enforce secure configuration, and implement interactive mobile citations --- .env.test.template | 2 +- docker-compose.test.yml | 2 +- .../Persistence/DbInitializer.cs | 7 +- src/NexusReader.Maui/MauiProgram.cs | 1 + .../Molecules/SelectionAiPanel.razor | 1 + .../Organisms/GlobalIntelligence.razor | 62 ++++++--- .../Organisms/GlobalIntelligence.razor.css | 129 ++++++++++++++++++ .../Organisms/MobileReaderToolbar.razor | 10 +- .../Components/Organisms/ReaderCanvas.razor | 55 +++++--- .../Layout/ReaderLayout.razor | 105 +++++++------- .../Models/ReaderModels.cs | 41 ++++++ .../Pages/Intelligence.razor | 16 +-- .../Pages/SerilogDemo.razor | 32 +++-- .../Services/IReaderInteractionService.cs | 7 +- .../Services/IReaderStateService.cs | 14 ++ .../Services/ReaderInteractionService.cs | 9 +- .../Services/ReaderStateService.cs | 39 ++++++ .../wwwroot/js/viewport.js | 40 ++++++ src/NexusReader.Web.Client/Program.cs | 1 + src/NexusReader.Web/Program.cs | 1 + 20 files changed, 431 insertions(+), 143 deletions(-) create mode 100644 src/NexusReader.UI.Shared/Models/ReaderModels.cs create mode 100644 src/NexusReader.UI.Shared/Services/IReaderStateService.cs create mode 100644 src/NexusReader.UI.Shared/Services/ReaderStateService.cs create mode 100644 src/NexusReader.UI.Shared/wwwroot/js/viewport.js 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(); -- 2.52.0