diff --git a/API.Tests/Services/ReadingProfileServiceTest.cs b/API.Tests/Services/ReadingProfileServiceTest.cs index 79254a2ff..6201415be 100644 --- a/API.Tests/Services/ReadingProfileServiceTest.cs +++ b/API.Tests/Services/ReadingProfileServiceTest.cs @@ -7,6 +7,7 @@ using API.Helpers.Builders; using API.Services; using Kavita.Common; using Microsoft.EntityFrameworkCore; +using NSubstitute; using Xunit; namespace API.Tests.Services; @@ -29,12 +30,52 @@ public class ReadingProfileServiceTest: AbstractDbTest user.Libraries.Add(library); await UnitOfWork.CommitAsync(); - var rps = new ReadingProfileService(UnitOfWork); + var rps = new ReadingProfileService(UnitOfWork, Substitute.For()); user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.UserPreferences); return (rps, user, library, series); } + [Fact] + public async Task ImplicitProfileFirst() + { + await ResetDb(); + var (rps, user, library, series) = await Setup(); + + var profile = new AppUserReadingProfileBuilder(user.Id) + .WithImplicit(true) + .WithSeries(series) + .WithName("Implicit Profile") + .Build(); + + var profile2 = new AppUserReadingProfileBuilder(user.Id) + .WithSeries(series) + .WithName("Non-implicit Profile") + .Build(); + + user.UserPreferences.ReadingProfiles.Add(profile); + user.UserPreferences.ReadingProfiles.Add(profile2); + await UnitOfWork.CommitAsync(); + + var seriesProfile = await UnitOfWork.AppUserReadingProfileRepository.GetProfileForSeries(user.Id, series.Id); + Assert.NotNull(seriesProfile); + Assert.Equal("Implicit Profile", seriesProfile.Name); + + var seriesProfileDto = await UnitOfWork.AppUserReadingProfileRepository.GetProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(seriesProfileDto); + Assert.Equal("Implicit Profile", seriesProfileDto.Name); + + await rps.DeleteImplicitForSeries(user.Id, series.Id); + + seriesProfile = await UnitOfWork.AppUserReadingProfileRepository.GetProfileForSeries(user.Id, series.Id); + Assert.NotNull(seriesProfile); + Assert.Equal("Non-implicit Profile", seriesProfile.Name); + + seriesProfileDto = await UnitOfWork.AppUserReadingProfileRepository.GetProfileDtoForSeries(user.Id, series.Id); + Assert.NotNull(seriesProfileDto); + Assert.Equal("Non-implicit Profile", seriesProfileDto.Name); + } + [Fact] public async Task DeleteImplicitSeriesReadingProfile() { diff --git a/API/Controllers/ReadingProfileController.cs b/API/Controllers/ReadingProfileController.cs index 9f6868247..84ab91936 100644 --- a/API/Controllers/ReadingProfileController.cs +++ b/API/Controllers/ReadingProfileController.cs @@ -1,9 +1,13 @@ #nullable enable +using System; +using System.Collections.Generic; using System.Threading.Tasks; using API.Data; +using API.Data.Repositories; using API.DTOs; using API.Extensions; using API.Services; +using Kavita.Common; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -13,6 +17,16 @@ public class ReadingProfileController(ILogger logger, IReadingProfileService readingProfileService): BaseApiController { + /// + /// Gets all non-implicit reading profiles for a user + /// + /// + [HttpGet("all")] + public async Task>> GetAllReadingProfiles() + { + return Ok(await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(User.GetUserId(), true)); + } + /// /// Returns the ReadingProfile that should be applied to the given series, walks up the tree. /// Series -> Library -> Default @@ -20,13 +34,13 @@ public class ReadingProfileController(ILogger logger, /// /// [HttpGet("{seriesId}")] - public async Task> GetProfileForSeries(int seriesId) + public async Task> GetProfileForSeries(int seriesId) { return Ok(await readingProfileService.GetReadingProfileForSeries(User.GetUserId(), seriesId)); } /// - /// Update, or create the given profile + /// Updates the given reading profile, must belong to the current user /// /// /// @@ -42,13 +56,23 @@ public class ReadingProfileController(ILogger logger, await readingProfileService.DeleteImplicitForSeries(User.GetUserId(), seriesCtx.Value); } - var success = await readingProfileService.UpdateReadingProfile(User.GetUserId(), dto); if (!success) return BadRequest(); return Ok(); } + /// + /// Creates a new reading profile for the current user + /// + /// + /// + [HttpPost("create")] + public async Task> CreateReadingProfile([FromBody] UserReadingProfileDto dto) + { + return Ok(await readingProfileService.CreateReadingProfile(User.GetUserId(), dto)); + } + /// /// Update the implicit reading profile for a series, creates one if none exists /// @@ -64,4 +88,32 @@ public class ReadingProfileController(ILogger logger, return Ok(); } + /// + /// Sets the given profile as the global default + /// + /// + /// + /// + /// + [HttpPost("set-default")] + public async Task SetDefault([FromQuery] int profileId) + { + await readingProfileService.SetDefaultReadingProfile(User.GetUserId(), profileId); + return Ok(); + } + + /// + /// Deletes the given profile, requires the profile to belong to the logged-in user + /// + /// + /// + /// + /// + [HttpDelete] + public async Task DeleteReadingProfile([FromQuery] int profileId) + { + await readingProfileService.DeleteReadingProfile(User.GetUserId(), profileId); + return Ok(); + } + } diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 944ea987b..17ebc758e 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -103,38 +103,13 @@ public class UsersController : BaseApiController var existingPreferences = user!.UserPreferences; - existingPreferences.ReadingDirection = preferencesDto.ReadingDirection; - existingPreferences.ScalingOption = preferencesDto.ScalingOption; - existingPreferences.PageSplitOption = preferencesDto.PageSplitOption; - existingPreferences.AutoCloseMenu = preferencesDto.AutoCloseMenu; - existingPreferences.ShowScreenHints = preferencesDto.ShowScreenHints; - existingPreferences.EmulateBook = preferencesDto.EmulateBook; - existingPreferences.ReaderMode = preferencesDto.ReaderMode; - existingPreferences.LayoutMode = preferencesDto.LayoutMode; - existingPreferences.BackgroundColor = string.IsNullOrEmpty(preferencesDto.BackgroundColor) ? "#000000" : preferencesDto.BackgroundColor; - existingPreferences.BookReaderMargin = preferencesDto.BookReaderMargin; - existingPreferences.BookReaderLineSpacing = preferencesDto.BookReaderLineSpacing; - existingPreferences.BookReaderFontFamily = preferencesDto.BookReaderFontFamily; - existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize; - existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate; - existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection; - existingPreferences.BookReaderWritingStyle = preferencesDto.BookReaderWritingStyle; - existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName; - existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode; - existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode; existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode; existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries; - existingPreferences.LayoutMode = preferencesDto.LayoutMode; existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize; existingPreferences.NoTransitions = preferencesDto.NoTransitions; - existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate; existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships; existingPreferences.ShareReviews = preferencesDto.ShareReviews; - existingPreferences.PdfTheme = preferencesDto.PdfTheme; - existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode; - existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode; - if (await _licenseService.HasActiveLicense()) { existingPreferences.AniListScrobblingEnabled = preferencesDto.AniListScrobblingEnabled; diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 6645a8f39..dd66353fc 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -9,61 +9,8 @@ namespace API.DTOs; public sealed record UserPreferencesDto { - /// - [Required] - public ReadingDirection ReadingDirection { get; set; } - /// - [Required] - public ScalingOption ScalingOption { get; set; } - /// - [Required] - public PageSplitOption PageSplitOption { get; set; } - /// - [Required] - public ReaderMode ReaderMode { get; set; } - /// - [Required] - public LayoutMode LayoutMode { get; set; } - /// - [Required] - public bool EmulateBook { get; set; } - /// - [Required] - public string BackgroundColor { get; set; } = "#000000"; - /// - [Required] - public bool SwipeToPaginate { get; set; } - /// - [Required] - public bool AutoCloseMenu { get; set; } - /// - [Required] - public bool ShowScreenHints { get; set; } = true; - /// - [Required] - public bool AllowAutomaticWebtoonReaderDetection { get; set; } - - /// - [Required] - public int BookReaderMargin { get; set; } - /// - [Required] - public int BookReaderLineSpacing { get; set; } - /// - [Required] - public int BookReaderFontSize { get; set; } - /// - [Required] - public string BookReaderFontFamily { get; set; } = null!; - /// - [Required] - public bool BookReaderTapToPaginate { get; set; } - /// - [Required] - public ReadingDirection BookReaderReadingDirection { get; set; } - /// - [Required] - public WritingStyle BookReaderWritingStyle { get; set; } + /// + public int DefaultReadingProfileId { get; init; } /// /// UI Site Global Setting: The UI theme the user should use. @@ -72,15 +19,6 @@ public sealed record UserPreferencesDto [Required] public SiteThemeDto? Theme { get; set; } - [Required] public string BookReaderThemeName { get; set; } = null!; - /// - [Required] - public BookPageLayoutMode BookReaderLayoutMode { get; set; } - /// - [Required] - public bool BookReaderImmersiveMode { get; set; } = false; - /// - [Required] public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards; /// [Required] @@ -101,16 +39,6 @@ public sealed record UserPreferencesDto [Required] public string Locale { get; set; } - /// - [Required] - public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; - /// - [Required] - public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; - /// - [Required] - public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; - /// public bool AniListScrobblingEnabled { get; set; } /// diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 4e5f8c227..1460cea26 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -257,6 +257,19 @@ public sealed class DataContext : IdentityDbContext() .Property(b => b.EnableCoverImage) .HasDefaultValue(true); + + builder.Entity() + .Property(b => b.BookThemeName) + .HasDefaultValue("Dark"); + builder.Entity() + .Property(b => b.BackgroundColor) + .HasDefaultValue("#000000"); + builder.Entity() + .Property(b => b.BookReaderWritingStyle) + .HasDefaultValue(WritingStyle.Horizontal); + builder.Entity() + .Property(b => b.AllowAutomaticWebtoonReaderDetection) + .HasDefaultValue(true); } #nullable enable diff --git a/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs b/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs index 081310167..37eed970a 100644 --- a/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs +++ b/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs @@ -1,13 +1,16 @@ using System; using System.Threading.Tasks; +using API.Entities; using API.Entities.History; +using API.Extensions; +using API.Helpers.Builders; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -namespace API.Data.ManualMigrations.v0._8._7; +namespace API.Data.ManualMigrations; -public class ManualMigrateReadingProfiles +public static class ManualMigrateReadingProfiles { public static async Task Migrate(DataContext context, ILogger logger) { @@ -18,62 +21,55 @@ public class ManualMigrateReadingProfiles logger.LogCritical("Running ManualMigrateReadingProfiles migration - Please be patient, this may take some time. This is not an error"); - await context.Database.ExecuteSqlRawAsync(@" - INSERT INTO AppUserReadingProfiles ( - AppUserId, - ReadingDirection, - ScalingOption, - PageSplitOption, - ReaderMode, - AutoCloseMenu, - ShowScreenHints, - EmulateBook, - LayoutMode, - BackgroundColor, - SwipeToPaginate, - AllowAutomaticWebtoonReaderDetection, - BookReaderMargin, - BookReaderLineSpacing, - BookReaderFontSize, - BookReaderFontFamily, - BookReaderTapToPaginate, - BookReaderReadingDirection, - BookReaderWritingStyle, - BookThemeName, - BookReaderLayoutMode, - BookReaderImmersiveMode, - PdfTheme, - PdfScrollMode, - PdfSpreadMode - ) - SELECT - AppUserId, - ReadingDirection, - ScalingOption, - PageSplitOption, - ReaderMode, - AutoCloseMenu, - ShowScreenHints, - EmulateBook, - LayoutMode, - BackgroundColor, - SwipeToPaginate, - AllowAutomaticWebtoonReaderDetection, - BookReaderMargin, - BookReaderLineSpacing, - BookReaderFontSize, - BookReaderFontFamily, - BookReaderTapToPaginate, - BookReaderReadingDirection, - BookReaderWritingStyle, - BookThemeName, - BookReaderLayoutMode, - BookReaderImmersiveMode, - PdfTheme, - PdfScrollMode, - PdfSpreadMode - FROM AppUserPreferences - "); + var users = await context.AppUser + .Include(u => u.UserPreferences) + .Include(u => u.UserPreferences.ReadingProfiles) + .ToListAsync(); + + foreach (var user in users) + { + var readingProfile = new AppUserReadingProfile + { + Name = "Default", + NormalizedName = "Default".ToNormalized(), + BackgroundColor = user.UserPreferences.BackgroundColor, + EmulateBook = user.UserPreferences.EmulateBook, + User = user, + PdfTheme = user.UserPreferences.PdfTheme, + ReaderMode = user.UserPreferences.ReaderMode, + ReadingDirection = user.UserPreferences.ReadingDirection, + ScalingOption = user.UserPreferences.ScalingOption, + LayoutMode = user.UserPreferences.LayoutMode, + WidthOverride = null, + UserId = user.Id, + AutoCloseMenu = user.UserPreferences.AutoCloseMenu, + BookReaderMargin = user.UserPreferences.BookReaderMargin, + PageSplitOption = user.UserPreferences.PageSplitOption, + BookThemeName = user.UserPreferences.BookThemeName, + PdfSpreadMode = user.UserPreferences.PdfSpreadMode, + PdfScrollMode = user.UserPreferences.PdfScrollMode, + SwipeToPaginate = user.UserPreferences.SwipeToPaginate, + BookReaderFontFamily = user.UserPreferences.BookReaderFontFamily, + BookReaderFontSize = user.UserPreferences.BookReaderFontSize, + BookReaderImmersiveMode = user.UserPreferences.BookReaderImmersiveMode, + BookReaderLayoutMode = user.UserPreferences.BookReaderLayoutMode, + BookReaderLineSpacing = user.UserPreferences.BookReaderLineSpacing, + BookReaderReadingDirection = user.UserPreferences.BookReaderReadingDirection, + BookReaderWritingStyle = user.UserPreferences.BookReaderWritingStyle, + AllowAutomaticWebtoonReaderDetection = user.UserPreferences.AllowAutomaticWebtoonReaderDetection, + BookReaderTapToPaginate = user.UserPreferences.BookReaderTapToPaginate, + ShowScreenHints = user.UserPreferences.ShowScreenHints, + }; + user.UserPreferences.ReadingProfiles.Add(readingProfile); + } + + await context.SaveChangesAsync(); + foreach (var user in users) + { + user.UserPreferences.DefaultReadingProfileId = + (await context.AppUserReadingProfile + .FirstAsync(rp => rp.UserId == user.Id)).Id; + } context.ManualMigrationHistory.Add(new ManualMigrationHistory { diff --git a/API/Data/Migrations/20250515215234_AppUserReadingProfiles.Designer.cs b/API/Data/Migrations/20250517195000_AppUserReadingProfiles.Designer.cs similarity index 99% rename from API/Data/Migrations/20250515215234_AppUserReadingProfiles.Designer.cs rename to API/Data/Migrations/20250517195000_AppUserReadingProfiles.Designer.cs index 873394173..4b0f0c3ea 100644 --- a/API/Data/Migrations/20250515215234_AppUserReadingProfiles.Designer.cs +++ b/API/Data/Migrations/20250517195000_AppUserReadingProfiles.Designer.cs @@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace API.Data.Migrations { [DbContext(typeof(DataContext))] - [Migration("20250515215234_AppUserReadingProfiles")] + [Migration("20250517195000_AppUserReadingProfiles")] partial class AppUserReadingProfiles { /// @@ -622,7 +622,9 @@ namespace API.Data.Migrations .HasColumnType("INTEGER"); b.Property("AllowAutomaticWebtoonReaderDetection") - .HasColumnType("INTEGER"); + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); b.Property("AppUserPreferencesId") .HasColumnType("INTEGER"); @@ -631,7 +633,9 @@ namespace API.Data.Migrations .HasColumnType("INTEGER"); b.Property("BackgroundColor") - .HasColumnType("TEXT"); + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); b.Property("BookReaderFontFamily") .HasColumnType("TEXT"); @@ -658,10 +662,14 @@ namespace API.Data.Migrations .HasColumnType("INTEGER"); b.Property("BookReaderWritingStyle") - .HasColumnType("INTEGER"); + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); b.Property("BookThemeName") - .HasColumnType("TEXT"); + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); b.Property("EmulateBook") .HasColumnType("INTEGER"); diff --git a/API/Data/Migrations/20250515215234_AppUserReadingProfiles.cs b/API/Data/Migrations/20250517195000_AppUserReadingProfiles.cs similarity index 97% rename from API/Data/Migrations/20250515215234_AppUserReadingProfiles.cs rename to API/Data/Migrations/20250517195000_AppUserReadingProfiles.cs index 77a2071ab..be75f7949 100644 --- a/API/Data/Migrations/20250515215234_AppUserReadingProfiles.cs +++ b/API/Data/Migrations/20250517195000_AppUserReadingProfiles.cs @@ -34,9 +34,9 @@ namespace API.Data.Migrations ShowScreenHints = table.Column(type: "INTEGER", nullable: false), EmulateBook = table.Column(type: "INTEGER", nullable: false), LayoutMode = table.Column(type: "INTEGER", nullable: false), - BackgroundColor = table.Column(type: "TEXT", nullable: true), + BackgroundColor = table.Column(type: "TEXT", nullable: true, defaultValue: "#000000"), SwipeToPaginate = table.Column(type: "INTEGER", nullable: false), - AllowAutomaticWebtoonReaderDetection = table.Column(type: "INTEGER", nullable: false), + AllowAutomaticWebtoonReaderDetection = table.Column(type: "INTEGER", nullable: false, defaultValue: true), WidthOverride = table.Column(type: "INTEGER", nullable: true), BookReaderMargin = table.Column(type: "INTEGER", nullable: false), BookReaderLineSpacing = table.Column(type: "INTEGER", nullable: false), @@ -44,8 +44,8 @@ namespace API.Data.Migrations BookReaderFontFamily = table.Column(type: "TEXT", nullable: true), BookReaderTapToPaginate = table.Column(type: "INTEGER", nullable: false), BookReaderReadingDirection = table.Column(type: "INTEGER", nullable: false), - BookReaderWritingStyle = table.Column(type: "INTEGER", nullable: false), - BookThemeName = table.Column(type: "TEXT", nullable: true), + BookReaderWritingStyle = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), + BookThemeName = table.Column(type: "TEXT", nullable: true, defaultValue: "Dark"), BookReaderLayoutMode = table.Column(type: "INTEGER", nullable: false), BookReaderImmersiveMode = table.Column(type: "INTEGER", nullable: false), PdfTheme = table.Column(type: "INTEGER", nullable: false), diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 505f37c53..e55e2cebd 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -619,7 +619,9 @@ namespace API.Data.Migrations .HasColumnType("INTEGER"); b.Property("AllowAutomaticWebtoonReaderDetection") - .HasColumnType("INTEGER"); + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); b.Property("AppUserPreferencesId") .HasColumnType("INTEGER"); @@ -628,7 +630,9 @@ namespace API.Data.Migrations .HasColumnType("INTEGER"); b.Property("BackgroundColor") - .HasColumnType("TEXT"); + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); b.Property("BookReaderFontFamily") .HasColumnType("TEXT"); @@ -655,10 +659,14 @@ namespace API.Data.Migrations .HasColumnType("INTEGER"); b.Property("BookReaderWritingStyle") - .HasColumnType("INTEGER"); + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); b.Property("BookThemeName") - .HasColumnType("TEXT"); + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); b.Property("EmulateBook") .HasColumnType("INTEGER"); diff --git a/API/Data/Repositories/AppUserReadingProfileRepository.cs b/API/Data/Repositories/AppUserReadingProfileRepository.cs index 47df09ffb..31c534524 100644 --- a/API/Data/Repositories/AppUserReadingProfileRepository.cs +++ b/API/Data/Repositories/AppUserReadingProfileRepository.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using API.DTOs; using API.Entities; +using API.Extensions; using API.Extensions.QueryExtensions; using AutoMapper; using AutoMapper.QueryableExtensions; @@ -22,13 +23,14 @@ public enum ReadingProfileIncludes public interface IAppUserReadingProfileRepository { - Task> GetProfilesForUser(int userId); - Task GetProfileForSeries(int userId, int seriesId); + Task> GetProfilesForUser(int userId, bool nonImplicitOnly, ReadingProfileIncludes includes = ReadingProfileIncludes.None); + Task GetProfileForSeries(int userId, int seriesId, ReadingProfileIncludes includes = ReadingProfileIncludes.None); Task GetProfileDtoForSeries(int userId, int seriesId); - Task GetProfileForLibrary(int userId, int libraryId); + Task GetProfileForLibrary(int userId, int libraryId, ReadingProfileIncludes includes = ReadingProfileIncludes.None); Task GetProfileDtoForLibrary(int userId, int libraryId); Task GetProfile(int profileId, ReadingProfileIncludes includes = ReadingProfileIncludes.None); Task GetProfileDto(int profileId); + Task GetProfileByName(int userId, string name); void Add(AppUserReadingProfile readingProfile); void Update(AppUserReadingProfile readingProfile); @@ -38,17 +40,20 @@ public interface IAppUserReadingProfileRepository public class AppUserReadingProfileRepository(DataContext context, IMapper mapper): IAppUserReadingProfileRepository { - public async Task> GetProfilesForUser(int userId) + public async Task> GetProfilesForUser(int userId, bool nonImplicitOnly, ReadingProfileIncludes includes = ReadingProfileIncludes.None) { return await context.AppUserReadingProfile - .Where(rp => rp.UserId == userId) + .Where(rp => rp.UserId == userId && !(nonImplicitOnly && rp.Implicit)) + .Includes(includes) .ToListAsync(); } - public async Task GetProfileForSeries(int userId, int seriesId) + public async Task GetProfileForSeries(int userId, int seriesId, ReadingProfileIncludes includes = ReadingProfileIncludes.None) { return await context.AppUserReadingProfile .Where(rp => rp.UserId == userId && rp.Series.Any(s => s.Id == seriesId)) + .Includes(includes) + .OrderByDescending(rp => rp.Implicit) // Get implicit profiles first .FirstOrDefaultAsync(); } @@ -56,14 +61,16 @@ public class AppUserReadingProfileRepository(DataContext context, IMapper mapper { return await context.AppUserReadingProfile .Where(rp => rp.UserId == userId && rp.Series.Any(s => s.Id == seriesId)) + .OrderByDescending(rp => rp.Implicit) // Get implicit profiles first .ProjectTo(mapper.ConfigurationProvider) .FirstOrDefaultAsync(); } - public async Task GetProfileForLibrary(int userId, int libraryId) + public async Task GetProfileForLibrary(int userId, int libraryId, ReadingProfileIncludes includes = ReadingProfileIncludes.None) { return await context.AppUserReadingProfile .Where(rp => rp.UserId == userId && rp.Libraries.Any(s => s.Id == libraryId)) + .Includes(includes) .FirstOrDefaultAsync(); } @@ -90,6 +97,15 @@ public class AppUserReadingProfileRepository(DataContext context, IMapper mapper .FirstOrDefaultAsync(); } + public async Task GetProfileByName(int userId, string name) + { + var normalizedName = name.ToNormalized(); + + return await context.AppUserReadingProfile + .Where(rp => rp.NormalizedName == normalizedName && rp.UserId == userId) + .FirstOrDefaultAsync(); + } + public void Add(AppUserReadingProfile readingProfile) { context.AppUserReadingProfile.Add(readingProfile); diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index e95c4f65e..10f285669 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -54,6 +54,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index 8bbbc8f4b..4de5ff81f 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -220,7 +220,8 @@ public static class IncludesExtensions { query = query .Include(u => u.UserPreferences) - .ThenInclude(p => p.Theme); + .ThenInclude(p => p.Theme) + .Include(u => u.UserPreferences.ReadingProfiles); } if (includeFlags.HasFlag(AppUserIncludes.WantToRead)) diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 2e9b89735..ae6865212 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -275,13 +275,7 @@ public class AutoMapperProfiles : Profile CreateMap() .ForMember(dest => dest.Theme, opt => - opt.MapFrom(src => src.Theme)) - .ForMember(dest => dest.BookReaderThemeName, - opt => - opt.MapFrom(src => src.BookThemeName)) - .ForMember(dest => dest.BookReaderLayoutMode, - opt => - opt.MapFrom(src => src.BookReaderLayoutMode)); + opt.MapFrom(src => src.Theme)); CreateMap() .ForMember(dest => dest.BookReaderThemeName, diff --git a/API/Services/ReadingProfileService.cs b/API/Services/ReadingProfileService.cs index 873bbf5b9..54440b4b4 100644 --- a/API/Services/ReadingProfileService.cs +++ b/API/Services/ReadingProfileService.cs @@ -24,13 +24,21 @@ public interface IReadingProfileService Task GetReadingProfileForSeries(int userId, int seriesId); /// - /// Updates, or adds a specific reading profile for a user + /// Updates a given reading profile for a user /// /// /// /// Task UpdateReadingProfile(int userId, UserReadingProfileDto dto); + /// + /// Creates a new reading profile for a user. Name must be unique per user + /// + /// + /// + /// + Task CreateReadingProfile(int userId, UserReadingProfileDto dto); + /// /// Updates the implicit reading profile for a series, creates one if none exists /// @@ -58,6 +66,14 @@ public interface IReadingProfileService /// The default profile for the user cannot be deleted Task DeleteReadingProfile(int userId, int profileId); + /// + /// Sets the given profile as global default + /// + /// + /// + /// + Task SetDefaultReadingProfile(int userId, int profileId); + } public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService localizationService): IReadingProfileService @@ -74,7 +90,7 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService var libraryProfile = await unitOfWork.AppUserReadingProfileRepository.GetProfileDtoForLibrary(userId, series.LibraryId); if (libraryProfile != null) return libraryProfile; - var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId); + var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); if (user == null) throw new UnauthorizedAccessException(); return await unitOfWork.AppUserReadingProfileRepository.GetProfileDto(user.UserPreferences.DefaultReadingProfileId); @@ -82,27 +98,47 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService public async Task UpdateReadingProfile(int userId, UserReadingProfileDto dto) { - var existingProfile = await unitOfWork.AppUserReadingProfileRepository.GetProfile(dto.Id); - if (existingProfile == null) - { - existingProfile = new AppUserReadingProfileBuilder(userId).Build(); - } + var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null) throw new UnauthorizedAccessException(); - if (existingProfile.UserId != userId) return false; + var existingProfile = await unitOfWork.AppUserReadingProfileRepository.GetProfile(dto.Id); + if (existingProfile == null) throw new KavitaException("profile-does-not-exist"); + + if (existingProfile.UserId != userId) throw new UnauthorizedAccessException(); UpdateReaderProfileFields(existingProfile, dto); unitOfWork.AppUserReadingProfileRepository.Update(existingProfile); return await unitOfWork.CommitAsync(); } + public async Task CreateReadingProfile(int userId, UserReadingProfileDto dto) + { + var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null) throw new UnauthorizedAccessException(); + + var other = await unitOfWork.AppUserReadingProfileRepository.GetProfileByName(userId, dto.Name); + if (other != null) throw new KavitaException("name-already-in-use"); + + var newProfile = new AppUserReadingProfileBuilder(user.Id).Build(); + UpdateReaderProfileFields(newProfile, dto); + unitOfWork.AppUserReadingProfileRepository.Add(newProfile); + + await unitOfWork.CommitAsync(); + + return await unitOfWork.AppUserReadingProfileRepository.GetProfileDto(newProfile.Id); + } + public async Task UpdateImplicitReadingProfile(int userId, int seriesId, UserReadingProfileDto dto) { + var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null) throw new UnauthorizedAccessException(); + var existingProfile = await unitOfWork.AppUserReadingProfileRepository.GetProfileForSeries(userId, seriesId); // Series already had an implicit profile, update it if (existingProfile is {Implicit: true}) { - UpdateReaderProfileFields(existingProfile, dto); + UpdateReaderProfileFields(existingProfile, dto, false); unitOfWork.AppUserReadingProfileRepository.Update(existingProfile); return await unitOfWork.CommitAsync(); } @@ -113,14 +149,18 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService .WithImplicit(true) .Build(); - unitOfWork.AppUserReadingProfileRepository.Add(existingProfile); + UpdateReaderProfileFields(existingProfile, dto, false); + existingProfile.Name = $"Implicit Profile for {seriesId}"; + existingProfile.NormalizedName = existingProfile.Name.ToNormalized(); + + user.UserPreferences.ReadingProfiles.Add(existingProfile); return await unitOfWork.CommitAsync(); } public async Task DeleteImplicitForSeries(int userId, int seriesId) { - var profile = await unitOfWork.AppUserReadingProfileRepository.GetProfileForSeries(userId, seriesId); - if (profile == null) throw new KavitaException(await localizationService.Translate(userId, "profile-doesnt-exist")); + var profile = await unitOfWork.AppUserReadingProfileRepository.GetProfileForSeries(userId, seriesId, ReadingProfileIncludes.Series); + if (profile == null) return; if (!profile.Implicit) return; @@ -143,9 +183,23 @@ public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService await unitOfWork.CommitAsync(); } - private static void UpdateReaderProfileFields(AppUserReadingProfile existingProfile, UserReadingProfileDto dto) + public async Task SetDefaultReadingProfile(int userId, int profileId) { - if (!string.IsNullOrEmpty(dto.Name) && existingProfile.NormalizedName != dto.Name.ToNormalized()) + var profile = await unitOfWork.AppUserReadingProfileRepository.GetProfile(profileId); + if (profile == null) throw new KavitaException("profile-not-found"); + + if (profile.UserId != userId) throw new UnauthorizedAccessException(); + + var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null) throw new UnauthorizedAccessException(); + + user.UserPreferences.DefaultReadingProfileId = profile.Id; + await unitOfWork.CommitAsync(); + } + + private static void UpdateReaderProfileFields(AppUserReadingProfile existingProfile, UserReadingProfileDto dto, bool updateName = true) + { + if (updateName && !string.IsNullOrEmpty(dto.Name) && existingProfile.NormalizedName != dto.Name.ToNormalized()) { existingProfile.Name = dto.Name; existingProfile.NormalizedName = dto.Name.ToNormalized(); diff --git a/API/Startup.cs b/API/Startup.cs index cb32d1742..f57cb7d01 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -293,6 +293,9 @@ public class Startup await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger); await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger); + // v0.8.7 + await ManualMigrateReadingProfiles.Migrate(dataContext, logger); + #endregion // Update the version in the DB after all migrations are run diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 57466cc80..ed2095649 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -2,6 +2,8 @@ import {PageLayoutMode} from '../page-layout-mode'; import {SiteTheme} from './site-theme'; export interface Preferences { + defaultReadingProfileId: number; + // Global theme: SiteTheme; globalPageLayoutMode: PageLayoutMode; diff --git a/UI/Web/src/app/_services/reading-profile.service.ts b/UI/Web/src/app/_services/reading-profile.service.ts index 9013d05e7..d88a92f5d 100644 --- a/UI/Web/src/app/_services/reading-profile.service.ts +++ b/UI/Web/src/app/_services/reading-profile.service.ts @@ -23,8 +23,24 @@ export class ReadingProfileService { return this.httpClient.post(this.baseUrl + "ReadingProfile", profile); } + createProfile(profile: ReadingProfile) { + return this.httpClient.post(this.baseUrl + "ReadingProfile/create", profile); + } + updateImplicit(profile: ReadingProfile, seriesId: number) { return this.httpClient.post(this.baseUrl + "ReadingProfile/series?seriesId="+seriesId, profile); } + all() { + return this.httpClient.get(this.baseUrl + "ReadingProfile/all"); + } + + delete(id: number) { + return this.httpClient.delete(this.baseUrl + "ReadingProfile?profileId="+id); + } + + setDefault(id: number) { + return this.httpClient.post(this.baseUrl + "ReadingProfile/set-default?profileId=" + id, {}); + } + } diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.html b/UI/Web/src/app/settings/_components/settings/settings.component.html index 168c98a85..3e3d4f144 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.html +++ b/UI/Web/src/app/settings/_components/settings/settings.component.html @@ -217,6 +217,15 @@ } } + + @defer (when fragment === SettingsTabId.ReadingProfiles; prefetch on idle) { + @if (fragment === SettingsTabId.ReadingProfiles) { +
+ +
+ } + } + } diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.ts b/UI/Web/src/app/settings/_components/settings/settings.component.ts index 84b1bfe0a..d470972d0 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.ts +++ b/UI/Web/src/app/settings/_components/settings/settings.component.ts @@ -52,43 +52,47 @@ import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobb import { ManageMetadataSettingsComponent } from "../../../admin/manage-metadata-settings/manage-metadata-settings.component"; +import { + ManageReadingProfilesComponent +} from "../../../user-settings/manage-reading-profiles/manage-reading-profiles.component"; @Component({ selector: 'app-settings', - imports: [ - ChangeAgeRestrictionComponent, - ChangeEmailComponent, - ChangePasswordComponent, - ManageDevicesComponent, - ManageOpdsComponent, - ManageScrobblingProvidersComponent, - ManageUserPreferencesComponent, - SideNavCompanionBarComponent, - ThemeManagerComponent, - TranslocoDirective, - UserStatsComponent, - AsyncPipe, - LicenseComponent, - ManageEmailSettingsComponent, - ManageLibraryComponent, - ManageMediaSettingsComponent, - ManageSettingsComponent, - ManageSystemComponent, - ManageTasksSettingsComponent, - ManageUsersComponent, - ServerStatsComponent, - SettingFragmentPipe, - ManageScrobblingComponent, - ManageMediaIssuesComponent, - ManageCustomizationComponent, - ImportMalCollectionComponent, - ImportCblComponent, - ManageMatchedMetadataComponent, - ManageUserTokensComponent, - EmailHistoryComponent, - ScrobblingHoldsComponent, - ManageMetadataSettingsComponent - ], + imports: [ + ChangeAgeRestrictionComponent, + ChangeEmailComponent, + ChangePasswordComponent, + ManageDevicesComponent, + ManageOpdsComponent, + ManageScrobblingProvidersComponent, + ManageUserPreferencesComponent, + SideNavCompanionBarComponent, + ThemeManagerComponent, + TranslocoDirective, + UserStatsComponent, + AsyncPipe, + LicenseComponent, + ManageEmailSettingsComponent, + ManageLibraryComponent, + ManageMediaSettingsComponent, + ManageSettingsComponent, + ManageSystemComponent, + ManageTasksSettingsComponent, + ManageUsersComponent, + ServerStatsComponent, + SettingFragmentPipe, + ManageScrobblingComponent, + ManageMediaIssuesComponent, + ManageCustomizationComponent, + ImportMalCollectionComponent, + ImportCblComponent, + ManageMatchedMetadataComponent, + ManageUserTokensComponent, + EmailHistoryComponent, + ScrobblingHoldsComponent, + ManageMetadataSettingsComponent, + ManageReadingProfilesComponent + ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss', changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts index 6574fe945..98ba48968 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts @@ -41,6 +41,7 @@ export enum SettingsTabId { // Non-Admin Account = 'account', Preferences = 'preferences', + ReadingProfiles = 'reading-profiles', Clients = 'clients', Theme = 'theme', Devices = 'devices', @@ -111,6 +112,7 @@ export class PreferenceNavComponent implements AfterViewInit { children: [ new SideNavItem(SettingsTabId.Account, []), new SideNavItem(SettingsTabId.Preferences), + new SideNavItem(SettingsTabId.ReadingProfiles), new SideNavItem(SettingsTabId.Customize, [], undefined, [Role.ReadOnly]), new SideNavItem(SettingsTabId.Clients), new SideNavItem(SettingsTabId.Theme), diff --git a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.html b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.html new file mode 100644 index 000000000..8e04676cb --- /dev/null +++ b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.html @@ -0,0 +1,499 @@ + + + + + @if (!loading) { +
+ +
+ +

{{t('description')}}

+

{{t('extra-tip')}}

+ + +
+ +
+
+ + @if (readingProfiles.length < virtualScrollerBreakPoint) { + @for (readingProfile of readingProfiles; track readingProfile.id) { + + } + } @else { + + @for (readingProfile of scroll.viewPortItems; track readingProfile.id) { + + } + + } +
+
+ +
+
+ @if (selectedProfile === null) { +

{{t('no-selected')}}

+

{{t('selection-tip')}}

+ } + + @if (readingProfileForm !== null && selectedProfile !== null) { + +
+ +
+ + + {{readingProfileForm.get('name')!.value}} + + + + + + + @if (this.selectedProfile?.id !== 0) { +
+ + +
+ } + +
+ + +
+ +
+ } +
+
+ + + +
+ + + + +
+
{{profile.name | sentenceCase}}
+ + @if (profile.id === user.preferences.defaultReadingProfileId) { + {{t('default-profile')}} + } + +
+
+ } + +
diff --git a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.scss b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.scss new file mode 100644 index 000000000..13f341a32 --- /dev/null +++ b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.scss @@ -0,0 +1,38 @@ +@use '../../../series-detail-common'; + + + +.group-item { + background-color: transparent; + + &:hover { + background-color: var(--card-bg-color); + border-radius: 5px; + cursor: pointer; + } + + &:active, &.active { + background-color: var(--card-bg-color); + border-radius: 5px; + } +} + +.pill { + font-size: .8rem; + background-color: var(--card-bg-color); + border-radius: 0.375rem; + color: var(--badge-text-color); + + &.active { + background-color : var(--primary-color); + } +} + +.custom-position { + right: 15px; + top: -42px; +} + +a:hover { + cursor: pointer; +} diff --git a/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts new file mode 100644 index 000000000..d0a64b0a1 --- /dev/null +++ b/UI/Web/src/app/user-settings/manage-reading-profiles/manage-reading-profiles.component.ts @@ -0,0 +1,277 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnInit} from '@angular/core'; +import {ReadingProfileService} from "../../_services/reading-profile.service"; +import { + bookLayoutModes, bookWritingStyles, layoutModes, + pageSplitOptions, pdfScrollModes, + pdfSpreadModes, pdfThemes, + readingDirections, readingModes, + ReadingProfile, scalingOptions +} from "../../_models/preferences/reading-profiles"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {Location, NgStyle, NgTemplateOutlet, TitleCasePipe} from "@angular/common"; +import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; +import {User} from "../../_models/user"; +import {AccountService} from "../../_services/account.service"; +import {debounceTime, distinctUntilChanged, take, tap} from "rxjs/operators"; +import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe"; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms"; +import {BookService} from "../../book-reader/_services/book.service"; +import {BookPageLayoutMode} from "../../_models/readers/book-page-layout-mode"; +import {PdfTheme} from "../../_models/preferences/pdf-theme"; +import {PdfScrollMode} from "../../_models/preferences/pdf-scroll-mode"; +import {PdfSpreadMode} from "../../_models/preferences/pdf-spread-mode"; +import {bookColorThemes} from "../../book-reader/_components/reader-settings/reader-settings.component"; +import {BookPageLayoutModePipe} from "../../_pipes/book-page-layout-mode.pipe"; +import {LayoutModePipe} from "../../_pipes/layout-mode.pipe"; +import {PageSplitOptionPipe} from "../../_pipes/page-split-option.pipe"; +import {PdfScrollModePipe} from "../../_pipes/pdf-scroll-mode.pipe"; +import {PdfSpreadModePipe} from "../../_pipes/pdf-spread-mode.pipe"; +import {PdfThemePipe} from "../../_pipes/pdf-theme.pipe"; +import {ReaderModePipe} from "../../_pipes/reading-mode.pipe"; +import {ReadingDirectionPipe} from "../../_pipes/reading-direction.pipe"; +import {ScalingOptionPipe} from "../../_pipes/scaling-option.pipe"; +import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; +import {WritingStylePipe} from "../../_pipes/writing-style.pipe"; +import {ColorPickerDirective} from "ngx-color-picker"; +import { + NgbNav, + NgbNavItem, + NgbNavLinkBase, + NgbNavContent, + NgbNavOutlet +} from "@ng-bootstrap/ng-bootstrap"; +import {filter} from "rxjs"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {LoadingComponent} from "../../shared/loading/loading.component"; + +enum TabId { + ImageReader = "image-reader", + BookReader = "book-reader", + PdfReader = "pdf-reader", + Series = "series", + Libraries = "libraries", +} + +@Component({ + selector: 'app-manage-reading-profiles', + imports: [ + TranslocoDirective, + NgTemplateOutlet, + VirtualScrollerModule, + SentenceCasePipe, + BookPageLayoutModePipe, + FormsModule, + LayoutModePipe, + PageSplitOptionPipe, + PdfScrollModePipe, + PdfSpreadModePipe, + PdfThemePipe, + ReactiveFormsModule, + ReaderModePipe, + ReadingDirectionPipe, + ScalingOptionPipe, + SettingItemComponent, + SettingSwitchComponent, + TitleCasePipe, + WritingStylePipe, + NgStyle, + ColorPickerDirective, + NgbNav, + NgbNavItem, + NgbNavLinkBase, + NgbNavContent, + NgbNavOutlet, + LoadingComponent + ], + templateUrl: './manage-reading-profiles.component.html', + styleUrl: './manage-reading-profiles.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ManageReadingProfilesComponent implements OnInit { + + virtualScrollerBreakPoint = 20; + + fontFamilies: Array = []; + readingProfiles: ReadingProfile[] = []; + user!: User; + activeTabId = TabId.ImageReader; + loading = true; + + selectedProfile: ReadingProfile | null = null; + readingProfileForm: FormGroup | null = null; + bookColorThemesTranslated = bookColorThemes.map(o => { + const d = {...o}; + d.name = translate('theme.' + d.translationKey); + return d; + }); + + constructor( + private readingProfileService: ReadingProfileService, + private cdRef: ChangeDetectorRef, + private accountService: AccountService, + private bookService: BookService, + private destroyRef: DestroyRef, + ) { + this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title); + this.cdRef.markForCheck(); + } + + ngOnInit(): void { + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (user) { + this.user = user; + console.log(this.user.preferences.defaultReadingProfileId); + } + }); + + this.readingProfileService.all().subscribe(profiles => { + this.readingProfiles = profiles; + this.loading = false; + this.setupForm(); + this.cdRef.markForCheck(); + }); + + } + + delete(id: number) { + this.readingProfileService.delete(id).subscribe(() => { + this.selectProfile(undefined); + this.readingProfiles = this.readingProfiles.filter(o => o.id !== id); + this.cdRef.markForCheck(); + }); + } + + setDefault(id: number) { + this.readingProfileService.setDefault(id).subscribe(() => { + this.user.preferences.defaultReadingProfileId = id; + this.cdRef.markForCheck(); + }) + } + + setupForm() { + if (this.selectedProfile == null) { + return; + } + + + this.readingProfileForm = new FormGroup({}) + + if (this.fontFamilies.indexOf(this.selectedProfile.bookReaderFontFamily) < 0) { + this.selectedProfile.bookReaderFontFamily = 'default'; + } + + this.readingProfileForm.addControl('name', new FormControl(this.selectedProfile.name, Validators.required)); + this.readingProfileForm.addControl('readingDirection', new FormControl(this.selectedProfile.readingDirection, [])); + this.readingProfileForm.addControl('scalingOption', new FormControl(this.selectedProfile.scalingOption, [])); + this.readingProfileForm.addControl('pageSplitOption', new FormControl(this.selectedProfile.pageSplitOption, [])); + this.readingProfileForm.addControl('autoCloseMenu', new FormControl(this.selectedProfile.autoCloseMenu, [])); + this.readingProfileForm.addControl('showScreenHints', new FormControl(this.selectedProfile.showScreenHints, [])); + this.readingProfileForm.addControl('readerMode', new FormControl(this.selectedProfile.readerMode, [])); + this.readingProfileForm.addControl('layoutMode', new FormControl(this.selectedProfile.layoutMode, [])); + this.readingProfileForm.addControl('emulateBook', new FormControl(this.selectedProfile.emulateBook, [])); + this.readingProfileForm.addControl('swipeToPaginate', new FormControl(this.selectedProfile.swipeToPaginate, [])); + this.readingProfileForm.addControl('backgroundColor', new FormControl(this.selectedProfile.backgroundColor, [])); + this.readingProfileForm.addControl('allowAutomaticWebtoonReaderDetection', new FormControl(this.selectedProfile.allowAutomaticWebtoonReaderDetection, [])); + + this.readingProfileForm.addControl('bookReaderFontFamily', new FormControl(this.selectedProfile.bookReaderFontFamily, [])); + this.readingProfileForm.addControl('bookReaderFontSize', new FormControl(this.selectedProfile.bookReaderFontSize, [])); + this.readingProfileForm.addControl('bookReaderLineSpacing', new FormControl(this.selectedProfile.bookReaderLineSpacing, [])); + this.readingProfileForm.addControl('bookReaderMargin', new FormControl(this.selectedProfile.bookReaderMargin, [])); + this.readingProfileForm.addControl('bookReaderReadingDirection', new FormControl(this.selectedProfile.bookReaderReadingDirection, [])); + this.readingProfileForm.addControl('bookReaderWritingStyle', new FormControl(this.selectedProfile.bookReaderWritingStyle, [])) + this.readingProfileForm.addControl('bookReaderTapToPaginate', new FormControl(this.selectedProfile.bookReaderTapToPaginate, [])); + this.readingProfileForm.addControl('bookReaderLayoutMode', new FormControl(this.selectedProfile.bookReaderLayoutMode || BookPageLayoutMode.Default, [])); + this.readingProfileForm.addControl('bookReaderThemeName', new FormControl(this.selectedProfile.bookReaderThemeName || bookColorThemes[0].name, [])); + this.readingProfileForm.addControl('bookReaderImmersiveMode', new FormControl(this.selectedProfile.bookReaderImmersiveMode, [])); + + this.readingProfileForm.addControl('pdfTheme', new FormControl(this.selectedProfile.pdfTheme || PdfTheme.Dark, [])); + this.readingProfileForm.addControl('pdfScrollMode', new FormControl(this.selectedProfile.pdfScrollMode || PdfScrollMode.Vertical, [])); + this.readingProfileForm.addControl('pdfSpreadMode', new FormControl(this.selectedProfile.pdfSpreadMode || PdfSpreadMode.None, [])); + + this.readingProfileForm.valueChanges.pipe( + debounceTime(500), + distinctUntilChanged(), + filter(_ => this.readingProfileForm!.valid), + takeUntilDestroyed(this.destroyRef), + tap(_ => { + if (this.selectedProfile!.id == 0) { + this.readingProfileService.createProfile(this.packData()).subscribe({ + next: createdProfile => { + this.selectedProfile = createdProfile; + this.readingProfiles.push(createdProfile); + this.cdRef.markForCheck(); + }, + error: err => { + console.log(err); + } + }) + } else { + const profile = this.packData(); + this.readingProfileService.updateProfile(profile).subscribe({ + next: _ => { + this.readingProfiles = this.readingProfiles.map(p => { + if (p.id !== profile.id) return p; + return profile; + }); + this.cdRef.markForCheck(); + }, + error: err => { + console.log(err); + } + }) + } + }), + ).subscribe(); + } + + private packData(): ReadingProfile { + const data: ReadingProfile = this.readingProfileForm!.getRawValue(); + data.id = this.selectedProfile!.id; + return data; + } + + handleBackgroundColorChange(color: string) { + if (!this.readingProfileForm || !this.selectedProfile) return; + + this.readingProfileForm.markAsDirty(); + this.readingProfileForm.markAsTouched(); + this.selectedProfile.backgroundColor = color; + this.readingProfileForm.get('backgroundColor')?.setValue(color); + this.cdRef.markForCheck(); + } + + selectProfile(profile: ReadingProfile | undefined | null) { + if (profile === undefined) { + this.selectedProfile = null; + this.cdRef.markForCheck(); + return; + } + + this.selectedProfile = profile; + this.setupForm(); + this.cdRef.markForCheck(); + } + + addNew() { + const defaultProfile = this.readingProfiles.find(f => f.id === this.user.preferences.defaultReadingProfileId); + this.selectedProfile = {...defaultProfile!}; + this.selectedProfile.id = 0; + this.selectedProfile.name = "New Profile #" + (this.readingProfiles.length + 1); + this.setupForm(); + this.cdRef.markForCheck(); + } + + protected readonly readingDirections = readingDirections; + protected readonly pdfSpreadModes = pdfSpreadModes; + protected readonly pageSplitOptions = pageSplitOptions; + protected readonly bookLayoutModes = bookLayoutModes; + protected readonly pdfThemes = pdfThemes; + protected readonly scalingOptions = scalingOptions; + protected readonly layoutModes = layoutModes; + protected readonly readerModes = readingModes; + protected readonly bookWritingStyles = bookWritingStyles; + protected readonly pdfScrollModes = pdfScrollModes; + + protected readonly TabId = TabId; +} diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html index 8dd181536..19cd54cd1 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.html @@ -117,383 +117,6 @@
} - - -

{{t('image-reader-settings-title')}}

- -
- - - {{settingsForm.get('readingDirection')!.value | readingDirection}} - - - - - -
- -
- - - {{settingsForm.get('scalingOption')!.value | scalingOption}} - - - - - -
- -
- - - {{settingsForm.get('pageSplitOption')!.value | pageSplitOption}} - - - - - -
- -
- - - {{settingsForm.get('readerMode')!.value | readerMode}} - - - - - -
- -
- - - {{settingsForm.get('layoutMode')!.value | layoutMode}} - - - - - -
- - - -
- - -
- -
-
-
-
- -
- - -
- -
-
-
-
- -
- - -
- -
-
-
-
- -
- - -
- -
-
-
-
- -
- - -
- -
-
-
-
-
- -
- -

{{t('book-reader-settings-title')}}

- -
- - -
- -
-
-
-
- -
- - -
- -
-
-
-
- -
- - - {{settingsForm.get('bookReaderReadingDirection')!.value | readingDirection}} - - - - - -
- -
- - - {{settingsForm.get('bookReaderFontFamily')!.value | titlecase}} - - - - - -
- -
- - - {{settingsForm.get('bookReaderWritingStyle')!.value | writingStyle}} - - - - - -
- -
- - - {{settingsForm.get('bookReaderLayoutMode')!.value | bookPageLayoutMode}} - - - - - -
- -
- - - {{settingsForm.get('bookReaderThemeName')!.value}} - - - - - -
- -
- - - {{settingsForm.get('bookReaderFontSize')?.value + '%'}} - - -
-
- - -
- {{settingsForm.get('bookReaderFontSize')?.value + '%'}} -
-
-
-
- -
- - - {{settingsForm.get('bookReaderLineSpacing')?.value + '%'}} - - -
-
- -
- {{settingsForm.get('bookReaderLineSpacing')?.value + '%'}} -
-
-
-
- -
- - - {{settingsForm.get('bookReaderMargin')?.value + '%'}} - - -
-
- -
- {{settingsForm.get('bookReaderMargin')?.value + '%'}} -
-
-
-
-
- -
- -

{{t('pdf-reader-settings-title')}}

- -
- - - {{settingsForm.get('pdfSpreadMode')!.value | pdfSpreadMode}} - - - - - -
- -
- - - {{settingsForm.get('pdfTheme')!.value | pdfTheme}} - - - - - -
- -
- - - {{settingsForm.get('pdfScrollMode')!.value | pdfScrollMode}} - - - - - -
-
} diff --git a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts index bae90e7fe..c95d776cb 100644 --- a/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/manga-user-preferences/manage-user-preferences.component.ts @@ -80,23 +80,6 @@ export class ManageUserPreferencesComponent implements OnInit { private readonly localizationService = inject(LocalizationService); protected readonly licenseService = inject(LicenseService); - protected readonly readingDirections = readingDirections; - protected readonly scalingOptions = scalingOptions; - protected readonly pageSplitOptions = pageSplitOptions; - protected readonly readerModes = readingModes; - protected readonly layoutModes = layoutModes; - protected readonly bookWritingStyles = bookWritingStyles; - protected readonly bookLayoutModes = bookLayoutModes; - protected readonly pdfSpreadModes = pdfSpreadModes; - protected readonly pdfThemes = pdfThemes; - protected readonly pdfScrollModes = pdfScrollModes; - - bookColorThemesTranslated = bookColorThemes.map(o => { - const d = {...o}; - d.name = translate('theme.' + d.translationKey); - return d; - }); - fontFamilies: Array = []; locales: Array = []; @@ -137,37 +120,6 @@ export class ManageUserPreferencesComponent implements OnInit { this.user = results.user; this.user.preferences = results.pref; - /*if (this.fontFamilies.indexOf(this.user.preferences.bookReaderFontFamily) < 0) { - this.user.preferences.bookReaderFontFamily = 'default'; - } - - this.settingsForm.addControl('readingDirection', new FormControl(this.user.preferences.readingDirection, [])); - this.settingsForm.addControl('scalingOption', new FormControl(this.user.preferences.scalingOption, [])); - this.settingsForm.addControl('pageSplitOption', new FormControl(this.user.preferences.pageSplitOption, [])); - this.settingsForm.addControl('autoCloseMenu', new FormControl(this.user.preferences.autoCloseMenu, [])); - this.settingsForm.addControl('showScreenHints', new FormControl(this.user.preferences.showScreenHints, [])); - this.settingsForm.addControl('readerMode', new FormControl(this.user.preferences.readerMode, [])); - this.settingsForm.addControl('layoutMode', new FormControl(this.user.preferences.layoutMode, [])); - this.settingsForm.addControl('emulateBook', new FormControl(this.user.preferences.emulateBook, [])); - this.settingsForm.addControl('swipeToPaginate', new FormControl(this.user.preferences.swipeToPaginate, [])); - this.settingsForm.addControl('backgroundColor', new FormControl(this.user.preferences.backgroundColor, [])); - this.settingsForm.addControl('allowAutomaticWebtoonReaderDetection', new FormControl(this.user.preferences.allowAutomaticWebtoonReaderDetection, [])); - - this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, [])); - this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, [])); - this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(this.user.preferences.bookReaderLineSpacing, [])); - this.settingsForm.addControl('bookReaderMargin', new FormControl(this.user.preferences.bookReaderMargin, [])); - this.settingsForm.addControl('bookReaderReadingDirection', new FormControl(this.user.preferences.bookReaderReadingDirection, [])); - this.settingsForm.addControl('bookReaderWritingStyle', new FormControl(this.user.preferences.bookReaderWritingStyle, [])) - this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this.user.preferences.bookReaderTapToPaginate, [])); - this.settingsForm.addControl('bookReaderLayoutMode', new FormControl(this.user.preferences.bookReaderLayoutMode || BookPageLayoutMode.Default, [])); - this.settingsForm.addControl('bookReaderThemeName', new FormControl(this.user?.preferences.bookReaderThemeName || bookColorThemes[0].name, [])); - this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this.user?.preferences.bookReaderImmersiveMode, [])); - - this.settingsForm.addControl('pdfTheme', new FormControl(this.user?.preferences.pdfTheme || PdfTheme.Dark, [])); - this.settingsForm.addControl('pdfScrollMode', new FormControl(this.user?.preferences.pdfScrollMode || PdfScrollMode.Vertical, [])); - this.settingsForm.addControl('pdfSpreadMode', new FormControl(this.user?.preferences.pdfSpreadMode || PdfSpreadMode.None, []));*/ - this.settingsForm.addControl('theme', new FormControl(this.user.preferences.theme, [])); this.settingsForm.addControl('globalPageLayoutMode', new FormControl(this.user.preferences.globalPageLayoutMode, [])); this.settingsForm.addControl('blurUnreadSummaries', new FormControl(this.user.preferences.blurUnreadSummaries, [])); @@ -290,18 +242,8 @@ export class ManageUserPreferencesComponent implements OnInit { //pdfScrollMode: parseInt(modelSettings.pdfScrollMode, 10), //pdfSpreadMode: parseInt(modelSettings.pdfSpreadMode, 10), aniListScrobblingEnabled: modelSettings.aniListScrobblingEnabled, - wantToReadSync: modelSettings.wantToReadSync + wantToReadSync: modelSettings.wantToReadSync, + defaultReadingProfileId: this.user!.preferences.defaultReadingProfileId, }; } - - handleBackgroundColorChange(color: string) { - this.settingsForm.markAsDirty(); - this.settingsForm.markAsTouched(); - if (this.user?.preferences) { - //this.user.preferences.backgroundColor = color; - } - - this.settingsForm.get('backgroundColor')?.setValue(color); - this.cdRef.markForCheck(); - } } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index a943c0a27..560af383e 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -140,60 +140,6 @@ "want-to-read-sync-label": "Want To Read Sync", "want-to-read-sync-tooltip": "Allow Kavita to add items to your Want to Read list based on AniList and MAL series in Pending readlist", - "image-reader-settings-title": "Image Reader", - "reading-direction-label": "Reading Direction", - "reading-direction-tooltip": "Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.", - "scaling-option-label": "Scaling Options", - "scaling-option-tooltip": "How to scale the image to your screen.", - "page-splitting-label": "Page Splitting", - "page-splitting-tooltip": "How to split a full width image (ie both left and right images are combined)", - "reading-mode-label": "Reading Mode", - "reading-mode-tooltip": "Change reader to paginate vertically, horizontally, or have an infinite scroll", - "layout-mode-label": "Layout Mode", - "layout-mode-tooltip": "Render a single image to the screen or two side-by-side images", - "background-color-label": "Background Color", - "background-color-tooltip": "Background Color of Image Reader", - "auto-close-menu-label": "Auto Close Menu", - "auto-close-menu-tooltip": "Should menu auto close", - "show-screen-hints-label": "Show Screen Hints", - "show-screen-hints-tooltip": "Show an overlay to help understand pagination area and direction", - "emulate-comic-book-label": "Emulate comic book", - "emulate-comic-book-tooltip": "Applies a shadow effect to emulate reading from a book", - "swipe-to-paginate-label": "Swipe to Paginate", - "swipe-to-paginate-tooltip": "Should swiping on the screen cause the next or previous page to be triggered", - "allow-auto-webtoon-reader-label": "Automatic Webtoon Reader Mode", - "allow-auto-webtoon-reader-tooltip": "Switch into Webtoon Reader mode if pages look like a webtoon. Some false positives may occur.", - - "book-reader-settings-title": "Book Reader", - "tap-to-paginate-label": "Tap to Paginate", - "tap-to-paginate-tooltip": "Should the sides of the book reader screen allow tapping on it to move to prev/next page", - "immersive-mode-label": "Immersive Mode", - "immersive-mode-tooltip": "This will hide the menu behind a click on the reader document and turn tap to paginate on", - "reading-direction-book-label": "Reading Direction", - "reading-direction-book-tooltip": "Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.", - "font-family-label": "Font Family", - "font-family-tooltip": "Font family to load up. Default will load the book's default font", - "writing-style-label": "Writing Style", - "writing-style-tooltip": "Changes the direction of the text. Horizontal is left to right, vertical is top to bottom.", - "layout-mode-book-label": "Layout Mode", - "layout-mode-book-tooltip": "How content should be laid out. Scroll is as the book packs it. 1 or 2 Column fits to the height of the device and fits 1 or 2 columns of text per page", - "color-theme-book-label": "Color Theme", - "color-theme-book-tooltip": "What color theme to apply to the book reader content and menu", - "font-size-book-label": "Font Size", - "font-size-book-tooltip": "Percent of scaling to apply to font in the book", - "line-height-book-label": "Line Spacing", - "line-height-book-tooltip": "How much spacing between the lines of the book", - "margin-book-label": "Margin", - "margin-book-tooltip": "How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.", - - "pdf-reader-settings-title": "PDF Reader", - "pdf-scroll-mode-label": "Scroll Mode", - "pdf-scroll-mode-tooltip": "How you scroll through pages. Vertical/Horizontal and Tap to Paginate (no scroll)", - "pdf-spread-mode-label": "Spread Mode", - "pdf-spread-mode-tooltip": "How pages should be laid out. Single or double (odd/even)", - "pdf-theme-label": "Theme", - "pdf-theme-tooltip": "Color theme of the reader", - "clients-opds-alert": "OPDS is not enabled on this server. This will not affect Tachiyomi users.", "clients-opds-description": "All 3rd Party clients will either use the API key or the Connection Url below. These are like passwords, keep it private.", "clients-api-key-tooltip": "The API key is like a password. Resetting it will invalidate any existing clients.", @@ -1716,6 +1662,7 @@ "scrobble-holds": "Scrobble Holds", "account": "Account", "preferences": "Preferences", + "reading-profiles": "Reading Profiles", "clients": "API Key / OPDS", "devices": "Devices", "user-stats": "Stats", @@ -2838,6 +2785,75 @@ "pdf-dark": "Dark" }, + "manage-reading-profiles": { + "description": "Not all your series may be read in the same way, set up distinct reading profiles per library or series to make getting back in your series as seamless as possible.", + "extra-tip": "When changing reading settings for a specific series, an implicit profile is created until you save it to one you created. To keep your list from cluttering with every little change you might need", + "profiles-title": "Your reading profiles", + "default-profile": "Default", + "add": "{{common.add}}", + "make-default": "Set as default", + "no-selected": "No profile selected", + "selection-tip": "Select a profile from the list, or create a new one at the top right", + + "image-reader-settings-title": "Image Reader", + "reading-direction-label": "Reading Direction", + "reading-direction-tooltip": "Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.", + "scaling-option-label": "Scaling Options", + "scaling-option-tooltip": "How to scale the image to your screen.", + "page-splitting-label": "Page Splitting", + "page-splitting-tooltip": "How to split a full width image (ie both left and right images are combined)", + "reading-mode-label": "Reading Mode", + "reading-mode-tooltip": "Change reader to paginate vertically, horizontally, or have an infinite scroll", + "layout-mode-label": "Layout Mode", + "layout-mode-tooltip": "Render a single image to the screen or two side-by-side images", + "background-color-label": "Background Color", + "background-color-tooltip": "Background Color of Image Reader", + "auto-close-menu-label": "Auto Close Menu", + "auto-close-menu-tooltip": "Should menu auto close", + "show-screen-hints-label": "Show Screen Hints", + "show-screen-hints-tooltip": "Show an overlay to help understand pagination area and direction", + "emulate-comic-book-label": "Emulate comic book", + "emulate-comic-book-tooltip": "Applies a shadow effect to emulate reading from a book", + "swipe-to-paginate-label": "Swipe to Paginate", + "swipe-to-paginate-tooltip": "Should swiping on the screen cause the next or previous page to be triggered", + "allow-auto-webtoon-reader-label": "Automatic Webtoon Reader Mode", + "allow-auto-webtoon-reader-tooltip": "Switch into Webtoon Reader mode if pages look like a webtoon. Some false positives may occur.", + + "book-reader-settings-title": "Book Reader", + "tap-to-paginate-label": "Tap to Paginate", + "tap-to-paginate-tooltip": "Should the sides of the book reader screen allow tapping on it to move to prev/next page", + "immersive-mode-label": "Immersive Mode", + "immersive-mode-tooltip": "This will hide the menu behind a click on the reader document and turn tap to paginate on", + "reading-direction-book-label": "Reading Direction", + "reading-direction-book-tooltip": "Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.", + "font-family-label": "Font Family", + "font-family-tooltip": "Font family to load up. Default will load the book's default font", + "writing-style-label": "Writing Style", + "writing-style-tooltip": "Changes the direction of the text. Horizontal is left to right, vertical is top to bottom.", + "layout-mode-book-label": "Layout Mode", + "layout-mode-book-tooltip": "How content should be laid out. Scroll is as the book packs it. 1 or 2 Column fits to the height of the device and fits 1 or 2 columns of text per page", + "color-theme-book-label": "Color Theme", + "color-theme-book-tooltip": "What color theme to apply to the book reader content and menu", + "font-size-book-label": "Font Size", + "font-size-book-tooltip": "Percent of scaling to apply to font in the book", + "line-height-book-label": "Line Spacing", + "line-height-book-tooltip": "How much spacing between the lines of the book", + "margin-book-label": "Margin", + "margin-book-tooltip": "How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.", + + "pdf-reader-settings-title": "PDF Reader", + "pdf-scroll-mode-label": "Scroll Mode", + "pdf-scroll-mode-tooltip": "How you scroll through pages. Vertical/Horizontal and Tap to Paginate (no scroll)", + "pdf-spread-mode-label": "Spread Mode", + "pdf-spread-mode-tooltip": "How pages should be laid out. Single or double (odd/even)", + "pdf-theme-label": "Theme", + "pdf-theme-tooltip": "Color theme of the reader", + + "reading-profile-series-settings-title": "Series", + + "reading-profile-library-settings-title": "Library" + }, + "validation": { "required-field": "This field is required",