Files
MilkyShots/MilkStream.Client/Components/Shared/SearchDropdown.razor
T

305 lines
9.4 KiB
Plaintext

@using Butter.Dtos.Album
@using Butter.Dtos.Person
@using MilkStream.Client.Services
@namespace MilkStream.Client.Components.Shared
@inject PersonService PersonSvc
@inject AlbumService AlbumSvc
@inject NavigationManager Nav
@inject MediaService mediaService
<div class="search-dropdown-container position-relative">
<div class="d-flex">
<input class="form-control form-control-sm"
style="border-top-right-radius: 0; border-bottom-right-radius: 0"
type="search" placeholder="Search anything..." aria-label="search"
value="@_query"
@oninput="OnInput"
@onfocus="OnFocus"
@onblur="OnBlur"
@onkeydown="OnKeyDown" />
<button class="btn btn-sm btn-success"
style="border-top-left-radius: 0; border-bottom-left-radius: 0"
type="button" @onclick="OnSearchClick" title="Search">
<i class="bi bi-search-heart"></i>
</button>
</div>
@if (_showDropdown)
{
<div class="dropdown-menu search-dropdown-menu">
@if (_isSearching)
{
<div class="dropdown-item disabled">
<span class="text-muted small">Searching...</span>
</div>
}
else if (_items.Count == 0)
{
<div class="dropdown-item disabled">
<span class="text-muted small">No results found</span>
</div>
}
else
{
var index = 0;
@foreach (var item in _items)
{
var idx = index;
var active = idx == _selectedIndex ? "active" : "";
@if (item.Kind is SearchItemKind.HeaderPerson or SearchItemKind.HeaderAlbum)
{
<a class="dropdown-item search-dropdown-header @active"
href="@item.Url"
@onmouseenter="() => OnItemHover(idx)">
<span>@item.Label</span>
<i class="bi bi-chevron-right"></i>
</a>
}
else if (item.Kind == SearchItemKind.Person)
{
<a class="dropdown-item search-dropdown-item-flex @active"
href="@item.Url"
@onmouseenter="() => OnItemHover(idx)">
@if (item.ImageId.HasValue)
{
<img class="search-dropdown-thumb" src="@mediaService.ThumbUrl(item.ImageId.Value)" alt="" loading="lazy" />
}
else
{
<div class="search-dropdown-fallback">@item.Label[..1].ToUpper()</div>
}
<div>
<div class="search-dropdown-item-title">@item.Label</div>
</div>
</a>
}
else
{
<a class="dropdown-item search-dropdown-item-flex @active"
href="@item.Url"
@onmouseenter="() => OnItemHover(idx)">
@if (item.ImageId.HasValue)
{
<img class="search-dropdown-thumb-rounded" src="@mediaService.ThumbUrl(item.ImageId.Value)" alt="" loading="lazy" />
}
else
{
<div class="search-dropdown-fallback-rounded"><i class="bi bi-collection"></i></div>
}
<div>
<div class="search-dropdown-item-title">@item.Label</div>
@if (!string.IsNullOrEmpty(item.Subtitle))
{
<small class="text-muted">@item.Subtitle</small>
}
</div>
</a>
}
index++;
}
}
</div>
}
</div>
@code {
string _query = string.Empty;
List<SearchItem> _items = [];
bool _isSearching;
bool _showDropdown;
int _searchVersion;
int _selectedIndex = -1;
CancellationTokenSource? _blurCts;
async Task OnInput(ChangeEventArgs e)
{
_query = e.Value?.ToString() ?? string.Empty;
if (_query.Length < 2)
{
_items = [];
_showDropdown = false;
_selectedIndex = -1;
return;
}
var version = ++_searchVersion;
_isSearching = true;
_selectedIndex = -1;
var personTask = PersonSvc.GetAllAsync(page: 0, pageSize: 5, search: _query);
var albumTask = AlbumSvc.GetAlbumsAsync(page: 0, pageSize: 5, search: _query);
await Task.WhenAll(personTask, albumTask);
if (version != _searchVersion) return;
BuildItems(personTask.Result ?? [], albumTask.Result ?? []);
_isSearching = false;
_showDropdown = true;
}
void BuildItems(List<PersonPreviewDto> persons, List<AlbumPreviewDto> albums)
{
_items = [];
if (persons.Count > 0)
{
_items.Add(new SearchItem
{
Kind = SearchItemKind.HeaderPerson,
Label = "Cosplayers",
Url = $"/cosplayers?search={Uri.EscapeDataString(_query)}"
});
foreach (var p in persons)
{
_items.Add(new SearchItem
{
Kind = SearchItemKind.Person,
Label = p.Name,
Url = $"/cosplayer/{p.Id}",
ImageId = p.ProfileAssetId
});
}
}
if (albums.Count > 0)
{
_items.Add(new SearchItem
{
Kind = SearchItemKind.HeaderAlbum,
Label = "Albums",
Url = $"/albums?search={Uri.EscapeDataString(_query)}"
});
foreach (var a in albums)
{
_items.Add(new SearchItem
{
Kind = SearchItemKind.Album,
Label = a.Name,
Url = $"/albums/{a.Id}",
ImageId = a.CoverAssetId,
Subtitle = a.PersonName
});
}
}
}
async Task OnSearchClick()
{
if (_items.Count == 0 || _selectedIndex < 0) return;
Nav.NavigateTo(_items[_selectedIndex].Url);
}
void OnFocus()
{
_blurCts?.Cancel();
if (_query.Length >= 2 && _items.Count > 0)
_showDropdown = true;
}
async Task OnBlur()
{
_blurCts?.Cancel();
_blurCts = new CancellationTokenSource();
var token = _blurCts.Token;
try
{
await Task.Delay(150, token);
_showDropdown = false;
}
catch (OperationCanceledException)
{
}
}
void OnKeyDown(KeyboardEventArgs e)
{
if (_items.Count == 0) return;
switch (e.Key)
{
case "ArrowDown":
_selectedIndex = _selectedIndex >= _items.Count - 1 ? 0 : _selectedIndex + 1;
break;
case "ArrowUp":
_selectedIndex = _selectedIndex <= 0 ? _items.Count - 1 : _selectedIndex - 1;
break;
case "Enter":
if (_selectedIndex < 0) return;
Nav.NavigateTo(_items[_selectedIndex].Url);
break;
case "Escape":
_showDropdown = false;
_selectedIndex = -1;
break;
}
}
void OnItemHover(int index)
{
_selectedIndex = index;
}
/// <summary>
/// Represents a single item in the search dropdown menu.
/// </summary>
sealed class SearchItem
{
/// <summary>
/// Gets or sets the type of search item.
/// </summary>
public SearchItemKind Kind { get; init; }
/// <summary>
/// Gets or sets the display label.
/// </summary>
public string Label { get; init; } = string.Empty;
/// <summary>
/// Gets or sets the navigation URL.
/// </summary>
public string Url { get; init; } = string.Empty;
/// <summary>
/// Gets or sets the optional thumbnail image asset ID.
/// </summary>
public Guid? ImageId { get; init; }
/// <summary>
/// Gets or sets an optional subtitle (e.g. person name for albums).
/// </summary>
public string? Subtitle { get; init; }
}
/// <summary>
/// Categorizes a search dropdown item.
/// </summary>
enum SearchItemKind
{
/// <summary>
/// "Cosplayers" section header.
/// </summary>
HeaderPerson,
/// <summary>
/// "Albums" section header.
/// </summary>
HeaderAlbum,
/// <summary>
/// A cosplayer result.
/// </summary>
Person,
/// <summary>
/// An album result.
/// </summary>
Album
}
}