Files
Nexus.Reader/src/NexusReader.UI.Shared/Components/Atoms/NexusSearchBox.razor
T

335 lines
11 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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 @(IsFocused ? "focused" : "") @(HasResults ? "has-results" : "")" @onfocusin="HandleFocusIn" @onfocusout="HandleFocusOut">
<div class="search-wrapper">
<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 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; } = "Zapytaj swoją bibliotekę AI...";
[Parameter] public EventCallback<string> OnSearch { get; set; }
[Parameter] public int Limit { get; set; } = 5;
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)
{
SearchValue = e.Value?.ToString() ?? string.Empty;
_searchError = null;
if (string.IsNullOrWhiteSpace(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();
}
}