Compare commits

..

11 Commits

Author SHA1 Message Date
53f1df780c Updated packages.
All checks were successful
ci / build-test (push) Successful in 2m43s
ci / publish-image (push) Successful in 2m46s
2026-05-14 10:20:38 -04:00
53ec67f99d Updated command timeout length to 120 seconds for worker and API. 2026-05-14 10:19:43 -04:00
0ed3bc6298 Updated voice work delete logic + tests. 2026-05-14 10:19:06 -04:00
8a13f282b1 Updated packages.
Some checks failed
ci / build-test (push) Failing after 12m52s
ci / publish-image (push) Has been cancelled
2026-05-13 10:03:35 -04:00
5c27fb7f21 Updated login page background.
All checks were successful
ci / build-test (push) Successful in 2m33s
ci / publish-image (push) Successful in 1m51s
2026-05-10 20:58:57 -04:00
06d5aa345d Finalized delete voice work logic.
All checks were successful
ci / build-test (push) Successful in 2m45s
ci / publish-image (push) Successful in 1m54s
2026-05-09 22:19:44 -04:00
5eecba7eec Updated delete logic for voice works.
All checks were successful
ci / build-test (push) Successful in 3m1s
ci / publish-image (push) Successful in 1m58s
2026-05-09 00:51:10 -04:00
9c9e33ebec Added initial voice work edit logic (set favorite / delete) on Api and UI layers.
All checks were successful
ci / build-test (push) Successful in 2m44s
ci / publish-image (push) Successful in 1m45s
2026-05-07 00:07:20 -04:00
2bd7e3b970 Updated login page.
All checks were successful
ci / build-test (push) Successful in 2m29s
ci / publish-image (push) Successful in 1m44s
2026-05-04 01:52:35 -04:00
abcc82437f Updated logic for getting released work information (take 60 day period max limit into consideration). 2026-05-04 01:52:25 -04:00
f6674e0382 Added "Delete Voice Works" functionality. 2026-05-04 01:51:40 -04:00
38 changed files with 1130 additions and 119 deletions

View File

@@ -8,11 +8,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Serilog" Version="4.3.1" /> <PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" /> <PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -22,7 +22,10 @@ public static class ServiceCollectionExtensions
?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb"); ?? throw new InvalidOperationException("Missing ConnectionStrings:AppDb");
services.AddDbContextFactory<AppDbContext>(opt => services.AddDbContextFactory<AppDbContext>(opt =>
opt.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)) opt.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString), mySqlOptions =>
{
mySqlOptions.CommandTimeout(120);
})
.EnableSensitiveDataLogging(false)); .EnableSensitiveDataLogging(false));
services.AddControllers(); services.AddControllers();

View File

@@ -5,6 +5,8 @@ using JSMR.Application.Tags.Commands.SetEnglishName;
using JSMR.Application.Tags.Commands.UpdateTagStatus; using JSMR.Application.Tags.Commands.UpdateTagStatus;
using JSMR.Application.Tags.Queries.Search; using JSMR.Application.Tags.Queries.Search;
using JSMR.Application.Users; using JSMR.Application.Users;
using JSMR.Application.VoiceWorks.Commands.Delete;
using JSMR.Application.VoiceWorks.Commands.SetFavorite;
using JSMR.Application.VoiceWorks.Queries.Search; using JSMR.Application.VoiceWorks.Queries.Search;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
@@ -51,6 +53,23 @@ public static class WebApplicationExtensions
} }
}); });
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await Results.Problem(
title: "An unexpected error occurred.",
detail: app.Environment.IsDevelopment()
? "Check the API logs for details."
: null,
statusCode: StatusCodes.Status500InternalServerError
).ExecuteAsync(context);
});
});
return app; return app;
} }
@@ -59,6 +78,7 @@ public static class WebApplicationExtensions
app.MapGet("/health", () => Results.Ok(new { status = "ok" })); app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapSearchEndpoints(); app.MapSearchEndpoints();
app.MapVoiceWorkCommandEndpoints();
app.MapTagCommandEndpoints(); app.MapTagCommandEndpoints();
app.MapCreatorCommandEndpoints(); app.MapCreatorCommandEndpoints();
app.MapAuthenticationEndpoints(); app.MapAuthenticationEndpoints();
@@ -110,6 +130,27 @@ public static class WebApplicationExtensions
}); });
} }
private static void MapVoiceWorkCommandEndpoints(this WebApplication app)
{
app.MapPost("/api/voicework/set-favorite", async (
SetVoiceWorkFavoriteRequest request,
SetVoiceWorkFavoriteHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
});
app.MapPost("/api/voicework/delete", async (
DeleteVoiceWorkRequest request,
DeleteVoiceWorkHandler handler,
CancellationToken ct) =>
{
var result = await handler.HandleAsync(request, ct);
return Results.Ok(result);
});
}
private static void MapTagCommandEndpoints(this WebApplication app) private static void MapTagCommandEndpoints(this WebApplication app)
{ {
app.MapPost("/api/tags/update-status", async ( app.MapPost("/api/tags/update-status", async (

View File

@@ -5,6 +5,8 @@ using JSMR.Application.Scanning;
using JSMR.Application.Tags.Commands.SetEnglishName; using JSMR.Application.Tags.Commands.SetEnglishName;
using JSMR.Application.Tags.Commands.UpdateTagStatus; using JSMR.Application.Tags.Commands.UpdateTagStatus;
using JSMR.Application.Tags.Queries.Search; using JSMR.Application.Tags.Queries.Search;
using JSMR.Application.VoiceWorks.Commands.Delete;
using JSMR.Application.VoiceWorks.Commands.SetFavorite;
using JSMR.Application.VoiceWorks.Queries.Search; using JSMR.Application.VoiceWorks.Queries.Search;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -18,6 +20,9 @@ public static class ApplicationServiceCollectionExtensions
services.AddScoped<SearchCirclesHandler>(); services.AddScoped<SearchCirclesHandler>();
services.AddScoped<SearchVoiceWorksHandler>(); services.AddScoped<SearchVoiceWorksHandler>();
services.AddScoped<SetVoiceWorkFavoriteHandler>();
services.AddScoped<DeleteVoiceWorkHandler>();
services.AddScoped<ScanVoiceWorksHandler>(); services.AddScoped<ScanVoiceWorksHandler>();
services.AddScoped<SearchTagsHandler>(); services.AddScoped<SearchTagsHandler>();

View File

@@ -11,8 +11,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -4,11 +4,8 @@ namespace JSMR.Application.Scanning.Contracts;
public class DLSiteWork public class DLSiteWork
{ {
//public DLSiteWorkType Type { get; set; }
//public DLSiteWorkCategory Category { get; set; }
public required string ProductName { get; set; } public required string ProductName { get; set; }
public required string ProductId { get; set; } public required string ProductId { get; set; }
//public DateOnly? AnnouncedDate { get; set; }
public DateOnly? ExpectedDate { get; set; } public DateOnly? ExpectedDate { get; set; }
public DateOnly? SalesDate { get; set; } public DateOnly? SalesDate { get; set; }
public int Downloads { get; set; } public int Downloads { get; set; }

View File

@@ -0,0 +1,11 @@
using JSMR.Application.VoiceWorks.Ports;
namespace JSMR.Application.VoiceWorks.Commands.Delete;
public sealed class DeleteVoiceWorkHandler(IVoiceWorkWriter writer)
{
public async Task<DeleteVoiceWorkResponse> HandleAsync(DeleteVoiceWorkRequest request, CancellationToken cancellationToken = default)
{
return await writer.DeleteAsync(request, cancellationToken);
}
}

View File

@@ -0,0 +1,3 @@
namespace JSMR.Application.VoiceWorks.Commands.Delete;
public sealed record DeleteVoiceWorkRequest(int[] VoiceWorkIds);

View File

@@ -0,0 +1,3 @@
namespace JSMR.Application.VoiceWorks.Commands.Delete;
public sealed record DeleteVoiceWorkResponse(Dictionary<int, DeleteVoiceWorkStatus> Results);

View File

@@ -0,0 +1,9 @@
namespace JSMR.Application.VoiceWorks.Commands.Delete;
public enum DeleteVoiceWorkStatus
{
Deleted,
NotFound,
NotAllowed,
Failed
}

View File

@@ -1,8 +1,10 @@
using JSMR.Application.VoiceWorks.Commands.SetFavorite; using JSMR.Application.VoiceWorks.Commands.Delete;
using JSMR.Application.VoiceWorks.Commands.SetFavorite;
namespace JSMR.Application.VoiceWorks.Ports; namespace JSMR.Application.VoiceWorks.Ports;
public interface IVoiceWorkWriter public interface IVoiceWorkWriter
{ {
Task<SetVoiceWorkFavoriteResponse> SetFavoriteAsync(SetVoiceWorkFavoriteRequest request, CancellationToken cancellationToken); Task<SetVoiceWorkFavoriteResponse> SetFavoriteAsync(SetVoiceWorkFavoriteRequest request, CancellationToken cancellationToken);
Task<DeleteVoiceWorkResponse> DeleteAsync(DeleteVoiceWorkRequest request, CancellationToken cancellationToken);
} }

View File

@@ -4,29 +4,29 @@ namespace JSMR.Application.VoiceWorks.Queries.Search;
public record VoiceWorkSearchResult public record VoiceWorkSearchResult
{ {
public int VoiceWorkId { get; init; } public int VoiceWorkId { get; set; }
public required string ProductId { get; init; } public required string ProductId { get; set; }
public string? OriginalProductId { get; init; } public string? OriginalProductId { get; set; }
public string? Description { get; init; } public string? Description { get; set; }
public required string ProductName { get; init; } public required string ProductName { get; set; }
public required string ProductUrl { get; init; } public required string ProductUrl { get; set; }
public bool HasImage { get; init; } public bool HasImage { get; set; }
public required string Maker { get; init; } public required string Maker { get; set; }
public required string MakerId { get; init; } public required string MakerId { get; set; }
public DateTime? ExpectedDate { get; init; } public DateTime? ExpectedDate { get; set; }
public DateTime? SalesDate { get; init; } public DateTime? SalesDate { get; set; }
public DateTime? PlannedReleaseDate { get; init; } public DateTime? PlannedReleaseDate { get; set; }
public int? Downloads { get; init; } public int? Downloads { get; set; }
public int? WishlistCount { get; init; } public int? WishlistCount { get; set; }
public byte? StarRating { get; init; } public byte? StarRating { get; set; }
public int? Votes { get; init; } public int? Votes { get; set; }
public bool HasTrial { get; init; } public bool HasTrial { get; set; }
public bool HasChobit { get; init; } public bool HasChobit { get; set; }
public AgeRating Rating { get; init; } public AgeRating Rating { get; set; }
public bool Favorite { get; init; } public bool Favorite { get; set; }
public byte Status { get; init; } public byte Status { get; set; }
public byte SubtitleLanguage { get; init; } public byte SubtitleLanguage { get; set; }
public bool? IsValid { get; init; } public bool? IsValid { get; set; }
public required VoiceWorkCircleItem Circle { get; set; } public required VoiceWorkCircleItem Circle { get; set; }
public VoiceWorkCircleItem? OriginalCircle { get; set; } public VoiceWorkCircleItem? OriginalCircle { get; set; }
public VoiceWorkTagItem[] Tags { get; set; } = []; public VoiceWorkTagItem[] Tags { get; set; } = [];

View File

@@ -1,4 +1,5 @@
using JSMR.Application.VoiceWorks.Commands.SetFavorite; using JSMR.Application.VoiceWorks.Commands.Delete;
using JSMR.Application.VoiceWorks.Commands.SetFavorite;
using JSMR.Application.VoiceWorks.Ports; using JSMR.Application.VoiceWorks.Ports;
using JSMR.Domain.Entities; using JSMR.Domain.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -17,6 +18,57 @@ public class VoiceWorkWriter(AppDbContext dbContext) : IVoiceWorkWriter
return new SetVoiceWorkFavoriteResponse(request.VoiceWorkId, request.IsFavorite); return new SetVoiceWorkFavoriteResponse(request.VoiceWorkId, request.IsFavorite);
} }
public async Task<DeleteVoiceWorkResponse> DeleteAsync(DeleteVoiceWorkRequest request, CancellationToken cancellationToken)
{
Dictionary<int, DeleteVoiceWorkStatus> results = request.VoiceWorkIds.Select(x => x)
.ToDictionary(x => x, x => DeleteVoiceWorkStatus.NotFound);
VoiceWork[] voiceWorks = await dbContext.VoiceWorks
.Where(voiceWork => request.VoiceWorkIds.Contains(voiceWork.VoiceWorkId))
.Include(x => x.Circle)
.ToArrayAsync(cancellationToken);
List<VoiceWork> voiceWorksToDelete = [];
foreach (VoiceWork voiceWork in voiceWorks)
{
if (results.ContainsKey(voiceWork.VoiceWorkId) == false)
{
results[voiceWork.VoiceWorkId] = DeleteVoiceWorkStatus.NotFound;
continue;
}
if (voiceWork.Circle is null)
{
results[voiceWork.VoiceWorkId] = DeleteVoiceWorkStatus.NotFound;
continue;
}
if (voiceWork.IsValid == true && voiceWork.Circle.Spam == false)
{
results[voiceWork.VoiceWorkId] = DeleteVoiceWorkStatus.NotAllowed;
continue;
}
voiceWorksToDelete.Add(voiceWork);
results[voiceWork.VoiceWorkId] = DeleteVoiceWorkStatus.Deleted;
}
int[] voiceWorkIdsToDelete = [.. voiceWorksToDelete.Select(x => x.VoiceWorkId)];
if (voiceWorkIdsToDelete.Length > 0)
{
dbContext.VoiceWorks.RemoveRange(voiceWorksToDelete);
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.VoiceWorkSearches
.Where(x => voiceWorkIdsToDelete.Contains(x.VoiceWorkId))
.ExecuteDeleteAsync(cancellationToken);
}
return new DeleteVoiceWorkResponse(results);
}
private async Task<VoiceWork> GetVoiceWorkAsync(int voiceWorkId, CancellationToken cancellationToken) private async Task<VoiceWork> GetVoiceWorkAsync(int voiceWorkId, CancellationToken cancellationToken)
{ {
return await dbContext.VoiceWorks.FirstOrDefaultAsync(voiceWork => voiceWork.VoiceWorkId == voiceWorkId, cancellationToken) return await dbContext.VoiceWorks.FirstOrDefaultAsync(voiceWork => voiceWork.VoiceWorkId == voiceWorkId, cancellationToken)

View File

@@ -15,16 +15,16 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" /> <PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.5.0" /> <PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.6.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="NTextCat" Version="0.3.65" /> <PackageReference Include="NTextCat" Version="0.3.65" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />

View File

@@ -8,6 +8,8 @@ namespace JSMR.Infrastructure.Scanning;
public class ReleasedWorksProvider(IDLSiteClient dlsiteClient) : IReleasedWorksProvider public class ReleasedWorksProvider(IDLSiteClient dlsiteClient) : IReleasedWorksProvider
{ {
private const int MaxPeriodDays = 60;
public async Task<ReleasedWorksCollection> GetReleasedWorksAsync(VoiceWorkScanResult scanResult, CancellationToken cancellationToken) public async Task<ReleasedWorksCollection> GetReleasedWorksAsync(VoiceWorkScanResult scanResult, CancellationToken cancellationToken)
{ {
DateOnly[] salesDates = DateOnly[] salesDates =
@@ -20,20 +22,72 @@ public class ReleasedWorksProvider(IDLSiteClient dlsiteClient) : IReleasedWorksP
if (salesDates.Length == 0) if (salesDates.Length == 0)
return []; return [];
HashSet<string> productIds = [.. scanResult.Works.Select(x => x.ProductId)];
DateOnly minDate = salesDates.Min(); DateOnly minDate = salesDates.Min();
DateOnly maxDate = salesDates.Max(); DateOnly maxDate = salesDates.Max();
DateOnly requestDate = minDate.AddDays(-1); ReleasedWorksCollection collection = [];
DateOnly requestEndDate = maxDate.AddDays(1);
int period = (requestEndDate.DayNumber - requestDate.DayNumber) + 1; DateOnly chunkStart = minDate;
ReleasedWorksRequest releasedWorksRequest = new( while (chunkStart <= maxDate)
{
int endDayNumber = Math.Min(chunkStart.DayNumber + MaxPeriodDays - 1, maxDate.DayNumber);
DateOnly chunkEnd = DateOnly.FromDayNumber(endDayNumber);
int period = chunkEnd.DayNumber - chunkStart.DayNumber + 1;
ReleasedWorksRequest request = new(
Locale: Locale.English, Locale: Locale.English,
Date: requestEndDate, Date: chunkEnd,
Period: period Period: period);
);
return await dlsiteClient.GetReleasedWorksAsync(releasedWorksRequest, cancellationToken); ReleasedWorksCollection chunk = await dlsiteClient.GetReleasedWorksAsync(request, cancellationToken);
foreach (string productId in chunk.Keys)
{
if (productIds.Contains(productId) == false)
continue;
if (collection.ContainsKey(productId))
continue;
collection.Add(productId, chunk[productId]);
} }
chunkStart = chunkEnd.AddDays(1);
}
return collection;
}
//public async Task<ReleasedWorksCollection> GetReleasedWorksAsync(VoiceWorkScanResult scanResult, CancellationToken cancellationToken)
//{
// DateOnly[] salesDates =
// [
// .. scanResult.Works
// .Where(x => x.SalesDate.HasValue)
// .Select(x => x.SalesDate!.Value)
// ];
// if (salesDates.Length == 0)
// return [];
// DateOnly minDate = salesDates.Min();
// DateOnly maxDate = salesDates.Max();
// DateOnly requestDate = minDate.AddDays(-1);
// DateOnly requestEndDate = maxDate.AddDays(1);
// int period = (requestEndDate.DayNumber - requestDate.DayNumber) + 1;
// ReleasedWorksRequest releasedWorksRequest = new(
// Locale: Locale.English,
// Date: requestEndDate,
// Period: period
// );
// return await dlsiteClient.GetReleasedWorksAsync(releasedWorksRequest, cancellationToken);
//}
} }

View File

@@ -0,0 +1,73 @@
using JSMR.Application.VoiceWorks.Commands.Delete;
using JSMR.Infrastructure.Data;
using JSMR.Infrastructure.Data.Repositories.VoiceWorks;
using JSMR.Tests.Fixtures;
using Shouldly;
namespace JSMR.Tests.Data.Repositories.VoiceWorks;
public class Delete_Voice_Work_Tests(MariaDbContainerFixture container) : VoiceWorkRepositoryTests(container)
{
[Fact]
public async Task Try_Delete_Invalid_Voice_Work()
{
await using AppDbContext dbContext = await GetAppDbContextAsync();
VoiceWorkWriter writer = new(dbContext);
int voiceWorkId = 3;
DeleteVoiceWorkRequest request = new([voiceWorkId]);
dbContext.VoiceWorks.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldNotBeNull();
dbContext.VoiceWorkSearches.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldNotBeNull();
DeleteVoiceWorkResponse response = await writer.DeleteAsync(request, TestContext.Current.CancellationToken);
response.Results.Count.ShouldBe(1);
response.Results.ShouldContainKey(voiceWorkId);
response.Results[voiceWorkId].ShouldBe(DeleteVoiceWorkStatus.Deleted);
dbContext.VoiceWorks.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldBeNull();
dbContext.VoiceWorkSearches.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldBeNull();
}
[Fact]
public async Task Try_Delete_Valid_Voice_Work()
{
await using AppDbContext dbContext = await GetAppDbContextAsync();
VoiceWorkWriter writer = new(dbContext);
int voiceWorkId = 1;
DeleteVoiceWorkRequest request = new([voiceWorkId]);
dbContext.VoiceWorks.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldNotBeNull();
dbContext.VoiceWorkSearches.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldNotBeNull();
DeleteVoiceWorkResponse response = await writer.DeleteAsync(request, TestContext.Current.CancellationToken);
response.Results.Count.ShouldBe(1);
response.Results.ShouldContainKey(voiceWorkId);
response.Results[voiceWorkId].ShouldBe(DeleteVoiceWorkStatus.NotAllowed);
dbContext.VoiceWorks.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldNotBeNull();
dbContext.VoiceWorkSearches.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldNotBeNull();
}
[Fact]
public async Task Try_Delete_Missing_Voice_Work()
{
await using AppDbContext dbContext = await GetAppDbContextAsync();
VoiceWorkWriter writer = new(dbContext);
int voiceWorkId = 999;
DeleteVoiceWorkRequest request = new([voiceWorkId]);
dbContext.VoiceWorks.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldBeNull();
dbContext.VoiceWorkSearches.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldBeNull();
DeleteVoiceWorkResponse response = await writer.DeleteAsync(request, TestContext.Current.CancellationToken);
response.Results.Count.ShouldBe(1);
response.Results.ShouldContainKey(voiceWorkId);
response.Results[voiceWorkId].ShouldBe(DeleteVoiceWorkStatus.NotFound);
dbContext.VoiceWorks.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldBeNull();
dbContext.VoiceWorkSearches.FirstOrDefault(x => x.VoiceWorkId == voiceWorkId).ShouldBeNull();
}
}

View File

@@ -19,11 +19,65 @@ public static class VoiceWorkRepositorySeedData
); );
context.VoiceWorks.AddRange( context.VoiceWorks.AddRange(
new() { VoiceWorkId = 1, CircleId = 1, ProductId = "RJ0000001", ProductName = "Today Sounds", Description = "An average product.", Status = (byte)VoiceWorkStatus.Available, SalesDate = new(2025, 1, 1), Downloads = 500, WishlistCount = 750, StarRating = 35 }, new() { VoiceWorkId = 1, CircleId = 1, ProductId = "RJ0000001", ProductName = "Today Sounds", Description = "An average product.", Status = (byte)VoiceWorkStatus.Available, SalesDate = new(2025, 1, 1), Downloads = 500, WishlistCount = 750, StarRating = 35, IsValid = true },
new() { VoiceWorkId = 2, CircleId = 2, ProductId = "RJ0000002", ProductName = "Super Comfy ASMR", Description = "An amazing product!", Status = (byte)VoiceWorkStatus.NewRelease, SalesDate = new(2025, 1, 3), Downloads = 5000, WishlistCount = 12000, StarRating = 50, Favorite = true }, new() { VoiceWorkId = 2, CircleId = 2, ProductId = "RJ0000002", ProductName = "Super Comfy ASMR", Description = "An amazing product!", Status = (byte)VoiceWorkStatus.NewRelease, SalesDate = new(2025, 1, 3), Downloads = 5000, WishlistCount = 12000, StarRating = 50, Favorite = true, IsValid = true },
new() { VoiceWorkId = 3, CircleId = 3, ProductId = "RJ0000003", ProductName = "Low Effort", Description = "A bad product.", Status = (byte)VoiceWorkStatus.Available, SalesDate = new(2025, 1, 2), Downloads = 50, WishlistCount = 100, StarRating = 20 }, new() { VoiceWorkId = 3, CircleId = 3, ProductId = "RJ0000003", ProductName = "Low Effort", Description = "A bad product.", Status = (byte)VoiceWorkStatus.Available, SalesDate = new(2025, 1, 2), Downloads = 50, WishlistCount = 100, StarRating = 20, IsValid = false },
new() { VoiceWorkId = 4, CircleId = 1, ProductId = "RJ0000004", ProductName = "Tomorrow Sounds", Description = "A average upcoming product.", Status = (byte)VoiceWorkStatus.Upcoming, ExpectedDate = new(2025, 1, 1), WishlistCount = 300 }, new() { VoiceWorkId = 4, CircleId = 1, ProductId = "RJ0000004", ProductName = "Tomorrow Sounds", Description = "A average upcoming product.", Status = (byte)VoiceWorkStatus.Upcoming, ExpectedDate = new(2025, 1, 1), WishlistCount = 300, IsValid = true },
new() { VoiceWorkId = 5, CircleId = 2, ProductId = "RJ0000005", ProductName = "Super Comfy ASMR+", Description = "All your favorite sounds, plus more!", Status = (byte)VoiceWorkStatus.NewAndUpcoming, ExpectedDate = new(2025, 1, 11), WishlistCount = 10000 } new() { VoiceWorkId = 5, CircleId = 2, ProductId = "RJ0000005", ProductName = "Super Comfy ASMR+", Description = "All your favorite sounds, plus more!", Status = (byte)VoiceWorkStatus.NewAndUpcoming, ExpectedDate = new(2025, 1, 11), WishlistCount = 10000, IsValid = true }
);
context.Creators.AddRange(
new() { CreatorId = 1, Name = "Average Creator" },
new() { CreatorId = 2, Name = "Good Creator" },
new() { CreatorId = 3, Name = "Bad Creator" }
);
context.VoiceWorkCreators.AddRange(
new() { VoiceWorkId = 1, CreatorId = 1 },
new() { VoiceWorkId = 2, CreatorId = 2 },
new() { VoiceWorkId = 3, CreatorId = 3 }
);
context.Tags.AddRange(
new() { TagId = 1, Name = "ASMR" },
new() { TagId = 2, Name = "Tsundere" },
new() { TagId = 3, Name = "Office Lady" }
);
context.VoiceWorkTags.AddRange(
new() { VoiceWorkId = 1, TagId = 1 },
new() { VoiceWorkId = 2, TagId = 2 },
new() { VoiceWorkId = 3, TagId = 3 }
);
context.VoiceWorkSupportedLanguages.AddRange(
new() { VoiceWorkSupportedLanguageId = 1, VoiceWorkId = 1, Language = "JPN" },
new() { VoiceWorkSupportedLanguageId = 2, VoiceWorkId = 2, Language = "JPN" },
new() { VoiceWorkSupportedLanguageId = 3, VoiceWorkId = 3, Language = "JPN" }
);
context.EnglishVoiceWorks.AddRange(
new() { EnglishVoiceWorkId = 1, VoiceWorkId = 1, ProductName = "Today Sounds", Description = "An average product." },
new() { EnglishVoiceWorkId = 2, VoiceWorkId = 2, ProductName = "Super Comfy ASMR", Description = "An amazing product!" },
new() { EnglishVoiceWorkId = 3, VoiceWorkId = 3, ProductName = "Low Effort", Description = "A bad product." },
new() { EnglishVoiceWorkId = 4, VoiceWorkId = 4, ProductName = "Tomorrow Sounds", Description = "A average upcoming product." },
new() { EnglishVoiceWorkId = 5, VoiceWorkId = 5, ProductName = "Super Comfy ASMR+", Description = "All your favorite sounds, plus more!" }
);
context.VoiceWorkLocalizations.AddRange(
new() { VoiceWorkLocalizationId = 1, VoiceWorkId = 1, Language = "JPN", ProductName = "Today Sounds", Description = "An average product." },
new() { VoiceWorkLocalizationId = 2, VoiceWorkId = 2, Language = "JPN", ProductName = "Super Comfy ASMR", Description = "An amazing product!" },
new() { VoiceWorkLocalizationId = 3, VoiceWorkId = 3, Language = "JPN", ProductName = "Low Effort", Description = "A bad product." },
new() { VoiceWorkLocalizationId = 4, VoiceWorkId = 4, Language = "JPN", ProductName = "Tomorrow Sounds", Description = "A average upcoming product." },
new() { VoiceWorkLocalizationId = 5, VoiceWorkId = 5, Language = "JPN", ProductName = "Super Comfy ASMR+", Description = "All your favorite sounds, plus more!" }
);
context.VoiceWorkSearches.AddRange(
new() { VoiceWorkId = 1, SearchText = "Search 1" },
new() { VoiceWorkId = 2, SearchText = "Search 2" },
new() { VoiceWorkId = 3, SearchText = "Search 3" },
new() { VoiceWorkId = 4, SearchText = "Search 4" },
new() { VoiceWorkId = 5, SearchText = "Search 5" }
); );
await context.SaveChangesAsync(); await context.SaveChangesAsync();

View File

@@ -30,8 +30,8 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" /> <PackageReference Include="Shouldly" Version="4.3.0" />

View File

@@ -0,0 +1,288 @@
using JSMR.Application.Enums;
using JSMR.Application.Integrations.DLSite.Models.ReleasedWorks;
using JSMR.Application.Integrations.DLSite.Ports;
using JSMR.Application.Scanning.Contracts;
using JSMR.Infrastructure.Scanning;
using NSubstitute;
using Shouldly;
namespace JSMR.Tests.Unit;
public class ReleasedWorksProviderTests
{
private readonly IDLSiteClient _dlsiteClient = Substitute.For<IDLSiteClient>();
[Fact]
public async Task GetReleasedWorksAsync_WhenNoSalesDates_ReturnsEmptyAndDoesNotCallClient()
{
VoiceWorkScanResult scanResult = new(
Works:
[
new DLSiteWork
{
ProductId = "RJ001",
SalesDate = null,
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
}
],
EndOfResults: false
);
ReleasedWorksProvider provider = new(_dlsiteClient);
ReleasedWorksCollection result = await provider.GetReleasedWorksAsync(scanResult, TestContext.Current.CancellationToken);
result.ShouldBeEmpty();
await _dlsiteClient.DidNotReceiveWithAnyArgs().GetReleasedWorksAsync(default!, TestContext.Current.CancellationToken);
}
[Fact]
public async Task GetReleasedWorksAsync_WhenRangeIsUnder60Days_CallsClientOnce()
{
VoiceWorkScanResult scanResult = new(
Works:
[
new DLSiteWork
{
ProductId = "RJ001",
SalesDate = new DateOnly(2024, 1, 10),
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
},
new DLSiteWork
{
ProductId = "RJ002",
SalesDate = new DateOnly(2024, 1, 20),
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
}
],
EndOfResults: false
);
ReleasedWorksCollection apiResult = new()
{
["RJ001"] = new ReleasedWork
{
ProductId = "RJ001",
Title = "English title 1",
Description = "Description",
MaskedTitle = "English title 1",
MaskedDescription = "Description",
},
["RJ002"] = new ReleasedWork
{
ProductId = "RJ002",
Title = "English title 2",
Description = "Description",
MaskedTitle = "English title 2",
MaskedDescription = "Description",
}
};
_dlsiteClient
.GetReleasedWorksAsync(Arg.Any<ReleasedWorksRequest>(), Arg.Any<CancellationToken>())
.Returns(apiResult);
ReleasedWorksProvider provider = new(_dlsiteClient);
ReleasedWorksCollection result =
await provider.GetReleasedWorksAsync(scanResult, TestContext.Current.CancellationToken);
result.Keys.ShouldBe(["RJ001", "RJ002"], ignoreOrder: true);
await _dlsiteClient.Received(1).GetReleasedWorksAsync(
Arg.Is<ReleasedWorksRequest>(x =>
x.Locale == Locale.English &&
x.Date == new DateOnly(2024, 1, 20) &&
x.Period == 11),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetReleasedWorksAsync_WhenRangeExceeds60Days_SplitsIntoMultipleRequests()
{
VoiceWorkScanResult scanResult = new(
Works:
[
new DLSiteWork
{
ProductId = "RJ001",
SalesDate = new DateOnly(2024, 1, 1),
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
},
new DLSiteWork
{
ProductId = "RJ002",
SalesDate = new DateOnly(2024, 3, 5),
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
}
],
EndOfResults: false
);
_dlsiteClient
.GetReleasedWorksAsync(Arg.Any<ReleasedWorksRequest>(), Arg.Any<CancellationToken>())
.Returns([]);
ReleasedWorksProvider provider = new(_dlsiteClient);
await provider.GetReleasedWorksAsync(scanResult, TestContext.Current.CancellationToken);
await _dlsiteClient.Received(1).GetReleasedWorksAsync(
Arg.Is<ReleasedWorksRequest>(x =>
x.Date == new DateOnly(2024, 2, 29) &&
x.Period == 60),
Arg.Any<CancellationToken>());
await _dlsiteClient.Received(1).GetReleasedWorksAsync(
Arg.Is<ReleasedWorksRequest>(x =>
x.Date == new DateOnly(2024, 3, 5) &&
x.Period == 5),
Arg.Any<CancellationToken>());
await _dlsiteClient.Received(2).GetReleasedWorksAsync(
Arg.Any<ReleasedWorksRequest>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetReleasedWorksAsync_FiltersOutProductsNotInScanResult()
{
VoiceWorkScanResult scanResult = new(
Works:
[
new DLSiteWork
{
ProductId = "RJ001",
SalesDate = new DateOnly(2024, 1, 10),
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
}
],
EndOfResults: false
);
ReleasedWorksCollection apiResult = new()
{
["RJ001"] = new ReleasedWork
{
ProductId = "RJ001",
Title = "Keep me",
Description = "Description",
MaskedTitle = "Keep me",
MaskedDescription = "Description",
},
["RJ999"] = new ReleasedWork
{
ProductId = "RJ999",
Title = "Ignore me",
Description = "Description",
MaskedTitle = "Ignore me",
MaskedDescription = "Description",
},
};
_dlsiteClient
.GetReleasedWorksAsync(Arg.Any<ReleasedWorksRequest>(), Arg.Any<CancellationToken>())
.Returns(apiResult);
ReleasedWorksProvider provider = new(_dlsiteClient);
ReleasedWorksCollection result =
await provider.GetReleasedWorksAsync(scanResult, TestContext.Current.CancellationToken);
result.Keys.ShouldBe(["RJ001"]);
}
[Fact]
public async Task GetReleasedWorksAsync_WhenSameProductReturnedTwice_KeepsFirstResult()
{
VoiceWorkScanResult scanResult = new(
Works:
[
new DLSiteWork
{
ProductId = "RJ001",
SalesDate = new DateOnly(2024, 1, 1),
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
},
new DLSiteWork
{
ProductId = "RJ002",
SalesDate = new DateOnly(2024, 3, 5),
ProductName = "",
MakerId = "",
Maker = "",
ImageUrl = "",
SmallImageUrl = ""
}
],
EndOfResults: false
);
_dlsiteClient
.GetReleasedWorksAsync(
Arg.Is<ReleasedWorksRequest>(x => x.Period == 60),
Arg.Any<CancellationToken>())
.Returns(new ReleasedWorksCollection
{
["RJ001"] = new ReleasedWork
{
ProductId = "RJ001",
Title = "First",
Description = "Description",
MaskedTitle = "First",
MaskedDescription = "Description",
}
});
_dlsiteClient
.GetReleasedWorksAsync(
Arg.Is<ReleasedWorksRequest>(x => x.Period == 5),
Arg.Any<CancellationToken>())
.Returns(new ReleasedWorksCollection
{
["RJ001"] = new ReleasedWork
{
ProductId = "RJ001",
Title = "Second",
Description = "Description",
MaskedTitle = "Second",
MaskedDescription = "Description",
}
});
ReleasedWorksProvider provider = new(_dlsiteClient);
ReleasedWorksCollection result = await provider.GetReleasedWorksAsync(scanResult, TestContext.Current.CancellationToken);
result["RJ001"].Title.ShouldBe("First");
}
}

View File

@@ -11,7 +11,10 @@
UseCurrentColor> UseCurrentColor>
</Icon> </Icon>
} }
@if (ChildContent is not null)
{
<span>@ChildContent</span> <span>@ChildContent</span>
}
</div> </div>
} }
else else
@@ -26,7 +29,10 @@ else
UseCurrentColor> UseCurrentColor>
</Icon> </Icon>
} }
@if (ChildContent is not null)
{
<span>@ChildContent</span> <span>@ChildContent</span>
}
</a> </a>
} }
@@ -62,6 +68,12 @@ else
[Parameter] [Parameter]
public EventCallback Click { get; set; } public EventCallback Click { get; set; }
[Parameter]
public bool IsClickable { get; set; }
[Parameter]
public bool ThickBorder { get; set; }
private string GetClasses() private string GetClasses()
{ {
string color = Color.ToString().ToLower(); string color = Color.ToString().ToLower();
@@ -72,6 +84,17 @@ else
$"color-{color}" $"color-{color}"
]; ];
// Experimental
if (ChildContent is null)
{
classNames.Add("j-chip-icon-only");
}
if (ThickBorder)
{
classNames.Add("j-chip-thick-border");
}
switch (Varient) switch (Varient)
{ {
case ElementVarient.Filled: case ElementVarient.Filled:
@@ -99,7 +122,7 @@ else
classNames.Add($"varient-tint"); classNames.Add($"varient-tint");
} }
if (Click.HasDelegate || string.IsNullOrWhiteSpace(Url) == false) if (Click.HasDelegate || string.IsNullOrWhiteSpace(Url) == false || IsClickable)
{ {
classNames.Add("is-clickable"); classNames.Add("is-clickable");
} }

View File

@@ -1,4 +1,7 @@
@using JSMR.Application.VoiceWorks.Queries.Search @using AntDesign
@using JSMR.Application.VoiceWorks.Commands.Delete
@using JSMR.Application.VoiceWorks.Commands.SetFavorite
@using JSMR.Application.VoiceWorks.Queries.Search
@using JSMR.Domain.Enums @using JSMR.Domain.Enums
@using JSMR.UI.Blazor.Components.Chips @using JSMR.UI.Blazor.Components.Chips
@using JSMR.UI.Blazor.Enums @using JSMR.UI.Blazor.Enums
@@ -7,6 +10,10 @@
@using System.Globalization @using System.Globalization
@using Microsoft.AspNetCore.WebUtilities @using Microsoft.AspNetCore.WebUtilities
@inject VoiceWorksClient Client
@inject MessageService MessageService
@inject ModalService ModalService
<div class=@GetCardClasses(Product)> <div class=@GetCardClasses(Product)>
<div class="j-voice-work-image-container"> <div class="j-voice-work-image-container">
<JImage OverlayClass="j-voice-work-image-overlay" ImageClass="j-voice-work-image" Source="@ImageUrlProvider.GetImageUrl(Product, "main")" FallbackSource="@ImageUrlProvider.GetImageUrl(Product, "main", "webp")"></JImage> <JImage OverlayClass="j-voice-work-image-overlay" ImageClass="j-voice-work-image" Source="@ImageUrlProvider.GetImageUrl(Product, "main")" FallbackSource="@ImageUrlProvider.GetImageUrl(Product, "main", "webp")"></JImage>
@@ -16,12 +23,6 @@
<a href="@Product.ProductUrl" target="_blank">@Product.ProductName</a> <a href="@Product.ProductUrl" target="_blank">@Product.ProductName</a>
</div> </div>
<BitStack Horizontal="true" Gap="0.5em" AutoHeight Wrap> <BitStack Horizontal="true" Gap="0.5em" AutoHeight Wrap>
@* <MudChip T="string"
Href=@($"https://www.dlsite.com/maniax/circle/profile/=/maker_id/{Product.MakerId}.html")
Target="_blank"
Variant="MudBlazor.Variant.Filled"
Icon="@Icons.Material.Outlined.Circle">@Product.Maker</MudChip> *@
@* <CircleChip Circle="@Product.Circle"></CircleChip> *@
<Chip Graphic="Graphic.Circle" Varient="ElementVarient.Outlined" Color="ColorVarient.Secondary" Url=@($"https://www.dlsite.com/maniax/circle/profile/=/maker_id/{Product.MakerId}.html")>@Product.Maker</Chip> <Chip Graphic="Graphic.Circle" Varient="ElementVarient.Outlined" Color="ColorVarient.Secondary" Url=@($"https://www.dlsite.com/maniax/circle/profile/=/maker_id/{Product.MakerId}.html")>@Product.Maker</Chip>
@if (Product.OriginalCircle is not null) @if (Product.OriginalCircle is not null)
@@ -31,12 +32,6 @@
@foreach (var creator in Product.Creators) @foreach (var creator in Product.Creators)
{ {
@* <MudChip T="string"
Href=@($"https://www.dlsite.com/maniax/fsr/=/keyword_creater/{creator.Name}")
Target="_blank"
Variant="MudBlazor.Variant.Filled"
Icon="@Icons.Material.Filled.Person">@creator.Name</MudChip> *@
@* <CreatorChip Creator="@creator"></CreatorChip> *@
<Chip Graphic="Graphic.Person" Varient="ElementVarient.Outlined" IconVarient="IconVarient.Fill" Color="ColorVarient.Secondary" Url=@($"https://www.dlsite.com/maniax/fsr/=/keyword_creater/{creator.Name}")>@creator.Name</Chip> <Chip Graphic="Graphic.Person" Varient="ElementVarient.Outlined" IconVarient="IconVarient.Fill" Color="ColorVarient.Secondary" Url=@($"https://www.dlsite.com/maniax/fsr/=/keyword_creater/{creator.Name}")>@creator.Name</Chip>
} }
</BitStack> </BitStack>
@@ -70,16 +65,9 @@
<div class="j-tags"> <div class="j-tags">
@foreach (var tag in Product.Tags) @foreach (var tag in Product.Tags)
{ {
@* <div class="j-tag">@tag.Name</div> *@
<ProductTag Tag="tag"></ProductTag> <ProductTag Tag="tag"></ProductTag>
} }
</div> </div>
@* <div class="j-tags">
@foreach (var tag in Product.Tags)
{
<TagChip Tag="tag"></TagChip>
}
</div> *@
</div> </div>
<div class="j-voice-work-info"> <div class="j-voice-work-info">
<div class="j-release-date-container"> <div class="j-release-date-container">
@@ -102,20 +90,45 @@
<BitStack Horizontal="true" Gap="0.5rem" VerticalAlign="BitAlignment.End" HorizontalAlign="BitAlignment.End"> <BitStack Horizontal="true" Gap="0.5rem" VerticalAlign="BitAlignment.End" HorizontalAlign="BitAlignment.End">
@if (Product.IsValid != true) @if (Product.IsValid != true)
{ {
<ProductIndicator Graphic="Graphic.Warning" IconVarient="IconVarient.Fill" Color="ColorVarient.Orange" BackgroundColor="ColorVarient.Black"></ProductIndicator> @* <ProductIndicator Graphic="Graphic.Warning" IconVarient="IconVarient.Fill" Color="ColorVarient.Orange" BackgroundColor="ColorVarient.Black"></ProductIndicator> *@
<Chip Graphic="Graphic.Warning" IconVarient="IconVarient.Fill" Varient="ElementVarient.Outlined" Color="ColorVarient.Orange" ThickBorder></Chip>
} }
@if (Product.OriginalProductId is not null) @if (Product.OriginalProductId is not null)
{ {
<ProductIndicator Graphic="Graphic.Translate" Color="ColorVarient.Primary" BackgroundColor="ColorVarient.Black"></ProductIndicator> @* <ProductIndicator Graphic="Graphic.Translate" Color="ColorVarient.Primary" BackgroundColor="ColorVarient.Black"></ProductIndicator> *@
<Chip Graphic="Graphic.Translate" Varient="ElementVarient.Outlined" Color="ColorVarient.Primary" ThickBorder></Chip>
} }
@if (Product.Favorite) @if (Product.Favorite)
{ {
<ProductIndicator Graphic="Graphic.Star" Color="ColorVarient.Pink" BackgroundColor="ColorVarient.Black"></ProductIndicator> @* <ProductIndicator Graphic="Graphic.Star" Color="ColorVarient.Pink" BackgroundColor="ColorVarient.Black"></ProductIndicator> *@
<Chip Graphic="Graphic.Star" Varient="ElementVarient.Outlined" Color="ColorVarient.Pink" ThickBorder></Chip>
} }
@if (Product.HasTrial || Product.HasChobit) @if (Product.HasTrial || Product.HasChobit)
{ {
<ProductIndicator Graphic="Graphic.Headphones" Color="ColorVarient.Blue" BackgroundColor="ColorVarient.Black"></ProductIndicator> @* <ProductIndicator Graphic="Graphic.Headphones" Color="ColorVarient.Blue" BackgroundColor="ColorVarient.Black"></ProductIndicator> *@
<Chip Graphic="Graphic.Headphones" Varient="ElementVarient.Outlined" Color="ColorVarient.Blue" ThickBorder></Chip>
} }
<Dropdown Trigger="@([Trigger.Click])">
<Overlay>
<Menu Selectable="false">
@if (!Product.Favorite)
{
<MenuItem OnClick="(e) => SetFavorite(true)">Add to Favorites</MenuItem>
}
@if (Product.Favorite)
{
<MenuItem OnClick="(e) => SetFavorite(false)">Remove from Favorites</MenuItem>
}
@if (Product.IsValid != true)
{
<MenuItem OnClick="(e) => Delete()">Delete</MenuItem>
}
</Menu>
</Overlay>
<ChildContent>
<Chip Graphic="Graphic.Pencil" Varient="ElementVarient.Outlined" Color="ColorVarient.Surface" IsClickable ThickBorder></Chip>
</ChildContent>
</Dropdown>
</BitStack> </BitStack>
</div> </div>
</div> </div>
@@ -124,6 +137,9 @@
[Parameter] [Parameter]
public required VoiceWorkSearchResult Product { get; set; } public required VoiceWorkSearchResult Product { get; set; }
[Parameter]
public EventCallback ProductDeleted { get; set; }
private string GetCardClasses(VoiceWorkSearchResult voiceWork) private string GetCardClasses(VoiceWorkSearchResult voiceWork)
{ {
List<string> classNames = ["j-card", "j-voice-work-card"]; List<string> classNames = ["j-card", "j-voice-work-card"];
@@ -194,4 +210,74 @@
default: return code; default: return code;
} }
} }
private async Task SetFavorite(bool value)
{
SetVoiceWorkFavoriteRequest request = new(
VoiceWorkId: Product.VoiceWorkId,
IsFavorite: value
);
SetVoiceWorkFavoriteResponse? response = await Client.SetVoiceWorkFavoriteeAsync(request);
if (response is not null)
{
Product.Favorite = response.IsFavorite;
}
//await InvokeAsync(StateHasChanged);
MessageConfig messageConfig = new()
{
Content = $"Product '{Product.ProductName}' has been {(Product.Favorite ? "added to your favorites" : "removed from your favorites")}.",
Type = MessageType.Success
};
_ = MessageService.OpenAsync(messageConfig);
}
private async Task Delete()
{
RenderFragment icon = @<AntDesign.Icon Type="@IconType.Outline.ExclamationCircle" />;
AntDesign.ConfirmOptions options = new()
{
Title = "Are you sure you want to delete the following product?",
Icon = icon,
Content = Product.ProductName,
Centered = true,
OnOk = async (e) =>
{
DeleteVoiceWorkRequest request = new(
VoiceWorkIds: [Product.VoiceWorkId]
);
try
{
DeleteVoiceWorkResponse? response = await Client.DeleteVoiceWorkAsync(request);
if (response is null || response.Results[Product.VoiceWorkId] != DeleteVoiceWorkStatus.Deleted)
return;
await ProductDeleted.InvokeAsync();
}
catch (Exception ex)
{
AntDesign.ConfirmOptions errorOptions = new()
{
Title = "Unable to delete product",
Content = "Something went wrong while deleting this product. The product was not deleted. Check the API logs for details.",
Centered = true,
//Width = "70vw",
};
await ModalService.ErrorAsync(errorOptions);
e.Cancel = true;
}
}
};
await ModalService.ConfirmAsync(options);
}
} }

View File

@@ -13,7 +13,7 @@ else
<div class="j-product-items-container"> <div class="j-product-items-container">
@foreach (var product in Products) @foreach (var product in Products)
{ {
<JProduct Product="@product"></JProduct> <JProduct Product="@product" ProductDeleted="OnProductDeleted"></JProduct>
} }
</div> </div>
} }
@@ -21,4 +21,12 @@ else
@code { @code {
[Parameter] [Parameter]
public VoiceWorkSearchResult[]? Products { get; set; } public VoiceWorkSearchResult[]? Products { get; set; }
[Parameter]
public EventCallback ProductDeleted { get; set; }
private async Task OnProductDeleted()
{
await ProductDeleted.InvokeAsync();
}
} }

View File

@@ -21,5 +21,6 @@ public enum Graphic
Age, Age,
Calendar, Calendar,
Download, Download,
Microphone Microphone,
Pencil
} }

View File

@@ -0,0 +1,9 @@
using System.Net;
namespace JSMR.UI.Blazor.Exceptions;
public sealed class ApiException(HttpStatusCode statusCode, string message, string? responseBody = null) : Exception(message)
{
public HttpStatusCode StatusCode { get; } = statusCode;
public string? ResponseBody { get; } = responseBody;
}

View File

@@ -12,13 +12,13 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AntDesign" Version="1.6.1" /> <PackageReference Include="AntDesign" Version="1.6.1" />
<PackageReference Include="Bit.BlazorUI" Version="10.4.3" /> <PackageReference Include="Bit.BlazorUI" Version="10.4.3" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.7" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.7" PrivateAssets="all" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.8" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.9" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.10" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.8" />
<PackageReference Include="MudBlazor" Version="9.4.0" /> <PackageReference Include="MudBlazor" Version="9.4.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Radzen.Blazor" Version="10.3.1" /> <PackageReference Include="Radzen.Blazor" Version="10.4.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -8,13 +8,13 @@
<BitPivot Size="BitSize.Medium"> <BitPivot Size="BitSize.Medium">
<BitPivotItem HeaderText="@($"Available ({availableVoiceWorks?.Length ?? 0})")"> <BitPivotItem HeaderText="@($"Available ({availableVoiceWorks?.Length ?? 0})")">
<JProductCollection Products="availableVoiceWorks"></JProductCollection> <JProductCollection Products="availableVoiceWorks" ProductDeleted="OnAvailableProductDeleted"></JProductCollection>
</BitPivotItem> </BitPivotItem>
<BitPivotItem HeaderText="@($"Upcoming ({upcomingVoiceWorks?.Length ?? 0})")"> <BitPivotItem HeaderText="@($"Upcoming ({upcomingVoiceWorks?.Length ?? 0})")">
<JProductCollection Products="upcomingVoiceWorks"></JProductCollection> <JProductCollection Products="upcomingVoiceWorks" ProductDeleted="OnUpcomingProductDeleted"></JProductCollection>
</BitPivotItem> </BitPivotItem>
<BitPivotItem HeaderText="@($"Announcements ({announcedVoiceWorks?.Length ?? 0})")"> <BitPivotItem HeaderText="@($"Announcements ({announcedVoiceWorks?.Length ?? 0})")">
<JProductCollection Products="announcedVoiceWorks"></JProductCollection> <JProductCollection Products="announcedVoiceWorks" ProductDeleted="OnAnnouncedProductDeleted"></JProductCollection>
</BitPivotItem> </BitPivotItem>
</BitPivot> </BitPivot>
@@ -105,4 +105,21 @@
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
} }
private async Task OnAvailableProductDeleted()
{
_ = LoadAvailableVoiceWorksAsync();
}
private async Task OnUpcomingProductDeleted()
{
_ = LoadUpcomingVoiceWorksAsync();
_ = LoadAnnouncedVoiceWorksAsync();
}
private async Task OnAnnouncedProductDeleted()
{
_ = LoadUpcomingVoiceWorksAsync();
_ = LoadAnnouncedVoiceWorksAsync();
}
} }

View File

@@ -1,12 +1,17 @@
@page "/login" @page "/login"
@layout LoginLayout @layout LoginLayout
@using AntDesign
@using JSMR.UI.Blazor.Services @using JSMR.UI.Blazor.Services
@using MudBlazor.Charts
@using System.ComponentModel.DataAnnotations
@inject SessionState Session @inject SessionState Session
@inject NavigationManager Nav @inject NavigationManager Nav
<h3>Login</h3> @* <h3>Login</h3> *@
<PageTitle>Sign In - JSMR</PageTitle>
@if (Session.IsAuthenticated) @if (Session.IsAuthenticated)
{ {
@@ -15,27 +20,83 @@
} }
else else
{ {
<div style="max-width: 360px;"> <div class="login-container">
<BitCard> <AntDesign.Card Title=@("Sign In") Class="ant-blurred-card">
<BitStack> <Body>
<BitTextField Label="Username" @bind-Value="username"></BitTextField> <AntDesign.Form Model="@loginModel" Layout="FormLayout.Vertical">
<BitTextField Label="Password" @bind-Value="password" Type="BitInputType.Password"></BitTextField> <AntDesign.FormItem Label="Username">
<BitButton OnClick="LoginAsync" IsEnabled="@(!busy)">Login</BitButton> <AntDesign.Input @bind-Value="context.Username"></AntDesign.Input>
</AntDesign.FormItem>
<AntDesign.FormItem Label="Password">
<AntDesign.InputPassword @bind-Value="context.Password"></AntDesign.InputPassword>
</AntDesign.FormItem>
<AntDesign.Button Class="login-button" OnClick="Login2Async" Disabled="@(busy)" Type="AntDesign.ButtonType.Primary">Sign In</AntDesign.Button>
</AntDesign.Form>
</Body>
</AntDesign.Card>
@if (!string.IsNullOrWhiteSpace(error)) @if (!string.IsNullOrWhiteSpace(error))
{ {
<p style="color: crimson; margin-top: 8px;">@error</p> <Alert Type="AlertType.Error" Message="@error" ShowIcon="false" />
} }
</BitStack>
</BitCard>
</div> </div>
} }
<style>
body {
/* background-image: url(https://mdn.alipayobjects.com/huamei_iwk9zp/afts/img/A*ETkNSJ-oUGwAAAAAQ_AAAAgAegCCAQ/original);
background-size: cover; */
background: #1b1f23;
}
.ant-card-head-title {
display: flex;
justify-content: center;
}
.ant-form-item-required {
display: flex;
flex-direction: row-reverse;
gap: .25rem;
align-items: flex-start;
}
.login-container {
max-width: 450px;
padding: 1rem;
margin: auto;
display: flex;
flex-direction: column;
gap: .5rem;
}
.login-button {
width: 100%;
}
.ant-blurred-card {
background-color: color-mix(in srgb, #141414 70%, transparent);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
</style>
@code { @code {
private string username = ""; private string username = "";
private string password = ""; private string password = "";
private bool busy; private bool busy;
private string? error; private string? error;
private LoginModel loginModel = new();
public class LoginModel
{
[Required]
public string? Username { get; set; }
[Required]
public string? Password { get; set; }
}
private async Task LoginAsync() private async Task LoginAsync()
{ {
busy = true; busy = true;
@@ -61,6 +122,43 @@ else
} }
} }
private async Task Login2Async()
{
busy = true;
error = null;
try
{
if (string.IsNullOrWhiteSpace(loginModel.Username))
{
error = "Username is required.";
return;
}
if (string.IsNullOrWhiteSpace(loginModel.Password))
{
error = "Password is required.";
return;
}
var ok = await Session.LoginAsync(loginModel.Username, loginModel.Password);
if (!ok)
{
error = "Invalid username or password.";
return;
}
Nav.NavigateTo("/");
}
catch (Exception ex)
{
error = ex.Message;
}
finally
{
busy = false;
}
}
private async Task Logout() private async Task Logout()
{ {
busy = true; busy = true;

View File

@@ -17,7 +17,7 @@
<h3>Voice Works</h3> <h3>Voice Works</h3>
<VoiceWorkFilters Value="@State" ValueChanged="UpdateAsync" /> <VoiceWorkFilters Value="@State" ValueChanged="UpdateAsync" />
<JProductCollection Products="Result?.Items"></JProductCollection> <JProductCollection Products="Result?.Items" ProductDeleted="OnProductDeleted"></JProductCollection>
@if (Result is not null) @if (Result is not null)
{ {
@@ -69,4 +69,9 @@
protected override Task<SearchResult<VoiceWorkSearchResult>> ExecuteSearchAsync(VoiceWorkFilterState state, CancellationToken ct) protected override Task<SearchResult<VoiceWorkSearchResult>> ExecuteSearchAsync(VoiceWorkFilterState state, CancellationToken ct)
=> Client.SearchAsync(state.ToSearchRequest(), ct).ContinueWith(t => t.Result?.Results ?? new SearchResult<VoiceWorkSearchResult>(), ct); => Client.SearchAsync(state.ToSearchRequest(), ct).ContinueWith(t => t.Result?.Results ?? new SearchResult<VoiceWorkSearchResult>(), ct);
private async Task OnProductDeleted()
{
_ = RunSearchAsync(false);
}
} }

View File

@@ -4,7 +4,10 @@ using JSMR.Application.Creators.Queries.Search;
using JSMR.Application.Tags.Commands.SetEnglishName; using JSMR.Application.Tags.Commands.SetEnglishName;
using JSMR.Application.Tags.Commands.UpdateTagStatus; using JSMR.Application.Tags.Commands.UpdateTagStatus;
using JSMR.Application.Tags.Queries.Search; using JSMR.Application.Tags.Queries.Search;
using JSMR.Application.VoiceWorks.Commands.Delete;
using JSMR.Application.VoiceWorks.Commands.SetFavorite;
using JSMR.Application.VoiceWorks.Queries.Search; using JSMR.Application.VoiceWorks.Queries.Search;
using JSMR.UI.Blazor.Exceptions;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
@@ -21,6 +24,18 @@ public class VoiceWorksClient(HttpClient http)
} }
}; };
private static async Task<T?> ReadJsonOrThrowAsync<T>(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
{
string body = await response.Content.ReadAsStringAsync(cancellationToken);
throw new ApiException(response.StatusCode, $"Request failed: {(int)response.StatusCode}", body);
}
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken: cancellationToken);
}
public async Task<SearchVoiceWorksResponse?> SearchAsync(SearchVoiceWorksRequest request, CancellationToken ct = default) public async Task<SearchVoiceWorksResponse?> SearchAsync(SearchVoiceWorksRequest request, CancellationToken ct = default)
{ {
using var resp = await http.PostAsJsonAsync("/api/voiceworks/search", request, ct); using var resp = await http.PostAsJsonAsync("/api/voiceworks/search", request, ct);
@@ -45,6 +60,19 @@ public class VoiceWorksClient(HttpClient http)
return await resp.Content.ReadFromJsonAsync<SearchTagsResponse>(JsonOptions, cancellationToken: ct); return await resp.Content.ReadFromJsonAsync<SearchTagsResponse>(JsonOptions, cancellationToken: ct);
} }
public async Task<SetVoiceWorkFavoriteResponse?> SetVoiceWorkFavoriteeAsync(SetVoiceWorkFavoriteRequest request, CancellationToken ct = default)
{
using var resp = await http.PostAsJsonAsync("/api/voicework/set-favorite", request, ct);
return await resp.Content.ReadFromJsonAsync<SetVoiceWorkFavoriteResponse>(JsonOptions, cancellationToken: ct);
}
public async Task<DeleteVoiceWorkResponse?> DeleteVoiceWorkAsync(DeleteVoiceWorkRequest request, CancellationToken ct = default)
{
using var resp = await http.PostAsJsonAsync("/api/voicework/delete", request, ct);
//return await resp.Content.ReadFromJsonAsync<DeleteVoiceWorkResponse>(JsonOptions, cancellationToken: ct);
return await ReadJsonOrThrowAsync<DeleteVoiceWorkResponse>(resp, ct);
}
public async Task<UpdateTagStatusResponse?> UpdateTagStatusAsync(UpdateTagStatusRequest request, CancellationToken ct = default) public async Task<UpdateTagStatusResponse?> UpdateTagStatusAsync(UpdateTagStatusRequest request, CancellationToken ct = default)
{ {
using var resp = await http.PostAsJsonAsync("/api/tags/update-status", request, ct); using var resp = await http.PostAsJsonAsync("/api/tags/update-status", request, ct);

View File

@@ -41,6 +41,19 @@ public abstract class SearchPageBase<TState, TItem> : ComponentBase, IAsyncDispo
_ = RunSearchAsync(); _ = RunSearchAsync();
} }
//protected async Task Update2Async(TState next, bool scrollToTop)
//{
// if (Equals(next, State))
// return;
// Console.WriteLine("Got here");
// State = next;
// NavigateToState(next);
// _ = RunSearchAsync(scrollToTop);
//}
private void NavigateToState(TState next) private void NavigateToState(TState next)
{ {
string uri = BuildUri(next); string uri = BuildUri(next);
@@ -73,10 +86,13 @@ public abstract class SearchPageBase<TState, TItem> : ComponentBase, IAsyncDispo
_ = RunSearchAsync(); _ = RunSearchAsync();
} }
private async Task RunSearchAsync() protected async Task RunSearchAsync(bool scrollTotop = true)
{ {
if (scrollTotop)
await JS.InvokeVoidAsync("pageHelpers.scrollToTop"); await JS.InvokeVoidAsync("pageHelpers.scrollToTop");
Console.WriteLine("Got here 2");
try try
{ {
IsLoading = true; IsLoading = true;

View File

@@ -0,0 +1,51 @@
/* Modals */
.ant-modal-content {
background-color: var(--ant-modal-content-bg);
border-radius: var(--ant-border-radius-lg);
border-width: 1px;
border-style: solid;
border-top-color: rgb(83, 99, 109);
border-left-color: rgb(72, 88, 99);
border-right-color: rgb(72, 88, 99);
border-bottom-color: rgb(63, 78, 88);
}
.ant-modal-confirm-title {
color: var(--ant-modal-title-color);
text-shadow: 1px 1px 2px black;
}
.ant-modal-confirm-body .ant-modal-confirm-content {
color: var(--ant-color-text);
}
/* Buttons */
.ant-btn {
/*font-weight: var(--ant-button-font-weight);
border-width: var(--ant-btn-border-width);
border-style: var(--ant-btn-border-style);
border-color: var(--ant-btn-border-color);
color: var(--ant-btn-text-color);
background-color: var(--ant-btn-bg-color);
border-radius: var(--ant-border-radius);*/
}
/*
--ant-button-font-weight: 400;
--ant-button-icon-gap: 8px;
--ant-button-padding-inline: 15px;
--ant-button-default-border-color: rgba(180, 200, 214, 0.25);
--ant-button-content-font-size: 14px;
--ant-btn-color-base: var(--ant-button-default-border-color);
--ant-btn-text-color: var(--ant-button-default-color);
--ant-btn-text-color-hover: var(--ant-button-default-hover-color);
--ant-btn-shadow: var(--ant-button-default-shadow);
--ant-btn-border-color: var(--ant-btn-color-base);
--ant-btn-border-color-hover: var(--ant-btn-color-hover);
--ant-btn-border-color-active: var(--ant-btn-color-active);
--ant-btn-bg-color: var(--ant-btn-bg-color-container);
--ant-btn-text-color: var(--ant-btn-color-base);
--ant-btn-text-color-hover: var(--ant-btn-color-hover);
--ant-btn-text-color-active: var(--ant-btn-color-active);
*/

View File

@@ -669,6 +669,16 @@ code {
border: 1px solid transparent; border: 1px solid transparent;
} }
.j-chip-icon-only {
padding: .75em;
border-radius: 2em;
}
.j-chip-thick-border,
.j-chip.varient-outlined.j-chip-thick-border {
border-width: 2px;
}
.j-chip.is-clickable { .j-chip.is-clickable {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@@ -770,6 +780,10 @@ code {
--chip-fg-rgb: var(--rgb-on-yellow, 255 255 255); --chip-fg-rgb: var(--rgb-on-yellow, 255 255 255);
} }
.j-chip.color-orange {
--chip-rgb: var(--rgb-orange);
}
.j-chip.color-pink { .j-chip.color-pink {
color: rgb(var(--chip-fg-rgb)); color: rgb(var(--chip-fg-rgb));
--chip-rgb: var(--rgb-pink); --chip-rgb: var(--rgb-pink);
@@ -955,6 +969,14 @@ code {
mask-image: url("../svg/microphone-fill.svg"); mask-image: url("../svg/microphone-fill.svg");
} }
.j-icon-pencil {
mask-image: url("../svg/pencil.svg");
}
.j-icon-pencil-fill {
mask-image: url("../svg/pencil-fill.svg");
}
.j-icon-2 { .j-icon-2 {
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center; background-position: center;

View File

@@ -48,6 +48,7 @@
--rgb-blue: 115 196 255; --rgb-blue: 115 196 255;
--rgb-yellow: 255 224 115; --rgb-yellow: 255 224 115;
--rgb-on-yellow: 0 0 0; --rgb-on-yellow: 0 0 0;
--rgb-orange: 255 167 115;
--rgb-pink: 224 104 148; --rgb-pink: 224 104 148;
--rgb-on-pink: 255 255 255; --rgb-on-pink: 255 255 255;
--rgb-red: 224 104 104; --rgb-red: 224 104 104;
@@ -74,6 +75,47 @@
--surface-container-outline-high: rgb(83, 99, 109); --surface-container-outline-high: rgb(83, 99, 109);
--surface-container-outline: rgb(72, 88, 99); --surface-container-outline: rgb(72, 88, 99);
--surface-container-outline-low: rgb(63, 78, 88); --surface-container-outline-low: rgb(63, 78, 88);
/* Ant Design - Core */
--ant-border-radius: 12px;
--ant-line-width: 1px;
/* Ant Design - Modals */
--ant-color-text: #b4c8d6;
--ant-modal-content-bg: #273f50;
--ant-modal-title-color: #b4c8d6;
/* Ant Design - Buttons */
/* Button Part I */
--ant-btn-text-color: var(--ant-button-default-color);
--ant-btn-text-color-hover: var(--ant-button-default-hover-color);
--ant-btn-text-color-active: var(--ant-button-default-active-color);
--ant-btn-bg-color-container: var(--ant-button-default-bg);
--ant-btn-bg-color-hover: var(--ant-button-default-hover-bg);
--ant-btn-bg-color-active: var(--ant-button-default-active-bg);
/* Part II */
--ant-button-default-bg: #1e3545;
--ant-button-default-border-color: rgba(180, 200, 214, 0.25);
--ant-button-font-weight: 400;
--ant-button-icon-gap: 8px;
--ant-button-padding-inline: 15px;
--ant-button-content-font-size: 14px;
--ant-border-radius-lg: 16px;
--ant-button-font-weight: 400;
--ant-btn-border-width: var(--ant-line-width);
--ant-btn-border-color: #000;
--ant-btn-border-color-hover: var(--ant-btn-border-color);
--ant-btn-border-color-active: var(--ant-btn-border-color);
--ant-btn-border-color-disabled: var(--ant-btn-border-color);
--ant-btn-border-style: solid;
--ant-btn-text-color: #000;
--ant-btn-text-color-hover: var(--ant-btn-text-color);
--ant-btn-text-color-active: var(--ant-btn-text-color);
--ant-btn-text-color-disabled: var(--ant-btn-text-color);
--ant-btn-border-color: var(--ant-btn-color-base);
--ant-btn-border-color-hover: var(--ant-btn-color-hover);
--ant-btn-border-color-active: var(--ant-btn-color-active);
--ant-btn-bg-color: var(--ant-btn-bg-color-container);
--ant-btn-text-color: var(--ant-btn-color-base);
--ant-btn-text-color-hover: var(--ant-btn-color-hover);
--ant-btn-text-color-active: var(--ant-btn-color-active);
} }
/* /*

View File

@@ -24,6 +24,7 @@
<link rel="stylesheet" href="css/radzen.css" /> <link rel="stylesheet" href="css/radzen.css" />
<link href="_content/Bit.BlazorUI/styles/bit.blazorui.css" rel="stylesheet" /> <link href="_content/Bit.BlazorUI/styles/bit.blazorui.css" rel="stylesheet" />
<link href="_content/AntDesign/css/ant-design-blazor.dark.css" rel="stylesheet" /> <link href="_content/AntDesign/css/ant-design-blazor.dark.css" rel="stylesheet" />
<link rel="stylesheet" href="css/ant-design.css" />
<link rel="stylesheet" href="css/bit-blazor.css" /> <link rel="stylesheet" href="css/bit-blazor.css" />
<link rel="stylesheet" href="css/font.css" /> <link rel="stylesheet" href="css/font.css" />
<link rel="stylesheet" href="css/app.css" /> <link rel="stylesheet" href="css/app.css" />

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil-fill" viewBox="0 0 16 16">
<path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.5.5 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11z"/>
</svg>

After

Width:  |  Height:  |  Size: 561 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"/>
</svg>

After

Width:  |  Height:  |  Size: 521 B

View File

@@ -26,15 +26,15 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.8" />
<PackageReference Include="Serilog" Version="4.3.1" /> <PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" /> <PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0" /> <PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
<PackageReference Include="Spectre.Console" Version="0.55.2" /> <PackageReference Include="Spectre.Console" Version="0.55.2" />
<PackageReference Include="System.CommandLine" Version="2.0.7" /> <PackageReference Include="System.CommandLine" Version="2.0.8" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -59,7 +59,10 @@ builder.Services
builder.Services.AddDbContextFactory<AppDbContext>(optionsBuilder => builder.Services.AddDbContextFactory<AppDbContext>(optionsBuilder =>
optionsBuilder optionsBuilder
.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)) .UseMySql(connectionString, ServerVersion.AutoDetect(connectionString), mySqlOptions =>
{
mySqlOptions.CommandTimeout(120);
})
.EnableSensitiveDataLogging(false)); .EnableSensitiveDataLogging(false));
// Worker services // Worker services