From 48ed84b7bd2a7706bce9c939e1550a16c561917c Mon Sep 17 00:00:00 2001 From: REDCODE Date: Sat, 11 Jul 2026 17:20:34 +0200 Subject: [PATCH 01/50] feat: add SearchDropdown typeahead component New SearchDropdown component that provides a live typeahead dropdown in the navbar searching both cosplayers (persons) and albums. - Fires API calls on every keystroke (no debounce) to existing /api/person and /api/album - Version counter discards stale responses - Minimum 2 characters before searching - Keyboard navigation (arrows, enter, escape) - Blur delay for click-to-navigate - Thumbnail support with fallback avatars/icons - Loading and empty states --- .../Components/Shared/SearchDropdown.razor | 221 ++++++++++++++++++ .../Shared/SearchDropdown.razor.css | 82 +++++++ 2 files changed, 303 insertions(+) create mode 100644 MilkStream.Client/Components/Shared/SearchDropdown.razor create mode 100644 MilkStream.Client/Components/Shared/SearchDropdown.razor.css diff --git a/MilkStream.Client/Components/Shared/SearchDropdown.razor b/MilkStream.Client/Components/Shared/SearchDropdown.razor new file mode 100644 index 0000000..28b8594 --- /dev/null +++ b/MilkStream.Client/Components/Shared/SearchDropdown.razor @@ -0,0 +1,221 @@ +@using Butter.Dtos.Album +@using Butter.Dtos.Person +@using Microsoft.Extensions.Options +@using MilkStream.Client.Services + +@namespace MilkStream.Client.Components.Shared + +@inject PersonService PersonSvc +@inject AlbumService AlbumSvc +@inject NavigationManager Nav +@inject IOptions ServiceOptions +@inject LoginService LoginSvc + +
+
+ + +
+ + @if (_showDropdown) + { +
+ @if (_isSearching) + { +
+ Searching... +
+ } + else if (_persons.Count == 0 && _albums.Count == 0) + { +
+ No results found +
+ } + else + { + @if (_persons.Count > 0) + { +
Cosplayers
+ @{ var pi = 0; } + @foreach (var person in _persons) + { + var idx = pi; + + @if (person.ProfileAssetId.HasValue) + { + + } + else + { +
@person.Name[..1].ToUpper()
+ } +
+
@person.Name
+
+
+ pi++; + } + } + + @if (_albums.Count > 0) + { +
Albums
+ @{ var ai = _persons.Count; } + @foreach (var album in _albums) + { + var idx = ai; + + @if (album.CoverAssetId.HasValue) + { + + } + else + { +
+ } +
+
@album.Name
+ @if (!string.IsNullOrEmpty(album.PersonName)) + { + @album.PersonName + } +
+
+ ai++; + } + } + } +
+ } +
+ +@code { + string _query = string.Empty; + List _persons = []; + List _albums = []; + bool _isSearching; + bool _showDropdown; + int _searchVersion; + int _selectedIndex = -1; + CancellationTokenSource? _blurCts; + + string ApiBase => ServiceOptions.Value.BaseUrl.TrimEnd('/'); + string Token => LoginSvc.AuthInfo?.Token ?? string.Empty; + string TokenParam => string.IsNullOrEmpty(Token) ? "" : $"?token={Token}"; + + string ThumbUrl(Guid id) => $"{ApiBase}/api/media/thumb/{id}{TokenParam}"; + + async Task OnInput(ChangeEventArgs e) + { + _query = e.Value?.ToString() ?? string.Empty; + + if (_query.Length < 2) + { + _persons = []; + _albums = []; + _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; + + _persons = personTask.Result ?? []; + _albums = albumTask.Result ?? []; + _isSearching = false; + _showDropdown = true; + } + + async Task OnSearchClick() + { + var total = _persons.Count + _albums.Count; + if (total == 0) return; + + if (_selectedIndex >= 0 && _selectedIndex < _persons.Count) + Nav.NavigateTo($"/cosplayer/{_persons[_selectedIndex].Id}"); + else if (_selectedIndex >= _persons.Count && _selectedIndex < total) + Nav.NavigateTo($"/albums/{_albums[_selectedIndex - _persons.Count].Id}"); + else if (_persons.Count > 0) + Nav.NavigateTo($"/cosplayer/{_persons[0].Id}"); + else if (_albums.Count > 0) + Nav.NavigateTo($"/albums/{_albums[0].Id}"); + } + + void OnFocus() + { + _blurCts?.Cancel(); + if (_query.Length >= 2 && (_persons.Count > 0 || _albums.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) + { + var total = _persons.Count + _albums.Count; + if (total == 0) return; + + switch (e.Key) + { + case "ArrowDown": + _selectedIndex = _selectedIndex >= total - 1 ? 0 : _selectedIndex + 1; + break; + case "ArrowUp": + _selectedIndex = _selectedIndex <= 0 ? total - 1 : _selectedIndex - 1; + break; + case "Enter": + if (_selectedIndex < 0) _selectedIndex = 0; + if (_selectedIndex < _persons.Count) + Nav.NavigateTo($"/cosplayer/{_persons[_selectedIndex].Id}"); + else + Nav.NavigateTo($"/albums/{_albums[_selectedIndex - _persons.Count].Id}"); + break; + case "Escape": + _showDropdown = false; + _selectedIndex = -1; + break; + } + } + + void OnItemHover(int index) + { + _selectedIndex = index; + } +} diff --git a/MilkStream.Client/Components/Shared/SearchDropdown.razor.css b/MilkStream.Client/Components/Shared/SearchDropdown.razor.css new file mode 100644 index 0000000..b1b7348 --- /dev/null +++ b/MilkStream.Client/Components/Shared/SearchDropdown.razor.css @@ -0,0 +1,82 @@ +.search-dropdown-menu { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1030; + max-height: 400px; + overflow-y: auto; + background: var(--bs-body-bg, #fff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: 0.375rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + margin-top: 0.25rem; +} + +.search-dropdown-header { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--bs-secondary-color, #6c757d); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.search-dropdown-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + text-decoration: none; + color: var(--bs-body-color, #212529); + cursor: pointer; + transition: background 0.1s; +} + +.search-dropdown-item:hover, +.search-dropdown-item.active { + background: var(--bs-tertiary-bg, #e9ecef); + color: var(--bs-body-color, #212529); +} + +.search-dropdown-item.disabled { + cursor: default; + justify-content: center; + padding: 0.75rem; +} + +.search-dropdown-thumb { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.search-dropdown-fallback { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent, #0d6efd); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 700; + flex-shrink: 0; +} + +.search-dropdown-fallback .bi { + font-size: 1rem; +} + +.search-dropdown-item-title { + font-size: 0.875rem; + line-height: 1.25; +} + +.search-dropdown-item small { + font-size: 0.75rem; + line-height: 1.2; +} -- 2.54.0 From e772e6d2b948dcd9465566b0bc376274cff733cc Mon Sep 17 00:00:00 2001 From: REDCODE Date: Sat, 11 Jul 2026 17:20:47 +0200 Subject: [PATCH 02/50] feat: wire SearchDropdown into NavMenu Replace static search input markup in NavMenu with the new SearchDropdown typeahead component. --- MilkStream.Client/Components/Layout/NavMenu.razor | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/MilkStream.Client/Components/Layout/NavMenu.razor b/MilkStream.Client/Components/Layout/NavMenu.razor index 80c8757..402f894 100644 --- a/MilkStream.Client/Components/Layout/NavMenu.razor +++ b/MilkStream.Client/Components/Layout/NavMenu.razor @@ -36,15 +36,7 @@ } else {
-
- - -
+
for search-dropdown-container - Replace @{} variable blocks with IndexOf() calls (invalid inside @if) - Remove leftover pi++ and ai++ incrementers --- MilkStream.Client/Components/Shared/SearchDropdown.razor | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/MilkStream.Client/Components/Shared/SearchDropdown.razor b/MilkStream.Client/Components/Shared/SearchDropdown.razor index 28b8594..752f3b0 100644 --- a/MilkStream.Client/Components/Shared/SearchDropdown.razor +++ b/MilkStream.Client/Components/Shared/SearchDropdown.razor @@ -48,10 +48,9 @@ @if (_persons.Count > 0) {
Cosplayers
- @{ var pi = 0; } @foreach (var person in _persons) { - var idx = pi; + var idx = _persons.IndexOf(person); @@ -67,17 +66,15 @@
@person.Name
- pi++; } } @if (_albums.Count > 0) {
Albums
- @{ var ai = _persons.Count; } @foreach (var album in _albums) { - var idx = ai; + var idx = _persons.Count + _albums.IndexOf(album); @@ -97,7 +94,6 @@ } - ai++; } } } -- 2.54.0 From bc72243c3e6ab1ac80a87c2b4c98267a4abc5546 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Sat, 11 Jul 2026 17:26:13 +0200 Subject: [PATCH 04/50] style: use rounded-square thumbnails for album results Album covers now show as rounded squares (border-radius: 0.375rem) while cosplayer profile pics remain circular, improving visual differentiation between result types. --- .../Components/Shared/SearchDropdown.razor | 4 ++-- .../Shared/SearchDropdown.razor.css | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/MilkStream.Client/Components/Shared/SearchDropdown.razor b/MilkStream.Client/Components/Shared/SearchDropdown.razor index 752f3b0..fb0ac27 100644 --- a/MilkStream.Client/Components/Shared/SearchDropdown.razor +++ b/MilkStream.Client/Components/Shared/SearchDropdown.razor @@ -80,11 +80,11 @@ @onmouseenter="() => OnItemHover(idx)"> @if (album.CoverAssetId.HasValue) { - + } else { -
+
}
@album.Name
diff --git a/MilkStream.Client/Components/Shared/SearchDropdown.razor.css b/MilkStream.Client/Components/Shared/SearchDropdown.razor.css index b1b7348..f9f1da9 100644 --- a/MilkStream.Client/Components/Shared/SearchDropdown.razor.css +++ b/MilkStream.Client/Components/Shared/SearchDropdown.razor.css @@ -53,6 +53,14 @@ flex-shrink: 0; } +.search-dropdown-thumb-rounded { + width: 32px; + height: 32px; + border-radius: 0.375rem; + object-fit: cover; + flex-shrink: 0; +} + .search-dropdown-fallback { width: 32px; height: 32px; @@ -67,6 +75,18 @@ flex-shrink: 0; } +.search-dropdown-fallback-rounded { + width: 32px; + height: 32px; + border-radius: 0.375rem; + background: var(--accent, #0d6efd); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + .search-dropdown-fallback .bi { font-size: 1rem; } -- 2.54.0 From fc50aec9967d68444c2b615b4375baa7894a95b8 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Sat, 11 Jul 2026 17:28:48 +0200 Subject: [PATCH 05/50] feat: make search dropdown headers selectable with keyboard and mouse Refactor SearchDropdown to use a flat item list for unified keyboard navigation. Section headers (Cosplayers/Albums) are now clickable links that navigate to the respective list page with the search query as a ?search= parameter. --- .../Components/Shared/SearchDropdown.razor | 195 +++++++++++++----- .../Shared/SearchDropdown.razor.css | 10 + 2 files changed, 155 insertions(+), 50 deletions(-) diff --git a/MilkStream.Client/Components/Shared/SearchDropdown.razor b/MilkStream.Client/Components/Shared/SearchDropdown.razor index fb0ac27..c55bcf7 100644 --- a/MilkStream.Client/Components/Shared/SearchDropdown.razor +++ b/MilkStream.Client/Components/Shared/SearchDropdown.razor @@ -37,7 +37,7 @@ Searching...
} - else if (_persons.Count == 0 && _albums.Count == 0) + else if (_items.Count == 0) {
No results found @@ -45,56 +45,62 @@ } else { - @if (_persons.Count > 0) + var index = 0; + @foreach (var item in _items) { -
Cosplayers
- @foreach (var person in _persons) + var idx = index; + var active = idx == _selectedIndex ? "active" : ""; + + @if (item.Kind is SearchItemKind.HeaderPerson or SearchItemKind.HeaderAlbum) { - var idx = _persons.IndexOf(person); - - @if (person.ProfileAssetId.HasValue) + @item.Label + + } + else if (item.Kind == SearchItemKind.Person) + { + + @if (item.ImageId.HasValue) { - + } else { -
@person.Name[..1].ToUpper()
+
@item.Label[..1].ToUpper()
}
-
@person.Name
+
@item.Label
} - } - - @if (_albums.Count > 0) - { -
Albums
- @foreach (var album in _albums) + else { - var idx = _persons.Count + _albums.IndexOf(album); - - @if (album.CoverAssetId.HasValue) + @if (item.ImageId.HasValue) { - + } else {
}
-
@album.Name
- @if (!string.IsNullOrEmpty(album.PersonName)) +
@item.Label
+ @if (!string.IsNullOrEmpty(item.Subtitle)) { - @album.PersonName + @item.Subtitle }
} + + index++; } }
@@ -103,8 +109,7 @@ @code { string _query = string.Empty; - List _persons = []; - List _albums = []; + List _items = []; bool _isSearching; bool _showDropdown; int _searchVersion; @@ -123,8 +128,7 @@ if (_query.Length < 2) { - _persons = []; - _albums = []; + _items = []; _showDropdown = false; _selectedIndex = -1; return; @@ -140,31 +144,69 @@ if (version != _searchVersion) return; - _persons = personTask.Result ?? []; - _albums = albumTask.Result ?? []; + BuildItems(personTask.Result ?? [], albumTask.Result ?? []); _isSearching = false; _showDropdown = true; } + void BuildItems(List persons, List 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() { - var total = _persons.Count + _albums.Count; - if (total == 0) return; + if (_items.Count == 0) return; - if (_selectedIndex >= 0 && _selectedIndex < _persons.Count) - Nav.NavigateTo($"/cosplayer/{_persons[_selectedIndex].Id}"); - else if (_selectedIndex >= _persons.Count && _selectedIndex < total) - Nav.NavigateTo($"/albums/{_albums[_selectedIndex - _persons.Count].Id}"); - else if (_persons.Count > 0) - Nav.NavigateTo($"/cosplayer/{_persons[0].Id}"); - else if (_albums.Count > 0) - Nav.NavigateTo($"/albums/{_albums[0].Id}"); + var target = _selectedIndex >= 0 ? _items[_selectedIndex] : _items[0]; + Nav.NavigateTo(target.Url); } void OnFocus() { _blurCts?.Cancel(); - if (_query.Length >= 2 && (_persons.Count > 0 || _albums.Count > 0)) + if (_query.Length >= 2 && _items.Count > 0) _showDropdown = true; } @@ -185,23 +227,19 @@ void OnKeyDown(KeyboardEventArgs e) { - var total = _persons.Count + _albums.Count; - if (total == 0) return; + if (_items.Count == 0) return; switch (e.Key) { case "ArrowDown": - _selectedIndex = _selectedIndex >= total - 1 ? 0 : _selectedIndex + 1; + _selectedIndex = _selectedIndex >= _items.Count - 1 ? 0 : _selectedIndex + 1; break; case "ArrowUp": - _selectedIndex = _selectedIndex <= 0 ? total - 1 : _selectedIndex - 1; + _selectedIndex = _selectedIndex <= 0 ? _items.Count - 1 : _selectedIndex - 1; break; case "Enter": if (_selectedIndex < 0) _selectedIndex = 0; - if (_selectedIndex < _persons.Count) - Nav.NavigateTo($"/cosplayer/{_persons[_selectedIndex].Id}"); - else - Nav.NavigateTo($"/albums/{_albums[_selectedIndex - _persons.Count].Id}"); + Nav.NavigateTo(_items[_selectedIndex].Url); break; case "Escape": _showDropdown = false; @@ -214,4 +252,61 @@ { _selectedIndex = index; } + + /// + /// Represents a single item in the search dropdown menu. + /// + sealed class SearchItem + { + /// + /// Gets or sets the type of search item. + /// + public SearchItemKind Kind { get; init; } + + /// + /// Gets or sets the display label. + /// + public string Label { get; init; } = string.Empty; + + /// + /// Gets or sets the navigation URL. + /// + public string Url { get; init; } = string.Empty; + + /// + /// Gets or sets the optional thumbnail image asset ID. + /// + public Guid? ImageId { get; init; } + + /// + /// Gets or sets an optional subtitle (e.g. person name for albums). + /// + public string? Subtitle { get; init; } + } + + /// + /// Categorizes a search dropdown item. + /// + enum SearchItemKind + { + /// + /// "Cosplayers" section header. + /// + HeaderPerson, + + /// + /// "Albums" section header. + /// + HeaderAlbum, + + /// + /// A cosplayer result. + /// + Person, + + /// + /// An album result. + /// + Album + } } diff --git a/MilkStream.Client/Components/Shared/SearchDropdown.razor.css b/MilkStream.Client/Components/Shared/SearchDropdown.razor.css index f9f1da9..1069d4f 100644 --- a/MilkStream.Client/Components/Shared/SearchDropdown.razor.css +++ b/MilkStream.Client/Components/Shared/SearchDropdown.razor.css @@ -14,12 +14,22 @@ } .search-dropdown-header { + display: block; padding: 0.375rem 0.75rem; font-size: 0.75rem; font-weight: 600; color: var(--bs-secondary-color, #6c757d); text-transform: uppercase; letter-spacing: 0.05em; + text-decoration: none; + cursor: pointer; + transition: background 0.1s, color 0.1s; +} + +.search-dropdown-header:hover, +.search-dropdown-header.active { + background: var(--bs-tertiary-bg, #e9ecef); + color: var(--bs-body-color, #212529); } .search-dropdown-item { -- 2.54.0 From 0b8c435f7cb47457faa5bf38b3f66bedd5a7b190 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Sat, 11 Jul 2026 17:28:51 +0200 Subject: [PATCH 06/50] feat: parse ?search= query param on Cosplayers and Albums pages Headers in the search dropdown navigate to the respective list page with the search query pre-filled via ?search= parameter. Both pages now parse this parameter on initialization. --- MilkStream.Client/Components/Pages/Albums.razor | 13 +++++++++++++ MilkStream.Client/Components/Pages/Cosplayers.razor | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/MilkStream.Client/Components/Pages/Albums.razor b/MilkStream.Client/Components/Pages/Albums.razor index 1f513b5..a73f6f1 100644 --- a/MilkStream.Client/Components/Pages/Albums.razor +++ b/MilkStream.Client/Components/Pages/Albums.razor @@ -103,6 +103,19 @@ await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll"); _loggedUserChangedHandler = (_, _) => StateHasChanged(); loginService.LoggedUserChanged += _loggedUserChangedHandler; + ParseSearchQuery(); + } + + void ParseSearchQuery() { + var uri = navigationManager.Uri; + var qIdx = uri.IndexOf('?'); + if (qIdx < 0) return; + foreach (var part in uri[(qIdx + 1)..].Split('&')) + { + var kv = part.Split('=', 2); + if (kv.Length == 2 && kv[0] == "search") + searchQuery = Uri.UnescapeDataString(kv[1].Replace("+", " ")); + } } void SetMasonry() => viewMode = "masonry"; diff --git a/MilkStream.Client/Components/Pages/Cosplayers.razor b/MilkStream.Client/Components/Pages/Cosplayers.razor index 2e85d5c..7458989 100644 --- a/MilkStream.Client/Components/Pages/Cosplayers.razor +++ b/MilkStream.Client/Components/Pages/Cosplayers.razor @@ -85,6 +85,19 @@ protected override async Task OnInitializedAsync() { loginService.LoggedUserChanged += OnLoggedUserChanged; + ParseSearchQuery(); + } + + void ParseSearchQuery() { + var uri = navigationManager.Uri; + var qIdx = uri.IndexOf('?'); + if (qIdx < 0) return; + foreach (var part in uri[(qIdx + 1)..].Split('&')) + { + var kv = part.Split('=', 2); + if (kv.Length == 2 && kv[0] == "search") + searchQuery = Uri.UnescapeDataString(kv[1].Replace("+", " ")); + } } void OnLoggedUserChanged(object? _, UserInfoDto? _2) => StateHasChanged(); -- 2.54.0 From e02466771ad2a41c371982fbf9833daa07af6284 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Sat, 11 Jul 2026 17:30:17 +0200 Subject: [PATCH 07/50] fix: pressing enter with nothing selected does nothing --- MilkStream.Client/Components/Shared/SearchDropdown.razor | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/MilkStream.Client/Components/Shared/SearchDropdown.razor b/MilkStream.Client/Components/Shared/SearchDropdown.razor index c55bcf7..d06f6d6 100644 --- a/MilkStream.Client/Components/Shared/SearchDropdown.razor +++ b/MilkStream.Client/Components/Shared/SearchDropdown.razor @@ -197,10 +197,9 @@ async Task OnSearchClick() { - if (_items.Count == 0) return; + if (_items.Count == 0 || _selectedIndex < 0) return; - var target = _selectedIndex >= 0 ? _items[_selectedIndex] : _items[0]; - Nav.NavigateTo(target.Url); + Nav.NavigateTo(_items[_selectedIndex].Url); } void OnFocus() @@ -238,7 +237,7 @@ _selectedIndex = _selectedIndex <= 0 ? _items.Count - 1 : _selectedIndex - 1; break; case "Enter": - if (_selectedIndex < 0) _selectedIndex = 0; + if (_selectedIndex < 0) return; Nav.NavigateTo(_items[_selectedIndex].Url); break; case "Escape": -- 2.54.0 From f2059e0dd71ec583a5d123d005669e9ba1be423b Mon Sep 17 00:00:00 2001 From: REDCODE Date: Sat, 11 Jul 2026 17:34:04 +0200 Subject: [PATCH 08/50] fix: move query param parsing to OnParametersSet OnInitializedAsync only fires once per component lifetime, so re-navigating to the same page with a different ?search= value wouldn't re-parse. OnParametersSet fires on every parameter change, covering both initial load and subsequent navigations. --- MilkStream.Client/Components/Pages/Albums.razor | 3 +++ MilkStream.Client/Components/Pages/Cosplayers.razor | 3 +++ 2 files changed, 6 insertions(+) diff --git a/MilkStream.Client/Components/Pages/Albums.razor b/MilkStream.Client/Components/Pages/Albums.razor index a73f6f1..21d32b6 100644 --- a/MilkStream.Client/Components/Pages/Albums.razor +++ b/MilkStream.Client/Components/Pages/Albums.razor @@ -103,6 +103,9 @@ await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll"); _loggedUserChangedHandler = (_, _) => StateHasChanged(); loginService.LoggedUserChanged += _loggedUserChangedHandler; + } + + protected override void OnParametersSet() { ParseSearchQuery(); } diff --git a/MilkStream.Client/Components/Pages/Cosplayers.razor b/MilkStream.Client/Components/Pages/Cosplayers.razor index 7458989..6a2038a 100644 --- a/MilkStream.Client/Components/Pages/Cosplayers.razor +++ b/MilkStream.Client/Components/Pages/Cosplayers.razor @@ -85,6 +85,9 @@ protected override async Task OnInitializedAsync() { loginService.LoggedUserChanged += OnLoggedUserChanged; + } + + protected override void OnParametersSet() { ParseSearchQuery(); } -- 2.54.0 From 5fba560bec8d9966d2cb08a7bf7692c0336458f3 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Sat, 11 Jul 2026 17:38:51 +0200 Subject: [PATCH 09/50] fix: parse search query before await in Albums OnInitializedAsync Blazor triggers an early render when OnInitializedAsync has a true await (masonryObserver.unlockBodyScroll). By that point searchQuery was still null, so the grid briefly loaded unfiltered results and a stale response could overwrite the correct filtered results. ParseSearchQuery must run before the await to ensure the first render passes the correct search query to AlbumGrid. --- MilkStream.Client/Components/Pages/Albums.razor | 1 + 1 file changed, 1 insertion(+) diff --git a/MilkStream.Client/Components/Pages/Albums.razor b/MilkStream.Client/Components/Pages/Albums.razor index 21d32b6..531b1aa 100644 --- a/MilkStream.Client/Components/Pages/Albums.razor +++ b/MilkStream.Client/Components/Pages/Albums.razor @@ -100,6 +100,7 @@ }; protected override async Task OnInitializedAsync() { + ParseSearchQuery(); await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll"); _loggedUserChangedHandler = (_, _) => StateHasChanged(); loginService.LoggedUserChanged += _loggedUserChangedHandler; -- 2.54.0 From c5ba90202ddaa287b37f48f7e4c85afa4b683817 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Sat, 11 Jul 2026 17:44:13 +0200 Subject: [PATCH 10/50] style: add chevron-right indicator to search dropdown headers Shows a right-pointing arrow on section headers (Cosplayers/Albums) to visually signal they are clickable and navigate to the list page. The arrow subtly moves right on hover for extra affordance. --- .../Components/Shared/SearchDropdown.razor | 3 ++- .../Components/Shared/SearchDropdown.razor.css | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/MilkStream.Client/Components/Shared/SearchDropdown.razor b/MilkStream.Client/Components/Shared/SearchDropdown.razor index d06f6d6..f2f6746 100644 --- a/MilkStream.Client/Components/Shared/SearchDropdown.razor +++ b/MilkStream.Client/Components/Shared/SearchDropdown.razor @@ -56,7 +56,8 @@ - @item.Label + @item.Label + } else if (item.Kind == SearchItemKind.Person) diff --git a/MilkStream.Client/Components/Shared/SearchDropdown.razor.css b/MilkStream.Client/Components/Shared/SearchDropdown.razor.css index 1069d4f..1a9705b 100644 --- a/MilkStream.Client/Components/Shared/SearchDropdown.razor.css +++ b/MilkStream.Client/Components/Shared/SearchDropdown.razor.css @@ -14,7 +14,9 @@ } .search-dropdown-header { - display: block; + display: flex; + align-items: center; + justify-content: space-between; padding: 0.375rem 0.75rem; font-size: 0.75rem; font-weight: 600; @@ -26,6 +28,18 @@ transition: background 0.1s, color 0.1s; } +.search-dropdown-header .bi { + font-size: 0.7rem; + opacity: 0.5; + transition: opacity 0.1s, transform 0.1s; +} + +.search-dropdown-header:hover .bi, +.search-dropdown-header.active .bi { + opacity: 0.8; + transform: translateX(1px); +} + .search-dropdown-header:hover, .search-dropdown-header.active { background: var(--bs-tertiary-bg, #e9ecef); -- 2.54.0 From faa8e93d55914c6d8824252f5611791e0eddca73 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Sat, 11 Jul 2026 18:03:17 +0200 Subject: [PATCH 11/50] fix: use case-insensitive ILike search for persons and albums Replace EF Core Contains() with EF.Functions.ILike() which translates to PostgreSQL ILIKE for case-insensitive matching. Searching 'alice', 'Alice', or 'ALICE' now returns the same results. --- Lactose/Repositories/AlbumRepository.cs | 2 +- Lactose/Repositories/PersonRepository.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Lactose/Repositories/AlbumRepository.cs b/Lactose/Repositories/AlbumRepository.cs index 4d59d5a..8e19864 100644 --- a/Lactose/Repositories/AlbumRepository.cs +++ b/Lactose/Repositories/AlbumRepository.cs @@ -29,7 +29,7 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository { .Include(a => a.CoverAsset) .Include(a => a.PersonOwner) .Include(a => a.Assets)!.ThenInclude(a => a.SharedWith) - .Where(x => x.Title.Contains(query)); + .Where(x => EF.Functions.ILike(x.Title, $"%{query}%")); if (unassigned) albumsQuery = albumsQuery.Where(a => a.PersonOwnerId == null); diff --git a/Lactose/Repositories/PersonRepository.cs b/Lactose/Repositories/PersonRepository.cs index 26fdcf7..f85f03d 100644 --- a/Lactose/Repositories/PersonRepository.cs +++ b/Lactose/Repositories/PersonRepository.cs @@ -76,7 +76,7 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository { // Apply search filter if (!string.IsNullOrEmpty(query)) - peopleQuery = peopleQuery.Where(p => p.Name.Contains(query)); + peopleQuery = peopleQuery.Where(p => EF.Functions.ILike(p.Name, $"%{query}%")); // Apply sorting IOrderedQueryable ordered = sortBy?.ToLower() switch -- 2.54.0 From 7ea72e13ac66f5e70f97f7483af011ab57df35a4 Mon Sep 17 00:00:00 2001 From: REDCODE Date: Sat, 11 Jul 2026 18:09:57 +0200 Subject: [PATCH 12/50] style: use Bootstrap dropdown-menu and dropdown-item classes Match styling of the user profile dropdown in the navbar by using Bootstrap's native .dropdown-menu and .dropdown-item classes instead of custom classes. Keep only custom positioning, thumbnail sizing, and the clickable header styles. --- .../Components/Shared/SearchDropdown.razor | 12 ++--- .../Shared/SearchDropdown.razor.css | 46 +++---------------- 2 files changed, 13 insertions(+), 45 deletions(-) diff --git a/MilkStream.Client/Components/Shared/SearchDropdown.razor b/MilkStream.Client/Components/Shared/SearchDropdown.razor index f2f6746..7be603c 100644 --- a/MilkStream.Client/Components/Shared/SearchDropdown.razor +++ b/MilkStream.Client/Components/Shared/SearchDropdown.razor @@ -30,16 +30,16 @@ @if (_showDropdown) { -
+