mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Misc Bugfixes (#2216)
* Folder watching will now appropriately ignore changes that occur in blacklisted folders. * Fixed up recently updated from dashboard not opening a pre-sorted page. There were issues with how encoding and decoding was done plus missing code. * Fixed up all streams from Dashboard opening to correctly filtered pages. * All search linking now works. * Rating tooltip and stars are bigger on mobile. * A bit of cleanup * Added day breakdown to user stats page. * Removed Token checks before we write events to the history table for scrobbling. Refactored so series holds will prevent writing events for reviews, ratings, etc. * Fixed a potential bug where series name could be taken from a chapter that isn't the first ordered (very unlikely) for epubs. Fixed a bug where Volume 1.5 could be selected for series-level metadata over Volume 1. * Optimized the license check code so that users without any license entered would still take advantage of the cache layer. * Sped up an API that checks if the library allows scrobbling * Cleaned up the mobile CSS a bit for filters.
This commit is contained in:
parent
ef3e76e3e5
commit
c84a3294e9
@ -802,6 +802,26 @@ public class SeriesServiceTests : AbstractDbTest
|
||||
return series;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstChapterForMetadata_BookWithOnlyVolumeNumbers_Test()
|
||||
{
|
||||
var file = new MangaFileBuilder("Test.cbz", MangaFormat.Archive, 1).Build();
|
||||
|
||||
var series = new SeriesBuilder("Test")
|
||||
.WithVolume(new VolumeBuilder("1")
|
||||
.WithChapter(new ChapterBuilder("0").WithPages(1).WithFile(file).Build())
|
||||
.Build())
|
||||
|
||||
.WithVolume(new VolumeBuilder("1.5")
|
||||
.WithChapter(new ChapterBuilder("0").WithPages(2).WithFile(file).Build())
|
||||
.Build())
|
||||
.Build();
|
||||
series.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build();
|
||||
|
||||
var firstChapter = SeriesService.GetFirstChapterForMetadata(series);
|
||||
Assert.Equal(1, firstChapter.Pages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetFirstChapterForMetadata_Book_Test()
|
||||
{
|
||||
|
@ -143,8 +143,7 @@ public class ScrobblingController : BaseApiController
|
||||
[HttpGet("library-allows-scrobbling")]
|
||||
public async Task<ActionResult<bool>> LibraryAllowsScrobbling(int seriesId)
|
||||
{
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library);
|
||||
return Ok(series != null && series.Library.AllowScrobbling);
|
||||
return Ok(await _unitOfWork.LibraryRepository.GetAllowsScrobblingBySeriesId(seriesId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -125,14 +125,21 @@ public class StatsController : BaseApiController
|
||||
}
|
||||
|
||||
[HttpGet("day-breakdown")]
|
||||
[Authorize("RequireAdminRole")]
|
||||
[ResponseCache(CacheProfileName = "Statistics")]
|
||||
public ActionResult<IEnumerable<StatCount<DayOfWeek>>> GetDayBreakdown()
|
||||
public async Task<ActionResult<IEnumerable<StatCount<DayOfWeek>>>> GetDayBreakdown(int userId = 0)
|
||||
{
|
||||
return Ok(_statService.GetDayBreakdown());
|
||||
if (userId == 0)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
||||
if (!isAdmin) return BadRequest();
|
||||
}
|
||||
|
||||
return Ok(_statService.GetDayBreakdown(userId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[HttpGet("user/reading-history")]
|
||||
[ResponseCache(CacheProfileName = "Statistics")]
|
||||
public async Task<ActionResult<IEnumerable<ReadHistoryEvent>>> GetReadingHistory(int userId)
|
||||
|
@ -15,7 +15,7 @@ namespace API.Data.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.9");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.10");
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||
{
|
||||
@ -180,7 +180,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("AppUserBookmark");
|
||||
b.ToTable("AppUserBookmark", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
|
||||
@ -201,7 +201,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserOnDeckRemoval");
|
||||
b.ToTable("AppUserOnDeckRemoval", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
|
||||
@ -315,7 +315,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("ThemeId");
|
||||
|
||||
b.ToTable("AppUserPreferences");
|
||||
b.ToTable("AppUserPreferences", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
|
||||
@ -365,7 +365,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserProgresses");
|
||||
b.ToTable("AppUserProgresses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserRating", b =>
|
||||
@ -398,7 +398,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserRating");
|
||||
b.ToTable("AppUserRating", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserRole", b =>
|
||||
@ -466,7 +466,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserTableOfContent");
|
||||
b.ToTable("AppUserTableOfContent", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Chapter", b =>
|
||||
@ -576,7 +576,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("VolumeId");
|
||||
|
||||
b.ToTable("Chapter");
|
||||
b.ToTable("Chapter", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.CollectionTag", b =>
|
||||
@ -611,7 +611,7 @@ namespace API.Data.Migrations
|
||||
b.HasIndex("Id", "Promoted")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CollectionTag");
|
||||
b.ToTable("CollectionTag", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Device", b =>
|
||||
@ -657,7 +657,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("Device");
|
||||
b.ToTable("Device", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.FolderPath", b =>
|
||||
@ -679,7 +679,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("LibraryId");
|
||||
|
||||
b.ToTable("FolderPath");
|
||||
b.ToTable("FolderPath", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Genre", b =>
|
||||
@ -699,7 +699,7 @@ namespace API.Data.Migrations
|
||||
b.HasIndex("NormalizedTitle")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Genre");
|
||||
b.ToTable("Genre", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Library", b =>
|
||||
@ -757,7 +757,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Library");
|
||||
b.ToTable("Library", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MangaFile", b =>
|
||||
@ -806,7 +806,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("ChapterId");
|
||||
|
||||
b.ToTable("MangaFile");
|
||||
b.ToTable("MangaFile", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MediaError", b =>
|
||||
@ -841,7 +841,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("MediaError");
|
||||
b.ToTable("MediaError", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
||||
@ -942,7 +942,7 @@ namespace API.Data.Migrations
|
||||
b.HasIndex("Id", "SeriesId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SeriesMetadata");
|
||||
b.ToTable("SeriesMetadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
|
||||
@ -966,7 +966,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("TargetSeriesId");
|
||||
|
||||
b.ToTable("SeriesRelation");
|
||||
b.ToTable("SeriesRelation", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Person", b =>
|
||||
@ -986,7 +986,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Person");
|
||||
b.ToTable("Person", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ReadingList", b =>
|
||||
@ -1049,7 +1049,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("ReadingList");
|
||||
b.ToTable("ReadingList", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ReadingListItem", b =>
|
||||
@ -1083,7 +1083,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("VolumeId");
|
||||
|
||||
b.ToTable("ReadingListItem");
|
||||
b.ToTable("ReadingListItem", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b =>
|
||||
@ -1128,7 +1128,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("ScrobbleError");
|
||||
b.ToTable("ScrobbleError", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b =>
|
||||
@ -1188,8 +1188,8 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("VolumeNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
b.Property<float?>("VolumeNumber")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
@ -1199,7 +1199,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("ScrobbleEvent");
|
||||
b.ToTable("ScrobbleEvent", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b =>
|
||||
@ -1232,7 +1232,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("ScrobbleHold");
|
||||
b.ToTable("ScrobbleHold", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Series", b =>
|
||||
@ -1328,7 +1328,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("LibraryId");
|
||||
|
||||
b.ToTable("Series");
|
||||
b.ToTable("Series", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ServerSetting", b =>
|
||||
@ -1345,7 +1345,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("ServerSetting");
|
||||
b.ToTable("ServerSetting", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ServerStatistics", b =>
|
||||
@ -1383,7 +1383,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ServerStatistics");
|
||||
b.ToTable("ServerStatistics", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.SiteTheme", b =>
|
||||
@ -1421,7 +1421,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SiteTheme");
|
||||
b.ToTable("SiteTheme", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Tag", b =>
|
||||
@ -1441,7 +1441,7 @@ namespace API.Data.Migrations
|
||||
b.HasIndex("NormalizedTitle")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tag");
|
||||
b.ToTable("Tag", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Volume", b =>
|
||||
@ -1477,8 +1477,8 @@ namespace API.Data.Migrations
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
b.Property<float>("Number")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int>("Pages")
|
||||
.HasColumnType("INTEGER");
|
||||
@ -1493,7 +1493,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("Volume");
|
||||
b.ToTable("Volume", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AppUserLibrary", b =>
|
||||
@ -1508,7 +1508,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("LibrariesId");
|
||||
|
||||
b.ToTable("AppUserLibrary");
|
||||
b.ToTable("AppUserLibrary", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterGenre", b =>
|
||||
@ -1523,7 +1523,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("GenresId");
|
||||
|
||||
b.ToTable("ChapterGenre");
|
||||
b.ToTable("ChapterGenre", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterPerson", b =>
|
||||
@ -1538,7 +1538,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("PeopleId");
|
||||
|
||||
b.ToTable("ChapterPerson");
|
||||
b.ToTable("ChapterPerson", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterTag", b =>
|
||||
@ -1553,7 +1553,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("ChapterTag");
|
||||
b.ToTable("ChapterTag", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
|
||||
@ -1568,7 +1568,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesMetadatasId");
|
||||
|
||||
b.ToTable("CollectionTagSeriesMetadata");
|
||||
b.ToTable("CollectionTagSeriesMetadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GenreSeriesMetadata", b =>
|
||||
@ -1583,7 +1583,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesMetadatasId");
|
||||
|
||||
b.ToTable("GenreSeriesMetadata");
|
||||
b.ToTable("GenreSeriesMetadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
|
||||
@ -1682,7 +1682,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("SeriesMetadatasId");
|
||||
|
||||
b.ToTable("PersonSeriesMetadata");
|
||||
b.ToTable("PersonSeriesMetadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SeriesMetadataTag", b =>
|
||||
@ -1697,7 +1697,7 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("SeriesMetadataTag");
|
||||
b.ToTable("SeriesMetadataTag", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
||||
|
@ -53,6 +53,7 @@ public interface ILibraryRepository
|
||||
Task<IList<string>> GetAllCoverImagesAsync();
|
||||
Task<IDictionary<int, LibraryType>> GetLibraryTypesForIdsAsync(IEnumerable<int> libraryIds);
|
||||
Task<IList<Library>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
|
||||
Task<bool> GetAllowsScrobblingBySeriesId(int seriesId);
|
||||
}
|
||||
|
||||
public class LibraryRepository : ILibraryRepository
|
||||
@ -374,4 +375,11 @@ public class LibraryRepository : ILibraryRepository
|
||||
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> GetAllowsScrobblingBySeriesId(int seriesId)
|
||||
{
|
||||
return await _context.Series.Where(s => s.Id == seriesId)
|
||||
.Select(s => s.Library.AllowScrobbling)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
|
@ -128,15 +128,18 @@ public class LicenseService : ILicenseService
|
||||
/// <remarks>Expected to be called at startup and on reoccurring basis</remarks>
|
||||
public async Task ValidateLicenseStatus()
|
||||
{
|
||||
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||
try
|
||||
{
|
||||
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
if (string.IsNullOrEmpty(license.Value)) return;
|
||||
if (string.IsNullOrEmpty(license.Value)) {
|
||||
await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Validating Kavita+ License");
|
||||
var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License);
|
||||
await provider.FlushAsync();
|
||||
|
||||
await provider.FlushAsync();
|
||||
var isValid = await IsLicenseValid(license.Value);
|
||||
await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout);
|
||||
|
||||
@ -145,6 +148,7 @@ public class LicenseService : ILicenseService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins");
|
||||
await provider.SetAsync(CacheKey, false, _licenseCacheTimeout);
|
||||
BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30));
|
||||
}
|
||||
}
|
||||
|
@ -104,7 +104,7 @@ public class ScrobblingService : IScrobblingService
|
||||
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// An automated job that will run against all user's tokens and validate if they are still active
|
||||
/// </summary>
|
||||
/// <remarks>This service can validate without license check as the task which calls will be guarded</remarks>
|
||||
/// <returns></returns>
|
||||
@ -115,6 +115,7 @@ public class ScrobblingService : IScrobblingService
|
||||
foreach (var user in users)
|
||||
{
|
||||
if (string.IsNullOrEmpty(user.AniListAccessToken) || !_tokenService.HasTokenExpired(user.AniListAccessToken)) continue;
|
||||
_logger.LogInformation("User {UserName}'s AniList token has expired! They need to regenerate it for scrobbling to work", user.UserName);
|
||||
await _eventHub.SendMessageToAsync(MessageFactory.ScrobblingKeyExpired,
|
||||
MessageFactory.ScrobblingKeyExpiredEvent(ScrobbleProvider.AniList), user.Id);
|
||||
}
|
||||
@ -184,17 +185,13 @@ public class ScrobblingService : IScrobblingService
|
||||
public async Task ScrobbleReviewUpdate(int userId, int seriesId, string reviewTitle, string reviewBody)
|
||||
{
|
||||
if (!await _licenseService.HasActiveLicense()) return;
|
||||
var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList);
|
||||
if (await HasTokenExpired(token, ScrobbleProvider.AniList))
|
||||
{
|
||||
throw new KavitaException(await _localizationService.Translate(userId, "unable-to-register-k+"));
|
||||
}
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library);
|
||||
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
|
||||
if (library is not {AllowScrobbling: true}) return;
|
||||
if (library.Type == LibraryType.Comic) return;
|
||||
|
||||
_logger.LogInformation("Processing Scrobbling review event for {UserId} on {SeriesName}", userId, series.Name);
|
||||
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
|
||||
|
||||
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
|
||||
ScrobbleEventType.Review);
|
||||
@ -229,17 +226,12 @@ public class ScrobblingService : IScrobblingService
|
||||
public async Task ScrobbleRatingUpdate(int userId, int seriesId, float rating)
|
||||
{
|
||||
if (!await _licenseService.HasActiveLicense()) return;
|
||||
var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList);
|
||||
if (await HasTokenExpired(token, ScrobbleProvider.AniList))
|
||||
{
|
||||
throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired"));
|
||||
}
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library);
|
||||
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
|
||||
if (library is not {AllowScrobbling: true}) return;
|
||||
if (library.Type == LibraryType.Comic) return;
|
||||
|
||||
_logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name);
|
||||
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
|
||||
|
||||
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
|
||||
ScrobbleEventType.ScoreUpdated);
|
||||
@ -273,22 +265,12 @@ public class ScrobblingService : IScrobblingService
|
||||
public async Task ScrobbleReadingUpdate(int userId, int seriesId)
|
||||
{
|
||||
if (!await _licenseService.HasActiveLicense()) return;
|
||||
var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList);
|
||||
if (await HasTokenExpired(token, ScrobbleProvider.AniList))
|
||||
{
|
||||
throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired"));
|
||||
}
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library);
|
||||
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||
if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId))
|
||||
{
|
||||
_logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name, userId);
|
||||
return;
|
||||
}
|
||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
|
||||
if (library is not {AllowScrobbling: true}) return;
|
||||
if (library.Type == LibraryType.Comic) return;
|
||||
|
||||
_logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name);
|
||||
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
|
||||
|
||||
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
|
||||
ScrobbleEventType.ChapterRead);
|
||||
@ -338,17 +320,12 @@ public class ScrobblingService : IScrobblingService
|
||||
public async Task ScrobbleWantToReadUpdate(int userId, int seriesId, bool onWantToRead)
|
||||
{
|
||||
if (!await _licenseService.HasActiveLicense()) return;
|
||||
var token = await GetTokenForProvider(userId, ScrobbleProvider.AniList);
|
||||
if (await HasTokenExpired(token, ScrobbleProvider.AniList))
|
||||
{
|
||||
throw new KavitaException(await _localizationService.Translate(userId, "anilist-cred-expired"));
|
||||
}
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library);
|
||||
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
|
||||
if (library is not {AllowScrobbling: true}) return;
|
||||
if (library.Type == LibraryType.Comic) return;
|
||||
|
||||
_logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name);
|
||||
if (await CheckIfCanScrobble(userId, seriesId, series)) return;
|
||||
|
||||
var existing = await _unitOfWork.ScrobbleRepository.Exists(userId, series.Id,
|
||||
onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead);
|
||||
@ -369,6 +346,21 @@ public class ScrobblingService : IScrobblingService
|
||||
_logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {UserId} ", series.Name, userId);
|
||||
}
|
||||
|
||||
private async Task<bool> CheckIfCanScrobble(int userId, int seriesId, Series series)
|
||||
{
|
||||
if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId))
|
||||
{
|
||||
_logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name,
|
||||
userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId);
|
||||
if (library is not {AllowScrobbling: true}) return true;
|
||||
if (library.Type == LibraryType.Comic) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<int> GetRateLimit(string license, string aniListToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(aniListToken)) return 0;
|
||||
|
@ -65,16 +65,19 @@ public class SeriesService : ISeriesService
|
||||
/// <returns></returns>
|
||||
public static Chapter? GetFirstChapterForMetadata(Series series)
|
||||
{
|
||||
var sortedVolumes = series.Volumes.OrderBy(v => v.Number, ChapterSortComparer.Default);
|
||||
var sortedVolumes = series.Volumes
|
||||
.Where(v => float.TryParse(v.Name, out var parsedValue) && parsedValue != 0.0f)
|
||||
.OrderBy(v => float.TryParse(v.Name, out var parsedValue) ? parsedValue : float.MaxValue);
|
||||
var minVolumeNumber = sortedVolumes
|
||||
.Where(v => v.Number != 0)
|
||||
.MinBy(v => v.Number);
|
||||
.MinBy(v => float.Parse(v.Name));
|
||||
|
||||
var minChapter = series.Volumes
|
||||
.SelectMany(v => v.Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparer.Default))
|
||||
|
||||
var allChapters = series.Volumes
|
||||
.SelectMany(v => v.Chapters.OrderBy(c => float.Parse(c.Number), ChapterSortComparer.Default)).ToList();
|
||||
var minChapter = allChapters
|
||||
.FirstOrDefault();
|
||||
|
||||
if (minVolumeNumber != null && minChapter != null && float.Parse(minChapter.Number) > minVolumeNumber.Number)
|
||||
if (minVolumeNumber != null && minChapter != null && float.TryParse(minChapter.Number, out var chapNum) && chapNum >= minVolumeNumber.Number)
|
||||
{
|
||||
return minVolumeNumber.Chapters.MinBy(c => float.Parse(c.Number), ChapterSortComparer.Default);
|
||||
}
|
||||
|
@ -27,7 +27,7 @@ public interface IStatisticService
|
||||
Task<IEnumerable<TopReadDto>> GetTopUsers(int days);
|
||||
Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId);
|
||||
Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0, int days = 0);
|
||||
IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown();
|
||||
IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown(int userId = 0);
|
||||
IEnumerable<StatCount<int>> GetPagesReadCountByYear(int userId = 0);
|
||||
IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0);
|
||||
Task UpdateServerStatistics();
|
||||
@ -411,11 +411,12 @@ public class StatisticService : IStatisticService
|
||||
return results.OrderBy(r => r.Value);
|
||||
}
|
||||
|
||||
public IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown()
|
||||
public IEnumerable<StatCount<DayOfWeek>> GetDayBreakdown(int userId)
|
||||
{
|
||||
return _context.AppUserProgresses
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking()
|
||||
.WhereIf(userId > 0, p => p.AppUserId == userId)
|
||||
.GroupBy(p => p.LastModified.DayOfWeek)
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => new StatCount<DayOfWeek>{ Value = g.Key, Count = g.Count() })
|
||||
|
@ -229,14 +229,20 @@ public class LibraryWatcher : ILibraryWatcher
|
||||
public async Task ProcessChange(string filePath, bool isDirectoryChange = false)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
_logger.LogDebug("[LibraryWatcher] Processing change of {FilePath}", filePath);
|
||||
_logger.LogTrace("[LibraryWatcher] Processing change of {FilePath}", filePath);
|
||||
try
|
||||
{
|
||||
// If the change occurs in a blacklisted folder path, then abort processing
|
||||
if (Parser.Parser.HasBlacklistedFolderInPath(filePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// If not a directory change AND file is not an archive or book, ignore
|
||||
if (!isDirectoryChange &&
|
||||
!(Parser.Parser.IsArchive(filePath) || Parser.Parser.IsBook(filePath)))
|
||||
{
|
||||
_logger.LogDebug("[LibraryWatcher] Change from {FilePath} is not an archive or book, ignoring change", filePath);
|
||||
_logger.LogTrace("[LibraryWatcher] Change from {FilePath} is not an archive or book, ignoring change", filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -248,10 +254,10 @@ public class LibraryWatcher : ILibraryWatcher
|
||||
.ToList();
|
||||
|
||||
var fullPath = GetFolder(filePath, libraryFolders);
|
||||
_logger.LogDebug("Folder path: {FolderPath}", fullPath);
|
||||
_logger.LogTrace("Folder path: {FolderPath}", fullPath);
|
||||
if (string.IsNullOrEmpty(fullPath))
|
||||
{
|
||||
_logger.LogDebug("[LibraryWatcher] Change from {FilePath} could not find root level folder, ignoring change", filePath);
|
||||
_logger.LogTrace("[LibraryWatcher] Change from {FilePath} could not find root level folder, ignoring change", filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -146,7 +146,6 @@ public class ProcessSeries : IProcessSeries
|
||||
_logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName);
|
||||
|
||||
// parsedInfos[0] is not the first volume or chapter. We need to find it using a ComicInfo check (as it uses firstParsedInfo for series sort)
|
||||
// BUG: This check doesn't work for Books, as books usually have metadata on all files. (#2167)
|
||||
var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, firstInfo);
|
||||
|
||||
UpdateVolumes(series, parsedInfos, forceUpdate);
|
||||
|
@ -112,7 +112,7 @@ export class StatisticsService {
|
||||
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days);
|
||||
}
|
||||
|
||||
getDayBreakdown() {
|
||||
return this.httpClient.get<Array<StatCount<DayOfWeek>>>(this.baseUrl + 'stats/day-breakdown');
|
||||
getDayBreakdown( userId = 0) {
|
||||
return this.httpClient.get<Array<StatCount<DayOfWeek>>>(this.baseUrl + 'stats/day-breakdown?userId=' + userId);
|
||||
}
|
||||
}
|
||||
|
@ -30,7 +30,6 @@ import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-ope
|
||||
import { NgIf, DecimalPipe } from '@angular/common';
|
||||
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
|
||||
|
||||
|
||||
|
@ -5,7 +5,6 @@ import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ContentChild,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
@ -13,7 +12,6 @@ import {
|
||||
Inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
TemplateRef,
|
||||
@ -70,12 +68,15 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
||||
* Any actions to exist on the header for the parent collection (library, collection)
|
||||
*/
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
@Input() trackByIdentity!: TrackByFunction<any>; //(index: number, item: any) => string
|
||||
/**
|
||||
* A trackBy to help with rendering. This is required as without it there are issues when scrolling
|
||||
*/
|
||||
@Input({required: true}) trackByIdentity!: TrackByFunction<any>;
|
||||
@Input() filterSettings!: FilterSettings;
|
||||
@Input() refresh!: EventEmitter<void>;
|
||||
|
||||
|
||||
@Input() jumpBarKeys: Array<JumpKey> = []; // This is aprox 784 pixels tall, original keys
|
||||
@Input() jumpBarKeys: Array<JumpKey> = []; // This is approx 784 pixels tall, original keys
|
||||
jumpBarKeysToRender: Array<JumpKey> = []; // What is rendered on screen
|
||||
|
||||
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
|
||||
@ -115,7 +116,7 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.trackByIdentity === undefined) {
|
||||
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`;
|
||||
this.trackByIdentity = (_: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`;
|
||||
}
|
||||
|
||||
if (this.filterSettings === undefined) {
|
||||
|
@ -25,7 +25,6 @@ import {CompactNumberPipe} from "../../pipe/compact-number.pipe";
|
||||
import {AgeRatingPipe} from "../../pipe/age-rating.pipe";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {MetadataDetailComponent} from "../../series-detail/_components/metadata-detail/metadata-detail.component";
|
||||
import {FilterQueryParam} from "../../shared/_services/filter-utilities.service";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||
@ -132,6 +131,4 @@ export class EntityInfoCardsComponent implements OnInit {
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
protected readonly FilterQueryParam = FilterQueryParam;
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import {Title} from '@angular/platform-browser';
|
||||
import {Router, RouterLink} from '@angular/router';
|
||||
import {Observable, of, ReplaySubject} from 'rxjs';
|
||||
import {debounceTime, map, shareReplay, take, tap} from 'rxjs/operators';
|
||||
import {FilterQueryParam, FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
|
||||
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
|
||||
import {SeriesAddedEvent} from 'src/app/_models/events/series-added-event';
|
||||
import {SeriesRemovedEvent} from 'src/app/_models/events/series-removed-event';
|
||||
import {Library} from 'src/app/_models/library';
|
||||
@ -169,23 +169,36 @@ export class DashboardComponent implements OnInit {
|
||||
handleSectionClick(sectionTitle: string) {
|
||||
if (sectionTitle.toLowerCase() === 'recently updated series') {
|
||||
const params: any = {};
|
||||
params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc
|
||||
params[FilterQueryParam.Page] = 1;
|
||||
params['page'] = 1;
|
||||
params['title'] = 'Recently Updated';
|
||||
this.router.navigate(['all-series'], {queryParams: params});
|
||||
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||
if (filter.sortOptions) {
|
||||
filter.sortOptions.sortField = SortField.LastChapterAdded;
|
||||
filter.sortOptions.isAscending = false;
|
||||
}
|
||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
|
||||
} else if (sectionTitle.toLowerCase() === 'on deck') {
|
||||
const params: any = {};
|
||||
params[FilterQueryParam.ReadStatus] = 'true,false,false';
|
||||
params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc
|
||||
params[FilterQueryParam.Page] = 1;
|
||||
params['page'] = 1;
|
||||
params['title'] = 'On Deck';
|
||||
this.router.navigate(['all-series'], {queryParams: params});
|
||||
|
||||
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||
filter.statements.push({field: FilterField.ReadProgress, comparison: FilterComparison.GreaterThan, value: '0'});
|
||||
if (filter.sortOptions) {
|
||||
filter.sortOptions.sortField = SortField.LastChapterAdded;
|
||||
filter.sortOptions.isAscending = false;
|
||||
}
|
||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
|
||||
}else if (sectionTitle.toLowerCase() === 'newly added series') {
|
||||
const params: any = {};
|
||||
params[FilterQueryParam.SortBy] = SortField.Created + ',false'; // sort by created, desc
|
||||
params[FilterQueryParam.Page] = 1;
|
||||
params['page'] = 1;
|
||||
params['title'] = 'Newly Added';
|
||||
this.router.navigate(['all-series'], {queryParams: params});
|
||||
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||
if (filter.sortOptions) {
|
||||
filter.sortOptions.sortField = SortField.Created;
|
||||
filter.sortOptions.isAscending = false;
|
||||
}
|
||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -25,13 +25,13 @@
|
||||
</div>
|
||||
<form [formGroup]="sortGroup" class="container-fluid">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-2">
|
||||
<div class="col-md-2 col-sm-2">
|
||||
<div class="form-group pe-1">
|
||||
<label for="limit-to" class="form-label">{{t('limit-label')}}</label>
|
||||
<input id="limit-to" type="number" inputmode="numeric" class="form-control" formControlName="limitTo">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-3 col-sm-10">
|
||||
<label for="sort-options" class="form-label">{{t('sort-by-label')}}</label>
|
||||
<button class="btn btn-sm btn-secondary-outline" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0;" [disabled]="filterSettings.sortDisabled">
|
||||
<i class="fa fa-arrow-up" [title]="t('ascending-alt')" *ngIf="isAscendingSort; else descSort"></i>
|
||||
@ -43,15 +43,23 @@
|
||||
<option *ngFor="let field of allSortFields" [value]="field">{{field | sortField}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- TODO: I might want to put a Clear button which blanks out the whole filter -->
|
||||
<div class="col-md-2 me-3 mt-4">
|
||||
<button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button>
|
||||
</div>
|
||||
<div class="col-md-2 me-3 mt-4">
|
||||
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *ngIf="utilityService.getActiveBreakpoint() > Breakpoint.Tablet" [ngTemplateOutlet]="buttons"></ng-container>
|
||||
</div>
|
||||
<div class="row mb-3" *ngIf="utilityService.getActiveBreakpoint() <= Breakpoint.Tablet">
|
||||
<ng-container [ngTemplateOutlet]="buttons"></ng-container>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template #buttons>
|
||||
<!-- TODO: I might want to put a Clear button which blanks out the whole filter -->
|
||||
<div class="col-md-2 col-sm-6 mt-4">
|
||||
<button class="btn btn-secondary col-12" (click)="clear()">{{t('reset')}}</button>
|
||||
</div>
|
||||
<div class="col-md-2 col-sm-6 mt-4">
|
||||
<button class="btn btn-primary col-12" (click)="apply()">{{t('apply')}}</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
|
||||
|
@ -55,6 +55,7 @@ export class MetadataFilterComponent implements OnInit {
|
||||
|
||||
@ContentChild('[ngbCollapse]') collapse!: NgbCollapse;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
public readonly utilityService = inject(UtilityService);
|
||||
|
||||
|
||||
/**
|
||||
@ -82,10 +83,7 @@ export class MetadataFilterComponent implements OnInit {
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
|
||||
constructor(private utilityService: UtilityService,
|
||||
public toggleService: ToggleService,
|
||||
private filterUtilityService: FilterUtilitiesService) {
|
||||
}
|
||||
constructor(public toggleService: ToggleService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.filterSettings === undefined) {
|
||||
@ -203,4 +201,5 @@ export class MetadataFilterComponent implements OnInit {
|
||||
this.toggleService.set(!this.filteringCollapsed);
|
||||
}
|
||||
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
}
|
||||
|
@ -79,7 +79,7 @@
|
||||
</ng-template>
|
||||
|
||||
<ng-template #tagTemplate let-item>
|
||||
<div style="display: flex;padding: 5px;" (click)="goTo('tags', item.id)">
|
||||
<div style="display: flex;padding: 5px;" (click)="goToOther(FilterField.Tags, item.id)">
|
||||
<div class="ms-1">
|
||||
<span>{{item.title}}</span>
|
||||
</div>
|
||||
@ -97,7 +97,7 @@
|
||||
</ng-template>
|
||||
|
||||
<ng-template #genreTemplate let-item>
|
||||
<div style="display: flex;padding: 5px;" class="clickable" (click)="goTo('genres', item.id)">
|
||||
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToOther(FilterField.Genres, item.id)">
|
||||
<div class="ms-1">
|
||||
<div [innerHTML]="item.title"></div>
|
||||
</div>
|
||||
|
@ -1,40 +1,44 @@
|
||||
import { DOCUMENT, NgIf, NgOptimizedImage, AsyncPipe } from '@angular/common';
|
||||
import {AsyncPipe, DOCUMENT, NgIf, NgOptimizedImage} from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
inject,
|
||||
Inject,
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { fromEvent } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, filter, tap } from 'rxjs/operators';
|
||||
import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { Library } from 'src/app/_models/library';
|
||||
import { MangaFile } from 'src/app/_models/manga-file';
|
||||
import { PersonRole } from 'src/app/_models/metadata/person';
|
||||
import { ReadingList } from 'src/app/_models/reading-list';
|
||||
import { SearchResult } from 'src/app/_models/search/search-result';
|
||||
import { SearchResultGroup } from 'src/app/_models/search/search-result-group';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { NavService } from 'src/app/_services/nav.service';
|
||||
import { ScrollService } from 'src/app/_services/scroll.service';
|
||||
import { SearchService } from 'src/app/_services/search.service';
|
||||
import {NavigationEnd, Router, RouterLink, RouterLinkActive} from '@angular/router';
|
||||
import {fromEvent} from 'rxjs';
|
||||
import {debounceTime, distinctUntilChanged, filter, tap} from 'rxjs/operators';
|
||||
import {Chapter} from 'src/app/_models/chapter';
|
||||
import {CollectionTag} from 'src/app/_models/collection-tag';
|
||||
import {Library} from 'src/app/_models/library';
|
||||
import {MangaFile} from 'src/app/_models/manga-file';
|
||||
import {PersonRole} from 'src/app/_models/metadata/person';
|
||||
import {ReadingList} from 'src/app/_models/reading-list';
|
||||
import {SearchResult} from 'src/app/_models/search/search-result';
|
||||
import {SearchResultGroup} from 'src/app/_models/search/search-result-group';
|
||||
import {AccountService} from 'src/app/_services/account.service';
|
||||
import {ImageService} from 'src/app/_services/image.service';
|
||||
import {NavService} from 'src/app/_services/nav.service';
|
||||
import {ScrollService} from 'src/app/_services/scroll.service';
|
||||
import {SearchService} from 'src/app/_services/search.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import { SentenceCasePipe } from '../../../pipe/sentence-case.pipe';
|
||||
import { PersonRolePipe } from '../../../pipe/person-role.pipe';
|
||||
import { NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { EventsWidgetComponent } from '../events-widget/events-widget.component';
|
||||
import { SeriesFormatComponent } from '../../../shared/series-format/series-format.component';
|
||||
import { ImageComponent } from '../../../shared/image/image.component';
|
||||
import { GroupedTypeaheadComponent } from '../grouped-typeahead/grouped-typeahead.component';
|
||||
import {SentenceCasePipe} from '../../../pipe/sentence-case.pipe';
|
||||
import {PersonRolePipe} from '../../../pipe/person-role.pipe';
|
||||
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {EventsWidgetComponent} from '../events-widget/events-widget.component';
|
||||
import {SeriesFormatComponent} from '../../../shared/series-format/series-format.component';
|
||||
import {ImageComponent} from '../../../shared/image/image.component';
|
||||
import {GroupedTypeaheadComponent} from '../grouped-typeahead/grouped-typeahead.component';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
|
||||
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
|
||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-header',
|
||||
@ -58,10 +62,12 @@ export class NavHeaderComponent implements OnInit {
|
||||
backToTopNeeded = false;
|
||||
searchFocused: boolean = false;
|
||||
scrollElem: HTMLElement;
|
||||
protected readonly FilterField = FilterField;
|
||||
|
||||
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
|
||||
public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
|
||||
private scrollService: ScrollService, private searchService: SearchService, private readonly cdRef: ChangeDetectorRef) {
|
||||
private scrollService: ScrollService, private searchService: SearchService, private readonly cdRef: ChangeDetectorRef,
|
||||
private filterUtilityService: FilterUtilitiesService) {
|
||||
this.scrollElem = this.document.body;
|
||||
}
|
||||
|
||||
@ -69,12 +75,11 @@ export class NavHeaderComponent implements OnInit {
|
||||
this.scrollService.scrollContainer$.pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef), tap((scrollContainer) => {
|
||||
if (scrollContainer === 'body' || scrollContainer === undefined) {
|
||||
this.scrollElem = this.document.body;
|
||||
fromEvent(this.document.body, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(this.document.body));
|
||||
} else {
|
||||
const elem = scrollContainer as ElementRef<HTMLDivElement>;
|
||||
this.scrollElem = elem.nativeElement;
|
||||
fromEvent(elem.nativeElement, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(elem.nativeElement));
|
||||
}
|
||||
fromEvent(this.scrollElem, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(this.scrollElem));
|
||||
})).subscribe();
|
||||
|
||||
// Sometimes the top event emitter can be slow, so let's also check when a navigation occurs and recalculate
|
||||
@ -125,49 +130,54 @@ export class NavHeaderComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
goTo(queryParamName: string, filter: any) {
|
||||
goTo(statement: FilterStatement) {
|
||||
let params: any = {};
|
||||
params[queryParamName] = filter;
|
||||
params[FilterQueryParam.Page] = 1;
|
||||
const filter = this.filterUtilityService.createSeriesV2Filter();
|
||||
filter.statements = [statement];
|
||||
params['page'] = 1;
|
||||
this.clearSearch();
|
||||
this.router.navigate(['all-series'], {queryParams: params});
|
||||
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params);
|
||||
}
|
||||
|
||||
goToOther(field: FilterField, value: string) {
|
||||
this.goTo({field, comparison: FilterComparison.Equal, value});
|
||||
}
|
||||
|
||||
goToPerson(role: PersonRole, filter: any) {
|
||||
this.clearSearch();
|
||||
switch(role) {
|
||||
case PersonRole.Writer:
|
||||
this.goTo(FilterQueryParam.Writers, filter);
|
||||
this.goTo({field: FilterField.Writers, comparison: FilterComparison.Equal, value: filter});
|
||||
break;
|
||||
case PersonRole.Artist:
|
||||
this.goTo(FilterQueryParam.Artists, filter);
|
||||
this.goTo({field: FilterField.CoverArtist, comparison: FilterComparison.Equal, value: filter}); // TODO: What is this supposed to be?
|
||||
break;
|
||||
case PersonRole.Character:
|
||||
this.goTo(FilterQueryParam.Character, filter);
|
||||
this.goTo({field: FilterField.Characters, comparison: FilterComparison.Equal, value: filter});
|
||||
break;
|
||||
case PersonRole.Colorist:
|
||||
this.goTo(FilterQueryParam.Colorist, filter);
|
||||
this.goTo({field: FilterField.Colorist, comparison: FilterComparison.Equal, value: filter});
|
||||
break;
|
||||
case PersonRole.Editor:
|
||||
this.goTo(FilterQueryParam.Editor, filter);
|
||||
this.goTo({field: FilterField.Editor, comparison: FilterComparison.Equal, value: filter});
|
||||
break;
|
||||
case PersonRole.Inker:
|
||||
this.goTo(FilterQueryParam.Inker, filter);
|
||||
this.goTo({field: FilterField.Inker, comparison: FilterComparison.Equal, value: filter});
|
||||
break;
|
||||
case PersonRole.CoverArtist:
|
||||
this.goTo(FilterQueryParam.CoverArtists, filter);
|
||||
this.goTo({field: FilterField.CoverArtist, comparison: FilterComparison.Equal, value: filter});
|
||||
break;
|
||||
case PersonRole.Letterer:
|
||||
this.goTo(FilterQueryParam.Letterer, filter);
|
||||
this.goTo({field: FilterField.Letterer, comparison: FilterComparison.Equal, value: filter});
|
||||
break;
|
||||
case PersonRole.Penciller:
|
||||
this.goTo(FilterQueryParam.Penciller, filter);
|
||||
this.goTo({field: FilterField.Penciller, comparison: FilterComparison.Equal, value: filter});
|
||||
break;
|
||||
case PersonRole.Publisher:
|
||||
this.goTo(FilterQueryParam.Publisher, filter);
|
||||
this.goTo({field: FilterField.Publisher, comparison: FilterComparison.Equal, value: filter});
|
||||
break;
|
||||
case PersonRole.Translator:
|
||||
this.goTo(FilterQueryParam.Translator, filter);
|
||||
this.goTo({field: FilterField.Translators, comparison: FilterComparison.Equal, value: filter});
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -232,4 +242,6 @@ export class NavHeaderComponent implements OnInit {
|
||||
hideSideNav() {
|
||||
this.navService.toggleSideNav();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div class="row g-0">
|
||||
<div class="col-auto custom-col clickable" [ngbPopover]="popContent"
|
||||
popoverTitle="Your Rating + Overall" popoverClass="md-popover">
|
||||
popoverTitle="Your Rating + Overall" [popoverClass]="utilityService.getActiveBreakpoint() > Breakpoint.Mobile ? 'md-popover' : 'lg-popover'">
|
||||
<span class="badge rounded-pill ps-0 me-1">
|
||||
<img class="me-1" ngSrc="assets/images/logo-32.png" width="24" height="24" alt="">
|
||||
<ng-container *ngIf="hasUserRated; else notYetRated">{{userRating * 20}}</ng-container>
|
||||
@ -23,7 +23,7 @@
|
||||
</div>
|
||||
|
||||
<ng-template #popContent>
|
||||
<ngx-stars [initialStars]="userRating" (ratingOutput)="updateRating($event)"
|
||||
<ngx-stars [initialStars]="userRating" (ratingOutput)="updateRating($event)" [size]="utilityService.getActiveBreakpoint() > Breakpoint.Mobile ? 1 : 2"
|
||||
[maxStars]="5" [color]="starColor"></ngx-stars>
|
||||
{{userRating * 20}}%
|
||||
</ng-template>
|
||||
|
@ -19,6 +19,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.lg-popover {
|
||||
width: 320px;
|
||||
|
||||
> .popover-body {
|
||||
padding-top: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.rating-star {
|
||||
i {
|
||||
position: relative;
|
||||
|
@ -18,6 +18,7 @@ import {LibraryType} from "../../../_models/library";
|
||||
import {ProviderNamePipe} from "../../../pipe/provider-name.pipe";
|
||||
import {NgxStarsModule} from "ngx-stars";
|
||||
import {ThemeService} from "../../../_services/theme.service";
|
||||
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-external-rating',
|
||||
@ -37,6 +38,7 @@ export class ExternalRatingComponent implements OnInit {
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
public readonly utilityService = inject(UtilityService);
|
||||
|
||||
ratings: Array<Rating> = [];
|
||||
isLoading: boolean = false;
|
||||
@ -71,4 +73,6 @@ export class ExternalRatingComponent implements OnInit {
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
}
|
||||
|
@ -3,8 +3,7 @@ import {CommonModule} from '@angular/common';
|
||||
import {A11yClickDirective} from "../../../shared/a11y-click.directive";
|
||||
import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.component";
|
||||
import {TagBadgeComponent, TagBadgeCursor} from "../../../shared/tag-badge/tag-badge.component";
|
||||
import {FilterQueryParam, FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
|
||||
import {Router} from "@angular/router";
|
||||
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
|
||||
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||
|
||||
@ -25,7 +24,6 @@ export class MetadataDetailComponent {
|
||||
@ContentChild('titleTemplate') titleTemplate!: TemplateRef<any>;
|
||||
@ContentChild('itemTemplate') itemTemplate?: TemplateRef<any>;
|
||||
|
||||
private readonly router = inject(Router);
|
||||
private readonly filterUtilitiesService = inject(FilterUtilitiesService);
|
||||
protected readonly TagBadgeCursor = TagBadgeCursor;
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ActivatedRouteSnapshot, Router} from '@angular/router';
|
||||
import {ActivatedRouteSnapshot, Params, Router} from '@angular/router';
|
||||
import {Pagination} from 'src/app/_models/pagination';
|
||||
import {SortField, SortOptions} from 'src/app/_models/metadata/series-filter';
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
@ -9,50 +9,17 @@ import {FilterCombination} from "../../_models/metadata/v2/filter-combination";
|
||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
||||
|
||||
/**
|
||||
* Used to pass state between the filter and the url
|
||||
*/
|
||||
export enum FilterQueryParam {
|
||||
Format = 'format',
|
||||
Genres = 'genres',
|
||||
AgeRating = 'ageRating',
|
||||
PublicationStatus = 'publicationStatus',
|
||||
Tags = 'tags',
|
||||
Languages = 'languages',
|
||||
CollectionTags = 'collectionTags',
|
||||
Libraries = 'libraries',
|
||||
Writers = 'writers',
|
||||
Artists = 'artists',
|
||||
Character = 'character',
|
||||
Colorist = 'colorist',
|
||||
CoverArtists = 'coverArtists',
|
||||
Editor = 'editor',
|
||||
Inker = 'inker',
|
||||
Letterer = 'letterer',
|
||||
Penciller = 'penciller',
|
||||
Publisher = 'publisher',
|
||||
Translator = 'translators',
|
||||
ReadStatus = 'readStatus',
|
||||
SortBy = 'sortBy',
|
||||
Rating = 'rating',
|
||||
Name = 'name',
|
||||
/**
|
||||
* This is a pagination control
|
||||
*/
|
||||
Page = 'page',
|
||||
/**
|
||||
* Special case for the UI. Does not trigger filtering
|
||||
*/
|
||||
None = 'none'
|
||||
}
|
||||
const sortOptionsKey = 'sortOptions=';
|
||||
const statementsKey = 'stmts=';
|
||||
const limitToKey = 'limitTo=';
|
||||
const combinationKey = 'combination=';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FilterUtilitiesService {
|
||||
|
||||
constructor(private metadataService: MetadataService, private router: Router) {
|
||||
}
|
||||
constructor(private metadataService: MetadataService, private router: Router) {}
|
||||
|
||||
applyFilter(page: Array<any>, filter: FilterField, comparison: FilterComparison, value: string) {
|
||||
const dto: SeriesFilterV2 = {
|
||||
@ -61,8 +28,14 @@ export class FilterUtilitiesService {
|
||||
limitTo: 0
|
||||
};
|
||||
|
||||
console.log('applying filter: ', this.urlFromFilterV2(page.join('/') + '?', dto))
|
||||
this.router.navigateByUrl(this.urlFromFilterV2(page.join('/') + '?', dto));
|
||||
const url = this.urlFromFilterV2(page.join('/') + '?', dto);
|
||||
return this.router.navigateByUrl(url);
|
||||
}
|
||||
|
||||
applyFilterWithParams(page: Array<any>, filter: SeriesFilterV2, extraParams: Params) {
|
||||
let url = this.urlFromFilterV2(page.join('/') + '?', filter);
|
||||
url += Object.keys(extraParams).map(k => `&${k}=${extraParams[k]}`).join('');
|
||||
return this.router.navigateByUrl(url, extraParams);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -84,7 +57,7 @@ export class FilterUtilitiesService {
|
||||
|
||||
/**
|
||||
* Will fetch current page from route if present
|
||||
* @param ActivatedRouteSnapshot to fetch page from. Must be from component else may get stale data
|
||||
* @param snapshot to fetch page from. Must be from component else may get stale data
|
||||
* @param itemsPerPage If you want pagination, pass non-zero number
|
||||
* @returns A default pagination object
|
||||
*/
|
||||
@ -107,10 +80,10 @@ export class FilterUtilitiesService {
|
||||
|
||||
encodeSeriesFilter(filter: SeriesFilterV2) {
|
||||
const encodedStatements = this.encodeFilterStatements(filter.statements);
|
||||
const encodedSortOptions = filter.sortOptions ? `sortOptions=${this.encodeSortOptions(filter.sortOptions)}` : '';
|
||||
const encodedLimitTo = `limitTo=${filter.limitTo}`;
|
||||
const encodedSortOptions = filter.sortOptions ? `${sortOptionsKey}${this.encodeSortOptions(filter.sortOptions)}` : '';
|
||||
const encodedLimitTo = `${limitToKey}${filter.limitTo}`;
|
||||
|
||||
return `${this.encodeName(filter.name)}stmts=${encodedStatements}&${encodedSortOptions}&${encodedLimitTo}&combination=${filter.combination}`;
|
||||
return `${this.encodeName(filter.name)}${encodedStatements}&${encodedSortOptions}&${encodedLimitTo}&${combinationKey}${filter.combination}`;
|
||||
}
|
||||
|
||||
encodeName(name: string | undefined) {
|
||||
@ -124,7 +97,8 @@ export class FilterUtilitiesService {
|
||||
}
|
||||
|
||||
encodeFilterStatements(statements: Array<FilterStatement>) {
|
||||
return encodeURIComponent(statements.map(statement => {
|
||||
if (statements.length === 0) return '';
|
||||
return statementsKey + encodeURIComponent(statements.map(statement => {
|
||||
const encodedComparison = `comparison=${statement.comparison}`;
|
||||
const encodedField = `field=${statement.field}`;
|
||||
const encodedValue = `value=${encodeURIComponent(statement.value)}`;
|
||||
@ -144,19 +118,23 @@ export class FilterUtilitiesService {
|
||||
}
|
||||
|
||||
const fullUrl = window.location.href.split('?')[1];
|
||||
const stmtsStartIndex = fullUrl.indexOf('stmts=');
|
||||
let endIndex = fullUrl.indexOf('&sortOptions=');
|
||||
const stmtsStartIndex = fullUrl.indexOf(statementsKey);
|
||||
let endIndex = fullUrl.indexOf('&' + sortOptionsKey);
|
||||
if (endIndex < 0) {
|
||||
endIndex = fullUrl.indexOf('&limitTo=');
|
||||
endIndex = fullUrl.indexOf('&' + limitToKey);
|
||||
}
|
||||
|
||||
if (stmtsStartIndex !== -1 && endIndex !== -1) {
|
||||
const stmtsEncoded = fullUrl.substring(stmtsStartIndex + 6, endIndex);
|
||||
if (stmtsStartIndex !== -1 || endIndex !== -1) {
|
||||
// +1 is for the =
|
||||
const stmtsEncoded = fullUrl.substring(stmtsStartIndex + statementsKey.length, endIndex);
|
||||
filter.statements = this.decodeFilterStatements(stmtsEncoded);
|
||||
}
|
||||
|
||||
if (queryParams.sortOptions) {
|
||||
const sortOptions = this.decodeSortOptions(queryParams.sortOptions);
|
||||
const optionsStartIndex = fullUrl.indexOf('&' + sortOptionsKey);
|
||||
const endIndex = fullUrl.indexOf('&' + limitToKey);
|
||||
const sortOptionsEncoded = fullUrl.substring(optionsStartIndex + sortOptionsKey.length + 1, endIndex);
|
||||
const sortOptions = this.decodeSortOptions(sortOptionsEncoded);
|
||||
if (sortOptions) {
|
||||
filter.sortOptions = sortOptions;
|
||||
}
|
||||
@ -174,7 +152,7 @@ export class FilterUtilitiesService {
|
||||
}
|
||||
|
||||
decodeSortOptions(encodedSortOptions: string): SortOptions | null {
|
||||
const parts = encodedSortOptions.split('&');
|
||||
const parts = decodeURIComponent(encodedSortOptions).split('&');
|
||||
const sortFieldPart = parts.find(part => part.startsWith('sortField='));
|
||||
const isAscendingPart = parts.find(part => part.startsWith('isAscending='));
|
||||
|
||||
@ -188,7 +166,7 @@ export class FilterUtilitiesService {
|
||||
}
|
||||
|
||||
decodeFilterStatements(encodedStatements: string): FilterStatement[] {
|
||||
const statementStrings = decodeURIComponent(encodedStatements).split(','); // I don't think this will wrk
|
||||
const statementStrings = decodeURIComponent(encodedStatements).split(',');
|
||||
return statementStrings.map(statementString => {
|
||||
const parts = statementString.split('&');
|
||||
if (parts === null || parts.length < 3) return null;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {ChangeDetectionStrategy, Component, DestroyRef, inject} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
||||
import {FormControl} from '@angular/forms';
|
||||
import { BarChartModule } from '@swimlane/ngx-charts';
|
||||
import {map, Observable} from 'rxjs';
|
||||
@ -18,8 +18,9 @@ import {TranslocoDirective} from "@ngneat/transloco";
|
||||
standalone: true,
|
||||
imports: [BarChartModule, AsyncPipe, TranslocoDirective]
|
||||
})
|
||||
export class DayBreakdownComponent {
|
||||
export class DayBreakdownComponent implements OnInit {
|
||||
|
||||
@Input() userId = 0;
|
||||
view: [number, number] = [0,0];
|
||||
showLegend: boolean = true;
|
||||
|
||||
@ -27,9 +28,11 @@ export class DayBreakdownComponent {
|
||||
dayBreakdown$!: Observable<Array<PieDataItem>>;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(private statService: StatisticsService) {
|
||||
constructor(private statService: StatisticsService) {}
|
||||
|
||||
ngOnInit() {
|
||||
const dayOfWeekPipe = new DayOfWeekPipe();
|
||||
this.dayBreakdown$ = this.statService.getDayBreakdown().pipe(
|
||||
this.dayBreakdown$ = this.statService.getDayBreakdown(this.userId).pipe(
|
||||
map((data: Array<StatCount<DayOfWeek>>) => {
|
||||
return data.map(d => {
|
||||
return {name: dayOfWeekPipe.transform(d.value), value: d.count};
|
||||
|
@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, Component, DestroyRef, HostListener, inject} fr
|
||||
import {Router} from '@angular/router';
|
||||
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {map, Observable, ReplaySubject, shareReplay} from 'rxjs';
|
||||
import {FilterQueryParam, FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
|
||||
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
|
||||
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {Series} from 'src/app/_models/series';
|
||||
import {ImageService} from 'src/app/_services/image.service';
|
||||
|
@ -14,6 +14,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 pt-4 pb-2" style="height: 242px">
|
||||
<div class="col-md-12 col-sm-12 mt-4 pt-2">
|
||||
<app-day-breakdown [userId]="userId"></app-day-breakdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 pt-4 pb-2 " style="height: 242px">
|
||||
<app-stat-list [data$]="percentageRead$" [label]="t('read-percentage')" [title]="t('library-read-progress-title')"></app-stat-list>
|
||||
</div>
|
||||
|
@ -13,6 +13,7 @@ import {StatListComponent} from '../stat-list/stat-list.component';
|
||||
import {ReadingActivityComponent} from '../reading-activity/reading-activity.component';
|
||||
import {UserStatsInfoCardsComponent} from '../user-stats-info-cards/user-stats-info-cards.component';
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {DayBreakdownComponent} from "../day-breakdown/day-breakdown.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-stats',
|
||||
@ -27,6 +28,7 @@ import {TranslocoModule} from "@ngneat/transloco";
|
||||
StatListComponent,
|
||||
AsyncPipe,
|
||||
TranslocoModule,
|
||||
DayBreakdownComponent,
|
||||
],
|
||||
})
|
||||
export class UserStatsComponent implements OnInit {
|
||||
|
13
openapi.json
13
openapi.json
@ -7,7 +7,7 @@
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||
},
|
||||
"version": "0.7.7.9"
|
||||
"version": "0.7.7.10"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@ -9855,6 +9855,17 @@
|
||||
"tags": [
|
||||
"Stats"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "userId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"default": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
|
Loading…
x
Reference in New Issue
Block a user