mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-02-19 23:43:59 -05:00
301 lines
12 KiB
Plaintext
301 lines
12 KiB
Plaintext
@inject DbService DbService
|
|
@inject NavigationManager NavigationManager
|
|
@inject KeyboardShortcutService KeyboardShortcutService
|
|
@inject JsInteropService JsInteropService
|
|
@inject IStringLocalizerFactory LocalizerFactory
|
|
@inject LanguageService LanguageService
|
|
@inject ItemService ItemService
|
|
@implements IAsyncDisposable
|
|
@using Microsoft.Extensions.Localization
|
|
@using System.Timers
|
|
@using AliasClientDb
|
|
@using AliasClientDb.Models
|
|
|
|
<div class="relative" id="searchWidgetContainer">
|
|
<input
|
|
id="searchWidget"
|
|
type="text"
|
|
placeholder="@Localizer["SearchVaultPlaceholder"]"
|
|
autocomplete="off"
|
|
class="w-full px-4 py-2 text-gray-700 bg-white border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:focus:ring-primary-500"
|
|
@bind-value="SearchTerm"
|
|
@oninput="SearchTermChanged"
|
|
@onfocus="OnFocusClick"
|
|
@onclick="OnFocusClick"
|
|
@onkeydown="HandleKeyDown"/>
|
|
|
|
@if (ShowHelpText || ShowResults)
|
|
{
|
|
<ClickOutsideHandler OnClose="OnClose" ContentId="searchWidgetContainer">
|
|
@if (ShowHelpText)
|
|
{
|
|
<div class="absolute z-10 w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 p-2 text-sm text-gray-600 dark:text-gray-400">
|
|
@if (string.IsNullOrEmpty(SearchTerm))
|
|
{
|
|
<p>@Localizer["SearchHelpText"]</p>
|
|
}
|
|
else if (SearchTerm.Length == 1)
|
|
{
|
|
<p>@Localizer["SearchTooShortMessage"]</p>
|
|
}
|
|
else
|
|
{
|
|
<p>@string.Format(Localizer["SearchingForMessage"], SearchTerm)</p>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@if (ShowResults && SearchTerm.Length >= 2)
|
|
{
|
|
@if (_isLoading)
|
|
{
|
|
<div class="absolute z-10 w-screen left-0 sm:left-auto sm:w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
|
|
<div class="px-4 py-2 flex items-center">
|
|
<svg class="animate-spin -ml-1 mr-3 h-4 w-4 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
@Localizer["SearchingMessage"]
|
|
</div>
|
|
</div>
|
|
}
|
|
else if (SearchResults.Any())
|
|
{
|
|
<div class="absolute z-10 w-screen left-0 sm:left-auto sm:w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
|
|
@for (int i = 0; i < SearchResults.Count; i++)
|
|
{
|
|
var result = SearchResults[i];
|
|
<div
|
|
class="search-result @(i == SelectedIndex ? "bg-gray-100 dark:bg-gray-700" : "") px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center"
|
|
@onclick="() => SelectResult(result)">
|
|
<DisplayFavicon FaviconBytes="@result.Logo?.FileData" Width="24" />
|
|
<div class="ml-2">
|
|
<div>@result.Name</div>
|
|
@{
|
|
var email = ItemService.GetFieldValue(result, FieldKey.LoginEmail);
|
|
var username = ItemService.GetFieldValue(result, FieldKey.LoginUsername);
|
|
}
|
|
@if (!string.IsNullOrEmpty(email))
|
|
{
|
|
<span class="text-gray-500">(@email)</span>
|
|
}
|
|
else if (!string.IsNullOrEmpty(username))
|
|
{
|
|
<span class="text-gray-500">(@username)</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="absolute z-10 w-screen left-0 sm:left-auto sm:w-full mt-1 bg-white rounded-md shadow-lg dark:bg-gray-800 text-sm text-gray-600 dark:text-gray-400">
|
|
<div class="px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
@Localizer["NoResultsFoundMessage"]
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
</ClickOutsideHandler>
|
|
}
|
|
</div>
|
|
|
|
@code {
|
|
private IStringLocalizer? _localizer;
|
|
private IStringLocalizer Localizer => _localizer ??= LocalizerFactory.Create("Components.Main.Widgets.SearchWidget", "AliasVault.Client");
|
|
|
|
private string SearchTerm { get; set; } = string.Empty;
|
|
private List<Item> SearchResults { get; set; } = new();
|
|
private bool ShowResults { get; set; }
|
|
private bool ShowHelpText { get; set; }
|
|
private int SelectedIndex { get; set; } = -1;
|
|
private Timer? _searchTimer;
|
|
private bool _isSearching = false;
|
|
private bool _isLoading = false;
|
|
|
|
/// <inheritdoc />
|
|
async ValueTask IAsyncDisposable.DisposeAsync()
|
|
{
|
|
await KeyboardShortcutService.UnregisterShortcutAsync("gs");
|
|
await KeyboardShortcutService.UnregisterShortcutAsync("gf");
|
|
NavigationManager.LocationChanged -= ResetSearchField;
|
|
LanguageService.LanguageChanged -= OnLanguageChanged;
|
|
_searchTimer?.Dispose();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
await base.OnAfterRenderAsync(firstRender);
|
|
|
|
if (firstRender)
|
|
{
|
|
await KeyboardShortcutService.RegisterShortcutAsync("gs", FocusSearchField);
|
|
await KeyboardShortcutService.RegisterShortcutAsync("gf", FocusSearchField);
|
|
NavigationManager.LocationChanged += ResetSearchField;
|
|
LanguageService.LanguageChanged += OnLanguageChanged;
|
|
|
|
// Initialize search timer debounce.
|
|
_searchTimer = new Timer(100);
|
|
_searchTimer.Elapsed += async (sender, e) => await PerformSearchAsync();
|
|
_searchTimer.AutoReset = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles language change events and triggers component refresh.
|
|
/// </summary>
|
|
/// <param name="languageCode">The new language code.</param>
|
|
private void OnLanguageChanged(string languageCode)
|
|
{
|
|
// Reset the localizer to force it to use the new language
|
|
_localizer = null;
|
|
InvokeAsync(StateHasChanged);
|
|
}
|
|
|
|
private void OnFocusClick()
|
|
{
|
|
// Ensure popup stays open when clicking inside the input field
|
|
ShowHelpText = true;
|
|
ShowResults = true;
|
|
StateHasChanged();
|
|
}
|
|
|
|
private void OnClose()
|
|
{
|
|
ShowHelpText = false;
|
|
ShowResults = false;
|
|
StateHasChanged();
|
|
}
|
|
|
|
private void SearchTermChanged(ChangeEventArgs e)
|
|
{
|
|
SearchTerm = e.Value?.ToString() ?? string.Empty;
|
|
|
|
// Reset timer for debounced search
|
|
_searchTimer?.Stop();
|
|
_searchTimer?.Start();
|
|
|
|
// Update UI immediately for short search terms
|
|
if (SearchTerm.Length < 2)
|
|
{
|
|
SearchResults.Clear();
|
|
SelectedIndex = -1;
|
|
_isLoading = false;
|
|
StateHasChanged();
|
|
}
|
|
else
|
|
{
|
|
// For longer search terms, set loading state but don't clear results yet
|
|
// This prevents the "no results found" message from showing briefly
|
|
_isLoading = true;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private async Task PerformSearchAsync()
|
|
{
|
|
if (_isSearching) return;
|
|
|
|
_isSearching = true;
|
|
try
|
|
{
|
|
var context = await DbService.GetDbContextAsync();
|
|
if (SearchTerm.Length >= 2)
|
|
{
|
|
var searchTerms = SearchTerm.Trim().ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
var query = context.Items
|
|
.Include(x => x.Logo)
|
|
.Include(x => x.FieldValues.Where(fv => !fv.IsDeleted))
|
|
.Where(x => !x.IsDeleted)
|
|
.Where(x => x.DeletedAt == null)
|
|
.AsQueryable();
|
|
|
|
foreach (var term in searchTerms)
|
|
{
|
|
// We filter items by searching in the following fields:
|
|
// - Item name
|
|
// - Username (from field values)
|
|
// - Email (from field values)
|
|
// - URL (from field values)
|
|
// - Notes (from field values)
|
|
query = query.Where(x =>
|
|
(x.Name != null && EF.Functions.Like(x.Name.ToLower(), $"%{term}%")) ||
|
|
x.FieldValues.Any(fv =>
|
|
!fv.IsDeleted &&
|
|
fv.Value != null &&
|
|
(fv.FieldKey == FieldKey.LoginEmail ||
|
|
fv.FieldKey == FieldKey.LoginUsername ||
|
|
fv.FieldKey == FieldKey.LoginUrl ||
|
|
fv.FieldKey == FieldKey.NotesContent) &&
|
|
EF.Functions.Like(fv.Value.ToLower(), $"%{term}%"))
|
|
);
|
|
}
|
|
|
|
SearchResults = await query.Take(10).ToListAsync();
|
|
|
|
// Select first entry by default so when pressing enter, the first result is immediately selected.
|
|
SelectedIndex = SearchResults.Count > 0 ? 0 : -1;
|
|
}
|
|
else
|
|
{
|
|
SearchResults.Clear();
|
|
SelectedIndex = -1;
|
|
}
|
|
|
|
_isLoading = false;
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
finally
|
|
{
|
|
_isSearching = false;
|
|
}
|
|
}
|
|
|
|
private async Task HandleKeyDown(KeyboardEventArgs e)
|
|
{
|
|
switch (e.Key)
|
|
{
|
|
case "ArrowDown":
|
|
SelectedIndex = Math.Min(SelectedIndex + 1, SearchResults.Count - 1);
|
|
StateHasChanged();
|
|
break;
|
|
case "ArrowUp":
|
|
SelectedIndex = Math.Max(SelectedIndex - 1, -1);
|
|
StateHasChanged();
|
|
break;
|
|
case "Enter":
|
|
if (SelectedIndex >= 0 && SelectedIndex < SearchResults.Count)
|
|
{
|
|
await SelectResult(SearchResults[SelectedIndex]);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
private async Task SelectResult(Item item)
|
|
{
|
|
await JsInteropService.BlurElementById("searchWidget");
|
|
NavigationManager.NavigateTo($"/items/{item.Id}");
|
|
}
|
|
|
|
private void ResetSearchField(object? sender, LocationChangedEventArgs e)
|
|
{
|
|
SearchTerm = string.Empty;
|
|
SearchResults.Clear();
|
|
SelectedIndex = -1;
|
|
ShowHelpText = false;
|
|
ShowResults = false;
|
|
_isLoading = false;
|
|
_searchTimer?.Stop();
|
|
StateHasChanged();
|
|
}
|
|
|
|
private async Task FocusSearchField()
|
|
{
|
|
await JsInteropService.FocusElementById("searchWidget");
|
|
}
|
|
}
|