2 Commits
3 changed files with 210 additions and 75 deletions
@@ -0,0 +1,135 @@
@using Butter.Dtos.Album
@inject AlbumService albumService
@inject IJSRuntime JSRuntime
<div class="position-relative">
<input type="text" class="form-control"
placeholder="@Placeholder"
value="@Query"
@ref="albumInputRef"
@oninput="OnInput"
@onfocus="OnFocus"
@onblur="OnBlur"
@onkeydown="OnKeyDown" />
@if (showAlbumDropdown) {
<div class="dropdown-menu show album-dropdown-menu"
@ref="albumDropdownRef">
@if (isSearching) {
<span class="dropdown-item disabled">Searching…</span>
} else if (Query.Length < 2) {
<span class="dropdown-item disabled">Type at least 2 characters to search</span>
} else if (searchResults is null || searchResults.Count == 0) {
<span class="dropdown-item disabled">No matching albums</span>
} else {
@foreach (var album in searchResults) {
<button type="button"
class="dropdown-item @(SelectedAlbumId == album.Id ? "active" : "")"
@onclick="() => SelectAlbum(album)">
@album.Name
@if (!string.IsNullOrEmpty(album.PersonName)) {
<small class="text-muted ms-1">@album.PersonName</small>
}
</button>
}
}
</div>
}
</div>
@code {
/// <summary>
/// Gets or sets the current search text (two-way bound so the owner can prefill/reset it).
/// </summary>
[Parameter] public string Query { get; set; } = "";
/// <summary>
/// Gets or sets the callback fired as <see cref="Query"/> changes.
/// </summary>
[Parameter] public EventCallback<string> QueryChanged { get; set; }
/// <summary>
/// Gets or sets the currently selected album ID, used to highlight the active result.
/// </summary>
[Parameter] public Guid? SelectedAlbumId { get; set; }
/// <summary>
/// Gets or sets the callback fired when an album is selected.
/// </summary>
[Parameter] public EventCallback<AlbumPreviewDto> OnAlbumSelected { get; set; }
/// <summary>
/// Gets or sets the input placeholder text.
/// </summary>
[Parameter] public string Placeholder { get; set; } = "Search albums…";
bool showAlbumDropdown;
CancellationTokenSource? blurCts;
List<AlbumPreviewDto>? searchResults;
bool isSearching;
int searchVersion;
ElementReference albumInputRef;
ElementReference albumDropdownRef;
bool pendingPosition;
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (pendingPosition && showAlbumDropdown) {
pendingPosition = false;
await JSRuntime.InvokeVoidAsync("masonryObserver.positionDropdown", albumInputRef, albumDropdownRef);
}
}
void OnFocus() {
blurCts?.Cancel();
showAlbumDropdown = true;
pendingPosition = true;
if (Query.Length >= 2) {
_ = Search(Query);
}
}
async Task OnInput(ChangeEventArgs e) {
Query = e.Value?.ToString() ?? string.Empty;
await QueryChanged.InvokeAsync(Query);
showAlbumDropdown = true;
pendingPosition = true;
if (Query.Length >= 2) {
await Search(Query);
} else {
searchResults = null;
isSearching = false;
}
}
async Task Search(string query) {
var version = ++searchVersion;
isSearching = true;
searchResults = await albumService.GetAlbumsAsync(page: 0, pageSize: 20, search: query);
if (version == searchVersion) {
isSearching = false;
}
StateHasChanged();
}
async Task OnBlur() {
blurCts?.Cancel();
blurCts = new CancellationTokenSource();
try {
await Task.Delay(150, blurCts.Token);
showAlbumDropdown = false;
} catch (OperationCanceledException) { }
}
void OnKeyDown(KeyboardEventArgs e) {
if (e.Key is "Escape") {
showAlbumDropdown = false;
}
}
void SelectAlbum(AlbumPreviewDto album) {
Query = album.Name;
_ = QueryChanged.InvokeAsync(Query);
showAlbumDropdown = false;
searchResults = null;
_ = OnAlbumSelected.InvokeAsync(album);
}
}
@@ -0,0 +1,5 @@
.album-dropdown-menu {
display: block;
max-height: 250px;
overflow-y: auto;
}
@@ -3,27 +3,46 @@
@using Butter.Dtos.Person
@using Butter.Dtos.User
@inject AssetService assetService
@inject UserService userService
@inject AlbumService albumService
@inject PersonService personService
@inject MediaService mediaService
@inject AssetService assetService
@inject UserService userService
@inject AlbumService albumService
@inject MediaService mediaService
<div class="d-flex flex-column gap-2 mb-3">
<div class="d-flex flex-wrap gap-2 align-items-end">
<div>
<label class="form-label mb-0 small text-muted">Album</label>
<input type="text" class="form-control form-control-sm" placeholder="Search albums..." maxlength="80"
value="@albumQuery" @oninput="OnAlbumSearchInput" />
<label class="form-label mb-0 small text-muted">Cosplayer</label>
<div class="d-flex gap-1 align-items-center">
<PersonSearchDropdown @bind-Query="personQuery"
SelectedPersonId="selectedPersonId"
OnPersonSelected="HandlePersonSelected"
Placeholder="Filter by cosplayer..." />
@if (selectedPersonId != null) {
<button type="button" class="btn btn-sm btn-outline-secondary" title="Clear cosplayer filter"
@onclick="ClearPersonFilter">
<i class="bi bi-x"></i>
</button>
}
</div>
</div>
<div>
<label class="form-label mb-0 small text-muted">Cosplayer</label>
<input type="text" class="form-control form-control-sm" placeholder="Search cosplayers..." maxlength="80"
value="@personQuery" @oninput="OnPersonSearchInput" />
<label class="form-label mb-0 small text-muted">Album</label>
<div class="d-flex gap-1 align-items-center">
<AlbumSearchDropdown @bind-Query="albumQuery"
SelectedAlbumId="selectedAlbumId"
OnAlbumSelected="HandleAlbumSelected"
Placeholder="Filter by album..." />
@if (selectedAlbumId != null) {
<button type="button" class="btn btn-sm btn-outline-secondary" title="Clear album filter"
@onclick="ClearAlbumFilter">
<i class="bi bi-x"></i>
</button>
}
</div>
</div>
<div>
<label class="form-label mb-0 small text-muted">Uploader</label>
<select class="form-select form-select-sm" value="@uploaderFilter" @onchange="(e) => uploaderFilter = e.Value?.ToString() ?? string.Empty">
<select class="form-select form-select-sm" value="@uploaderFilter" @onchange="HandleUploaderChanged">
<option value="">All uploaders</option>
@foreach (var user in users) {
<option value="@user.Id">@user.Username</option>
@@ -32,15 +51,12 @@
</div>
<div>
<label class="form-label mb-0 small text-muted">From</label>
<input type="date" class="form-control form-control-sm" value="@startDateFilter" @onchange="(e) => startDateFilter = e.Value?.ToString()" />
<input type="date" class="form-control form-control-sm" value="@startDateFilter" @onchange="HandleStartDateChanged" />
</div>
<div>
<label class="form-label mb-0 small text-muted">To</label>
<input type="date" class="form-control form-control-sm" value="@endDateFilter" @onchange="(e) => endDateFilter = e.Value?.ToString()" />
<input type="date" class="form-control form-control-sm" value="@endDateFilter" @onchange="HandleEndDateChanged" />
</div>
<button class="btn btn-sm btn-secondary" @onclick="ApplyFilters">
<i class="bi bi-funnel"></i> Apply
</button>
@if (HasActiveFilters) {
<button class="btn btn-sm btn-outline-secondary" @onclick="ClearFilters">
<i class="bi bi-x-circle"></i> Clear
@@ -53,31 +69,6 @@
</button>
</div>
@if (albumMatches is { Count: > 0 }) {
<div class="d-flex flex-wrap gap-1 align-items-center">
<span class="text-muted small me-1"><i class="bi bi-folder"></i></span>
@foreach (var album in albumMatches.Take(8)) {
<button type="button" class="btn btn-sm @(selectedAlbumId == album.Id ? "btn-primary" : "btn-outline-primary")"
title="@(selectedAlbumId == album.Id ? "Click to clear album filter" : $"Filter by album '{album.Name}'")"
@onclick="() => ToggleAlbumFilter(album)">
@(selectedAlbumId == album.Id ? $"{album.Name} ×" : album.Name)
</button>
}
</div>
}
@if (personMatches is { Count: > 0 }) {
<div class="d-flex flex-wrap gap-1 align-items-center">
<span class="text-muted small me-1"><i class="bi bi-person"></i></span>
@foreach (var person in personMatches.Take(8)) {
<button type="button" class="btn btn-sm @(selectedPersonId == person.Id ? "btn-primary" : "btn-outline-primary")"
title="@(selectedPersonId == person.Id ? "Click to clear cosplayer filter" : $"Filter by cosplayer '{person.Name}'")"
@onclick="() => TogglePersonFilter(person)">
@(selectedPersonId == person.Id ? $"{person.Name} ×" : person.Name)
</button>
}
</div>
}
@if (!string.IsNullOrEmpty(statusMessage)) {
<div class="alert alert-info py-1 px-2 mb-0 small" role="alert">@statusMessage</div>
}
@@ -156,8 +147,6 @@
string albumQuery = "", personQuery = "";
Guid? selectedAlbumId, selectedPersonId;
List<AlbumPreviewDto>? albumMatches;
List<PersonPreviewDto>? personMatches;
const int PageSize = 50;
@@ -191,38 +180,46 @@
} finally { isLoading = false; }
}
void OnAlbumSearchInput(ChangeEventArgs e) => albumQuery = e.Value?.ToString() ?? "";
void OnPersonSearchInput(ChangeEventArgs e) => personQuery = e.Value?.ToString() ?? "";
async Task HandlePersonSelected(PersonPreviewDto person) {
selectedPersonId = person.Id;
await ReloadFromStart();
}
async Task ApplyFilters() {
selectedAlbumId = null;
async Task ClearPersonFilter() {
selectedPersonId = null;
albumMatches = albumQuery.Length >= 2 ? await albumService.GetAlbumsAsync(pageSize: 10, search: albumQuery) : null;
personMatches = personQuery.Length >= 2 ? await personService.GetAllAsync(pageSize: 10, search: personQuery) : null;
currentPage = 0;
personQuery = "";
await ReloadFromStart();
}
async Task HandleAlbumSelected(AlbumPreviewDto album) {
selectedAlbumId = album.Id;
await ReloadFromStart();
}
async Task ClearAlbumFilter() {
selectedAlbumId = null;
albumQuery = "";
await ReloadFromStart();
}
async Task HandleUploaderChanged(ChangeEventArgs e) {
uploaderFilter = e.Value?.ToString() ?? "";
await ReloadFromStart();
}
async Task HandleStartDateChanged(ChangeEventArgs e) {
startDateFilter = string.IsNullOrEmpty(e.Value?.ToString()) ? null : e.Value?.ToString();
await ReloadFromStart();
}
async Task HandleEndDateChanged(ChangeEventArgs e) {
endDateFilter = string.IsNullOrEmpty(e.Value?.ToString()) ? null : e.Value?.ToString();
await ReloadFromStart();
}
async Task ReloadFromStart() {
currentPage = 0;
statusMessage = null;
if ((albumQuery.Length >= 2 || personQuery.Length >= 2)
&& (albumMatches is not { Count: > 0 }) && (personMatches is not { Count: > 0 })) {
statusMessage = "No matching albums or cosplayers found — showing unfiltered results.";
}
await Load();
}
async Task ToggleAlbumFilter(AlbumPreviewDto album) {
bool isSelected = selectedAlbumId == album.Id;
selectedAlbumId = isSelected ? null : album.Id;
albumQuery = isSelected ? "" : album.Name;
currentPage = 0;
statusMessage = null;
await Load();
}
async Task TogglePersonFilter(PersonPreviewDto person) {
bool isSelected = selectedPersonId == person.Id;
selectedPersonId = isSelected ? null : person.Id;
personQuery = isSelected ? "" : person.Name;
currentPage = 0;
statusMessage = null;
await Load();
}
@@ -234,8 +231,6 @@
personQuery = "";
selectedAlbumId = null;
selectedPersonId = null;
albumMatches = null;
personMatches = null;
statusMessage = null;
currentPage = 0;
await Load();