Compare commits
2
Commits
451c131711
...
985178b741
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
985178b741 | ||
|
|
677a3816d2 |
@@ -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;
|
||||
}
|
||||
@@ -6,24 +6,43 @@
|
||||
@inject AssetService assetService
|
||||
@inject UserService userService
|
||||
@inject AlbumService albumService
|
||||
@inject PersonService personService
|
||||
@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,36 +180,44 @@
|
||||
} 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;
|
||||
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();
|
||||
personQuery = "";
|
||||
await ReloadFromStart();
|
||||
}
|
||||
|
||||
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 HandleAlbumSelected(AlbumPreviewDto album) {
|
||||
selectedAlbumId = album.Id;
|
||||
await ReloadFromStart();
|
||||
}
|
||||
|
||||
async Task TogglePersonFilter(PersonPreviewDto person) {
|
||||
bool isSelected = selectedPersonId == person.Id;
|
||||
selectedPersonId = isSelected ? null : person.Id;
|
||||
personQuery = isSelected ? "" : person.Name;
|
||||
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;
|
||||
await Load();
|
||||
@@ -234,8 +231,6 @@
|
||||
personQuery = "";
|
||||
selectedAlbumId = null;
|
||||
selectedPersonId = null;
|
||||
albumMatches = null;
|
||||
personMatches = null;
|
||||
statusMessage = null;
|
||||
currentPage = 0;
|
||||
await Load();
|
||||
|
||||
Reference in New Issue
Block a user