Updated search logic. More UI updates.
All checks were successful
ci / build-test (push) Successful in 2m17s
ci / publish-image (push) Has been skipped

This commit is contained in:
2025-11-17 21:05:55 -05:00
parent 9ef1972472
commit 2418bd0a8f
13 changed files with 430 additions and 108 deletions

View File

@@ -50,6 +50,10 @@ public static class LoggingExtensions
.AddIfNotEmpty("Title", criteria.Title) .AddIfNotEmpty("Title", criteria.Title)
.AddIfNotEmpty("Circle", criteria.Circle) .AddIfNotEmpty("Circle", criteria.Circle)
.Add("Locale", criteria.Locale) .Add("Locale", criteria.Locale)
.AddIfNotEmpty("SaleStatus", criteria.SaleStatus.ToString())
.AddIfNotEmpty("CircleStatus", criteria.CircleStatus.ToString())
.AddIfNotEmpty("TagStatus", criteria.TagStatus.ToString())
.AddIfNotEmpty("CreatorStatus", criteria.CreatorStatus.ToString())
.AddIfNotEmpty("AgeRatings", criteria.AgeRatings) .AddIfNotEmpty("AgeRatings", criteria.AgeRatings)
.AddIfNotEmpty("Languages", criteria.SupportedLanguages) .AddIfNotEmpty("Languages", criteria.SupportedLanguages)
.AddIfNotEmpty("TagIds", criteria.TagIds, preview: 5) .AddIfNotEmpty("TagIds", criteria.TagIds, preview: 5)

View File

@@ -2,11 +2,14 @@
public enum VoiceWorkSortField public enum VoiceWorkSortField
{ {
ExpectedReleaseDate,
ScheduledReleaseDate, ScheduledReleaseDate,
ReleaseDate, ReleaseDate,
AnyReleaseDate,
Downloads, Downloads,
WishlistCount, WishlistCount,
SalesToWishlistRatio, SalesToWishlistRatio,
StarRating, StarRating,
FavoriteCircle FavoriteCircle,
ProductId
} }

View File

@@ -8,6 +8,8 @@ public abstract class SearchProvider<TItem, TCriteria, TSortField, TBaseQuery> :
where TCriteria : new() where TCriteria : new()
where TSortField : struct, Enum where TSortField : struct, Enum
{ {
protected abstract bool UseSelectIdQuery { get; }
public async Task<SearchResult<TItem>> SearchAsync(SearchOptions<TCriteria, TSortField> options, CancellationToken cancellationToken = default) public async Task<SearchResult<TItem>> SearchAsync(SearchOptions<TCriteria, TSortField> options, CancellationToken cancellationToken = default)
{ {
IQueryable<TBaseQuery> baseQuery = GetBaseQuery(); IQueryable<TBaseQuery> baseQuery = GetBaseQuery();
@@ -16,12 +18,7 @@ public abstract class SearchProvider<TItem, TCriteria, TSortField, TBaseQuery> :
int total = await filteredQuery.CountAsync(cancellationToken); int total = await filteredQuery.CountAsync(cancellationToken);
IOrderedQueryable<TBaseQuery> orderedQuery = ApplySorting(filteredQuery, options.SortOptions); IOrderedQueryable<TBaseQuery> orderedQuery = ApplySorting(filteredQuery, options.SortOptions);
IQueryable<TItem> selectQuery = GetSelectQuery(orderedQuery); TItem[] items = await GetItemsAsync(options, orderedQuery, cancellationToken);
TItem[] items = await selectQuery
.Skip((options.PageNumber - 1) * options.PageSize)
.Take(options.PageSize)
.ToArrayAsync(cancellationToken);
await PostLoadAsync(items, cancellationToken); await PostLoadAsync(items, cancellationToken);
@@ -44,10 +41,10 @@ public abstract class SearchProvider<TItem, TCriteria, TSortField, TBaseQuery> :
var (field, direction) = (sortOptions[i].Field, sortOptions[i].Direction); var (field, direction) = (sortOptions[i].Field, sortOptions[i].Direction);
bool isDescending = direction == SortDirection.Descending; bool isDescending = direction == SortDirection.Descending;
IOrderedQueryable<TBaseQuery> applyFirst(Expression<Func<TBaseQuery, object>> selector) => isDescending ? query.OrderByDescending(selector) : query.OrderBy(selector); IOrderedQueryable<TBaseQuery> applyFirst(Expression<Func<TBaseQuery, object?>> selector) => isDescending ? query.OrderByDescending(selector) : query.OrderBy(selector);
IOrderedQueryable<TBaseQuery> applyNext(Expression<Func<TBaseQuery, object>> selector) => isDescending ? ordered!.ThenByDescending(selector) : ordered!.ThenBy(selector); IOrderedQueryable<TBaseQuery> applyNext(Expression<Func<TBaseQuery, object?>> selector) => isDescending ? ordered!.ThenByDescending(selector) : ordered!.ThenBy(selector);
Expression<Func<TBaseQuery, object>> selector = GetSortExpression(field); Expression<Func<TBaseQuery, object?>> selector = GetSortExpression(field);
ordered = (i == 0) ? applyFirst(selector) : applyNext(selector); ordered = (i == 0) ? applyFirst(selector) : applyNext(selector);
} }
@@ -83,8 +80,34 @@ public abstract class SearchProvider<TItem, TCriteria, TSortField, TBaseQuery> :
} }
} }
protected abstract Expression<Func<TBaseQuery, object>> GetSortExpression(TSortField field); private async Task<TItem[]> GetItemsAsync(SearchOptions<TCriteria, TSortField> options, IOrderedQueryable<TBaseQuery> orderedQuery, CancellationToken cancellationToken)
{
if (UseSelectIdQuery)
{
int[] ids = await GetSelectIdQuery(orderedQuery)
.Skip((options.PageNumber - 1) * options.PageSize)
.Take(options.PageSize)
.ToArrayAsync(cancellationToken);
Dictionary<int, TItem> items = await GetItems(ids);
return [.. ids.Select(uniqueId => items[uniqueId])];
}
else
{
IQueryable<TItem> selectQuery = GetSelectQuery(orderedQuery);
return await selectQuery
.Skip((options.PageNumber - 1) * options.PageSize)
.Take(options.PageSize)
.ToArrayAsync(cancellationToken);
}
}
protected abstract Expression<Func<TBaseQuery, object?>> GetSortExpression(TSortField field);
protected abstract IEnumerable<(Expression<Func<TBaseQuery, object>> Selector, SortDirection Dir)> GetDefaultSortChain(); protected abstract IEnumerable<(Expression<Func<TBaseQuery, object>> Selector, SortDirection Dir)> GetDefaultSortChain();
protected abstract IQueryable<int> GetSelectIdQuery(IOrderedQueryable<TBaseQuery> query);
protected abstract IQueryable<TItem> GetSelectQuery(IOrderedQueryable<TBaseQuery> query); protected abstract IQueryable<TItem> GetSelectQuery(IOrderedQueryable<TBaseQuery> query);
protected abstract Task<Dictionary<int, TItem>> GetItems(int[] ids);
protected virtual Task PostLoadAsync(IList<TItem> items, CancellationToken cancellationToken) => Task.CompletedTask; protected virtual Task PostLoadAsync(IList<TItem> items, CancellationToken cancellationToken) => Task.CompletedTask;
} }

View File

@@ -14,6 +14,8 @@ public class CircleQuery
public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleSearchItem, CircleSearchCriteria, CircleSortField, CircleQuery>, ICircleSearchProvider public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleSearchItem, CircleSearchCriteria, CircleSortField, CircleQuery>, ICircleSearchProvider
{ {
protected override bool UseSelectIdQuery => false;
protected override IQueryable<CircleQuery> GetBaseQuery() protected override IQueryable<CircleQuery> GetBaseQuery()
{ {
// Project from Circles so we can use correlated subqueries per CircleId. // Project from Circles so we can use correlated subqueries per CircleId.
@@ -120,7 +122,7 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleS
return query; return query;
} }
protected override Expression<Func<CircleQuery, object>> GetSortExpression(CircleSortField field) => field switch protected override Expression<Func<CircleQuery, object?>> GetSortExpression(CircleSortField field) => field switch
{ {
CircleSortField.Favorite => x => !x.Circle.Favorite, CircleSortField.Favorite => x => !x.Circle.Favorite,
CircleSortField.Blacklisted => x => !x.Circle.Blacklisted, CircleSortField.Blacklisted => x => !x.Circle.Blacklisted,
@@ -134,6 +136,11 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleS
yield return (x => x.Circle.MakerId, SortDirection.Ascending); yield return (x => x.Circle.MakerId, SortDirection.Ascending);
} }
protected override IQueryable<int> GetSelectIdQuery(IOrderedQueryable<CircleQuery> query)
{
return query.Select(x => x.Circle.CircleId);
}
protected override IQueryable<CircleSearchItem> GetSelectQuery(IOrderedQueryable<CircleQuery> query) protected override IQueryable<CircleSearchItem> GetSelectQuery(IOrderedQueryable<CircleQuery> query)
{ {
// Join to VoiceWorks by LatestProductId to fill HasImage / SalesDate // Join to VoiceWorks by LatestProductId to fill HasImage / SalesDate
@@ -205,4 +212,77 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleS
return selected; return selected;
} }
protected override async Task<Dictionary<int, CircleSearchItem>> GetItems(int[] ids)
{
// Join to VoiceWorks by LatestProductId to fill HasImage / SalesDate
var selected =
from circle in context.Circles.AsNoTracking()
//join vw in context.VoiceWorks.AsNoTracking() on item.LatestProductId equals vw.ProductId into vws
//from latest in vws.DefaultIfEmpty()
where ids.Contains(circle.CircleId)
select new CircleSearchItem
{
CircleId = circle.CircleId,
Name = circle.Name,
MakerId = circle.MakerId,
Favorite = circle.Favorite,
Blacklisted = circle.Blacklisted,
Spam = circle.Spam,
// Aggregates
Downloads = context.VoiceWorks
.Where(v => v.CircleId == circle.CircleId)
.Select(v => (int?)v.Downloads) // make nullable for Sum over empty set
.Sum() ?? 0,
Releases = context.VoiceWorks
.Count(v => v.CircleId == circle.CircleId && v.SalesDate != null),
Pending = context.VoiceWorks
.Count(v => v.CircleId == circle.CircleId && v.ExpectedDate != null),
FirstReleaseDate = context.VoiceWorks
.Where(v => v.CircleId == circle.CircleId)
.Select(v => v.SalesDate)
.Min(),
LatestReleaseDate = context.VoiceWorks
.Where(v => v.CircleId == circle.CircleId)
.Select(v => v.SalesDate)
.Max(),
// "Latest" by ProductId length, then value
LatestProductId = context.VoiceWorks
.Where(v => v.CircleId == circle.CircleId)
.OrderByDescending(v => v.ProductId.Length)
.ThenByDescending(v => v.ProductId)
.Select(v => v.ProductId)
.FirstOrDefault(),
// If you want these two in base query too:
LatestVoiceWorkHasImage = context.VoiceWorks
.Where(v => v.CircleId == circle.CircleId)
.OrderByDescending(v => v.ProductId.Length)
.ThenByDescending(v => v.ProductId)
.Select(v => (bool?)v.HasImage)
.FirstOrDefault(),
LatestVoiceWorkSalesDate = context.VoiceWorks
.Where(v => v.CircleId == circle.CircleId)
.OrderByDescending(v => v.ProductId.Length)
.ThenByDescending(v => v.ProductId)
.Select(v => v.SalesDate)
.FirstOrDefault(),
//Downloads = item.Downloads,
//Releases = item.Releases,
//Pending = item.Pending,
//FirstReleaseDate = item.FirstReleaseDate,
//LatestReleaseDate = item.LatestReleaseDate,
//LatestProductId = item.LatestProductId,
//LatestVoiceWorkHasImage = latest != null ? latest.HasImage : (bool?)null,
//LatestVoiceWorkSalesDate = latest != null ? latest.SalesDate : null
};
return await selected.ToDictionaryAsync(x => x.CircleId);
}
} }

View File

@@ -8,6 +8,8 @@ namespace JSMR.Infrastructure.Data.Repositories.Creators;
public class CreatorSearchProvider(AppDbContext context) : SearchProvider<CreatorSearchItem, CreatorSearchCriteria, CreatorSortField, CreatorSearchItem>, ICreatorSearchProvider public class CreatorSearchProvider(AppDbContext context) : SearchProvider<CreatorSearchItem, CreatorSearchCriteria, CreatorSortField, CreatorSearchItem>, ICreatorSearchProvider
{ {
protected override bool UseSelectIdQuery => false;
protected override IQueryable<CreatorSearchItem> GetBaseQuery() protected override IQueryable<CreatorSearchItem> GetBaseQuery()
{ {
return return
@@ -37,9 +39,9 @@ public class CreatorSearchProvider(AppDbContext context) : SearchProvider<Creato
return filteredQuery; return filteredQuery;
} }
protected override Expression<Func<CreatorSearchItem, object>> GetSortExpression(CreatorSortField field) protected override Expression<Func<CreatorSearchItem, object?>> GetSortExpression(CreatorSortField field)
{ {
Expression<Func<CreatorSearchItem, object>> selector = field switch Expression<Func<CreatorSearchItem, object?>> selector = field switch
{ {
CreatorSortField.VoiceWorkCount => x => x.VoiceWorkCount, CreatorSortField.VoiceWorkCount => x => x.VoiceWorkCount,
CreatorSortField.Favorite => x => !x.Favorite, CreatorSortField.Favorite => x => !x.Favorite,
@@ -59,4 +61,14 @@ public class CreatorSearchProvider(AppDbContext context) : SearchProvider<Creato
{ {
return query; return query;
} }
protected override IQueryable<int> GetSelectIdQuery(IOrderedQueryable<CreatorSearchItem> query)
{
throw new NotImplementedException();
}
protected override Task<Dictionary<int, CreatorSearchItem>> GetItems(int[] ids)
{
return Task.FromResult(new Dictionary<int, CreatorSearchItem>());
}
} }

View File

@@ -8,6 +8,8 @@ namespace JSMR.Infrastructure.Data.Repositories.Tags;
public class TagSearchProvider(AppDbContext context) : SearchProvider<TagSearchItem, TagSearchCriteria, TagSortField, TagSearchItem>, ITagSearchProvider public class TagSearchProvider(AppDbContext context) : SearchProvider<TagSearchItem, TagSearchCriteria, TagSortField, TagSearchItem>, ITagSearchProvider
{ {
protected override bool UseSelectIdQuery => false;
protected override IQueryable<TagSearchItem> GetBaseQuery() protected override IQueryable<TagSearchItem> GetBaseQuery()
{ {
return return
@@ -41,9 +43,9 @@ public class TagSearchProvider(AppDbContext context) : SearchProvider<TagSearchI
return filteredQuery; return filteredQuery;
} }
protected override Expression<Func<TagSearchItem, object>> GetSortExpression(TagSortField field) protected override Expression<Func<TagSearchItem, object?>> GetSortExpression(TagSortField field)
{ {
Expression<Func<TagSearchItem, object>> selector = field switch Expression<Func<TagSearchItem, object?>> selector = field switch
{ {
TagSortField.EnglishName => x => x.EnglishName ?? "", TagSortField.EnglishName => x => x.EnglishName ?? "",
TagSortField.VoiceWorkCount => x => x.VoiceWorkCount, TagSortField.VoiceWorkCount => x => x.VoiceWorkCount,
@@ -64,4 +66,14 @@ public class TagSearchProvider(AppDbContext context) : SearchProvider<TagSearchI
{ {
return query; return query;
} }
protected override IQueryable<int> GetSelectIdQuery(IOrderedQueryable<TagSearchItem> query)
{
throw new NotImplementedException();
}
protected override Task<Dictionary<int, TagSearchItem>> GetItems(int[] ids)
{
return Task.FromResult(new Dictionary<int, TagSearchItem>());
}
} }

View File

@@ -5,6 +5,7 @@ using JSMR.Domain.Enums;
using JSMR.Infrastructure.Common.Queries; using JSMR.Infrastructure.Common.Queries;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions; using System.Linq.Expressions;
using static Microsoft.EntityFrameworkCore.DbLoggerCategory;
using CircleStatus = JSMR.Application.VoiceWorks.Queries.Search.CircleStatus; using CircleStatus = JSMR.Application.VoiceWorks.Queries.Search.CircleStatus;
namespace JSMR.Infrastructure.Data.Repositories.VoiceWorks; namespace JSMR.Infrastructure.Data.Repositories.VoiceWorks;
@@ -20,6 +21,8 @@ public class VoiceWorkQuery
public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSearch fullTextSearch) : SearchProvider<VoiceWorkSearchResult, VoiceWorkSearchCriteria, VoiceWorkSortField, VoiceWorkQuery>, IVoiceWorkSearchProvider public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSearch fullTextSearch) : SearchProvider<VoiceWorkSearchResult, VoiceWorkSearchCriteria, VoiceWorkSortField, VoiceWorkQuery>, IVoiceWorkSearchProvider
{ {
protected override bool UseSelectIdQuery => true;
protected override IQueryable<VoiceWorkQuery> GetBaseQuery() protected override IQueryable<VoiceWorkQuery> GetBaseQuery()
{ {
return return
@@ -45,14 +48,6 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
IQueryable<VoiceWorkQuery> filteredQuery = query; IQueryable<VoiceWorkQuery> filteredQuery = query;
filteredQuery = ApplyKeywordsFilter(filteredQuery, criteria); filteredQuery = ApplyKeywordsFilter(filteredQuery, criteria);
filteredQuery = ApplyCircleStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyTagStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyCreatorStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyTagIdsFilter(filteredQuery, criteria);
filteredQuery = ApplyCreatorIdsFilter(filteredQuery, criteria);
if (criteria.Status is not null)
filteredQuery = filteredQuery.Where(x => x.VoiceWork.Status == (byte)criteria.Status);
switch (criteria.SaleStatus) switch (criteria.SaleStatus)
{ {
@@ -64,6 +59,18 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
break; break;
} }
if (criteria.SupportedLanguages.Length > 0)
filteredQuery = filteredQuery.Where(x => criteria.SupportedLanguages.Contains((Language)x.VoiceWork.SubtitleLanguage));
filteredQuery = ApplyCircleStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyTagStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyCreatorStatusFilter(filteredQuery, criteria);
filteredQuery = ApplyTagIdsFilter(filteredQuery, criteria);
filteredQuery = ApplyCreatorIdsFilter(filteredQuery, criteria);
if (criteria.Status is not null)
filteredQuery = filteredQuery.Where(x => x.VoiceWork.Status == (byte)criteria.Status);
if (criteria.ScheduledReleaseDateStart is not null) if (criteria.ScheduledReleaseDateStart is not null)
filteredQuery = filteredQuery.Where(x => x.VoiceWork.PlannedReleaseDate >= criteria.ScheduledReleaseDateStart.Value.ToDateTime(TimeOnly.MinValue)); filteredQuery = filteredQuery.Where(x => x.VoiceWork.PlannedReleaseDate >= criteria.ScheduledReleaseDateStart.Value.ToDateTime(TimeOnly.MinValue));
@@ -79,8 +86,8 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
if (criteria.AgeRatings.Length > 0) if (criteria.AgeRatings.Length > 0)
filteredQuery = filteredQuery.Where(x => criteria.AgeRatings.Contains((AgeRating)x.VoiceWork.Rating)); filteredQuery = filteredQuery.Where(x => criteria.AgeRatings.Contains((AgeRating)x.VoiceWork.Rating));
if (criteria.SupportedLanguages.Length > 0) //if (criteria.SupportedLanguages.Length > 0)
filteredQuery = filteredQuery.Where(x => criteria.SupportedLanguages.Contains((Language)x.VoiceWork.SubtitleLanguage)); // filteredQuery = filteredQuery.Where(x => criteria.SupportedLanguages.Contains((Language)x.VoiceWork.SubtitleLanguage));
if (criteria.AIGenerationOptions.Length > 0) if (criteria.AIGenerationOptions.Length > 0)
filteredQuery = filteredQuery.Where(x => criteria.AIGenerationOptions.Contains((AIGeneration)x.VoiceWork.AIGeneration)); filteredQuery = filteredQuery.Where(x => criteria.AIGenerationOptions.Contains((AIGeneration)x.VoiceWork.AIGeneration));
@@ -133,6 +140,19 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
break; break;
} }
//switch (criteria.CircleStatus)
//{
// case CircleStatus.NotBlacklisted:
// query = query.Where(x => x.Circle.Blacklisted == false);
// break;
// case CircleStatus.Favorited:
// query = query.Where(x => x.Circle.Favorite);
// break;
// case CircleStatus.Blacklisted:
// query = query.Where(x => x.Circle.Blacklisted);
// break;
//}
return query; return query;
} }
@@ -314,13 +334,15 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
protected override IEnumerable<(Expression<Func<VoiceWorkQuery, object>> Selector, SortDirection Dir)> GetDefaultSortChain() protected override IEnumerable<(Expression<Func<VoiceWorkQuery, object>> Selector, SortDirection Dir)> GetDefaultSortChain()
{ {
yield return (x => x.VoiceWork.ProductId, SortDirection.Ascending); yield return (x => x.VoiceWork.ProductId, SortDirection.Descending);
} }
protected override Expression<Func<VoiceWorkQuery, object>> GetSortExpression(VoiceWorkSortField field) => field switch protected override Expression<Func<VoiceWorkQuery, object?>> GetSortExpression(VoiceWorkSortField field) => field switch
{ {
VoiceWorkSortField.ScheduledReleaseDate => x => x.VoiceWork.PlannedReleaseDate ?? x.VoiceWork.PlannedReleaseDate ?? DateTime.MinValue, VoiceWorkSortField.ExpectedReleaseDate => x => x.VoiceWork.ExpectedDate,
VoiceWorkSortField.ReleaseDate => x => x.VoiceWork.SalesDate ?? x.VoiceWork.ExpectedDate ?? DateTime.MinValue, VoiceWorkSortField.ScheduledReleaseDate => x => x.VoiceWork.PlannedReleaseDate,
VoiceWorkSortField.ReleaseDate => x => x.VoiceWork.SalesDate,
VoiceWorkSortField.AnyReleaseDate => x => x.VoiceWork.SalesDate ?? x.VoiceWork.PlannedReleaseDate ?? x.VoiceWork.ExpectedDate,
VoiceWorkSortField.Downloads => x => x.VoiceWork.Downloads ?? 0, VoiceWorkSortField.Downloads => x => x.VoiceWork.Downloads ?? 0,
VoiceWorkSortField.WishlistCount => x => x.VoiceWork.WishlistCount ?? 0, VoiceWorkSortField.WishlistCount => x => x.VoiceWork.WishlistCount ?? 0,
VoiceWorkSortField.StarRating => x => x.VoiceWork.StarRating ?? 0, VoiceWorkSortField.StarRating => x => x.VoiceWork.StarRating ?? 0,
@@ -328,6 +350,11 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
_ => x => x.VoiceWork.ProductId _ => x => x.VoiceWork.ProductId
}; };
protected override IQueryable<int> GetSelectIdQuery(IOrderedQueryable<VoiceWorkQuery> query)
{
return query.Select(x => x.VoiceWork.VoiceWorkId);
}
protected override IQueryable<VoiceWorkSearchResult> GetSelectQuery(IOrderedQueryable<VoiceWorkQuery> query) protected override IQueryable<VoiceWorkSearchResult> GetSelectQuery(IOrderedQueryable<VoiceWorkQuery> query)
{ {
var result = var result =
@@ -363,6 +390,43 @@ public class VoiceWorkSearchProvider(AppDbContext context, IVoiceWorkFullTextSea
return result; return result;
} }
protected override async Task<Dictionary<int, VoiceWorkSearchResult>> GetItems(int[] ids)
{
var result =
from voiceWork in context.VoiceWorks.AsNoTracking()
join englishVoiceWork in context.EnglishVoiceWorks.AsNoTracking() on voiceWork.VoiceWorkId equals englishVoiceWork.VoiceWorkId into ps
from englishVoiceWork in ps.DefaultIfEmpty()
join circle in context.Circles.AsNoTracking() on voiceWork.CircleId equals circle.CircleId into cs
from circle in cs.DefaultIfEmpty()
let productLinkPage = voiceWork.SalesDate.HasValue ? "work" : "announce"
where ids.Contains(voiceWork.VoiceWorkId)
select new VoiceWorkSearchResult()
{
VoiceWorkId = voiceWork.VoiceWorkId,
ProductId = voiceWork.ProductId,
OriginalProductId = voiceWork.OriginalProductId,
ProductName = englishVoiceWork != null ? englishVoiceWork.ProductName : voiceWork.ProductName,
ProductUrl = "http://www.dlsite.com/maniax/" + productLinkPage + "/=/product_id/" + voiceWork.ProductId + ".html",
Description = englishVoiceWork != null ? englishVoiceWork.Description : voiceWork.Description,
Favorite = voiceWork.Favorite,
HasImage = voiceWork.HasImage,
Maker = circle.Name,
MakerId = circle.MakerId,
ExpectedDate = voiceWork.ExpectedDate,
SalesDate = voiceWork.SalesDate,
PlannedReleaseDate = voiceWork.PlannedReleaseDate,
Downloads = voiceWork.Downloads,
WishlistCount = voiceWork.WishlistCount,
Status = voiceWork.Status,
SubtitleLanguage = voiceWork.SubtitleLanguage,
HasTrial = voiceWork.HasTrial,
HasChobit = voiceWork.HasChobit,
IsValid = voiceWork.IsValid
};
return await result.ToDictionaryAsync(x => x.VoiceWorkId);
}
protected override async Task PostLoadAsync(IList<VoiceWorkSearchResult> items, CancellationToken cancellationToken) protected override async Task PostLoadAsync(IList<VoiceWorkSearchResult> items, CancellationToken cancellationToken)
{ {
if (items.Count == 0) if (items.Count == 0)

View File

@@ -430,7 +430,8 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
{ {
SortOptions = SortOptions =
[ [
new(VoiceWorkSortField.ReleaseDate, SortDirection.Ascending) new(VoiceWorkSortField.AnyReleaseDate, SortDirection.Ascending),
new(VoiceWorkSortField.ProductId, SortDirection.Ascending)
] ]
}; };
@@ -448,7 +449,8 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
{ {
SortOptions = SortOptions =
[ [
new(VoiceWorkSortField.ReleaseDate, SortDirection.Descending) new(VoiceWorkSortField.AnyReleaseDate, SortDirection.Descending),
new(VoiceWorkSortField.ProductId, SortDirection.Ascending)
] ]
}; };
@@ -466,7 +468,8 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
{ {
SortOptions = SortOptions =
[ [
new(VoiceWorkSortField.Downloads, SortDirection.Ascending) new(VoiceWorkSortField.Downloads, SortDirection.Ascending),
new(VoiceWorkSortField.ProductId, SortDirection.Ascending)
] ]
}; };
@@ -484,7 +487,8 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
{ {
SortOptions = SortOptions =
[ [
new(VoiceWorkSortField.Downloads, SortDirection.Descending) new(VoiceWorkSortField.Downloads, SortDirection.Descending),
new(VoiceWorkSortField.ProductId, SortDirection.Ascending)
] ]
}; };
@@ -538,7 +542,8 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
{ {
SortOptions = SortOptions =
[ [
new(VoiceWorkSortField.StarRating, SortDirection.Ascending) new(VoiceWorkSortField.StarRating, SortDirection.Ascending),
new(VoiceWorkSortField.ProductId, SortDirection.Ascending)
] ]
}; };
@@ -556,7 +561,8 @@ public class VoiceWorkSearchProviderTests(VoiceWorkSearchProviderFixture fixture
{ {
SortOptions = SortOptions =
[ [
new(VoiceWorkSortField.StarRating, SortDirection.Descending) new(VoiceWorkSortField.StarRating, SortDirection.Descending),
new(VoiceWorkSortField.ProductId, SortDirection.Ascending)
] ]
}; };

View File

@@ -2,8 +2,8 @@
<div> <div>
<label>@IndexInfo</label> <label>@IndexInfo</label>
</div> </div>
<MudPagination ShowFirstButton="true" ShowLastButton="true" Count="@((int)Math.Ceiling((decimal)TotalItems / (decimal)PageSize))" Selected="@PageNumber" SelectedChanged="OnSelectedChanged" /> <MudPagination class="pager" ShowFirstButton="true" ShowLastButton="true" Count="@((int)Math.Ceiling((decimal)TotalItems / (decimal)PageSize))" Selected="@PageNumber" SelectedChanged="OnSelectedChanged" />
<div> <div class="page-sizes">
<MudSelect T="int" Value="PageSize" ValueChanged="OnPageSizeChanged" Dense="true" Variant="Variant.Outlined" Margin="Margin.Dense"> <MudSelect T="int" Value="PageSize" ValueChanged="OnPageSizeChanged" Dense="true" Variant="Variant.Outlined" Margin="Margin.Dense">
@foreach (int value in PageSizes) @foreach (int value in PageSizes)
{ {

View File

@@ -7,18 +7,20 @@
<JImage OverlayClass="j-voice-work-image-overlay" ImageClass="j-voice-work-image" Source="@ImageUrlProvider.GetImageUrl(Product, "main")"></JImage> <JImage OverlayClass="j-voice-work-image-overlay" ImageClass="j-voice-work-image" Source="@ImageUrlProvider.GetImageUrl(Product, "main")"></JImage>
</div> </div>
<div class="j-voice-work-content"> <div class="j-voice-work-content">
<div class="j-product-title">@Product.ProductName</div> <div class="j-product-title">
<a href="@Product.ProductUrl" target="_blank">@Product.ProductName</a>
</div>
<div class="j-product-contributors"> <div class="j-product-contributors">
<span class="j-circle"> <span class="j-circle">
<MudChip T="string" <MudChip T="string"
Href="https://github.com/MudBlazor/MudBlazor" Href=@($"https://www.dlsite.com/maniax/circle/profile/=/maker_id/{Product.MakerId}.html")
Target="_blank" Target="_blank"
Variant="Variant.Filled" Variant="Variant.Filled"
Icon="@Icons.Material.Outlined.Circle">@Product.Maker</MudChip> Icon="@Icons.Material.Outlined.Circle">@Product.Maker</MudChip>
@foreach (var creator in Product.Creators) @foreach (var creator in Product.Creators)
{ {
<MudChip T="string" <MudChip T="string"
Href="https://github.com/MudBlazor/MudBlazor" Href=@($"https://www.dlsite.com/maniax/fsr/=/keyword_creater/{creator.Name}")
Target="_blank" Target="_blank"
Variant="Variant.Filled" Variant="Variant.Filled"
Icon="@Icons.Material.Filled.Person">@creator.Name</MudChip> Icon="@Icons.Material.Filled.Person">@creator.Name</MudChip>
@@ -36,7 +38,7 @@
<div class="j-voice-work-info"> <div class="j-voice-work-info">
<div class="j-release-date-container"> <div class="j-release-date-container">
<span class="j-icon j-icon-calendar"></span> <span class="j-icon j-icon-calendar"></span>
<span>@GetSomething(Product)</span> <span>@GetReleaseDateText(Product)</span>
</div> </div>
<div class="j-wishlist-container"> <div class="j-wishlist-container">
<span class="j-icon j-icon-star j-icon-color-yellow"></span> <span class="j-icon j-icon-star j-icon-color-yellow"></span>
@@ -49,6 +51,14 @@
<span>@((Product.Downloads ?? 0).ToString("n0"))</span> <span>@((Product.Downloads ?? 0).ToString("n0"))</span>
</div> </div>
} }
@* <div class="j-icon-2 j-icon-2-flag-@GetFlagClassSuffix(Product)"></div> *@
<div class="j-spacer"></div>
@if (Product.HasTrial || Product.HasChobit)
{
<div class="j-trial-container">
<div class="j-icon j-icon-headphones"></div>
</div>
}
</div> </div>
</div> </div>
@@ -56,7 +66,7 @@
[Parameter] [Parameter]
public required VoiceWorkSearchResult Product { get; set; } public required VoiceWorkSearchResult Product { get; set; }
private string GetSomething(VoiceWorkSearchResult voiceWork) private string GetReleaseDateText(VoiceWorkSearchResult voiceWork)
{ {
if (voiceWork.SalesDate.HasValue) if (voiceWork.SalesDate.HasValue)
{ {
@@ -82,4 +92,19 @@
return "Unknown"; return "Unknown";
} }
private string GetFlagClassSuffix(VoiceWorkSearchResult voiceWork)
{
switch (voiceWork.SubtitleLanguage)
{
case 1:
return "us";
case 2:
return "cn";
case 3:
return "kr";
default:
return "jp";
}
}
} }

View File

@@ -18,67 +18,6 @@
</MudTabPanel> </MudTabPanel>
</MudTabs> </MudTabs>
<style>
.j-product-items-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.j-voice-work-card {
display: flex;
gap: 1rem;
background-image: linear-gradient(0deg, rgb(30, 53, 69), rgb(39, 59, 73));
border-color: rgb(63, 78, 88);
background-image: linear-gradient(0deg, rgb(30, 53, 69), rgb(57, 79, 94));
}
.j-voice-work-image-container {
width: 240px;
width: 300px;
}
.j-voice-work-card > .j-voice-work-image-container {
flex-shrink: 0;
}
.j-voice-work-image {
border-radius: 20px;
}
.j-voice-work-content {
display: flex;
flex-direction: column;
gap: .5rem;
}
.j-voice-work-card > .j-voice-work-content {
flex-grow: 1;
}
.j-product-title {
font-size: 1.25rem;
font-weight: 600;
font-family: "Poppins", "M+ 1p";
color: #d2dce6;
text-shadow: 1px 1px 2px black;
}
.j-product-description {
/* color: #7C8099; */
font-size: 1rem;
font-family: "Poppins", "M+ 1p";
}
.j-voice-work-info {
width: 240px;
}
.j-voice-work-card > .j-voice-work-info {
flex-shrink: 0;
}
</style>
@code { @code {
VoiceWorkSearchResult[]? availableVoiceWorks; VoiceWorkSearchResult[]? availableVoiceWorks;
VoiceWorkSearchResult[]? upcomingVoiceWorks; VoiceWorkSearchResult[]? upcomingVoiceWorks;

View File

@@ -14,6 +14,39 @@
<div class="search-filter-control-span-4"> <div class="search-filter-control-span-4">
<MudTextField T="string" Value="Keywords" ValueChanged="OnKeywordsChanged" Immediate="true" DebounceInterval="500" Label="Filter" Variant="Variant.Text" Adornment="@Adornment.Start" AdornmentIcon="@Icons.Material.Outlined.Search" /> <MudTextField T="string" Value="Keywords" ValueChanged="OnKeywordsChanged" Immediate="true" DebounceInterval="500" Label="Filter" Variant="Variant.Text" Adornment="@Adornment.Start" AdornmentIcon="@Icons.Material.Outlined.Search" />
</div> </div>
<div class="search-filter-control-span-1">
<MudSelect T="string" Value="SelectedSaleStatus" ValueChanged="OnSaleStatusChanged" Label="Sale Status" Variant="Variant.Text">
<MudSelectItem Value="@SaleStatus.Available.ToString()">Available</MudSelectItem>
<MudSelectItem Value="@SaleStatus.Upcoming.ToString()">Upcoming</MudSelectItem>
<MudSelectItem Value="@String.Empty">All</MudSelectItem>
</MudSelect>
</div>
<div class="search-filter-control-span-1">
<MudSelect T="string" Value="SelectedCircleStatus" ValueChanged="OnCircleStatusChanged" Label="Circle Status" Variant="Variant.Text">
<MudSelectItem Value="@CircleStatus.NotBlacklisted.ToString()">Not Blacklisted</MudSelectItem>
<MudSelectItem Value="@CircleStatus.Favorited.ToString()">Favorite</MudSelectItem>
<MudSelectItem Value="@CircleStatus.Blacklisted.ToString()">Blacklisted</MudSelectItem>
<MudSelectItem Value="@String.Empty">All</MudSelectItem>
</MudSelect>
</div>
<div class="search-filter-control-span-1">
<MudSelect T="string" Value="SelectedTagStatus" ValueChanged="OnTagStatusChanged" Label="Tag Status" Dense="true" Variant="Variant.Outlined" Margin="Margin.Dense" Adornment="@Adornment.Start" AdornmentIcon="@Icons.Material.Outlined.Search">
<MudSelectItem Value="@TagStatus.NotBlacklisted.ToString()">Not Blacklisted</MudSelectItem>
<MudSelectItem Value="@TagStatus.FavoriteExcludeBlacklist.ToString()">Favorite (Exclude Blacklisted)</MudSelectItem>
<MudSelectItem Value="@TagStatus.FavoriteIncludeBlacklist.ToString()">Favorite (Include Blacklisted)</MudSelectItem>
<MudSelectItem Value="@TagStatus.Blacklisted.ToString()">Blacklisted</MudSelectItem>
<MudSelectItem Value="@String.Empty">All</MudSelectItem>
</MudSelect>
</div>
<div class="search-filter-control-span-1">
<MudSelect T="string" Value="SelectedCreatorStatus" ValueChanged="OnCreatorStatusChanged" Label="Creator Status" Dense="true" Variant="Variant.Outlined" Margin="Margin.Dense" Adornment="@Adornment.Start" AdornmentIcon="@Icons.Material.Outlined.Search">
<MudSelectItem Value="@CreatorStatus.NotBlacklisted.ToString()">Not Blacklisted</MudSelectItem>
<MudSelectItem Value="@CreatorStatus.FavoriteExcludeBlacklist.ToString()">Favorite (Exclude Blacklisted)</MudSelectItem>
<MudSelectItem Value="@CreatorStatus.FavoriteIncludeBlacklist.ToString()">Favorite (Include Blacklisted)</MudSelectItem>
<MudSelectItem Value="@CreatorStatus.Blacklisted.ToString()">Blacklisted</MudSelectItem>
<MudSelectItem Value="@String.Empty">All</MudSelectItem>
</MudSelect>
</div>
</div> </div>
<JProductCollection Products="searchResults?.Items"></JProductCollection> <JProductCollection Products="searchResults?.Items"></JProductCollection>
@@ -25,6 +58,10 @@
@code { @code {
public string? Keywords { get; set; } public string? Keywords { get; set; }
public string? SelectedSaleStatus { get; set; } = string.Empty;
public string? SelectedCircleStatus { get; set; } = string.Empty;
public string? SelectedTagStatus { get; set; } = string.Empty;
public string? SelectedCreatorStatus { get; set; } = string.Empty;
public int PageNumber { get; set; } = 1; public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 100; public int PageSize { get; set; } = 100;
@@ -48,11 +85,15 @@
Criteria = new() Criteria = new()
{ {
Keywords = Keywords, Keywords = Keywords,
SupportedLanguages = [Domain.Enums.Language.English] SaleStatus = string.IsNullOrWhiteSpace(SelectedSaleStatus) == false ? Enum.Parse<SaleStatus>(SelectedSaleStatus) : null,
CircleStatus = string.IsNullOrWhiteSpace(SelectedCircleStatus) == false ? Enum.Parse<CircleStatus>(SelectedCircleStatus) : null,
TagStatus = string.IsNullOrWhiteSpace(SelectedTagStatus) == false ? Enum.Parse<TagStatus>(SelectedTagStatus) : null,
CreatorStatus = string.IsNullOrWhiteSpace(SelectedCreatorStatus) == false ? Enum.Parse<CreatorStatus>(SelectedCreatorStatus) : null,
//SupportedLanguages = [Domain.Enums.Language.English]
}, },
SortOptions = SortOptions =
[ [
new(VoiceWorkSortField.ReleaseDate, Application.Common.Search.SortDirection.Descending) new(GetSortField(), Application.Common.Search.SortDirection.Descending)
], ],
PageNumber = PageNumber, PageNumber = PageNumber,
PageSize = PageSize PageSize = PageSize
@@ -70,6 +111,30 @@
await UpdateDataAsync(true); await UpdateDataAsync(true);
} }
public async Task OnSaleStatusChanged(string? saleStatus)
{
SelectedSaleStatus = saleStatus;
await UpdateDataAsync(true);
}
public async Task OnCircleStatusChanged(string? circleStatus)
{
SelectedCircleStatus = circleStatus;
await UpdateDataAsync(true);
}
public async Task OnTagStatusChanged(string? tagStatus)
{
SelectedTagStatus = tagStatus;
await UpdateDataAsync(true);
}
public async Task OnCreatorStatusChanged(string? creatorStatus)
{
SelectedCreatorStatus = creatorStatus;
await UpdateDataAsync(true);
}
public async Task OnPageNumberChanged(int newPageNumber) public async Task OnPageNumberChanged(int newPageNumber)
{ {
PageNumber = newPageNumber; PageNumber = newPageNumber;
@@ -81,4 +146,19 @@
PageSize = newPageSize; PageSize = newPageSize;
await UpdateDataAsync(true); await UpdateDataAsync(true);
} }
private VoiceWorkSortField GetSortField()
{
SaleStatus? saleStatus = string.IsNullOrWhiteSpace(SelectedSaleStatus) == false ? Enum.Parse<SaleStatus>(SelectedSaleStatus) : null;
switch (saleStatus)
{
case SaleStatus.Available:
return VoiceWorkSortField.ReleaseDate;
case SaleStatus.Upcoming:
return VoiceWorkSortField.ExpectedReleaseDate;
default:
return VoiceWorkSortField.AnyReleaseDate;
}
}
} }

View File

@@ -200,6 +200,20 @@ code {
z-index: 1; z-index: 1;
} }
.pagination .pager {
padding: 0;
justify-content: center;
}
.pagination > .page-sizes {
justify-content: flex-end;
display: inline-flex;
}
.pagination > .page-sizes > * {
max-width: 6rem;
}
/* Circle */ /* Circle */
.j-circle-image-container { .j-circle-image-container {
height: 300px; height: 300px;
@@ -330,8 +344,12 @@ code {
width: 100%; width: 100%;
} }
/* Product */ /* Spacer */
.j-spacer {
flex-grow: 1;
}
/* Product */
.j-product-items-container { .j-product-items-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -344,6 +362,9 @@ code {
background-image: linear-gradient(0deg, rgb(30, 53, 69), rgb(39, 59, 73)); background-image: linear-gradient(0deg, rgb(30, 53, 69), rgb(39, 59, 73));
border-color: rgb(63, 78, 88); border-color: rgb(63, 78, 88);
background-image: linear-gradient(0deg, rgb(30, 53, 69), rgb(57, 79, 94)); background-image: linear-gradient(0deg, rgb(30, 53, 69), rgb(57, 79, 94));
border-top-color: rgb(83, 99, 109);
border-left-color: rgb(72, 88, 99);
border-right-color: rgb(72, 88, 99);
} }
.j-voice-work-image-container { .j-voice-work-image-container {
@@ -381,6 +402,15 @@ code {
text-shadow: 1px 1px 2px black; text-shadow: 1px 1px 2px black;
} }
.j-product-title > a,
.j-product-title > a:hover {
color: var(--product-title-text-color);
}
.j-product-title > a:hover {
text-decoration: underline;
}
.j-product-contributors { .j-product-contributors {
font-size: 1rem; font-size: 1rem;
font-family: "Poppins", "M+ 1p"; font-family: "Poppins", "M+ 1p";
@@ -437,6 +467,19 @@ code {
gap: .5rem; gap: .5rem;
font-size: 1rem; font-size: 1rem;
font-weight: 500; font-weight: 500;
color: #afe07d;
}
.j-trial-container {
border: 1px solid rgb(30, 53, 69);
padding: .5rem;
border-radius: 100%;
border-color: var(--product-title-text-color);
}
.j-trial-container > .j-icon {
width: 16px;
height: 16px;
} }
/* Tags */ /* Tags */
@@ -461,7 +504,9 @@ code {
/* Icons */ /* Icons */
.j-icon { .j-icon {
mask-size: auto; mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
align-self: center; align-self: center;
background: rgb(180,200, 214); background: rgb(180,200, 214);
height: 16px; height: 16px;
@@ -474,6 +519,7 @@ code {
.j-icon-color-green { .j-icon-color-green {
background: #388E3C; background: #388E3C;
background: #afe07d;
} }
.j-icon-calendar { .j-icon-calendar {
@@ -490,4 +536,32 @@ code {
.j-icon-bag-fill { .j-icon-bag-fill {
mask-image: url("../svg/bag-fill.svg"); mask-image: url("../svg/bag-fill.svg");
}
.j-icon-headphones {
mask-image: url("../svg/headphones.svg");
}
.j-icon-2 {
background-repeat: no-repeat;
background-position: center;
background-size: contain;
width: 32px;
height: 32px;
}
.j-icon-2-flag-jp {
background-image: url("../svg/flag-jp.svg");
}
.j-icon-2-flag-us {
background-image: url("../svg/flag-us.svg");
}
.j-icon-2-flag-cn {
background-image: url("../svg/flag-cn.svg");
}
.j-icon-2-flag-kr {
background-image: url("../svg/flag-kr.svg");
} }