mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Misc bunch of changes (#2815)
This commit is contained in:
parent
18792b7b56
commit
63c9bff32e
@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Progress;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.ManualMigrations;
|
||||
using API.DTOs.Progress;
|
||||
using API.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@ -29,4 +30,15 @@ public class AdminController : BaseApiController
|
||||
var users = await _userManager.GetUsersInRoleAsync("Admin");
|
||||
return users.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the progress information for a particular user
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[Authorize("RequireAdminRole")]
|
||||
[HttpPost("update-chapter-progress")]
|
||||
public async Task<ActionResult<bool>> UpdateChapterProgress(UpdateUserProgressDto dto)
|
||||
{
|
||||
return Ok(await Task.FromResult(false));
|
||||
}
|
||||
}
|
||||
|
@ -3,10 +3,12 @@ using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.Collection;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.Entities.Metadata;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.Services.Plus;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -23,14 +25,16 @@ public class CollectionController : BaseApiController
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ICollectionTagService _collectionService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IExternalMetadataService _externalMetadataService;
|
||||
|
||||
/// <inheritdoc />
|
||||
public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService,
|
||||
ILocalizationService localizationService)
|
||||
ILocalizationService localizationService, IExternalMetadataService externalMetadataService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_collectionService = collectionService;
|
||||
_localizationService = localizationService;
|
||||
_externalMetadataService = externalMetadataService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -168,4 +172,15 @@ public class CollectionController : BaseApiController
|
||||
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For the authenticated user, if they have an active Kavita+ subscription and a MAL username on record,
|
||||
/// fetch their Mal interest stacks (including restacks)
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("mal-stacks")]
|
||||
public async Task<ActionResult<IList<MalStackDto>>> GetMalStacksForUser()
|
||||
{
|
||||
return Ok(await _externalMetadataService.GetStacksForUser(User.GetUserId()));
|
||||
}
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.OPDS;
|
||||
using API.DTOs.Progress;
|
||||
using API.DTOs.Search;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Progress;
|
||||
using API.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
@ -7,8 +7,8 @@ using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Progress;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
@ -880,4 +880,21 @@ public class ReaderController : BaseApiController
|
||||
await _unitOfWork.CommitAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all progress events for a given chapter
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("all-chapter-progress")]
|
||||
public async Task<ActionResult<IEnumerable<FullProgressDto>>> GetProgressForChapter(int chapterId)
|
||||
{
|
||||
if (User.IsInRole(PolicyConstants.AdminRole))
|
||||
{
|
||||
return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId));
|
||||
}
|
||||
|
||||
return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, User.GetUserId()));
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -52,6 +52,23 @@ public class ScrobblingController : BaseApiController
|
||||
return Ok(user.AniListAccessToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current user's MAL token & username
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("mal-token")]
|
||||
public async Task<ActionResult<MalUserInfoDto>> GetMalToken()
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
return Ok(new MalUserInfoDto()
|
||||
{
|
||||
Username = user.MalUserName,
|
||||
AccessToken = user.MalAccessToken
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the current user's AniList token
|
||||
/// </summary>
|
||||
@ -76,6 +93,26 @@ public class ScrobblingController : BaseApiController
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the current user's MAL token (Client ID) and Username
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update-mal-token")]
|
||||
public async Task<ActionResult> UpdateMalToken(MalUserInfoDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
user.MalAccessToken = dto.AccessToken;
|
||||
user.MalUserName = dto.Username;
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the current Scrobbling token for the given Provider has expired for the current user
|
||||
/// </summary>
|
||||
|
@ -457,6 +457,7 @@ public class SettingsController : BaseApiController
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup.
|
||||
/// </summary>
|
||||
|
@ -8,6 +8,7 @@ using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.Services.Plus;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -22,14 +23,16 @@ public class StatsController : BaseApiController
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly ILicenseService _licenseService;
|
||||
|
||||
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork,
|
||||
UserManager<AppUser> userManager, ILocalizationService localizationService)
|
||||
UserManager<AppUser> userManager, ILocalizationService localizationService, ILicenseService licenseService)
|
||||
{
|
||||
_statService = statService;
|
||||
_unitOfWork = unitOfWork;
|
||||
_userManager = userManager;
|
||||
_localizationService = localizationService;
|
||||
_licenseService = licenseService;
|
||||
}
|
||||
|
||||
[HttpGet("user/{userId}/read")]
|
||||
@ -181,6 +184,18 @@ public class StatsController : BaseApiController
|
||||
return Ok(_statService.GetWordsReadCountByYear(userId));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns for Kavita+ the number of Series that have been processed, errored, and not processed
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[Authorize("RequireAdminRole")]
|
||||
[HttpGet("kavitaplus-metadata-breakdown")]
|
||||
[ResponseCache(CacheProfileName = "Statistics")]
|
||||
public async Task<ActionResult<IEnumerable<StatCount<int>>>> GetKavitaPlusMetadataBreakdown()
|
||||
{
|
||||
if (!await _licenseService.HasActiveLicense())
|
||||
return BadRequest("This data is not available for non-Kavita+ servers");
|
||||
return Ok(await _statService.GetKavitaPlusMetadataBreakdown());
|
||||
}
|
||||
|
||||
}
|
||||
|
19
API/DTOs/Collection/MalStackDto.cs
Normal file
19
API/DTOs/Collection/MalStackDto.cs
Normal file
@ -0,0 +1,19 @@
|
||||
namespace API.DTOs.Collection;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an Interest Stack from MAL
|
||||
/// </summary>
|
||||
public class MalStackDto
|
||||
{
|
||||
public required string Title { get; set; }
|
||||
public required long StackId { get; set; }
|
||||
public required string Url { get; set; }
|
||||
public required string? Author { get; set; }
|
||||
public required int SeriesCount { get; set; }
|
||||
public required int RestackCount { get; set; }
|
||||
/// <summary>
|
||||
/// If an existing collection exists within Kavita
|
||||
/// </summary>
|
||||
/// <remarks>This is filled out from Kavita and not Kavita+</remarks>
|
||||
public int ExistingId { get; set; }
|
||||
}
|
19
API/DTOs/Progress/FullProgressDto.cs
Normal file
19
API/DTOs/Progress/FullProgressDto.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using System;
|
||||
|
||||
namespace API.DTOs.Progress;
|
||||
|
||||
/// <summary>
|
||||
/// A full progress Record from the DB (not all data, only what's needed for API)
|
||||
/// </summary>
|
||||
public class FullProgressDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ChapterId { get; set; }
|
||||
public int PagesRead { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
public DateTime LastModifiedUtc { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public int AppUserId { get; set; }
|
||||
public string UserName { get; set; }
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace API.DTOs;
|
||||
namespace API.DTOs.Progress;
|
||||
#nullable enable
|
||||
|
||||
public class ProgressDto
|
11
API/DTOs/Progress/UpdateUserProgressDto.cs
Normal file
11
API/DTOs/Progress/UpdateUserProgressDto.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace API.DTOs.Progress;
|
||||
#nullable enable
|
||||
|
||||
public class UpdateUserProgressDto
|
||||
{
|
||||
public int PageNum { get; set; }
|
||||
public DateTime LastModifiedUtc { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
13
API/DTOs/Scrobbling/MalUserInfoDto.cs
Normal file
13
API/DTOs/Scrobbling/MalUserInfoDto.cs
Normal file
@ -0,0 +1,13 @@
|
||||
namespace API.DTOs.Scrobbling;
|
||||
|
||||
/// <summary>
|
||||
/// Information about a User's MAL connection
|
||||
/// </summary>
|
||||
public class MalUserInfoDto
|
||||
{
|
||||
public required string Username { get; set; }
|
||||
/// <summary>
|
||||
/// This is actually the Client Id
|
||||
/// </summary>
|
||||
public required string AccessToken { get; set; }
|
||||
}
|
17
API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs
Normal file
17
API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs
Normal file
@ -0,0 +1,17 @@
|
||||
namespace API.DTOs.Statistics;
|
||||
|
||||
public class KavitaPlusMetadataBreakdownDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Total amount of Series
|
||||
/// </summary>
|
||||
public int TotalSeries { get; set; }
|
||||
/// <summary>
|
||||
/// Series on the Blacklist (errored or bad match)
|
||||
/// </summary>
|
||||
public int ErroredSeries { get; set; }
|
||||
/// <summary>
|
||||
/// Completed so far
|
||||
/// </summary>
|
||||
public int SeriesCompleted { get; set; }
|
||||
}
|
2904
API/Data/Migrations/20240321173812_UserMalToken.Designer.cs
generated
Normal file
2904
API/Data/Migrations/20240321173812_UserMalToken.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
API/Data/Migrations/20240321173812_UserMalToken.cs
Normal file
38
API/Data/Migrations/20240321173812_UserMalToken.cs
Normal file
@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UserMalToken : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "MalAccessToken",
|
||||
table: "AspNetUsers",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "MalUserName",
|
||||
table: "AspNetUsers",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MalAccessToken",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MalUserName",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
@ -97,6 +97,12 @@ namespace API.Data.Migrations
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("MalAccessToken")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("MalUserName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
@ -5,8 +5,10 @@ using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.ManualMigrations;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Progress;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
@ -36,6 +38,7 @@ public interface IAppUserProgressRepository
|
||||
Task<DateTime?> GetLatestProgressForSeries(int seriesId, int userId);
|
||||
Task<DateTime?> GetFirstProgressForSeries(int seriesId, int userId);
|
||||
Task UpdateAllProgressThatAreMoreThanChapterPages();
|
||||
Task<IList<FullProgressDto>> GetUserProgressForChapter(int chapterId, int userId = 0);
|
||||
}
|
||||
#nullable disable
|
||||
public class AppUserProgressRepository : IAppUserProgressRepository
|
||||
@ -233,6 +236,33 @@ public class AppUserProgressRepository : IAppUserProgressRepository
|
||||
await _context.Database.ExecuteSqlRawAsync(batchSql);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <param name="userId">If 0, will pull all records</param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<FullProgressDto>> GetUserProgressForChapter(int chapterId, int userId = 0)
|
||||
{
|
||||
return await _context.AppUserProgresses
|
||||
.WhereIf(userId > 0, p => p.AppUserId == userId)
|
||||
.Where(p => p.ChapterId == chapterId)
|
||||
.Include(p => p.AppUser)
|
||||
.Select(p => new FullProgressDto()
|
||||
{
|
||||
AppUserId = p.AppUserId,
|
||||
ChapterId = p.ChapterId,
|
||||
PagesRead = p.PagesRead,
|
||||
Id = p.Id,
|
||||
Created = p.Created,
|
||||
CreatedUtc = p.CreatedUtc,
|
||||
LastModified = p.LastModified,
|
||||
LastModifiedUtc = p.LastModifiedUtc,
|
||||
UserName = p.AppUser.UserName
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
#nullable enable
|
||||
public async Task<AppUserProgress?> GetUserProgressAsync(int chapterId, int userId)
|
||||
{
|
||||
|
@ -63,6 +63,15 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
||||
/// <remarks>Requires Kavita+ Subscription</remarks>
|
||||
public string? AniListAccessToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The Username of the MAL user
|
||||
/// </summary>
|
||||
public string? MalUserName { get; set; }
|
||||
/// <summary>
|
||||
/// The Client ID for the user's MAL account. User should create a client on MAL for this.
|
||||
/// </summary>
|
||||
public string? MalAccessToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A list of Series the user doesn't want scrobbling for
|
||||
/// </summary>
|
||||
|
@ -1,5 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Metadata;
|
||||
using API.Services.Plus;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Entities;
|
||||
@ -41,6 +43,21 @@ public class CollectionTag
|
||||
|
||||
public ICollection<SeriesMetadata> SeriesMetadatas { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Is this Collection tag managed by another system, like Kavita+
|
||||
/// </summary>
|
||||
//public bool IsManaged { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// The last time this Collection was Synchronized. Only applicable for Managed Tags.
|
||||
/// </summary>
|
||||
//public DateTime LastSynchronized { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Who created this Collection (Kavita, or external services)
|
||||
/// </summary>
|
||||
//public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.Kavita;
|
||||
|
||||
/// <summary>
|
||||
/// Not Used due to not using concurrency update
|
||||
/// </summary>
|
||||
|
@ -10,6 +10,7 @@ using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.MediaErrors;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Progress;
|
||||
using API.DTOs.Reader;
|
||||
using API.DTOs.ReadingLists;
|
||||
using API.DTOs.Recommendation;
|
||||
|
@ -6,6 +6,7 @@ using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Collection;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.DTOs.Scrobbling;
|
||||
using API.DTOs.SeriesDetail;
|
||||
@ -61,6 +62,8 @@ public interface IExternalMetadataService
|
||||
/// <param name="libraryType"></param>
|
||||
/// <returns></returns>
|
||||
Task GetNewSeriesData(int seriesId, LibraryType libraryType);
|
||||
|
||||
Task<IList<MalStackDto>> GetStacksForUser(int userId);
|
||||
}
|
||||
|
||||
public class ExternalMetadataService : IExternalMetadataService
|
||||
@ -70,7 +73,8 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ILicenseService _licenseService;
|
||||
private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30);
|
||||
public static readonly ImmutableArray<LibraryType> NonEligibleLibraryTypes = ImmutableArray.Create<LibraryType>(LibraryType.Comic, LibraryType.Book, LibraryType.Image, LibraryType.ComicVine);
|
||||
public static readonly ImmutableArray<LibraryType> NonEligibleLibraryTypes = ImmutableArray.Create
|
||||
(LibraryType.Comic, LibraryType.Book, LibraryType.Image, LibraryType.ComicVine);
|
||||
private readonly SeriesDetailPlusDto _defaultReturn = new()
|
||||
{
|
||||
Recommendations = null,
|
||||
@ -137,12 +141,15 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
public async Task ForceKavitaPlusRefresh(int seriesId)
|
||||
{
|
||||
if (!await _licenseService.HasActiveLicense()) return;
|
||||
// Remove from Blacklist if applicable
|
||||
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId);
|
||||
if (!IsPlusEligible(libraryType)) return;
|
||||
|
||||
// Remove from Blacklist if applicable
|
||||
await _unitOfWork.ExternalSeriesMetadataRepository.RemoveFromBlacklist(seriesId);
|
||||
|
||||
var metadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId);
|
||||
if (metadata == null) return;
|
||||
|
||||
metadata.ValidUntilUtc = DateTime.UtcNow.Subtract(_externalSeriesMetadataCache);
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
@ -170,10 +177,50 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
// Prefetch SeriesDetail data
|
||||
await GetSeriesDetailPlus(seriesId, libraryType);
|
||||
|
||||
// TODO: Fetch Series Metadata
|
||||
// TODO: Fetch Series Metadata (Summary, etc)
|
||||
|
||||
}
|
||||
|
||||
public async Task<IList<MalStackDto>> GetStacksForUser(int userId)
|
||||
{
|
||||
if (!await _licenseService.HasActiveLicense()) return ArraySegment<MalStackDto>.Empty;
|
||||
|
||||
// See if this user has Mal account on record
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||
if (user == null || string.IsNullOrEmpty(user.MalUserName) || string.IsNullOrEmpty(user.MalAccessToken))
|
||||
{
|
||||
_logger.LogInformation("User is attempting to fetch MAL Stacks, but missing information on their account");
|
||||
return ArraySegment<MalStackDto>.Empty;
|
||||
}
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Fetching Kavita+ for MAL Stacks for user {UserName}", user.MalUserName);
|
||||
|
||||
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
|
||||
var result = await ($"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stacks?username={user.MalUserName}")
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.WithHeader("x-license-key", license)
|
||||
.WithHeader("x-installId", HashUtil.ServerToken())
|
||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs))
|
||||
.GetJsonAsync<IList<MalStackDto>>();
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return ArraySegment<MalStackDto>.Empty;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Fetching Kavita+ for MAL Stacks for user {UserName} failed", user.MalUserName);
|
||||
return ArraySegment<MalStackDto>.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves Metadata about a Recommended External Series
|
||||
/// </summary>
|
||||
|
@ -94,6 +94,7 @@ public class ScrobblingService : IScrobblingService
|
||||
ScrobbleProvider.AniList
|
||||
};
|
||||
|
||||
|
||||
private const string UnknownSeriesErrorMessage = "Series cannot be matched for Scrobbling";
|
||||
private const string AccessTokenErrorMessage = "Access Token needs to be rotated to continue scrobbling";
|
||||
|
||||
@ -332,15 +333,7 @@ public class ScrobblingService : IScrobblingService
|
||||
await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(seriesId, userId),
|
||||
Format = LibraryTypeHelper.GetFormat(series.Library.Type),
|
||||
};
|
||||
// NOTE: Not sure how to handle scrobbling specials or handling sending loose leaf volumes
|
||||
if (evt.VolumeNumber is Parser.SpecialVolumeNumber)
|
||||
{
|
||||
evt.VolumeNumber = 0;
|
||||
}
|
||||
if (evt.VolumeNumber is Parser.DefaultChapterNumber)
|
||||
{
|
||||
evt.VolumeNumber = 0;
|
||||
}
|
||||
|
||||
_unitOfWork.ScrobbleRepository.Attach(evt);
|
||||
await _unitOfWork.CommitAsync();
|
||||
_logger.LogDebug("Added Scrobbling Read update on {SeriesName} with Userid {UserId} ", series.Name, userId);
|
||||
@ -826,6 +819,20 @@ public class ScrobblingService : IScrobblingService
|
||||
try
|
||||
{
|
||||
var data = await createEvent(evt);
|
||||
// We need to handle the encoding and changing it to the old one until we can update the API layer to handle these
|
||||
// which could happen in v0.8.3
|
||||
if (data.VolumeNumber is Parser.SpecialVolumeNumber)
|
||||
{
|
||||
data.VolumeNumber = 0;
|
||||
}
|
||||
if (data.VolumeNumber is Parser.DefaultChapterNumber)
|
||||
{
|
||||
data.VolumeNumber = 0;
|
||||
}
|
||||
if (data.ChapterNumber is Parser.DefaultChapterNumber)
|
||||
{
|
||||
data.ChapterNumber = 0;
|
||||
}
|
||||
userRateLimits[evt.AppUserId] = await PostScrobbleUpdate(data, license.Value, evt);
|
||||
evt.IsProcessed = true;
|
||||
evt.ProcessDateUtc = DateTime.UtcNow;
|
||||
@ -870,6 +877,7 @@ public class ScrobblingService : IScrobblingService
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static bool DoesUserHaveProviderAndValid(ScrobbleEvent readEvent)
|
||||
{
|
||||
var userProviders = GetUserProviders(readEvent.AppUser);
|
||||
|
@ -9,6 +9,7 @@ using API.Comparators;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Progress;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
@ -9,6 +9,7 @@ using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Services.Plus;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
@ -33,6 +34,7 @@ public interface IStatisticService
|
||||
IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0);
|
||||
Task UpdateServerStatistics();
|
||||
Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> libraryIds);
|
||||
Task<KavitaPlusMetadataBreakdownDto> GetKavitaPlusMetadataBreakdown();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -531,6 +533,29 @@ public class StatisticService : IStatisticService
|
||||
p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages))));
|
||||
}
|
||||
|
||||
public async Task<KavitaPlusMetadataBreakdownDto> GetKavitaPlusMetadataBreakdown()
|
||||
{
|
||||
// We need to count number of Series that have an external series record
|
||||
// Then count how many series are blacklisted
|
||||
// Then get total count of series that are Kavita+ eligible
|
||||
var plusLibraries = await _context.Library
|
||||
.Where(l => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(l.Type))
|
||||
.Select(l => l.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var countOfBlacklisted = await _context.SeriesBlacklist.CountAsync();
|
||||
var totalSeries = await _context.Series.Where(s => plusLibraries.Contains(s.LibraryId)).CountAsync();
|
||||
var seriesWithMetadata = await _context.ExternalSeriesMetadata.CountAsync();
|
||||
|
||||
return new KavitaPlusMetadataBreakdownDto()
|
||||
{
|
||||
TotalSeries = totalSeries,
|
||||
ErroredSeries = countOfBlacklisted,
|
||||
SeriesCompleted = seriesWithMetadata
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TopReadDto>> GetTopUsers(int days)
|
||||
{
|
||||
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
|
||||
|
@ -9,6 +9,7 @@ using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using API.SignalR;
|
||||
using ExCSS;
|
||||
using Kavita.Common.Helpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@ -448,22 +449,42 @@ public class ParseScannedFiles
|
||||
var infos = scannedSeries[series].Where(info => info.Volumes == volume.Key).ToList();
|
||||
IList<ParserInfo> chapters;
|
||||
var specialTreatment = infos.TrueForAll(info => info.IsSpecial);
|
||||
var hasAnySpMarker = infos.Exists(info => info.SpecialIndex > 0);
|
||||
var counter = 0f;
|
||||
|
||||
if (specialTreatment)
|
||||
if (specialTreatment && hasAnySpMarker)
|
||||
{
|
||||
chapters = infos
|
||||
.OrderBy(info => info.SpecialIndex)
|
||||
.ToList();
|
||||
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
chapter.IssueOrder = counter;
|
||||
counter++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
else
|
||||
|
||||
|
||||
chapters = infos
|
||||
.OrderByNatural(info => info.Chapters)
|
||||
.ToList();
|
||||
|
||||
|
||||
// If everything is a special but we don't have any SpecialIndex, then order naturally and use 0, 1, 2
|
||||
if (specialTreatment)
|
||||
{
|
||||
chapters = infos
|
||||
.OrderByNatural(info => info.Chapters)
|
||||
.ToList();
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
chapter.IssueOrder = counter;
|
||||
counter++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var counter = 0f;
|
||||
counter = 0f;
|
||||
var prevIssue = string.Empty;
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
|
@ -95,6 +95,11 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
|
||||
// Patch in other information from ComicInfo
|
||||
UpdateFromComicInfo(ret);
|
||||
|
||||
if (ret.Volumes == Parser.LooseLeafVolume && ret.Chapters == Parser.DefaultChapter)
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
}
|
||||
|
||||
// v0.8.x: Introducing a change where Specials will go in a separate Volume with a reserved number
|
||||
if (ret.IsSpecial)
|
||||
{
|
||||
|
@ -22,6 +22,7 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer
|
||||
|
||||
if (string.IsNullOrEmpty(info.ComicInfo?.Volume) && hasVolumeInTitle && (hasVolumeInSeries || string.IsNullOrEmpty(info.Series)))
|
||||
{
|
||||
// NOTE: I'm not sure the comment is true. I've never seen this triggered
|
||||
// This is likely a light novel for which we can set series from parsed title
|
||||
info.Series = Parser.ParseSeries(info.Title);
|
||||
info.Volumes = Parser.ParseVolume(info.Title);
|
||||
@ -30,6 +31,12 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer
|
||||
{
|
||||
var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, comicInfo);
|
||||
info.Merge(info2);
|
||||
if (type == LibraryType.LightNovel && hasVolumeInSeries && info2 != null && Parser.ParseVolume(info2.Series)
|
||||
.Equals(Parser.LooseLeafVolume))
|
||||
{
|
||||
// Override the Series name so it groups appropriately
|
||||
info.Series = info2.Series;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
8
UI/Web/src/app/_models/collection/mal-stack.ts
Normal file
8
UI/Web/src/app/_models/collection/mal-stack.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface MalStack {
|
||||
title: string;
|
||||
stackId: number;
|
||||
url: string;
|
||||
author?: string;
|
||||
seriesCount: number;
|
||||
restackCount: number;
|
||||
}
|
11
UI/Web/src/app/_models/readers/full-progress.ts
Normal file
11
UI/Web/src/app/_models/readers/full-progress.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export interface FullProgress {
|
||||
id: number;
|
||||
chapterId: number;
|
||||
pagesRead: number;
|
||||
lastModified: string;
|
||||
lastModifiedUtc: string;
|
||||
created: string;
|
||||
createdUtc: string;
|
||||
appUserId: number;
|
||||
userName: string;
|
||||
}
|
@ -5,6 +5,7 @@ import { environment } from 'src/environments/environment';
|
||||
import { CollectionTag } from '../_models/collection-tag';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import { ImageService } from './image.service';
|
||||
import {MalStack} from "../_models/collection/mal-stack";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -45,4 +46,8 @@ export class CollectionTagService {
|
||||
deleteTag(tagId: number) {
|
||||
return this.httpClient.delete<string>(this.baseUrl + 'collection?tagId=' + tagId, TextResonse);
|
||||
}
|
||||
|
||||
getMalStacks() {
|
||||
return this.httpClient.get<Array<MalStack>>(this.baseUrl + 'collection/mal-stacks');
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,9 @@ export class LibraryService {
|
||||
constructor(private httpClient: HttpClient, private readonly messageHub: MessageHubService, private readonly destroyRef: DestroyRef) {
|
||||
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(e => e.event === EVENTS.LibraryModified),
|
||||
tap((e) => {
|
||||
this.libraryNames = undefined;
|
||||
console.log('LibraryModified event came in, clearing library name cache');
|
||||
this.libraryNames = undefined;
|
||||
this.libraryTypes = undefined;
|
||||
})).subscribe();
|
||||
}
|
||||
|
||||
|
@ -18,6 +18,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {PersonalToC} from "../_models/readers/personal-toc";
|
||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
import NoSleep from 'nosleep.js';
|
||||
import {FullProgress} from "../_models/readers/full-progress";
|
||||
|
||||
|
||||
export const CHAPTER_ID_DOESNT_EXIST = -1;
|
||||
@ -155,6 +156,10 @@ export class ReaderService {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/progress', {libraryId, seriesId, volumeId, chapterId, pageNum: page, bookScrollId});
|
||||
}
|
||||
|
||||
getAllProgressForChapter(chapterId: number) {
|
||||
return this.httpClient.get<Array<FullProgress>>(this.baseUrl + 'reader/all-chapter-progress?chapterId=' + chapterId);
|
||||
}
|
||||
|
||||
markVolumeRead(seriesId: number, volumeId: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/mark-volume-read', {seriesId, volumeId});
|
||||
}
|
||||
|
@ -36,10 +36,18 @@ export class ScrobblingService {
|
||||
return this.httpClient.post(this.baseUrl + 'scrobbling/update-anilist-token', {token});
|
||||
}
|
||||
|
||||
updateMalToken(username: string, accessToken: string) {
|
||||
return this.httpClient.post(this.baseUrl + 'scrobbling/update-mal-token', {username, accessToken});
|
||||
}
|
||||
|
||||
getAniListToken() {
|
||||
return this.httpClient.get<string>(this.baseUrl + 'scrobbling/anilist-token', TextResonse);
|
||||
}
|
||||
|
||||
getMalToken() {
|
||||
return this.httpClient.get<{username: string, accessToken: string}>(this.baseUrl + 'scrobbling/mal-token');
|
||||
}
|
||||
|
||||
getScrobbleErrors() {
|
||||
return this.httpClient.get<Array<ScrobbleError>>(this.baseUrl + 'scrobbling/scrobble-errors');
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import { PublicationStatus } from '../_models/metadata/publication-status';
|
||||
import { MangaFormat } from '../_models/manga-format';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import {TranslocoService} from "@ngneat/transloco";
|
||||
import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown";
|
||||
|
||||
export enum DayOfWeek
|
||||
{
|
||||
@ -115,4 +116,8 @@ export class StatisticsService {
|
||||
getDayBreakdown( userId = 0) {
|
||||
return this.httpClient.get<Array<StatCount<DayOfWeek>>>(this.baseUrl + 'stats/day-breakdown?userId=' + userId);
|
||||
}
|
||||
|
||||
getKavitaPlusMetadataBreakdown() {
|
||||
return this.httpClient.get<KavitaPlusMetadataBreakdown>(this.baseUrl + 'stats/kavitaplus-metadata-breakdown');
|
||||
}
|
||||
}
|
||||
|
@ -11,12 +11,13 @@
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-2 mt-4">
|
||||
<ngb-pagination *ngIf="pagination"
|
||||
[(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
[collectionSize]="pagination.totalItems"
|
||||
(pageChange)="onPageChange($event)"
|
||||
></ngb-pagination>
|
||||
@if(pagination) {
|
||||
<ngb-pagination [(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
[collectionSize]="pagination.totalItems"
|
||||
(pageChange)="onPageChange($event)"
|
||||
></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-striped table-hover table-sm scrollable">
|
||||
@ -43,9 +44,12 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngIf="events.length === 0">
|
||||
<td colspan="6">{{t('no-data')}}</td>
|
||||
</tr>
|
||||
@if (events.length === 0) {
|
||||
<tr>
|
||||
<td colspan="6">{{t('no-data')}}</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
<tr *ngFor="let item of events; let idx = index;">
|
||||
<td>
|
||||
{{item.createdUtc | utcToLocalTime | defaultValue}}
|
||||
@ -60,25 +64,28 @@
|
||||
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.seriesName}}</a>
|
||||
</td>
|
||||
<td>
|
||||
<ng-container [ngSwitch]="item.scrobbleEventType">
|
||||
<ng-container *ngSwitchCase="ScrobbleEventType.ChapterRead">
|
||||
@if(item.volumeNumber === SpecialVolumeNumber) {
|
||||
{{t('chapter-num', {num: item.volumeNumber})}}
|
||||
} @else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
|
||||
@switch (item.scrobbleEventType) {
|
||||
@case (ScrobbleEventType.ChapterRead) {
|
||||
@if(item.volumeNumber === LooseLeafOrDefaultNumber) {
|
||||
{{t('chapter-num', {num: item.chapterNumber})}}
|
||||
}
|
||||
@else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
|
||||
{{t('volume-num', {num: item.volumeNumber})}}
|
||||
} @else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) {
|
||||
|
||||
} @else {
|
||||
}
|
||||
@else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) {
|
||||
Special
|
||||
}
|
||||
@else {
|
||||
{{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}}
|
||||
}
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="ScrobbleEventType.ScoreUpdated">
|
||||
}
|
||||
@case (ScrobbleEventType.ScoreUpdated) {
|
||||
{{t('rating', {r: item.rating})}}
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchDefault>
|
||||
}
|
||||
@default {
|
||||
{{t('not-applicable')}}
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if(item.isProcessed) {
|
||||
|
@ -21,7 +21,8 @@ import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapt
|
||||
@Component({
|
||||
selector: 'app-user-scrobble-history',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip],
|
||||
imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule,
|
||||
DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip],
|
||||
templateUrl: './user-scrobble-history.component.html',
|
||||
styleUrls: ['./user-scrobble-history.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
@ -1,17 +1,13 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {take} from 'rxjs';
|
||||
import {SettingsService} from '../settings.service';
|
||||
import {ServerSettings} from '../_models/server-settings';
|
||||
import {
|
||||
NgbAccordionBody,
|
||||
NgbAccordionButton,
|
||||
NgbAccordionCollapse,
|
||||
NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem,
|
||||
NgbTooltip
|
||||
} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {NgForOf, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
||||
import {NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
||||
import {translate, TranslocoModule} from "@ngneat/transloco";
|
||||
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||
import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component";
|
||||
@ -23,8 +19,7 @@ import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component";
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe,
|
||||
ManageAlertsComponent, NgbAccordionBody, NgbAccordionButton, NgbAccordionCollapse, NgbAccordionDirective,
|
||||
NgbAccordionHeader, NgbAccordionItem, NgForOf, TitleCasePipe]
|
||||
ManageAlertsComponent, TitleCasePipe]
|
||||
})
|
||||
export class ManageEmailSettingsComponent implements OnInit {
|
||||
|
||||
|
@ -9,15 +9,19 @@
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<button *ngIf="hasMarkAsUnread" class="btn btn-icon" (click)="executeAction(Action.MarkAsUnread)" [ngbTooltip]="t('mark-as-unread')" placement="bottom">
|
||||
@if (hasMarkAsUnread) {
|
||||
<button class="btn btn-icon" (click)="executeAction(Action.MarkAsUnread)" [ngbTooltip]="t('mark-as-unread')" placement="bottom">
|
||||
<i class="fa-regular fa-circle-check" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('mark-as-unread')}}</span>
|
||||
</button>
|
||||
<button *ngIf="hasMarkAsRead" class="btn btn-icon" (click)="executeAction(Action.MarkAsRead)" [ngbTooltip]="t('mark-as-read')" placement="bottom">
|
||||
}
|
||||
@if (hasMarkAsRead) {
|
||||
<button class="btn btn-icon" (click)="executeAction(Action.MarkAsRead)" [ngbTooltip]="t('mark-as-read')" placement="bottom">
|
||||
<i class="fa-solid fa-circle-check" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('mark-as-read')}}</span>
|
||||
</button>
|
||||
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables>
|
||||
}
|
||||
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables>
|
||||
</span>
|
||||
|
||||
<span id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>
|
||||
|
@ -32,59 +32,6 @@
|
||||
|
||||
<app-entity-info-cards [entity]="data" [libraryId]="libraryId"></app-entity-info-cards>
|
||||
|
||||
|
||||
<!-- 2 rows to show some tags-->
|
||||
<ng-container *ngIf="chapterMetadata !== undefined">
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<h6>{{t('writers-title')}}</h6>
|
||||
<ng-container *ngIf="chapterMetadata.writers.length > 0; else noBadges">
|
||||
<app-badge-expander [items]="chapterMetadata.writers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<h6>{{t('genres-title')}}</h6>
|
||||
<ng-container *ngIf="chapterMetadata.genres.length > 0; else noBadges">
|
||||
<app-badge-expander [items]="chapterMetadata.genres">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-tag-badge>{{item.title}}</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<h6>{{t('publishers-title')}}</h6>
|
||||
<ng-container *ngIf="chapterMetadata.publishers.length > 0; else noBadges">
|
||||
<app-badge-expander [items]="chapterMetadata.publishers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<h6>{{t('tags-title')}}</h6>
|
||||
<ng-container *ngIf="chapterMetadata.tags.length > 0; else noBadges">
|
||||
<app-badge-expander [items]="chapterMetadata.tags">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-tag-badge>{{item.title}}</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #noBadges>
|
||||
{{t('not-defined')}}
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
@ -96,6 +43,13 @@
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.Progress]">
|
||||
<a ngbNavLink>{{t(tabs[TabID.Progress].title)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-edit-chapter-progress [chapter]="chapter"></app-edit-chapter-progress>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.Cover]" [disabled]="(isAdmin$ | async) === false">
|
||||
<a ngbNavLink>{{t(tabs[TabID.Cover].title)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
@ -113,8 +67,7 @@
|
||||
}
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex my-4" *ngFor="let chapter of chapters">
|
||||
<!-- TODO: Localize title -->
|
||||
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read">
|
||||
<a (click)="readChapter(chapter)" href="javascript:void(0);" [title]="t('read')">
|
||||
<app-image class="me-2" width="74px" [imageUrl]="imageService.getChapterCoverImage(chapter.id)"></app-image>
|
||||
</a>
|
||||
<div class="flex-grow-1">
|
||||
|
@ -50,18 +50,20 @@ import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
|
||||
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||
import {EditChapterProgressComponent} from "../edit-chapter-progress/edit-chapter-progress.component";
|
||||
|
||||
enum TabID {
|
||||
General = 0,
|
||||
Metadata = 1,
|
||||
Cover = 2,
|
||||
Files = 3
|
||||
Progress = 3,
|
||||
Files = 4
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-detail-drawer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, EntityTitleComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ImageComponent, ReadMoreComponent, EntityInfoCardsComponent, CoverImageChooserComponent, ChapterMetadataDetailComponent, CardActionablesComponent, DefaultDatePipe, BytesPipe, NgbNavOutlet, BadgeExpanderComponent, TagBadgeComponent, PersonBadgeComponent, TranslocoDirective],
|
||||
imports: [CommonModule, EntityTitleComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ImageComponent, ReadMoreComponent, EntityInfoCardsComponent, CoverImageChooserComponent, ChapterMetadataDetailComponent, CardActionablesComponent, DefaultDatePipe, BytesPipe, NgbNavOutlet, BadgeExpanderComponent, TagBadgeComponent, PersonBadgeComponent, TranslocoDirective, EditChapterProgressComponent],
|
||||
templateUrl: './card-detail-drawer.component.html',
|
||||
styleUrls: ['./card-detail-drawer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@ -106,6 +108,7 @@ export class CardDetailDrawerComponent implements OnInit {
|
||||
{title: 'general-tab', disabled: false},
|
||||
{title: 'metadata-tab', disabled: false},
|
||||
{title: 'cover-tab', disabled: false},
|
||||
{title: 'progress-tab', disabled: false},
|
||||
{title: 'info-tab', disabled: false}
|
||||
];
|
||||
active = this.tabs[0];
|
||||
|
@ -0,0 +1,48 @@
|
||||
<ng-container *transloco="let t; read: 'edit-chapter-progress'">
|
||||
<table class="table table-striped" [formGroup]="formGroup">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{t('user-header')}}</th>
|
||||
<th scope="col">{{t('page-read-header')}}</th>
|
||||
<th scope="col">{{t('date-created-header')}}</th>
|
||||
<th scope="col">{{t('date-updated-header')}}</th>
|
||||
<!-- <th scope="col">{{t('action-header')}}</th>-->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for(rowForm of items.controls; track rowForm; let idx = $index) {
|
||||
<tr >
|
||||
<td id="progress-event--{{idx}}">
|
||||
{{progressEvents[idx].userName}}
|
||||
</td>
|
||||
<td>
|
||||
@if(editMode[idx]) {
|
||||
<input type="number" formControlName="pagesRead" class="form-control"/>
|
||||
} @else {
|
||||
{{progressEvents[idx].pagesRead}}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{{progressEvents[idx].createdUtc}}
|
||||
</td>
|
||||
<td>
|
||||
{{progressEvents[idx].lastModifiedUtc}}
|
||||
</td>
|
||||
<!-- <td>-->
|
||||
<!-- @if(editMode[idx]) {-->
|
||||
<!-- <button class="btn btn-primary" (click)="save(progressEvents[idx], idx)" attr.aria-labelledby="progress-event--{{idx}}">-->
|
||||
<!-- <i class="fa-solid fa-floppy-disk" aria-hidden="true"></i>-->
|
||||
<!-- <span class="visually-hidden">{{t('save-alt')}}</span>-->
|
||||
<!-- </button>-->
|
||||
<!-- } @else {-->
|
||||
<!-- <button class="btn btn-primary" (click)="edit(progressEvents[idx], idx)" attr.aria-labelledby="progress-event--{{idx}}">-->
|
||||
<!-- <i class="fa-solid fa-pencil" aria-hidden="true"></i>-->
|
||||
<!-- <span class="visually-hidden">{{t('edit-alt')}}</span>-->
|
||||
<!-- </button>-->
|
||||
<!-- }-->
|
||||
<!-- </td>-->
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</ng-container>
|
@ -0,0 +1,80 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
|
||||
import {Chapter} from "../../_models/chapter";
|
||||
import {AsyncPipe, NgForOf, TitleCasePipe} from "@angular/common";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
import {FullProgress} from "../../_models/readers/full-progress";
|
||||
import {ReaderService} from "../../_services/reader.service";
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-chapter-progress',
|
||||
standalone: true,
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
DefaultValuePipe,
|
||||
NgForOf,
|
||||
TitleCasePipe,
|
||||
UtcToLocalTimePipe,
|
||||
TranslocoDirective,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
templateUrl: './edit-chapter-progress.component.html',
|
||||
styleUrl: './edit-chapter-progress.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class EditChapterProgressComponent implements OnInit {
|
||||
|
||||
private readonly readerService = inject(ReaderService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
@Input({required: true}) chapter!: Chapter;
|
||||
|
||||
progressEvents: Array<FullProgress> = [];
|
||||
editMode: {[key: number]: boolean} = {};
|
||||
formGroup = this.fb.group({
|
||||
items: this.fb.array([])
|
||||
});
|
||||
|
||||
get items() {
|
||||
return this.formGroup.get('items') as FormArray;
|
||||
}
|
||||
|
||||
|
||||
ngOnInit() {
|
||||
this.readerService.getAllProgressForChapter(this.chapter!.id).subscribe(res => {
|
||||
this.progressEvents = res;
|
||||
this.progressEvents.forEach((v, i) => {
|
||||
this.editMode[i] = false;
|
||||
this.items.push(this.createRowForm(v));
|
||||
});
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
createRowForm(progress: FullProgress): FormGroup {
|
||||
return this.fb.group({
|
||||
pagesRead: [progress.pagesRead, [Validators.required, Validators.min(0), Validators.max(this.chapter!.pages)]],
|
||||
created: [progress.createdUtc, [Validators.required]],
|
||||
lastModified: [progress.lastModifiedUtc, [Validators.required]],
|
||||
});
|
||||
}
|
||||
|
||||
edit(progress: FullProgress, idx: number) {
|
||||
this.editMode[idx] = !this.editMode[idx];
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
save(progress: FullProgress, idx: number) {
|
||||
// todo
|
||||
this.editMode[idx] = !this.editMode[idx];
|
||||
// this.formGroup[idx + ''].patchValue({
|
||||
// pagesRead: progress.pagesRead,
|
||||
// // Patch other form values as needed
|
||||
// });
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
<ng-container *transloco="let t; read: 'entity-info-cards'">
|
||||
|
||||
<div class="mt-4 mb-3">
|
||||
<div class="mt-3 mb-3">
|
||||
<div class="row g-0" *ngIf="chapterMetadata ">
|
||||
<!-- Tags and Characters are used a lot of Hentai and Doujinshi type content, so showing in list item has value add on first glance -->
|
||||
<app-metadata-detail [tags]="chapterMetadata.tags" [libraryId]="libraryId" [queryParam]="FilterField.Tags" heading="Tags">
|
||||
@ -90,11 +90,8 @@
|
||||
<div class="col-auto">
|
||||
<app-icon-and-title [label]="t('links-title')" [clickable]="false" fontClasses="fa-solid fa-link" [title]="t('links-title')">
|
||||
<a class="me-1" [href]="link | safeHtml" *ngFor="let link of WebLinks" target="_blank" rel="noopener noreferrer" [title]="link">
|
||||
<img width="24" height="24" #img class="lazyload img-placeholder"
|
||||
src="data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
|
||||
[attr.data-src]="imageService.getWebLinkImage(link)"
|
||||
(error)="imageService.updateErroredWebLinkImage($event)"
|
||||
aria-hidden="true" alt="">
|
||||
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
|
||||
[errorImage]="imageService.errorWebLinkImage"></app-image>
|
||||
</a>
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
@ -117,6 +114,15 @@
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="isChapter">
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
<div class="col-auto">
|
||||
<app-icon-and-title [label]="t('sort-order-title')" [clickable]="false" fontClasses="fa-solid fa-arrow-down-1-9" [title]="t('sort-order-title')">
|
||||
{{chapter.sortOrder}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,7 +10,6 @@ import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { ChapterMetadata } from 'src/app/_models/metadata/chapter-metadata';
|
||||
import { HourEstimateRange } from 'src/app/_models/series-detail/hour-estimate-range';
|
||||
import { LibraryType } from 'src/app/_models/library/library';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
@ -29,17 +28,27 @@ import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-entity-info-cards',
|
||||
standalone: true,
|
||||
imports: [CommonModule, IconAndTitleComponent, SafeHtmlPipe, DefaultDatePipe, BytesPipe, CompactNumberPipe, AgeRatingPipe, NgbTooltip, MetadataDetailComponent, TranslocoModule, CompactNumberPipe, TranslocoLocaleModule, UtcToLocalTimePipe],
|
||||
imports: [CommonModule, IconAndTitleComponent, SafeHtmlPipe, DefaultDatePipe, BytesPipe, CompactNumberPipe,
|
||||
AgeRatingPipe, NgbTooltip, MetadataDetailComponent, TranslocoModule, CompactNumberPipe, TranslocoLocaleModule,
|
||||
UtcToLocalTimePipe, ImageComponent],
|
||||
templateUrl: './entity-info-cards.component.html',
|
||||
styleUrls: ['./entity-info-cards.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class EntityInfoCardsComponent implements OnInit {
|
||||
|
||||
protected readonly AgeRating = AgeRating;
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
protected readonly FilterField = FilterField;
|
||||
|
||||
public readonly imageService = inject(ImageService);
|
||||
|
||||
|
||||
@Input({required: true}) entity!: Volume | Chapter;
|
||||
@Input({required: true}) libraryId!: number;
|
||||
/**
|
||||
@ -48,7 +57,7 @@ export class EntityInfoCardsComponent implements OnInit {
|
||||
@Input() includeMetadata: boolean = false;
|
||||
|
||||
/**
|
||||
* Hide more system based fields, like Id or Date Added
|
||||
* Hide more system based fields, like id or Date Added
|
||||
*/
|
||||
@Input() showExtendedProperties: boolean = true;
|
||||
|
||||
@ -62,22 +71,6 @@ export class EntityInfoCardsComponent implements OnInit {
|
||||
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
|
||||
size: number = 0;
|
||||
|
||||
imageService = inject(ImageService);
|
||||
|
||||
get LibraryType() {
|
||||
return LibraryType;
|
||||
}
|
||||
|
||||
get MangaFormat() {
|
||||
return MangaFormat;
|
||||
}
|
||||
|
||||
get AgeRating() {
|
||||
return AgeRating;
|
||||
}
|
||||
|
||||
get FilterField() { return FilterField; }
|
||||
|
||||
get WebLinks() {
|
||||
if (this.chapter.webLinks === '') return [];
|
||||
return this.chapter.webLinks.split(',');
|
||||
|
@ -0,0 +1,36 @@
|
||||
<ng-container *transloco="let t; read: 'import-mal-collection-modal'">
|
||||
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="ngbModal.close()"></button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
<p>{{t('description')}}</p>
|
||||
|
||||
<ul>
|
||||
@for(stack of stacks; track stack.url) {
|
||||
<li>
|
||||
<div><a [href]="stack.url" rel="noreferrer noopener" target="_blank">{{stack.title}}</a></div>
|
||||
<div>by {{stack.author}} • {{t('series-count', {num: stack.seriesCount})}} • <span><i class="fa-solid fa-layer-group me-1" aria-hidden="true"></i>{{t('restack-count', {num: stack.restackCount})}}</span></div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<!-- <div class="col-auto">-->
|
||||
<!-- <a class="btn btn-icon" href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/reading-lists#creating-a-reading-list-via-cbl" target="_blank" rel="noopener noreferrer">Help</a>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="col-auto">-->
|
||||
<!-- <button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="col-auto">-->
|
||||
<!-- <button type="button" class="btn btn-primary" (click)="prevStep()" [disabled]="!canMoveToPrevStep()">{{t('prev')}}</button>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="col-auto">-->
|
||||
<!-- <button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="!canMoveToNextStep()">{{t(NextButtonLabel)}}</button>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
|
||||
</ng-container>
|
@ -0,0 +1,40 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {ReactiveFormsModule} from "@angular/forms";
|
||||
import {Select2Module} from "ng-select2-component";
|
||||
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {CollectionTagService} from "../../../_services/collection-tag.service";
|
||||
import {MalStack} from "../../../_models/collection/mal-stack";
|
||||
|
||||
@Component({
|
||||
selector: 'app-import-mal-collection-modal',
|
||||
standalone: true,
|
||||
imports: [
|
||||
TranslocoDirective,
|
||||
ReactiveFormsModule,
|
||||
Select2Module
|
||||
],
|
||||
templateUrl: './import-mal-collection-modal.component.html',
|
||||
styleUrl: './import-mal-collection-modal.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ImportMalCollectionModalComponent {
|
||||
|
||||
protected readonly ngbModal = inject(NgbActiveModal);
|
||||
protected readonly collectionService = inject(CollectionTagService);
|
||||
protected readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
stacks: Array<MalStack> = [];
|
||||
isLoading = true;
|
||||
|
||||
constructor() {
|
||||
this.collectionService.getMalStacks().subscribe(stacks => {
|
||||
this.stacks = stacks;
|
||||
this.isLoading = false;
|
||||
this.cdRef.markForCheck();
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -7,6 +7,7 @@
|
||||
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-loading [absolute]="true" [loading]="bulkLoader"></app-loading>
|
||||
<app-card-detail-layout *ngIf="filter"
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
|
@ -44,6 +44,7 @@ import {MetadataService} from "../_services/metadata.service";
|
||||
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
|
||||
import {FilterField} from "../_models/metadata/v2/filter-field";
|
||||
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
|
||||
import {LoadingComponent} from "../shared/loading/loading.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-detail',
|
||||
@ -52,10 +53,14 @@ import {CardActionablesComponent} from "../_single-module/card-actionables/card-
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf
|
||||
, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective]
|
||||
, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent]
|
||||
})
|
||||
export class LibraryDetailComponent implements OnInit {
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
libraryId!: number;
|
||||
libraryName = '';
|
||||
series: Series[] = [];
|
||||
@ -69,18 +74,16 @@ export class LibraryDetailComponent implements OnInit {
|
||||
filterActiveCheck!: SeriesFilterV2;
|
||||
refresh: EventEmitter<void> = new EventEmitter();
|
||||
jumpKeys: Array<JumpKey> = [];
|
||||
bulkLoader: boolean = false;
|
||||
|
||||
tabs: Array<{title: string, fragment: string, icon: string}> = [
|
||||
{title: 'library-tab', fragment: '', icon: 'fa-landmark'},
|
||||
{title: 'recommended-tab', fragment: 'recommended', icon: 'fa-award'},
|
||||
];
|
||||
active = this.tabs[0];
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
|
||||
bulkActionCallback = (action: ActionItem<any>, data: any) => {
|
||||
bulkActionCallback = async (action: ActionItem<any>, data: any) => {
|
||||
const selectedSeriesIndices = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndices.includes(index + ''));
|
||||
|
||||
@ -123,7 +126,14 @@ export class LibraryDetailComponent implements OnInit {
|
||||
});
|
||||
break;
|
||||
case Action.Delete:
|
||||
this.actionService.deleteMultipleSeries(selectedSeries, (successful) => {
|
||||
if (selectedSeries.length > 25) {
|
||||
this.bulkLoader = true;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
await this.actionService.deleteMultipleSeries(selectedSeries, (successful) => {
|
||||
this.bulkLoader = false;
|
||||
this.cdRef.markForCheck();
|
||||
if (!successful) return;
|
||||
this.bulkSelectionService.deselectAll();
|
||||
this.loadPage();
|
||||
|
@ -1,7 +1,7 @@
|
||||
<ng-container *transloco="let t; read: 'import-cbl-modal'">
|
||||
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">CBL Import</h4>
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
} from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { CblImportResult } from 'src/app/_models/reading-list/cbl/cbl-import-result.enum';
|
||||
import { CblImportSummary } from 'src/app/_models/reading-list/cbl/cbl-import-summary';
|
||||
import { ReadingListService } from 'src/app/_services/reading-list.service';
|
||||
@ -16,7 +16,7 @@ import {CommonModule} from "@angular/common";
|
||||
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
|
||||
import {CblConflictReasonPipe} from "../../../_pipes/cbl-conflict-reason.pipe";
|
||||
import {CblImportResultPipe} from "../../../_pipes/cbl-import-result.pipe";
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||
|
||||
interface FileStep {
|
||||
fileName: string;
|
||||
|
@ -25,15 +25,17 @@ import {Breakpoint, UtilityService} from "../../../shared/_services/utility.serv
|
||||
})
|
||||
export class CustomizeDashboardStreamsComponent {
|
||||
|
||||
items: DashboardStream[] = [];
|
||||
smartFilters: SmartFilter[] = [];
|
||||
accessibilityMode: boolean = false;
|
||||
|
||||
private readonly dashboardService = inject(DashboardService);
|
||||
private readonly filterService = inject(FilterService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
|
||||
items: DashboardStream[] = [];
|
||||
smartFilters: SmartFilter[] = [];
|
||||
accessibilityMode: boolean = false;
|
||||
|
||||
|
||||
|
||||
listForm: FormGroup = new FormGroup({
|
||||
'filterQuery': new FormControl('', [])
|
||||
});
|
||||
|
@ -148,7 +148,6 @@ export class CustomizeSidenavStreamsComponent implements OnDestroy {
|
||||
if (this.pageOperationsForm.get('accessibilityMode')?.disabled) return;
|
||||
this.pageOperationsForm.get('accessibilityMode')?.disable();
|
||||
} else {
|
||||
if (this.pageOperationsForm.get('accessibilityMode')?.disabled) return;
|
||||
this.pageOperationsForm.get('accessibilityMode')?.enable();
|
||||
}
|
||||
}),
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div class="side-nav" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async), 'hidden': (navService.sideNavVisibility$ | async) === false, 'no-donate': (accountService.hasValidLicense$ | async) === true}" *ngIf="accountService.currentUser$ | async as user">
|
||||
<app-side-nav-item icon="fa-home" [title]="t('home')" link="/home/">
|
||||
<ng-container actions>
|
||||
<app-card-actionables [actions]="homeActions" [labelBy]="t('home')" iconClass="fa-ellipsis-v" (actionHandler)="handleHomeActions()"></app-card-actionables>
|
||||
<app-card-actionables [actions]="homeActions" [labelBy]="t('home')" iconClass="fa-ellipsis-v" (actionHandler)="handleHomeActions($event)"></app-card-actionables>
|
||||
</ng-container>
|
||||
</app-side-nav-item>
|
||||
|
||||
|
@ -30,6 +30,9 @@ import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
|
||||
import {CustomizeDashboardModalComponent} from "../customize-dashboard-modal/customize-dashboard-modal.component";
|
||||
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
|
||||
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
|
||||
import {
|
||||
ImportMalCollectionModalComponent
|
||||
} from "../../../collections/_components/import-mal-collection-modal/import-mal-collection-modal.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-side-nav',
|
||||
@ -46,8 +49,12 @@ export class SideNavComponent implements OnInit {
|
||||
|
||||
cachedData: SideNavStream[] | null = null;
|
||||
actions: ActionItem<Library>[] = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
|
||||
readingListActions = [{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)}];
|
||||
homeActions = [{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.handleHomeActions.bind(this)}];
|
||||
readingListActions = [];
|
||||
homeActions = [
|
||||
{action: Action.Edit, title: 'customize', children: [], requiresAdmin: false, callback: this.openCustomize.bind(this)},
|
||||
{action: Action.Import, title: 'import-cbl', children: [], requiresAdmin: true, callback: this.importCbl.bind(this)},
|
||||
//{action: Action.Import, title: 'import-mal-stack', children: [], requiresAdmin: true, callback: this.importMalCollection.bind(this)}, // This requires the Collection Rework (https://github.com/Kareadita/Kavita/issues/2810)
|
||||
];
|
||||
|
||||
filterQuery: string = '';
|
||||
filterLibrary = (stream: SideNavStream) => {
|
||||
@ -168,7 +175,11 @@ export class SideNavComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
handleHomeActions() {
|
||||
handleHomeActions(action: ActionItem<void>) {
|
||||
action.callback(action, undefined);
|
||||
}
|
||||
|
||||
openCustomize() {
|
||||
this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl', fullscreen: 'md'});
|
||||
}
|
||||
|
||||
@ -176,6 +187,10 @@ export class SideNavComponent implements OnInit {
|
||||
this.ngbModal.open(ImportCblModalComponent, {size: 'xl', fullscreen: 'md'});
|
||||
}
|
||||
|
||||
importMalCollection() {
|
||||
this.ngbModal.open(ImportMalCollectionModalComponent, {size: 'xl', fullscreen: 'md'});
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<Library>, library: Library) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action, library);
|
||||
|
@ -0,0 +1,13 @@
|
||||
<ng-container *transloco="let t; read:'generic-list-modal'">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="modalService.dismiss()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ng-container [ngTemplateOutlet]="bodyTemplate"></ng-container>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" (click)="modalService.dismiss()">{{t('close')}}</button>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
@ -0,0 +1,27 @@
|
||||
import {Component, ContentChild, inject, Input, TemplateRef} from '@angular/core';
|
||||
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {NgTemplateOutlet} from "@angular/common";
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-generic-table-modal',
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgTemplateOutlet,
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './generic-table-modal.component.html',
|
||||
styleUrl: './generic-table-modal.component.scss'
|
||||
})
|
||||
export class GenericTableModalComponent {
|
||||
|
||||
public readonly modalService = inject(NgbActiveModal);
|
||||
|
||||
@Input({required: true}) title: string = '';
|
||||
@Input() bodyTemplate!: TemplateRef<any>;
|
||||
|
||||
ngOnInit() {
|
||||
console.log('bodyTemplate: ', this.bodyTemplate)
|
||||
}
|
||||
|
||||
}
|
@ -21,17 +21,19 @@ import {tap} from "rxjs/operators";
|
||||
})
|
||||
export class DayBreakdownComponent implements OnInit {
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly statService = inject(StatisticsService);
|
||||
|
||||
@Input() userId = 0;
|
||||
|
||||
view: [number, number] = [0,0];
|
||||
showLegend: boolean = true;
|
||||
max: number = 1;
|
||||
|
||||
formControl: FormControl = new FormControl(true, []);
|
||||
dayBreakdown$!: Observable<Array<PieDataItem>>;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
constructor(private statService: StatisticsService) {}
|
||||
|
||||
ngOnInit() {
|
||||
const dayOfWeekPipe = new DayOfWeekPipe();
|
||||
|
@ -23,6 +23,7 @@
|
||||
<ng-container *ngIf="formControl.value; else tableLayout">
|
||||
<ngx-charts-advanced-pie-chart [results]="vizData2$ | async"></ngx-charts-advanced-pie-chart>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #tableLayout>
|
||||
<table class="table table-striped table-striped table-hover table-sm scrollable">
|
||||
<thead>
|
||||
|
@ -1,14 +1,14 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectionStrategy, ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
QueryList,
|
||||
QueryList, TemplateRef, ViewChild,
|
||||
ViewChildren
|
||||
} from '@angular/core';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { PieChartModule } from '@swimlane/ngx-charts';
|
||||
import { Observable, BehaviorSubject, combineLatest, map, shareReplay } from 'rxjs';
|
||||
import {Observable, BehaviorSubject, combineLatest, map, shareReplay, switchMap} from 'rxjs';
|
||||
import { StatisticsService } from 'src/app/_services/statistics.service';
|
||||
import { SortableHeader, SortEvent, compare } from 'src/app/_single-module/table/_directives/sortable-header.directive';
|
||||
import { FileExtension, FileExtensionBreakdown } from '../../_models/file-breakdown';
|
||||
@ -18,8 +18,10 @@ import { MangaFormatPipe } from '../../../_pipes/manga-format.pipe';
|
||||
import { BytesPipe } from '../../../_pipes/bytes.pipe';
|
||||
import { SortableHeader as SortableHeader_1 } from '../../../_single-module/table/_directives/sortable-header.directive';
|
||||
import { NgIf, NgFor, AsyncPipe, DecimalPipe } from '@angular/common';
|
||||
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
|
||||
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {filter, tap} from "rxjs/operators";
|
||||
import {GenericTableModalComponent} from "../_modals/generic-table-modal/generic-table-modal.component";
|
||||
|
||||
export interface StackedBarChartDataItem {
|
||||
name: string,
|
||||
@ -36,7 +38,11 @@ export interface StackedBarChartDataItem {
|
||||
})
|
||||
export class FileBreakdownStatsComponent {
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
@ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>;
|
||||
@ViewChild('tablelayout') tableTemplate!: TemplateRef<any>;
|
||||
|
||||
rawData$!: Observable<FileExtensionBreakdown>;
|
||||
files$!: Observable<Array<FileExtension>>;
|
||||
@ -45,14 +51,15 @@ export class FileBreakdownStatsComponent {
|
||||
currentSort = new BehaviorSubject<SortEvent<FileExtension>>({column: 'extension', direction: 'asc'});
|
||||
currentSort$: Observable<SortEvent<FileExtension>> = this.currentSort.asObservable();
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
view: [number, number] = [700, 400];
|
||||
|
||||
formControl: FormControl = new FormControl(true, []);
|
||||
|
||||
private readonly statService = inject(StatisticsService);
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly ngbModal = inject(NgbModal);
|
||||
|
||||
constructor(private statService: StatisticsService, private translocoService: TranslocoService) {
|
||||
constructor() {
|
||||
this.rawData$ = this.statService.getFileBreakdown().pipe(takeUntilDestroyed(this.destroyRef), shareReplay());
|
||||
|
||||
this.files$ = combineLatest([this.currentSort$, this.rawData$]).pipe(
|
||||
@ -73,6 +80,17 @@ export class FileBreakdownStatsComponent {
|
||||
this.vizData2$ = this.files$.pipe(takeUntilDestroyed(this.destroyRef), map(data => data.map(d => {
|
||||
return {name: d.extension || this.translocoService.translate('file-breakdown-stats.not-classified'), value: d.totalFiles, extra: d.totalSize};
|
||||
})));
|
||||
|
||||
// TODO: See if you can figure this out
|
||||
// this.formControl.valueChanges.pipe(filter(v => !v), takeUntilDestroyed(this.destroyRef), switchMap(_ => {
|
||||
// const ref = this.ngbModal.open(GenericTableModalComponent);
|
||||
// ref.componentInstance.title = translate('file-breakdown-stats.format-title');
|
||||
// ref.componentInstance.bodyTemplate = this.tableTemplate;
|
||||
// return ref.dismissed;
|
||||
// }, tap(_ => {
|
||||
// this.formControl.setValue(true);
|
||||
// this.cdRef.markForCheck();
|
||||
// }))).subscribe();
|
||||
}
|
||||
|
||||
onSort(evt: SortEvent<FileExtension>) {
|
||||
|
@ -0,0 +1,38 @@
|
||||
<ng-container *transloco="let t; read: 'kavitaplus-metadata-breakdown-stats'">
|
||||
<div class="dashboard-card-content">
|
||||
<h4>{{t('title')}}</h4>
|
||||
|
||||
@if(breakdown) {
|
||||
@if(breakdown.totalSeries === 0 || breakdown.seriesCompleted === 0) {
|
||||
<div>{{t('no-data')}}</div>
|
||||
}
|
||||
@if (percentDone >= 1) {
|
||||
<p>{{t('complete') }}</p>
|
||||
} @else {
|
||||
<p>{{t('total-series-progress-label', {percent: percentDone * 100 | percent}) }}</p>
|
||||
|
||||
<div class="day-breakdown-chart">
|
||||
<table class="charts-css pie show-labels">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<th scope="row">{{t('completed-series-label')}}</th>
|
||||
<td class="completed" style="--start: 0; --end: ' + {{ completedEnd }}">
|
||||
<span class="data">{{ breakdown.seriesCompleted }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th scope="row">{{t('errored-series-label')}}</th>
|
||||
<td class="error" style="--start: ' + {{ errorStart }}; --end: {{ errorEnd }}'">
|
||||
<span class="data">{{ breakdown.erroredSeries }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
@ -0,0 +1,18 @@
|
||||
.dashboard-card-content {
|
||||
max-width: 400px;
|
||||
height: auto;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
|
||||
.day-breakdown-chart {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
.completed {
|
||||
color: var(--color-5);
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
|
||||
import {StatisticsService} from "../../../_services/statistics.service";
|
||||
import {KavitaPlusMetadataBreakdown} from "../../_models/kavitaplus-metadata-breakdown";
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {PercentPipe} from "@angular/common";
|
||||
|
||||
@Component({
|
||||
selector: 'app-kavitaplus-metadata-breakdown-stats',
|
||||
standalone: true,
|
||||
imports: [
|
||||
TranslocoDirective,
|
||||
PercentPipe
|
||||
],
|
||||
templateUrl: './kavitaplus-metadata-breakdown-stats.component.html',
|
||||
styleUrl: './kavitaplus-metadata-breakdown-stats.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class KavitaplusMetadataBreakdownStatsComponent {
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly statsService = inject(StatisticsService);
|
||||
|
||||
breakdown: KavitaPlusMetadataBreakdown | undefined;
|
||||
completedStart!: number;
|
||||
completedEnd!: number;
|
||||
errorStart!: number;
|
||||
errorEnd!: number;
|
||||
percentDone!: number;
|
||||
|
||||
constructor() {
|
||||
this.statsService.getKavitaPlusMetadataBreakdown().subscribe(res => {
|
||||
this.breakdown = res;
|
||||
this.completedStart = 0;
|
||||
this.completedEnd = ((res.seriesCompleted - res.erroredSeries) / res.totalSeries);
|
||||
this.errorStart = this.completedEnd;
|
||||
this.errorEnd = Math.max(1, ((res.seriesCompleted) / res.totalSeries));
|
||||
this.percentDone = res.seriesCompleted / res.totalSeries;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
@ -113,9 +113,14 @@
|
||||
</div>
|
||||
|
||||
<div class="row g-0 pt-4 pb-2">
|
||||
<div class="col-md-12 col-sm-12 mt-4 pt-2">
|
||||
<div class="col-md-6 col-sm-12 mt-4 pt-2">
|
||||
<app-day-breakdown></app-day-breakdown>
|
||||
</div>
|
||||
@if (accountService.hasValidLicense$ | async) {
|
||||
<div class="col-md-4 col-md-offset-2 col-sm-12 mt-4 pt-2">
|
||||
<app-kavitaplus-metadata-breakdown-stats></app-kavitaplus-metadata-breakdown-stats>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
|
@ -26,6 +26,10 @@ import {AsyncPipe, DecimalPipe, NgIf} from '@angular/common';
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {FilterComparison} from "../../../_models/metadata/v2/filter-comparison";
|
||||
import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||
import {
|
||||
KavitaplusMetadataBreakdownStatsComponent
|
||||
} from "../kavitaplus-metadata-breakdown-stats/kavitaplus-metadata-breakdown-stats.component";
|
||||
import {AccountService} from "../../../_services/account.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-stats',
|
||||
@ -33,12 +37,15 @@ import {FilterField} from "../../../_models/metadata/v2/filter-field";
|
||||
styleUrls: ['./server-stats.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgIf, IconAndTitleComponent, StatListComponent, TopReadersComponent, FileBreakdownStatsComponent,
|
||||
PublicationStatusStatsComponent, ReadingActivityComponent, DayBreakdownComponent, AsyncPipe, DecimalPipe,
|
||||
CompactNumberPipe, TimeDurationPipe, BytesPipe, TranslocoDirective]
|
||||
imports: [NgIf, IconAndTitleComponent, StatListComponent, TopReadersComponent, FileBreakdownStatsComponent,
|
||||
PublicationStatusStatsComponent, ReadingActivityComponent, DayBreakdownComponent, AsyncPipe, DecimalPipe,
|
||||
CompactNumberPipe, TimeDurationPipe, BytesPipe, TranslocoDirective, KavitaplusMetadataBreakdownStatsComponent]
|
||||
})
|
||||
export class ServerStatsComponent {
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly accountService = inject(AccountService);
|
||||
|
||||
releaseYears$!: Observable<Array<PieDataItem>>;
|
||||
mostActiveUsers$!: Observable<Array<PieDataItem>>;
|
||||
mostActiveLibrary$!: Observable<Array<PieDataItem>>;
|
||||
@ -54,7 +61,7 @@ export class ServerStatsComponent {
|
||||
breakpointSubject = new ReplaySubject<Breakpoint>(1);
|
||||
breakpoint$: Observable<Breakpoint> = this.breakpointSubject.asObservable();
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
@HostListener('window:orientationchange', ['$event'])
|
||||
|
@ -0,0 +1,5 @@
|
||||
export interface KavitaPlusMetadataBreakdown {
|
||||
totalSeries: number;
|
||||
erroredSeries: number;
|
||||
seriesCompleted: number;
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
<ng-container *transloco="let t; read:'scrobbling-providers'">
|
||||
<div class="card mt-2">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<div class="container-fluid row mb-2">
|
||||
<div class="col-10 col-sm-11">
|
||||
<h4 id="anilist-token-header">{{t('title')}}
|
||||
@if(!tokenExpired) {
|
||||
<i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('token-valid')"></i>
|
||||
<span class="visually-hidden">{{t('token-valid')}}</span>
|
||||
} @else {
|
||||
<i class="fa-solid fa-circle ms-1 confirm-icon error" aria-hidden="true" [ngbTooltip]="t('token-expired')"></i>
|
||||
<span class="visually-hidden">{{t('token-expired')}}</span>
|
||||
}
|
||||
</h4>
|
||||
|
||||
</div>
|
||||
<div class="col-1 text-end">
|
||||
<button class="btn btn-primary btn-sm" [disabled]="!hasValidLicense" (click)="toggleViewMode()">{{isViewMode ? t('edit') : t('cancel')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="isViewMode">
|
||||
<div class="container-fluid row">
|
||||
<span class="col-12">
|
||||
<ng-container *ngIf="!hasValidLicense; else showToken">
|
||||
{{t('requires', {product: 'Kavita+'})}}
|
||||
</ng-container>
|
||||
<ng-template #showToken>
|
||||
<ng-container *ngIf="token && token.length > 0; else noToken">
|
||||
<img class="me-2" width="32" height="32" ngSrc="assets/images/ExternalServices/AniList.png" alt="AniList" ngbTooltip="AniList"> {{t('token-set')}}
|
||||
<i class="error fa-solid fa-exclamation-circle" [ngbTooltip]="t('token-expired')" *ngIf="tokenExpired">
|
||||
<span class="visually-hidden">{{t('token-expired')}}</span>
|
||||
</i>
|
||||
</ng-container>
|
||||
<ng-template #noToken>{{t('no-token-set')}}</ng-template>
|
||||
</ng-template>
|
||||
</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
|
||||
<p>{{t('instructions', {service: 'AniList'})}}</p>
|
||||
<form [formGroup]="formGroup">
|
||||
<div class="form-group mb-3">
|
||||
<label for="anilist-token">{{t('token-input-label', {service: 'AniList'})}}</label>
|
||||
<textarea id="anilist-token" rows="2" cols="3" class="form-control" formControlName="aniListToken"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<a class="flex-fill btn btn-secondary me-2"
|
||||
href="https://anilist.co/api/v2/oauth/authorize?client_id=12809&redirect_url=https://anilist.co/api/v2/oauth/pin&response_type=token"
|
||||
target="_blank" rel="noopener noreferrer">{{t('generate')}}</a>
|
||||
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="anilist-token-header" (click)="saveForm()">{{t('save')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</ng-container>
|
@ -1,9 +0,0 @@
|
||||
.error {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
color: var(--primary-color);
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
}
|
@ -1,80 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from "@angular/forms";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import { NgbTooltip, NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NgIf, NgOptimizedImage } from '@angular/common';
|
||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-anilist-key',
|
||||
templateUrl: './anilist-key.component.html',
|
||||
styleUrls: ['./anilist-key.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgIf, NgOptimizedImage, NgbTooltip, NgbCollapse, ReactiveFormsModule, TranslocoDirective]
|
||||
})
|
||||
export class AnilistKeyComponent implements OnInit {
|
||||
|
||||
hasValidLicense: boolean = false;
|
||||
|
||||
formGroup: FormGroup = new FormGroup({});
|
||||
token: string = '';
|
||||
isViewMode: boolean = true;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
tokenExpired: boolean = false;
|
||||
|
||||
|
||||
constructor(public accountService: AccountService, private scrobblingService: ScrobblingService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) {
|
||||
this.accountService.hasValidLicense().subscribe(res => {
|
||||
this.hasValidLicense = res;
|
||||
this.cdRef.markForCheck();
|
||||
if (this.hasValidLicense) {
|
||||
this.scrobblingService.getAniListToken().subscribe(token => {
|
||||
this.token = token;
|
||||
this.formGroup.get('aniListToken')?.setValue(token);
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => {
|
||||
this.tokenExpired = hasExpired;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.formGroup.addControl('aniListToken', new FormControl('', [Validators.required]));
|
||||
}
|
||||
|
||||
|
||||
|
||||
resetForm() {
|
||||
this.formGroup.get('aniListToken')?.setValue('');
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
saveForm() {
|
||||
this.scrobblingService.updateAniListToken(this.formGroup.get('aniListToken')!.value).subscribe(() => {
|
||||
this.toastr.success(translate('toasts.anilist-token-updated'));
|
||||
this.token = this.formGroup.get('aniListToken')!.value;
|
||||
this.resetForm();
|
||||
this.isViewMode = true;
|
||||
});
|
||||
}
|
||||
|
||||
toggleViewMode() {
|
||||
this.isViewMode = !this.isViewMode;
|
||||
this.resetForm();
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -0,0 +1,151 @@
|
||||
<ng-container *transloco="let t; read:'scrobbling-providers'">
|
||||
<div class="card mt-2">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<div class="container-fluid row mb-2">
|
||||
<div class="col-10 col-sm-11">
|
||||
<h4>{{t('title')}}
|
||||
@if(!aniListTokenExpired) {
|
||||
<i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('token-valid')"></i>
|
||||
<span class="visually-hidden">{{t('token-valid')}}</span>
|
||||
} @else {
|
||||
<i class="fa-solid fa-circle ms-1 confirm-icon error" aria-hidden="true" [ngbTooltip]="t('token-expired')"></i>
|
||||
<span class="visually-hidden">{{t('token-expired')}}</span>
|
||||
}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="col-1 text-end">
|
||||
<button class="btn btn-primary btn-sm" [disabled]="!hasValidLicense" (click)="toggleViewMode()">{{isViewMode ? t('edit') : t('cancel')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(loaded) {
|
||||
<ng-container *ngIf="isViewMode">
|
||||
<div class="container-fluid row">
|
||||
<span class="col-12">
|
||||
@if(!hasValidLicense) {
|
||||
{{t('requires', {product: 'Kavita+'})}}
|
||||
} @else {
|
||||
<span>
|
||||
<img class="me-2" width="32" height="32" ngSrc="assets/images/ExternalServices/AniList.png" alt="AniList" ngbTooltip="AniList">
|
||||
@if(aniListToken && aniListToken.length > 0) {
|
||||
{{t('token-set')}}
|
||||
} @else {
|
||||
{{t('no-token-set')}}
|
||||
}
|
||||
@if(aniListTokenExpired) {
|
||||
<i class="error fa-solid fa-exclamation-circle" [ngbTooltip]="t('token-expired')">
|
||||
<span class="visually-hidden">{{t('token-expired')}}</span>
|
||||
</i>
|
||||
}
|
||||
</span>
|
||||
|
||||
<span class="ms-2">
|
||||
<img class="me-2" width="32" height="32" ngSrc="assets/images/ExternalServices/MAL.png" alt="MAL" ngbTooltip="MAL">
|
||||
@if (malToken && malToken.length > 0) {
|
||||
{{t('token-set')}}
|
||||
}
|
||||
@else {
|
||||
{{t('no-token-set')}}
|
||||
}
|
||||
</span>
|
||||
|
||||
@if(malTokenExpired) {
|
||||
<i class="error fa-solid fa-exclamation-circle" [ngbTooltip]="t('token-expired')">
|
||||
<span class="visually-hidden">{{t('token-expired')}}</span>
|
||||
</i>
|
||||
}
|
||||
|
||||
@if (!aniListToken && !malToken) {
|
||||
{{t('no-token-set')}}
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div [(ngbCollapse)]="isViewMode">
|
||||
<p>{{t('generic-instructions')}}</p>
|
||||
<form [formGroup]="formGroup">
|
||||
|
||||
<div class="mt-3" ngbAccordion #accordion [destroyOnHide]="false" [closeOthers]="true">
|
||||
<div ngbAccordionItem="anilist">
|
||||
<h2 ngbAccordionHeader>
|
||||
<button ngbAccordionButton id="anilist-token-header">
|
||||
AniList
|
||||
@if(!aniListTokenExpired) {
|
||||
<i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('token-valid')"></i>
|
||||
<span class="visually-hidden">{{t('token-valid')}}</span>
|
||||
} @else {
|
||||
<i class="fa-solid fa-circle ms-1 confirm-icon error" aria-hidden="true" [ngbTooltip]="t('token-expired')"></i>
|
||||
<span class="visually-hidden">{{t('token-expired')}}</span>
|
||||
}
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<p>{{t('instructions', {service: 'AniList'})}}</p>
|
||||
<div class="form-group mb-3">
|
||||
<label for="anilist-token">{{t('token-input-label', {service: 'AniList'})}}</label>
|
||||
<textarea id="anilist-token" rows="2" cols="3" class="form-control" formControlName="aniListToken"></textarea>
|
||||
</div>
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<a class="flex-fill btn btn-secondary me-2"
|
||||
href="https://anilist.co/api/v2/oauth/authorize?client_id=12809&redirect_url=https://anilist.co/api/v2/oauth/pin&response_type=token"
|
||||
target="_blank" rel="noopener noreferrer">{{t('generate')}}</a>
|
||||
<button type="button" class="flex-fill btn btn-primary" aria-describedby="anilist-token-header" (click)="saveAniListForm()">{{t('save')}}</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ngbAccordionItem="mal">
|
||||
<h2 ngbAccordionHeader>
|
||||
<button ngbAccordionButton id="mal-token-header">
|
||||
MAL
|
||||
@if(!malTokenExpired) {
|
||||
<i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('token-valid')"></i>
|
||||
<span class="visually-hidden">{{t('token-valid')}}</span>
|
||||
} @else {
|
||||
<i class="fa-solid fa-circle ms-1 confirm-icon error" aria-hidden="true" [ngbTooltip]="t('token-expired')"></i>
|
||||
<span class="visually-hidden">{{t('token-expired')}}</span>
|
||||
}
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<p>{{t('mal-instructions', {service: 'MAL'})}}</p>
|
||||
<div class="form-group mb-3">
|
||||
<label for="mal-token">{{t('mal-token-input-label')}}</label>
|
||||
<input type="text" id="mal-token" class="form-control" formControlName="malClientId"/>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="mal-username">{{t('mal-username-input-label')}}</label>
|
||||
<input type="text" id="mal-username" class="form-control" formControlName="malUsername"/>
|
||||
</div>
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<button type="button" class="flex-fill btn btn-primary" aria-describedby="mal-token-header" (click)="saveMalForm()">{{t('save')}}</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
} @else {
|
||||
<app-loading [loading]="!loaded" [message]="t('loading')"></app-loading>
|
||||
}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</ng-container>
|
@ -0,0 +1,126 @@
|
||||
import {ChangeDetectorRef, Component, ContentChild, DestroyRef, ElementRef, inject, OnInit} from '@angular/core';
|
||||
import {NgIf, NgOptimizedImage} from "@angular/common";
|
||||
import {
|
||||
NgbAccordionBody,
|
||||
NgbAccordionButton,
|
||||
NgbAccordionCollapse, NgbAccordionDirective, NgbAccordionHeader, NgbAccordionItem,
|
||||
NgbCollapse,
|
||||
NgbTooltip
|
||||
} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {Select2Module} from "ng-select2-component";
|
||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {ManageAlertsComponent} from "../../admin/manage-alerts/manage-alerts.component";
|
||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-scrobbling-providers',
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgIf,
|
||||
NgOptimizedImage,
|
||||
NgbTooltip,
|
||||
ReactiveFormsModule,
|
||||
Select2Module,
|
||||
TranslocoDirective,
|
||||
NgbCollapse,
|
||||
ManageAlertsComponent,
|
||||
NgbAccordionBody,
|
||||
NgbAccordionButton,
|
||||
NgbAccordionCollapse,
|
||||
NgbAccordionDirective,
|
||||
NgbAccordionHeader,
|
||||
NgbAccordionItem,
|
||||
LoadingComponent,
|
||||
],
|
||||
templateUrl: './manage-scrobbling-providers.component.html',
|
||||
styleUrl: './manage-scrobbling-providers.component.scss'
|
||||
})
|
||||
export class ManageScrobblingProvidersComponent implements OnInit {
|
||||
public readonly accountService = inject(AccountService);
|
||||
private readonly scrobblingService = inject(ScrobblingService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
hasValidLicense: boolean = false;
|
||||
|
||||
formGroup: FormGroup = new FormGroup({});
|
||||
aniListToken: string = '';
|
||||
malToken: string = '';
|
||||
malUsername: string = '';
|
||||
|
||||
aniListTokenExpired: boolean = false;
|
||||
malTokenExpired: boolean = false;
|
||||
|
||||
isViewMode: boolean = true;
|
||||
loaded: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.accountService.hasValidLicense().subscribe(res => {
|
||||
this.hasValidLicense = res;
|
||||
this.cdRef.markForCheck();
|
||||
if (this.hasValidLicense) {
|
||||
this.scrobblingService.getAniListToken().subscribe(token => {
|
||||
this.aniListToken = token;
|
||||
this.formGroup.get('aniListToken')?.setValue(token);
|
||||
this.loaded = true;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
this.scrobblingService.getMalToken().subscribe(dto => {
|
||||
this.malToken = dto.accessToken;
|
||||
this.malUsername = dto.username;
|
||||
this.formGroup.get('malToken')?.setValue(this.malToken);
|
||||
this.formGroup.get('malUsername')?.setValue(this.malUsername);
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => {
|
||||
this.aniListTokenExpired = hasExpired;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.formGroup.addControl('aniListToken', new FormControl('', [Validators.required]));
|
||||
this.formGroup.addControl('malClientId', new FormControl('', [Validators.required]));
|
||||
this.formGroup.addControl('malUsername', new FormControl('', [Validators.required]));
|
||||
}
|
||||
|
||||
|
||||
|
||||
resetForm() {
|
||||
this.formGroup.get('aniListToken')?.setValue('');
|
||||
this.formGroup.get('malClientId')?.setValue('');
|
||||
this.formGroup.get('malUsername')?.setValue('');
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
saveAniListForm() {
|
||||
this.scrobblingService.updateAniListToken(this.formGroup.get('aniListToken')!.value).subscribe(() => {
|
||||
this.toastr.success(translate('toasts.anilist-token-updated'));
|
||||
this.aniListToken = this.formGroup.get('aniListToken')!.value;
|
||||
this.resetForm();
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
saveMalForm() {
|
||||
this.scrobblingService.updateMalToken(this.formGroup.get('malUsername')!.value, this.formGroup.get('malClientId')!.value).subscribe(() => {
|
||||
this.toastr.success(translate('toasts.mal-clientId-updated'));
|
||||
this.malToken = this.formGroup.get('malClientId')!.value;
|
||||
this.malUsername = this.formGroup.get('malUsername')!.value;
|
||||
this.resetForm();
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
toggleViewMode() {
|
||||
this.isViewMode = !this.isViewMode;
|
||||
this.resetForm();
|
||||
}
|
||||
}
|
@ -13,7 +13,7 @@
|
||||
<app-change-email></app-change-email>
|
||||
<app-change-password></app-change-password>
|
||||
<app-change-age-restriction></app-change-age-restriction>
|
||||
<app-anilist-key></app-anilist-key>
|
||||
<app-manage-scrobbling-providers></app-manage-scrobbling-providers>
|
||||
}
|
||||
|
||||
@defer (when tab.fragment === FragmentID.Preferences; prefetch on idle) {
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import {take, tap} from 'rxjs/operators';
|
||||
import {take} from 'rxjs/operators';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import {
|
||||
readingDirections,
|
||||
@ -39,7 +39,6 @@ import { ManageDevicesComponent } from '../manage-devices/manage-devices.compone
|
||||
import { ThemeManagerComponent } from '../theme-manager/theme-manager.component';
|
||||
import { ApiKeyComponent } from '../api-key/api-key.component';
|
||||
import { ColorPickerModule } from 'ngx-color-picker';
|
||||
import { AnilistKeyComponent } from '../anilist-key/anilist-key.component';
|
||||
import { ChangeAgeRestrictionComponent } from '../change-age-restriction/change-age-restriction.component';
|
||||
import { ChangePasswordComponent } from '../change-password/change-password.component';
|
||||
import { ChangeEmailComponent } from '../change-email/change-email.component';
|
||||
@ -50,6 +49,7 @@ import {LocalizationService} from "../../_services/localization.service";
|
||||
import {Language} from "../../_models/metadata/language";
|
||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
import {ManageScrobblingProvidersComponent} from "../manage-scrobbling-providers/manage-scrobbling-providers.component";
|
||||
|
||||
enum AccordionPanelID {
|
||||
ImageReader = 'image-reader',
|
||||
@ -74,10 +74,10 @@ enum FragmentID {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [SideNavCompanionBarComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, RouterLink, NgbNavContent, NgIf, ChangeEmailComponent,
|
||||
ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
|
||||
ChangePasswordComponent, ChangeAgeRestrictionComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
|
||||
NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent,
|
||||
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe,
|
||||
TranslocoDirective, LoadingComponent],
|
||||
TranslocoDirective, LoadingComponent, ManageScrobblingProvidersComponent],
|
||||
})
|
||||
export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
|
||||
@ -166,7 +166,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.route.fragment.subscribe(frag => {
|
||||
const tab = this.tabs.filter(item => item.fragment === frag);
|
||||
console.log('tab: ', tab);
|
||||
if (tab.length > 0) {
|
||||
this.active = tab[0];
|
||||
} else {
|
||||
|
@ -294,11 +294,17 @@
|
||||
"no-token-set": "No Token Set",
|
||||
"token-set": "Token Set",
|
||||
"generate": "Generate",
|
||||
"generic-instructions": "Fill out information about different External Services you have to allow Kavita+ to interact with them.",
|
||||
"instructions": "First time users should click on \"{{scrobbling-providers.generate}}\" below to allow Kavita+ to talk with {{service}}. Once you authorize the program, copy and paste the token in the input below. You can regenerate your token at any time.",
|
||||
"mal-instructions": "Kavita uses a MAL Client Id for authentication. Create a new Client for Kavita and once approved, supply the client Id and your username.",
|
||||
"scrobbling-applicable-label": "Scrobbling Applicable",
|
||||
"token-input-label": "{{service}} Token Goes Here",
|
||||
"mal-token-input-label": "MAL Client Id",
|
||||
"mal-username-input-label": "MAL Username",
|
||||
"edit": "{{common.edit}}",
|
||||
"cancel": "{{common.cancel}}",
|
||||
"save": "{{common.save}}"
|
||||
"save": "{{common.save}}",
|
||||
"loading": "{{common.loading}}"
|
||||
},
|
||||
|
||||
"typeahead": {
|
||||
@ -937,6 +943,7 @@
|
||||
"metadata-tab": "Metadata",
|
||||
"cover-tab": "Cover",
|
||||
"info-tab": "Info",
|
||||
"progress-tab": "Progress",
|
||||
"no-summary": "No Summary available.",
|
||||
"writers-title": "{{series-metadata-detail.writers-title}}",
|
||||
"genres-title": "{{series-metadata-detail.genres-title}}",
|
||||
@ -1025,6 +1032,7 @@
|
||||
"id-title": "ID",
|
||||
"links-title": "{{series-metadata-detail.links-title}}",
|
||||
"isbn-title": "ISBN",
|
||||
"sort-order-title": "Sort Order",
|
||||
"last-read-title": "Last Read",
|
||||
"less-than-hour": "<1 Hour",
|
||||
"range-hours": "{{value}} {{hourWord}}",
|
||||
@ -1551,6 +1559,23 @@
|
||||
"promote-tooltip": "Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them."
|
||||
},
|
||||
|
||||
"import-mal-collection-modal": {
|
||||
"close": "{{common.close}}",
|
||||
"title": "MAL Interest Stack Import",
|
||||
"description": "Import your MAL Interest Stacks and create Collections within Kavita",
|
||||
"series-count": "{{common.series-count}}",
|
||||
"restack-count": "{{num}} Restacks"
|
||||
},
|
||||
|
||||
"edit-chapter-progress": {
|
||||
"user-header": "User",
|
||||
"page-read-header": "Pages Read",
|
||||
"date-created-header": "Created (UTC)",
|
||||
"date-updated-header": "Last Updated (UTC)",
|
||||
"action-header": "{{common.edit}}",
|
||||
"edit-alt": "{{common.edit}}"
|
||||
},
|
||||
|
||||
"import-cbl-modal": {
|
||||
"close": "{{common.close}}",
|
||||
"title": "CBL Import",
|
||||
@ -1759,6 +1784,15 @@
|
||||
"y-axis-label": "Reading Events"
|
||||
},
|
||||
|
||||
"kavitaplus-metadata-breakdown-stats": {
|
||||
"title": "Kavita+ Metadata Breakdown",
|
||||
"no-data": "No data",
|
||||
"errored-series-label": "Errored Series",
|
||||
"completed-series-label": "Completed Series",
|
||||
"total-series-progress-label": "Series Processed: {{percent}}",
|
||||
"complete": "All Series have metadata"
|
||||
},
|
||||
|
||||
"file-breakdown-stats": {
|
||||
"format-title": "Format",
|
||||
"format-tooltip": "Not Classified means Kavita has not scanned some files. This occurs on old files existing prior to v0.7. You may need to run a forced scan via Library settings modal.",
|
||||
@ -2125,6 +2159,7 @@
|
||||
"view-series": "View Series",
|
||||
"clear": "{{common.clear}}",
|
||||
"import-cbl": "Import CBL",
|
||||
"import-mal-stack": "Import MAL Stack",
|
||||
"read": "Read",
|
||||
"add-rule-group-and": "Add Rule Group (AND)",
|
||||
"add-rule-group-or": "Add Rule Group (OR)",
|
||||
|
@ -1,10 +1,9 @@
|
||||
.progress {
|
||||
background-color: var(--progress-bg-color);
|
||||
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background-color: var(--progress-bar-color);
|
||||
background-color: var(--progress-bar-color) !important;
|
||||
}
|
||||
|
||||
.progress-bar-striped {
|
||||
|
364
openapi.json
364
openapi.json
@ -7,7 +7,7 @@
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||
},
|
||||
"version": "0.7.14.6"
|
||||
"version": "0.7.14.8"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@ -886,6 +886,55 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Admin/update-chapter-progress": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"summary": "Set the progress information for a particular user",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateUserProgressDto"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateUserProgressDto"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateUserProgressDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Book/{chapterId}/book-info": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -1507,6 +1556,45 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Collection/mal-stacks": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Collection"
|
||||
],
|
||||
"summary": "For the authenticated user, if they have an active Kavita+ subscription and a MAL username on record,\r\nfetch their Mal interest stacks (including restacks)",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MalStackDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MalStackDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MalStackDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Device/create": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@ -6126,6 +6214,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Reader/all-chapter-progress": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Reader"
|
||||
],
|
||||
"summary": "Get all progress events for a given chapter",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "chapterId",
|
||||
"in": "query",
|
||||
"description": "",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FullProgressDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FullProgressDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FullProgressDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/ReadingList": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -7350,6 +7488,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Scrobbling/mal-token": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Scrobbling"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MalUserInfoDto"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MalUserInfoDto"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MalUserInfoDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Scrobbling/update-anilist-token": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@ -7383,6 +7550,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Scrobbling/update-mal-token": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Scrobbling"
|
||||
],
|
||||
"summary": "Update the current user's MAL token (Client ID) and Username",
|
||||
"requestBody": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MalUserInfoDto"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MalUserInfoDto"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MalUserInfoDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Scrobbling/token-expired": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -10832,6 +11032,45 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Stats/kavitaplus-metadata-breakdown": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Stats"
|
||||
],
|
||||
"summary": "Returns for Kavita+ the number of Series that have been processed, errored, and not processed",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Int32StatCount"
|
||||
}
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Int32StatCount"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Int32StatCount"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Stream/dashboard": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -12754,6 +12993,16 @@
|
||||
"description": "The JWT for the user's AniList account. Expires after a year.",
|
||||
"nullable": true
|
||||
},
|
||||
"malUserName": {
|
||||
"type": "string",
|
||||
"description": "The Username of the MAL user",
|
||||
"nullable": true
|
||||
},
|
||||
"malAccessToken": {
|
||||
"type": "string",
|
||||
"description": "The Client ID for the user's MAL account. User should create a client on MAL for this.",
|
||||
"nullable": true
|
||||
},
|
||||
"scrobbleHolds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -15766,6 +16015,49 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"FullProgressDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"chapterId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"pagesRead": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"lastModified": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"lastModifiedUtc": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"created": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"createdUtc": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"appUserId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"userName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "A full progress Record from the DB (not all data, only what's needed for API)"
|
||||
},
|
||||
"Genre": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -16277,6 +16569,58 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MalStackDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"stackId": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"seriesCount": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"restackCount": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"existingId": {
|
||||
"type": "integer",
|
||||
"description": "If an existing collection exists within Kavita",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Represents an Interest Stack from MAL"
|
||||
},
|
||||
"MalUserInfoDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"accessToken": {
|
||||
"type": "string",
|
||||
"description": "This is actually the Client Id",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"description": "Information about a User's MAL connection"
|
||||
},
|
||||
"MangaFile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -20133,6 +20477,24 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"UpdateUserProgressDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pageNum": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"lastModifiedUtc": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"createdUtc": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"UpdateUserReviewDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user