@@ -143,6 +150,7 @@
private string? ErrorMessage { get; set; }
private byte[]? _epubBytes;
private bool _disposed;
+ private bool IsUploadActive => !IsParsing && !IsVerifying && !IsIngesting && !IsIndexing;
// Allow up to 50 MB
private const long MaxFileSize = 50 * 1024 * 1024;
@@ -163,6 +171,8 @@
if (!_disposed)
{
+ // Dispatch the state change to the Blazor synchronization context
+ // because this event is triggered asynchronously from a SignalR / WebSocket background thread.
await InvokeAsync(StateHasChanged);
}
@@ -177,6 +187,8 @@
if (IngestedBookId != Guid.Empty)
{
var bookId = IngestedBookId;
+ // Dispatch UI updates and navigation back to the Blazor thread
+ // to avoid thread affinity issues and potential UI lockups in MAUI/Web applications.
await InvokeAsync(async () => {
if (_disposed) return;
await CloseModal();
diff --git a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css
index 519639b..d92d068 100644
--- a/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css
+++ b/src/NexusReader.UI.Shared/Components/Organisms/BookIngestionModal.razor.css
@@ -118,8 +118,9 @@
z-index: 10;
}
-/* Parsing State */
-.parsing-state {
+/* Parsing and Ingesting States */
+.parsing-state,
+.ingesting-state {
flex: 1;
display: flex;
justify-content: center;
@@ -158,7 +159,8 @@
filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.3));
}
-.parsing-state p {
+.parsing-state p,
+.ingesting-state p {
color: var(--nexus-text);
font-family: var(--nexus-font-mono, monospace);
font-size: 0.9rem;
@@ -371,10 +373,11 @@
position: absolute;
width: 20px;
height: 20px;
- border: 2px solid rgba(0, 0, 0, 0.1);
- border-top-color: #000;
+ border: 2px solid rgba(255, 255, 255, 0.2);
+ border-top-color: var(--nexus-neon, #00ffaa);
border-radius: 50%;
animation: spin 0.8s linear infinite;
+ filter: drop-shadow(0 0 4px var(--nexus-neon, #00ffaa));
}
/* Indexing State */
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..fa67ad8
--- /dev/null
+++ b/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor
@@ -0,0 +1,367 @@
+@using NexusReader.Application.DTOs.AI
+@using NexusReader.Application.Abstractions.Services
+@using NexusReader.Application.DTOs.User
+@using NexusReader.UI.Shared.Components.Atoms
+@using NexusReader.UI.Shared.Services
+@using NexusReader.UI.Shared.Models
+@using System.Net.Http.Json
+@namespace NexusReader.UI.Shared.Components.Organisms
+@inject HttpClient Http
+@inject IKnowledgeService KnowledgeService
+@inject IReaderNavigationService NavigationService
+
+
+
+
+
+
+
+
+
+
+ @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)
+ {
+ HandleCitationClick(segment.CitationId)">
+ [@segment.CitationId]
+
+ }
+ else
+ {
+ @RenderMarkdown(segment.Text)
+ }
+ }
+
+
+
+ }
+
+ @if (_isLoading)
+ {
+
+
+
+
+
+
+ Asystent
+ Generowanie...
+
+
+
+
+
+
+
+
Analiza grafu pojęć...
+
+
+
+ }
+ }
+
+
+
+
+
+ @if (_selectedCitation != null)
+ {
+
+
+
+
+ @if (!string.IsNullOrEmpty(_selectedCitation.Author))
+ {
+
Autor: @_selectedCitation.Author
+ }
+ @if (_selectedCitation.PageNumber.HasValue)
+ {
+
Strona: @_selectedCitation.PageNumber.Value
+ }
+
"@_selectedCitation.Snippet"
+
+
+
+
+ }
+
+
+
+@code {
+ private static readonly System.Text.RegularExpressions.Regex CitationRegex = new(
+ @"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
+
+ private static readonly System.Text.RegularExpressions.Regex BoldRegex = new(
+ @"\*\*(.*?)\*\*",
+ System.Text.RegularExpressions.RegexOptions.Compiled);
+
+ private static readonly System.Text.RegularExpressions.Regex ItalicRegex = new(
+ @"\*(.*?)\*",
+ System.Text.RegularExpressions.RegexOptions.Compiled);
+
+ private static readonly System.Text.RegularExpressions.Regex CodeBlockRegex = new(
+ @"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```",
+ System.Text.RegularExpressions.RegexOptions.Compiled);
+
+ private static readonly System.Text.RegularExpressions.Regex InlineCodeRegex = new(
+ @"`(.*?)`",
+ System.Text.RegularExpressions.RegexOptions.Compiled);
+
+ [Parameter] public bool IsOpen { get; set; }
+ [Parameter] public EventCallback OnClose { get; set; }
+ [Parameter] public string? TenantId { get; set; }
+
+ private string _question = string.Empty;
+ private bool _isLoading;
+ private string _activeBookTitle = string.Empty;
+ private List
_chatMessages = new();
+ private CitationDto? _selectedCitation;
+
+ protected override async Task OnParametersSetAsync()
+ {
+ if (IsOpen && string.IsNullOrEmpty(_activeBookTitle) && NavigationService.CurrentEbookId != Guid.Empty)
+ {
+ _activeBookTitle = NavigationService.ChapterTitle ?? "Aktywna książka";
+ }
+ }
+
+ private async Task HandleClose()
+ {
+ if (OnClose.HasDelegate)
+ {
+ await OnClose.InvokeAsync();
+ }
+ }
+
+ private async Task HandleKeyUp(KeyboardEventArgs e)
+ {
+ if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_question) && !_isLoading)
+ {
+ await AskQuestionAsync();
+ }
+ }
+
+ private void HandleCitationClick(string citationId)
+ {
+ _selectedCitation = _chatMessages
+ .SelectMany(m => m.Citations)
+ .FirstOrDefault(c => c.CitationId.Equals(citationId, StringComparison.OrdinalIgnoreCase))
+ ?? new CitationDto
+ {
+ CitationId = citationId,
+ SourceBook = "Grounded Document Chunk",
+ Snippet = "Context snippet retrieved from vector search node."
+ };
+ }
+
+ private void CloseCitationModal()
+ {
+ _selectedCitation = null;
+ }
+
+ private async Task AskQuestionAsync()
+ {
+ if (string.IsNullOrWhiteSpace(_question) || _isLoading) return;
+
+ var userQuestion = _question;
+ _question = string.Empty;
+ _isLoading = true;
+
+ _chatMessages.Add(new ChatMessage
+ {
+ Sender = "User",
+ 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 tenantId = TenantId ?? "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 matches = CitationRegex.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 = BoldRegex.Replace(html, "$1");
+ html = ItalicRegex.Replace(html, "$1");
+ html = CodeBlockRegex.Replace(html, "$1
");
+ html = InlineCodeRegex.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..2036e84
--- /dev/null
+++ b/src/NexusReader.UI.Shared/Components/Organisms/GlobalIntelligence.razor.css
@@ -0,0 +1,545 @@
+.global-intelligence-sheet {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ z-index: 1500;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ pointer-events: none;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+}
+
+.global-intelligence-sheet.is-open {
+ pointer-events: all;
+}
+
+.sheet-backdrop {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.4);
+ backdrop-filter: blur(4px);
+ opacity: 0;
+ transition: opacity 0.35s ease;
+ z-index: 1;
+}
+
+.global-intelligence-sheet.is-open .sheet-backdrop {
+ opacity: 1;
+}
+
+.sheet-content {
+ position: relative;
+ width: 100%;
+ height: 80vh;
+ background: rgba(18, 18, 18, 0.85);
+ backdrop-filter: blur(24px);
+ border-top: 1px solid rgba(0, 255, 153, 0.3);
+ border-top-left-radius: 20px;
+ border-top-right-radius: 20px;
+ box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.5);
+ z-index: 2;
+ transform: translateY(100%);
+ transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
+ display: flex;
+ flex-direction: column;
+}
+
+.global-intelligence-sheet.is-open .sheet-content {
+ transform: translateY(0);
+}
+
+.sheet-drag-handle {
+ width: 40px;
+ height: 4px;
+ background-color: rgba(255, 255, 255, 0.2);
+ border-radius: 2px;
+ margin: 10px auto 4px auto;
+}
+
+.sheet-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1.25rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.header-main {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.ai-avatar-badge {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, rgba(0, 255, 153, 0.15) 0%, rgba(0, 240, 255, 0.15) 100%);
+ border: 1px solid rgba(0, 255, 153, 0.3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.ai-avatar-badge ::deep i {
+ color: var(--nexus-neon, #00FF99);
+}
+
+.header-info h3 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #FFFFFF;
+ margin: 0;
+}
+
+.header-info .subtitle {
+ font-size: 0.75rem;
+ color: rgba(255, 255, 255, 0.5);
+ margin: 0;
+}
+
+.close-btn {
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.6);
+ padding: 6px;
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+}
+
+.close-btn:hover {
+ background-color: rgba(255, 255, 255, 0.05);
+ color: #FFFFFF;
+}
+
+.sheet-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1.25rem;
+}
+
+.chat-thread {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ padding-bottom: 2rem;
+}
+
+.welcome-container {
+ text-align: center;
+ padding: 4rem 1.5rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.welcome-glow-icon {
+ width: 64px;
+ height: 64px;
+ border-radius: 50%;
+ background: rgba(0, 255, 153, 0.05);
+ border: 1px solid rgba(0, 255, 153, 0.15);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 1.25rem;
+ box-shadow: 0 0 20px rgba(0, 255, 153, 0.1);
+}
+
+.welcome-glow-icon ::deep i {
+ color: var(--nexus-neon, #00FF99);
+}
+
+.welcome-container h4 {
+ font-size: 1.1rem;
+ font-weight: 550;
+ color: #FFFFFF;
+ margin-bottom: 0.5rem;
+}
+
+.welcome-container p {
+ font-size: 0.85rem;
+ color: rgba(255, 255, 255, 0.55);
+ line-height: 1.5;
+ max-width: 280px;
+}
+
+.message-row {
+ display: flex;
+ gap: 0.75rem;
+ max-width: 88%;
+}
+
+.message-row.user-row {
+ align-self: flex-end;
+ flex-direction: row-reverse;
+}
+
+.message-row.ai-row {
+ align-self: flex-start;
+}
+
+.message-avatar {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background-color: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.user-row .message-avatar {
+ background-color: rgba(0, 255, 153, 0.1);
+ border: 1px solid rgba(0, 255, 153, 0.2);
+}
+
+.message-avatar ::deep i {
+ font-size: 0.85rem;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.user-row .message-avatar ::deep i {
+ color: var(--nexus-neon, #00FF99);
+}
+
+.message-bubble {
+ padding: 0.75rem 1rem;
+ border-radius: 14px;
+ position: relative;
+}
+
+.user-bubble {
+ background-color: rgba(0, 255, 153, 0.08);
+ border: 1px solid rgba(0, 255, 153, 0.2);
+ border-top-right-radius: 2px;
+}
+
+.ai-bubble {
+ background-color: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-top-left-radius: 2px;
+}
+
+.message-meta {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.25rem;
+ gap: 1rem;
+}
+
+.sender-name {
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.user-bubble .sender-name {
+ color: var(--nexus-neon, #00FF99);
+}
+
+.ai-bubble .sender-name {
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.message-time {
+ font-size: 0.65rem;
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.message-text {
+ font-size: 0.85rem;
+ line-height: 1.5;
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.message-text strong {
+ color: #FFFFFF;
+}
+
+.nexus-mobile-citation {
+ background-color: rgba(0, 240, 255, 0.15);
+ border: 1px solid rgba(0, 240, 255, 0.3);
+ color: #00F0FF;
+ border-radius: 4px;
+ padding: 1px 4px;
+ font-size: 0.75rem;
+ font-weight: bold;
+ cursor: pointer;
+ margin-left: 2px;
+ display: inline-block;
+}
+
+.nexus-mobile-code-block {
+ background-color: rgba(0, 0, 0, 0.4);
+ border-left: 3px solid var(--nexus-neon, #00FF99);
+ padding: 0.75rem;
+ border-radius: 6px;
+ margin: 0.5rem 0;
+ overflow-x: auto;
+ font-family: monospace;
+ font-size: 0.75rem;
+}
+
+.nexus-mobile-inline-code {
+ background-color: rgba(255, 255, 255, 0.08);
+ color: #FF7B72;
+ padding: 2px 4px;
+ border-radius: 4px;
+ font-family: monospace;
+ font-size: 0.75rem;
+}
+
+/* Typing indicator */
+.typing-indicator {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 0;
+}
+
+.typing-indicator span {
+ width: 6px;
+ height: 6px;
+ background-color: rgba(255, 255, 255, 0.5);
+ border-radius: 50%;
+ animation: bounce 1.4s infinite ease-in-out both;
+}
+
+.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
+.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
+
+@keyframes bounce {
+ 0%, 80%, 100% { transform: scale(0); }
+ 40% { transform: scale(1); }
+}
+
+.loading-label {
+ font-size: 0.75rem;
+ color: rgba(255, 255, 255, 0.5);
+ margin-left: 8px;
+ vertical-align: middle;
+}
+
+.sheet-footer {
+ padding: 0.75rem 1rem 1.5rem 1rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ background-color: rgba(10, 10, 10, 0.5);
+}
+
+.scope-indicator {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 0.7rem;
+ color: rgba(255, 255, 255, 0.45);
+ margin-bottom: 0.5rem;
+}
+
+.scope-indicator ::deep i {
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.input-container {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.nexus-mobile-input {
+ flex: 1;
+ background-color: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 12px;
+ padding: 0.65rem 0.9rem;
+ font-size: 0.85rem;
+ color: #FFFFFF;
+ outline: none;
+ transition: all 0.25s ease;
+}
+
+.nexus-mobile-input:focus {
+ border-color: rgba(0, 255, 153, 0.4);
+ background-color: rgba(255, 255, 255, 0.07);
+ box-shadow: 0 0 8px rgba(0, 255, 153, 0.15);
+}
+
+.send-btn {
+ width: 38px;
+ height: 38px;
+ border-radius: 12px;
+ background: linear-gradient(135deg, #00FF99 0%, #00F0FF 100%);
+ border: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #0b0c10;
+ cursor: pointer;
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: 0 0 10px rgba(0, 255, 153, 0.2);
+ flex-shrink: 0;
+}
+
+.send-btn.disabled {
+ background: rgba(255, 255, 255, 0.05);
+ color: rgba(255, 255, 255, 0.3);
+ box-shadow: none;
+ cursor: not-allowed;
+}
+
+.btn-spinner {
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(0,0,0,0.1);
+ border-top: 2px solid #000000;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+/* Citation Modal Overlay & Glassmorphic Card */
+.citation-modal-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(8px);
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1.5rem;
+ animation: fadeIn 0.25s ease-out;
+}
+
+.citation-modal {
+ width: 100%;
+ max-width: 320px;
+ background: rgba(20, 20, 20, 0.85);
+ border: 1px solid rgba(0, 240, 255, 0.25);
+ box-shadow: 0 0 30px rgba(0, 240, 255, 0.15);
+ border-radius: 16px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.citation-modal .modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.citation-modal .book-title {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: #FFFFFF;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.citation-modal .book-title ::deep i {
+ color: #00F0FF;
+}
+
+.citation-modal .close-btn {
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.5);
+ cursor: pointer;
+ padding: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.citation-modal .modal-body {
+ padding: 1rem;
+ font-size: 0.8rem;
+ line-height: 1.5;
+ color: rgba(255, 255, 255, 0.85);
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.citation-modal .citation-author,
+.citation-modal .citation-page {
+ font-size: 0.75rem;
+ color: rgba(255, 255, 255, 0.5);
+ margin: 0;
+}
+
+.citation-modal .citation-author strong,
+.citation-modal .citation-page strong {
+ color: rgba(255, 255, 255, 0.75);
+}
+
+.citation-modal .citation-snippet {
+ font-style: italic;
+ background: rgba(0, 240, 255, 0.04);
+ border-left: 2px solid #00F0FF;
+ padding: 0.5rem 0.75rem;
+ border-radius: 4px;
+ color: rgba(255, 255, 255, 0.9);
+ margin: 0.25rem 0 0 0;
+}
+
+.citation-modal .modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ padding: 0.75rem 1rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.citation-modal .btn-nexus {
+ font-size: 0.8rem;
+ padding: 0.4rem 1rem;
+ border-radius: 8px;
+ background: linear-gradient(135deg, rgba(0, 240, 255, 0.2) 0%, rgba(0, 255, 153, 0.2) 100%);
+ border: 1px solid rgba(0, 240, 255, 0.4);
+ color: #FFFFFF;
+ font-weight: 550;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.citation-modal .btn-nexus:hover {
+ background: linear-gradient(135deg, rgba(0, 240, 255, 0.35) 0%, rgba(0, 255, 153, 0.35) 100%);
+ border-color: rgba(0, 240, 255, 0.6);
+ box-shadow: 0 0 10px rgba(0, 240, 255, 0.2);
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slideUp {
+ from { transform: translateY(20px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
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..168cd08
--- /dev/null
+++ b/src/NexusReader.UI.Shared/Components/Organisms/MobileReaderToolbar.razor
@@ -0,0 +1,151 @@
+@using NexusReader.UI.Shared.Components.Atoms
+@using NexusReader.UI.Shared.Services
+@using NexusReader.UI.Shared.Models
+@using NexusReader.Application.Utilities
+@namespace NexusReader.UI.Shared.Components.Organisms
+@inject IReaderInteractionService InteractionService
+@inject IReaderStateService StateService
+
+
+
+
+
+
+
+
+
+
+ @if (Checkpoints == null || !Checkpoints.Any())
+ {
+
+
+
Brak punktów kontrolnych w tym rozdziale.
+
+ }
+ else
+ {
+
+ @foreach (var cp in Checkpoints)
+ {
+ var isCurrent = cp == StateService.CurrentBlockId;
+
SelectCheckpoint(cp)">
+
+
+ @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; }
+
+
+ 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 066e336..98d2323 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,11 +12,36 @@
@inject IReaderNavigationService NavigationService
@inject KnowledgeCoordinator Coordinator
@inject IReaderInteractionService InteractionService
+@inject IReaderStateService StateService
@inject ISyncService SyncService
@inject AuthenticationStateProvider AuthStateProvider
+@inject IQuizStateService QuizService
+@inject IPlatformService PlatformService
+@inject NavigationManager Navigation
@inject ILogger Logger
+ @if (_isMobile && ViewModel != null)
+ {
+
+ }
+
@if (ViewModel == null)
{
@@ -53,6 +79,8 @@
BlockId="@_selectedBlockId"
Coordinates="@_selectionCoords"
FullPageContent="@GetFullPageContent()" />
+
+
@code {
@@ -68,17 +96,31 @@
private ElementReference _containerRef;
private bool _isInteractive;
private string? _currentActiveBlockId;
+ private bool _isMobile = false;
+ private DotNetObjectReference
? _selfReference;
+ private IJSObjectReference? _viewportModule;
protected override async Task OnInitializedAsync()
{
await Coordinator.ClearAsync();
ThemeService.OnThemeChanged += HandleUpdate;
NavigationService.OnNavigationChanged += OnNavigationChanged;
+ QuizService.OnQuizUpdated += HandleUpdate;
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
InteractionService.OnTextSelected += HandleTextSelected;
SyncService.OnProgressReceived += HandleSyncProgressReceived;
+
+ var context = PlatformService.GetDeviceContext();
+ if (context.IsSuccess)
+ {
+ _isMobile = context.Value.DeviceType switch
+ {
+ DeviceType.Phone or DeviceType.Tablet => true,
+ _ => false
+ };
+ }
}
protected override async Task OnParametersSetAsync()
@@ -99,12 +141,15 @@
{
if (firstRender)
{
+ _selfReference = DotNetObjectReference.Create(this);
await SyncService.InitializeAsync();
_isInteractive = true;
if (ViewModel != null)
{
await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId);
}
+
+ await InitViewportDetectionAsync();
}
if (ViewModel != null && !_isJsInitialized)
@@ -115,12 +160,52 @@
}
}
+ private async ValueTask EnsureViewportModuleAsync()
+ {
+ if (_viewportModule == null)
+ {
+ _viewportModule = await JS.InvokeAsync("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
+ }
+ return _viewportModule;
+ }
+
+ private async Task InitViewportDetectionAsync()
+ {
+ try
+ {
+ var module = await EnsureViewportModuleAsync();
+ var isMobileViewport = await module.InvokeAsync("isMobileViewport");
+ await OnViewportChanged(isMobileViewport);
+ if (_selfReference != null)
+ {
+ await module.InvokeVoidAsync("registerViewportObserver", _selfReference);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogWarning(ex, "Failed to initialize viewport detection in ReaderCanvas.");
+ }
+ }
+
+ [JSInvokable]
+ public async Task OnViewportChanged(bool isMobile)
+ {
+ if (_isMobile != isMobile)
+ {
+ _isMobile = isMobile;
+ await InvokeAsync(StateHasChanged);
+ }
+ }
+
private async Task InitializeSelectionListenerAsync()
{
try
{
var module = await JS.InvokeAsync("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
- await module.InvokeVoidAsync("initSelectionListener", DotNetObjectReference.Create(this), _containerRef);
+ if (_selfReference != null)
+ {
+ await module.InvokeVoidAsync("initSelectionListener", _selfReference, _containerRef);
+ }
}
catch (Exception ex)
{
@@ -128,12 +213,18 @@
}
}
+ 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");
+ if (_selfReference != null)
+ {
+ await module.InvokeVoidAsync("initObserver", _selfReference, ".reader-flow-container", ".block-wrapper");
+ _scrollListenerReference = await module.InvokeAsync("initScrollListener", _selfReference, ".reader-flow-container");
+ }
}
catch (Exception ex)
{
@@ -141,10 +232,19 @@
}
}
+ [JSInvokable]
+ public async Task HandleScrollPercentChanged(int percent)
+ {
+ StateService.CurrentScrollPercentage = percent;
+ await InteractionService.NotifyScrollPercentChanged(percent);
+ }
+
[JSInvokable]
public async Task HandleBlockReached(string blockId, string content)
{
_currentActiveBlockId = blockId;
+ StateService.CurrentBlockId = blockId;
+ await InteractionService.NotifyBlockReached(blockId);
await Coordinator.OnBlockReachedAsync(blockId, content);
if (ViewModel != null)
@@ -244,6 +344,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();
+ StateService.CurrentCheckpoints = checkpoints;
+
if (_isInteractive)
{
await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId);
@@ -259,16 +366,25 @@
_isLoadingChapter = false;
StateHasChanged();
- if (result.IsSuccess && !string.IsNullOrEmpty(NavigationService.PendingScrollBlockId))
+ if (result.IsSuccess)
{
- var targetBlockId = NavigationService.PendingScrollBlockId;
- NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls
- _currentActiveBlockId = targetBlockId;
+ if (!string.IsNullOrEmpty(NavigationService.PendingScrollBlockId))
+ {
+ var targetBlockId = NavigationService.PendingScrollBlockId;
+ NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls
+ _currentActiveBlockId = targetBlockId;
- // Give the browser slightly more than one frame to render the loaded blocks
- await Task.Delay(150);
- await ScrollToNodeAsync(targetBlockId);
- await InteractionService.RequestHighlightBlock(targetBlockId);
+ // Give the browser slightly more than one frame to render the loaded blocks
+ await Task.Delay(150);
+ await ScrollToNodeAsync(targetBlockId);
+ await InteractionService.RequestHighlightBlock(targetBlockId);
+ }
+ else
+ {
+ // Reset scroll to top now that the new content DOM is rendered
+ await Task.Delay(50); // Give the browser a frame to render the new chapter content
+ await ScrollToTopAsync();
+ }
}
}
@@ -276,7 +392,8 @@
{
try
{
- await JS.InvokeVoidAsync("eval", $"document.getElementById('{id}')?.scrollIntoView({{ behavior: 'smooth', block: 'center' }});");
+ var module = await EnsureViewportModuleAsync();
+ await module.InvokeVoidAsync("scrollIntoView", id);
}
catch (Exception ex)
{
@@ -284,16 +401,73 @@
}
}
+ public async Task ScrollToTopAsync()
+ {
+ try
+ {
+ var module = await EnsureViewportModuleAsync();
+ await module.InvokeVoidAsync("scrollToTop", ".reader-canvas");
+ }
+ catch (Exception ex)
+ {
+ Logger.LogWarning(ex, "Failed to scroll reader canvas to top.");
+ }
+ }
+
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
- public void Dispose()
+ private void HandleEscape()
+ {
+ if (ViewModel != null)
+ {
+ Navigation.NavigateTo("/");
+ }
+ }
+
+ private async Task HandleAssistantFabClick()
+ {
+ await InteractionService.RequestAssistant();
+ }
+
+ public async ValueTask DisposeAsync()
{
ThemeService.OnThemeChanged -= HandleUpdate;
NavigationService.OnNavigationChanged -= OnNavigationChanged;
+ QuizService.OnQuizUpdated -= HandleUpdate;
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
InteractionService.OnTextSelected -= HandleTextSelected;
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
+
+ try
+ {
+ if (_viewportModule != null)
+ {
+ if (_selfReference != null)
+ {
+ await _viewportModule.InvokeVoidAsync("unregisterViewportObserver", _selfReference);
+ }
+ await _viewportModule.DisposeAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogDebug(ex, "Teardown of viewport observer module failed in ReaderCanvas disposal.");
+ }
+
+ try
+ {
+ if (_scrollListenerReference != null)
+ {
+ await _scrollListenerReference.DisposeAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogDebug(ex, "Teardown of scroll listener reference failed in ReaderCanvas disposal.");
+ }
+
+ _selfReference?.Dispose();
}
}
diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css
index 443a865..fac7f04 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; }
-}
\ No newline at end of file
+}
+
+/* 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);
+}
diff --git a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor
index 70d012c..14574f3 100644
--- a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor
+++ b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor
@@ -4,9 +4,39 @@
@using NexusReader.Application.Abstractions.Services
@using NexusReader.UI.Shared.Services
-
+@if (!_isFullyLoaded)
+{
+
+
+
Synchronizing Secure Session...
+
+}
+
+