@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 Logger @implements IDisposable
@if (_isLoading) {
} else { }
@if (!string.IsNullOrEmpty(SearchValue)) { }
@if (_isDropdownOpen && (!string.IsNullOrEmpty(SearchValue) || _isLoading || _results.Any() || _searchError != null)) {
@if (_isLoading) { } else if (_searchError != null) { } else if (_results.Any()) { } else if (!string.IsNullOrEmpty(SearchValue)) { }
}
@code { [Parameter] public string Placeholder { get; set; } = "Zapytaj swoją bibliotekę AI..."; [Parameter] public EventCallback 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 _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(); _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, "$1", RegexOptions.IgnoreCase); } catch { return text; } } public void Dispose() { _searchCts?.Cancel(); _searchCts?.Dispose(); } }