305 lines
9.4 KiB
Plaintext
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
|
|
}
|
|
}
|