Files
aliasvault/apps/server/AliasVault.Client/Main/Components/Widgets/SearchWidget.razor
2025-04-30 19:03:18 +02:00

196 lines
7.1 KiB
Plaintext

@inject DbService DbService
@inject NavigationManager NavigationManager
@inject KeyboardShortcutService KeyboardShortcutService
@inject JsInteropService JsInteropService
@implements IAsyncDisposable
<ClickOutsideHandler OnClose="OnClose" ContentId="searchWidgetContainer">
<div class="relative" id="searchWidgetContainer">
<input
id="searchWidget"
type="text"
placeholder="Search vault..."
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="OnFocus"
@onkeydown="HandleKeyDown"/>
@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>Type a term to search for, this can be the service name, description or email address.</p>
}
else if (SearchTerm.Length == 1)
{
<p>Please type more chars</p>
}
else
{
<p>Searching for "@SearchTerm"</p>
}
</div>
}
@if (ShowResults && SearchTerm.Length >= 2)
{
@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.Service.Logo" Width="24" />
<div class="ml-2">
<div>@result.Service.Name</div>
@if (!string.IsNullOrEmpty(result.Alias.Email))
{
<span class="text-gray-500">(@result.Alias.Email)</span>
}
else if (!string.IsNullOrEmpty(result.Username))
{
<span class="text-gray-500">(@result.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">
No results found
</div>
</div>
}
}
</div>
</ClickOutsideHandler>
@code {
private string SearchTerm { get; set; } = string.Empty;
private List<Credential> SearchResults { get; set; } = new();
private bool ShowResults { get; set; }
private bool ShowHelpText { get; set; }
private int SelectedIndex { get; set; } = -1;
/// <inheritdoc />
async ValueTask IAsyncDisposable.DisposeAsync()
{
await KeyboardShortcutService.UnregisterShortcutAsync("gs");
await KeyboardShortcutService.UnregisterShortcutAsync("gc");
NavigationManager.LocationChanged -= ResetSearchField;
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await KeyboardShortcutService.RegisterShortcutAsync("gs", FocusSearchField);
await KeyboardShortcutService.RegisterShortcutAsync("gf", FocusSearchField);
NavigationManager.LocationChanged += ResetSearchField;
}
}
private void OnFocus()
{
ShowHelpText = true;
ShowResults = true;
}
private void OnClose()
{
ShowHelpText = false;
ShowResults = false;
}
private async Task SearchTermChanged(ChangeEventArgs e)
{
SearchTerm = e.Value?.ToString() ?? string.Empty;
await PerformSearch();
}
private async Task PerformSearch()
{
var context = await DbService.GetDbContextAsync();
if (SearchTerm.Length >= 2)
{
var searchTerms = SearchTerm.Trim().ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries);
var query = context.Credentials
.Include(x => x.Service)
.Include(x => x.Alias)
.Where(x => !x.IsDeleted)
.AsQueryable();
foreach (var term in searchTerms)
{
query = query.Where(x =>
(x.Service.Name != null && EF.Functions.Like(x.Service.Name.ToLower(), $"%{term}%")) ||
(x.Alias.Email != null && EF.Functions.Like(x.Alias.Email.ToLower(), $"%{term}%")) ||
(x.Username != null && EF.Functions.Like(x.Username.ToLower(), $"%{term}%")) ||
(x.Service.Url != null && EF.Functions.Like(x.Service.Url.ToLower(), $"%{term}%"))
);
}
SearchResults = await query.Take(10).ToListAsync();
// Select first entry by default so when pressing enter, the first result is immediately selected.
SelectedIndex = 0;
}
else
{
SearchResults.Clear();
}
StateHasChanged();
}
private async Task HandleKeyDown(KeyboardEventArgs e)
{
switch (e.Key)
{
case "ArrowDown":
SelectedIndex = Math.Min(SelectedIndex + 1, SearchResults.Count - 1);
break;
case "ArrowUp":
SelectedIndex = Math.Max(SelectedIndex - 1, -1);
break;
case "Enter":
if (SelectedIndex >= 0 && SelectedIndex < SearchResults.Count)
{
await SelectResult(SearchResults[SelectedIndex]);
}
break;
}
}
private async Task SelectResult(Credential credential)
{
await JsInteropService.BlurElementById("searchWidget");
NavigationManager.NavigateTo($"/credentials/{credential.Id}");
}
private void ResetSearchField(object? sender, LocationChangedEventArgs e)
{
SearchTerm = string.Empty;
SearchResults.Clear();
StateHasChanged();
OnClose();
}
private async Task FocusSearchField()
{
await JsInteropService.FocusElementById("searchWidget");
}
}