feat(ui): implement premium NexusSearchBox component and integrate semantic search navigation

This commit is contained in:
2026-05-21 20:16:14 +02:00
parent 37bec89484
commit 0a3ca77d46
9 changed files with 632 additions and 116 deletions
@@ -1,39 +1,334 @@
@namespace NexusReader.UI.Shared.Components.Atoms
@using System.Text.RegularExpressions
@using NexusReader.Application.DTOs.AI
@inject IKnowledgeService KnowledgeService
@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 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;
await InvokeAsync(StateHasChanged);
try
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
var tenantId = authState.User.FindFirst("TenantId")?.Value ?? "global";
var result = await KnowledgeService.SearchLibrarySemanticallyAsync(SearchValue, tenantId, Limit, token);
if (token.IsCancellationRequested) 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)
{
_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)
{
_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);
_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) || string.IsNullOrWhiteSpace(query))
return text;
var words = query.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
.Where(w => w.Length > 2)
.Select(Regex.Escape);
if (!words.Any())
return text;
var pattern = "(" + string.Join("|", words) + ")";
try
{
return Regex.Replace(text, pattern, "<mark class=\"search-highlight\">$1</mark>", RegexOptions.IgnoreCase);
}
catch
{
return text;
}
}
public void Dispose()
{
_searchCts?.Cancel();
_searchCts?.Dispose();
}
}