feat(search/rag): implement NexusSearchBox, dynamic Qdrant collection auto-provisioning, batch vector ingestion, mobile Serilog logging, and resolve 401 auth handler error (#51)
Resolves #52 This Pull Request introduces the **NexusSearchBox** search feature with premium unified styling, implements a robust **dynamic Qdrant collection auto-provisioning and batch-vector ingestion pipeline**, integrates a unified **Serilog logging infrastructure** for the Blazor Hybrid environment (MAUI), and resolves the **401 Unauthorized API header propagation error** inside mobile builds. ### 🚀 Key Implementations #### 1. Premium `NexusSearchBox` & Semantic Search UI * **NexusSearchBox Component:** Created an elegant search-as-you-type search box with smooth key navigation, quick-clearing, and seamless dynamic styling. * **Unified Aesthetics:** Refactored the search box isolated styling to align perfectly with the dashboard's design system using glassmorphism, `--nexus-neon` token gradients, and smooth pulse/fade animations. * **Semantic Search Integration:** Integrated semantic search query dispatching (`SearchLibrarySemanticallyQuery`) and wired up navigation seamlessly through the updated `ReaderNavigationService`. * **Tests Hardening:** Added/adapted query assertions in `QueryTests.cs` to guarantee safe parameterization and error boundary mapping. #### 2. Qdrant Collection Provisioning & Vector Ingestion * **Dynamic Auto-Provisioning:** Implemented dynamic checking and lazy-creation of the `knowledge_units` collection using 768 dimensions and Cosine distance. * **High-Performance Ingestion:** Optimized `ProcessKnowledgeUnitsAsync` with high-performance batch embedding generation using `_embeddingGenerator` and deterministic MD5 GUIDs for stable, duplicate-free upsertion. * **Database Cache Clear Sync:** Integrated Qdrant collection deletion in `ClearCacheAsync` to ensure absolute consistency between the PostgreSQL database cache and vector database indices. #### 3. Cross-Platform MAUI Logging (Serilog Infrastructure) * **Serilog Integration:** Configured cross-platform Serilog routing in `SerilogConfiguration.cs`, streaming diagnostic logs safely across native platforms and the Blazor Webview container. * **Interop Bridge:** Built `BlazorLoggingBridge.cs` to capture web console messages and pipe them directly to the native host logger. * **Demo Interface:** Added an interactive `SerilogDemo.razor` sandbox under Pages. #### 4. Resolving 401 Load Errors (Authentication Handler Flow) * **Authentication Header Handler:** Implemented the `MobileAuthenticationHeaderHandler` to correctly extract, validate, and inject bearer JWT tokens into outbound API requests. * **Configuration-based API Host:** Structured standard API URI routing to use clean configuration bindings in `appsettings.json`. --- ### 🧪 Verification & Build Status * Run `dotnet build` from the solution root: Successfully compiled the full multi-targeted solution (`Liczba błędów: 0`). * All unit and integration tests successfully executed and verified (`dotnet test`). --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Co-authored-by: Marek Jaisński <jasins.marek@gmail.com> Reviewed-on: #51 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #51.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
|
||||
<div class="nexus-citation-container" @onmouseenter="ShowPopup" @onmouseleave="HidePopup">
|
||||
<button type="button" class="nexus-citation-trigger" aria-label="Citation source">
|
||||
<!-- Circular Neon SVG Radar Ping / Stylized Book Icon -->
|
||||
<svg class="neon-radar-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="12" r="6"></circle>
|
||||
<circle cx="12" cy="12" r="2"></circle>
|
||||
</svg>
|
||||
<span class="pulse-ring"></span>
|
||||
</button>
|
||||
|
||||
@if (_isHovered && _citation != null)
|
||||
{
|
||||
<div class="nexus-citation-popup">
|
||||
<div class="popup-header">
|
||||
<span class="book-title"><i class="bi bi-book-half"></i> @_citation.SourceBook</span>
|
||||
@if (!string.IsNullOrEmpty(_citation.Author))
|
||||
{
|
||||
<span class="separator">•</span>
|
||||
<span class="book-author">@_citation.Author</span>
|
||||
}
|
||||
@if (_citation.PageNumber.HasValue)
|
||||
{
|
||||
<span class="separator">•</span>
|
||||
<span class="page-number">Page @_citation.PageNumber.Value</span>
|
||||
}
|
||||
</div>
|
||||
<div class="popup-body">
|
||||
<p class="citation-quote">"@_citation.Snippet"</p>
|
||||
</div>
|
||||
<div class="popup-footer">
|
||||
<span class="id-badge">ID: @SourceId.Substring(0, Math.Min(8, SourceId.Length))</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public string SourceId { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public List<CitationDto>? Citations { get; set; }
|
||||
|
||||
private bool _isHovered;
|
||||
private CitationDto? _citation;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_citation = Citations?.FirstOrDefault(c => c.CitationId.Equals(SourceId, System.StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// If not found in the thread citations, provide a clean fallback so the UI never displays an empty error
|
||||
if (_citation == null)
|
||||
{
|
||||
_citation = new CitationDto
|
||||
{
|
||||
CitationId = SourceId,
|
||||
SourceBook = "Grounded Document Chunk",
|
||||
Snippet = "Context snippet retrieved from vector search node."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowPopup()
|
||||
{
|
||||
_isHovered = true;
|
||||
}
|
||||
|
||||
private void HidePopup()
|
||||
{
|
||||
_isHovered = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
.nexus-citation-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: middle;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.nexus-citation-trigger {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #06b6d4; /* Glowing Cyan */
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nexus-citation-trigger:hover {
|
||||
color: #00ff99; /* Neon Green on hover */
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.neon-radar-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 0 4px currentColor);
|
||||
animation: radar-spin 8s linear infinite;
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: radar-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nexus-citation-popup {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(5px);
|
||||
width: 320px;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 16, 26, 0.9); /* Premium dark background */
|
||||
border: 1px solid rgba(6, 182, 212, 0.25); /* Cyan border */
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 12px rgba(6, 182, 212, 0.15);
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
animation: popup-fade-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #00ff99; /* Emerald/Neon Green micro-header */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.book-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.book-author, .page-number {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.citation-quote {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.id-badge {
|
||||
font-size: 0.65rem;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes radar-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes radar-ping {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes popup-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(8px) scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,347 @@
|
||||
@namespace NexusReader.UI.Shared.Components.Atoms
|
||||
@using System.Text.RegularExpressions
|
||||
@using MediatR
|
||||
@using NexusReader.Application.DTOs.AI
|
||||
@using NexusReader.Application.Queries.Library
|
||||
@inject IMediator Mediator
|
||||
@inject IReaderNavigationService NavService
|
||||
@inject IReaderInteractionService InteractionService
|
||||
@inject NavigationManager NavManager
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject ILogger<NexusSearchBox> Logger
|
||||
@implements IDisposable
|
||||
|
||||
<div class="nexus-search-container @(IsActive ? "active" : "")">
|
||||
<div class="nexus-search-container @(IsFocused ? "focused" : "") @(HasResults ? "has-results" : "")" @onfocusin="HandleFocusIn" @onfocusout="HandleFocusOut">
|
||||
<div class="search-wrapper">
|
||||
<i class="nexus-icon @IconClass"></i>
|
||||
<input type="text"
|
||||
@bind="SearchValue"
|
||||
@bind:event="oninput"
|
||||
@onkeypress="HandleKeyPress"
|
||||
placeholder="@Placeholder"
|
||||
<div class="search-icon-container">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="neon-spinner"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="nexus-icon bi bi-search"></i>
|
||||
}
|
||||
</div>
|
||||
|
||||
<input type="text"
|
||||
value="@SearchValue"
|
||||
@oninput="HandleInput"
|
||||
@onkeydown="HandleKeyDown"
|
||||
placeholder="@Placeholder"
|
||||
class="nexus-search-input" />
|
||||
|
||||
<div class="ai-status-indicator" title="Aktywny silnik AI biblioteki">
|
||||
<span class="ai-pulse-dot"></span>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(SearchValue))
|
||||
{
|
||||
<button class="clear-btn" @onclick="ClearSearch">×</button>
|
||||
<button type="button" class="clear-btn" @onclick="ClearSearch" aria-label="Wyczyść wyszukiwanie">×</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_isDropdownOpen && (!string.IsNullOrEmpty(SearchValue) || _isLoading || _results.Any() || _searchError != null))
|
||||
{
|
||||
<div class="search-dropdown glass-panel">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="dropdown-state-container">
|
||||
<div class="neon-spinner-large"></div>
|
||||
<span class="state-text">Analizowanie biblioteki semantycznej...</span>
|
||||
</div>
|
||||
}
|
||||
else if (_searchError != null)
|
||||
{
|
||||
<div class="dropdown-state-container error">
|
||||
<i class="bi bi-exclamation-triangle-fill error-icon"></i>
|
||||
<span class="state-text">@_searchError</span>
|
||||
</div>
|
||||
}
|
||||
else if (_results.Any())
|
||||
{
|
||||
<div class="dropdown-results-list">
|
||||
@foreach (var result in _results)
|
||||
{
|
||||
<div class="result-card" @onclick="() => HandleResultClick(result)">
|
||||
<div class="result-header">
|
||||
<span class="relevance-badge">@(Math.Round(result.RelevanceScore * 100))% Trafności</span>
|
||||
@if (!string.IsNullOrEmpty(result.SourceBookTitle))
|
||||
{
|
||||
<span class="source-title" title="@result.SourceBookTitle">w <strong>@result.SourceBookTitle</strong></span>
|
||||
}
|
||||
</div>
|
||||
<div class="result-snippet">
|
||||
@((MarkupString)HighlightQueryWords(result.Snippet, SearchValue))
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(SearchValue))
|
||||
{
|
||||
<div class="dropdown-state-container empty">
|
||||
<i class="bi bi-search empty-icon"></i>
|
||||
<span class="state-text">Brak wyników dla zapytania.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Placeholder { get; set; } = "Search your library...";
|
||||
[Parameter] public string IconClass { get; set; } = "bi bi-search";
|
||||
[Parameter] public string Placeholder { get; set; } = "Zapytaj swoją bibliotekę AI...";
|
||||
[Parameter] public EventCallback<string> OnSearch { get; set; }
|
||||
|
||||
private string SearchValue { get; set; } = string.Empty;
|
||||
private bool IsActive => !string.IsNullOrEmpty(SearchValue);
|
||||
[Parameter] public int Limit { get; set; } = 5;
|
||||
|
||||
private async Task HandleKeyPress(KeyboardEventArgs e)
|
||||
private string SearchValue { get; set; } = string.Empty;
|
||||
private bool IsFocused { get; set; }
|
||||
private bool HasResults => _results.Any() && _isDropdownOpen;
|
||||
|
||||
private List<SemanticSearchResultDto> _results = new();
|
||||
private bool _isLoading;
|
||||
private string? _searchError;
|
||||
private bool _isDropdownOpen;
|
||||
private bool _disposed;
|
||||
|
||||
private CancellationTokenSource? _searchCts;
|
||||
|
||||
private async Task HandleInput(ChangeEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
SearchValue = e.Value?.ToString() ?? string.Empty;
|
||||
_searchError = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SearchValue))
|
||||
{
|
||||
await OnSearch.InvokeAsync(SearchValue);
|
||||
_results.Clear();
|
||||
_isDropdownOpen = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_isDropdownOpen = true;
|
||||
|
||||
// Cancel previous search in-flight
|
||||
_searchCts?.Cancel();
|
||||
_searchCts?.Dispose();
|
||||
_searchCts = new CancellationTokenSource();
|
||||
|
||||
var token = _searchCts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
// Debounce for 300ms
|
||||
await Task.Delay(300, token);
|
||||
await PerformSearchAsync(token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Typing continued, search cancelled
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PerformSearchAsync(CancellationToken token)
|
||||
{
|
||||
_isLoading = true;
|
||||
_searchError = null;
|
||||
if (!_disposed)
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
|
||||
|
||||
var result = await Mediator.Send(new SearchLibrarySemanticallyQuery(SearchValue, tenantId, Limit), token);
|
||||
if (token.IsCancellationRequested || _disposed) return;
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_results = result.Value ?? new List<SemanticSearchResultDto>();
|
||||
_searchError = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_results.Clear();
|
||||
_searchError = result.Errors.FirstOrDefault()?.Message ?? "Nie udało się wykonać wyszukiwania.";
|
||||
Logger.LogWarning("Semantic search returned errors: {Errors}", string.Join(", ", result.Errors.Select(e => e.Message)));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!token.IsCancellationRequested && !_disposed)
|
||||
{
|
||||
_results.Clear();
|
||||
_searchError = "Wystąpił nieoczekiwany błąd podczas wyszukiwania.";
|
||||
Logger.LogError(ex, "Unexpected error during semantic search for query: {Query}", SearchValue);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!token.IsCancellationRequested && !_disposed)
|
||||
{
|
||||
_isLoading = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleResultClick(SemanticSearchResultDto result)
|
||||
{
|
||||
_isDropdownOpen = false;
|
||||
|
||||
// 1. Resolve Ebook ID
|
||||
Guid? ebookId = null;
|
||||
if (result.Metadata != null)
|
||||
{
|
||||
foreach (var key in new[] { "ebookId", "ebook_id", "EbookId", "Ebook_Id" })
|
||||
{
|
||||
if (result.Metadata.TryGetValue(key, out var val) && val != null)
|
||||
{
|
||||
if (Guid.TryParse(val.ToString(), out var g))
|
||||
{
|
||||
ebookId = g;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ebookId == null || ebookId == Guid.Empty)
|
||||
{
|
||||
ebookId = NavService.CurrentEbookId;
|
||||
}
|
||||
|
||||
if (ebookId == null || ebookId == Guid.Empty)
|
||||
{
|
||||
Logger.LogWarning("Could not resolve ebook ID from search result metadata.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Resolve Chapter Index
|
||||
int chapterIndex = 0;
|
||||
if (result.Metadata != null)
|
||||
{
|
||||
foreach (var key in new[] { "chapterIndex", "chapter_index", "ChapterIndex", "chapter" })
|
||||
{
|
||||
if (result.Metadata.TryGetValue(key, out var val) && val != null)
|
||||
{
|
||||
if (int.TryParse(val.ToString(), out var parsedInt))
|
||||
{
|
||||
chapterIndex = parsedInt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Resolve Block ID
|
||||
string? blockId = null;
|
||||
if (result.Metadata != null)
|
||||
{
|
||||
foreach (var key in new[] { "blockId", "block_id", "BlockId", "nodeId", "node_id", "NodeId", "id" })
|
||||
{
|
||||
if (result.Metadata.TryGetValue(key, out var val) && val != null)
|
||||
{
|
||||
blockId = val.ToString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(blockId))
|
||||
{
|
||||
blockId = result.ContentHash;
|
||||
}
|
||||
|
||||
// 4. Set pending scroll and navigate
|
||||
NavService.PendingScrollBlockId = blockId;
|
||||
|
||||
if (NavService.CurrentEbookId == ebookId.Value && NavService.CurrentChapterIndex == chapterIndex)
|
||||
{
|
||||
// Same chapter - scroll and highlight immediately
|
||||
if (!string.IsNullOrEmpty(blockId))
|
||||
{
|
||||
await InteractionService.RequestScrollToBlock(blockId);
|
||||
await InteractionService.RequestHighlightBlock(blockId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Different chapter or book - perform routing
|
||||
NavService.SetBook(ebookId.Value, chapterIndex);
|
||||
NavManager.NavigateTo($"/reader/{ebookId.Value}?chapter={chapterIndex}");
|
||||
}
|
||||
|
||||
// Invoke the optional callback for parent components
|
||||
await OnSearch.InvokeAsync(SearchValue);
|
||||
}
|
||||
|
||||
private void HandleKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Escape")
|
||||
{
|
||||
_isDropdownOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleFocusIn()
|
||||
{
|
||||
IsFocused = true;
|
||||
_isDropdownOpen = true;
|
||||
}
|
||||
|
||||
private async Task HandleFocusOut()
|
||||
{
|
||||
IsFocused = false;
|
||||
// Delay slightly to allow click handlers on result cards to execute
|
||||
await Task.Delay(200);
|
||||
if (_disposed) return;
|
||||
_isDropdownOpen = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ClearSearch()
|
||||
{
|
||||
SearchValue = string.Empty;
|
||||
_results.Clear();
|
||||
_searchError = null;
|
||||
_isDropdownOpen = false;
|
||||
}
|
||||
|
||||
private string HighlightQueryWords(string text, string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return string.Empty;
|
||||
|
||||
var escapedText = System.Net.WebUtility.HtmlEncode(text);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return escapedText;
|
||||
|
||||
var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(w => w.Length > 2)
|
||||
.Select(Regex.Escape);
|
||||
|
||||
if (!words.Any())
|
||||
return escapedText;
|
||||
|
||||
var pattern = "(" + string.Join("|", words) + ")";
|
||||
try
|
||||
{
|
||||
return Regex.Replace(escapedText, pattern, "<mark class=\"search-highlight\">$1</mark>", RegexOptions.IgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return escapedText;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
_searchCts?.Cancel();
|
||||
_searchCts?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,309 @@
|
||||
.nexus-search-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin: 1rem auto;
|
||||
transition: all 0.3s ease;
|
||||
max-width: 600px;
|
||||
margin: 1.5rem auto;
|
||||
font-family: var(--nexus-font-sans), 'Inter', sans-serif;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--nexus-card, #141414);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem 1rem;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 0.65rem 1.1rem;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.nexus-search-container.active .search-wrapper,
|
||||
.search-wrapper:focus-within {
|
||||
border-color: var(--nexus-neon, #00ff99);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 153, 0.2);
|
||||
/* Focused state: glowing neon border matching other dashboard components */
|
||||
.nexus-search-container.focused .search-wrapper {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: var(--nexus-neon);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 15px rgba(0, 255, 153, 0.25);
|
||||
}
|
||||
|
||||
.search-icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 0.85rem;
|
||||
}
|
||||
|
||||
.nexus-icon {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 1.25rem;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.nexus-search-container.focused .nexus-icon {
|
||||
color: var(--nexus-neon);
|
||||
}
|
||||
|
||||
.nexus-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nexus-search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-style: italic;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.nexus-search-container.focused .nexus-search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Pulsing neon-green AI status indicator */
|
||||
.ai-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 0.75rem;
|
||||
}
|
||||
|
||||
.ai-pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--nexus-neon);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
box-shadow: 0 0 8px var(--nexus-neon);
|
||||
}
|
||||
|
||||
.ai-pulse-dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: var(--nexus-neon);
|
||||
border-radius: 50%;
|
||||
z-index: -1;
|
||||
animation: pulse 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 0.5rem;
|
||||
transition: color 0.2s ease;
|
||||
padding: 0 0.25rem;
|
||||
margin-left: 0.5rem;
|
||||
transition: color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: var(--nexus-neon, #00ff99);
|
||||
color: #ff3b30;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Frosted glass results container */
|
||||
.search-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(18, 18, 18, 0.9);
|
||||
backdrop-filter: blur(20px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(160%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.6), 0 0 20px rgba(0, 255, 153, 0.05);
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
animation: slideDown 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.search-dropdown::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.search-dropdown::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.search-dropdown::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--nexus-neon);
|
||||
}
|
||||
|
||||
/* In-flight spinners */
|
||||
.neon-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(0, 255, 153, 0.15);
|
||||
border-top: 2px solid var(--nexus-neon);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
.neon-spinner-large {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.05);
|
||||
border-top: 3px solid var(--nexus-neon);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dropdown-state-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2.5rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.error-icon, .empty-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #ff3b30;
|
||||
text-shadow: 0 0 10px rgba(255, 59, 48, 0.4);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Results Cards list */
|
||||
.dropdown-results-list {
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
padding: 0.95rem 1.1rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.result-card:hover {
|
||||
background: rgba(0, 255, 153, 0.05);
|
||||
border-color: rgba(0, 255, 153, 0.2);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.relevance-badge {
|
||||
background: rgba(0, 255, 153, 0.1);
|
||||
color: var(--nexus-neon);
|
||||
border: 1px solid rgba(0, 255, 153, 0.25);
|
||||
border-radius: 6px;
|
||||
padding: 0.15rem 0.45rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-shadow: 0 0 4px rgba(0, 255, 153, 0.25);
|
||||
}
|
||||
|
||||
.source-title {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-title strong {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.result-snippet {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-weight: 300;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Markup highlights */
|
||||
::deep mark.search-highlight {
|
||||
background: rgba(0, 255, 153, 0.2);
|
||||
color: var(--nexus-neon);
|
||||
border-bottom: 1px solid var(--nexus-neon);
|
||||
padding: 0.05rem 0.15rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(0, 255, 153, 0.7);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.6);
|
||||
box-shadow: 0 0 0 6px rgba(0, 255, 153, 0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(0, 255, 153, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
@using NexusReader.Application.Queries.Quiz
|
||||
@using NexusReader.Application.Commands.Quiz
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using NexusReader.UI.Shared.Components.Atoms
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@inject IMediator Mediator
|
||||
@inject IPlatformService PlatformService
|
||||
@inject IQuizStateService QuizService
|
||||
@inject IIdentityService IdentityService
|
||||
@inject IKnowledgeGraphService GraphService
|
||||
@inject KnowledgeCoordinator Coordinator
|
||||
|
||||
<div class="knowledge-check">
|
||||
<div class="quiz-header">
|
||||
@@ -12,10 +17,33 @@
|
||||
<button class="expand-btn">⌵</button>
|
||||
</div>
|
||||
|
||||
@if (QuizService.IsHydrating)
|
||||
@if (QuizService.IsHydrating || _isGenerating)
|
||||
{
|
||||
<div class="loading-state shimmer">Skanowanie wiedzy przez AI...</div>
|
||||
}
|
||||
else if (_isSubmitted)
|
||||
{
|
||||
<div class="submitted-container">
|
||||
<div class="success-icon-wrapper">
|
||||
<NexusIcon Name="check" Size="48" Class="success-glow" />
|
||||
</div>
|
||||
<h3 class="submitted-title">Gratulacje!</h3>
|
||||
<p class="submitted-text">Sprawdzian zakończony pomyślnie. Twój wynik został zapisany w bazie danych.</p>
|
||||
|
||||
<div class="score-card">
|
||||
<div class="score-main">
|
||||
<span class="score-num">@_score</span>
|
||||
<span class="score-divider">/</span>
|
||||
<span class="score-total">@_totalQuestions</span>
|
||||
</div>
|
||||
<div class="score-percent">@((int)_percentage)% poprawnych odpowiedzi</div>
|
||||
</div>
|
||||
|
||||
<button class="reset-quiz-btn" @onclick="CloseQuiz">
|
||||
<span>ZAKOŃCZ</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else if (QuizService.CurrentQuiz != null)
|
||||
{
|
||||
<div class="quiz-body">
|
||||
@@ -41,17 +69,45 @@
|
||||
}
|
||||
|
||||
<div class="quiz-footer">
|
||||
<button class="submit-btn" disabled="@(!AllQuestionsAnswered())">Wyślij</button>
|
||||
<button class="submit-btn" disabled="@(!AllQuestionsAnswered() || _isSubmitting)" @onclick="SubmitQuizAsync">
|
||||
@if (_isSubmitting)
|
||||
{
|
||||
<span>Zapisywanie...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Wyślij</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="empty-quiz-state">
|
||||
<div class="empty-icon-wrapper">
|
||||
<NexusIcon Name="robot" Size="48" Class="neon-glow" />
|
||||
</div>
|
||||
<h3 class="empty-title">Brak Aktywnego Quizu</h3>
|
||||
<p class="empty-text">Generuj spersonalizowany sprawdzian wiedzy na podstawie bieżącego rozdziału książki.</p>
|
||||
|
||||
<button class="generate-quiz-btn" @onclick="GenerateChapterQuizAsync" disabled="@(string.IsNullOrWhiteSpace(Coordinator.CurrentFullPageContent))">
|
||||
<span>GENERUJ QUIZ DLA ROZDZIAŁU</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@code {
|
||||
[Parameter] public string ContextBlockId { get; set; } = string.Empty;
|
||||
|
||||
private Dictionary<QuizQuestionDto, (int SelectedIndex, bool IsCorrect)> _states = new();
|
||||
private bool _isSubmitting = false;
|
||||
private bool _isSubmitted = false;
|
||||
private bool _isGenerating = false;
|
||||
private int _score = 0;
|
||||
private int _totalQuestions = 0;
|
||||
private double _percentage = 0.0;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
@@ -65,6 +121,24 @@
|
||||
QuizService.OnQuizUpdated -= HandleUpdate;
|
||||
}
|
||||
|
||||
private async Task GenerateChapterQuizAsync()
|
||||
{
|
||||
if (_isGenerating || string.IsNullOrWhiteSpace(Coordinator.CurrentFullPageContent)) return;
|
||||
|
||||
_isGenerating = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
await Coordinator.RequestSummaryAndQuizAsync(Coordinator.CurrentFullPageContent);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isGenerating = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SelectOptionAsync(QuizQuestionDto question, int index)
|
||||
{
|
||||
if (_states.ContainsKey(question)) return;
|
||||
@@ -90,6 +164,67 @@
|
||||
return QuizService.CurrentQuiz != null && _states.Count == QuizService.CurrentQuiz.Questions.Count;
|
||||
}
|
||||
|
||||
private async Task SubmitQuizAsync()
|
||||
{
|
||||
if (QuizService.CurrentQuiz == null || !AllQuestionsAnswered() || _isSubmitting) return;
|
||||
|
||||
_isSubmitting = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
_score = _states.Values.Count(s => s.IsCorrect);
|
||||
_totalQuestions = QuizService.CurrentQuiz.Questions.Count;
|
||||
_percentage = _totalQuestions > 0 ? ((double)_score / _totalQuestions) * 100 : 0.0;
|
||||
|
||||
string topic = "Quiz wiedzy";
|
||||
var graph = GraphService.CurrentGraphData;
|
||||
if (graph != null && !string.IsNullOrEmpty(QuizService.CurrentQuizBlockId))
|
||||
{
|
||||
var node = graph.Nodes.FirstOrDefault(n => n.Id == QuizService.CurrentQuizBlockId);
|
||||
if (node != null && !string.IsNullOrEmpty(node.Label))
|
||||
{
|
||||
topic = $"Test: {node.Label}";
|
||||
}
|
||||
}
|
||||
|
||||
var profileResult = await IdentityService.GetProfileAsync();
|
||||
if (profileResult.IsSuccess && profileResult.Value != null)
|
||||
{
|
||||
var userId = profileResult.Value.UserId;
|
||||
|
||||
var cmd = new SubmitQuizResultCommand(userId, topic, _score, _totalQuestions);
|
||||
var result = await Mediator.Send(cmd);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
IdentityService.ClearCache();
|
||||
_isSubmitted = true;
|
||||
await PlatformService.VibrateSuccessAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await PlatformService.VibrateErrorAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
await PlatformService.VibrateErrorAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSubmitting = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseQuiz()
|
||||
{
|
||||
_isSubmitted = false;
|
||||
_states.Clear();
|
||||
QuizService.SetQuiz(null, null);
|
||||
}
|
||||
|
||||
private string GetBlockClass(QuizQuestionDto question)
|
||||
{
|
||||
|
||||
@@ -121,3 +121,217 @@
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.option-revealed-correct {
|
||||
border-color: #00ff99 !important;
|
||||
background: rgba(0, 255, 153, 0.08) !important;
|
||||
box-shadow: 0 0 8px rgba(0, 255, 153, 0.15);
|
||||
}
|
||||
|
||||
.option-faded {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.submitted-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.success-icon-wrapper {
|
||||
background: rgba(0, 255, 153, 0.1);
|
||||
border: 1px solid rgba(0, 255, 153, 0.3);
|
||||
border-radius: 50%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 0 20px rgba(0, 255, 153, 0.15);
|
||||
}
|
||||
|
||||
.success-glow {
|
||||
color: var(--nexus-neon, #00ff99);
|
||||
filter: drop-shadow(0 0 8px var(--nexus-neon, #00ff99));
|
||||
}
|
||||
|
||||
.submitted-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.submitted-text {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.score-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem 2.5rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.score-main {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.score-num {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
color: var(--nexus-neon, #00ff99);
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 15px rgba(0, 255, 153, 0.3);
|
||||
}
|
||||
|
||||
.score-divider {
|
||||
font-size: 1.8rem;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.score-total {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.score-percent {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.reset-quiz-btn {
|
||||
padding: 0.8rem 3rem;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 30px;
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.reset-quiz-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: #fff;
|
||||
box-shadow: 0 0 15px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-quiz-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2.5rem 1rem;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.empty-icon-wrapper {
|
||||
background: rgba(0, 255, 153, 0.03);
|
||||
border: 1px solid rgba(0, 255, 153, 0.15);
|
||||
border-radius: 50%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 0 30px rgba(0, 255, 153, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.empty-quiz-state:hover .empty-icon-wrapper {
|
||||
background: rgba(0, 255, 153, 0.08);
|
||||
border-color: rgba(0, 255, 153, 0.4);
|
||||
box-shadow: 0 0 35px rgba(0, 255, 153, 0.15);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.neon-glow {
|
||||
color: var(--nexus-neon, #00ff99);
|
||||
filter: drop-shadow(0 0 6px var(--nexus-neon, #00ff99));
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.5;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.generate-quiz-btn {
|
||||
padding: 0.85rem 2rem;
|
||||
background: rgba(0, 255, 153, 0.08);
|
||||
border: 1px solid var(--nexus-neon, #00ff99);
|
||||
border-radius: 30px;
|
||||
color: var(--nexus-neon, #00ff99);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
letter-spacing: 0.8px;
|
||||
text-shadow: 0 0 10px rgba(0, 255, 153, 0.3);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 153, 0.1);
|
||||
}
|
||||
|
||||
.generate-quiz-btn:not(:disabled):hover {
|
||||
background: var(--nexus-neon, #00ff99);
|
||||
color: #000;
|
||||
box-shadow: 0 0 25px rgba(0, 255, 153, 0.4);
|
||||
transform: translateY(-2px);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.generate-quiz-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: #666;
|
||||
text-shadow: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using NexusReader.Application.Queries.Reader
|
||||
@using NexusReader.Application.Commands.Library
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using System.Net.Http.Json
|
||||
@inject IEpubMetadataExtractor MetadataExtractor
|
||||
@inject ILogger<BookIngestionModal> Logger
|
||||
@inject HttpClient Http
|
||||
@inject IReaderNavigationService ReaderNavigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject ISyncService SyncService
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@if (IsOpen)
|
||||
@@ -16,20 +18,23 @@
|
||||
<div class="modal-content glass-panel" @onclick:stopPropagation>
|
||||
<div class="modal-header">
|
||||
<h2>Add New Book</h2>
|
||||
<button class="close-btn" @onclick="CloseModal">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
@if (!IsIngesting && !IsIndexing)
|
||||
{
|
||||
<button class="close-btn" @onclick="CloseModal">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="parsing-state shimmer" style="@(IsParsing ? "display:flex;" : "display:none;")">
|
||||
<div class="parsing-state shimmer" style="@(IsParsing && !IsIndexing ? "display:flex;" : "display:none;")">
|
||||
<div class="shimmer-content">
|
||||
<div class="spinner"></div>
|
||||
<p>Scanning metadata...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verification-state" style="@(IsVerifying && !IsParsing ? "display:flex;" : "display:none;")">
|
||||
<div class="verification-state" style="@(IsVerifying && !IsParsing && !IsIndexing ? "display:flex;" : "display:none;")">
|
||||
@if (Metadata != null)
|
||||
{
|
||||
<div class="verification-layout">
|
||||
@@ -74,7 +79,7 @@
|
||||
</div>
|
||||
|
||||
<div class="upload-state @(_isDragging ? "drag-over" : "")"
|
||||
style="@(!IsParsing && !IsVerifying ? "display:flex;" : "display:none;")"
|
||||
style="@(!IsParsing && !IsVerifying && !IsIndexing ? "display:flex;" : "display:none;")"
|
||||
@ondragenter="OnDragEnter"
|
||||
@ondragleave="OnDragLeave">
|
||||
<div class="drop-zone">
|
||||
@@ -87,6 +92,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="indexing-state" style="@(IsIndexing ? "display:flex;" : "display:none;")">
|
||||
<div class="indexing-content">
|
||||
<div class="spinner"></div>
|
||||
<h3>Nexus AI Indexing</h3>
|
||||
<p class="status-msg">@IngestionStatusMessage</p>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-fill" style="width: @((IngestionProgressPercent * 100).ToString("F0"))%"></div>
|
||||
</div>
|
||||
<span class="percent">@((IngestionProgressPercent * 100).ToString("F0"))%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
@@ -118,15 +135,61 @@
|
||||
private bool IsParsing { get; set; }
|
||||
private bool IsVerifying { get; set; }
|
||||
private bool IsIngesting { get; set; }
|
||||
private bool IsIndexing { get; set; }
|
||||
private string IngestionStatusMessage { get; set; } = "Initializing...";
|
||||
private double IngestionProgressPercent { get; set; }
|
||||
private Guid IngestedBookId { get; set; } = Guid.Empty;
|
||||
private LocalEpubMetadata? Metadata { get; set; }
|
||||
private string? ErrorMessage { get; set; }
|
||||
private byte[]? _epubBytes;
|
||||
private bool _disposed;
|
||||
|
||||
// Allow up to 50 MB
|
||||
private const long MaxFileSize = 50 * 1024 * 1024;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await SyncService.InitializeAsync();
|
||||
SyncService.OnIngestionProgressReceived += HandleIngestionProgress;
|
||||
}
|
||||
|
||||
private async Task HandleIngestionProgress(string message, double progress)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (!IsIndexing) return;
|
||||
|
||||
IngestionStatusMessage = message;
|
||||
IngestionProgressPercent = progress;
|
||||
|
||||
if (!_disposed)
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
if (progress >= 1.0)
|
||||
{
|
||||
// Give the user a moment to see the completion message
|
||||
await Task.Delay(2500);
|
||||
|
||||
if (_disposed) return;
|
||||
|
||||
// Now close the modal and navigate to the book
|
||||
if (IngestedBookId != Guid.Empty)
|
||||
{
|
||||
var bookId = IngestedBookId;
|
||||
await InvokeAsync(async () => {
|
||||
if (_disposed) return;
|
||||
await CloseModal();
|
||||
ReaderNavigation.NavigateToBook(bookId);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseModal()
|
||||
{
|
||||
if (IsIngesting || IsIndexing) return;
|
||||
|
||||
IsOpen = false;
|
||||
Reset();
|
||||
await IsOpenChanged.InvokeAsync(false);
|
||||
@@ -137,6 +200,10 @@
|
||||
IsParsing = false;
|
||||
IsVerifying = false;
|
||||
IsIngesting = false;
|
||||
IsIndexing = false;
|
||||
IngestionStatusMessage = "Initializing...";
|
||||
IngestionProgressPercent = 0.0;
|
||||
IngestedBookId = Guid.Empty;
|
||||
Metadata = null;
|
||||
ErrorMessage = null;
|
||||
_isDragging = false;
|
||||
@@ -168,10 +235,12 @@
|
||||
using var stream = file.OpenReadStream(MaxFileSize);
|
||||
using var memoryStream = new MemoryStream();
|
||||
await stream.CopyToAsync(memoryStream);
|
||||
if (_disposed) return;
|
||||
_epubBytes = memoryStream.ToArray();
|
||||
|
||||
memoryStream.Position = 0;
|
||||
var result = await MetadataExtractor.ExtractMetadataAsync(memoryStream);
|
||||
if (_disposed) return;
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
@@ -186,12 +255,18 @@
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error uploading EPUB");
|
||||
ErrorMessage = $"An unexpected error occurred: {ex.Message}";
|
||||
if (!_disposed)
|
||||
{
|
||||
ErrorMessage = $"An unexpected error occurred: {ex.Message}";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsParsing = false;
|
||||
StateHasChanged();
|
||||
if (!_disposed)
|
||||
{
|
||||
IsParsing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,39 +289,55 @@
|
||||
);
|
||||
|
||||
var response = await Http.PostAsJsonAsync("api/library/ingest", request);
|
||||
if (_disposed) return;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<IngestResult>();
|
||||
if (_disposed) return;
|
||||
if (result != null)
|
||||
{
|
||||
await CloseModal();
|
||||
ReaderNavigation.NavigateToBook(result.Id);
|
||||
IngestedBookId = result.Id;
|
||||
IsVerifying = false;
|
||||
IsIngesting = false;
|
||||
IsIndexing = true;
|
||||
IngestionStatusMessage = "Book saved! Starting background indexing...";
|
||||
IngestionProgressPercent = 0.0;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = await response.Content.ReadAsStringAsync();
|
||||
IsIngesting = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error during ingestion");
|
||||
ErrorMessage = "Failed to save book to library. Please try again.";
|
||||
if (!_disposed)
|
||||
{
|
||||
ErrorMessage = "Failed to save book to library. Please try again.";
|
||||
IsIngesting = false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsIngesting = false;
|
||||
StateHasChanged();
|
||||
if (!_disposed)
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record IngestResult(Guid Id);
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_disposed = true;
|
||||
SyncService.OnIngestionProgressReceived -= HandleIngestionProgress;
|
||||
// Clear the large byte array so it is eligible for GC even if the component is cached.
|
||||
_epubBytes = null;
|
||||
return ValueTask.CompletedTask;
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +377,72 @@
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Indexing State */
|
||||
.indexing-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: inset 0 0 12px rgba(255, 255, 255, 0.02);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 2rem;
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.indexing-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.indexing-content h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
color: var(--nexus-neon, #00ffaa);
|
||||
text-shadow: 0 0 10px rgba(0, 255, 153, 0.2);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-msg {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--nexus-text-muted, #888);
|
||||
min-height: 2.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--nexus-neon, #00ffaa) 0%, #00b3ff 100%);
|
||||
box-shadow: 0 0 10px rgba(0, 255, 153, 0.4);
|
||||
border-radius: 4px;
|
||||
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.percent {
|
||||
font-family: var(--nexus-font-mono, monospace);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--nexus-text);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
private bool _isJsInitialized;
|
||||
private ElementReference _containerRef;
|
||||
private bool _isInteractive;
|
||||
private string? _currentActiveBlockId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -143,6 +144,7 @@
|
||||
[JSInvokable]
|
||||
public async Task HandleBlockReached(string blockId, string content)
|
||||
{
|
||||
_currentActiveBlockId = blockId;
|
||||
await Coordinator.OnBlockReachedAsync(blockId, content);
|
||||
|
||||
if (ViewModel != null)
|
||||
@@ -160,8 +162,15 @@
|
||||
|
||||
private async Task HandleSyncProgressReceived(string blockId, DateTime timestamp)
|
||||
{
|
||||
if (string.IsNullOrEmpty(blockId) || blockId == _currentActiveBlockId)
|
||||
{
|
||||
Logger.LogDebug("[Sync] Received progress {BlockId} is empty or matches active block. Ignoring scroll.", blockId);
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogInformation("[Sync] Received progress from another device: block {BlockId} at {Timestamp}", blockId, timestamp);
|
||||
|
||||
_currentActiveBlockId = blockId;
|
||||
await ScrollToNodeAsync(blockId);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
@@ -211,6 +220,8 @@
|
||||
|
||||
private async Task LoadChapterAsync(int index)
|
||||
{
|
||||
await Coordinator.ClearAsync();
|
||||
_isJsInitialized = false; // Reset JS initialization to re-bind the scroll observer to new DOM elements!
|
||||
_isLoadingChapter = true;
|
||||
StatusMessage = "Wczytywanie treści...";
|
||||
StateHasChanged();
|
||||
@@ -247,6 +258,18 @@
|
||||
|
||||
_isLoadingChapter = false;
|
||||
StateHasChanged();
|
||||
|
||||
if (result.IsSuccess && !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);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ScrollToNodeAsync(string id)
|
||||
|
||||
Reference in New Issue
Block a user