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])