From 403c436a34db4d35d041843047824de5f4bc03f4 Mon Sep 17 00:00:00 2001 From: Brian Bicknell Date: Thu, 27 Nov 2025 18:28:57 -0500 Subject: [PATCH] Added SearchPageBase class and implemented it on the voice works page and circles page. Turned off AOT compilation. --- JSMR.UI.Blazor/Components/CircleFilters.razor | 41 ++++ JSMR.UI.Blazor/Filters/CircleFilterState.cs | 64 +++++ JSMR.UI.Blazor/Filters/IFilterState.cs | 7 + JSMR.UI.Blazor/Filters/QueryParameters.cs | 51 ++++ .../Filters/VoiceWorkFilterState.cs | 7 + JSMR.UI.Blazor/JSMR.UI.Blazor.csproj | 2 +- JSMR.UI.Blazor/Layout/MainLayout.razor | 12 +- JSMR.UI.Blazor/Pages/Circles.razor | 221 ++---------------- JSMR.UI.Blazor/Pages/VoiceWorks.razor | 135 ++--------- JSMR.UI.Blazor/Shared/SearchPageBase.cs | 122 ++++++++++ 10 files changed, 341 insertions(+), 321 deletions(-) create mode 100644 JSMR.UI.Blazor/Components/CircleFilters.razor create mode 100644 JSMR.UI.Blazor/Filters/CircleFilterState.cs create mode 100644 JSMR.UI.Blazor/Filters/IFilterState.cs create mode 100644 JSMR.UI.Blazor/Filters/QueryParameters.cs create mode 100644 JSMR.UI.Blazor/Shared/SearchPageBase.cs diff --git a/JSMR.UI.Blazor/Components/CircleFilters.razor b/JSMR.UI.Blazor/Components/CircleFilters.razor new file mode 100644 index 0000000..2e5c9f4 --- /dev/null +++ b/JSMR.UI.Blazor/Components/CircleFilters.razor @@ -0,0 +1,41 @@ +@using JSMR.Application.Circles.Queries.Search +@using JSMR.UI.Blazor.Filters +@using JSMR.UI.Blazor.Services + +
+
+ +
+
+ +
+
+ +@code { + [Parameter] + public CircleFilterState Value { get; set; } = new(); + + [Parameter] + public EventCallback ValueChanged { get; set; } + + List> CircleStatuses = + [ + new() { Text = "Not Blacklisted", Value = CircleStatus.NotBlacklisted }, + new() { Text = "Favorites", Value = CircleStatus.Favorited }, + new() { Text = "Blacklisted", Value = CircleStatus.Blacklisted }, + new() { Text = "Spam", Value = CircleStatus.Spam }, + new() { Text = "All", Value = null } + ]; + + private Task Update(CircleFilterState next) + => ValueChanged.InvokeAsync(next with { PageNumber = 1 }); +} diff --git a/JSMR.UI.Blazor/Filters/CircleFilterState.cs b/JSMR.UI.Blazor/Filters/CircleFilterState.cs new file mode 100644 index 0000000..d056b56 --- /dev/null +++ b/JSMR.UI.Blazor/Filters/CircleFilterState.cs @@ -0,0 +1,64 @@ +using JSMR.Application.Circles.Queries.Search; +using Microsoft.AspNetCore.WebUtilities; +using System.Globalization; + +namespace JSMR.UI.Blazor.Filters; + +public sealed record CircleFilterState : IFilterState +{ + public string? Keywords { get; init; } + public CircleStatus? Status { get; init; } + public int PageNumber { get; init; } = 1; + public int PageSize { get; init; } = 100; + + public QueryParameters ToQuery() + { + QueryParameters query = []; + + query.Set("keywords", Keywords); + + if (Status is not null) + query.Set("circle", Status.ToString()); + + if (PageNumber != 1) + query.Set("pageNumber", PageNumber.ToString(CultureInfo.InvariantCulture)); + + if (PageSize != 100) + query.Set("pageSize", PageSize.ToString(CultureInfo.InvariantCulture)); + + return query; + } + + public static CircleFilterState FromQuery(string query) + { + QueryParameters queryParameters = new(QueryHelpers.ParseQuery(query)); + + return new CircleFilterState + { + Keywords = queryParameters.GetValue("keywords"), + Status = queryParameters.GetEnum("circle"), + PageNumber = Math.Max(1, queryParameters.GetInteger("pageNumber", 1)), + PageSize = queryParameters.GetInteger("pageSize", defaultValue: 100) + }; + } + + public SearchCirclesRequest ToSearchRequest() + { + return new( + Options: new() + { + PageNumber = PageNumber, + PageSize = PageSize, + Criteria = new() + { + Name = Keywords, + Status = Status + }, + SortOptions = + [ + new(CircleSortField.Name, Application.Common.Search.SortDirection.Ascending) + ] + } + ); + } +} \ No newline at end of file diff --git a/JSMR.UI.Blazor/Filters/IFilterState.cs b/JSMR.UI.Blazor/Filters/IFilterState.cs new file mode 100644 index 0000000..e12a646 --- /dev/null +++ b/JSMR.UI.Blazor/Filters/IFilterState.cs @@ -0,0 +1,7 @@ +namespace JSMR.UI.Blazor.Filters; + +public interface IFilterState +{ + QueryParameters ToQuery(); + TSearchRequest ToSearchRequest(); +} \ No newline at end of file diff --git a/JSMR.UI.Blazor/Filters/QueryParameters.cs b/JSMR.UI.Blazor/Filters/QueryParameters.cs new file mode 100644 index 0000000..eefa3e9 --- /dev/null +++ b/JSMR.UI.Blazor/Filters/QueryParameters.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Primitives; + +namespace JSMR.UI.Blazor.Filters; + +public class QueryParameters : Dictionary +{ + public QueryParameters() + { + + } + public QueryParameters(IEnumerable> items) + { + foreach (var kv in items) + { + this[kv.Key] = kv.Value.ToString(); + } + } + + public static QueryParameters FromQueryString(string query) + => new(QueryHelpers.ParseQuery(query)); + + public void Set(string key, string? value, bool includeWhenEmpty = false) + { + if (includeWhenEmpty || !string.IsNullOrWhiteSpace(value)) + this[key] = value; + } + + public bool Has(string key) => ContainsKey(key); + + public string? GetValue(string key) => TryGetValue(key, out var value) ? value!.ToString() : null; + public string[] GetValues(string key) => GetValue(key)?.Split(',', StringSplitOptions.RemoveEmptyEntries) ?? []; + + public int GetInteger(string key, int defaultValue) + => TryGetValue(key, out var value) && int.TryParse(value, out int intValue) ? intValue : defaultValue; + + public int[] GetIntegers(string key) => [.. GetValues(key).Select(int.Parse)]; + + public T? GetEnum(string key) where T : struct, Enum + => TryGetValue(key, out var value) && Enum.TryParse(value!, true, out var enumValue) ? enumValue : null; + + public TEnum[] GetEnums(string key) where TEnum : struct, Enum + => GetValues(key).Select(value => + Enum.TryParse(value, true, out var enumValue) + ? enumValue + : (TEnum?)null).Where(e => e is not null).Select(e => e!.Value).ToArray() + ?? []; + + public DateOnly? GetDate(string key, string? format = "yyyy-MM-dd") + => DateOnly.TryParseExact(GetValue(key), format, out var date) ? date : null; +} \ No newline at end of file diff --git a/JSMR.UI.Blazor/Filters/VoiceWorkFilterState.cs b/JSMR.UI.Blazor/Filters/VoiceWorkFilterState.cs index a75873e..5b1e13d 100644 --- a/JSMR.UI.Blazor/Filters/VoiceWorkFilterState.cs +++ b/JSMR.UI.Blazor/Filters/VoiceWorkFilterState.cs @@ -8,6 +8,13 @@ using System.Globalization; namespace JSMR.UI.Blazor.Filters; +public abstract record FilterStateBase : IFilterState +{ + public abstract QueryParameters ToQuery(); + public abstract TSearchRequest ToSearchRequest(); + //public static abstract IFilterState FromQuery(string query); +} + public sealed record VoiceWorkFilterState { // Core diff --git a/JSMR.UI.Blazor/JSMR.UI.Blazor.csproj b/JSMR.UI.Blazor/JSMR.UI.Blazor.csproj index bc46f35..478dd76 100644 --- a/JSMR.UI.Blazor/JSMR.UI.Blazor.csproj +++ b/JSMR.UI.Blazor/JSMR.UI.Blazor.csproj @@ -5,7 +5,7 @@ enable enable service-worker-assets.js - true + false diff --git a/JSMR.UI.Blazor/Layout/MainLayout.razor b/JSMR.UI.Blazor/Layout/MainLayout.razor index ff768ab..00a6005 100644 --- a/JSMR.UI.Blazor/Layout/MainLayout.razor +++ b/JSMR.UI.Blazor/Layout/MainLayout.razor @@ -10,12 +10,12 @@ Home - Tags - Circles - Creators - Scanner - Analytics - Voice Works + Tags + Circles + Creators + Scanner + Analytics + Voice Works diff --git a/JSMR.UI.Blazor/Pages/Circles.razor b/JSMR.UI.Blazor/Pages/Circles.razor index 5b1f825..b229f77 100644 --- a/JSMR.UI.Blazor/Pages/Circles.razor +++ b/JSMR.UI.Blazor/Pages/Circles.razor @@ -2,42 +2,32 @@ @using JSMR.Application.Circles.Queries.Search @using JSMR.Application.Common.Search @using JSMR.UI.Blazor.Components +@using JSMR.UI.Blazor.Filters @using JSMR.UI.Blazor.Services +@using JSMR.UI.Blazor.Shared @inject VoiceWorksClient Client -@inject IJSRuntime JS @inject HttpClient Http +@inherits SearchPageBase + Circles

Circles

-
-
- -
-
- - Not Blacklisted - Favorite - Blacklisted - Spam - All - -
-
+ -@if (searchResults is null) +@if (Result is null) {

Loading…

} -else if (searchResults.Items.Length == 0) +else if (Result.Items.Length == 0) {

No results.

} else {
- @foreach (var item in searchResults.Items) + @foreach (var item in Result.Items) {
@@ -90,101 +80,11 @@ else }
- - - - - - - - - -
-
@context.Item.Name
-
-
-
- @* - - @context.Item.MakerId - - *@ - @* - *@ - - - @if (context.Item.Favorite) - { - Favorite - } - else if (context.Item.Blacklisted) - { - Blacklisted - } - else if (context.Item.Spam) - { - Spam - } - else - { - Normal - } - - - - - @if (context.Item.Releases > 0) - { -
-
-
- } -
-
- - - - @* - - @context.Item.FirstReleaseDate?.ToString("MMMM d, yyyy") - - - - - @context.Item.LatestReleaseDate?.ToString("MMMM d, yyyy") - - *@ -
-
- - @*
- @foreach (var item in searchResults.Items) - { - - - @item.Name -
@item.Releases
-
@item.Pending
- - @if (item.Releases > 0) - { -
-
-
- } - -
@item.Downloads
- @item.Downloads.ToString("n0") -
@item.FirstReleaseDate?.ToString("MMMM d, yyyy")
-
@item.LatestReleaseDate?.ToString("MMMM d, yyyy")
- - - -
- } -
*@ - - + } @code { - private string? keywords; + protected override CircleFilterState ParseStateFromUri(string absoluteUri) + => CircleFilterState.FromQuery(new Uri(absoluteUri).Query); - public string? Keywords + protected override string BuildUri(CircleFilterState state) { - get { return keywords; } - set - { - keywords = value; - _ = UpdateDataAsync(true); - } + var basePath = new Uri(Nav.Uri).GetLeftPart(UriPartial.Path); + return Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(basePath, state.ToQuery()); } - private string circleStatus = string.Empty; + protected override bool IsThisPage(string absoluteUri) + => Nav.ToBaseRelativePath(absoluteUri).StartsWith("circles", StringComparison.OrdinalIgnoreCase); - public string SelectedCircleStatus - { - get { return circleStatus; } - set - { - circleStatus = value; - _ = UpdateDataAsync(true); - } - } - - private int pageNumber = 1; - - public int PageNumber - { - get { return pageNumber; } - set - { - pageNumber = value; - _ = UpdateDataAsync(false); - } - } - - int pageSize = 100; - - public int PageSize - { - get { return pageSize; } - set - { - pageSize = value; - _ = UpdateDataAsync(true); - } - } - - SearchResult? searchResults; - - protected override Task OnInitializedAsync() - { - _ = LoadCirclesAsync(); - - return Task.CompletedTask; - } - - private async Task LoadCirclesAsync() - { - SearchCirclesRequest request = new( - Options: new() - { - PageNumber = PageNumber, - PageSize = pageSize, - Criteria = new() - { - Name = Keywords, - Status = string.IsNullOrWhiteSpace(SelectedCircleStatus) == false ? Enum.Parse(SelectedCircleStatus) : null - }, - SortOptions = - [ - new(CircleSortField.Name, Application.Common.Search.SortDirection.Ascending) - ] - } - ); - - await JS.InvokeVoidAsync("pageHelpers.scrollToTop"); - var result = await Client.SearchAsync(request); - - searchResults = result?.Results ?? new(); - - await InvokeAsync(StateHasChanged); - } - - private async Task UpdateDataAsync(bool resetPageNumber) - { - if (resetPageNumber) - pageNumber = 1; - - await LoadCirclesAsync(); - } + protected override Task> ExecuteSearchAsync(CircleFilterState state, CancellationToken ct) + => Client.SearchAsync(state.ToSearchRequest(), ct).ContinueWith(t => t.Result?.Results ?? new SearchResult(), ct); private string GetStarRatingClass(CircleSearchItem item) { diff --git a/JSMR.UI.Blazor/Pages/VoiceWorks.razor b/JSMR.UI.Blazor/Pages/VoiceWorks.razor index 63d3c7d..ed94ade 100644 --- a/JSMR.UI.Blazor/Pages/VoiceWorks.razor +++ b/JSMR.UI.Blazor/Pages/VoiceWorks.razor @@ -4,136 +4,41 @@ @using JSMR.UI.Blazor.Components @using JSMR.UI.Blazor.Filters @using JSMR.UI.Blazor.Services +@using JSMR.UI.Blazor.Shared @using Microsoft.AspNetCore.WebUtilities @inject VoiceWorksClient Client -@inject IJSRuntime JS -@inject NavigationManager NavigationManager -@implements IAsyncDisposable; + +@inherits SearchPageBase Voice Works

Voice Works

- - + + -@if (searchResults is not null) +@if (Result is not null) { - + } @code { - private bool _suppressNextLocation; - private bool _isAlive = true; - //private bool IsLoading; - private CancellationTokenSource _cts = new(); + protected override VoiceWorkFilterState ParseStateFromUri(string absoluteUri) + => VoiceWorkFilterState.FromQuery(new Uri(absoluteUri).Query); - VoiceWorkFilterState FilterState = new(); - SearchResult? searchResults; - - protected override async Task OnInitializedAsync() + protected override string BuildUri(VoiceWorkFilterState state) { - NavigationManager.LocationChanged += OnLocationChanged; - - FilterState = VoiceWorkFilterState.FromQuery(new Uri(NavigationManager.Uri).Query); - await RunSearchAsync(); + var basePath = new Uri(Nav.Uri).GetLeftPart(UriPartial.Path); + return Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(basePath, state.ToQuery()); } - private async void OnLocationChanged(object? sender, LocationChangedEventArgs e) - { - if (_suppressNextLocation) - { - _suppressNextLocation = false; - return; - } + protected override bool IsThisPage(string absoluteUri) + => Nav.ToBaseRelativePath(absoluteUri).StartsWith("voiceworks", StringComparison.OrdinalIgnoreCase); - if (!_isAlive) - return; - - if (!IsThisPage(e.Location)) - return; - - string query = NavigationManager.ToAbsoluteUri(e.Location).Query; - VoiceWorkFilterState next = VoiceWorkFilterState.FromQuery(query); - - if (next != FilterState) - { - FilterState = next; - await InvokeAsync(StateHasChanged); - RunSearchAsync(); - } - } - - private bool IsThisPage(string absoluteUri) - { - string baseRelativePath = NavigationManager.ToBaseRelativePath(absoluteUri); - - return baseRelativePath.StartsWith("voiceworks", StringComparison.OrdinalIgnoreCase); - } - - async Task OnFilterStateChanged(VoiceWorkFilterState next) - { - if (next == FilterState) - return; - - FilterState = next; - - // Optional immediate paint if you want instant visual feedback on filters: - //await InvokeAsync(StateHasChanged); - - UpdateUrl(next, false); - RunSearchAsync(); - } - - void UpdateUrl(VoiceWorkFilterState next, bool replace) - { - string basePath = new Uri(NavigationManager.Uri).GetLeftPart(UriPartial.Path); - string uri = QueryHelpers.AddQueryString(basePath, next.ToQuery()); - - _suppressNextLocation = true; - - NavigationManager.NavigateTo(uri, replace: replace); - } - - async Task RunSearchAsync() - { - await JS.InvokeVoidAsync("pageHelpers.scrollToTop"); - - try - { - //IsLoading = true; // show skeletons NOW - //StateHasChanged(); // paint immediately - - _cts.Cancel(); - _cts = new(); - - SearchVoiceWorksResponse? response = await Client.SearchAsync(FilterState.ToSearchRequest(), _cts.Token); - searchResults = response?.Results; - } - catch (OperationCanceledException) - { - - } - finally - { - if (_isAlive) - { - //IsLoading = false; // hide skeletons - await InvokeAsync(StateHasChanged); - } - } - } - - public async ValueTask DisposeAsync() - { - _isAlive = false; - NavigationManager.LocationChanged -= OnLocationChanged; - _cts.Cancel(); - _cts.Dispose(); - await Task.CompletedTask; - } + protected override Task> ExecuteSearchAsync(VoiceWorkFilterState state, CancellationToken ct) + => Client.SearchAsync(state.ToSearchRequest(), ct).ContinueWith(t => t.Result?.Results ?? new SearchResult(), ct); } \ No newline at end of file diff --git a/JSMR.UI.Blazor/Shared/SearchPageBase.cs b/JSMR.UI.Blazor/Shared/SearchPageBase.cs new file mode 100644 index 0000000..0ef2b12 --- /dev/null +++ b/JSMR.UI.Blazor/Shared/SearchPageBase.cs @@ -0,0 +1,122 @@ +using JSMR.Application.Common.Search; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Routing; +using Microsoft.JSInterop; + +namespace JSMR.UI.Blazor.Shared; + +public abstract class SearchPageBase : ComponentBase, IAsyncDisposable + where TState : notnull +{ + [Inject] + protected NavigationManager Nav { get; set; } = default!; + + [Inject] + protected IJSRuntime JS { get; set; } = default!; + + protected TState State { get; private set; } = default!; + protected SearchResult? Result { get; private set; } + protected bool IsLoading { get; private set; } + + private bool _suppressNextLocation; + private bool _isAlive = true; + private CancellationTokenSource _cancellationTokenSource = new(); + + protected override async Task OnInitializedAsync() + { + Nav.LocationChanged += OnLocationChanged; + State = ParseStateFromUri(Nav.Uri); + + await RunSearchAsync(); + } + + protected async Task UpdateAsync(TState next) + { + if (Equals(next, State)) + return; + + State = next; + NavigateToState(next); + + _ = RunSearchAsync(); + } + + private void NavigateToState(TState next) + { + string uri = BuildUri(next); + _suppressNextLocation = true; + Nav.NavigateTo(uri); + } + + private async void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + if (_suppressNextLocation) + { + _suppressNextLocation = false; + return; + } + + if (!_isAlive) + return; + + if (!IsThisPage(e.Location)) + return; + + TState next = ParseStateFromUri(e.Location); + + if (Equals(next, State)) + return; + + State = next; + + await InvokeAsync(StateHasChanged); + _ = RunSearchAsync(); + } + + private async Task RunSearchAsync() + { + await JS.InvokeVoidAsync("pageHelpers.scrollToTop"); + + try + { + IsLoading = true; + //StateHasChanged(); + + _cancellationTokenSource.Cancel(); + _cancellationTokenSource = new(); + + Result = await ExecuteSearchAsync(State, _cancellationTokenSource.Token); + } + catch (OperationCanceledException) + { + + } + finally + { + if (_isAlive) + { + IsLoading = false; + await InvokeAsync(StateHasChanged); + } + } + } + + protected abstract TState ParseStateFromUri(string absoluteUri); + protected abstract string BuildUri(TState state); + protected abstract bool IsThisPage(string absoluteUri); + protected abstract Task> ExecuteSearchAsync(TState state, CancellationToken cancellationToken); + + public async ValueTask DisposeAsync() + { + _isAlive = false; + + Nav.LocationChanged -= OnLocationChanged; + + _cancellationTokenSource.Cancel(); + _cancellationTokenSource.Dispose(); + + GC.SuppressFinalize(this); + + await Task.CompletedTask; + } +} \ No newline at end of file